Skip to content

feat(platform-wallet): keyring_core secret backends — encrypted-file + OS keyring (secrets feature)#3672

Open
Claudius-Maginificent wants to merge 17 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-storage-secrets
Open

feat(platform-wallet): keyring_core secret backends — encrypted-file + OS keyring (secrets feature)#3672
Claudius-Maginificent wants to merge 17 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-storage-secrets

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

@Claudius-Maginificent Claudius-Maginificent commented May 19, 2026

DRAFT PR — do not merge until CI has been run and #3625 lands.

STACKED ON #3625 (feat/platform-wallet-sqlite-persistor). The base branch for this PR is feat/platform-wallet-sqlite-persistor, not v3.1-dev. Please diff against that base, and merge this PR only after #3625 lands (or rebase onto v3.1-dev at that point).


REWORK NOTE — reviewers please read first. This PR has been through a hybrid rework (see the live branch head). The original commits shipped a custom SecretStore trait surface and a cargo-deny CI job. The rework adopts the upstream keyring-core 1.0.0 SPI (CredentialStoreApi / CredentialApi) as the public contract while preserving the full security construction byte-for-byte behind it. The file-encryption stack (Argon2id with parameter floors, XChaCha20-Poly1305 with per-put random XNonce, AAD binding, passphrase-verification token, atomic 0600 writes) is unchanged. rekey() returns a typed FileStoreError::Busy rather than panicking when credentials are outstanding. Please diff against the base branch (feat/platform-wallet-sqlite-persistor) as usual.


Issue being fixed or feature implemented

Wallet apps built on rs-platform-wallet need 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-wallet derives 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 the secrets module, enabled by default.


What was done?

platform_wallet_storage::secrets — default-on, upstream SPI

The secrets feature is in the default feature 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 by cargo clippy --no-default-features --features sqlite,cli in the gate set.

The public SPI is upstream keyring_core::api::{CredentialApi, CredentialStoreApi} from keyring-core 1.0.0. This crate contributes two production backends plus zeroizing wrappers — not a bespoke trait surface.

Key shape

upstream field this crate's mapping
service SERVICE_PREFIX + hex(wallet_id)"dash.platform-wallet-storage/" + 64 hex chars; one namespace per wallet
user label, validated against ^[A-Za-z0-9._-]{1,64}$ (SEC-REQ-4.3) at build time and at every operation

WalletId is a fixed 32-byte newtype. validated_label runs defence-in-depth because credentials are long-lived objects.

Zeroizing wrappers (src/secrets/secret.rs)

  • SecretBytes — wraps Zeroizing<Vec<u8>>. Redacting Debug, no Display/Deref/Serialize, constant-time equality via subtle::ConstantTimeEq only. PartialEq/Eq are NOT implemented — == on SecretBytes or SecretString is a compile error, enforced by compile_fail doc-tests that serve as durable forward-compat guards. Best-effort mlock via region. Used for seeds, derived keys, AEAD key material, and decrypted plaintext.
  • SecretString — in-crate type for mnemonics and passphrases. Same redacting Debug / no-Display / no-Deref / no-Serialize disciplines. Full-capacity zeroize on Drop. PartialEq removed; constant-time compare via ct_eq only.

