From faffe2246a9ade468b0bcc6a885fc6c4a10adfc6 Mon Sep 17 00:00:00 2001 From: kafkade <179981006+kafkade@users.noreply.github.com> Date: Mon, 27 Jul 2026 16:25:25 -0700 Subject: [PATCH] extend at-rest encryption --- CHANGELOG.md | 1 + Cargo.lock | 1 + Cargo.toml | 13 +- apps/ios/ldgr/Sources/Views/UnlockView.swift | 28 ++- apps/web/package.json | 2 +- apps/web/test/at-rest.mjs | 155 ++++++++++++ .../swift/Sources/LdgrSwift/LdgrClient.swift | 64 +++++ .../Tests/LdgrSwiftTests/LdgrSwiftTests.swift | 34 +++ bindings/swift/build-xcframework.sh | 15 ++ crates/ldgr-ffi/Cargo.toml | 19 +- crates/ldgr-ffi/src/ldgr.udl | 9 + crates/ldgr-ffi/src/lib.rs | 221 +++++++++++++++++- crates/ldgr-ffi/src/migrate.rs | 218 +++++++++++++++++ ...-local-working-store-at-rest-encryption.md | 147 ++++++++++++ 14 files changed, 911 insertions(+), 16 deletions(-) create mode 100644 apps/web/test/at-rest.mjs create mode 100644 crates/ldgr-ffi/src/migrate.rs create mode 100644 docs/adr/010-local-working-store-at-rest-encryption.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca4a84..87a66aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `ldgr migrate` command that encrypts an existing plaintext working store in place: it detects a legacy unencrypted `vault.db`, re-materializes it as an encrypted SQLCipher database keyed by your unlocked vault key, verifies the copy preserves the schema version and every table's row count before swapping it in, and keeps the original as a `vault.db.plaintext.bak` backup for backout. `ldgr unlock` now detects a plaintext store and prompts you to run `ldgr migrate` rather than migrating silently, so migrating a large ledger is always an explicit, user-driven step +- At-rest encryption now covers the iOS/iPadOS/macOS app (extending the CLI protection to every native platform, issue #315): the shared Rust FFI opens the working store (`vault.db`) as a SQLCipher database keyed by a subkey derived from your vault key, so account names, transaction descriptions, amounts, and commodities are no longer readable from a locked device's filesystem or an unencrypted backup. Existing plaintext vaults created by earlier builds are upgraded on unlock via an explicit, verified, reversible migration (schema + per-table row-count check, original kept as `vault.db.plaintext.bak`) — password unlock migrates with the password, biometric unlock migrates with the cached session key so you are never forced to re-enter it. The web app was already encrypted at rest by design (sql.js runs only in memory and is sealed into the AES-256-GCM vault container before it is written to IndexedDB) and now has a regression test asserting the persisted blob is ciphertext and that Web Storage holds no vault secrets. watchOS stores no vault and is unaffected. See ADR-010 for the per-platform model - `ldgr devices` CLI command group for QR/X25519 device onboarding against a self-hosted server: `ldgr devices list` shows the devices registered to your vault, `ldgr devices add` starts a pairing session on an already-set-up device (rendering a scannable QR code plus a copy-paste token and a verification code), `ldgr devices join ` completes onboarding on a new device (already configured with `ldgr sync setup`), and `ldgr devices remove ` deregisters a device. The vault key is transferred end-to-end encrypted under an ECDH shared secret over the existing server relay — the server never sees it in plaintext — and both devices display the same verification code so the user can detect a man-in-the-middle. iOS QR-scanning onboarding remains a follow-up - Cloudflare Worker market data proxy (`infra/market-proxy`) at `api.ldgr.dev/market/`: a shared caching proxy in front of Yahoo Finance, CoinGecko, and the ECB that serves many users the same symbols from one cached upstream response (ADR-007). Routes for quotes (`/quote`), crypto (`/crypto`), forex (`/forex`), historical OHLCV (`/historical`), and a `/health` check; symbol lists are normalized and sorted so `AAPL,MSFT` and `MSFT,AAPL` share a cache key; responses are cached in Cloudflare KV with per-type TTLs (quotes/crypto 15 min, historical/forex 24 hr); concurrent misses are de-duplicated to a single upstream fetch and upstream requests are throttled to ~1/sec per provider; CORS headers and an `X-Cache: HIT|MISS` header are returned for the web app. The proxy only ever handles public market data and clients fall back to direct provider requests when it is unavailable - Native macOS window and sidebar interface: on the Mac the app now presents a `NavigationSplitView` sidebar (Dashboard, Transactions, Accounts, Investments, Budget) with a resizable list/detail layout instead of the iPhone-style tab bar, plus a macOS menu bar with commands and keyboard shortcuts — New Transaction (⌘N), Import… (⌘I), New Window (⇧⌘N), Sync Now (⌘R), and Lock Vault (⌘L) — and multi-window support. The navigation shell is Mac-specific while the underlying views, view models, and encrypted vault are shared with iOS. diff --git a/Cargo.lock b/Cargo.lock index aa30af9..6444e56 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1800,6 +1800,7 @@ dependencies = [ "thiserror 2.0.18", "uniffi", "uuid", + "zeroize", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index a39d112..6c1073c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,11 +40,14 @@ rust_decimal = { version = "1", features = ["serde-with-str"] } # - ldgr-cli enables `bundled-sqlcipher-vendored-openssl` (SQLCipher, encrypts # `vault.db` at rest for issue #295); vendored OpenSSL builds from source for # portable, reproducible client builds. -# - ldgr-core / ldgr-ffi / ldgr-server enable plain `bundled`. Issue #295 is -# scoped to the CLI + core; extending at-rest encryption to the iOS/FFI path -# (keyed `PRAGMA key` open, which plain SQLite treats as a no-op) and web/WASM -# is tracked separately in issue #315, so the FFI crate stays on plain SQLite -# here and avoids pulling vendored OpenSSL into the Apple cross-compile. +# - ldgr-ffi enables SQLCipher per-target (issue #315 extends at-rest encryption +# to the iOS/FFI working store): `bundled-sqlcipher-vendored-openssl` on every +# Apple target except watchOS, and plain `bundled` on watchOS (openssl-src +# cannot configure OpenSSL for aarch64-apple-watchos, and the watch app never +# opens a vault DB). See crates/ldgr-ffi/Cargo.toml. +# - ldgr-core / ldgr-server enable plain `bundled`. The server's encrypted-blob +# store never keys the working SQLite, and core stays SQLCipher-free so it +# keeps compiling to WASM without vendored OpenSSL. # NOTE: Cargo unifies features across a full `cargo build --workspace`, so a # combined/dev build links SQLCipher into the shared rusqlite; the SHIPPED server # is always built isolated with `cargo build -p ldgr-server` (Dockerfile / diff --git a/apps/ios/ldgr/Sources/Views/UnlockView.swift b/apps/ios/ldgr/Sources/Views/UnlockView.swift index 90b91c5..977b8c0 100644 --- a/apps/ios/ldgr/Sources/Views/UnlockView.swift +++ b/apps/ios/ldgr/Sources/Views/UnlockView.swift @@ -8,6 +8,7 @@ struct UnlockView: View { @State private var password = "" @State private var isUnlocking = false + @State private var isMigrating = false @State private var showBiometricError = false @State private var biometricErrorMessage = "" @@ -54,7 +55,7 @@ struct UnlockView: View { if isUnlocking && !appState.isBiometricEnabled { ProgressView() .padding(.trailing, 4) - Text("Unlocking…") + Text(isMigrating ? "Encrypting…" : "Unlocking…") } else { Text("Unlock") } @@ -111,10 +112,20 @@ struct UnlockView: View { appState.transitionToUnlocking() defer { isUnlocking = false + isMigrating = false password = "" } do { + // Upgrade a legacy plaintext working store to the encrypted format + // before opening it (issue #315). Explicit, verified, reversible — + // mirrors the CLI's `ldgr migrate`. + if (try? client.needsMigration()) == true { + isMigrating = true + try await client.migrate(password: password) + isMigrating = false + } + try await client.open(password: password) // Store session key for biometric unlock if biometrics are available @@ -135,11 +146,24 @@ struct UnlockView: View { private func unlockWithBiometrics() async { isUnlocking = true appState.transitionToUnlocking() - defer { isUnlocking = false } + defer { + isUnlocking = false + isMigrating = false + } do { // Keychain read triggers biometric prompt let keyData = try KeychainManager.retrieveSessionKey() + + // Upgrade a legacy plaintext working store using the cached session + // key, so a biometric user is not forced to re-enter their password + // just to migrate (issue #315). + if (try? client.needsMigration()) == true { + isMigrating = true + try await client.migrateWithSessionKey(keyData) + isMigrating = false + } + try await client.openWithSessionKey(keyData) appState.transitionToUnlocked() } catch let error as KeychainError { diff --git a/apps/web/package.json b/apps/web/package.json index 9432b66..05d6d6f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -10,7 +10,7 @@ "build": "next build --webpack", "start": "next start", "lint": "next lint", - "test": "node --experimental-strip-types --test test/integration.mjs test/sync-wasm.mjs test/admin-api.mjs" + "test": "node --experimental-strip-types --test test/integration.mjs test/sync-wasm.mjs test/admin-api.mjs test/at-rest.mjs" }, "dependencies": { "next": "^16.2.10", diff --git a/apps/web/test/at-rest.mjs b/apps/web/test/at-rest.mjs new file mode 100644 index 0000000..0502514 --- /dev/null +++ b/apps/web/test/at-rest.mjs @@ -0,0 +1,155 @@ +/** + * At-rest encryption regression test for the web working store (issue #315). + * + * The web app never persists a plaintext SQLite database: `sql.js` runs purely + * in memory, and `VaultContext.saveVault()` seals the exported db bytes into the + * Rust core's AES-256-GCM vault container (`serializeVault()`) BEFORE writing the + * result to IndexedDB (`saveVaultBlob`). This test locks that guarantee in: + * + * Part 1 (crypto) — reproduces the exact saveVault pipeline against the REAL + * compiled `ldgr-wasm` (addItem → serializeVault) and asserts the persisted + * blob is ciphertext: no `SQLite format 3` magic header and none of the known + * plaintext tokens (account name, sync token, 2SKD secret key) survive. Also + * proves the sealed item round-trips back out and that a wrong password cannot + * open it. Requires `npm run build:wasm`; skipped gracefully if pkg/ is absent. + * + * Part 2 (static audit) — scans the web source and asserts the ONLY keys ever + * written to localStorage / sessionStorage are the documented non-secret ones + * (theme preference; ephemeral admin-panel bearer token), so no vault secret + * can leak to Web Storage, and that saveVault seals before it persists. + */ + +import { test, describe } from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; + +const pkgJs = new URL('../pkg/ldgr_wasm.js', import.meta.url); +const pkgWasm = new URL('../pkg/ldgr_wasm_bg.wasm', import.meta.url); +const havePkg = existsSync(fileURLToPath(pkgJs)) && existsSync(fileURLToPath(pkgWasm)); + +const SQLITE_MAGIC = Buffer.from('SQLite format 3\0', 'latin1'); + +// The known plaintext secrets a broken saveVault would leak. These live in the +// sql.js DB (account names + the sync_state table's token / 2SKD secret key), so +// they must all be sealed inside the vault container, never persisted in clear. +const ACCOUNT_NAME = 'Assets:SecretBankAccount'; +const SYNC_TOKEN = 'sync-token-DEADBEEF-must-not-leak'; +const SECRET_KEY = 'A3-SECRETKEY-MUST-NOT-LEAK-0000'; + +/** Build a fake sql.js `db.export()` blob: a real SQLite header + secret tokens. */ +function fakeSqlJsExport() { + const body = Buffer.from( + `\x00tables...${ACCOUNT_NAME}...sync_state:${SYNC_TOKEN}...secret:${SECRET_KEY}...`, + 'latin1', + ); + return new Uint8Array(Buffer.concat([SQLITE_MAGIC, body])); +} + +const contains = (haystack, needle) => + Buffer.from(haystack).includes(Buffer.from(needle, 'latin1')); + +describe('web working store is encrypted at rest', { + skip: havePkg ? false : 'pkg not built (run npm run build:wasm)', +}, () => { + let LdgrWasm; + let persisted; // the blob saveVault() would write to IndexedDB + + test('loads the compiled wasm module', async () => { + const mod = await import(pkgJs.href); + await mod.default({ module_or_path: readFileSync(pkgWasm) }); + LdgrWasm = mod.LdgrWasm; + assert.equal(typeof LdgrWasm.createVault, 'function'); + }); + + test('the blob persisted to IndexedDB is ciphertext, not plaintext SQLite', () => { + // Mirror VaultContext.saveVault(): seal the exported db bytes as vault item 0, + // then serialize the sealed container — this is exactly what saveVaultBlob + // receives. + const created = LdgrWasm.createVault('correct-horse-battery', 'web'); + const vault = LdgrWasm.openVault(created.vaultData, 'correct-horse-battery'); + + const dbBlob = fakeSqlJsExport(); + if (vault.itemCount() > 0) { + vault.replaceItem(0, dbBlob); + } else { + vault.addItem(dbBlob); + } + persisted = vault.serializeVault(); + + assert.ok(persisted instanceof Uint8Array && persisted.length > 0); + + // Not a plaintext SQLite database. + assert.ok( + !Buffer.from(persisted.subarray(0, 16)).equals(SQLITE_MAGIC), + 'persisted vault blob must not start with the SQLite magic header', + ); + // None of the plaintext secrets survive in the sealed blob. + assert.ok(!contains(persisted, ACCOUNT_NAME), 'account name leaked to disk'); + assert.ok(!contains(persisted, SYNC_TOKEN), 'sync token leaked to disk'); + assert.ok(!contains(persisted, SECRET_KEY), 'secret key leaked to disk'); + assert.ok(!contains(persisted, 'SQLite format 3'), 'SQLite header leaked to disk'); + }); + + test('the sealed db blob round-trips back out with the right password', () => { + const reopened = LdgrWasm.openVault(persisted, 'correct-horse-battery'); + assert.equal(reopened.itemCount(), 1); + const restored = reopened.getItem(reopened.itemCount() - 1); + assert.ok( + Buffer.from(restored).equals(Buffer.from(fakeSqlJsExport())), + 'sealed db bytes must decrypt back to the original export', + ); + }); + + test('a wrong password cannot open the persisted blob', () => { + assert.throws(() => LdgrWasm.openVault(persisted, 'wrong-password')); + }); +}); + +describe('web Web Storage carries no vault secrets (static audit)', () => { + const webSrc = fileURLToPath(new URL('../src', import.meta.url)); + + // The complete allowlist of keys the web app may write to localStorage / + // sessionStorage. Both are NON-secret: the theme preference, and the ephemeral + // admin-panel bearer token (sessionStorage only — cleared on tab close, never a + // vault secret). Any new key must be reviewed for at-rest leakage before being + // added here. + const ALLOWED_KEYS = new Set(['ldgr-theme', 'ldgr:admin:session']); + + function walk(dir) { + const files = []; + for (const entry of readdirSync(dir)) { + const p = `${dir}/${entry}`; + if (statSync(p).isDirectory()) files.push(...walk(p)); + else if (/\.(ts|tsx)$/.test(entry)) files.push(p); + } + return files; + } + + test('every localStorage/sessionStorage key written is a known non-secret', () => { + const setItem = /\b(?:local|session)Storage\.setItem\(\s*['"`]([^'"`]+)['"`]/g; + const found = new Set(); + for (const file of walk(webSrc)) { + const src = readFileSync(file, 'utf8'); + for (const m of src.matchAll(setItem)) found.add(m[1]); + } + assert.ok(found.size > 0, 'expected to find at least one setItem call to audit'); + for (const key of found) { + assert.ok( + ALLOWED_KEYS.has(key), + `unexpected Web Storage key "${key}" — audit it for at-rest secret leakage`, + ); + } + }); + + test('saveVault seals via serializeVault before persisting to IndexedDB', () => { + const src = readFileSync(`${webSrc}/contexts/VaultContext.tsx`, 'utf8'); + const seal = src.indexOf('serializeVault()'); + const persist = src.indexOf('saveVaultBlob(', seal); + assert.ok(seal !== -1, 'saveVault must call serializeVault()'); + assert.ok( + persist !== -1 && persist > seal, + 'saveVaultBlob must be called with the sealed serializeVault() output', + ); + }); +}); diff --git a/bindings/swift/Sources/LdgrSwift/LdgrClient.swift b/bindings/swift/Sources/LdgrSwift/LdgrClient.swift index 77f97ce..46fd298 100644 --- a/bindings/swift/Sources/LdgrSwift/LdgrClient.swift +++ b/bindings/swift/Sources/LdgrSwift/LdgrClient.swift @@ -330,6 +330,70 @@ public final class LdgrClient: @unchecked Sendable { } } + // MARK: - At-Rest Migration (issue #315) + + /// Whether the on-disk working store is a legacy **plaintext** database that + /// must be migrated to the encrypted (SQLCipher) format before it can be + /// opened. + /// + /// Only reads the file header — does not require the vault to be unlocked. + /// Callers should check this before ``open(password:)`` / + /// ``openWithSessionKey(_:)`` and, if `true`, run ``migrate(password:)`` + /// (password path) or ``migrateWithSessionKey(_:)`` (biometric path) first. + public func needsMigration() throws -> Bool { + do { + return try vault.needsMigration() + } catch let error as LdgrError { + throw LdgrClientError(from: error) + } + } + + /// Migrate a legacy plaintext working store to the encrypted format, keyed by + /// the vault key derived from `password`. + /// + /// Mirrors the CLI's explicit `ldgr migrate`: verifies the password, rewrites + /// the store as SQLCipher-encrypted, checks the copy preserves the schema and + /// every table's row count, then atomically swaps it in — keeping the original + /// as a `.plaintext.bak` backup. A no-op if the store is already encrypted. + /// Leaves the vault **locked**; call ``open(password:)`` afterwards. Runs on a + /// background thread (SQLCipher export can be expensive for large ledgers). + public func migrate(password: String) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Task.detached { [vault] in + do { + try vault.migrate(password: password) + continuation.resume() + } catch let error as LdgrError { + continuation.resume(throwing: LdgrClientError(from: error)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + /// Migrate a legacy plaintext working store using a previously exported + /// session key (biometric-unlock path). + /// + /// Like ``migrate(password:)`` but keyed from the cached 32-byte session key, + /// so a biometric user upgrading from an unencrypted build is not forced to + /// re-enter their password. Leaves the vault **locked**; call + /// ``openWithSessionKey(_:)`` afterwards. + public func migrateWithSessionKey(_ key: Data) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + Task.detached { [vault] in + do { + try vault.migrateWithSessionKey(key: Array(key)) + continuation.resume() + } catch let error as LdgrError { + continuation.resume(throwing: LdgrClientError(from: error)) + } catch { + continuation.resume(throwing: error) + } + } + } + } + // MARK: - Light Operations (sync, still safe from any actor) /// Get the vault name. diff --git a/bindings/swift/Tests/LdgrSwiftTests/LdgrSwiftTests.swift b/bindings/swift/Tests/LdgrSwiftTests/LdgrSwiftTests.swift index 00a66c7..d44d7e6 100644 --- a/bindings/swift/Tests/LdgrSwiftTests/LdgrSwiftTests.swift +++ b/bindings/swift/Tests/LdgrSwiftTests/LdgrSwiftTests.swift @@ -77,4 +77,38 @@ final class LdgrSwiftTests: XCTestCase { } } } + + /// The on-disk working store must be SQLCipher-encrypted (issue #315): no + /// plaintext `SQLite format 3` header and no ledger identifiers readable from + /// the raw file. This is the byte-level guarantee that a stolen locked device + /// yields only ciphertext. + func testWorkingStoreEncryptedAtRest() async throws { + let tmpDir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tmpDir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tmpDir) } + + let client = try LdgrClient(path: tmpDir.path) + _ = try await client.createVault(password: "test123", name: "Secret Ledger") + _ = try client.addAccount(name: "Assets:SecretBank", type: .asset, commodity: "USD") + client.close() + + let dbURL = tmpDir.appendingPathComponent("vault.db") + let bytes = try Data(contentsOf: dbURL) + XCTAssertGreaterThan(bytes.count, 16, "working store should be non-empty") + + let sqliteMagic = Data("SQLite format 3\0".utf8) + XCTAssertNotEqual(bytes.prefix(16), sqliteMagic, "vault.db must be encrypted at rest") + XCTAssertNil( + bytes.range(of: Data("Assets:SecretBank".utf8)), + "account name must not be readable in plaintext" + ) + XCTAssertNil( + bytes.range(of: Data("sqlite_master".utf8)), + "schema must not be readable in plaintext" + ) + + // A freshly created (already encrypted) vault does not need migration. + XCTAssertFalse(try client.needsMigration()) + } } diff --git a/bindings/swift/build-xcframework.sh b/bindings/swift/build-xcframework.sh index 4d0588c..f505016 100755 --- a/bindings/swift/build-xcframework.sh +++ b/bindings/swift/build-xcframework.sh @@ -33,6 +33,21 @@ if [[ "${1:-}" == "--release" ]]; then CARGO_FLAGS="--release" fi +# ── Deployment targets ────────────────────────────────────────────────────────── +# Pin a minimum OS version for every Apple slice. This is REQUIRED now that +# ldgr-ffi links SQLCipher's vendored OpenSSL (issues #295/#315): those C objects +# are built for the host SDK and reference `___chkstk_darwin`, a libSystem +# stack-probe symbol that only exists from iOS 12 / watchOS 5 onward. Without an +# explicit target, cargo/rustc default to the ancient iOS 10.0 baseline, where +# that symbol is undefined and the link fails ("Undefined symbols: +# ___chkstk_darwin"). These values match the minimums declared in Package.swift +# so the framework links against the same floor the Swift package advertises, and +# each is overridable via `${VAR:-default}`. Each `cargo build --target` reads the +# env var matching its platform at link time. +export IPHONEOS_DEPLOYMENT_TARGET="${IPHONEOS_DEPLOYMENT_TARGET:-16.0}" +export MACOSX_DEPLOYMENT_TARGET="${MACOSX_DEPLOYMENT_TARGET:-13.0}" +export WATCHOS_DEPLOYMENT_TARGET="${WATCHOS_DEPLOYMENT_TARGET:-10.0}" + echo "╔══════════════════════════════════════════════════╗" echo "║ ldgr — Building XCFramework ($PROFILE) ║" echo "╚══════════════════════════════════════════════════╝" diff --git a/crates/ldgr-ffi/Cargo.toml b/crates/ldgr-ffi/Cargo.toml index 044fd77..497c371 100644 --- a/crates/ldgr-ffi/Cargo.toml +++ b/crates/ldgr-ffi/Cargo.toml @@ -21,8 +21,23 @@ uuid.workspace = true rust_decimal.workspace = true thiserror.workspace = true chrono.workspace = true -# Plain bundled SQLite. At-rest encryption for the iOS/FFI working store -# (SQLCipher keyed open + Swift Keychain session) is tracked separately in #315. +zeroize.workspace = true + +# At-rest encryption for the iOS/FFI working store (issue #315): the keyed +# `PRAGMA key` open only encrypts when rusqlite is built with SQLCipher — plain +# SQLite treats `PRAGMA key` as a silent no-op and would write plaintext. The +# SQLCipher feature uses rusqlite's vendored OpenSSL, but `openssl-src` cannot +# configure OpenSSL for watchOS ("don't know how to configure OpenSSL for +# aarch64-apple-watchos"), so the feature is scoped to every target EXCEPT +# watchOS. This is safe: the watchOS app links only `LdgrShared` (pre-computed +# summaries over WatchConnectivity) and never opens a vault database, so it needs +# neither SQLCipher nor at-rest encryption. Each `cargo build --target` selects +# exactly one table below, so their features never unify across a device vs. +# watch build. +[target.'cfg(not(target_os = "watchos"))'.dependencies] +rusqlite = { workspace = true, features = ["bundled-sqlcipher-vendored-openssl"] } + +[target.'cfg(target_os = "watchos")'.dependencies] rusqlite = { workspace = true, features = ["bundled"] } [build-dependencies] diff --git a/crates/ldgr-ffi/src/ldgr.udl b/crates/ldgr-ffi/src/ldgr.udl index c168db2..92b5f1c 100644 --- a/crates/ldgr-ffi/src/ldgr.udl +++ b/crates/ldgr-ffi/src/ldgr.udl @@ -118,6 +118,15 @@ interface LdgrVault { [Throws=LdgrError] void open_with_session_key(sequence key); + [Throws=LdgrError] + boolean needs_migration(); + + [Throws=LdgrError] + void migrate(string password); + + [Throws=LdgrError] + void migrate_with_session_key(sequence key); + boolean is_unlocked(); [Throws=LdgrError] diff --git a/crates/ldgr-ffi/src/lib.rs b/crates/ldgr-ffi/src/lib.rs index 0a4a336..4e449b8 100644 --- a/crates/ldgr-ffi/src/lib.rs +++ b/crates/ldgr-ffi/src/lib.rs @@ -15,8 +15,8 @@ use ldgr_core::accounting::parser::{ParseError, parse_journal}; use ldgr_core::accounting::reports; use ldgr_core::accounting::types as acct; use ldgr_core::crypto::{ - self, Argon2Params, UnlockedVault, encode_recovery_key, open_vault, restore_vault_from_session, - serialize_vault, + self, Argon2Params, UnlockedVault, derive_db_key, encode_recovery_key, open_vault, + restore_vault_from_session, serialize_vault, }; use ldgr_core::storage::accounts::{self, AccountType, ListOptions, NewAccount}; use ldgr_core::storage::error::StorageError; @@ -25,9 +25,11 @@ use ldgr_core::storage::schema; use ldgr_core::storage::sync as sync_storage; use ldgr_core::storage::transactions::{self, NewPosting, NewTransaction, TransactionStatus}; use rusqlite::Connection; +use zeroize::Zeroizing; uniffi::include_scaffolding!("ldgr"); +mod migrate; mod sync; pub use sync::*; @@ -211,6 +213,40 @@ enum VaultState { // ── LdgrVault Object ─────────────────────────────────────────────────────────── +/// Open the working-store database and apply the `SQLCipher` key derived from the +/// session vault key, so financial data is encrypted at rest (issue #315). +/// +/// Mirrors the CLI's keyed open ([`ldgr_cli::db::open_encrypted`]): a raw-key +/// `PRAGMA key` (so `SQLCipher` uses the derived bytes directly and skips its own +/// PBKDF2 — we already derive from the Argon2id-based key chain), then a forced +/// page read so a wrong key (or an unmigrated plaintext store) fails now with a +/// clear error rather than at the first query. The formatted `PRAGMA` statement, +/// which embeds the key hex, is zeroized after use. +/// +/// This is what makes the FFI/iOS path actually encrypt: with plain `SQLite` the +/// `PRAGMA key` is silently ignored and the store would be written in plaintext. +/// +/// We deliberately do **not** enable `PRAGMA cipher_memory_security`: issue #295 +/// removed it from the CLI because its global `VirtualLock`-on-every-allocation +/// hook exhausts the Windows working-set quota. At-rest confidentiality comes +/// entirely from `PRAGMA key` encrypting the database file on disk. +fn open_encrypted_db( + path: &std::path::Path, + session_key: &[u8; 32], +) -> Result { + let conn = Connection::open(path)?; + let db_key = derive_db_key(session_key)?; + let pragma = db_key.to_pragma_hex(); + let stmt = Zeroizing::new(format!("PRAGMA key = \"{}\";", pragma.as_str())); + conn.execute_batch(&stmt)?; + // Force a page read so a wrong key (or an unmigrated plaintext store) fails + // now with a clear error rather than at the first query. + conn.query_row("SELECT count(*) FROM sqlite_master", [], |r| { + r.get::<_, i64>(0) + })?; + Ok(conn) +} + pub struct LdgrVault { vault_dir: PathBuf, vault_path: PathBuf, @@ -257,8 +293,9 @@ impl LdgrVault { let vault_bytes = serialize_vault(&vault)?; atomic_write(&self.vault_path, &vault_bytes)?; - // Create and initialize SQLite database - let conn = Connection::open(&self.db_path)?; + // Create and initialize the encrypted SQLite (SQLCipher) database + let session_key = vault.export_session_key(); + let conn = open_encrypted_db(&self.db_path, &session_key)?; schema::initialize(&conn)?; let recovery_string = encode_recovery_key(&recovery_key); @@ -281,7 +318,8 @@ impl LdgrVault { let data = std::fs::read(&self.vault_path)?; let vault = open_vault(&data, password.as_bytes())?; - let conn = Connection::open(&self.db_path)?; + let session_key = vault.export_session_key(); + let conn = open_encrypted_db(&self.db_path, &session_key)?; // Ensure schema is initialized (idempotent) schema::initialize(&conn)?; @@ -353,7 +391,8 @@ impl LdgrVault { let data = std::fs::read(&self.vault_path)?; let vault = restore_vault_from_session(&data, &key_bytes)?; - let conn = Connection::open(&self.db_path)?; + let session_key = vault.export_session_key(); + let conn = open_encrypted_db(&self.db_path, &session_key)?; schema::initialize(&conn)?; let mut state = self.state.lock().expect("mutex poisoned"); @@ -362,6 +401,73 @@ impl LdgrVault { Ok(()) } + /// Whether the working store is a legacy **plaintext** `SQLite` database that + /// must be migrated to the encrypted (`SQLCipher`) format before it can be + /// opened (issue #315). + /// + /// Only reads the file header — does not require the vault to be unlocked. + /// The caller should check this before [`open`](Self::open) / + /// [`open_with_session_key`](Self::open_with_session_key) and, if `true`, run + /// [`migrate`](Self::migrate) (password path) or + /// [`migrate_with_session_key`](Self::migrate_with_session_key) (biometric + /// path) first. + pub fn needs_migration(&self) -> Result { + migrate::is_plaintext_sqlite(&self.db_path) + } + + /// Migrate a legacy plaintext working store to the encrypted format, keyed by + /// the vault key derived from `password` (issue #315). + /// + /// Mirrors the CLI's explicit `ldgr migrate`: opens the vault to obtain the + /// session key (so a wrong password is rejected before any file is touched), + /// re-materialises `vault.db` as `SQLCipher`-encrypted, verifies the copy + /// preserves the schema version and every table's row count, then atomically + /// swaps it in — keeping the original as `vault.db.plaintext.bak`. A no-op if + /// the store is already encrypted. Leaves the vault **locked**; call + /// [`open`](Self::open) afterwards. + pub fn migrate(&self, password: String) -> Result<(), LdgrError> { + if !self.vault_path.exists() { + return Err(LdgrError::NotFound(format!( + "vault file not found: {}", + self.vault_path.display() + ))); + } + let data = std::fs::read(&self.vault_path)?; + let vault = open_vault(&data, password.as_bytes())?; + let session_key = vault.export_session_key(); + migrate::migrate_if_plaintext(&self.db_path, &session_key)?; + Ok(()) + } + + /// Migrate a legacy plaintext working store using a previously exported + /// session key (biometric-unlock path, issue #315). + /// + /// Like [`migrate`](Self::migrate) but keyed from the cached 32-byte session + /// key instead of the password, so a biometric user upgrading from an + /// unencrypted build is not forced to re-enter their password. Validates the + /// key against the vault before migrating. Leaves the vault **locked**; call + /// [`open_with_session_key`](Self::open_with_session_key) afterwards. + pub fn migrate_with_session_key(&self, key: Vec) -> Result<(), LdgrError> { + let key_bytes: [u8; 32] = key.try_into().map_err(|v: Vec| { + LdgrError::InvalidInput(format!( + "session key must be exactly 32 bytes, got {}", + v.len() + )) + })?; + if !self.vault_path.exists() { + return Err(LdgrError::NotFound(format!( + "vault file not found: {}", + self.vault_path.display() + ))); + } + // Validate the key matches the vault before touching the working store. + let data = std::fs::read(&self.vault_path)?; + let vault = restore_vault_from_session(&data, &key_bytes)?; + let session_key = vault.export_session_key(); + migrate::migrate_if_plaintext(&self.db_path, &session_key)?; + Ok(()) + } + /// Check whether the vault is currently unlocked. pub fn is_unlocked(&self) -> bool { let state = self.state.lock().expect("mutex poisoned"); @@ -1343,4 +1449,107 @@ mod tests { LdgrError::VaultLocked )); } + + // ---- Issue #315: at-rest encryption + migration ---- + + const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0"; + + fn bytes_contain(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + #[test] + fn working_store_is_encrypted_at_rest() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_string_lossy().to_string(); + + let vault = LdgrVault::new(path).unwrap(); + vault + .create_vault("pw".to_string(), "Secret Ledger".to_string()) + .unwrap(); + vault + .add_account( + "Assets:SecretBank".to_string(), + "asset".to_string(), + Some("USD".to_string()), + ) + .unwrap(); + vault.close(); + + // The on-disk working store must be SQLCipher-encrypted: no plaintext + // SQLite header, and no ledger identifiers visible in the clear. + let on_disk = std::fs::read(dir.path().join("vault.db")).unwrap(); + assert_ne!(&on_disk[..16], SQLITE_MAGIC, "vault.db must be encrypted"); + assert!(!bytes_contain(&on_disk, b"Assets:SecretBank")); + assert!(!bytes_contain(&on_disk, b"sqlite_master")); + + // A freshly created (already encrypted) vault does not need migration. + assert!(!vault.needs_migration().unwrap()); + } + + #[test] + fn migrate_plaintext_store_to_encrypted() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_string_lossy().to_string(); + let db_path = dir.path().join("vault.db"); + + // Create a real (encrypted) vault, then simulate a legacy build by + // replacing vault.db with a plaintext SQLite database. + let vault = LdgrVault::new(path).unwrap(); + vault + .create_vault("pw".to_string(), "Legacy".to_string()) + .unwrap(); + vault.close(); + std::fs::remove_file(&db_path).unwrap(); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + schema::initialize(&conn).unwrap(); + conn.execute_batch( + "CREATE TABLE t_probe (id INTEGER); INSERT INTO t_probe VALUES (1),(2);", + ) + .unwrap(); + } + // Confirm it is plaintext before migrating. + assert_eq!(&std::fs::read(&db_path).unwrap()[..16], SQLITE_MAGIC); + assert!(vault.needs_migration().unwrap()); + + // Explicit migration (mirrors CLI `ldgr migrate`), then open. + vault.migrate("pw".to_string()).unwrap(); + assert!(!vault.needs_migration().unwrap()); + assert!(dir.path().join("vault.db.plaintext.bak").exists()); + assert!(!dir.path().join("vault.db.migrating").exists()); + + // Store is now encrypted and the seeded data survived. + let on_disk = std::fs::read(&db_path).unwrap(); + assert_ne!(&on_disk[..16], SQLITE_MAGIC); + assert!(!bytes_contain(&on_disk, b"t_probe")); + + vault.open("pw".to_string()).unwrap(); + assert!(vault.is_unlocked()); + vault.close(); + } + + #[test] + fn migrate_rejects_wrong_password() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().to_string_lossy().to_string(); + let db_path = dir.path().join("vault.db"); + + let vault = LdgrVault::new(path).unwrap(); + vault + .create_vault("correct".to_string(), "Legacy".to_string()) + .unwrap(); + vault.close(); + std::fs::remove_file(&db_path).unwrap(); + { + let conn = rusqlite::Connection::open(&db_path).unwrap(); + schema::initialize(&conn).unwrap(); + } + + // A wrong password must fail before any file is swapped; the plaintext + // store is left intact (still needs migration). + assert!(vault.migrate("wrong".to_string()).is_err()); + assert!(vault.needs_migration().unwrap()); + assert!(!dir.path().join("vault.db.plaintext.bak").exists()); + } } diff --git a/crates/ldgr-ffi/src/migrate.rs b/crates/ldgr-ffi/src/migrate.rs new file mode 100644 index 0000000..867d219 --- /dev/null +++ b/crates/ldgr-ffi/src/migrate.rs @@ -0,0 +1,218 @@ +//! Migration of a legacy plaintext `vault.db` to the encrypted (`SQLCipher`) +//! working store on the iOS/FFI path (issue #315). +//! +//! Prior versions of the FFI opened the working store with plain +//! `Connection::open`, writing an unencrypted `SQLite` database to the device +//! filesystem. This module detects such a file and re-materialises it as a +//! `SQLCipher`-encrypted database keyed by the session vault key, mirroring the +//! CLI's `ldgr migrate` semantics (`crates/ldgr-cli/src/migrate.rs`): the +//! caller triggers migration **explicitly** (see [`crate::LdgrVault::migrate`]), +//! it is never performed silently on unlock. The original is only removed once +//! the encrypted copy is verified to preserve the schema version and every +//! table's row count; the plaintext file is retained as a `.plaintext.bak` +//! backup for backout. +//! +//! The file I/O deliberately lives here in the FFI crate (not `ldgr-core`, which +//! is I/O-free) exactly as the equivalent logic lives in the CLI binary crate. + +use std::collections::BTreeMap; +use std::fs; +use std::io::Read; +use std::path::{Path, PathBuf}; + +use ldgr_core::crypto::derive_db_key; +use rusqlite::Connection; +use zeroize::Zeroizing; + +use crate::{LdgrError, open_encrypted_db}; + +/// The 16-byte header every unencrypted `SQLite` database starts with. +const SQLITE_MAGIC: &[u8; 16] = b"SQLite format 3\0"; + +fn storage_err(msg: impl Into) -> LdgrError { + LdgrError::StorageError(msg.into()) +} + +/// Returns `true` if the file is an unencrypted `SQLite` database (i.e. it still +/// needs migrating). A `SQLCipher`-encrypted file has an encrypted header and +/// will not match this magic. +pub fn is_plaintext_sqlite(path: &Path) -> Result { + if !path.exists() { + return Ok(false); + } + let mut file = fs::File::open(path)?; + let mut header = [0u8; 16]; + match file.read_exact(&mut header) { + Ok(()) => Ok(&header == SQLITE_MAGIC), + // Too small to be a plaintext database (e.g. empty/encrypted-only). + Err(_) => Ok(false), + } +} + +/// Migrate the store at `db_path` to encrypted form if it is currently +/// plaintext. Returns `true` if a migration was performed. +pub fn migrate_if_plaintext(db_path: &Path, session_key: &[u8; 32]) -> Result { + if !is_plaintext_sqlite(db_path)? { + return Ok(false); + } + migrate_plaintext_to_encrypted(db_path, session_key)?; + Ok(true) +} + +fn migrate_plaintext_to_encrypted(db_path: &Path, session_key: &[u8; 32]) -> Result<(), LdgrError> { + let dir = db_path.parent().unwrap_or_else(|| Path::new(".")); + let tmp_enc: PathBuf = dir.join("vault.db.migrating"); + let backup: PathBuf = dir.join("vault.db.plaintext.bak"); + + if tmp_enc.exists() { + fs::remove_file(&tmp_enc)?; + } + + // Snapshot the plaintext source for later verification. + let src = Connection::open(db_path)?; + let src_version = ldgr_core::storage::schema::current_version(&src)?; + let src_counts = table_counts(&src)?; + + // Export the plaintext main database into a freshly keyed SQLCipher file. + let db_key = derive_db_key(session_key)?; + let pragma = db_key.to_pragma_hex(); + let attach = Zeroizing::new(format!( + "ATTACH DATABASE '{}' AS encrypted KEY \"{}\";", + sql_escape_single_quotes(&tmp_enc.to_string_lossy()), + pragma.as_str() + )); + src.execute_batch(&attach)?; + src.query_row("SELECT sqlcipher_export('encrypted')", [], |_| Ok(()))?; + src.execute_batch("DETACH DATABASE encrypted;")?; + drop(src); + + // Verify the encrypted copy is complete before touching the original. + let verify = || -> Result<(), LdgrError> { + let enc = open_encrypted_db(&tmp_enc, session_key)?; + let enc_version = ldgr_core::storage::schema::current_version(&enc)?; + let enc_counts = table_counts(&enc)?; + if enc_version != src_version || enc_counts != src_counts { + return Err(storage_err(format!( + "migration verification failed (schema {src_version}->{enc_version}); \ + original database left untouched" + ))); + } + Ok(()) + }; + if let Err(e) = verify() { + let _ = fs::remove_file(&tmp_enc); + return Err(e); + } + + // Atomic swap: keep the plaintext original as a backup, move encrypted in. + fs::rename(db_path, &backup)?; + if let Err(e) = fs::rename(&tmp_enc, db_path) { + // Attempt to restore the original on failure. + let _ = fs::rename(&backup, db_path); + return Err(e.into()); + } + + Ok(()) +} + +/// Row counts for every user table, used to verify a migration preserved data. +fn table_counts(conn: &Connection) -> Result, LdgrError> { + let mut stmt = conn.prepare( + "SELECT name FROM sqlite_master \ + WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name", + )?; + let names: Vec = stmt + .query_map([], |r| r.get::<_, String>(0))? + .collect::>()?; + + let mut counts = BTreeMap::new(); + for name in names { + let count: i64 = conn.query_row(&format!("SELECT count(*) FROM \"{name}\""), [], |r| { + r.get(0) + })?; + counts.insert(name, count); + } + Ok(counts) +} + +fn sql_escape_single_quotes(s: &str) -> String { + s.replace('\'', "''") +} + +#[cfg(test)] +mod tests { + use super::*; + + const KEY: [u8; 32] = [7u8; 32]; + + fn byte_contains(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) + } + + /// Create a plaintext `SQLite` database at `path` with the real schema plus a + /// small custom table carrying known row counts. + fn seed_plaintext_db(path: &Path) { + let conn = Connection::open(path).unwrap(); + ldgr_core::storage::schema::initialize(&conn).unwrap(); + conn.execute_batch( + "CREATE TABLE t_probe (id INTEGER); INSERT INTO t_probe VALUES (1),(2),(3);", + ) + .unwrap(); + } + + #[test] + fn detects_plaintext_and_encrypted() { + let dir = tempfile::tempdir().unwrap(); + let plain = dir.path().join("plain.db"); + seed_plaintext_db(&plain); + assert!(is_plaintext_sqlite(&plain).unwrap()); + + // A fresh encrypted database must NOT be detected as plaintext. + let enc = dir.path().join("enc.db"); + let conn = open_encrypted_db(&enc, &KEY).unwrap(); + ldgr_core::storage::schema::initialize(&conn).unwrap(); + drop(conn); + assert!(!is_plaintext_sqlite(&enc).unwrap()); + + // A non-existent file is not "plaintext to migrate". + assert!(!is_plaintext_sqlite(&dir.path().join("nope.db")).unwrap()); + } + + #[test] + fn migration_round_trip_preserves_data_and_encrypts() { + let dir = tempfile::tempdir().unwrap(); + let db_path = dir.path().join("vault.db"); + seed_plaintext_db(&db_path); + + // Sanity: on-disk header is the plaintext SQLite magic before migration. + let before = std::fs::read(&db_path).unwrap(); + assert_eq!(&before[..16], SQLITE_MAGIC); + + assert!(migrate_if_plaintext(&db_path, &KEY).unwrap()); + // Running again is a no-op (already encrypted). + assert!(!migrate_if_plaintext(&db_path, &KEY).unwrap()); + + // The on-disk file is no longer a plaintext SQLite database, and neither + // the schema table names nor the seeded probe data survive in the clear. + let after = std::fs::read(&db_path).unwrap(); + assert_ne!( + &after[..16], + SQLITE_MAGIC, + "store must be encrypted at rest" + ); + assert!(!is_plaintext_sqlite(&db_path).unwrap()); + assert!(!byte_contains(&after, b"t_probe")); + assert!(!byte_contains(&after, b"sqlite_master")); + + // The plaintext backup is retained; the temp migration file is cleaned up. + assert!(dir.path().join("vault.db.plaintext.bak").exists()); + assert!(!dir.path().join("vault.db.migrating").exists()); + + // Data is preserved and readable with the correct key. + let conn = open_encrypted_db(&db_path, &KEY).unwrap(); + let probe: i64 = conn + .query_row("SELECT count(*) FROM t_probe", [], |r| r.get(0)) + .unwrap(); + assert_eq!(probe, 3); + } +} diff --git a/docs/adr/010-local-working-store-at-rest-encryption.md b/docs/adr/010-local-working-store-at-rest-encryption.md new file mode 100644 index 0000000..dc02e0a --- /dev/null +++ b/docs/adr/010-local-working-store-at-rest-encryption.md @@ -0,0 +1,147 @@ +# ADR-010: Local Working-Store At-Rest Encryption (Per-Platform Model) + +**Status**: Accepted +**Date**: 2026-07-27 +**Decision makers**: @kafkade + +## Context + +A vault is a sealed, zero-knowledge container: the `.ldgr` file and every sync blob are +AES-256-GCM encrypted, and the server never sees plaintext (ADR-001, ADR-004). But to *use* a +vault, each platform decrypts it into a working **SQLite** database and runs queries against +that live store. If that working store is written to disk in plaintext, an attacker with +filesystem access to a **locked** vault (another local user, a stolen locked laptop, an iOS +backup, a browser profile on shared hardware) can read account names, transaction descriptions, +amounts, and commodities directly — defeating the point of encrypting the vault at rest. + +Two efforts closed this gap: + +- **#295** encrypted the CLI working store with SQLCipher and moved the unlocked session key out + of a plaintext `session.json` into the OS keystore. It added the reusable core primitives + (`crypto::derive_db_key`, `crypto::DatabaseKey::to_pragma_hex`) and the `ldgr migrate` command. +- **#315** (this ADR) extends the same at-rest guarantee to the **iOS/FFI** path and formally + documents that the **web/WASM** path was already encrypted at rest by design. + +The platforms have materially different storage substrates, so a single mechanism does not fit +all of them. This ADR records the **per-platform** model and why each choice is correct. + +### Constraints + +- `ldgr-core` is zero-I/O and WASM-safe (ADR-005). The key-derivation primitive lives in core; + all file I/O (opening the store, SQLCipher `PRAGMA`, migration) lives in the platform crates + (`ldgr-cli`, `ldgr-ffi`) — never in core. +- The `core` WASM bundle must stay < 2 MB gzip (ADR-005). rusqlite/SQLCipher/OpenSSL must **not** + enter the WASM dependency graph. +- watchOS cannot build vendored OpenSSL: `openssl-src` cannot configure OpenSSL for + `aarch64-apple-watchos`. + +## Decision + +### 1. Derive the database key from the vault key, in core + +All keyed platforms use the same subkey derivation, exposed by `ldgr-core`: + +``` +VaultKey ── HKDF-SHA256(info = "ldgr-sqlcipher-key-v1") ──▶ DatabaseKey (32 bytes) +``` + +`DatabaseKey::to_pragma_hex()` renders the raw-key SQLCipher form `x'<64 hex>'`, so SQLCipher +uses the derived bytes directly and skips its own PBKDF2 (we already derive from the Argon2id +key chain). `DatabaseKey` is `Zeroize`/`ZeroizeOnDrop` with a redacted `Debug`. The working +store is therefore keyed by the vault key and can only be opened by an unlocked vault — never by +password alone at the file level, and never by the server. + +### 2. Per-platform at-rest mechanism + +| Platform | Working store | At-rest mechanism | +|----------|---------------|-------------------| +| **CLI** (`ldgr-cli`) | `vault.db` on disk | SQLCipher keyed file (`PRAGMA key`), rusqlite `bundled-sqlcipher-vendored-openssl` | +| **iOS/iPadOS** (`ldgr-ffi`) | `vault.db` on device | SQLCipher keyed file (`PRAGMA key`), same derivation as CLI | +| **watchOS** (`ldgr-ffi`) | *none* | **N/A** — the watch app never opens a vault DB (see Decision 4) | +| **Web** (`ldgr-wasm`) | in-memory `sql.js` | No on-disk DB at all; only the **sealed vault container** is persisted (see Decision 3) | + +For CLI and iOS the keyed open is a single shared shape (`ldgr-cli`'s `db::open_encrypted`, +`ldgr-ffi`'s `open_encrypted_db`): open the connection, apply the raw-key `PRAGMA key`, then +force a page read (`SELECT count(*) FROM sqlite_master`) so a wrong key or an unmigrated +plaintext store fails immediately with a clear error rather than at the first query. + +We deliberately do **not** enable `PRAGMA cipher_memory_security`. #295 removed it because its +process-global `mlock`/`VirtualLock`-on-every-allocation hook exhausts the small default Windows +working-set quota (surfacing as a spurious `STATUS_STACK_OVERFLOW`). At-rest confidentiality +comes entirely from `PRAGMA key` encrypting the file on disk, not from locking in-memory pages. + +### 3. Web is encrypted at rest *by construction*, not by SQLCipher + +The web app runs SQLite via `sql.js`, which is **in-memory only** (the database lives in the +WASM heap and is never written to disk). Persistence works differently and was designed this way +from the start (the "hold-in-memory + re-seal" model): + +1. `db.export()` produces the plaintext sql.js bytes **in memory**. +2. Those bytes are placed into the vault as an item and `serializeVault()` seals the whole vault + with AES-256-GCM (the core container crypto). +3. Only the **sealed** vault blob is written to IndexedDB (`ldgr-vault/vaults`). + +The plaintext `db.export()` bytes never reach disk — they are always wrapped inside the sealed +container first. The sync session token and the 2SKD Account Secret Key live in the sql.js +`sync_state` table, so they are sealed along with the ledger. `localStorage` holds only the +non-secret theme preference; `sessionStorage` holds only the ephemeral admin-panel bearer token +(in-memory session, cleared on sign-out/tab close, deliberately never persisted). There is no +Cache API / OPFS / filesystem write path for ledger data. + +**Consequence:** the web path must **not** add rusqlite/SQLCipher/OpenSSL — doing so would break +the WASM budget (ADR-005) for zero at-rest benefit, since nothing plaintext is persisted. A +regression test asserts the persisted IndexedDB blob is ciphertext (no `SQLite format 3` magic, +no known plaintext tokens) and that `localStorage`/`sessionStorage` carry no vault secrets. + +### 4. watchOS is out of scope for at-rest (no vault on the watch) + +The watch app links only `LdgrShared` and receives pre-computed summaries over +WatchConnectivity; it never imports `LdgrSwift`/`LdgrFFI` and never opens a vault database. There +is no working store on the watch to encrypt. Because `openssl-src` cannot cross-compile for +`aarch64-apple-watchos`, `ldgr-ffi` scopes its rusqlite features **per target**: non-watchOS +Apple/desktop targets use `bundled-sqlcipher-vendored-openssl`; the watchOS slice uses plain +`bundled` SQLite (kept in the XCFramework, but it never keys or opens a store). + +> ⚠️ On plain SQLite the `PRAGMA key` is **silently ignored** and the store would be written in +> plaintext. The SQLCipher feature must therefore be genuinely enabled on every target that +> actually opens a vault (CLI, iOS device/simulator, macOS). This is enforced by the per-target +> feature scoping above plus an at-rest byte-level test. + +### 5. Migration is explicit and reversible, on every keyed platform + +Legacy plaintext `vault.db` files from prior versions are upgraded by the same procedure on CLI +and iOS: detect the 16-byte `SQLite format 3\0` header → `ATTACH` a freshly keyed database and +`sqlcipher_export` into it → verify the copy preserves the schema version and every table's row +count → atomically swap it in, keeping the original as `vault.db.plaintext.bak` for backout. + +Migration is **user-driven**, never silent: the CLI's `ldgr unlock` only *detects* and instructs; +`ldgr migrate` performs it. On iOS the FFI exposes `needs_migration()` (header check, no unlock) +plus `migrate(password)` and `migrate_with_session_key(key)` (biometric-upgrade path); the app +runs migration explicitly before opening. Web needs no migration — it was never plaintext at rest. + +## Consequences + +### Positive + +- The at-rest guarantee now holds on CLI, iOS/iPadOS, and web with a single shared key + derivation and a documented, testable model per platform. +- The WASM budget is preserved: web stays SQLCipher-free because it never needs it. +- Migration is safe (verified, reversible) and never surprises the user. + +### Negative / trade-offs + +- iOS device/simulator/macOS builds now compile vendored OpenSSL + SQLCipher, increasing FFI + build time and requiring explicit Apple deployment targets at link time + (`IPHONEOS_DEPLOYMENT_TARGET`/`MACOSX_DEPLOYMENT_TARGET`) so vendored-OpenSSL objects that + reference `___chkstk_darwin` link cleanly. +- The at-rest mechanism is intentionally **not uniform** across platforms (keyed SQLCipher file + vs. sealed in-memory container). This ADR is the reference for why that asymmetry is correct. + +## Related + +- ADR-001 (source of truth), ADR-004 (data model), ADR-005 (platform boundaries / WASM budget), + ADR-008 (2SKD Secret Key lives in `sync_state`, so it is sealed with the ledger). +- Issues #295 (CLI at-rest + core primitives + `ldgr migrate`) and #315 (iOS/FFI + web + verification). +- This corresponds to the private security assessment's draft at-rest-encryption ADR + (numbered 013 in that draft); the repo's next-sequential number is 010.