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 07579e3) |
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()`.
…-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.
…zes the source The previous 2-line `// Do not remove:` comment above `source.zeroize()` misled a reviewer on PR #3953 into thinking production code somehow allows `unsafe` in tests. It does not — `rs-platform-wallet-storage` declares `#![deny(unsafe_code)]` at the crate root in `src/lib.rs:28` and `src/secrets/secret.rs` has no `#[allow(unsafe_code)]` override. Rewrite the comment to spell out the WHY in 4 lines: a direct freed- buffer scan would need `unsafe` (forbidden here) and would be use- after-free anyway, so the `secret_string_new_zeroizes_string_source` test pins the `String::zeroize` primitive and this call site rather than reading freed memory. The named test makes the comment legible without re-reading the test body. Functional behavior is unchanged (comment-only edit; `cargo test -p platform-wallet-storage --lib secret::tests` count and outcomes are identical to baseline).
…tected unwrap Symmetric guard on the Tier-2 read path. Lands the BLOCKING fix flagged by the Codex / @thepastaclaw review on PR #3953. Wrap rejected `Some(blank)` at enrol (`BlankPassphrase`), but `unwrap_scheme1` accepted `Some(blank)` and ran the full Argon2id + XChaCha20-Poly1305 path — an asymmetry the wrap-side comment documents as the crate-wide invariant but which the read side did not actually enforce. Under this PR's threat model the backend bytes are attacker-writable: a backend-write attacker who plants a fresh scheme-1 envelope sealed under the blank password with the correct `(wallet_id, label)` AAD will produce a blob whose AEAD tag VERIFIES when any caller forwards `Some(SecretString::empty())` to `unwrap`. The caller then receives attacker-controlled plaintext instead of the typed `BlankPassphrase` failure the wrap path advertises. Fix mirrors wrap's guard inside `unwrap_scheme1`, sited AFTER the `MIN_SCHEME1_BODY` length check (the "blob is sealed-but-broken" signal is about bytes, not the password) and BEFORE `decode_kdf` / `enforce_bounds` / `derive_key` so no KDF or AEAD work is done on a blank object password. The guard fires regardless of whether the envelope was attacker-forged or accidentally constructed — the exploit path is implicit, what the contract pins is "blank `Some(...)` never reaches the crypto". Regression test `unwrap_scheme1_rejects_some_blank_password` builds a well-formed scheme-1 envelope under a NON-blank password (so the body is valid), then unwraps with `Some(SecretString::empty())` and pins the result to `BlankPassphrase` — explicitly not `WrongPassword`, not `Decrypt`, not plaintext. Sits next to the existing `blank_object_password_rejected_at_enrol` to keep the two halves of the invariant adjacent. 15 envelope tests pass (was 14); `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` clean; `cargo fmt --check` clean.
…lete and reprotect Two mutators in `SecretStore` were swallowing "absent" silently — neither the caller's invariant check nor the on-disk reality could detect a mismatch. The reviewer (PR #3953, review thread #6, comment id 3480589666) asked for absence to be surfaced. Both arms now do so: - `SecretStore::delete: Result<(), _>` → `Result<bool, _>`. `Ok(true)` on removal, `Ok(false)` on absent. The File arm just forwards the bool from the inner `delete_bytes` (which already returned `Result<bool, _>`); the OS arm maps `Ok(())` → `Ok(true)` and `Err(KeyringError::NoEntry)` → `Ok(false)`. Callers that don't care still write `.delete(...)?;` — the bool drops; race-detecting callers can `match delete()?`. - `SecretStore::reprotect` absent branch: returns `Err(NoEntry)` instead of `Ok(())`. A silent no-op when the target is absent means the caller's trusted protection-status record says "protected" while the backend has nothing — a downgrade-detection blind spot. `reprotect` is operational; absence is a signal, not a no-op. Upstream `keyring-core 1.0.0` already documents `delete_credential` as returning `Err(NoEntry)` for absent credentials (lib.rs:340-358); the file backend's inner `delete_bytes` already returns `Result<bool, _>` (file/mod.rs:397-413). This change stops swallowing both at the `SecretStore` seam. Error API: a top-level `SecretStoreError::NoEntry` is added (backend- agnostic — file arm can hit absence too, so the previous `OsKeyringErrorKind::NoEntry` was the wrong scope). `map_spi` projects `KeyringError::NoEntry` to the new top-level variant; the SPI back-projection maps it to `KeyringError::NoEntry` — round-trippable. The now-redundant `OsKeyringErrorKind::NoEntry` is removed. Tests: `delete_is_idempotent` → `delete_returns_false_on_absent_true_on_present` asserts `Ok(false)` on absent, `Ok(true)` on present, `Ok(false)` on the second delete. New `reprotect_absent_returns_no_entry` asserts the new `NoEntry` error on an empty-store reprotect. No callers outside the storage crate. clippy `--all-targets -D warnings` clean; `cargo fmt --check` clean; `cargo check -p platform-wallet -p platform-wallet-ffi` clean; 149 `secrets` unit tests green. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF
…e scaffold Stand up the new `secrets/wire/` module that owns every byte crossing the AEAD seam under the Tier-2 envelope and the three vault AAD contexts. T-1 is type-only — the encoder/decoder land in the follow-up commits. - `wire/config.rs`: WIRE_CONFIG (`standard().with_big_endian().with_no_limit()`, matching `rs-platform-serialization`), ENVELOPE_VERSION = 1u32, and three pair-wise-disjoint domain tags (`PWSEV-TIER2-AAD-v2`, `PWSV-ENTRY-AAD-v2`, `PWSV-VERIFY-AAD-v2`). - `wire/kdf.rs`: KdfParamsEncoded — the bincode-derived wire image of KdfParams, with infallible From<KdfParams> and a TryFrom that runs enforce_bounds before yielding the in-memory type. - `wire/aad.rs`: Tier2Aad<'a>, EntryAad<'a>, VerifyAad — Encode-only (producer-side) structs with explicit `scheme_discriminant` so the AAD shape is stable under a future Payload re-ordering. - `wire/envelope.rs`: Envelope + Payload (Unprotected / Password) type defs; the wrap/unwrap functions land in T-2/T-3. - Cargo.toml: `dep:bincode` added to the `secrets` feature so the module compiles under `--no-default-features --features secrets`. - secrets/mod.rs: new `mod wire;` (pub(crate) only). Pins TC-014, TC-015, TC-016 (Tier2Aad encode-side field binding), TC-025, TC-026, TC-027 (pair-wise AAD prefix-disjointness), TC-037 (EntryAad binds format_version + wallet_id + label with length-prefix sanity), TC-038 (VerifyAad binds salt + KDF, deterministic), and (via the deny lint at wire module root) TC-039. Refs design-brief.md §2.1, §2.2 (locked module layout + type defs); dev-plan.md T-1.
…goldens
Flesh out `secrets/wire/envelope.rs` with the bincode-encoded `wrap` /
`wrap_with_params` path and pin the wire output against golden hex
vectors plus the runtime `MAX_ENVELOPE_OVERHEAD` cross-check.
- `wrap_with_params` builds an `Envelope { version, payload }` and
bincode-encodes it once against `WIRE_CONFIG`. Scheme-0 ships the
plaintext under `Payload::Unprotected`; scheme-1 derives the Argon2id
key, bincode-encodes a `Tier2Aad`, AEAD-seals under the AAD, and
packs the result into `Payload::Password { kdf, salt, nonce,
ciphertext }`. `crypto::derive_key`/`crypto::seal`/
`crypto::random_bytes` are reused unchanged.
- `MAX_ENVELOPE_OVERHEAD = 112` — the smallest scheme-1 envelope (empty
plaintext sealed → 16-byte AEAD tag) measures 81 bytes; rounded up to
the next 16-byte boundary above the runtime measurement plus a
16-byte safety margin. `MAX_PLAINTEXT_LEN = MAX_SECRET_LEN -
MAX_ENVELOPE_OVERHEAD`, with the cap enforced before any derive.
- `#[cfg(test)] wrap_with_params_for_test` + `crypto::seal_with_nonce`
give the deterministic encoder seam the golden vectors need.
- Golden hex vectors landed as `SCHEME0_GOLDEN_HEX` /
`SCHEME1_GOLDEN_HEX` constants — captured once from the runtime
encoder. A future failure here is a bincode-config-drift signal, not
a re-generation chore.
Pins TC-028 (scheme-0 golden hex), TC-029 (scheme-1 deterministic
golden hex), TC-030 (MAX_ENVELOPE_OVERHEAD runtime cross-check),
TC-033 (blank-pw rejected at wrap-side enrol), TC-034 (plaintext cap
accept/reject for both schemes), TC-035 size-budget half (scheme-1
at-cap envelope fits backend cap). The round-trip half of TC-035 lands
with the decoder in T-3.
Refs design-brief.md §2.5 (encoder), §2.6 (overhead constant);
dev-plan.md T-2.
…tch + fuzz
Land the strict fail-closed `unwrap` + `unwrap_password_payload` in
`secrets/wire/envelope.rs`, decoding via a limited bincode config so a
hostile blob declaring a multi-GiB length prefix is refused before any
allocation.
- `unwrap(wallet_id, label, password, blob)` decodes `Envelope` from
`DECODE_CONFIG` (= `WIRE_CONFIG.with_limit::<MAX_SECRET_LEN +
MAX_ENVELOPE_OVERHEAD>()`); every bincode `DecodeError` collapses to
`Corruption`. `envelope.version` is gated to
`UnsupportedEnvelopeVersion { found: as u8 }` ahead of dispatch.
- The `(payload, password)` table is the strict fail-closed four-arm:
`(Unprotected, None)` -> bytes; `(Unprotected, Some)` ->
`ExpectedProtectedButUnsealed` (strip/downgrade); `(Password, None)`
-> `NeedsPassword`; `(Password, Some)` -> `unwrap_password_payload`.
- `unwrap_password_payload` runs the wider `enforce_bounds` (TC-023)
AND the stricter per-read `default_target` ceiling (TC-024) BEFORE
`derive_key`; AAD is re-built via the same `encode_tier2_aad` the
encoder uses, so the two cannot drift; AEAD `Decrypt` maps to
`WrongPassword` per CWE-347.
- `DECODE_CONFIG` keeps encoding on the canonical `WIRE_CONFIG`
(no-limit, matching `rs-platform-serialization`); only the decode
path bounds reads.
Pins TC-001 (scheme-0 round-trip), TC-002 (scheme-1 round-trip), TC-003
(fresh salt/nonce per wrap), TC-004 (wrong-pw -> WrongPassword), TC-005
.. TC-009 (wire-flip per field), TC-010 (cross-wallet-id), TC-011
(cross-label), TC-012 (version flip), TC-013 (Payload dispatch
redirect), TC-017 (truncated -> Corruption), TC-018 (garbage ->
Corruption), TC-019 (forged v2 envelope), TC-020 (unknown payload tag),
TC-021 (Some+scheme-0), TC-022 (None+scheme-1), TC-023 (KDF ceiling
pre-derive), TC-024 (per-read default_target ceiling), TC-031
(ConstantTimeEq), TC-032 (fuzz never panics), TC-035 round-trip half,
TC-036 (value rollback pinning), TC-040 (proptest single-byte flip).
Refs design-brief.md §2.4 (decoder), §1.1 F6 (per-read ceiling), §3.1
(AC-F1..F8); dev-plan.md T-3.
…acy magic-less path
Wire up the new `secrets/wire/` module as the sole producer of the
Tier-2 envelope wire bytes and the three AAD constructions. The old
hand-rolled `secrets/envelope.rs` (`MAGIC`, `HEADER_LEN`, manual
`encode_kdf` / `decode_kdf` / `scheme1_aad`, `warn_legacy_once`, the
magic-peek dispatch, and the in-module test block) collapses to a thin
~5-line re-export of `wire::envelope::{wrap, wrap_with_params, unwrap,
MAX_PLAINTEXT_LEN, MAX_ENVELOPE_OVERHEAD}`. `store.rs` keeps its public
surface unchanged (`set_secret` / `get_secret` / `reprotect` —
caller-compat).
- `format.rs::aad()` / `verify_aad()` now `bincode::encode_to_vec` an
`EntryAad` / `VerifyAad` against `WIRE_CONFIG`. Domain tags
`ENTRY_DOMAIN_V2` / `VERIFY_DOMAIN_V2` replace the implicit
`VERIFY_WALLET_ID` + `VERIFY_LABEL` disjointness trick. The three
AAD tests in `format.rs` are superseded by the eight in
`wire/aad.rs`.
- `store.rs::run_quadrant` drops the magic-less legacy raw + magic-
present-but-truncated branches: raw non-envelope bytes and a
truncated envelope now both surface as `Corruption` (no legacy
tolerance — A1 + A8 in the design brief).
- `store.rs::run_scheme_flip` flips the scheme via bincode
decode/mutate/re-encode instead of byte-poking at a magic offset.
- `error.rs::ExpectedProtectedButUnsealed` /
`UnsupportedEnvelopeVersion` docstrings drop the stale
magic-less-raw / magic-byte references.
- `wire/mod.rs` drops its scaffolding `#![allow(dead_code)]`; every
pub(crate) item now has a real consumer.
Caller-side compatibility (TC-041): `cargo check -p platform-wallet
-p platform-wallet-ffi --all-targets` is clean — neither crate sees a
surface change at the `SecretStore` boundary. `cargo test
-p platform-wallet-storage --all-targets` passes (451 tests; the new
wire suite adds 40 cases; 3 superseded AAD tests in `format.rs` and
the in-module envelope test block are deleted).
**Wire-format break**: previously stored Tier-2 envelopes from earlier
#3953 commits no longer decode. PR #3953 is already `feat(...)!:`.
Refs design-brief.md §2.3, §2.7, §3.3; dev-plan.md T-4.
…omment Final regression-sweep commit: add the `wire` module pointer to `secrets/mod.rs` rustdoc so audit readers find the bincode-as-wire-format decision, and rephrase the `run_quadrant` raw-bytes branch comment to describe present state instead of the removed magic-less tolerance path (coding-best-practices: no tombstone comments). All four workflow checks land clean on this commit: - `cargo fmt --all -- --check` - `cargo clippy -p platform-wallet-storage --all-targets -- -D warnings` - `cargo test -p platform-wallet-storage --all-targets` — 211 lib + 240 integration + 3 doc tests pass - `cargo check -p platform-wallet -p platform-wallet-ffi --all-targets` — no caller-side change (AC-NF4) Refs dev-plan.md T-5.
…s on wire layer Single follow-up commit on top of the five-commit bincode refactor landed earlier on this branch. Picks up the post-audit findings flagged by the Phase-3 reviewers; no functional change to the public Tier-2 surface (set_secret / get_secret / reprotect / delete unaffected). * PROJ-003 (HIGH) — Rewrite the stale SECRETS.md sections (envelope wire format, AAD construction, strict-read truth table, "Greenfield / legacy entries"). The doc no longer references the deleted PWSEV magic-byte layout, hand-rolled AAD concatenation, or magic-less raw legacy quadrant; it now points at src/secrets/wire/mod.rs and src/secrets/wire/envelope.rs as the wire-format source of truth and names the bincode Tier2Aad / EntryAad / VerifyAad struct shapes. * S3.1 (MEDIUM) — Extend the per-read default_target ceiling in unwrap_password_payload to gate the `t` axis symmetrically with `m_kib`. Closes the CPU-axis gap between ARGON2_MAX_T=16 and the shipped t=3 default (worst-case forged read ~5.3× the shipped iteration count). New test `kdf_t_ceiling_fires_before_derive`. * QA-008 (LOW) — Justify the DECODE_CONFIG asymmetry in its docstring (hostile decode-allocation defense vs. design-brief NF2's no-limit encoder convention), naming this as a security-positive deviation and explaining why the encoder still uses WIRE_CONFIG. * S2.3 (LOW) — Assert `consumed == blob.len()` after bincode::decode_from_slice; refuse trailing bytes as Corruption (truncation/extension probe defense). New test `decode_rejects_trailing_garbage`. * QA-005 (LOW) — Fix the inaccurate `VERIFY_DOMAIN_V2` rustdoc claim of "distinct length from the other two" (TIER2 and VERIFY are both 18 bytes; ENTRY is 17). Rewrite to describe disjointness by content past the common prefix and cite the empirical disjointness tests. Adjacent cleanups: wire/aad.rs module header and Tier2Aad.domain rustdoc had the same length-distinct miscalibration — fixed. PROJ-002 / PROJ-005 / QA-A1 / QA-018 are out of scope per the brief (accepted-with-rationale or design-brief defect, not in this work). Refs PR #3953.
…' into feat/platform-wallet-secret-protection
…nto feat/platform-wallet-secret-protection
…e' into feat/platform-wallet-secret-protection
…d' into feat/platform-wallet-secret-protection # Conflicts: # packages/rs-platform-wallet-storage/src/secrets/envelope.rs
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Both prior findings are FIXED at HEAD a29cbf8: prior-1 (blank-password symmetry on read) is closed by the password.is_blank() guard at wire/envelope.rs:270-272 before any KDF/AEAD work, with regression test unwrap_password_payload_rejects_some_blank_password; prior-2 (per-read KDF ceiling) is closed by the default_target() clamp on BOTH m_kib and t at wire/envelope.rs:283-286, with regression tests covering each axis. One new in-scope documentation finding (codex-general): SECRETS.md still describes the old reprotect no-op semantics for absent objects, but the implementation now returns Err(NoEntry) (pinned by reprotect_absent_returns_no_entry). No new blocking defects in the latest delta.
🟡 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/SECRETS.md`:
- [SUGGESTION] packages/rs-platform-wallet-storage/SECRETS.md:251-255: Update `reprotect` docs to match `NoEntry` semantics on absent objects
The SECRETS.md paragraph still says an absent object is a no-op returning `Ok(())`, but the current `SecretStore::reprotect` implementation intentionally returns `Err(SecretStoreError::NoEntry)` when `get_secret` returns `None` (`secrets/store.rs:262-264`), and the test `reprotect_absent_returns_no_entry` (`store.rs:409-419`) pins that behavior. The rustdoc on `reprotect` (`store.rs:239-242`) is already correct. The PR description calls out this change as closing a downgrade-detection blind spot, so leaving the public design doc with the old no-op contract is misleading: a consumer reading SECRETS.md would treat an absent protected object as harmless instead of as a backend/protection-status disagreement that should not be silently dropped. This is in-scope because the semantic shift is introduced by this PR.
…NoEntry) semantics The reprotect paragraph in SECRETS.md still described an absent object as a no-op returning `Ok(())`, but `SecretStore::reprotect` returns `Err(SecretStoreError::NoEntry)` on absent since `abe77810db` (test `reprotect_absent_returns_no_entry` pins the contract). Public design doc now matches the implementation and the rustdoc. Closes PR #3953 review thread (comment 3481600904 / thepastaclaw). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF
…r shim removal Completes the envelope.rs shim collapse begun in 483e667 (which deleted the 22-line re-export file alongside the SECRETS.md doc fix). The `MAX_PLAINTEXT_LEN` re-export now goes directly from `wire::envelope`, and `secrets::store` imports `wire::envelope` instead of the now-gone `secrets::envelope`. Call sites stay `envelope::wrap` / `envelope::unwrap` since the module is brought into scope by the new `use` path. No public API change; cargo check on `platform-wallet` / `platform-wallet-ffi` clean. Closes PR #3953 review thread (comment 3481683194 / lklimek). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcZFae6ABdoMEvCBQZnaJF
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Incremental delta a29cbf8→07579e3e is a small cleanup: SECRETS.md reprotect paragraph updated to document Err(SecretStoreError::NoEntry) for absent objects, and the thin secrets/envelope.rs shim removed with secrets/mod.rs and secrets/store.rs rewired to import from wire::envelope directly. Prior finding prior-a29-1 is FIXED at HEAD. The one new agent-flagged blocking finding (codex-general: AAD format break) is a false positive — the entire rs-platform-wallet-storage crate does not exist on origin/master, so there are no existing v1 file vaults to strand; the PR's published 'unshipped' rationale applies to the whole crate, not only the Tier-2 envelope.
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/wire/): bincode-encodedEnvelope { version, payload: Payload }wherePayloadisUnprotected(Vec<u8>) | Password { kdf, salt, nonce, ciphertext }. The encoder/decoder shareWIRE_CONFIG = standard().with_big_endian().with_no_limit()for AAD/encode; the decoder uses a derivedDECODE_CONFIGaddingwith_limit::<MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD>()so an attacker-controlled length prefix can't drive multi-GiBVec::with_capacitybefore any tag check.-v2domain tags for byte-disjoint cross-context defense):Tier2Aad(domain ‖ envelope_version ‖ scheme_discriminant ‖ kdf ‖ salt ‖ wallet_id ‖ label),EntryAad,VerifyAad. The KDF cost ceiling is enforced before derivation, and per-read also gated againstKdfParams::default_target()on bothm_kibandtaxes so an inflated header bounded by the globalARGON2_MAX_*ceiling can't drive 16× memory or 5.3× CPU per protected read.get_secret(service, label, Option<&SecretString>). ASome(pw)read of a scheme-0 / 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.unwraprejectsSome(SecretString::empty())withBlankPassphraseBEFORE any KDF/AEAD work — mirroring the existing wrap-sidepw.is_blank()guard. Closes a backend-write attacker plant-under-blank-pw injection path.bincode::decode_from_slicereturns(value, consumed);unwrapassertsconsumed == blob.len()and fails closed otherwise, so a truncation/extension probe surfaces asCorruption.set_secret(.., password)andreprotect(current, new)(add / change / remove a password, crash-safe same-slot rewrite).set/getare reimplemented as non-breaking.., Nonewrappers.deletereturnsResult<bool>(true= removed,false= was absent) andreprotectreturnsErr(NoEntry)on absent — surfaces upstreamkeyring-core::Error::NoEntryinstead of swallowing it, and closes a downgrade-detection blind spot where a silentreprotectno-op left the caller's trusted protection-status record disagreeing with the backend.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, the vault's atomic-write path. Wire format follows the project's standingbincode = "=2.0.1"+standard().with_big_endian().with_no_limit()convention (used byrs-platform-serialization,rs-dpp,rs-drive-proof-verifier, etc.).How Has This Been Tested?
cargo test -p platform-wallet-storage --lib→ 215 passed / 0 failed / 0 ignored.cargo test -p platform-wallet-storage --all-targets(--lib+ 7 integration binaries + 3 doctests) all green.cargo check -p platform-wallet -p platform-wallet-ffi --all-targetsclean — no caller-side API change despite the wire-format refactor.Some(pw)+scheme-0 read fails closed AND the same blob would decode to the attacker seed underNone— proving the strict rule (not malformation) blocks it. Symmetric tests on BOTH the file vault AND the OS-keychain arms.#[cfg(test)]seal_with_nonceseam) — any future bincode-config drift trips them before it can corrupt stored data.proptest = "1", already in the workspace)prop_single_byte_flip_never_yields_plaintextruns a single-byte flip across every field within its 60-second budget.unwrap—decode_rejects_trailing_garbage,kdf_m_kib_ceiling_fires_before_derive,kdf_t_ceiling_fires_before_derive, scheme-0/1 dispatch quadrants, AAD-bind one-byte flip per field.Tier2Aad,EntryAad,VerifyAadare pairwise byte-disjoint.security-engineer-smythe+qa-engineer-marvin+project-reviewer-adamsparallel panel): 1 HIGH + 4 MED + 13 LOW — all MEDIUM+ items folded into commitc4af266e4e. Final verdict ACCEPT.cargo fmt --all -- --checkclean;cargo clippy -p platform-wallet-storage --all-targets -- -D warningszero warnings;cargo auditclean on the crypto stack (RUSTSEC-2025-0141bincode unmaintainedis an INFO-level project-wide notice, no exploit).Breaking Changes
open/rekeynow reject a blank passphrase (BlankPassphrase).SecretStore::deletereturnsResult<bool, SecretStoreError>instead ofResult<(), SecretStoreError>..delete(...)?;still compiles and silently discards the bool for callers that don't care.SecretStore::reprotectnow returnsErr(SecretStoreError::NoEntry)for an absent target instead of a silentOk(())no-op.SecretStoreError::NoEntrypromoted to a top-level variant (was nested underOsKeyringErrorKind::NoEntry).-v2domain tags). Any Tier-2 envelope written by an earlier commit of this PR no longer decodes — acceptable because this PR has not yet shipped.ExpectedProtectedButUnsealed,NeedsPassword,WrongPassword,BlankPassphrase,UnsupportedEnvelopeVersion, top-levelNoEntry.get_secretis an additive password-aware read; existingget/setcall sites are unchanged.Review feedback resolved this round
is_blank()guard onunwrap(nowwire/envelope.rs::unwrap_password_payload).default_target()onm_kibANDt.bind_object_identityhelper, subsumed by the new bincodeTier2Aad/EntryAad/VerifyAadstructs.SecretString::newdeny(unsafe_code)comment.delete: Result<bool>+reprotect: Err(NoEntry)on absent.consumed == blob.len()assert on bincode decode.DECODE_CONFIGasymmetric-config rustdoc justifies the security-positive deviation from the project's standardwith_no_limit().VERIFY_DOMAIN_V2rustdoc corrected (TIER2 and VERIFY are both 18 bytes; disjointness is by content past the common prefix).SECRETS.mdrewritten for the new wire format.Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent