kdf: version Argon2id profiles (legacy stores still open) and raise the default to 64 MiB / t=3 #194

Merged
sirius merged 2 commits from onionwire/t_fb6ffa87-strengthen-and-version-the-store-backup into main 2026-10-26 16:13:09 +00:00
Owner

What

The store and identity backup shared one hard-coded Argon2id profile (m=19456 KiB, t=2, p=1) that was not recorded anywhere, so changing it would have bricked every existing store and backup. This adds a versioned profile and raises the default to m = 65_536 KiB (64 MiB), t = 3, p = 1, out = 32.

  • KdfProfile { Legacy = 0, Current = 1 } in src/backup.rs; kdf() is now Current.
  • Backups: seal() writes owbak1 || profile(1) || salt(16) || nonce(12) || ct. The pre-profile layout (exactly SEALED_BODY_LEN bytes after MAGIC) is read as Legacy. An unknown profile id is a clear error, never a fallback. MAGIC and SALT_LEN are unchanged.
  • Stores: store_meta gains kdf_profile INTEGER NOT NULL DEFAULT 0 (additive migration). Legacy rows are re-wrapped once on a successful unlock — new kdf_salt, new wrapped_key, kdf_profile = 1 in a single transaction. New stores insert profile 1 directly.
  • docs/THREAT_MODEL.md now states the new params and that profiles are versioned.

Spec note: the SQLCipher file key needs its own detection

store_meta.kdf_profile cannot say which profile keyed the SQLCipher file, because store_meta is inside that file. The file key is still the raw kdf(passphrase, store_salt) (no second KDF — the Locks hold). A legacy store is therefore detected by trial-opening with the legacy profile, then rekeyed to the current profile with PRAGMA rekey in one SQLCipher transaction (it rolls back on any page-write failure). WAL is checkpointed first so no old-key frames survive the rewrite. This is one-shot: after the rekey the next start takes the current path, and the legacy key no longer opens the file (asserted in the test).

store_salt stays 16 bytes; SALT_LEN stays 16.

Tests (new)

src/backup.rs:

  • legacy-profile blob still opens (hand-built pre-profile layout)
  • new blob records profile 1 and round-trips
  • wrong passphrase on a current-profile blob → exact existing wrong passphrase or corrupt backup
  • unknown profile id → unknown kdf profile 9, no fallback
  • trailing garbage rejected

src/store.rs (at_rest):

  • legacy store (legacy file key + legacy wrap, profile 0) opens; message survives; store_meta flips to profile 1 with a changed salt and wrapped key; the file is rekeyed (current key opens, legacy key fails); a second unlock does not re-wrap again
  • unknown store_meta.kdf_profile is a clean error

Verification

  • cargo test --locked --target x86_64-unknown-linux-gnu → EXITCODE=0; lib: 168 passed; 0 failed; 2 ignored; all integration binaries green (incl. two_node, wallet, store, backup).
  • cargo clippy --locked --all-targets --target x86_64-unknown-linux-gnu -- -D warnings → rc=0.
  • rustfmt --edition 2024 --check src/backup.rs src/store.rs → clean.

Release-build timings on this host (--release --ignored measurement tests):

legacy m=19456 t=2 p=1: 0.018s
current m=65536 t=3 p=1: 0.095s
first open (create): 0.795s
reopen, current profile: 0.354s
reopen, legacy store (open + rekey + rewrap): 0.528s
reopen after upgrade: 0.464s

Well under the ~1.5 s ceiling, so the 64 MiB profile stays.

No dependency changes. Scope limited to src/backup.rs, src/store.rs, docs/THREAT_MODEL.md.

