Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <token>` completes onboarding on a new device (already configured with `ldgr sync setup`), and `ldgr devices remove <id>` 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.
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down
28 changes: 26 additions & 2 deletions apps/ios/ldgr/Sources/Views/UnlockView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""

Expand Down Expand Up @@ -54,7 +55,7 @@ struct UnlockView: View {
if isUnlocking && !appState.isBiometricEnabled {
ProgressView()
.padding(.trailing, 4)
Text("Unlocking…")
Text(isMigrating ? "Encrypting…" : "Unlocking…")
} else {
Text("Unlock")
}
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
155 changes: 155 additions & 0 deletions apps/web/test/at-rest.mjs
Original file line number Diff line number Diff line change
@@ -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',
);
});
});
64 changes: 64 additions & 0 deletions bindings/swift/Sources/LdgrSwift/LdgrClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void, Error>) 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<Void, Error>) 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.
Expand Down
Loading
Loading