Skip to content

feat(platform-wallet)!: layered opt-in secret protection (Tier-2 password envelope)#3953

Merged
lklimek merged 26 commits into
fix/wallet-core-derived-rehydrationfrom
feat/platform-wallet-secret-protection
Jun 26, 2026
Merged

feat(platform-wallet)!: layered opt-in secret protection (Tier-2 password envelope)#3953
lklimek merged 26 commits into
fix/wallet-core-derived-rehydrationfrom
feat/platform-wallet-secret-protection

Conversation

@Claudius-Maginificent

@Claudius-Maginificent Claudius-Maginificent commented Jun 22, 2026

Copy link
Copy Markdown
Collaborator

Why this PR exists

  • Problem: dash-evo-tool stores wallet secrets — multi-key/seed wallets and single private keys. By default they belong in the OS keychain (or, for the file backend, a passphrase-encrypted vault), but the user had no way to add an extra password to individual critical objects, and the SecretStore had a latent strip/downgrade weakness.
  • What breaks without it: an attacker with write access to the secret backend replaces a password-protected secret with a well-formed unprotected blob carrying a chosen seed. If protection is inferred from the stored blob, the password prompt is bypassed and the attacker's seed is returned to the wallet → funds redirection.
  • Blocking relationship: stacked on refactor(platform-wallet)!: retire in-band pool snapshot; hardcode core UTXO account_index=0 #3828 (fix/wallet-core-derived-rehydration).

What was done?