## What The store and identity backup shared one hard-coded Argon2id profile (m=19456 KiB, t=2, p=1) that was not recorded anywhere, so changing it would have bricked every existing store and backup. This adds a versioned profile and raises the default to **m = 65_536 KiB (64 MiB), t = 3, p = 1, out = 32**. - `KdfProfile { Legacy = 0, Current = 1 }` in `src/backup.rs`; `kdf()` is now Current. - **Backups**: `seal()` writes `owbak1 || profile(1) || salt(16) || nonce(12) || ct`. The pre-profile layout (exactly `SEALED_BODY_LEN` bytes after `MAGIC`) is read as Legacy. An unknown profile id is a clear error, never a fallback. `MAGIC` and `SALT_LEN` are unchanged. - **Stores**: `store_meta` gains `kdf_profile INTEGER NOT NULL DEFAULT 0` (additive migration). Legacy rows are re-wrapped once on a successful unlock — new `kdf_salt`, new `wrapped_key`, `kdf_profile = 1` in a single transaction. New stores insert profile 1 directly. - `docs/THREAT_MODEL.md` now states the new params and that profiles are versioned. ## Spec note: the SQLCipher file key needs its own detection `store_meta.kdf_profile` cannot say which profile keyed the SQLCipher file, because `store_meta` is inside that file. The file key is still the raw `kdf(passphrase, store_salt)` (no second KDF — the Locks hold). A legacy store is therefore detected by trial-opening with the legacy profile, then **rekeyed** to the current profile with `PRAGMA rekey` in one SQLCipher transaction (it rolls back on any page-write failure). WAL is checkpointed first so no old-key frames survive the rewrite. This is one-shot: after the rekey the next start takes the current path, and the legacy key no longer opens the file (asserted in the test). `store_salt` stays 16 bytes; `SALT_LEN` stays 16. ## Tests (new) `src/backup.rs`: - legacy-profile blob still opens (hand-built pre-profile layout) - new blob records profile 1 and round-trips - wrong passphrase on a current-profile blob → exact existing `wrong passphrase or corrupt backup` - unknown profile id → `unknown kdf profile 9`, no fallback - trailing garbage rejected `src/store.rs` (`at_rest`): - legacy store (legacy file key + legacy wrap, profile 0) opens; message survives; `store_meta` flips to profile 1 with a changed salt and wrapped key; the file is rekeyed (current key opens, legacy key fails); a second unlock does not re-wrap again - unknown `store_meta.kdf_profile` is a clean error ## Verification - `cargo test --locked --target x86_64-unknown-linux-gnu` → `EXITCODE=0`; lib: `168 passed; 0 failed; 2 ignored`; all integration binaries green (incl. two_node, wallet, store, backup). - `cargo clippy --locked --all-targets --target x86_64-unknown-linux-gnu -- -D warnings` → rc=0. - `rustfmt --edition 2024 --check src/backup.rs src/store.rs` → clean. Release-build timings on this host (`--release --ignored` measurement tests): ``` legacy m=19456 t=2 p=1: 0.018s current m=65536 t=3 p=1: 0.095s first open (create): 0.795s reopen, current profile: 0.354s reopen, legacy store (open + rekey + rewrap): 0.528s reopen after upgrade: 0.464s ``` Well under the ~1.5 s ceiling, so the 64 MiB profile stays. No dependency changes. Scope limited to `src/backup.rs`, `src/store.rs`, `docs/THREAT_MODEL.md`.
kdf: version Argon2id profiles and raise the default to 64 MiB / t=3
All checks were successful
ci / test (pull_request) Successful in 29m38s
ci / sdk (pull_request) Successful in 2m7s
8f959e937f
The store and identity backup shared one hard-coded Argon2id profile
(m=19456 KiB, t=2, p=1) with nothing recorded on disk, so changing it
would have bricked every existing store and backup.

Record the profile with the data and raise the default:
- KdfProfile { Legacy = 0, Current = 1 }; Current is m=65536 KiB,
  t=3, p=1, out=32.
- Backups: seal() writes a profile byte after owbak1. Legacy blobs
  (exactly SEALED_BODY_LEN after MAGIC) still open; an unknown id is
  an error, never a fallback.
- Stores: store_meta gains kdf_profile (additive, default 0). The
  SQLCipher file key has no place to record a profile before the db
  opens, so a legacy file is detected by trial-opening with the
  legacy profile, then rekeyed to Current in one SQLCipher
  transaction; store_meta is re-wrapped (salt + ciphertext + profile)
  in a single transaction on the first successful unlock. Both are
  one-shot: the next start takes the current path.
- docs/THREAT_MODEL.md states the new params and the versioning.

Measured in release on this host: kdf legacy 0.018s / current 0.095s;
Store reopen current 0.354s, legacy upgrade path 0.528s.
Merge branch 'main' into onionwire/t_fb6ffa87-strengthen-and-version-the-store-backup
Some checks failed
ci / sdk (pull_request) Has been cancelled
ci / test (pull_request) Has been cancelled
4cf0a09ceb
sirius merged commit 4e8342b2d8 into main 2026-10-26 16:13:09 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
sirius/onionwire!194
No description provided.