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.
…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.
There was a problem hiding this comment.
Done in b6a84fdc19 — FileStoreFailure collapsed into FileStoreError; error_bridge.rs deleted entirely. Single typed-error path, Corruption distinguished from WrongPassphrase. Leaving for your ack.
|
|
||
| /// 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.
There was a problem hiding this comment.
Done in 8ab4208332 — replaced the manual byte-layout with versioned serde_json (two-step parse: envelope header then payload). Manual codec retired. Leaving for your ack.
| @@ -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).
There was a problem hiding this comment.
Done in e1c7fa9418 — MemoryCredentialStore removed; the __secrets-test-helpers feature retired. Tests now use the file store backed by tempfile::tempdir(). Leaving for your ack.
| @@ -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.
There was a problem hiding this comment.
Done in dc492ccf89 — stripped the SEC-* / SECRET-* historical IDs from inline comments across the secrets module. Leaving for your ack.
| //! 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.
There was a problem hiding this comment.
Done in dc492ccf89 — license header removed from secret.rs (and other secrets-module files). Leaving for your ack.
| //! | ||
| //! # 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.
There was a problem hiding this comment.
Done in 0066a5a472 — new public SecretStore API exposes SecretBytes only, never &[u8] / Vec<u8>. keyring_core::CredentialStoreApi adapter sits behind it. Leaving for your ack.
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.
…bridge; distinguish Corruption from WrongPassphrase Collapse the two-error-type split into a single `FileStoreError` enum and delete `error_bridge.rs` entirely. The boxed-marker downcast machinery (`FileStoreFailure`, `into_keyring`, `downcast_failure`, `marker_from_message`, `bad_format`) is replaced by a plain `impl From<FileStoreError> for keyring_core::Error`. The SPI projection is lossy by design: `WrongPassphrase`/`Busy` ride in `NoStorageAccess` with the typed error boxed as the source (still recoverable by downcast); the corruption/format family collapses into `BadStoreFormat`. Stop mapping AEAD tag failures to `WrongPassphrase` once the header verify-token has already passed. In `get()` and `rekey()`, an entry tag failure means corruption or tampering, so it now maps to the new `Corruption` variant. The internal `Decrypt` signal stays crate-private to the crypto seam and is translated at the call sites that hold the vault context. New tests prove the distinction: a bit-flipped entry ciphertext after a correct unlock yields `Corruption`, while a genuinely wrong passphrase still yields `WrongPassphrase`; the `Busy` no-panic rekey test is kept. BREAKING CHANGE: `FileStoreFailure` and `downcast_failure` are removed from the public surface; consumers recover structure from the typed `FileStoreError` or by downcasting `keyring_core::Error::NoStorageAccess`. Refs CMT-004 CMT-005 CMT-006 CMT-011 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… and dangling mlock on empty SecretBytes Delete `SecretString`'s custom `Drop`. It formed a `&mut [u8]` over the uninitialized `len..cap` region via `from_raw_parts_mut`, which is UB even when only writing. `Zeroizing<String>` already wipes the full capacity on drop, so the custom Drop was redundant; removing it makes `SecretString` symmetric with `SecretBytes`. Field order (`inner` before `_lock`) still wipes the buffer while it is mlock'ed. Guard `SecretBytes::new`'s `region::lock` on `capacity() > 0`: an empty `Vec`'s `as_ptr()` is dangling, and locking a forced length of 1 over it invoked an OS call on an invalid address. Drop the dead `bytes.zeroize()` after `std::mem::take` — the move transferred the allocation, leaving nothing to wipe. Add an empty-`SecretBytes` construction test; the ignored full-capacity wipe tests still pass with the custom Drop gone. Refs CMT-001 CMT-003 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d two-step parse (CMT-007) Replace the hand-rolled binary `format.rs` with a serde_json vault: a top-level `version` key, lax `VersionProbe` for the dispatch gate, then a strict `deny_unknown_fields` `VaultFile` payload for the compiled-in FORMAT_VERSION. Byte fields (salt, nonce, verify_ct, ciphertext) are lowercase hex (no new base64 dep); Argon2 params are JSON numbers. Smythe's binding conditions: - C1: `aad()`/`verify_aad()` unchanged; the JSON `version` is never routed into AAD — documented as the AAD-determinism invariant. - C2/SEC-001: add Argon2 upper bounds (ARGON2_MAX_M_KIB = 1 GiB, ARGON2_MAX_T = 16); rename `enforce_floors` -> `enforce_bounds`, gated in `derive_key` BEFORE Params::new / hash_password_into, so an inflated m_kib fails before any allocation and before verify-token derivation. - C3: `VersionProbe` lax; `VaultFile`/`KdfDescriptor`/`EntryRecord` `deny_unknown_fields`. - C4: explicit post-parse `kdf.id == KDF_ID_ARGON2ID` check. - C5/SEC-003: all serde_json errors mapped to MalformedVault / VersionUnsupported with the source discarded; regression test asserts no input bytes leak into the rendered error. - C6/SEC-002: every byte-field length validated post-deserialize (salt/nonce widths, verify_ct/ciphertext >= AEAD tag); wrong length => MalformedVault, never a panic in copy_from_slice. - C7: version stays 2 (clean pre-release break); no committed `*.pwsvault` fixtures exist; roundtrip + bad-version tests ported to the JSON path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…amedTempFile::persist (CMT-009) Replace the POSIX-only `O_EXCL`-temp + `fs::rename` + dir-fsync writer with `tempfile::NamedTempFile::persist`, the crate's existing idiom (sqlite/backup.rs). The old `rename`-over-existing path failed on the second write on Windows; `persist` replaces atomically on win/mac/linux, amd64+arm. Smythe's binding conditions: - C8: `NamedTempFile::new_in(parent)` keeps the temp in the destination's directory so `persist` is never cross-volume. - C9: do not loosen the temp perms (tempfile is owner-private on all OSes); on Unix additionally pin 0600 before writing. Windows DACL work deferred for v1. - C10/C11: order is write -> sync_all (all OSes) -> persist -> `#[cfg(unix)]` parent-dir fsync; never pre-remove the destination; on persist failure the temp drops and self-cleans (no manual remove race). Comment notes Windows relies on NTFS metadata journaling for dir durability. - C12: drop the `COUNTER` static + `std::process::id()` temp naming. - C13: `check_perms` read-check stays `#[cfg(unix)]`; added a `// TODO(CMT-009)` for the deferred Windows ACL read-check. Regression test `second_write_over_existing_vault_succeeds` exercises the replace-over-existing path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tBytes, never raw bytes (CMT-002)
Add `SecretStore` as the public, never-leaking secrets entry point.
`get` yields a zeroizing `SecretBytes` (a raw `Vec<u8>` never crosses the
boundary); `set` takes `&SecretBytes` so callers cannot pass an unwrapped
buffer. The `File` arm delegates to new inherent typed methods on
`EncryptedFileStore`, returning `FileStoreError` losslessly so
`WrongPassphrase` vs `Corruption` vs `Busy` stay distinct. The `Os` arm
projects `keyring_core::Error` best-effort into the new
`FileStoreError::OsKeyring { kind }` payload-free discriminant. The
internal `CredentialApi`/`CredentialStoreApi` SPI impls are unchanged;
`SecretStore` wraps them.
Docs (SECRETS.md, lib.rs, secrets/mod.rs) present `SecretStore` as the
consumer front door with keyring_core as the internal SPI.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ed-path error distinction Drop the boxed-marker recovery in `From<FileStoreError> for keyring_core::Error`: the SPI seam is now lossy and string-only, with no `Box<dyn>` round-trip. The lossless `WrongPassphrase`/`Corruption`/`Busy` distinction lives on the typed `SecretStore` path. Repoint the in-crate SPI tests that recovered the typed error through `NoStorageAccess` onto the typed path, asserting only the lossy projection at the seam. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ore NoStorageAccess for lossless SPI recovery Revert the string-only `From<FileStoreError> for keyring_core::Error`: `WrongPassphrase` / `Busy` now box the single typed `FileStoreError` itself into `NoStorageAccess`, so external keyring_core-SPI consumers recover the variant losslessly via `source().downcast_ref::<FileStoreError>()`. No second type is reintroduced (FileStoreFailure stays deleted), satisfying the original error.rs objection. The `BadStoreFormat` group has no box slot, so it carries only a secret-free string and stays fully typed on the `SecretStore` path. Seam tests assert downcast recovery and the secret-free BadStoreFormat rendering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re __secrets-test-helpers (CMT-008) The in-RAM MemoryCredentialStore test double had no consumer outside its own module. Its behaviors (label rejection, namespacing, zeroizing storage) are already covered by the tempdir-backed EncryptedFileStore tests, so the store and its dedicated `__secrets-test-helpers` feature are retired. The dev-dependency self-reference uses `__test-helpers`, not the secrets one, so nothing else needs it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pose_secret guard scan (CMT-012/010)
CMT-012: `hex::decode_to_slice` accepts uppercase, so `parse_service`
now rejects any A-F before decoding — the service string is always
constructed lowercase via `WalletId::to_hex`, making lowercase a clean
parse invariant. Adds a test that an uppercase-hex service is rejected
and the lowercase form of the same bytes is accepted.
CMT-010: the expose_secret leak guard joined only a 2-line window, so a
3+-line `tracing::…(field = expose_secret(), …)` call slipped through.
The scan now groups whole statements (concatenating until parens
balance and a `;`/`{` is seen) so the sink and `expose_secret` land in
one window. Adds a non-vacuous planted 3-line case the widened scan
catches and the old 2-line window would have missed.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ader (CMT-013/014) CMT-013: removes internal finding-ID and rework narration (SEC-00N, EDIT-N, CMT-NNN, "trimmed fork of", "the defect: used to…") from comments across src/secrets/, keeping the present-state behavior and requirement-spec rationale. Comments describe what IS, not the journey. CMT-014: removes the embedded MIT license-text block atop secret.rs (first-party, same org, matching license) and replaces the module header with a one-line doc. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… failures (Display-only, secret-free)
Library-idiom + security-event logging only; no blanket error! at
routine return sites.
- Swallowed mlock failures in secret.rs (3 sites) move from debug! to
warn!: they are .ok()-swallowed and caller-invisible, yet
security-relevant (the secret may be swappable to disk or land in a
core dump). Display `{e}` only.
- Corruption/tamper detected in get()/rekey() (post-verify AEAD tag
failure → Corruption): error! with the non-secret wallet-id/label,
Display only, never the secret or the raw keyring error.
- Vault write failure in write_vault: warn! with the io error's
Display; paths are caller-supplied non-secret.
Never `{:?}` a keyring_core::Error and never log a secret wrapper; all
new lines use `%` Display, so the EDIT-2 no-debug-format guard still
passes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_secrets-test-helpers references (QA-002) The `__secrets-test-helpers` feature and its `secrets::MemoryCredentialStore` in-RAM test double were removed in the keyring_core SPI rework. Remove the stale feature row from the README Cargo features table and replace the obsolete backend bullet in SECRETS.md with the current test pattern: a tempdir-backed `EncryptedFileStore` (or `SecretStore::file`) constructed via `tempfile::tempdir()`, available under the default `secrets` feature with no special flag required. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…istor' into feat/platform-wallet-storage-secrets # Conflicts: # Cargo.lock
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Three carried-forward prior findings still reproduce at 6aa2942: the file vault still does unsynchronized read-modify-write updates, Swift still drops FFI error code 10 into .unknown, and Swift’s shielded prover warm-up API still overstates completion semantics. The prior wrong-passphrase-versus-corruption finding is fixed. The latest delta also introduces a new blocking regression: rekeying one wallet mutates the store-wide passphrase for every wallet sharing that file-store directory.
🔴 2 blocking | 🟡 2 suggestion(s)
2 additional finding(s) omitted (not in diff).
Review details
🤖 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/file/mod.rs`:
- [BLOCKING] lines 216-394: Carried-forward prior finding: vault updates still race and can silently discard concurrent writes
`put`, `delete`, and `rekey` still implement updates as `read_vault(path)` -> mutate an in-memory `Vec<VaultEntry>` -> `write_vault(path, ...)` with no mutex, file lock, generation check, or compare-and-swap. This is not an internal-only pattern: `EncryptedFileStore` is exposed through `CredentialStoreApi`, `build()` clones the inner `Arc`, and the public API is explicitly multi-wallet, so concurrent callers in one process are supported and separate processes can point at the same directory as well. If two writers start from the same snapshot, the later `persist(path)` replaces the whole file and silently drops the earlier mutation. Atomic rename protects crash consistency, but it does not protect against lost updates, so this remains a real integrity/data-loss bug in the secret store.
In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [BLOCKING] lines 275-316: New finding in latest delta: `rekey(wallet_id, ...)` corrupts the store-wide passphrase for other wallets
The new backend is explicitly one store directory for many wallet IDs: each vault file is namespaced by `wallet_id` (`vault_path`), and the public `SecretStore` routes arbitrary `WalletId` values through one `EncryptedFileStore`. `rekey(wallet_id, new_passphrase)` rewrites only that wallet's file, but then assigns `self.passphrase = new_passphrase` for the entire store. After rekeying wallet A, any existing wallet B in the same directory still has its old header and can no longer be opened because future reads derive with A's new passphrase. The `None` branch is worse: rekeying a nonexistent wallet updates the global passphrase without rewriting any vault file, which immediately orphans every existing wallet in that directory. That is a correctness and data-loss regression introduced by the per-wallet-file redesign.
In `packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift`:
- [SUGGESTION] lines 292-314: Carried-forward prior finding: Swift still does not map Rust `DriveInternalError` (code 10)
Rust now emits `DashSDKErrorCode::DriveInternalError = 10` and the FFI conversion explicitly uses that code for `dash_sdk::Error::DriveInternalError(...)`, but `SDKError.fromDashSDKError` still only handles 1 through 9 and 99. An incoming code 10 therefore falls through the default arm and becomes `.unknown(message)`. That drops the new error classification at the Swift boundary and prevents Swift callers from distinguishing Drive-layer failures from generic unknown errors.
In `packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift`:
- [SUGGESTION] lines 332-340: Carried-forward prior finding: Swift warm-up API still promises readiness that the Rust FFI does not provide
`warmUpShieldedProver()` is documented and typed in a way that invites callers to `await` prover readiness, but the Rust entry point it wraps explicitly returns immediately after scheduling `CachedOrchardProver::warm_up()` on a background worker. Readiness is exposed separately through `platform_wallet_shielded_prover_is_ready()`. As implemented, `await PlatformWalletManager.warmUpShieldedProver()` confirms only that the warm-up task was queued, not that the proving key is available, so the Swift API contract is still misleading.
Reviewed commit: 6aa2942d
GitHub rejected inline review comments for this update, so the findings are posted body-only.
📦 Review-response push + base merge-up —
|
| OID | Headline | Closes |
|---|---|---|
b6a84fdc19 |
refactor!: unify FileStoreError, drop error_bridge; distinguish Corruption from WrongPassphrase |
lklimek error.rs:25, copilot file/mod.rs:374 |
647567e322 |
fix: remove redundant SecretString Drop (UB) and dangling mlock on empty SecretBytes |
copilot secret.rs:131,216 |
8ab4208332 |
feat!: serde_json vault format with versioned two-step parse (CMT-007) |
lklimek format.rs:84 |
68ed3d13b5 |
fix: cross-platform atomic vault write via NamedTempFile::persist (CMT-009) |
copilot file/mod.rs:224 |
0066a5a472 |
feat!: public SecretStore API exposing SecretBytes, never raw bytes (CMT-002) |
lklimek mod.rs:32 |
c636ac07d0 |
refactor: string-only keyring_core From; typed-path error distinction |
(architecture follow-up) |
a5c5bf0c6a |
fix: box typed FileStoreError into keyring_core NoStorageAccess for lossless SPI recovery |
(post-rework loss-of-typed-error gap) |
e1c7fa9418 |
refactor: remove MemoryCredentialStore; retire __secrets-test-helpers (CMT-008) |
lklimek memory.rs:1 |
671ce69c3f |
fix: enforce lowercase-hex service, widen expose_secret guard scan (CMT-012/010) |
copilot file/mod.rs:351, copilot tests/secrets_guard.rs:61 |
dc492ccf89 |
docs: strip historical comments + license header (CMT-013/014) |
lklimek secret.rs:3,17 |
c58a2b5d00 |
feat: log swallowed mlock + corruption/write failures (Display-only, secret-free) |
(observability gap) |
6aa2942d22 |
docs: drop deleted MemoryCredentialStore / __secrets-test-helpers references (QA-002) |
(stale-doc QA finding) |
Base merge-up — 34c8ecbb9c
Merged origin/feat/platform-wallet-sqlite-persistor (which moved from 8a5ef7aabd to fe01634684 since this PR was last rebased) — brings in:
- 10 hardening commits from feat(platform-wallet): add platform-wallet-storage crate (sqlite persister) #3625 (CMT-001..012 fixes — 2 BLOCKING data-loss, 2 HIGH security, 6 MEDIUM, 2 LOW)
- 11 atomicity commits (A-1..A-8 + N-1..N-10 — 4 HIGH, 5 MEDIUM, 1 LOW, 5 doc-only) including the breaking
commit_writes→CommitReportsignature change onSqlitePersister - 1 polish commit (Drop side-effect comment + README operational notes)
Conflicts: only Cargo.lock (regenerated by cargo). Auto-merged: Cargo.toml, README.md, src/lib.rs (disjoint sections on each side). All 4 WalletStorageError additions (BlobTooLarge, AssetLockEntryMismatch, IntegrityCheckFailed from sqlite side; FileStore-related from secrets side) coexist with wildcard-free classifier matches intact.
Verification post-merge
cargo fmt --check— cleancargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings— cleancargo test -p platform-wallet-storage— all green (114 unit + integration + 12 hardening + 11 atomicity + secrets API + 5 restore_staged_validation)cargo test --doc -p platform-wallet-storage— 3 passed, 1 ignored
Thread status
- 8 copilot threads — auto-resolved (bot, fixes verified at file:line)
- 6 lklimek threads — replies posted with the addressing commit's OID; left unresolved for the author to ack
Head + state
| Head | 34c8ecbb9c |
| Mergeable | should refresh to MERGEABLE once GitHub re-evaluates against the merged base |
| Base | feat/platform-wallet-sqlite-persistor @ fe01634684 (#3625) |
🤖 Co-authored by Claudius the Magnificent AI Agent
thepastaclaw
left a comment
There was a problem hiding this comment.
Code Review
Of the 4 prior findings from review 6aa2942d, rekey() changing the store-wide passphrase after re-encrypting only one wallet is STILL VALID, file-backed secret mutations remaining unsynchronized read-modify-write cycles is STILL VALID, forward-schema databases being accepted by SqlitePersister::open() is FIXED, and staged-restore validation failures deleting WAL/SHM before rejection is FIXED. The carried-forward problems are both in the file-backed secrets backend. The latest delta adds 4 new blocking regressions in SQLite restore/persist and SPV runtime paths, plus 4 valid lower-severity filesystem hardening issues; this requires changes before merge.
_Note: Inline posting failed (command failed (1): python3 scripts/review_poster.py dashpay/platform 3672 34c8ecb
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: 34c8ecb
🔴 6 blocking | 🟡 4 suggestion(s)
Verified findings
blocking: Carried-forward prior finding: `rekey()` changes the store passphrase globally after re-encrypting only one wallet vault
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 275)
EncryptedFileStoreInner stores a single passphrase for the whole store, but rekey() only rewrites the vault file for the supplied wallet_id before assigning self.passphrase = new_passphrase. The same store instance serves arbitrary wallet IDs through build(), put(), get(), and delete(), so rekeying wallet_a immediately makes every other existing vault in the directory unreadable through that store instance: subsequent operations derive with the new passphrase even though those other vault files are still encrypted with the old one. This is a real multi-wallet data-loss bug, not just a theoretical inconsistency.
blocking: Carried-forward prior finding: file-backed secret mutations still lose concurrent updates
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 321)
Both put() and delete() still implement mutations as read_vault -> mutate Vec<VaultEntry> -> write_vault with no in-process mutex and no inter-process file lock. CredentialStoreApi::build() returns independent credentials backed by the same Arc<EncryptedFileStoreInner>, so concurrent writers can read the same old snapshot and then each atomically replace the vault with conflicting results. Atomic rename prevents torn files, but it does not serialize the read-modify-write cycle, so the last writer silently discards the other update.
blocking: Latest-delta finding: `restore_from()` still deletes destination WAL/SHM before its last fallible steps
packages/rs-platform-wallet-storage/src/sqlite/backup.rs (line 242)
The staged-copy validation now runs before the unlink loop, which fixes the earlier rejection path, but the restore is still not atomic. restore_from() removes <dest>-wal and <dest>-shm before the later set_permissions() and tmp.persist(dest_db_path) calls. Either of those later operations can still fail, leaving the old main database file in place after its WAL/SHM sidecars have already been deleted. On a live WAL-mode database that drops committed-but-uncheckpointed state from the destination. The code comment claiming that either both the main DB and siblings are replaced or none are touched is therefore false in the current implementation.
blocking: Latest-delta finding: committed WAL/SHM sidecars are never re-chmodded after ordinary writes
packages/rs-platform-wallet-storage/src/sqlite/persister.rs (line 171)
SqlitePersister::open() calls apply_secure_permissions(&config.path) immediately after opening the database, but the WAL and SHM sidecars normally do not exist yet. The normal write path in write_changeset_in_one_tx() commits at line 650 and returns without reapplying the helper, and apply_secure_permissions() is only called from open, backup, and restore. That means the first transaction can create wallet.db-wal and wallet.db-shm with the process umask instead of 0600, exposing recently committed wallet pages on multi-user hosts. The new test only proves the helper works when called manually; it does not cover the persistence path that actually creates the sidecars.
tx.commit()?;
apply_secure_permissions(&self.config.path)?;
Ok(())
blocking: Latest-delta finding: `asset_locks::apply()` still writes rows whose typed columns can contradict the serialized blob
packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs (line 36)
The read path now rejects typed-column/blob mismatches in decode_row(), but the write path still does not validate that the map key op matches entry.out_point or that the typed account_index column matches entry.account_index. Unlike identity_keys::apply(), which now rejects mismatched typed-vs-blob representations before persisting them, asset_locks::apply() will serialize contradictory state to disk. Because AssetLockChangeSet and AssetLockEntry have public fields, a malformed caller can construct this inconsistency today. The result is durable on-disk corruption that the next load will hard-fail on.
blocking: Latest-delta finding: `SpvRuntime::run()` holds the client read lock across `await` and blocks `stop()` forever
packages/rs-platform-wallet/src/spv/runtime.rs (line 138)
run() takes self.client.read().await, borrows the inner DashSpvClient, and then awaits client.run() while the RwLock read guard is still alive. stop() needs self.client.write().await before it can call c.stop(), so the advertised shutdown path cannot progress while run() is active. This is not required by the client API: the vendored dash-spv revision used here implements Clone for DashSpvClient, so run() could clone the client out of the lock and drop the guard before awaiting. As written, background sync can deadlock its own shutdown.
let client = {
let client_guard = self.client.read().await;
client_guard
.as_ref()
.cloned()
.ok_or(PlatformWalletError::SpvNotRunning)?
};
let result = client
.run()
.await
.map_err(|e| PlatformWalletError::SpvError(e.to_string()));
let mut client = self.client.write().await;
let _ = client.take();
result
suggestion: Vault directory permissions are never hardened or validated
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 73)
EncryptedFileStore::open() creates or accepts the vault directory and immediately trusts it, but only individual vault files are later checked for 0600. On Unix, a default create_dir_all() under a normal umask can leave the directory traversable by other local users, which leaks wallet IDs through <wallet_id>.pwsvault filenames and allows deletion or replacement of vault files in shared or group-writable directories. That weakens the backend's local-filesystem trust boundary even when each vault file itself is 0600.
suggestion: `read_vault()` allocates the entire file before enforcing any size bound or authenticity check
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 217)
read_vault() performs fs::read(path) immediately after metadata lookup, so any replaced or hostile vault file is fully loaded into memory before JSON parsing, KDF parameter validation, or AEAD verification runs. That gives an attacker who can replace the file at rest a straightforward memory-exhaustion and availability kill switch on every get/put/delete/rekey path that opens the vault.
suggestion: Permission validation is vulnerable to a metadata-to-read race
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 217)
read_vault() checks permissions with fs::metadata(path) and then performs a separate fs::read(path). If an attacker can replace the directory entry between those two syscalls, the code can validate one file's mode bits and then parse different bytes. Combined with the missing directory hardening, this bypasses the intended permission gate and leaves the decrypt path open to rollback or denial-of-service through attacker-chosen vault contents.
suggestion: Windows builds still skip the pre-existing permissions check entirely
packages/rs-platform-wallet-storage/src/secrets/file/mod.rs (line 553)
On non-Unix targets check_perms() is a stub that always returns Ok(()), so the documented invariant that a pre-existing vault with loose permissions is rejected is not enforced on Windows at all. A Windows user can therefore open and keep using a vault file with an overly broad ACL, which exposes ciphertext and metadata to other local principals and gives them a practical tamper or rollback point.
🤖 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`:275-317: Carried-forward prior finding: `rekey()` changes the store passphrase globally after re-encrypting only one wallet vault
`EncryptedFileStoreInner` stores a single `passphrase` for the whole store, but `rekey()` only rewrites the vault file for the supplied `wallet_id` before assigning `self.passphrase = new_passphrase`. The same store instance serves arbitrary wallet IDs through `build()`, `put()`, `get()`, and `delete()`, so rekeying `wallet_a` immediately makes every other existing vault in the directory unreadable through that store instance: subsequent operations derive with the new passphrase even though those other vault files are still encrypted with the old one. This is a real multi-wallet data-loss bug, not just a theoretical inconsistency.
- [BLOCKING] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:321-395: Carried-forward prior finding: file-backed secret mutations still lose concurrent updates
Both `put()` and `delete()` still implement mutations as `read_vault -> mutate Vec<VaultEntry> -> write_vault` with no in-process mutex and no inter-process file lock. `CredentialStoreApi::build()` returns independent credentials backed by the same `Arc<EncryptedFileStoreInner>`, so concurrent writers can read the same old snapshot and then each atomically replace the vault with conflicting results. Atomic rename prevents torn files, but it does not serialize the read-modify-write cycle, so the last writer silently discards the other update.
- [BLOCKING] In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:242-274: Latest-delta finding: `restore_from()` still deletes destination WAL/SHM before its last fallible steps
The staged-copy validation now runs before the unlink loop, which fixes the earlier rejection path, but the restore is still not atomic. `restore_from()` removes `<dest>-wal` and `<dest>-shm` before the later `set_permissions()` and `tmp.persist(dest_db_path)` calls. Either of those later operations can still fail, leaving the old main database file in place after its WAL/SHM sidecars have already been deleted. On a live WAL-mode database that drops committed-but-uncheckpointed state from the destination. The code comment claiming that either both the main DB and siblings are replaced or none are touched is therefore false in the current implementation.
- [BLOCKING] In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:171-177: Latest-delta finding: committed WAL/SHM sidecars are never re-chmodded after ordinary writes
`SqlitePersister::open()` calls `apply_secure_permissions(&config.path)` immediately after opening the database, but the WAL and SHM sidecars normally do not exist yet. The normal write path in `write_changeset_in_one_tx()` commits at line 650 and returns without reapplying the helper, and `apply_secure_permissions()` is only called from open, backup, and restore. That means the first transaction can create `wallet.db-wal` and `wallet.db-shm` with the process umask instead of `0600`, exposing recently committed wallet pages on multi-user hosts. The new test only proves the helper works when called manually; it does not cover the persistence path that actually creates the sidecars.
- [BLOCKING] In `packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs`:36-50: Latest-delta finding: `asset_locks::apply()` still writes rows whose typed columns can contradict the serialized blob
The read path now rejects typed-column/blob mismatches in `decode_row()`, but the write path still does not validate that the map key `op` matches `entry.out_point` or that the typed `account_index` column matches `entry.account_index`. Unlike `identity_keys::apply()`, which now rejects mismatched typed-vs-blob representations before persisting them, `asset_locks::apply()` will serialize contradictory state to disk. Because `AssetLockChangeSet` and `AssetLockEntry` have public fields, a malformed caller can construct this inconsistency today. The result is durable on-disk corruption that the next load will hard-fail on.
- [BLOCKING] In `packages/rs-platform-wallet/src/spv/runtime.rs`:138-158: Latest-delta finding: `SpvRuntime::run()` holds the client read lock across `await` and blocks `stop()` forever
`run()` takes `self.client.read().await`, borrows the inner `DashSpvClient`, and then awaits `client.run()` while the `RwLock` read guard is still alive. `stop()` needs `self.client.write().await` before it can call `c.stop()`, so the advertised shutdown path cannot progress while `run()` is active. This is not required by the client API: the vendored `dash-spv` revision used here implements `Clone` for `DashSpvClient`, so `run()` could clone the client out of the lock and drop the guard before awaiting. As written, background sync can deadlock its own shutdown.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:73-76: Vault directory permissions are never hardened or validated
`EncryptedFileStore::open()` creates or accepts the vault directory and immediately trusts it, but only individual vault files are later checked for `0600`. On Unix, a default `create_dir_all()` under a normal umask can leave the directory traversable by other local users, which leaks wallet IDs through `<wallet_id>.pwsvault` filenames and allows deletion or replacement of vault files in shared or group-writable directories. That weakens the backend's local-filesystem trust boundary even when each vault file itself is `0600`.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:217-221: `read_vault()` allocates the entire file before enforcing any size bound or authenticity check
`read_vault()` performs `fs::read(path)` immediately after metadata lookup, so any replaced or hostile vault file is fully loaded into memory before JSON parsing, KDF parameter validation, or AEAD verification runs. That gives an attacker who can replace the file at rest a straightforward memory-exhaustion and availability kill switch on every `get`/`put`/`delete`/`rekey` path that opens the vault.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:217-220: Permission validation is vulnerable to a metadata-to-read race
`read_vault()` checks permissions with `fs::metadata(path)` and then performs a separate `fs::read(path)`. If an attacker can replace the directory entry between those two syscalls, the code can validate one file's mode bits and then parse different bytes. Combined with the missing directory hardening, this bypasses the intended permission gate and leaves the decrypt path open to rollback or denial-of-service through attacker-chosen vault contents.
- [SUGGESTION] In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:553-556: Windows builds still skip the pre-existing permissions check entirely
On non-Unix targets `check_perms()` is a stub that always returns `Ok(())`, so the documented invariant that a pre-existing vault with loose permissions is rejected is not enforced on Windows at all. A Windows user can therefore open and keep using a vault file with an overly broad ACL, which exposes ciphertext and metadata to other local principals and gives them a practical tamper or rollback point.
|
Supplemental correction to my review for
Those last two Swift items should be treated as carried-forward suggestion-level findings in addition to the findings listed in the review body. |
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