A layered, opt-in secret-protection scheme in packages/rs-platform-wallet-storage:

  • Tier-1 (baseline, always on): secrets live in the OS keychain (SecretStore::Os) or a passphrase-encrypted file vault (EncryptedFileStore); a real passphrase is required — blank is rejected (BlankPassphrase). open_unprotected is the explicit keyless door.
  • Tier-2 (opt-in, per object): a password envelope (Argon2id + XChaCha20-Poly1305) layered above the SecretStore, backend-independent. Applies to seed wallets and single private keys alike.
  • Envelope wire format (secrets/wire/): bincode-encoded Envelope { version, payload: Payload } where Payload is Unprotected(Vec<u8>) | Password { kdf, salt, nonce, ciphertext }. The encoder/decoder share WIRE_CONFIG = standard().with_big_endian().with_no_limit() for AAD/encode; the decoder uses a derived DECODE_CONFIG adding with_limit::<MAX_SECRET_LEN + MAX_ENVELOPE_OVERHEAD>() so an attacker-controlled length prefix can't drive multi-GiB Vec::with_capacity before any tag check.
  • AAD bind (three separate structs, all bincode-encoded with -v2 domain 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 against KdfParams::default_target() on both m_kib and t axes so an inflated header bounded by the global ARGON2_MAX_* ceiling can't drive 16× memory or 5.3× CPU per protected read.
  • ★ Strict fail-closed read (the keystone): get_secret(service, label, Option<&SecretString>). A Some(pw) read of a scheme-0 / malformed blob returns ExpectedProtectedButUnsealed — it never returns the bytes. The "is this object protected?" expectation lives only in the caller's Some/None (its trusted DB), and is never inferred from the stored blob. This is what closes the strip/downgrade injection.
  • Symmetric blank-password guard: unwrap rejects Some(SecretString::empty()) with BlankPassphrase BEFORE any KDF/AEAD work — mirroring the existing wrap-side pw.is_blank() guard. Closes a backend-write attacker plant-under-blank-pw injection path.
  • Trailing-byte defense: bincode::decode_from_slice returns (value, consumed); unwrap asserts consumed == blob.len() and fails closed otherwise, so a truncation/extension probe surfaces as Corruption.
  • Write API: set_secret(.., password) and reprotect(current, new) (add / change / remove a password, crash-safe same-slot rewrite). set/get are reimplemented as non-breaking .., None wrappers. delete returns Result<bool> (true = removed, false = was absent) and reprotect returns Err(NoEntry) on absent — surfaces upstream keyring-core::Error::NoEntry instead of swallowing it, and closes a downgrade-detection blind spot where a silent reprotect no-op left the caller's trusted protection-status record disagreeing with the backend.
  • Serde gating: SecretString gains Deserialize + JsonSchema behind dedicated default-OFF features secret-serde / secret-schemars (no Serialize; schema leaks no value/min/max/pattern), plus an always-on is_blank().
  • No bespoke crypto — reuses the existing crypto::* primitives, SecretBytes/SecretString, the vault's atomic-write path. Wire format follows the project's standing bincode = "=2.0.1" + standard().with_big_endian().with_no_limit() convention (used by rs-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-targets clean — no caller-side API change despite the wire-format refactor.
  • ★ Non-vacuous strip-injection regression: Some(pw)+scheme-0 read fails closed AND the same blob would decode to the attacker seed under None — proving the strict rule (not malformation) blocks it. Symmetric tests on BOTH the file vault AND the OS-keychain arms.
  • Golden byte-vector tests pin the exact wire output for scheme-0 and scheme-1 (deterministic salt/nonce via a #[cfg(test)] seal_with_nonce seam) — any future bincode-config drift trips them before it can corrupt stored data.
  • Property test (proptest = "1", already in the workspace) prop_single_byte_flip_never_yields_plaintext runs a single-byte flip across every field within its 60-second budget.
  • Adversarial fuzz of unwrapdecode_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.
  • AAD domain-separation tests prove Tier2Aad, EntryAad, VerifyAad are pairwise byte-disjoint.
  • Independent QA (5-specialist wave on the layered-protection feature itself): unanimous PASS, 0 findings above LOW; L-1 strip/downgrade, L-2 KDF-DoS, and L-3 relocation confirmed closed within the library's reach.
  • Independent QA on the bincode/AAD refactor (security-engineer-smythe + qa-engineer-marvin + project-reviewer-adams parallel panel): 1 HIGH + 4 MED + 13 LOW — all MEDIUM+ items folded into commit c4af266e4e. Final verdict ACCEPT.
  • cargo fmt --all -- --check clean; cargo clippy -p platform-wallet-storage --all-targets -- -D warnings zero warnings; cargo audit clean on the crypto stack (RUSTSEC-2025-0141 bincode unmaintained is an INFO-level project-wide notice, no exploit).

Breaking Changes

  • open / rekey now reject a blank passphrase (BlankPassphrase).
  • SecretStore::delete returns Result<bool, SecretStoreError> instead of Result<(), SecretStoreError>. .delete(...)?; still compiles and silently discards the bool for callers that don't care.
  • SecretStore::reprotect now returns Err(SecretStoreError::NoEntry) for an absent target instead of a silent Ok(()) no-op.
  • SecretStoreError::NoEntry promoted to a top-level variant (was nested under OsKeyringErrorKind::NoEntry).
  • Tier-2 envelope wire format changed (bincode-encoded structs, all three AADs carry -v2 domain tags). Any Tier-2 envelope written by an earlier commit of this PR no longer decodes — acceptable because this PR has not yet shipped.
  • New public error variants: ExpectedProtectedButUnsealed, NeedsPassword, WrongPassword, BlankPassphrase, UnsupportedEnvelopeVersion, top-level NoEntry.
  • get_secret is an additive password-aware read; existing get/set call sites are unchanged.

Review feedback resolved this round

  • Thread feat: enable mainnet for dashmate #2 (BLOCKING) — symmetric is_blank() guard on unwrap (now wire/envelope.rs::unwrap_password_payload).
  • Thread docs: improved sidebar and usage in DAPI client #3 (SUGGESTION) — per-read KDF ceiling at default_target() on m_kib AND t.
  • Thread chore: update webpack to version 5 in DPP #4bind_object_identity helper, subsumed by the new bincode Tier2Aad / EntryAad / VerifyAad structs.
  • Thread docs(sdk): provide getTransactionHistory #5 — clarified SecretString::new deny(unsafe_code) comment.
  • Thread build(sdk): upgrade to webpack 5 #6delete: Result<bool> + reprotect: Err(NoEntry) on absent.
  • Smythe S3.1 (MED) — t-axis added to per-read KDF ceiling.
  • Smythe S2.3 (LOW)consumed == blob.len() assert on bincode decode.
  • Marvin QA-008 (MED)DECODE_CONFIG asymmetric-config rustdoc justifies the security-positive deviation from the project's standard with_no_limit().
  • Marvin QA-005 / Smythe S1.3 (LOW)VERIFY_DOMAIN_V2 rustdoc corrected (TIER2 and VERIFY are both 18 bytes; disjointness is by content past the common prefix).
  • Adams PROJ-003 (HIGH)SECRETS.md rewritten for the new wire format.

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 10 commits June 22, 2026 17:58
…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
@coderabbitai

coderabbitai Bot commented Jun 22, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: de718d1b-2560-4fc6-9ca1-d1c4ac81bbbb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/platform-wallet-secret-protection

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@lklimek lklimek marked this pull request as ready for review June 26, 2026 09:04
@thepastaclaw

thepastaclaw commented Jun 26, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 07579e3)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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()`.

Comment thread packages/rs-platform-wallet-storage/src/secrets/envelope.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/envelope.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/envelope.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/envelope.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/secret.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/store.rs Outdated
@lklimek lklimek changed the title feat(platform-wallet-storage)!: layered opt-in secret protection (Tier-2 password envelope) feat(platform-wallet)!: layered opt-in secret protection (Tier-2 password envelope) Jun 26, 2026

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

lklimek and others added 7 commits June 26, 2026 10:40
…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.
lklimek added 6 commits June 26, 2026 11:55
…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
…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 thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread packages/rs-platform-wallet-storage/SECRETS.md Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/envelope.rs Outdated
lklimek and others added 2 commits June 26, 2026 13:56
…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
@lklimek lklimek merged commit 5367471 into fix/wallet-core-derived-rehydration Jun 26, 2026
3 checks passed
@lklimek lklimek deleted the feat/platform-wallet-secret-protection branch June 26, 2026 14:02

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants