Skip to content

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

Open
Claudius-Maginificent wants to merge 11 commits into
fix/wallet-core-derived-rehydrationfrom
feat/platform-wallet-secret-protection
Open

feat(platform-wallet)!: layered opt-in secret protection (Tier-2 password envelope)#3953
Claudius-Maginificent wants to merge 11 commits into
fix/wallet-core-derived-rehydrationfrom
feat/platform-wallet-secret-protection

Conversation

@Claudius-Maginificent

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 (secrets/envelope.rs): magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ nonce ‖ ct. AAD binds the full identity + header (relocation defense); the KDF cost ceiling is enforced before derivation (DoS defense); plaintext is capped at MAX_PLAINTEXT_LEN so plaintext + overhead ≤ MAX_SECRET_LEN.
  • ★ Strict fail-closed read (the keystone): get_secret(service, label, Option<&SecretString>). A Some(pw) read of a scheme-0 / legacy / 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.
  • 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.
  • 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, and the vault's atomic-write path.

How Has This Been Tested?

  • 148 secrets unit + 12 integration tests green; across the feature-flag matrix: default 187, --features secret-serde 188, --features secret-schemars 189 — 0 failures.
  • ★ Non-vacuous strip-injection regression (TS-L1-002) on BOTH the file vault AND the OS-keychain arms: it asserts the Some(pw)+scheme-0 read fails closed and that the same blob would decode to the attacker seed under None — proving the strict rule (not malformation) blocks it.
  • Crash-safety: file-arm and OS-arm mid-rewrite-failure tests confirm the old value stays intact (no half-rotation).
  • Independent QA (5-specialist wave): 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; cargo audit clean on the crypto stack.
  • cargo fmt clean; cargo clippy -p platform-wallet-storage --lib --tests zero warnings.

Breaking Changes

  • open / rekey now reject a blank passphrase (BlankPassphrase) — the one behavioral break (previously a blank was accepted).
  • New public error variants (ExpectedProtectedButUnsealed, NeedsPassword, WrongPassword, BlankPassphrase, UnsupportedEnvelopeVersion).
  • get_secret is an additive password-aware read; existing get/set call sites are unchanged (non-breaking wrappers). (Lead commits marked !.)

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: 945e6f4d-489f-4842-9e8b-9542fff210e6

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 1e70ebd)

@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 on lines +256 to +267
fn unwrap_scheme1(
wallet_id: &WalletId,
label: &str,
password: &SecretString,
body: &[u8],
) -> Result<SecretBytes, SecretStoreError> {
if body.len() < MIN_SCHEME1_BODY {
// The scheme byte says protected, but the body cannot hold a sealed
// payload — corrupt, not a strip.
return Err(SecretStoreError::Corruption);
}
let kdf = decode_kdf(&body[..KDF_FIELD_LEN]);

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.

🔴 Blocking: Reject blank object passwords on protected reads, mirroring the wrap-side invariant

wrap_with_params rejects pw.is_blank() at line 151 before sealing, but unwrap_scheme1 accepts Some(blank) and derives an Argon2 key from the empty string. Under this PR's threat model the backend bytes are attacker-writable; an attacker who plants a fresh scheme-1 envelope encrypted under the blank password — using the correct (wallet_id, label) AAD and a key derived from blank+salt — will produce a blob that decrypts successfully under Some(SecretString::empty()). Any caller that forwards an empty prompt value as Some(pw) (UI bug, defensive double-call, etc.) then receives attacker-controlled plaintext instead of the typed BlankPassphrase failure that the wrap path documents as the crate-wide invariant. Since Option<&SecretString> carries the protection assertion across this boundary, validate the inner value at the read boundary too. The fix is one line, and both Codex reviewers independently flagged it.

Suggested change
fn unwrap_scheme1(
wallet_id: &WalletId,
label: &str,
password: &SecretString,
body: &[u8],
) -> Result<SecretBytes, SecretStoreError> {
if body.len() < MIN_SCHEME1_BODY {
// The scheme byte says protected, but the body cannot hold a sealed
// payload — corrupt, not a strip.
return Err(SecretStoreError::Corruption);
}
let kdf = decode_kdf(&body[..KDF_FIELD_LEN]);
fn unwrap_scheme1(
wallet_id: &WalletId,
label: &str,
password: &SecretString,
body: &[u8],
) -> Result<SecretBytes, SecretStoreError> {
if body.len() < MIN_SCHEME1_BODY {
// The scheme byte says protected, but the body cannot hold a sealed
// payload — corrupt, not a strip.
return Err(SecretStoreError::Corruption);
}
// Mirror wrap's invariant: a blank object password is rejected on read
// as well as enrol, so a backend-write attacker who plants a scheme-1
// envelope sealed under the blank password cannot inject plaintext to
// a caller that accidentally forwards Some(empty).
if password.is_blank() {
return Err(SecretStoreError::BlankPassphrase);
}
let kdf = decode_kdf(&body[..KDF_FIELD_LEN]);

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed against current code. wrap_with_params rejects pw.is_blank() at envelope.rs:151, but unwrap_scheme1 (256-286) goes straight from body.len() < MIN_SCHEME1_BODY to decode_kdfenforce_boundsderive_key(password, …). No symmetric password.is_blank() guard exists, so the asymmetry you describe IS present: a backend-write attacker who plants a scheme-1 envelope sealed under the blank password with the correct AAD (wallet_id, label, KdfParams::default_target(), attacker-chosen salt) yields plaintext to a Some(SecretString::empty()) read.

Status: unresolved — actual gap, not addressed in HEAD.

Proposed fix is exactly the one-line if password.is_blank() { return Err(SecretStoreError::BlankPassphrase); } immediately after the MIN_SCHEME1_BODY check, mirroring wrap's invariant. Will land in a follow-up commit with a regression test that wraps under blank, then asserts unwrap returns BlankPassphrase (not plaintext, not WrongPassword).

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.

Still present at 1e70ebd.

Comment on lines +267 to +278
let kdf = decode_kdf(&body[..KDF_FIELD_LEN]);
// Gate the inflated/unknown header BEFORE any derivation/alloc.
kdf.enforce_bounds()?;

let mut salt = [0u8; SALT_LEN];
salt.copy_from_slice(&body[KDF_FIELD_LEN..KDF_FIELD_LEN + SALT_LEN]);
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&body[KDF_FIELD_LEN + SALT_LEN..KDF_FIELD_LEN + SALT_LEN + NONCE_LEN]);
let ciphertext = &body[KDF_FIELD_LEN + SALT_LEN + NONCE_LEN..];

let aad = scheme1_aad(&kdf, &salt, wallet_id.as_bytes(), label);
let key = crypto::derive_key(password, &salt, kdf)?;

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.

🟡 Suggestion: Consider a per-read ceiling at default_target() to bound backend-write OOM/CPU attacks

unwrap_scheme1 reads KDF params from the attacker-controllable envelope and only gates them against the global enforce_bounds ceiling (ARGON2_MAX_M_KIB = 1_048_576 KiB, ARGON2_MAX_T = 16). Because the public wrap always uses KdfParams::default_target() (64 MiB / t=3), a backend-write attacker can plant a scheme-1 envelope at the bounds maximum and force ~1 GiB allocation plus 16 Argon2 passes per read — substantial on resource-constrained hosts (iOS/embedded) and an availability attack on protected reads. The existing test kdf_ceiling_enforced_before_derivation pins ARGON2_MAX as valid, so this is a deliberate design choice rather than a defect; flagging as a suggestion in case the tighter read-time ceiling is desirable defense-in-depth. If the project intends to expose operator-tuned KDF params later, this should remain as-is and the residual should be documented next to the existing KDF-bounds discussion. Fix would be to add kdf.m_kib > KdfParams::default_target().m_kib || kdf.t > KdfParams::default_target().t => return Err(SecretStoreError::KdfFailure) immediately after enforce_bounds().

source: ['codex']

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified the residual: unwrap_scheme1 calls only kdf.enforce_bounds() (envelope.rs:269) before derive_key. With ARGON2_MAX_M_KIB = 1_048_576 and ARGON2_MAX_T = 16, a backend-write attacker who plants an inflated scheme-1 envelope can drive ~1 GiB / 16-pass Argon2 per protected read on resource-constrained hosts. Public wrap paths all use KdfParams::default_target() (64 MiB / t=3), so a tighter read-time ceiling would not reject any value this crate's own writers produce.

Taking the suggestion. Adding a default_target() ceiling after enforce_bounds() is a strict tightening with no impact on legitimate envelopes and bounds the per-read DoS surface — defense in depth is cheap here. Will land in the same follow-up commit as the blank-pw fix, with a test that pins (a) the new error on inflated headers and (b) kdf_ceiling_enforced_before_derivation still passes for envelopes wrapped at the floor params.

Keeping the wider ARGON2_MAX_* as the absolute ceiling so a later operator-tunable wrap path remains representable; the per-read cap will be KdfParams::default_target() until/unless an operator-tuned wrap surfaces.

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.

Still present at 1e70ebd.

/// [`wrap`] with explicit Argon2 `params` (tests use the floor params for
/// speed; production uses [`KdfParams::default_target`]). `params` is
/// ignored when `password` is `None`.
pub(crate) fn wrap_with_params(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

why not just define some struct and use bincode for envelope?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Verified against current code (envelope.rs:122 wrap_with_params). The wire format is hand-rolled magic ‖ version ‖ scheme ‖ kdf ‖ salt ‖ nonce ‖ ciphertext rather than a #[derive(bincode::Encode)] struct on purpose:

  1. AAD parity with secrets/file::format::aad — the on-disk vault binds AAD with explicit length-prefixed LE fields (format::aad at file/format.rs:107). Tier-2 must reproduce that exact byte layout in scheme1_aad so a relocated/confused blob fails the tag; a serde/bincode struct introduces an extra layer (field order, varint length encoding, optional framing) that we'd then have to match in AAD construction anyway.
  2. Forward compatibility — the header is magic ‖ version ‖ scheme so an unknown version can fail closed at byte index 5 without parsing the rest. A bincode struct would need a sentinel pre-decode anyway, which is what MAGIC already is.
  3. Fixed bounded overheadMAX_ENVELOPE_OVERHEAD = 128 is computed from the fixed-size fields, which feeds MAX_PLAINTEXT_LEN. Bincode-encoded Vec<u8> carries a runtime length prefix; the overhead would no longer be a compile-time constant.

Leaving as-is — unwrap_scheme1 is ~25 lines and the byte layout is the security boundary.

/// Build the scheme-1 AAD binding object identity + header,
/// length-prefixed for the variable fields, mirroring
/// [`format::aad`](super::file::format::aad)/`verify_aad`.
fn scheme1_aad(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

ensure we don't already have that aad implemented in the secrets/file . If we do, prefer reuse over reimplementing

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Checked secrets/file/format.rs::aad (file/format.rs:107) — it builds format_version(u32 LE) ‖ wallet_id_len(u32 LE) ‖ wallet_id ‖ label_len(u32 LE) ‖ label. Tier-2's scheme1_aad needs to bind a different field set: TIER2_DOMAIN ‖ MAGIC ‖ ENVELOPE_VERSION ‖ SCHEME_PASSWORD ‖ encode_kdf ‖ salt_len ‖ salt ‖ wallet_id_len ‖ wallet_id ‖ label_len ‖ label.

The two AADs share the wallet_id+label tail, but the prefixes are intentionally disjoint:

  • The vault entry AAD uses format_version so a rolled-back FORMAT_VERSION fails the tag.
  • The Tier-2 AAD uses TIER2_DOMAIN (b"PWSEV-TIER2-AAD-v1") plus the envelope magic/version/scheme so a Tier-2 blob can never be confused with a vault entry AAD-bound value (defense against cross-context tag reuse), and the KDF params + salt go into AAD too so header tamper / KDF downgrade surfaces as WrongPassword.

Factoring out the shared wallet_id_len ‖ wallet_id ‖ label_len ‖ label tail into a bind_object_identity(buf, wallet_id, label) helper IS a clean reuse — it saves the duplicated (len_le, bytes) boilerplate without forcing the two AADs to share a prefix they must not share. Will land in the follow-up commit; rejecting the larger 'call format::aad directly' shape because domain-separation requires the prefix to differ.

let mut buf = String::with_capacity(cap);
buf.push_str(&source);
// Do not remove: wipes the moved-in plaintext source before it drops
// (its freed buffer cannot be scanned in a test under deny(unsafe_code)).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if thie deny(unsafe_code) is only an issue in test, you can annotate the test to explicitly allow unsafe for that one test. Don't allow unsafe on production code.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Re-read the code carefully — production does NOT allow unsafe. secret.rs carries crate-wide #![deny(unsafe_code)] and there is no #[allow(unsafe_code)] anywhere in SecretString::new or its tests (confirmed via grep -n unsafe packages/rs-platform-wallet-storage/src/secrets/secret.rs).

The // Do not remove comment at secret.rs:62-63 is explaining why source.zeroize() runs unconditionally on the moved-in String source: the test secret_string_new_zeroizes_string_source at line 380 needs to PIN that the source-side wipe happens, but a direct scan of the freed buffer would be use-after-free — which would require unsafe, which the crate forbids. So the test instead asserts the primitive (String::zeroize empties a buffer) plus pins the call-site via this do-not-remove comment. The source.zeroize() IS the workaround that keeps the test sound under deny(unsafe_code); it does not require, nor enable, any unsafe in production.

Status: no code change needed — this is a comment misread. If the comment wording is the concern (it's currently terse, and 'cannot be scanned under deny(unsafe_code)' reads as if the test is somehow constrained), I can reword to (// a direct freed-buffer scan would require unsafe, which this crate forbids; the test at line 380 instead pins the String::zeroize primitive and this call site) to make the rationale legible without re-reading the linked test. Let me know which you prefer.

/// - **change:** `current = Some(old)`, `new = Some(pw_new)`;
/// - **remove:** `current = Some(old)`, `new = None`.
///
/// An absent object is a no-op (`Ok(())`). The rewrite is the same-slot

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

better error as not found

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Confirmed: reprotect (store.rs:251-262) currently returns Ok(()) when the object is absent, and that's documented as 'An absent object is a no-op (Ok(()))' on line 238. The matching no-op behavior also exists for delete (line 266) which is idempotent.

Two paths forward:

(a) Change to Err(NotFound) — would surface programmer error at the boundary, matches what you're asking. Trade-off: a benign race where a parallel delete lands between the caller's list/check and reprotect becomes a hard error the caller must absorb, when today it silently no-ops. Currently absence and deletion-in-flight are indistinguishable (and there's no NotFound variant in SecretStoreError; get_raw returns Ok(None) for absence too).

(b) Keep Ok(()), sharpen the doc — the same-slot reprotect intent is 'enforce the protection state on this object'; if the object is gone, the protection state is vacuously enforced. Race-safe, idempotent, symmetric with delete.

I'd lean (b) for race safety + symmetry, but (a) is the stricter API. If you confirm (a), the follow-up will add SecretStoreError::NotFound, change reprotect's absence branch to return it, and audit delete's no-op for consistency (probably keep delete idempotent and only make reprotect strict — the caller of reprotect has typed 'here is a password I want enforced', so an absent target is meaningful in a way it isn't for delete). Which path do you want?

@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.

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