feat(platform-wallet): keyring_core secret backends — encrypted-file + OS keyring (secrets feature)#3672
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>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ult-on Removes the cargo-deny advisories CI job and its `deny.toml` config in favour of the existing `rustsec/audit-check` job. Once `secrets` is in the default feature set, `Cargo.lock` unconditionally pins the RustSec-clean crypto stack (`argon2`/`chacha20poly1305`/`zeroize`/ `subtle`/`region`/`keyring-core` + per-platform store crates) so a single audit run covers them all (SEC-REQ-4.7). `secrets` joins `sqlite`+`cli` as a default feature. Dev-dependency on self adds `default-features = false` so the off-state CI invocation (`--no-default-features --features sqlite,cli`) actually exercises the secrets-disabled graph — otherwise the dev-dep view would silently re-enable defaults for every integration test. New `tests/secrets_off_state.rs` is the runtime D4 guard: gated `#[cfg(not(feature = "secrets"))]`, it builds against the persister surface only and asserts the off-state graph stays consumable. T1+T2 land atomically — cargo-deny removal coincides with secrets going default-on so crypto pins never drop out of audit scope between commits. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…backends
Retires the crate-local `SecretStore` trait + `SecretStoreError` enum
and rebuilds the `secrets` submodule on
`keyring_core::api::{CredentialApi, CredentialStoreApi}` — the upstream
SPI shipped by `keyring-core 1.0.0`. The `EncryptedFileStore`'s
security construction (Argon2id + XChaCha20-Poly1305 + AAD verify
token + 0600 + atomic temp→rename + dir-fsync + zeroize) is preserved
byte-for-byte; only the trait surface changes.
API-shape mapping (Nagatha §1, variant A — the `:` delimiter is rejected
by the label allowlist):
service = "dash.platform-wallet-storage/" + hex(wallet_id)
user = label
Per-task content:
- **T3** `src/secrets/file/error.rs` — new `FileStoreError` enum
(`Decrypt`, `WrongPassphrase`, `KdfFailure`, `VersionUnsupported`,
`MalformedVault`, `InvalidLabel`, `InsecurePermissions`, `Io`).
Static `#[error]` strings only; no secret in any variant.
`src/secrets/file/error_bridge.rs` — `FileStoreFailure` unit-only
marker (Smythe EDIT-3: no `String`/`Vec<u8>`/`Path` fields permitted,
enforced via a compile-time `Copy` assertion) boxed inside
`keyring_core::Error::NoStorageAccess` (WrongPassphrase) or
rendered into `BadStoreFormat`'s static `String` payload. The
`downcast_failure` helper recovers the marker for D1(b).
- **T4** `src/secrets/file/mod.rs` — `EncryptedFileStore` implements
`CredentialStoreApi`; per-`(service, user)` entries implement
`CredentialApi`. The store is held behind an internal `Arc` so
long-lived credentials can outlive the public handle. `delete` honors
upstream's `NoEntry`-if-absent contract (D3). `service` parsing
rejects mismatch with `Invalid("service", _)`; `validated_label` runs
at `build` time AND every `CredentialApi` op (defence in depth,
M-2). All twelve in-module security tests port one-for-one through
the SPI (NoEntry for absence, downcast for typed-error checks).
- **T5** `src/secrets/keyring.rs` — `KeyringStore` wrapper retired in
favour of the bare `default_credential_store() -> Result<Arc<dyn
CredentialStoreApi + Send + Sync>, keyring_core::Error>` constructor.
Headless / unknown OS / D-Bus-less Linux → `NoDefaultStore` per D2
(typed, single SPI error). Never panics, never falls back.
- **T7** `src/secrets/memory.rs` — `MemoryStore` → `MemoryCredentialStore`
implementing `CredentialStoreApi`. Internal map keys on
`(service, user)` strings, values remain `SecretBytes` (SEC-REQ-2.3.2).
Still gated behind `__secrets-test-helpers`.
- **T8** `src/lib.rs` — object-safety + `Send + Sync` assertions now
target `keyring_core::Error` and `dyn CredentialStoreApi + Send +
Sync`. `src/secrets/mod.rs` re-exports the new surface; `pub use
SecretStore` / `SecretStoreError` retired.
- **Tests** — `tests/secrets_api.rs` rewritten against the SPI; the
`Vec<u8> → SecretBytes::new` consumer-seam pattern (Smythe EDIT-1:
no named intermediate `Vec` binding) is the type-shape assertion.
`tests/secrets_guard.rs` extended with the EDIT-2 EDIT-2 guard:
no `{{:?}}`-debug-format paired with `keyring_core::Error` in
`src/secrets/` (since `BadEncoding`/`BadDataFormat` embed raw
`Vec<u8>`). All twelve `EncryptedFileStore` security invariants
pass on the new API.
`tests/secrets_seed_provider_adapter.rs` and the
`seed_provider_adapter.rs` source file are NOT landed on this branch:
the `SeedProvider`/`WalletSecret`/`SeedUnavailable` types they consume
live in `rs-platform-wallet` on PR #3692, not on this base. The
rewritten adapter will land on PR #3692's rebase onto this tip — see
the rework report.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…core SPI
Rewrites SECRETS.md as the present-state spec for the secrets
submodule on the upstream `keyring_core::api` SPI:
- Drops the retired `SecretStore` trait listing.
- Documents the `service = "dash.platform-wallet-storage/" + hex(wid)`,
`user = label` key shape with the allowlist precondition.
- Memory hygiene section codifies Smythe EDIT-1: `SecretBytes::new(...)`
is the consumer-seam wrapper, no named intermediate `Vec` binding.
- Backends section: `EncryptedFileStore` + `default_credential_store()`
+ test-only `MemoryCredentialStore`.
- Cross-SPI error bridge: `FileStoreFailure` unit-only marker (EDIT-3
constraint stated as load-bearing), `downcast_failure` recovery
path, EDIT-2 `{:?}`-format ban on `keyring_core::Error` documented
with its enforcement test.
- Audit hooks section adds `secrets_off_state` (D4) and rephrases
`secrets_guard` to cover both leak sinks.
- Cargo features paragraph notes `secrets` is default-on; cargo-deny
removal is noted via the lockfile-is-audit-coverage rationale.
`src/lib.rs` crate-level doc retouched to point at the new SPI and
backend names (the prior "SecretStore reserved" phrasing retired).
`tests/secrets_scan.rs` exemption comment rephrased to describe the
present state.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…rface `tests/secrets_default_on_compiles.rs` (M-S4) — a build-only assertion that the default feature set (`secrets` in) re-exports every public type/function in the `secrets` submodule. Names: `EncryptedFileStore`, `SecretBytes`, `SecretString`, `WalletId`, `FileStoreError`, `FileStoreFailure`, `SERVICE_PREFIX`, `default_credential_store`, `keyring_core::Error`. Compiling the test target is the assertion; the body never exercises a backend. Pairs with `tests/secrets_off_state.rs` (D4 — runtime proof under `--no-default-features --features sqlite,cli` that the surface compiles out and the persister still links). Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…EDIT-4)
QA-501 (MEDIUM, EDIT-4 forward-compat): `SecretBytes`/`SecretString`
retained `impl PartialEq`/`Eq` despite EDIT-4's binding intent. The
impls delegated to constant-time compares so today's behaviour is
safe, but leaving `==` reachable means future bridge code could
inherit a non-constant-time path or a length-leaking shortcut without
review noticing.
EDIT-4 says: no `==` on secret bytes, no exception. Strip the impls
and let `subtle::ConstantTimeEq::ct_eq` be the only equality path.
- `secret.rs` — removed `impl PartialEq for SecretBytes` /
`impl Eq for SecretBytes` and `impl PartialEq for SecretString` /
`impl Eq for SecretString`. `SecretString` gains an
`impl ConstantTimeEq` so callers keep a constant-time-safe
equivalence path (was previously implicit inside `PartialEq::eq`).
- Public rustdoc on both types names `PartialEq`/`Eq` in the "not
implemented" list and points callers at `ConstantTimeEq::ct_eq`.
- `compile_fail` doc-test on each type asserts that `a == b` does NOT
compile — durable forward-compat guard. If a future change adds
`PartialEq` back, the doc-test starts compiling and the test fails.
- Test callers migrated:
- `secret_string_eq_is_value_based` →
`secret_string_ct_eq_is_value_based`, asserts via
`bool::from(a.ct_eq(&b))`.
- `secret_bytes_constant_time_eq` drops its trailing
`assert_eq!(a, b)` / `assert_ne!(a, c)` lines (the prior
ct_eq-based assertions above them already covered the same
invariant).
Workspace-wide grep confirmed no other `==`/`assert_eq!` callers on
`SecretBytes`/`SecretString` exist.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a default-on secrets subsystem to platform-wallet-storage to persist wallet secret material outside SQLite, using the upstream keyring_core SPI and providing both an encrypted-file vault backend and an OS-keyring backend.
Changes:
- Introduces
platform_wallet_storage::secrets(default feature) withEncryptedFileStore(Argon2id + XChaCha20-Poly1305) anddefault_credential_store()for OS keyrings. - Adds secret-handling wrappers (
SecretBytes,SecretString) plus validation and an error-bridging layer tokeyring_core::Error. - Adds multiple guard tests (
secrets_scan,secrets_guard, API shape checks, and “secrets off” build-mode guard) and updates docs/README/Cargo features accordingly.
Reviewed changes
Copilot reviewed 19 out of 20 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/rs-platform-wallet-storage/tests/secrets_scan.rs | Clarifies schema/migrations scan scope vs src/secrets/ exemption. |
| packages/rs-platform-wallet-storage/tests/secrets_off_state.rs | Adds runtime guard ensuring secrets compile out when feature is disabled. |
| packages/rs-platform-wallet-storage/tests/secrets_guard.rs | Adds string-level leak-prevention scans for the secrets module. |
| packages/rs-platform-wallet-storage/tests/secrets_default_on_compiles.rs | Build-only test asserting secrets surface is available in default build. |
| packages/rs-platform-wallet-storage/tests/secrets_api.rs | API/boundary shape tests for secrets SPI usage and error rendering. |
| packages/rs-platform-wallet-storage/src/secrets/validate.rs | Adds WalletId newtype + strict label allowlist validation. |
| packages/rs-platform-wallet-storage/src/secrets/secret.rs | Implements SecretBytes/SecretString zeroizing wrappers and CT equality. |
| packages/rs-platform-wallet-storage/src/secrets/mod.rs | Wires secrets submodules and public re-exports. |
| packages/rs-platform-wallet-storage/src/secrets/memory.rs | Adds in-RAM CredentialStoreApi test double behind __secrets-test-helpers. |
| packages/rs-platform-wallet-storage/src/secrets/keyring.rs | Adds OS-keyring default store constructor with fail-closed behavior. |
| packages/rs-platform-wallet-storage/src/secrets/file/mod.rs | Implements encrypted vault store + CredentialStoreApi/CredentialApi. |
| packages/rs-platform-wallet-storage/src/secrets/file/format.rs | Defines vault format framing and canonical AAD construction. |
| packages/rs-platform-wallet-storage/src/secrets/file/error.rs | Defines file-backend error taxonomy. |
| packages/rs-platform-wallet-storage/src/secrets/file/error_bridge.rs | Bridges file-backend errors into keyring_core::Error + downcast helper. |
| packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs | Implements Argon2id KDF + XChaCha20-Poly1305 seal/open helpers. |
| packages/rs-platform-wallet-storage/src/lib.rs | Exposes secrets module behind feature and adds send/sync/object-safety checks. |
| packages/rs-platform-wallet-storage/SECRETS.md | Updates spec/docs to present-state secrets implementation and audit hooks. |
| packages/rs-platform-wallet-storage/README.md | Updates feature table and build modes to include secrets and helpers. |
| packages/rs-platform-wallet-storage/Cargo.toml | Adds secrets dependencies, platform store deps, features, and dev-dep tweaks. |
| Cargo.lock | Pulls in keyring-core + platform store crates + crypto dependencies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // uniquely borrowed for `cap` bytes during drop. We only | ||
| // write zeros within `[0, cap)`. This wipes the bytes in | ||
| // `[len, cap)` that `Zeroizing<String>` (which clears only | ||
| // `0..len`) would miss. | ||
| #[allow(unsafe_code)] | ||
| let slice = unsafe { std::slice::from_raw_parts_mut(ptr, cap) }; | ||
| slice.zeroize(); |
| let lock = region::lock(bytes.as_ptr(), bytes.capacity().max(1)) | ||
| .map_err(|e| { | ||
| tracing::debug!("mlock failed for SecretBytes: {e}"); | ||
| e | ||
| }) | ||
| .ok(); |
| match crypto::open(&key, &entry.nonce, &aad, &entry.ciphertext) { | ||
| Ok(pt) => Ok(Some(pt.expose_secret().to_vec())), | ||
| Err(FileStoreError::Decrypt) => Err(FileStoreError::WrongPassphrase), | ||
| Err(e) => Err(e), | ||
| } |
| for e in &old_entries { | ||
| let aad = format::aad(format::FORMAT_VERSION, wallet_id.as_bytes(), &e.label); | ||
| let pt = crypto::open(&old_key, &e.nonce, &aad, &e.ciphertext) | ||
| .map_err(|_| FileStoreError::WrongPassphrase)?; |
| let unique = COUNTER.fetch_add(1, Ordering::Relaxed); | ||
| let tmp = path.with_extension(format!("pwsvault.tmp.{}.{unique}", std::process::id())); | ||
| let result = (|| -> Result<(), FileStoreError> { | ||
| let mut opts = OpenOptions::new(); | ||
| opts.write(true).create_new(true); | ||
| set_create_mode(&mut opts); | ||
| let mut f = opts.open(&tmp)?; | ||
| enforce_mode_0600(&f)?; | ||
| f.write_all(&serialized)?; | ||
| f.sync_all()?; | ||
| fs::rename(&tmp, path)?; | ||
| // The directory entry must be fsync'd too, or a crash can | ||
| // lose the rename (SEC-REQ-2.2.11). | ||
| if let Some(parent) = path.parent() { | ||
| let d = fs::File::open(parent)?; | ||
| d.sync_all()?; | ||
| } |
| // Join continuations: a leaking call may wrap across lines. | ||
| for (idx, window) in body.lines().collect::<Vec<_>>().windows(2).enumerate() { | ||
| let joined = format!("{} {}", window[0], window[1]); | ||
| if !joined.contains("expose_secret") { | ||
| continue; |
| /// `BadStoreFormat` with the marker both in the boxed `source()` chain | ||
| /// and as the rendered string — keeps Display informative while letting | ||
| /// downcast recover the structural variant. | ||
| fn bad_format(failure: FileStoreFailure) -> KeyringError { | ||
| KeyringError::BadStoreFormat(failure.to_string()) | ||
| } | ||
|
|
||
| /// Recover a [`FileStoreFailure`] from a `keyring_core::Error`, if | ||
| /// the error was produced by the file backend's [`into_keyring`]. |
| hex::decode_to_slice(hex, &mut bytes).map_err(|_| { | ||
| KeyringError::Invalid( | ||
| "service".to_string(), | ||
| "wallet id hex is not lowercase hex".to_string(), |
…rm-wallet-storage-secrets
…ead of panicking; doc cleanup `EncryptedFileStore::rekey` panicked via `Arc::get_mut(...).expect(...)` whenever an outstanding `EncryptedFileCredential` (which clones the inner `Arc` in `build()`) was still alive — a caller-reachable runtime state, not a logic bug. Swap the `expect` for a recoverable typed `FileStoreError::Busy`, preserving the fail-loud property (still no silent stale-handle rekey). Wire a parity `FileStoreFailure::Busy` unit variant through the SPI bridge (`into_keyring` -> NoStorageAccess, Display, marker_from_message) keeping the enum unit-variants-only + Copy. Add a focused rekey-busy test plus bridge round-trip coverage. Docs: present-state lede + package description (drop "future SecretStore"), fix `__secrets-test-helpers` to name `MemoryCredentialStore`, add `getrandom` to the SECRETS.md audit-scope enumeration, document the load-bearing FileStoreFailure Display text, and note why SecretBytes keeps `.max(1)` on region::lock. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
|
|
||
| /// Errors produced by the `EncryptedFileStore` vault backend. | ||
| #[derive(Debug, thiserror::Error)] | ||
| pub enum FileStoreError { |
There was a problem hiding this comment.
Why do we need both FileStoreError and FileStoreFailure? I would merge FileStoreFailure into FileStoreError , and only have one error.rs file for errors here, not two. I would also impl From or similar for conversion to keyring_core. This approach will lose some details, but I think we prefer that over confusion caused by two similar error types.
|
|
||
| /// Serialize a full vault (header + entries) to bytes. Contains only | ||
| /// salt/params (non-secret) + ciphertext — never plaintext. | ||
| pub(crate) fn serialize(header: &Header, entries: &[Entry]) -> Vec<u8> { |
There was a problem hiding this comment.
Manual serialization when serde / bincode is already in the dependency tree is an overengineering. Use serde + json if possible. It will also make it easier to debug, and data will be encrypted anyway.
| @@ -0,0 +1,226 @@ | |||
| //! In-RAM [`CredentialStoreApi`] test double. | |||
There was a problem hiding this comment.
Let's remove this, use file store instead (in tempfile/tempdir).
| @@ -0,0 +1,398 @@ | |||
| //! Zeroizing secret wrappers. | |||
| //! | |||
| //! [`SecretString`] is a trimmed fork of dash-evo-tool's `Secret` | |||
There was a problem hiding this comment.
remove historical references and our internal IDs like SEC-003 from comments.
| //! buffer wipe on drop, best-effort `region` mlock. | ||
| //! | ||
| //! --- | ||
| //! Portions Copyright (c) Dash Core Group, originating from |
There was a problem hiding this comment.
No need for the license, we own copyright, remove it.
| //! | ||
| //! # Memory hygiene | ||
| //! | ||
| //! The upstream SPI returns `Vec<u8>` from `get_secret`. Consumers |
There was a problem hiding this comment.
This convinced me to have our own API that will never leak raw secret data, and only expose that. Maybe an enum object or sth like that.
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
The PR is close, but the encrypted file store still has one blocking correctness flaw: concurrent mutations can overwrite each other and silently lose secrets. I also confirmed three non-blocking contract issues: entry-corruption is misclassified as a wrong passphrase after header verification, the new Rust DriveInternalError FFI code is not decoded on the Swift side, and the Swift prover warm-up API documents stronger completion semantics than the Rust FFI actually provides.
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3672 8a5ef7a
STDOUT:
STDERR:
Traceback (most recent call last):
File "/Users/claw/.openclaw/workspace/scripts/review_poster.py", line 138, in
result = post_review(repo, pr_number, h), so I posted the same verified findings as a top-level review body._
Reviewed commit: 8a5ef7a
🔴 1 blocking | 🟡 3 suggestion(s)
Verified findings
blocking: Vault writes are unsynchronized read-modify-write cycles that can drop concurrent updates
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 242)
rekey, put, and delete all read the current vault snapshot, mutate an in-memory Vec<VaultEntry>, and then replace the whole file with write_vault(). There is no mutex, file lock, version check, or compare-and-swap across that transaction. The store is explicitly exposed behind Arc<dyn CredentialStoreApi + Send + Sync>, so two threads can legally operate on the same vault at once; separate processes opening the same directory have the same problem. If two writers start from snapshot N, each applies a different change, and the last rename wins, one write is silently lost. For a secret store this is data loss, not a benign race.
suggestion: Entry integrity failures are misreported as wrong passphrases after the header token already authenticated the key
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 251)
derive_and_verify() has already proved that the supplied passphrase matches the vault header before these entry decryptions run. After that point, crypto::open() failing on an entry means the entry ciphertext or its AAD is corrupted or has been moved between labels, not that the passphrase is wrong. Mapping those failures to WrongPassphrase here, and again in get(), collapses the distinct FileStoreError::Decrypt / FileStoreFailure::Decrypt path that error.rs, error_bridge.rs, and SECRETS.md document for callers using downcast_failure(). That prevents callers from distinguishing operator error from vault corruption or tampering.
suggestion: Swift does not decode the new Rust `DriveInternalError` FFI code
packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift (line 292)
Rust now emits DashSDKErrorCode::DriveInternalError = 10 and has tests asserting that dash_sdk::Error::DriveInternalError(...) maps to that code. SDKError.fromDashSDKError still handles only codes 1 through 9 and 99, so code 10 falls into the default branch and is surfaced as .unknown(message). That loses the new error classification exactly at the Swift boundary and breaks any Swift-side handling that needs to distinguish storage-layer Drive failures from generic unknown errors.
switch error.code {
case DashSDKErrorCode(rawValue: 1): // Invalid parameter
return .invalidParameter(message)
case DashSDKErrorCode(rawValue: 2): // Invalid state
return .invalidState(message)
case DashSDKErrorCode(rawValue: 3): // Network error
return .networkError(message)
case DashSDKErrorCode(rawValue: 4): // Serialization error
return .serializationError(message)
case DashSDKErrorCode(rawValue: 5): // Protocol error
return .protocolError(message)
case DashSDKErrorCode(rawValue: 6): // Crypto error
return .cryptoError(message)
case DashSDKErrorCode(rawValue: 7): // Not found
return .notFound(message)
case DashSDKErrorCode(rawValue: 8): // Timeout
return .timeout(message)
case DashSDKErrorCode(rawValue: 9): // Not implemented
return .notImplemented(message)
case DashSDKErrorCode(rawValue: 10): // Drive internal error
return .internalError(message)
case DashSDKErrorCode(rawValue: 99): // Internal error
return .internalError(message)
default:
return .unknown(message)
}
suggestion: Swift warm-up API advertises completion semantics that the Rust FFI explicitly does not provide
packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift (line 332)
The Swift API documents warmUpShieldedProver() as building the proving key so the first shielded send does not pay the 30-second cost inline, and its async signature invites callers to await readiness. The Rust FFI it wraps does not wait for that work: platform_wallet_shielded_warm_up_prover() only calls runtime().spawn_blocking(...) and returns immediately, and the Rust docs explicitly say the first send still pays the cost if it races the background warm-up. As written, await PlatformWalletManager.warmUpShieldedProver() only confirms that the warm-up task was scheduled, not that the prover is ready.
🤖 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] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:242-331: Vault writes are unsynchronized read-modify-write cycles that can drop concurrent updates
`rekey`, `put`, and `delete` all read the current vault snapshot, mutate an in-memory `Vec<VaultEntry>`, and then replace the whole file with `write_vault()`. There is no mutex, file lock, version check, or compare-and-swap across that transaction. The store is explicitly exposed behind `Arc<dyn CredentialStoreApi + Send + Sync>`, so two threads can legally operate on the same vault at once; separate processes opening the same directory have the same problem. If two writers start from snapshot N, each applies a different change, and the last rename wins, one write is silently lost. For a secret store this is data loss, not a benign race.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:251-255: Entry integrity failures are misreported as wrong passphrases after the header token already authenticated the key
`derive_and_verify()` has already proved that the supplied passphrase matches the vault header before these entry decryptions run. After that point, `crypto::open()` failing on an entry means the entry ciphertext or its AAD is corrupted or has been moved between labels, not that the passphrase is wrong. Mapping those failures to `WrongPassphrase` here, and again in `get()`, collapses the distinct `FileStoreError::Decrypt` / `FileStoreFailure::Decrypt` path that `error.rs`, `error_bridge.rs`, and `SECRETS.md` document for callers using `downcast_failure()`. That prevents callers from distinguishing operator error from vault corruption or tampering.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:292-314: Swift does not decode the new Rust `DriveInternalError` FFI code
Rust now emits `DashSDKErrorCode::DriveInternalError = 10` and has tests asserting that `dash_sdk::Error::DriveInternalError(...)` maps to that code. `SDKError.fromDashSDKError` still handles only codes 1 through 9 and 99, so code 10 falls into the default branch and is surfaced as `.unknown(message)`. That loses the new error classification exactly at the Swift boundary and breaks any Swift-side handling that needs to distinguish storage-layer Drive failures from generic unknown errors.
- [SUGGESTION] In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`:332-340: Swift warm-up API advertises completion semantics that the Rust FFI explicitly does not provide
The Swift API documents `warmUpShieldedProver()` as building the proving key so the first shielded send does not pay the 30-second cost inline, and its `async` signature invites callers to `await` readiness. The Rust FFI it wraps does not wait for that work: `platform_wallet_shielded_warm_up_prover()` only calls `runtime().spawn_blocking(...)` and returns immediately, and the Rust docs explicitly say the first send still pays the cost if it races the background warm-up. As written, `await PlatformWalletManager.warmUpShieldedProver()` only confirms that the warm-up task was scheduled, not that the prover is ready.
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.A mobile or desktop wallet built on
rs-platform-walletderives a BIP-39 mnemonic at setup, persists wallet state via the SQLite backend, and the user closes the app. On next launch the wallet must rehydrate without asking for the phrase again. That requires a store 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 thesecretsmodule, enabled by default.What was done?
platform_wallet_storage::secrets— default-on, upstream SPIThe
secretsfeature is in thedefaultfeature set (default = ["sqlite", "cli", "secrets"]). The off-state (--no-default-features --features sqlite,cli) still builds and tests cleanly — proven by a runtime guard test (tests/secrets_off_state.rs) and confirmed bycargo clippy --no-default-features --features sqlite,cliin the gate set.The public SPI is upstream
keyring_core::api::{CredentialApi, CredentialStoreApi}fromkeyring-core 1.0.0. This crate contributes two production backends plus zeroizing wrappers — not a bespoke trait surface.Key shape
serviceSERVICE_PREFIX + hex(wallet_id)—"dash.platform-wallet-storage/"+ 64 hex chars; one namespace per walletuserlabel, validated against^[A-Za-z0-9._-]{1,64}$(SEC-REQ-4.3) atbuildtime and at every operationWalletIdis a fixed 32-byte newtype.validated_labelruns defence-in-depth because credentials are long-lived objects.Zeroizing wrappers (
src/secrets/secret.rs)SecretBytes— wrapsZeroizing<Vec<u8>>. RedactingDebug, noDisplay/Deref/Serialize, constant-time equality viasubtle::ConstantTimeEqonly.PartialEq/Eqare NOT implemented —==onSecretBytesorSecretStringis a compile error, enforced bycompile_faildoc-tests that serve as durable forward-compat guards. Best-effortmlockviaregion. Used for seeds, derived keys, AEAD key material, and decrypted plaintext.SecretString— in-crate type for mnemonics and passphrases. Same redactingDebug/ no-Display/ no-Deref/ no-Serializedisciplines. Full-capacity zeroize onDrop.PartialEqremoved; constant-time compare viact_eqonly.The memory hygiene contract at the SPI seam:
CredentialApi::get_secretreturnsVec<u8>; callers MUST wrap it intoSecretBytes::new(entry.get_secret()?)immediately, with no named intermediate binding.SecretBytes::newtakes theVec<u8>by value — the bare-buffer exposure window is zero statements.EncryptedFileStore(src/secrets/file/)Argon2id + XChaCha20-Poly1305 vault with the following security construction (unchanged from the original 10 commits):
argon2 0.5.3, pinned), enforced floors m ≥ 19 MiB / t ≥ 2 / p = 1, 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, in the header. Never constant, never derived fromwallet_idor path.chacha20poly1305 0.10.1, pinned). No unauthenticated mode.aes-gcmis absent.XNonceperput, stored alongside ciphertext. Counters are explicitly forbidden (multi-process / restart / file-copy scenario causes catastrophic keystream reuse).format_version ‖ wallet_id ‖ label. Decryption under a different(wallet_id, label)or rolled-backformat_versionfails the Poly1305 tag. Blob-swap and replay attacks are structurally rejected.decryptAPI — consistent with the lesson of RUSTSEC-2023-0096).PWSVAULT-VERIFY-v1detects a wrong passphrase before any entry decrypt attempt, preventing mixed-key vault corruption.O_EXCL+fchmodbefore any plaintext-derived byte is written. Pre-existing file with looser permissions surfaces as a typed error — never blindly overwritten.fsynctemp →renameover target →fsyncdirectory. A crash mid-write never truncates the prior vault..bakleft holding old key material.EncryptedFileStorenow implementsCredentialStoreApi+CredentialApi. File-backend-specific failure modes (WrongPassphrase,KdfFailure,MalformedVault,InsecurePermissions, etc.) bridge to the upstream error type via theFileStoreFailureboxed-marker mechanism described below.OS keyring backend
secrets::default_credential_store()returnsArc<dyn CredentialStoreApi + Send + Sync>over the platform's native store (linux-keyutils-keyring-store→dbus-secret-service-keyring-storeon Linux/FreeBSD;apple-native-keyring-storeon macOS;windows-native-keyring-storeon Windows). Fail-closed withkeyring_core::Error::NoDefaultStoreon headless / unknown OS — never a silent plaintext fallback.The cross-SPI error bridge (
src/secrets/file/error_bridge.rs)keyring_core::Errorhas noWrongPassphrasevariant. File-backend errors bridge as follows:WrongPassphraseboxes aFileStoreFailuremarker insidekeyring_core::Error::NoStorageAccess(matching the operator UX of "keyring locked"); other failure modes render intoBadStoreFormat's staticStringpayload.secrets::downcast_failure(&err)is the single recovery path for typed distinction.FileStoreFailureis unit-variants only (#[derive(Copy)]) with a_assert_copy::<FileStoreFailure>()compile-time witness. No variant carries a path, passphrase, label, or stringified user payload.Displayinterpolates no user data.cargo-denyremoved; RustSec advisory check covers all depsThe dedicated
cargo-denyCI job anddeny.tomlare deleted. Becausesecretsis now in the default feature set, the pinned crypto crates (argon2,chacha20poly1305,zeroize,subtle,region,keyring-core,getrandom, per-platform store crates) are unconditionally in the lockfile and therefore unconditionally in scope for the existingrustsec/audit-checkjob. No advisory coverage gap.MemoryCredentialStoreTest-only backend, gated behind
__secrets-test-helpers(unreachable from production builds). The dev-dependency for test helpers carriesdefault-features = false, features = ["__test-helpers"]so the off-state test (secrets_off_state) is genuinely non-vacuous.Secrets boundary: maintained and guarded
tests/secrets_scan.rs— grepssrc/sqlite/schema/andmigrations/forprivate,mnemonic,seed,xpriv,secret. Exemptssrc/secrets/by design.tests/secrets_guard.rs— positive guard forsrc/secrets/. Forbids logging/format sinks that pair withexpose_secret(...)on the same logical statement. Also forbids{:?}-debug-format paired withkeyring_core::Errorin anysrc/secrets/file (Smythe EDIT-2:BadEncoding(Vec<u8>)/BadDataFormat(Vec<u8>, _)carry byte payloads inDebug; our backends never construct those variants with secret bytes, and the guard enforces it). Guard is proven non-vacuous by a plant test.tests/secrets_api.rs— shape guards:get_secretre-wraps throughSecretBytes::new, redactingDebugonSecretBytes/SecretString, noBox<dyn Error>insrc/secrets/.tests/secrets_off_state.rs— runtime guard that--no-default-features --features sqlite,clibuilds without thesecretsmodule (D4).SECRETS.mdreflects the delivered specSECRETS.mdis rewritten as a present-state specification covering the keyring-core SPI, key shape, memory hygiene contract, both backends, the error bridge, and all audit hooks. No migration narrative.Downstream cascade (deferred, noted)
PR #3692 (full wallet rehydration) and #3693 (contacts + identity-keys) are stacked on this branch and have been rebased onto the keyring-core SPI: their
seed_provider_adapter.rsnow wraps aCredentialStoreApi-based store using theSecretBytes::new(entry.get_secret()?)zeroization seam. The consumer seam lives in #3692 because it consumesplatform_wallet::seed_providertypes that exist only on that branch — it could not land here.How Has This Been Tested?
The following commands form the intended gate. CI has not yet been run on this branch (it is a draft); substantive CI jobs will run when un-drafted. An internal multi-agent review (Smythe security re-validation, Marvin QA, Adams consistency) confirmed all CRITICAL and HIGH findings resolved after one targeted fix cycle — no open items remain in this PR's scope.
Test categories covered:
EncryptedFileStoreand OS keyring (put→ drop → reopen →get, byte-exact)delete_credentialhonoursNoEntrycontract on absent entry(wallet_id, label)→ rejectedputoperations on the same entryVersionUnsupportedInsecurePermissions.bakretainedDebugofSecretBytes/SecretStringis redactedMemoryCredentialStoreunreachable without__secrets-test-helpers==onSecretBytes/SecretStringis a compile error (compile_faildoc-tests)secrets_guardEDIT-2 scan: no{:?}paired withkeyring_core::Errorinsrc/secrets/--no-default-features --features sqlite,cli):secretsmodule absentBreaking Changes
None. No semver-breaking change to anything published. The retired
SecretStoretrait andSecretStoreErrortype were inside this unmerged PR — they never reached a release. The upstream-SPI adoption is purely internal to this PR's evolution. Existing builds without--features secretscompile exactly as before.The downstream PRs #3692 and #3693 will require a rebase to update their adapter to the
CredentialStoreApi-based seam — this is an intra-PR-stack concern, not a published API break.Checklist:
For repository code-owners and collaborators only
Known limitations and accepted risks
key-wallet::compute_wallet_idDebug/log hygiene. Accepted residual, untouched by this rework. A follow-up is owed before wider rollout.seed_provider_adapter.rsto theCredentialStoreApi-based seam. Tracked; deliberately deferred per rework plan.default_credential_store()fails closed (NoDefaultStore). No silent fallback. Operators on headless systems useEncryptedFileStoreexplicitly.🤖 Co-authored by Claudius the Magnificent AI Agent