The memory hygiene contract at the SPI seam: CredentialApi::get_secret returns Vec<u8>; callers MUST wrap it into SecretBytes::new(entry.get_secret()?) immediately, with no named intermediate binding. SecretBytes::new takes the Vec<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):

  • KDF: Argon2id (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.
  • Salt: 32 bytes from OsRng/getrandom, per-vault, in the header. Never constant, never derived from wallet_id or path.
  • AEAD: XChaCha20-Poly1305 (chacha20poly1305 0.10.1, pinned). No unauthenticated mode. aes-gcm is absent.
  • Nonce: fresh random 24-byte XNonce per put, stored alongside ciphertext. Counters are explicitly forbidden (multi-process / restart / file-copy scenario causes catastrophic keystream reuse).
  • AAD: canonical length-prefixed format_version ‖ wallet_id ‖ label. Decryption under a different (wallet_id, label) or rolled-back format_version fails the Poly1305 tag. Blob-swap and replay attacks are structurally rejected.
  • Tag failure: returns error and exposes zero decrypted-but-unverified bytes (non-detached AEAD decrypt API — consistent with the lesson of RUSTSEC-2023-0096).
  • Passphrase-verification token: header-stored token PWSVAULT-VERIFY-v1 detects a wrong passphrase before any entry decrypt attempt, preventing mixed-key vault corruption.
  • File permissions: created at 0600 via O_EXCL + fchmod before any plaintext-derived byte is written. Pre-existing file with looser permissions surfaces as a typed error — never blindly overwritten.
  • Atomic + durable writes: temp file at 0600 in same directory → write → fsync temp → rename over target → fsync directory. A crash mid-write never truncates the prior vault.
  • Rekey: decrypt-all → re-derive with fresh salt + current params → re-encrypt with fresh per-entry nonces → same atomic replace. No .bak left holding old key material.

EncryptedFileStore now implements CredentialStoreApi + CredentialApi. File-backend-specific failure modes (WrongPassphrase, KdfFailure, MalformedVault, InsecurePermissions, etc.) bridge to the upstream error type via the FileStoreFailure boxed-marker mechanism described below.

OS keyring backend

secrets::default_credential_store() returns Arc<dyn CredentialStoreApi + Send + Sync> over the platform's native store (linux-keyutils-keyring-storedbus-secret-service-keyring-store on Linux/FreeBSD; apple-native-keyring-store on macOS; windows-native-keyring-store on Windows). Fail-closed with keyring_core::Error::NoDefaultStore on headless / unknown OS — never a silent plaintext fallback.

The cross-SPI error bridge (src/secrets/file/error_bridge.rs)

keyring_core::Error has no WrongPassphrase variant. File-backend errors bridge as follows: WrongPassphrase boxes a FileStoreFailure marker inside keyring_core::Error::NoStorageAccess (matching the operator UX of "keyring locked"); other failure modes render into BadStoreFormat's static String payload. secrets::downcast_failure(&err) is the single recovery path for typed distinction.

FileStoreFailure is unit-variants only (#[derive(Copy)]) with a _assert_copy::<FileStoreFailure>() compile-time witness. No variant carries a path, passphrase, label, or stringified user payload. Display interpolates no user data.

cargo-deny removed; RustSec advisory check covers all deps

The dedicated cargo-deny CI job and deny.toml are deleted. Because secrets is 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 existing rustsec/audit-check job. No advisory coverage gap.

MemoryCredentialStore

Test-only backend, gated behind __secrets-test-helpers (unreachable from production builds). The dev-dependency for test helpers carries default-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 — greps src/sqlite/schema/ and migrations/ for private, mnemonic, seed, xpriv, secret. Exempts src/secrets/ by design.
  • tests/secrets_guard.rs — positive guard for src/secrets/. Forbids logging/format sinks that pair with expose_secret(...) on the same logical statement. Also forbids {:?}-debug-format paired with keyring_core::Error in any src/secrets/ file (Smythe EDIT-2: BadEncoding(Vec<u8>) / BadDataFormat(Vec<u8>, _) carry byte payloads in Debug; 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_secret re-wraps through SecretBytes::new, redacting Debug on SecretBytes/SecretString, no Box<dyn Error> in src/secrets/.
  • tests/secrets_off_state.rs — runtime guard that --no-default-features --features sqlite,cli builds without the secrets module (D4).

SECRETS.md reflects the delivered spec

SECRETS.md is 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.rs now wraps a CredentialStoreApi-based store using the SecretBytes::new(entry.get_secret()?) zeroization seam. The consumer seam lives in #3692 because it consumes platform_wallet::seed_provider types 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.

# Workspace compilation
cargo check --workspace

# Full test suite with all features (152 passed / 0 failed / 3 ignored,
# including 2 new compile_fail doc-tests)
cargo test --all-features

# Off-state: secrets module absent (D4 guard)
cargo test --no-default-features --features sqlite,cli

# Clippy — default features
cargo clippy --all-targets --all-features -- -D warnings

# Clippy — off-state
cargo clippy --no-default-features --features sqlite,cli -- -D warnings

# Audit hooks
cargo test -p rs-platform-wallet-storage secrets_scan   # SQLite side clean
cargo test -p rs-platform-wallet-storage secrets_guard  # secrets side clean (incl. EDIT-2 guard)

# Doc tests (incl. compile_fail guards on == for SecretBytes/SecretString)
cargo test --all-features --doc

Test categories covered:

  • Round-trip via EncryptedFileStore and OS keyring (put → drop → reopen → get, byte-exact)
  • delete_credential honours NoEntry contract on absent entry
  • Wrong passphrase → typed error, zero decrypted bytes returned
  • AAD blob-swap: ciphertext moved to different (wallet_id, label) → rejected
  • Nonce uniqueness across multiple put operations on the same entry
  • Header version rollback → VersionUnsupported
  • Vault file at looser-than-0600 permissions → InsecurePermissions
  • Crash-mid-write simulation: prior vault intact, no plaintext temp file left
  • Rekey: old-key ciphertext unrecoverable, no .bak retained
  • Debug of SecretBytes/SecretString is redacted
  • MemoryCredentialStore unreachable without __secrets-test-helpers
  • == on SecretBytes/SecretString is a compile error (compile_fail doc-tests)
  • secrets_guard EDIT-2 scan: no {:?} paired with keyring_core::Error in src/secrets/
  • Off-state (--no-default-features --features sqlite,cli): secrets module absent

Breaking Changes

None. No semver-breaking change to anything published. The retired SecretStore trait and SecretStoreError type 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 secrets compile 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:

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

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

Known limitations and accepted risks

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 10 commits May 19, 2026 16:03
…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>
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 19, 2026

Important

Review skipped

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

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

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 58a4fb11-d886-4525-a8a9-fb0b0a45a1c5

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

Use the checkbox below for a quick retry:

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

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet-storage): SecretStore — keyring + encrypted-file secret backends (secrets feature) feat(wallet-storage): SecretStore — keyring + encrypted-file secret backends (secrets feature) May 19, 2026
@Claudius-Maginificent Claudius-Maginificent changed the title feat(wallet-storage): SecretStore — keyring + encrypted-file secret backends (secrets feature) feat(platform-wallet): SecretStore — keyring + encrypted-file secret backends (secrets feature) May 19, 2026
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): SecretStore — keyring + encrypted-file secret backends (secrets feature) feat(platform-wallet): add SecretStore keyring + encrypted-file secret backends (secrets feature) May 19, 2026
lklimek and others added 5 commits May 20, 2026 14:40
…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>
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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) with EncryptedFileStore (Argon2id + XChaCha20-Poly1305) and default_credential_store() for OS keyrings.
  • Adds secret-handling wrappers (SecretBytes, SecretString) plus validation and an error-bridging layer to keyring_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.

Comment on lines +125 to +131
// 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();
Comment on lines +211 to +216
let lock = region::lock(bytes.as_ptr(), bytes.capacity().max(1))
.map_err(|e| {
tracing::debug!("mlock failed for SecretBytes: {e}");
e
})
.ok();
Comment on lines +302 to +306
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)?;
Comment on lines +208 to +224
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()?;
}
Comment on lines +57 to +61
// 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;
Comment on lines +98 to +106
/// `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(),
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): add SecretStore keyring + encrypted-file secret backends (secrets feature) feat(platform-wallet): keyring_core secret backends — encrypted-file + OS keyring (secrets feature) May 22, 2026
…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 {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why 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> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No need for the license, we own copyright, remove it.

//!
//! # Memory hygiene
//!
//! The upstream SPI returns `Vec<u8>` from `get_secret`. Consumers
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

@lklimek lklimek marked this pull request as ready for review May 22, 2026 12:06
@lklimek lklimek requested a review from QuantumExplorer as a code owner May 22, 2026 12:06
Copy link
Copy Markdown
Collaborator

@thepastaclaw thepastaclaw left a comment

Choose a reason for hiding this comment

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

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.

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

Labels

None yet

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

4 participants