feat(platform-wallet)!: layered opt-in secret protection (Tier-2 password envelope)#3953
Conversation
…opt-in, default-off) Task 1 of the layered secret-protection feature. - `SecretString::is_blank()` — always-on inherent method, trims via the Unicode White_Space property (NBSP blanks, ZWSP does not). The enforcement primitive for the Tier-1 blank-passphrase guard and the Tier-2 blank-object-password reject. - Manual `Deserialize` behind the dedicated default-off `secret-serde` feature: routes the owned `String` through `SecretString::new` so the transient plaintext is zeroized; documents the unavoidable deserializer-input-buffer residual. NO `Serialize` companion. - Manual `JsonSchema` behind default-off `secret-schemars`: renders a plain `string`, no minLength/maxLength/pattern/format/example/default — no length or value policy leak (F-7). Hand-written (no derive), so the lock gains no `schemars_derive`. - `MIN_PASSPHRASE_LEN = 1` (coarse floor; real entropy policy is the consumer's, per GAP-012). - Cargo features: `secret-serde = ["secrets","dep:serde"]`, `secret-schemars = ["secret-serde","dep:schemars"]`; `default` unchanged (excludes both); `secrets` does NOT pull them. The gates are on the IMPLS not the dep, so they are satisfiable default-off even with `secrets` (and serde) on (GAP-002). Tests (TS-SER-001..008): is_blank truth table incl. Unicode boundary; bool-no-borrow signature; compile-time no-Serialize/no-Display assertion; GAP-002 regression (Deserialize ABSENT under `secrets` alone, PRESENT under `secret-serde`); zeroizing-roundtrip Deserialize; schema-shape leak guard. Green under default, `--features secret-serde`, `--features secret-schemars`. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…ction
Task 2 of the layered secret-protection feature.
Add five `SecretStoreError` variants with redacted (secret-free) Display
and `keyring_core::Error` projections:
- `ExpectedProtectedButUnsealed` — L-1 keystone: caller asserted protection
(supplied a password) but the stored value is unprotected → fail closed.
- `NeedsPassword` — protected (scheme-1) read with no password; never
returns ciphertext.
- `WrongPassword` — Tier-2 object-password AEAD tag fail; distinct from
the Tier-1 `WrongPassphrase`.
- `BlankPassphrase` — blank vault passphrase or blank object password.
- `UnsupportedEnvelopeVersion { found: u8 }` — magic present, unknown
version/scheme; fail closed regardless of password (GAP-009).
Projections (resolving GAP-004): the four credential/protection STATE
errors ride `NoStorageAccess(boxed)` — losslessly downcast-recoverable,
mirroring `WrongPassphrase`; `UnsupportedEnvelopeVersion` joins the
secret-free `BadStoreFormat` group, mirroring `VersionUnsupported`.
Tests (TS-ERR-001..003): variants distinct (incl. WrongPassword !=
WrongPassphrase, ExpectedProtectedButUnsealed != Corruption); exact
secret-free Display; recoverable NoStorageAccess downcast for the four
states; BadStoreFormat for the version variant.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
Task 3 of the layered secret-protection feature. New `secrets::envelope`
module — backend-independent, sits above SecretStore over both arms.
Wire format: magic b"PWSEV" + ENVELOPE_VERSION:u8 + scheme:u8
(0 unprotected passthrough / 1 Argon2id+XChaCha20-Poly1305 password);
scheme-1 body = kdf(id,m_kib,t,p LE) + salt[32] + nonce[24] + ct+tag.
Reuses crypto::{derive_key,seal,open,random_bytes,KdfParams,enforce_bounds}
(no bespoke crypto); `crypto`/`format` widened to pub(super) so the
sibling envelope module can share them without duplication.
Guardrails:
- L-2: KDF param ceiling enforced BEFORE derivation on the untrusted
header (enforce_bounds gates pre-alloc).
- L-3: AAD binds domain ‖ magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖
wallet_id ‖ label (length-prefixed), mirroring format::aad/verify_aad —
relocation/header-tamper fail the tag.
- SEC-F006/GAP-006: plaintext capped at MAX_SECRET_LEN−MAX_ENVELOPE_OVERHEAD
(=65408), uniform across schemes, so enveloped bytes always fit the
backend cap. Re-exported as pub `MAX_PLAINTEXT_LEN`.
- GAP-009: magic-present unknown version/scheme → UnsupportedEnvelopeVersion,
fail closed regardless of password.
- Legacy-tolerant read (adopted §4.1 contingency): magic-less + None →
raw bytes (+ one-time warn, re-wrapped on next write); magic-less +
Some(pw) → ExpectedProtectedButUnsealed (L-1 preserved).
The strict fail-closed quadrant lives in `unwrap` (the L-1 keystone is
proven against it and wired into the store in the next task).
Tests (TS-ENV-001..010): scheme-0/1 round-trips, fresh salt+nonce,
WrongPassword, identity-AAD relocation rejection, per-field header tamper,
★ KDF-ceiling-before-derive (no OOM), blank-password reject, plaintext
size cap (v5 boundary), magic/version discrimination, and a 2000-iteration
deterministic byte-fuzz + full truncation sweep that never panics and
never leaks plaintext from a tag-failing branch.
NOTE: a scoped `#![allow(dead_code)]` covers the not-yet-wired primitives;
the next task wires them into SecretStore and removes it.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…eystone) Task 4 of the layered secret-protection feature — THE load-bearing control. Wires the envelope's strict, fail-closed quadrant into the store read path over BOTH arms. - `SecretStore::get_secret(service, label, password: Option<&SecretString>)` reads the opaque backend bytes via a new shared `get_raw` seam, then runs `envelope::unwrap`. The "expected-protected" bit lives SOLELY in the caller's `Some/None` argument — never inferred from the stored blob, and never persisted by the library. `get` is refactored to delegate to `get_raw` (behaviour unchanged). - GAP-005 fixture: `secrets::testing::InMemoryCredentialStore`, a writable in-memory `CredentialStoreApi` mock (gated on cfg(test)/`__test-helpers`) with a `raw_overwrite` attacker primitive, so the Os arm — where the L-1 residual bites hardest (§8.3) — and the File re-seal-under-vault-key strip are both coverable in CI. Tests (TS-L1-001..006), each parameterised over File AND Os: - ★ TS-L1-002 strip-injection (non-vacuous): a protected scheme-1 object is overwritten with a well-formed scheme-0 blob carrying a DIFFERENT seed; get_secret(Some(pw)) ⇒ ExpectedProtectedButUnsealed, the attacker seed is NEVER returned — and the same blob WOULD decode to S_evil under None, proving the refusal is the strict rule, not malformation. - Full quadrant; both DET-bug directions fail closed; expectation never inferred from the scheme byte; upgrade-confusion is DoS-only; in-place 1→0 scheme flip (Some fails closed; None GAP-010 residual pinned). Deviation (documented): magic-less + None → legacy raw bytes (adopted §4.1 contingency), not Corruption as the v4 TS-L1-001 row read; magic-less + Some(pw) still fails closed, so L-1 is intact. `!`: get_secret is additive, but the strict read changes how a password-supplied read of a non-enveloped slot behaves (now refuses). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
Task 5 of the layered secret-protection feature. - `SecretStore::set_secret(service, label, secret, password: Option<&SecretString>)` wraps via the envelope ABOVE the backend (the backend stores only the opaque envelope — ciphertext for a protected object) and writes through a shared `put_raw` seam over BOTH arms. - `set`/`get` are reimplemented as non-breaking `..,None` wrappers (signatures unchanged); `get` now routes through the strict `get_secret`. - `reprotect(service, label, current, new)` — the canonical add/change/ remove flow as one same-slot unwrap→rewrap→overwrite; reads under the `current` expectation (a strip is caught fail-closed before any rewrite), then re-writes under `new`. The atomic put leaves the prior value intact on a crash. - `envelope::wrap` now returns a zeroizing `SecretBytes` (symmetric with `unwrap`): a scheme-0 envelope embeds plaintext, so the wire bytes are mlock'd/wiped by construction rather than living in a bare `Vec`. Tests (TS-PW-001..005, TS-ARM-003, TS-T1-005), parameterised over File and Os: full enrol→change→remove lifecycle; no-recovery (lost password bricks the object, fail closed both ways); set/get `None`-wrapper round-trip + scheme-0 proof; Os-arm round-trip unaffected by the blank guard; and ★ TS-PW-004 [File] crash-safety — a disk-write failure mid-change leaves the OLD protected value intact and readable, no half-rotated state. 137 secrets unit tests + secrets integration tests green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…unprotected + docs Task 6 (final) of the layered secret-protection feature. - `EncryptedFileStore::open` and `rekey` now reject a blank (empty / all-whitespace) passphrase with `BlankPassphrase` via `is_blank` — a blank passphrase derives a key from a public salt only (obfuscation, not confidentiality; closes SEC-A / F-1). INTENDED behavioural break for any caller that relied on `SecretString::empty()`. - `EncryptedFileStore::open_unprotected(path)` / `SecretStore::file_unprotected(path)` — the explicit, named keyless door (AC-2.1 maps here, not to `open`). Used for the empty→real `rekey` migration or hosts where secrets carry their own Tier-2 password. - `reject_weak_passphrase` wires the coarse `MIN_PASSPHRASE_LEN` floor (1 = non-blank); real entropy policy is delegated to the consumer (GAP-012). - SECRETS.md: new "Two-tier secret protection" section — the model, the envelope wire format, which tier defeats which adversary, the strict fail-closed read + the caller-DB anti-downgrade dependency, the value-rollback non-defence, add/change/remove + no-recovery, entropy-policy delegation, greenfield/legacy tolerance, `open_unprotected` caveat, the `MAX_PLAINTEXT_LEN` cap; plus the five new error variants and their projections. Tests (TS-T1-001..004,006): blank rejected at open (no file/lock created) and at rekey (vault unchanged); open_unprotected keyless round-trip + real-pass open → WrongPassphrase; empty→real rekey migration; ★ rekey crash-safety leaves the pre-rekey keyless vault intact. 142 secrets unit tests + integration green; clippy clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…otate v5 deviations Post-review polish addressing the lead's dedup self-scan + QA annotation requirements. Dedup (prefer-established-packages): the GAP-005 Os-arm fixture was a bespoke `secrets::testing::InMemoryCredentialStore` (182 LOC). keyring-core 1.0.0 already ships `keyring_core::mock::Store` — not feature-gated, returns an `Arc<Store>` usable directly as `SecretStore::Os(..)`, and `build()` returns the SHARED `Arc<Cred>` per `(service, user)` so a raw SPI `set_secret` (the backend-write attacker primitive) persists across the fresh entry `get_secret` builds. Replaced the custom mock with it and removed `testing.rs` and its `pub mod testing` gate. The L-1 strip-injection and full quadrant still pass on the Os arm (now via the upstream mock). QA annotations (v5 design supersedes Marvin's v4 test-spec wherever it overrides): the three adapted tests now name the superseding v5 clause in the body — TS-ENV-008 (v5 §4.6: plaintext cap = MAX_SECRET_LEN − MAX_ENVELOPE_OVERHEAD, not MAX_SECRET_LEN), TS-ENV-010(a) and the TS-L1-001 quadrant row (v5 §4.1 legacy-tolerant: magic-less + None → bytes+warn, not Corruption; + Some(pw) still fails closed so L-1 holds). 142 secrets unit + integration tests green; clippy clean; all test targets compile. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…-ID scrub Consolidated QA-wave fix batch, part 1 (docs/comments only). - No-recovery + entropy-delegation warnings added to `set_secret` AND `reprotect` rustdoc (lost object password = permanently unrecoverable; Tier-2 confidentiality rests on caller-supplied password entropy, strength policy is the caller's). - SECRETS.md: new paragraph that an OS-arm envelope tag failure is ambiguous (wrong password OR corrupted keychain item), resolving the `WrongPassword` rustdoc's forward reference; added `UnsupportedEnvelopeVersion` to the itemized BadStoreFormat group; added the `None` + truncated-header → `Corruption` row to the strict-read table; documented `reprotect`'s absent-entry no-op. - `SecretStore` enum doc corrected: reads are `get` / `get_secret` / the read inside `reprotect`, not "only `get`". - `BlankPassphrase` Display now points to `open_unprotected` for the deliberate keyless-vault case. - Softened "atomic on both arms" wording: the File arm is the vault's atomic replace; the Os arm inherits the backend's single-item-replace contract. - Ephemeral-ID scrub: removed session-internal finding/spec/clause IDs (SEC-*, GAP-*, TS-*, L-1/2/3, F-*, AC-*, §x.y) and v4→v5 history narration from all committed comments/rustdoc and SECRETS.md, keeping the technical rationale. Traceability belongs in the PR description, not the source. No production logic changed. clippy --lib --tests clean; 142 secrets unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…t, test gaps Consolidated QA-wave fix batch, part 2 (defense-in-depth + test coverage). - Os-arm read bound: `get_raw` rejects a backend blob larger than `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD` BEFORE it reaches the envelope parse/derive path (`SecretTooLarge`), mirroring the File arm whose stored bytes are already capped by `put_bytes`. The Os backend has no such ceiling; a legitimate envelope never approaches the cap. - Os crash test: a backend failure during the rewrite's write (after the read succeeds) leaves the OLD value intact — no half-rotation. Uses the upstream mock's one-shot error injection, with reprotect's read/write split so the failure lands on the write. Test gaps closed: - `SecretString::new` source-wipe: verifies the `String::zeroize` primitive `new` applies + faithful content copy (the freed-source scan would be use-after-free and the crate forbids `unsafe`). - scheme-1 accepts a plaintext at EXACTLY `MAX_PLAINTEXT_LEN` (the accept boundary) and round-trips; enveloped bytes still fit the backend cap. - value-rollback non-defence pinned: an older valid envelope still decrypts cleanly under the current password (so nobody mistakes the strict read for rollback protection). - the default-on export test references the re-exported `MAX_PLAINTEXT_LEN` and `MIN_PASSPHRASE_LEN`. - a `SecretStore::set`-path variant of the no-plaintext-in-vault test. Skipped (optional, with rationale): extracting the AAD length-prefix idiom into a shared helper — it spans `format.rs` (outside this feature's churn) and the envelope module for a 2-line idiom; the coupling outweighs the win. 147 secrets unit + secrets integration tests green; clippy --lib --tests clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
…urce-wipe
Final QA close-out (two LOWs).
- QA-010: the Os-arm read-size guard in `get_raw` had no test. Add
`os_read_rejects_oversized_blob` — via the mock backend, place a raw blob
one byte over `MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD` into an Os slot and
assert both `get_secret` and the legacy `get` return
`SecretTooLarge { found, max }` with `max == MAX_SECRET_LEN +
MAX_ENVELOPE_OVERHEAD`.
- QA-001: the source-wipe test is a proxy (it can't assert `new()` invokes
`source.zeroize()` under `#![deny(unsafe_code)]`), so pin the call site
with a do-not-remove comment at `secret.rs` `source.zeroize();` and reword
the test doc to state what it actually checks (the `String::zeroize`
primitive + faithful copy), dropping the "exact primitive new applies"
overclaim.
148 secrets unit + integration tests green; clippy --lib --tests clean;
fmt clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157yd3YvWeyckhfQivS9gf7
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 1e70ebd) |
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Tier-2 per-object password envelope is carefully designed: AAD binds identity, KDF bounds enforced pre-derivation, fresh salt/nonce, strict fail-closed read with comprehensive File+Os tests. One blocking asymmetry: blank object passwords are rejected at wrap but not at unwrap, leaving an injection path under backend-write + Some(empty). One hardening suggestion on read-time KDF ceiling.
🔴 1 blocking | 🟡 1 suggestion(s)
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/rs-platform-wallet-storage/src/secrets/envelope.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/envelope.rs:256-267: Reject blank object passwords on protected reads, mirroring the wrap-side invariant
`wrap_with_params` rejects `pw.is_blank()` at line 151 before sealing, but `unwrap_scheme1` accepts `Some(blank)` and derives an Argon2 key from the empty string. Under this PR's threat model the backend bytes are attacker-writable; an attacker who plants a fresh scheme-1 envelope encrypted under the blank password — using the correct `(wallet_id, label)` AAD and a key derived from blank+salt — will produce a blob that decrypts successfully under `Some(SecretString::empty())`. Any caller that forwards an empty prompt value as `Some(pw)` (UI bug, defensive double-call, etc.) then receives attacker-controlled plaintext instead of the typed `BlankPassphrase` failure that the wrap path documents as the crate-wide invariant. Since `Option<&SecretString>` carries the protection assertion across this boundary, validate the inner value at the read boundary too. The fix is one line, and both Codex reviewers independently flagged it.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/envelope.rs:267-278: Consider a per-read ceiling at `default_target()` to bound backend-write OOM/CPU attacks
`unwrap_scheme1` reads KDF params from the attacker-controllable envelope and only gates them against the global `enforce_bounds` ceiling (`ARGON2_MAX_M_KIB = 1_048_576` KiB, `ARGON2_MAX_T = 16`). Because the public `wrap` always uses `KdfParams::default_target()` (64 MiB / t=3), a backend-write attacker can plant a scheme-1 envelope at the bounds maximum and force ~1 GiB allocation plus 16 Argon2 passes per read — substantial on resource-constrained hosts (iOS/embedded) and an availability attack on protected reads. The existing test `kdf_ceiling_enforced_before_derivation` pins ARGON2_MAX as valid, so this is a deliberate design choice rather than a defect; flagging as a suggestion in case the tighter read-time ceiling is desirable defense-in-depth. If the project intends to expose operator-tuned KDF params later, this should remain as-is and the residual should be documented next to the existing KDF-bounds discussion. Fix would be to add `kdf.m_kib > KdfParams::default_target().m_kib || kdf.t > KdfParams::default_target().t => return Err(SecretStoreError::KdfFailure)` immediately after `enforce_bounds()`.
| fn unwrap_scheme1( | ||
| wallet_id: &WalletId, | ||
| label: &str, | ||
| password: &SecretString, | ||
| body: &[u8], | ||
| ) -> Result<SecretBytes, SecretStoreError> { | ||
| if body.len() < MIN_SCHEME1_BODY { | ||
| // The scheme byte says protected, but the body cannot hold a sealed | ||
| // payload — corrupt, not a strip. | ||
| return Err(SecretStoreError::Corruption); | ||
| } | ||
| let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); |
There was a problem hiding this comment.
🔴 Blocking: Reject blank object passwords on protected reads, mirroring the wrap-side invariant
wrap_with_params rejects pw.is_blank() at line 151 before sealing, but unwrap_scheme1 accepts Some(blank) and derives an Argon2 key from the empty string. Under this PR's threat model the backend bytes are attacker-writable; an attacker who plants a fresh scheme-1 envelope encrypted under the blank password — using the correct (wallet_id, label) AAD and a key derived from blank+salt — will produce a blob that decrypts successfully under Some(SecretString::empty()). Any caller that forwards an empty prompt value as Some(pw) (UI bug, defensive double-call, etc.) then receives attacker-controlled plaintext instead of the typed BlankPassphrase failure that the wrap path documents as the crate-wide invariant. Since Option<&SecretString> carries the protection assertion across this boundary, validate the inner value at the read boundary too. The fix is one line, and both Codex reviewers independently flagged it.
| fn unwrap_scheme1( | |
| wallet_id: &WalletId, | |
| label: &str, | |
| password: &SecretString, | |
| body: &[u8], | |
| ) -> Result<SecretBytes, SecretStoreError> { | |
| if body.len() < MIN_SCHEME1_BODY { | |
| // The scheme byte says protected, but the body cannot hold a sealed | |
| // payload — corrupt, not a strip. | |
| return Err(SecretStoreError::Corruption); | |
| } | |
| let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); | |
| fn unwrap_scheme1( | |
| wallet_id: &WalletId, | |
| label: &str, | |
| password: &SecretString, | |
| body: &[u8], | |
| ) -> Result<SecretBytes, SecretStoreError> { | |
| if body.len() < MIN_SCHEME1_BODY { | |
| // The scheme byte says protected, but the body cannot hold a sealed | |
| // payload — corrupt, not a strip. | |
| return Err(SecretStoreError::Corruption); | |
| } | |
| // Mirror wrap's invariant: a blank object password is rejected on read | |
| // as well as enrol, so a backend-write attacker who plants a scheme-1 | |
| // envelope sealed under the blank password cannot inject plaintext to | |
| // a caller that accidentally forwards Some(empty). | |
| if password.is_blank() { | |
| return Err(SecretStoreError::BlankPassphrase); | |
| } | |
| let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); |
source: ['codex']
There was a problem hiding this comment.
Confirmed against current code. wrap_with_params rejects pw.is_blank() at envelope.rs:151, but unwrap_scheme1 (256-286) goes straight from body.len() < MIN_SCHEME1_BODY to decode_kdf → enforce_bounds → derive_key(password, …). No symmetric password.is_blank() guard exists, so the asymmetry you describe IS present: a backend-write attacker who plants a scheme-1 envelope sealed under the blank password with the correct AAD (wallet_id, label, KdfParams::default_target(), attacker-chosen salt) yields plaintext to a Some(SecretString::empty()) read.
Status: unresolved — actual gap, not addressed in HEAD.
Proposed fix is exactly the one-line if password.is_blank() { return Err(SecretStoreError::BlankPassphrase); } immediately after the MIN_SCHEME1_BODY check, mirroring wrap's invariant. Will land in a follow-up commit with a regression test that wraps under blank, then asserts unwrap returns BlankPassphrase (not plaintext, not WrongPassword).
There was a problem hiding this comment.
Still present at 1e70ebd.
| let kdf = decode_kdf(&body[..KDF_FIELD_LEN]); | ||
| // Gate the inflated/unknown header BEFORE any derivation/alloc. | ||
| kdf.enforce_bounds()?; | ||
|
|
||
| let mut salt = [0u8; SALT_LEN]; | ||
| salt.copy_from_slice(&body[KDF_FIELD_LEN..KDF_FIELD_LEN + SALT_LEN]); | ||
| let mut nonce = [0u8; NONCE_LEN]; | ||
| nonce.copy_from_slice(&body[KDF_FIELD_LEN + SALT_LEN..KDF_FIELD_LEN + SALT_LEN + NONCE_LEN]); | ||
| let ciphertext = &body[KDF_FIELD_LEN + SALT_LEN + NONCE_LEN..]; | ||
|
|
||
| let aad = scheme1_aad(&kdf, &salt, wallet_id.as_bytes(), label); | ||
| let key = crypto::derive_key(password, &salt, kdf)?; |
There was a problem hiding this comment.
🟡 Suggestion: Consider a per-read ceiling at default_target() to bound backend-write OOM/CPU attacks
unwrap_scheme1 reads KDF params from the attacker-controllable envelope and only gates them against the global enforce_bounds ceiling (ARGON2_MAX_M_KIB = 1_048_576 KiB, ARGON2_MAX_T = 16). Because the public wrap always uses KdfParams::default_target() (64 MiB / t=3), a backend-write attacker can plant a scheme-1 envelope at the bounds maximum and force ~1 GiB allocation plus 16 Argon2 passes per read — substantial on resource-constrained hosts (iOS/embedded) and an availability attack on protected reads. The existing test kdf_ceiling_enforced_before_derivation pins ARGON2_MAX as valid, so this is a deliberate design choice rather than a defect; flagging as a suggestion in case the tighter read-time ceiling is desirable defense-in-depth. If the project intends to expose operator-tuned KDF params later, this should remain as-is and the residual should be documented next to the existing KDF-bounds discussion. Fix would be to add kdf.m_kib > KdfParams::default_target().m_kib || kdf.t > KdfParams::default_target().t => return Err(SecretStoreError::KdfFailure) immediately after enforce_bounds().
source: ['codex']
There was a problem hiding this comment.
Verified the residual: unwrap_scheme1 calls only kdf.enforce_bounds() (envelope.rs:269) before derive_key. With ARGON2_MAX_M_KIB = 1_048_576 and ARGON2_MAX_T = 16, a backend-write attacker who plants an inflated scheme-1 envelope can drive ~1 GiB / 16-pass Argon2 per protected read on resource-constrained hosts. Public wrap paths all use KdfParams::default_target() (64 MiB / t=3), so a tighter read-time ceiling would not reject any value this crate's own writers produce.
Taking the suggestion. Adding a default_target() ceiling after enforce_bounds() is a strict tightening with no impact on legitimate envelopes and bounds the per-read DoS surface — defense in depth is cheap here. Will land in the same follow-up commit as the blank-pw fix, with a test that pins (a) the new error on inflated headers and (b) kdf_ceiling_enforced_before_derivation still passes for envelopes wrapped at the floor params.
Keeping the wider ARGON2_MAX_* as the absolute ceiling so a later operator-tunable wrap path remains representable; the per-read cap will be KdfParams::default_target() until/unless an operator-tuned wrap surfaces.
There was a problem hiding this comment.
Still present at 1e70ebd.
| /// [`wrap`] with explicit Argon2 `params` (tests use the floor params for | ||
| /// speed; production uses [`KdfParams::default_target`]). `params` is | ||
| /// ignored when `password` is `None`. | ||
| pub(crate) fn wrap_with_params( |
There was a problem hiding this comment.
why not just define some struct and use bincode for envelope?
There was a problem hiding this comment.
Verified against current code (envelope.rs:122 wrap_with_params). The wire format is hand-rolled magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ nonce ‖ ciphertext rather than a #[derive(bincode::Encode)] struct on purpose:
- AAD parity with
secrets/file::format::aad— the on-disk vault binds AAD with explicit length-prefixed LE fields (format::aadat file/format.rs:107). Tier-2 must reproduce that exact byte layout inscheme1_aadso a relocated/confused blob fails the tag; a serde/bincode struct introduces an extra layer (field order, varint length encoding, optional framing) that we'd then have to match in AAD construction anyway. - Forward compatibility — the header is
magic ‖ version ‖ schemeso an unknown version can fail closed at byte index 5 without parsing the rest. A bincode struct would need a sentinel pre-decode anyway, which is whatMAGICalready is. - Fixed bounded overhead —
MAX_ENVELOPE_OVERHEAD = 128is computed from the fixed-size fields, which feedsMAX_PLAINTEXT_LEN. Bincode-encodedVec<u8>carries a runtime length prefix; the overhead would no longer be a compile-time constant.
Leaving as-is — unwrap_scheme1 is ~25 lines and the byte layout is the security boundary.
| /// Build the scheme-1 AAD binding object identity + header, | ||
| /// length-prefixed for the variable fields, mirroring | ||
| /// [`format::aad`](super::file::format::aad)/`verify_aad`. | ||
| fn scheme1_aad( |
There was a problem hiding this comment.
ensure we don't already have that aad implemented in the secrets/file . If we do, prefer reuse over reimplementing
There was a problem hiding this comment.
Checked secrets/file/format.rs::aad (file/format.rs:107) — it builds format_version(u32 LE) ‖ wallet_id_len(u32 LE) ‖ wallet_id ‖ label_len(u32 LE) ‖ label. Tier-2's scheme1_aad needs to bind a different field set: TIER2_DOMAIN ‖ MAGIC ‖ ENVELOPE_VERSION ‖ SCHEME_PASSWORD ‖ encode_kdf ‖ salt_len ‖ salt ‖ wallet_id_len ‖ wallet_id ‖ label_len ‖ label.
The two AADs share the wallet_id+label tail, but the prefixes are intentionally disjoint:
- The vault entry AAD uses
format_versionso a rolled-backFORMAT_VERSIONfails the tag. - The Tier-2 AAD uses
TIER2_DOMAIN(b"PWSEV-TIER2-AAD-v1") plus the envelope magic/version/scheme so a Tier-2 blob can never be confused with a vault entry AAD-bound value (defense against cross-context tag reuse), and the KDF params + salt go into AAD too so header tamper / KDF downgrade surfaces asWrongPassword.
Factoring out the shared wallet_id_len ‖ wallet_id ‖ label_len ‖ label tail into a bind_object_identity(buf, wallet_id, label) helper IS a clean reuse — it saves the duplicated (len_le, bytes) boilerplate without forcing the two AADs to share a prefix they must not share. Will land in the follow-up commit; rejecting the larger 'call format::aad directly' shape because domain-separation requires the prefix to differ.
| let mut buf = String::with_capacity(cap); | ||
| buf.push_str(&source); | ||
| // Do not remove: wipes the moved-in plaintext source before it drops | ||
| // (its freed buffer cannot be scanned in a test under deny(unsafe_code)). |
There was a problem hiding this comment.
if thie deny(unsafe_code) is only an issue in test, you can annotate the test to explicitly allow unsafe for that one test. Don't allow unsafe on production code.
There was a problem hiding this comment.
Re-read the code carefully — production does NOT allow unsafe. secret.rs carries crate-wide #![deny(unsafe_code)] and there is no #[allow(unsafe_code)] anywhere in SecretString::new or its tests (confirmed via grep -n unsafe packages/rs-platform-wallet-storage/src/secrets/secret.rs).
The // Do not remove comment at secret.rs:62-63 is explaining why source.zeroize() runs unconditionally on the moved-in String source: the test secret_string_new_zeroizes_string_source at line 380 needs to PIN that the source-side wipe happens, but a direct scan of the freed buffer would be use-after-free — which would require unsafe, which the crate forbids. So the test instead asserts the primitive (String::zeroize empties a buffer) plus pins the call-site via this do-not-remove comment. The source.zeroize() IS the workaround that keeps the test sound under deny(unsafe_code); it does not require, nor enable, any unsafe in production.
Status: no code change needed — this is a comment misread. If the comment wording is the concern (it's currently terse, and 'cannot be scanned under deny(unsafe_code)' reads as if the test is somehow constrained), I can reword to (// a direct freed-buffer scan would require unsafe, which this crate forbids; the test at line 380 instead pins the String::zeroize primitive and this call site) to make the rationale legible without re-reading the linked test. Let me know which you prefer.
| /// - **change:** `current = Some(old)`, `new = Some(pw_new)`; | ||
| /// - **remove:** `current = Some(old)`, `new = None`. | ||
| /// | ||
| /// An absent object is a no-op (`Ok(())`). The rewrite is the same-slot |
There was a problem hiding this comment.
Confirmed: reprotect (store.rs:251-262) currently returns Ok(()) when the object is absent, and that's documented as 'An absent object is a no-op (Ok(()))' on line 238. The matching no-op behavior also exists for delete (line 266) which is idempotent.
Two paths forward:
(a) Change to Err(NotFound) — would surface programmer error at the boundary, matches what you're asking. Trade-off: a benign race where a parallel delete lands between the caller's list/check and reprotect becomes a hard error the caller must absorb, when today it silently no-ops. Currently absence and deletion-in-flight are indistinguishable (and there's no NotFound variant in SecretStoreError; get_raw returns Ok(None) for absence too).
(b) Keep Ok(()), sharpen the doc — the same-slot reprotect intent is 'enforce the protection state on this object'; if the object is gone, the protection state is vacuously enforced. Race-safe, idempotent, symmetric with delete.
I'd lean (b) for race safety + symmetry, but (a) is the stricter API. If you confirm (a), the follow-up will add SecretStoreError::NotFound, change reprotect's absence branch to return it, and audit delete's no-op for consistency (probably keep delete idempotent and only make reprotect strict — the caller of reprotect has typed 'here is a password I want enforced', so an absent target is meaningful in a way it isn't for delete). Which path do you want?
…-wallet-secret-protection
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental review fb7953e→1e70ebd0: the latest push is an unrelated upstream merge (rehydration/SDK/UI) that does not touch packages/rs-platform-wallet-storage/src/secrets/envelope.rs. No new in-scope defects were introduced. Both prior findings on the Tier-2 envelope read path are carried forward and verified STILL VALID against current HEAD: (1) unwrap_scheme1 accepts Some(blank) even though wrap_with_params rejects it, leaving an asymmetry exploitable under this PR's backend-write threat model (blocking), and (2) unwrap_scheme1 only enforces the wide global KDF ceiling, allowing a backend-write attacker to force ~1 GiB / t=16 Argon2 work per read (defense-in-depth suggestion).
🔴 1 blocking | 🟡 1 suggestion(s)
No new latest-delta findings were verified. The two findings below are carried forward from the prior fb7953ea review after re-validation against 1e70ebd0. The existing inline threads were updated as still present; this top-level review records the exact-SHA review action.
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/envelope.rs:262-269: [Carried forward from prior review] Reject blank object passwords on protected reads, mirroring the wrap-side invariant
STILL VALID at 1e70ebd0 — the latest push is a merge of unrelated rehydration work and does not touch `secrets/envelope.rs`. Verified against source: `wrap_with_params` rejects `pw.is_blank()` at envelope.rs:151 before sealing, but `unwrap_scheme1` (envelope.rs:256–286) takes `password: &SecretString` and proceeds straight from the body-length check (line 262) to `decode_kdf` / `enforce_bounds` / `crypto::derive_key(password, &salt, kdf)` at line 278 without validating `password.is_blank()`.
Under this PR's stated backend-write threat model, an attacker who can write the stored bytes can plant a fresh scheme-1 envelope encrypted under the blank password — using the correct `(wallet_id, label)` AAD and a key derived from blank+salt — and that blob decrypts successfully when a caller forwards `Some(SecretString::empty())`. Any caller that accidentally forwards an empty prompt value as `Some(pw)` (UI bug, defensive double-call, blank field returned by an unfocused dialog) then receives attacker-controlled plaintext instead of the typed `BlankPassphrase` failure that the wrap path documents as the crate-wide invariant. Because `Option<&SecretString>` is what carries the protection assertion across this boundary, the inner value must also be validated at the read boundary. Six independent reviewers (claude+codex across general/security/rust-quality) converged on this in both the prior and current cycles.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/envelope.rs:267-278: [Carried forward from prior review] Consider a per-read KDF ceiling at `default_target()` to bound backend-write OOM/CPU attacks
STILL VALID at 1e70ebd0 — envelope.rs unchanged in this delta. `unwrap_scheme1` reads KDF params from the attacker-controllable envelope and only gates them against the global `enforce_bounds` ceiling at line 269 (`ARGON2_MAX_M_KIB = 1_048_576` KiB, `ARGON2_MAX_T = 16`). Because the public `wrap` always emits `KdfParams::default_target()` (64 MiB / t=3), a backend-write attacker can plant a scheme-1 envelope at the bounds maximum and force ~1 GiB allocation plus 16 Argon2 passes per protected read before AEAD tag verification fails — substantial on resource-constrained hosts (iOS/embedded) and an availability attack on every `get_secret(.., Some(pw))` call.
The existing test `kdf_ceiling_enforced_before_derivation` pins ARGON2_MAX as the valid edge, so the current ceiling is a deliberate forward-compatibility choice rather than a defect — flagging as defense-in-depth. If operator-tuned KDF params remain out of scope, a minimal hardening would be `if kdf.m_kib > KdfParams::default_target().m_kib || kdf.t > KdfParams::default_target().t { return Err(SecretStoreError::KdfFailure); }` immediately after `enforce_bounds()`. If higher-cost envelopes are intended later, document the residual next to the existing KDF-bounds discussion instead.
Why this PR exists
fix/wallet-core-derived-rehydration).What was done?
A layered, opt-in secret-protection scheme in
packages/rs-platform-wallet-storage:SecretStore::Os) or a passphrase-encrypted file vault (EncryptedFileStore); a real passphrase is required — blank is rejected (BlankPassphrase).open_unprotectedis the explicit keyless door.secrets/envelope.rs):magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ nonce ‖ ct. AAD binds the full identity + header (relocation defense); the KDF cost ceiling is enforced before derivation (DoS defense); plaintext is capped atMAX_PLAINTEXT_LENsoplaintext + overhead ≤ MAX_SECRET_LEN.get_secret(service, label, Option<&SecretString>). ASome(pw)read of a scheme-0 / legacy / malformed blob returnsExpectedProtectedButUnsealed— it never returns the bytes. The "is this object protected?" expectation lives only in the caller'sSome/None(its trusted DB), and is never inferred from the stored blob. This is what closes the strip/downgrade injection.set_secret(.., password)andreprotect(current, new)(add / change / remove a password, crash-safe same-slot rewrite).set/getare reimplemented as non-breaking.., Nonewrappers.SecretStringgainsDeserialize+JsonSchemabehind dedicated default-OFF featuressecret-serde/secret-schemars(noSerialize; schema leaks no value/min/max/pattern), plus an always-onis_blank().crypto::*primitives,SecretBytes/SecretString, and the vault's atomic-write path.How Has This Been Tested?
--features secret-serde188,--features secret-schemars189 — 0 failures.Some(pw)+scheme-0 read fails closed and that the same blob would decode to the attacker seed underNone— proving the strict rule (not malformation) blocks it.cargo auditclean on the crypto stack.cargo fmtclean;cargo clippy -p platform-wallet-storage --lib --testszero warnings.Breaking Changes
open/rekeynow reject a blank passphrase (BlankPassphrase) — the one behavioral break (previously a blank was accepted).ExpectedProtectedButUnsealed,NeedsPassword,WrongPassword,BlankPassphrase,UnsupportedEnvelopeVersion).get_secretis an additive password-aware read; existingget/setcall sites are unchanged (non-breaking wrappers). (Lead commits marked!.)Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent