feat(platform-wallet): add SecretStore keyring + encrypted-file secret backends (secrets feature)#3672
Draft
Claudius-Maginificent wants to merge 10 commits into
Conversation
…ppers, error, validation, MemoryStore
Group A (Tasks 1–3) of the secret-storage feature. All gated behind the
opt-in `secrets` Cargo feature (never enabled by `default`).
Task 1 — `secrets::secret`: `SecretString` (trimmed MIT fork of
dash-evo-tool `Secret`, the egui `TextBuffer`/`take()` leak path deleted
by construction — SEC-REQ-3.8.1/3.8.2) + net-new byte-oriented
`SecretBytes`. Redacting `Debug`, no `Display`/`Deref`/`Serialize`,
full-capacity zeroize on drop, best-effort `region` mlock,
`subtle::ConstantTimeEq` on `SecretBytes`. The only `unsafe` is the
forked full-capacity wipe in `Drop`, confined behind a narrow
`#[allow(unsafe_code)]` + `// SAFETY:` proof — `#![deny(unsafe_code)]`
stays crate-wide (SEC-REQ-4.8).
Task 2 — `secrets::error::SecretStoreError`: concrete `thiserror` enum,
no boxed dyn error (SEC-REQ-4.4 / TC-082), no `#[non_exhaustive]`, no
secret/passphrase/plaintext/source in any variant, static `#[error]`
strings. `secrets::validate`: 32-byte `WalletId` newtype +
`^[A-Za-z0-9._-]{1,64}$` label allowlist, reject-not-sanitize
(SEC-REQ-4.3, CWE-22/20).
Task 3 — `secrets::store::SecretStore` trait (`get` returns
`Option<SecretBytes>`, never bare `Vec<u8>` — SEC-REQ-4.1) +
`MemoryStore` test double, gated by `__secrets-test-helpers` so it is
unreachable from production builds (SEC-REQ-2.3.1/2.3.2). `src/lib.rs`
slot activated; `secrets` feature wires only the RustSec-clean pinned
crypto (argon2=0.5.3, chacha20poly1305=0.10.1, zeroize=1.8.2,
subtle=2.6.1, region=3.0.2, getrandom; keyring-core 4.x split). MSRV
1.92 verified to compile the full dep set (`aes-gcm` omitted).
`Send + Sync` / object-safety compile-asserts added.
Satisfies SEC-REQ 3.1, 3.2, 3.3, 3.5, 3.6, 3.8.1, 3.8.2, 4.1, 4.2,
4.3, 4.4, 4.5, 4.6, 4.8, 2.0.3, 2.3.1, 2.3.2.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…a20-Poly1305 vault
Group B Task 4. `secrets::file::{mod,format,crypto}`:
- Argon2id KDF (`argon2 0.5.3`): floors m≥19456 KiB / t≥2 / p=1 enforced
before any derivation; shipped default 64 MiB / t=3; params + 32-byte
CSPRNG salt stored in the versioned header (SEC-REQ-2.2.1/.2/.3/.4).
- XChaCha20-Poly1305 (`chacha20poly1305 0.10.1`): fresh random 24-byte
nonce per `put` (counter forbidden); combined decrypt so no
unverified plaintext is ever materialized (SEC-REQ-2.2.5/.6/.8).
- AAD = canonical length-prefixed `format_version‖wallet_id‖label`,
defeating blob-swap / version-rollback (SEC-REQ-2.2.7).
- Self-describing magic+version header; unknown version refused, fail
closed (SEC-REQ-2.2.9).
- 0600 at creation via O_EXCL + fchmod before any ciphertext byte;
pre-existing loose perms refused; atomic temp→fsync→rename→dir-fsync;
temp holds only ciphertext, removed on failure (SEC-REQ-2.2.10/.11).
- Atomic rekey: fresh salt + fresh per-entry nonces, no `.bak`
(SEC-REQ-2.2.12). Passphrase held in `SecretString`, never persisted,
zeroized on drop; derived key recomputed per op, never retained
(SEC-REQ-2.2.13).
Satisfies SEC-REQ 2.0.1, 2.0.2, 2.0.4, 2.2.1–2.2.13, 4.1.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ring-core 4.x split)
Group B Task 5. `secrets::keyring::KeyringStore` over the keyring 4.x
split: `keyring-core 1.0.0` API + per-platform store crates
(linux-keyutils / dbus-secret-service / apple-native / windows-native),
all exact-pinned, RustSec-clean, MSRV-1.92-verified.
- Namespacing: service `dash.platform-wallet-storage`, account
`{wallet_id_hex}:{label}` — two wallets cannot collide, a different
app cannot silently read; only the non-secret index appears in
keyring attributes (SEC-REQ-2.1.2, CWE-312).
- Fail-closed: headless / no Secret Service / no D-Bus → typed
`BackendUnavailable`; locked → typed error. Never `unwrap`, never a
silent plaintext / weaker-store fallback (SEC-REQ-2.1.3/.4 / AR-4).
- keyring-core's bare `Vec<u8>` from `get_secret` is wrapped into
`SecretBytes` and the intermediate zeroized immediately
(SEC-REQ-3.1/4.1).
- Per-OS threat-coverage rustdoc on the type (SEC-REQ-2.0.4 / 2.1.3).
Backend selection is an explicit operator decision — no auto-fallback
between KeyringStore and EncryptedFileStore (SEC-REQ-2.1.3 / AR-4).
Satisfies SEC-REQ 2.0.1, 2.0.4, 2.1.1, 2.1.2, 2.1.3, 2.1.4.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…egration tests Group B Task 6. `tests/secrets_guard.rs` (SEC-REQ-4.5.1): positive string-level scan of `src/secrets/` asserting no logging/formatting sink (`tracing::*`/`println!`/`format!`/`panic!`/…) is paired with an `expose_secret()` result — the guard `tests/secrets_scan.rs` deliberately does NOT cover this tree. Green on the clean tree; fails the moment a secret is routed to a sink. `tests/secrets_api.rs`: `get` returns `Option<SecretBytes>` (type binding, never `Vec<u8>` — SEC-REQ-4.1); `dyn SecretStore` object-safety / positive build guard (SEC-REQ-4.5); no boxed dyn error in `src/secrets/` (TC-082 parity, comment-aware); error `Display` is static and secret-free (SEC-REQ-2.0.1/3.3, CWE-209); wrapper `Debug` redacted at the boundary (SEC-REQ-3.3). `MemoryStore` intentionally unreachable from this external test crate (SEC-REQ-2.3.1). Satisfies SEC-REQ 4.5, 4.5.1. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…secrets crypto deps Group B Task 8 (SEC-REQ-4.7). The existing `rustsec/audit-check` already audits the full `Cargo.lock` — which now pins the `secrets`-gated crypto (argon2/chacha20poly1305/zeroize/subtle/region/ keyring-core + per-platform stores), so they are advisory-checked even though `default` does not enable `secrets`. This adds a `cargo-deny check advisories --all-features` job so the feature-conditional dependency graph is exercised explicitly, plus a workspace `deny.toml` (advisory ignore kept in sync with `.cargo/audit.toml`). Locally verified: `cargo audit` exits 0; none of the secrets crypto pins carry any RustSec advisory (confirms Smythe §7 first-hand). The only flagged item, RUSTSEC-2025-0141 (bincode unmaintained), is a pre-existing unrelated wasm-sdk/dpp dependency, not in the secrets path. Satisfies SEC-REQ 4.7. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…d atomic vault write C1 (HIGH, Marvin QA-001): a `put`/`get`/`delete`/`rekey` against an EXISTING vault with a passphrase deriving a DIFFERENT key than the vault was created with previously wrote a mismatched-key entry and returned Ok, producing an unreadable mixed-key vault. The header now carries a passphrase-verification token: an XChaCha20-Poly1305 seal of a fixed constant under the header-Argon2id-derived key, AAD-bound to `(format_version, wallet_id, "\0verify")` (the leading-NUL label is disjoint from every allowlisted entry label, so the token can never alias a real slot). Every operation on an existing vault derives the key from the supplied passphrase and verifies the token FIRST; a mismatch fails the Poly1305 tag (constant-time, no extra compare, no plaintext on failure) and returns `SecretStoreError::WrongPassphrase` before any entry is read, written, or deleted. New vaults write the token at creation; `rekey` verifies the old token and writes a fresh one. `format_version` bumped 1→2; v1/v2 cross-reads fail closed via the existing `VersionUnsupported` path. C6 (LOW, Smythe SEC-RA-001): `write_vault` no longer swallows the directory-fsync result — it is propagated as a typed error so the atomic temp→fsync→rename→dir-fsync chain (SEC-REQ-2.2.11) is fully enforced. C7 (LOW, Marvin QA-004): the temp file now uses a unique name (`pid` + monotonic counter) created with `O_EXCL` and the destination is never pre-removed, so a crash can never leave the vault absent and concurrent writers cannot collide on a fixed temp name. The atomic rename + fsync ordering is unchanged. Tests (red→green, file/mod.rs): wrong-pass `put` to existing vault ⇒ `Err(WrongPassphrase)` + vault still readable with the correct pass + rejected slot never written; wrong-pass `get`/`delete` ⇒ `Err(WrongPassphrase)` + vault unmutated; correct pass round-trips unchanged. The two wrong-pass tests were FAILED before this fix and pass after; format (de)serialize round-trips the token fields. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ringLocked; correct keyring-core attribution
C3 (MED, Adams PROJ-002 / Marvin QA-003): `map_keyring_err` collapsed
keyring-core's `NoStorageAccess` into `BackendUnavailable`, leaving
`SecretStoreError::KeyringLocked` dead. Per keyring-core 1.0.0 docs,
`NoStorageAccess` covers the locked-collection case ("it might be that
the credential store is locked"), so it now maps to `KeyringLocked`,
enabling the unlock-retry UX (SEC-REQ-2.1.4). Genuinely-absent backends
(`NoDefaultStore` / `PlatformFailure`) stay `BackendUnavailable`.
Added `locked_keyring_maps_to_keyring_locked` asserting the locked,
absent, and not-found mappings.
C5 (LOW, Adams PROJ-003 / Marvin QA-004): the module header said
"keyring-core 4.x split" — inaccurate. Reworded to state the lib is
`keyring-core 1.0.0` plus the per-platform store crates; the `keyring`
4.x crate is the sample CLI and is not a dependency. No dependency
change.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…roizes on drop C4 (MED, Smythe SEC-RA-002 / Adams PROJ-004 / Marvin QA-002): the rustdoc claimed stored values sit in `SecretBytes`, but the map held a bare `Vec<u8>` that never zeroized — code contradicted the doc. Fixed the code (not the doc): the backing map is now `HashMap<(WalletId,String), SecretBytes>`, closing SEC-REQ-2.3.2 so even test memory is wiped on drop. Added `stored_value_is_zeroizing_ wrapper` (type-binding assertion) + a `needs_drop::<SecretBytes>()` compile-time guard. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…rgo.toml comment C5 (LOW, Adams PROJ-003 / Marvin QA-004): the per-platform-store dependency comment said "keyring-core 4.x split". Reworded to state accurately that `keyring-core 1.0.0` is the API and the per-platform crates provide the backends (the `keyring` 4.x crate is the sample CLI and is intentionally not depended on). No dependency change. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…etStore API C2 (MED, Adams PROJ-001): the trait sketch was stale/dangerous — `get -> Option<Vec<u8>>` (the exact CRITICAL leak SEC-REQ-4.1 forbids) and the false "feature flag exists today but flips no code" line. Rewritten to the delivered API: `get -> Result<Option<SecretBytes>, SecretStoreError>`, accurate `put`/`delete` signatures, the real backends (KeyringStore/EncryptedFileStore/MemoryStore with their fail-closed / gating semantics), and the now-true statement that enabling `secrets` activates the module. Present-state only, no history narration; no forbidden token introduced into `src/sqlite/schema/` or `migrations/`. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue being fixed or feature implemented
Wallet apps built on
rs-platform-walletneed a place to persist secret material — BIP-39 mnemonics, BIP-32 seeds, xpriv keys — so a wallet can be rehydrated from storage without the user re-entering their phrase every time. The SQLite persister introduced in #3625 deliberately stores only public/correlation state (UTXOs, identities, contacts, token balances). It must never hold secret bytes, and currently has no companion that does.Imagine you are building a mobile or desktop wallet on top of
rs-platform-wallet. You derive a BIP-39 mnemonic at setup, persist the wallet state via the SQLite backend, and your user closes the app. On next launch, you need to rehydrate the wallet — reconstruct its derived keys — without asking the user to re-enter their phrase. You need a place to store that phrase that is: (a) outside the SQLite file, so backups and DB exports stay secret-free; (b) authenticated and encrypted at rest; (c) keyed by wallet identity, not by the wallet file path. This PR delivers that capability as an explicit, opt-in module, behind a Cargo feature that is off by default.What was done?
New module:
platform_wallet_storage::secrets(behind--features secrets)The
secretsCargo feature (previously a no-op stub atCargo.toml:110) now activates a complete secrets subsystem undersrc/secrets/. The feature is not indefault— a build that does not opt in compiles exactly as before, with no new dependencies.SecretStoretrait (src/secrets/store.rs)getreturnsOption<SecretBytes>— a zeroize-on-drop wrapper, never a bareVec<u8>.labelis validated against^[A-Za-z0-9._-]{1,64}$before any backend touches it;wallet_idis a fixed-width 32-byte newtype. An ergonomicput_secretdefault method accepts&SecretBytesdirectly. The trait isSend + Sync; a compile assertion mirrors the existing_send_sync_checkblock.Zeroizing wrappers (
src/secrets/secret.rs)Two types carry secret material:
SecretBytes— net-new, byte-oriented, wrapsZeroizing<Vec<u8>>. RedactingDebug([REDACTED; N]), noDisplay/Deref/Serialize, constant-time equality viasubtle::ConstantTimeEq, best-effortmlockviaregion. The type used for seeds, derived keys, AEAD key, and decrypted plaintext.SecretString— a trimmed in-crate fork of theSecrettype fromdash-evo-tool(src/model/secret.rs), MIT, with attribution preserved. Theegui::TextBufferimpl (including its documentedtake()leak) is deleted in full — this crate has no egui dependency and the leak vector cannot exist by construction. RedactingDebug, noDisplay/Deref/Serialize, full-capacity zeroizeDrop. Used for mnemonics and passphrases. ThePartialEqconstant-time XOR fold is retained for passphrase UX equality only; its length-leak caveat is documented in rustdoc and it is explicitly not used for any security decision.The one
unsafeblock (slice-from-raw-parts in the forkedDrop) carries a// SAFETY:proof and a narrowly-scoped#[allow(unsafe_code)]; the crate-wide#![deny(unsafe_code)]is not relaxed.Error type (
src/secrets/error.rs)SecretStoreErroris a concretethiserrorenum — noBox<dyn Error>, no#[non_exhaustive], no secret bytes in any variant, static#[error("...")]strings. This satisfies the existing NFR-4 / TC-082 grep guard. Variants:BackendUnavailable,KeyringLocked,NotFound,Decrypt,InvalidLabel,Io,KdfFailure,VersionUnsupported,WrongPassphrase,InsecurePermissions.KdfFailureandDecryptdeliberately omit#[source]— the upstream Argon2 and AEAD errors carry no useful non-secret diagnostic and risk leaking structure.KeyringStore(src/secrets/keyring.rs)Uses
keyring-core 1.0.0(the split-crate model) plus per-platform credential-store crates. Namespacing: service ="dash.platform-wallet-storage", account ="{wallet_id_hex}:{label}"— two wallets cannot collide, a different app cannot silently read the entry. Keyring item attributes carry only the non-secret wallet/label index.Fail-closed by design. Headless Linux (no Secret Service), locked collections, and unavailable daemons surface as typed
BackendUnavailable/KeyringLockederrors. There is no silent fallback to plaintext or toEncryptedFileStore. The operator selects the backend explicitly. Per-OS caveats (macOS Keychain ACL, Windows DPAPI same-user limitation, headless Linux) are documented in rustdoc on the impl.EncryptedFileStore(src/secrets/file/)Argon2id + XChaCha20-Poly1305 vault file, fully under our control:
argon2 0.5.3, pinned) with enforced floors (m ≥ 19 MiB, t ≥ 2, p = 1) and shipped defaults (m = 64 MiB, t = 3). Parameters stored in the file header so future hardening does not strand existing vaults.OsRng/getrandom, per-vault, unique, in the header. Never constant, never derived fromwallet_idor path.chacha20poly1305 0.10.1, pinned). No unauthenticated mode.aes-gcmis deliberately absent.XNoncefreshly generated for everyput, stored alongside the ciphertext. Counters are explicitly forbidden (multi-process / restart / file-copy scenario would reuse counter values — catastrophic keystream reuse).format_version ‖ wallet_id ‖ label. Decryption under a different(wallet_id, label)or a rolled-backformat_versionfails the Poly1305 tag. Blob-swap and replay attacks are structurally rejected.SecretStoreError::Decryptand exposes zero decrypted-but-unverified bytes. The combined (non-detached) AEADdecryptAPI is used so unverified plaintext is never materialized — consistent with the behavioural lesson of RUSTSEC-2023-0096.O_EXCL+fchmodbefore any plaintext-derived byte is written (not chmod-after). A pre-existing file with looser permissions surfaces asSecretStoreError::InsecurePermissions— never blindly overwritten.fsynctemp,renameover target,fsyncdirectory. A crash mid-write never truncates the prior vault..bakleft holding old key material.MemoryStore(src/secrets/memory.rs)Test-only backend, gated behind
__secrets-test-helpersso it is unreachable from production builds. Stored values areSecretBytesso the zeroize contract is exercised even in tests.Secrets guard (
tests/secrets_guard.rs)Positive guard test scanning
src/secrets/fortracing::/println!/format!ofexpose_secret()or plaintext — the complement totests/secrets_scan.rs, which scanssrc/sqlite/and explicitly exemptssrc/secrets/.CI: cargo-deny advisories gate (
.github/workflows/security-audit-rust.yml,deny.toml)cargo auditandcargo deny advisoriesnow run with--features secretsin CI so the crypto dependencies (previously unreachable from the default feature set) are covered. All crypto pins are RustSec-clean at adoption (independently verified against the RustSec advisory-db forchacha20poly1305,argon2,zeroize,region,subtle;aes-gcmis not present).Secrets boundary: unchanged and enforced
The SECRETS.md boundary remains intact. The secrets module lives entirely under
src/secrets/. Thetests/secrets_scan.rsguard coverssrc/sqlite/schema/andmigrations/and exemptssrc/secrets/by design — this separation is load-bearing, not stylistic. The newtests/secrets_guard.rscovers the exempt side.Out of scope for this PR
The
SeedProvidertrait and the rehydration seam (load_from_persistorsignature, wrong-seed gate hardening inrs-platform-wallet/src/manager/load.rs) are not in this PR. They land with the rehydration PR-2 (item S), which depends on this PR as a hard prerequisite. This PR delivers the storage capability; PR-2 delivers the policy that consumes it.How Has This Been Tested?
The following commands form the gate. CI has not yet been run on this branch (it is a draft); commands are the intended test plan.
The following test categories are covered in
tests/secrets_api.rsandtests/secrets_guard.rs:put→ drop → reopen →get— byte-exact)deleteidempotent; missing label →Ok(None)SecretStoreError::Decrypt, zero decrypted bytes returned(wallet_id, label)slot → rejectedputoperationsVersionUnsupportedInsecurePermissions.bakretainedDebugofSecretBytes/SecretStringis redacted — no secret bytes in outputMemoryStoreunreachable from a--featuresbuild that omits__secrets-test-helpersexpose_secret()piped intotracing!orformat!insrc/secrets/KeyringStoreheadless-Linux path: fails with typed error, no panic, no plaintext fallback (CI-gated /#[ignore]on headless)The PR has been through an internal multi-agent review covering security requirements (threat model, crypto construction, memory hygiene, dependency vetting), QA test coverage, and consistency with the codebase's existing patterns. Findings from that review are reflected in the implementation; no CRITICAL or HIGH items remain open against this PR's scope.
Breaking Changes
None. This PR is purely additive:
secretsfeature is not indefaultand never will be without an explicit decision. No existing code path changes.SECRETS.mdis updated to reflect the delivered API (not a contract change — the boundary it describes is unchanged and now enforced by more tests).deny.tomland.github/workflows/security-audit-rust.ymladditions are CI-only and do not affect library consumers.Checklist:
For repository code-owners and collaborators only
🤖 Co-authored by Claudius the Magnificent AI Agent