Skip to content

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

Open
Claudius-Maginificent wants to merge 44 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 44 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

Summary

Adds the secrets feature to rs-platform-wallet-storage — a SecretStore SPI exposing two backends behind the keyring_core trait:

  • EncryptedFileStore — passphrase-protected vault file on disk (Argon2id KDF + XChaCha20-Poly1305 AEAD, atomic write via NamedTempFile::persist)
  • OS-native keyring stores — Linux (libsecret/dbus + linux-keyutils), macOS (apple-native), Windows (windows-native), via crate features

Stacks on feat/platform-wallet-sqlite-persistor (PR #3625, which now also includes the storage hardening landed via #3743).

Public surface

  • SecretStore trait (re-exports keyring_core::Credential-shaped API for callers)
  • SecretBytes / SecretStringDrop-zeroizing buffers with Display redaction and forbid PartialEq (use subtle::ConstantTimeEq for comparison)
  • FileStoreError — typed taxonomy (WrongPassphrase, Corruption, Busy, MalformedVault, …) with no raw bytes in Display
  • OsKeyringErrorKind — security-enforcing payload-stripping projection of keyring_core::Error (prevents BadEncoding/BadDataFormat raw bytes from propagating, CWE-209/CWE-532)

File vault format

Three core types (post-collapse):

  • Vault { version, kdf: KdfParams, salt: [u8; SALT_LEN], verify_nonce: [u8; NONCE_LEN], verify_ct: Vec<u8>, entries: Vec<Entry> }
  • Entry { label, nonce: [u8; NONCE_LEN], ciphertext: Vec<u8> }
  • KdfParams { id, m_kib, t, p } (Argon2id; id-field validated in enforce_bounds)

Serde-validated via const-generic hex_array adapter on fixed-size byte fields; length mismatches surface as MalformedVault at parse time. AEAD AAD is the typed (format_version, wallet_id, label) triple — never serialized bytes (C1 invariant).

Crate features

Feature Default Brings
secrets yes (via default) SecretStore SPI + EncryptedFileStore
apple-native-keyring-store no macOS keychain backend
windows-native-keyring-store no Windows credential manager
linux-keyutils-keyring-store no Linux kernel keyrings
dbus-secret-service-keyring-store no libsecret/dbus (GNOME/KDE)
Default also enables: sqlite, cli yes (inherited from base PR)

Off-state CI invocation (--no-default-features --features sqlite,cli) is exercised by secrets_off_state.rs — confirms the secrets module compiles out cleanly when disabled.

Notable design decisions

  • SecretBytes forbid == (EDIT-4) — comparison via subtle::ConstantTimeEq only; prevents accidental short-circuit timing attacks
  • SecretString Drop: dropped redundant manual Drop impl (the inner Vec<u8> already zeroizes via zeroize crate)
  • Atomic vault write via NamedTempFile::persist — cross-platform, no half-written vault on crash
  • serde_json versioned two-step parse: VersionProbe { version: u32 } first, then dispatch to the right Vault shape. Allows forward-compat without runtime version branching today (single version)
  • FileStoreError::Busy on rekey collision instead of panic — operators get a typed retry signal
  • Lowercase-hex service strings for OS-keyring entries — case-fold-stable across platforms (some keyrings normalize case differently)
  • Box typed FileStoreError into keyring_core::NoStorageAccess — lossless SPI recovery without payload leak

Test plan

  • cargo fmt --all -- --check
  • cargo clippy -p platform-wallet-storage --all-targets -- -D warnings (default features)
  • cargo clippy -p platform-wallet-storage --all-targets --no-default-features --features sqlite,cli -- -D warnings (off-state)
  • cargo test -p platform-wallet-storage (default features) — 70 secrets lib tests, 5 secrets_api, 1 secrets_default_on_compiles, 18 sqlite integration suites, all green
  • cargo test -p platform-wallet-storage --no-default-features --features sqlite,cli --test secrets_off_state — 1/1 pass
  • CI on the merged tip — pending dispatch

Recent activity

Related

🤖 Generated with Claude Code

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: 34756190-44a2-49cb-9795-1bca4606152c

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 thread packages/rs-platform-wallet-storage/src/secrets/secret.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/secret.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/tests/secrets_guard.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/error_bridge.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
@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>
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/error.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/format.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/memory.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/secret.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/secret.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/mod.rs Outdated
@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator Author

📦 Review-response push + base merge-up — 8a5ef7aabd..34c8ecbb9c

12 commits addressing the open review feedback + 1 merge-up commit pulling the recent base hardening + atomicity work from #3625.

Review-response commits

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_writesCommitReport signature change on SqlitePersister
  • 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 — clean
  • cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings — clean
  • cargo test -p platform-wallet-storageall 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

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

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.

@thepastaclaw
Copy link
Copy Markdown
Collaborator

Supplemental correction to my review for 34c8ecbb: the prior-finding reconciliation in the review body accidentally dropped two explicitly assigned prior Swift findings. I rechecked all four prior findings from 6aa2942d against the current head; the correct reconciliation is:

  • STILL VALIDpackages/rs-platform-wallet-storage/src/secrets/file/mod.rs: vault updates are still unsynchronized read-modify-write cycles that can silently discard concurrent writes.
  • STILL VALIDpackages/rs-platform-wallet-storage/src/secrets/file/mod.rs: rekey(wallet_id, ...) still rewrites one wallet vault and then changes the store-wide passphrase used for every wallet in the directory.
  • STILL VALIDpackages/swift-sdk/Sources/SwiftDashSDK/SDK.swift: Swift still does not map Rust FFI error code 10 (DriveInternalError), so it falls through to .unknown(message).
  • STILL VALIDpackages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift: Swift warmUpShieldedProver() still promises stronger completion semantics than the Rust FFI provides; awaiting it only confirms the Rust warm-up task was queued, not that the prover is ready.

Those last two Swift items should be treated as carried-forward suggestion-level findings in addition to the findings listed in the review body.

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

Cumulative revalidation against db7b6b562c4a86e9d9f248115d0a36e661d5f779 found prior findings 1-5 and 9-12 are STILL VALID and remain attributable to this PR’s storage/secrets changes; none of the prior findings are FIXED or INTENTIONALLY DEFERRED. Prior findings 6-8 are OUTDATED for this review because those SPV/Swift files still have the same issues, but they are unchanged from upstream/v3.1-dev and are not part of this PR branch’s scope. The latest delta 34c8ecbb9cbb0e0417723bfd47c54b6a2f6335a2..db7b6b562c4a86e9d9f248115d0a36e661d5f779 does not introduce any new findings, so the review action is driven entirely by carried-forward prior findings.

🔴 5 blocking | 🟡 4 suggestion(s)

3 additional finding(s) omitted (not in diff).

Out-of-scope follow-up suggestions (5)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • SpvRuntime::run() still deadlocks stop(), but that file is untouched by this PR branchpackages/rs-platform-wallet/src/spv/runtime.rs:138-158 still holds the RwLock read guard across client.run().await, so stop() cannot acquire the write lock while the run loop is active. The bug is real, but git diff upstream/v3.1-dev...db7b6b562c4a86e9d9f248115d0a36e661d5f779 shows this file is not part of the PR branch, so it should be tracked separately rather than carried as a PR finding here.
    • Follow-up: Create a separate issue or maintainer-requested PR to clone the client handle out of the lock before awaiting run().
  • The Swift SDK still has two FFI-contract mismatches, but neither file is touched by this PR branchpackages/swift-sdk/Sources/SwiftDashSDK/SDK.swift still drops Rust DriveInternalError code 10 to .unknown, and PlatformWalletManagerShieldedSync.swift still exposes warmUpShieldedProver() as an async readiness-looking API even though the Rust FFI only schedules background warm-up. Both issues are valid, but git diff upstream/v3.1-dev...db7b6b562c4a86e9d9f248115d0a36e661d5f779 shows neither file is part of this PR branch, so they are separate follow-up work.
    • Follow-up: Create a separate issue or maintainer-requested PR for the Swift FFI mapping and warm-up API contract cleanup.
  • Carried-forward prior finding: SpvRuntime::run() holds the client read lock across await and blocks stop() forever — OUTDATED for this PR review: the bug is still present, but packages/rs-platform-wallet/src/spv/runtime.rs is unchanged from upstream/v3.1-dev and is outside this PR branch’s scope.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
  • Carried-forward prior finding: Swift still does not map Rust DriveInternalError (code 10) — OUTDATED for this PR review: the mismatch is still present, but packages/swift-sdk/Sources/SwiftDashSDK/SDK.swift is unchanged from upstream/v3.1-dev and is outside this PR branch’s scope.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.
  • Carried-forward prior finding: Swift warm-up API still promises readiness that the Rust FFI does not provide — OUTDATED for this PR review: the contract mismatch is still present, but packages/swift-sdk/Sources/SwiftDashSDK/PlatformWallet/PlatformWalletManagerShieldedSync.swift is unchanged from upstream/v3.1-dev and is outside this PR branch’s scope.
    • Follow-up: Consider creating a separate issue or author/maintainer-requested PR for this.

Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
…istor' into feat/platform-wallet-storage-secrets
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

Cumulative incremental review at e7e1de8. The latest delta from db7b6b5..e7e1de8 contains zero changes under packages/rs-platform-wallet-storage/ (confirmed by git diff --stat and an empty diff in that path) — it is only an unrelated merge of dapi-cleanup, version bumps, CHANGELOG, and JS package metadata. No new latest-delta findings on the wallet-storage surface. All 9 prior findings from db7b6b5 were re-verified line-by-line against the current head and are STILL VALID and carried forward: (1) STILL VALID rekey() rotates the directory-wide passphrase, (2) STILL VALID lock-free read-modify-write race in put/delete, (3) STILL VALID restore_from() deletes WAL/SHM before fallible persist, (4) STILL VALID ordinary commits never re-harden new WAL/SHM sidecars, (5) STILL VALID asset_locks::apply writes self-contradictory rows, (6) STILL VALID vault directory perms never hardened, (7) STILL VALID read_vault has no size cap, (8) STILL VALID metadata→read TOCTOU, (9) STILL VALID Windows check_perms stub. All 9 fit within the 10-finding budget. Multi-agent convergence: claude/codex/security/rust-quality reviewers all independently flagged each item.

🔴 5 blocking | 🟡 4 suggestion(s)

3 additional finding(s) omitted (not in diff).

🤖 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] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:275-318: [Carried-forward, STILL VALID] rekey() swaps the directory-wide passphrase after rewriting only one wallet vault
  Verified at e7e1de8e. `EncryptedFileStoreInner` holds a single `passphrase: SecretString` at line 67 covering every `<wallet_id>.pwsvault` under `self.dir`. `rekey(wallet_id, new_passphrase)` only re-encrypts the vault file addressed by the supplied `wallet_id` (lines 280-315) before unconditionally assigning `self.passphrase = new_passphrase` (line 316). For multi-wallet stores this corrupts the directory invariant: after rekeying wallet A, every subsequent `get`/`put`/`delete`/`rekey` for wallet B derives keys from A's new passphrase and the header verify-token rejects with `WrongPassphrase`, effectively locking the operator out of every other vault in the same store. The missing-vault early return at lines 281-284 is worse: it overwrites the store-wide passphrase without rewriting any vault. The new `Arc::get_mut` guard at lines 96-98 only prevents in-flight credentials from observing a partial swap; it does not address cross-wallet correctness in a shared directory. Either scope `passphrase` per `wallet_id` (e.g. `HashMap<WalletId, SecretString>`) so the API matches the implementation, or have `rekey` walk every `*.pwsvault` under `self.dir` and re-encrypt all of them under the new passphrase before swapping `self.passphrase`. As a minimum fix, restrict the documented use-model to one wallet per `EncryptedFileStore` handle.
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:320-396: [Carried-forward, STILL VALID] put/delete race: read-modify-write over the vault without any in-process or inter-process lock
  Verified at e7e1de8e. `put` (lines 321-343) and `delete` (lines 380-396) each call `read_vault → mutate Vec<VaultEntry> → write_vault` with no `Mutex` around `EncryptedFileStoreInner` and no `flock`/`LockFileEx` on the on-disk vault. Because `CredentialStoreApi::build()` clones `Arc<EncryptedFileStoreInner>`, two credentials backed by the same store (legitimate per the SPI, common under a tokio runtime) can read the same snapshot, apply disjoint edits, and the later `tempfile::persist` atomically replaces the file — silently discarding the first writer's update with no error returned. Atomic-replace makes each individual write indivisible, but does NOT serialise the read+write window. The same race exists across processes that open the same vault directory. For a secret-storage backend, the lost write is silent secret loss: a subsequent `get` for the dropped label returns `NoEntry` and a wallet rehydration path interprets it as 'never written'. Fix: wrap the read/modify/write critical section in a `parking_lot::Mutex` on `EncryptedFileStoreInner` for in-process safety, and acquire an `fs2` exclusive advisory lock on the vault path (or a sibling `.lock` file) across the same window for cross-process safety.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:73-79: [Carried-forward, STILL VALID] vault directory permissions are never hardened or validated
  Verified at e7e1de8e. `EncryptedFileStore::open` (lines 73-79) calls `fs::create_dir_all(&dir)?` and immediately constructs the store with no chmod or metadata check on the directory itself. On Unix, a directory created under a permissive umask (commonly 0o755 or 0o775) remains traversable and listable by other local users even though each vault file is 0600 (`set_restrictive_perms` at line 254). The leaked surface is the set of `<wallet_id_hex>.pwsvault` filenames — i.e. the set of wallet IDs the user holds — plus the directory's mtime as a wallet-creation/rotation oracle. This undermines the A1 (other-local-user) threat model the file backend claims to defend. Fix: `chmod 0o700` the directory immediately after `create_dir_all`, and refuse to open an existing directory whose `mode & 0o077 != 0`, symmetric with the existing per-file `check_perms` policy.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:216-226: [Carried-forward, STILL VALID] read_vault() reads the full file before enforcing any size bound
  Verified at e7e1de8e. `read_vault` (lines 216-226) performs `fs::metadata` (line 217) for the permission check and then `fs::read(path)` (line 220) before any size cap, JSON header pre-validation, KDF-parameter sanity check, or AEAD verification. `format::deserialize(&bytes)` only runs after the full file has been allocated and read. A locally-authorised attacker who can swap the vault file (or a corrupted backup restore) can force a multi-GiB allocation in the wallet process before any authentication step runs — a trivial pre-auth DoS. Fix: enforce an explicit size cap before reading by checking `meta.len()` against a hard maximum (e.g. 16 MiB, vastly larger than the realistic seed/xpriv workload but bounded), or use `fs::File::open` + `Read::take(MAX).read_to_end(...)` and return a typed `OversizedVault` error.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:217-220: [Carried-forward, STILL VALID] permission validation has a metadata-to-read TOCTOU window
  Verified at e7e1de8e. The code validates permissions with `fs::metadata(path)` (line 217), then performs a separate `fs::read(path)` (line 220) on the same path string — two distinct syscalls operating on path resolution, not on a pinned inode. An attacker with the ability to manipulate the parent directory (the same A1 threat scenario the per-file 0600 check is meant to cover) can replace the directory entry between those syscalls so the code validates one inode's mode and then deserialises bytes from a different file. Composes naturally with finding #6 (un-hardened parent directory). Fix: open the file once with `OpenOptions` (ideally with `O_NOFOLLOW` on Unix), validate `file.metadata()?` against that handle, and read the bytes from the same FD via `file.take(MAX).read_to_end(&mut buf)` — pinning permissions and content to the same inode in one shot, which also composes with the size-cap fix above.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:553-557: [Carried-forward, STILL VALID] Windows check_perms() stub never validates ACLs on pre-existing vault files
  Verified at e7e1de8e. The non-Unix `check_perms` at lines 554-557 is still a stub that unconditionally returns `Ok(())`. The acknowledging `TODO: Windows ACL read-check is not yet implemented; tracked in PR #3672.` (line 553) acknowledges the gap but does not close it. The PR description and SECRETS.md advertise rejection of insecure pre-existing vault files as a backend invariant; on Windows that invariant is unenforced, so a vault file with an overly broad ACL (e.g. inherited Users:Read from a permissive parent) opens silently. Either implement a Windows ACL audit (e.g. via `windows-sys` `GetSecurityInfo` + DACL scan rejecting ACEs that grant access to anyone outside the current user / SYSTEM), or explicitly document in SECRETS.md and the module docs that A1 (other local user) is not enforced on Windows so operators are not misled by claimed parity.

In `packages/rs-platform-wallet-storage/src/sqlite/backup.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/backup.rs:247-278: [Carried-forward, STILL VALID] restore_from() deletes destination WAL/SHM before fallible persist and re-harden steps
  Verified at e7e1de8e. The pre-persist chmod on the temp file (lines 265-270) was correctly added — that closes the chmod-after-persist variant — but the WAL/SHM removal at lines 247-258 still runs BEFORE the remaining fallible steps: `tmp.persist(dest_db_path)` (lines 273-274) and the post-persist `apply_secure_permissions(dest_db_path)` (line 278). If `persist` returns `Err` (rename can fail with EXDEV-like errors, EACCES, ENOSPC, EBUSY) or if `apply_secure_permissions` fails on a sibling chmod, the original `dest_db_path` is left in place while its committed `<dest>-wal` and `<dest>-shm` are already gone. SQLite in WAL mode keeps committed-but-not-checkpointed transactions in the WAL, so reopening the un-replaced destination after the restore appears to fail will silently roll back to a pre-restore committed state — a real data-loss path the caller cannot detect from the returned `Err`. The comment at lines 242-246 frames the ordering as 'every staged-file validation has now passed', but validation passing does not make `persist` infallible. Fix: move the WAL/SHM removal to AFTER `tmp.persist(...)` succeeds (the old sidecars become stale only at that atomic instant anyway), and keep `apply_secure_permissions` as the final step.

In `packages/rs-platform-wallet-storage/src/sqlite/persister.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/persister.rs:605-651: [Carried-forward, STILL VALID] write_changeset_in_one_tx never re-hardens newly created WAL/SHM sidecars
  Verified at e7e1de8e. `apply_secure_permissions` is invoked exactly three times in the crate: `SqlitePersister::open` (persister.rs:176, one-shot at open before any sidecar is materialised), `backup::run_to` (backup.rs:114), and `backup::restore_from` (backup.rs:278). The hot-path commit at `write_changeset_in_one_tx` ends with `tx.commit()?; Ok(())` on lines 650-651 with no re-hardening pass. SQLite in WAL mode lazily materialises `<db>-wal` and `<db>-shm` on the first write transaction after open; those files are created by SQLite itself (not by `tempfile`) and therefore inherit the process umask rather than the intended 0600 hardening that the rest of the crate's threat model assumes. The same SEC-011 threat model that justifies hardening the main DB applies verbatim to the WAL — which contains the most recent committed wallet metadata, identity keys, contacts, and correlation state until checkpoint. Fix: call `apply_secure_permissions(&self.config.path)` immediately after `tx.commit()?` (idempotent and cheap), so newly created sidecars are hardened on first appearance and re-checked on every commit thereafter.

In `packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs:19-62: [Carried-forward, STILL VALID] asset_locks::apply writes self-contradictory rows that the read path will then refuse
  Verified at e7e1de8e. The read path's `decode_row` (lines 85-111) was correctly hardened to reject mismatches with `WalletStorageError::AssetLockEntryMismatch` when `entry.out_point != outpoint` or `entry.account_index != typed_account_index` (the CMT-007 cross-check at lines 104-111). The write path in `apply` (lines 36-51) however still trusts the caller-supplied `AssetLockChangeSet` blindly: it uses the map key `op` to populate the `outpoint` column and `entry.account_index` for the typed column, while the `lifecycle_blob` is `blob::encode(entry)` — with no assertion that `op == &entry.out_point` or that the typed column equals the value embedded in the blob. A malformed upstream caller (programming error, post-rebase migration drift, deserialisation round-trip bug) can persist self-contradictory rows that subsequently hard-fail every `load_state` call with `AssetLockEntryMismatch`, leaving the wallet unable to rehydrate — a self-inflicted DoS that PRAGMA integrity_check will not catch. Fix: at the top of the loop body, before the INSERT executes, assert `op == &entry.out_point` and that the typed `account_index` matches the blob's `entry.account_index`, returning the same typed `AssetLockEntryMismatch` symmetric with the reader. Fail fast at write time rather than corrupting the table.
Out-of-scope follow-up suggestions (3)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Latest delta is an unrelated merge — dapi/dapi-client changes should be reviewed on their originating PR — The db7b6b5..e7e1de8 diff is purely a merge of origin/feat/platform-wallet-sqlite-persistor (which itself merged v3.1-dev) into the secrets branch. It touches CHANGELOG.md, Cargo.lock, package.json version bumps across ~30 packages, js-dapi-client logging refactor, JsonRpcTransport changes, undici fetch shim removal, and the .pnp.cjs update. None of those files are in PR #3672's stated subject (keyring-core secret backends + SQLite hardening). Any security-relevant changes in the dapi/undici area should be reviewed on their originating PR (#3679 and adjacent) rather than absorbed into this PR's review.
    • Follow-up: Confirm the upstream dapi-client / undici / fetch / JsonRpcTransport changes are covered by their originating PR reviews; do not let this PR's blocking issues be conflated with the merged-in delta.
  • Upstream key-wallet::compute_wallet_id Debug/log hygiene (AR-7 / R3) — PR description lists this as a known residual accepted risk: the type used to compute WalletId lives upstream in packages/key-wallet/ and is not in this PR's diff. Every wallet_id = %wallet_id.to_hex() tracing call in this PR (e.g. mod.rs:300, mod.rs:367) trusts that the wallet id is a non-secret correlation identifier, which depends on the upstream Debug/log surface never leaking the seed that derived it.
    • Follow-up: Open a separate hardening issue against key-wallet for compute_wallet_id Debug/log audit, and block the un-draft of #3672 (or wider rollout per AR-7) on its resolution as the PR description committed to.
  • default_credential_store() fail-closed on headless Linux — operator docs gap — Per the PR description, default_credential_store() returns NoDefaultStore on headless / unknown OS instead of silently falling back to plaintext. That is the correct fail-closed posture, but CI / container / server operators will hit it and need a documented escape hatch (use EncryptedFileStore directly with a passphrase from a sealed secret / env var / k8s Secret / KMS). Not a blocker for this PR.
    • Follow-up: Author-requested follow-up: add a SECRETS.md subsection 'Headless / server deployments' documenting the EncryptedFileStore-only path and the passphrase-source recommendations.

Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
…istor' into feat/platform-wallet-storage-secrets

# Conflicts:
#	Cargo.lock
#	packages/rs-platform-wallet-storage/Cargo.toml
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

Cumulative incremental review at head 680155f vs prior review e7e1de8. The secrets module — this PR's actual scope (feat/platform-wallet-storage-secrets) — was not touched in the latest delta, which is a merge bringing in stacked PR #3625's SQLite persister work. All 9 prior findings remain STILL VALID against current source. In-scope (secrets) carried-forward findings: #1 (rekey store-wide passphrase), #2 (put/delete races), #6 (dir perms), #7 (unbounded read), #8 (metadata→read TOCTOU), #9 (Windows ACL stub). Out-of-scope (originate in stacked base PR #3625): #3 (restore_from WAL/SHM unlink ordering), #4 (sidecar hardening), #5 (asset_locks writer cross-check), plus new-delta items raised by codex (delete_wallet row-count-only footprint, restore_from lock release before persist, post-commit re-drain) — these belong on #3625, not this PR. No new in-scope defects identified in the latest delta.

🔴 2 blocking | 🟡 4 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:275-318: [carried-forward, STILL VALID] rekey() switches the store-wide passphrase after rewriting only one wallet vault
  Verified at current head — file unchanged in the latest delta. `EncryptedFileStoreInner` still holds a single `passphrase` for the whole directory (line 67), but `rekey()` only re-encrypts the vault for the supplied `wallet_id` (lines 285–315) before assigning `self.passphrase = new_passphrase` (line 316). In a directory containing vaults for wallets A and B, calling `rekey(A, new_pw)` re-encrypts only A's vault and then swaps the global passphrase; every subsequent `get`/`put`/`delete` for B derives a key from the new passphrase against B's still-old salt+ciphertext, so the AEAD verify-token fails and B surfaces as `WrongPassphrase`/`Corruption` until rekeyed by hand. The missing-vault branch at 281–284 is worse: it unconditionally swaps `self.passphrase` without rewriting any vault. The new `Arc::get_mut`/`Busy` guard on `EncryptedFileStore::rekey` (lines 96–99) closes the in-process credential-outstanding race but does not address the multi-vault desync. Either scope `passphrase` per-vault (e.g. keep it on disk in the header + a per-wallet handle), or atomically rekey every vault in the directory inside one fail-loud sequence (and refuse the swap if any vault cannot be opened with the current passphrase).
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:320-396: [carried-forward, STILL VALID] put()/delete() race silently discards concurrent writes
  Verified at current head — file unchanged. `put()` (320-343) and `delete()` (380-396) take `&self` and implement updates as `read_vault → mutate Vec<VaultEntry> → write_vault` with no in-process mutex on `EncryptedFileStoreInner` and no inter-process advisory lock around the read-modify-write window. Two credentials returned by `build()` share the `Arc<EncryptedFileStoreInner>`, so concurrent calls can read the same snapshot, apply different edits, and have the later `tempfile::persist` atomically overwrite the earlier update with a vault that omits the prior edit — no error to either caller, silent loss of wallet credentials. The atomic rename only prevents torn files; it does not serialise read-modify-write. The sibling SQLite arm protects itself with `BEGIN EXCLUSIVE`; the secrets arm has nothing equivalent. A `Mutex<()>` on `EncryptedFileStoreInner` covering read+write would close the in-process case; an OS file/dir lock would close the cross-process case.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:553-557: [carried-forward, STILL VALID] Windows skips the pre-existing vault permissions check entirely
  Verified at current head. The non-Unix `check_perms()` (554-557) is still a stub returning `Ok(())`, with an explicit `TODO` at line 553. The module-level threat-coverage commentary and PR description both claim insecure pre-existing vault files are rejected, but on Windows that invariant is unenforced: a `.pwsvault` file with an overly broad DACL opens successfully. Either implement the ACL audit (e.g. via `windows-sys`/`GetNamedSecurityInfoW`, rejecting any ACE granting access to non-owner principals beyond SYSTEM/Administrators) or surface a typed `PermissionCheckUnsupported`/downgrade the documented invariant in SECRETS.md so operators see the gap.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:73-79: [carried-forward, STILL VALID] vault directory permissions are never hardened or validated
  Verified at current head. `EncryptedFileStore::open()` still does `fs::create_dir_all(&dir)?` with no mode argument and no validation on a pre-existing directory. On Unix, a directory created under a permissive umask is world-traversable; even though each `.pwsvault` is 0600, the directory listing leaks wallet IDs (filename = `<hex_wallet_id>.pwsvault`) and their existence/mtimes to other local users, weakening the local-user threat coverage the file-level hardening advertises. Tighten the directory to 0700 on create and assert `mode & 0o077 == 0` on a pre-existing directory, mirroring `check_perms` on files (with a typed `InsecurePermissions`-style error).
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:216-226: [carried-forward, STILL VALID] read_vault() reads the whole file before enforcing any size bound
  Verified at current head. `read_vault` (216-226) calls `fs::read(path)` immediately after the metadata permission check, with no upper bound, no header-magic sniff, and no incremental validation before the slurp. A maliciously replaced or simply oversized vault file (an attacker who can write to the vault directory, or a corrupted filesystem entry) forces an unbounded `Vec<u8>` allocation before any AEAD/Argon2 step gets a chance to reject the input — process can OOM/exit before the format ever fails. Wallet vaults are small in practice; a defensive size cap (e.g. 8–64 MiB) returned as a typed `VaultTooLarge` before `fs::read` would bound the damage cheaply.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:217-220: [carried-forward, STILL VALID] permission validation has metadata-to-read TOCTOU gap
  Verified at current head. The sequence `fs::metadata(path) → check_perms(&meta) → fs::read(path)` resolves the path twice with two independent syscalls. An attacker with write access in the vault directory (the very scenario `check_perms` is meant to defend against, and compounded by the unfixed directory-mode finding) who can rename a permissive-mode file over the path between those calls makes the code validate one inode's mode and then parse a different inode's bytes. Open the file once via `OpenOptions::new().read(true).open(path)`, then call `metadata()` + permission check on the open `File`, then `read_to_end` from that same descriptor — that pins the inode for the operation. The AEAD tag will still reject foreign content, but `InsecurePermissions` is supposed to be a hard gate.
Out-of-scope follow-up suggestions (5)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • SQLite-side carried-forward findings (#3 restore_from WAL/SHM ordering, #4 sidecar permissions, #5 asset_locks writer cross-check) originate in stacked base PR #3625 — This PR (feat/platform-wallet-storage-secrets) is stacked on feat/platform-wallet-sqlite-persistor (PR #3625). The merge in the latest delta is what surfaces these files in this PR's diff against main, but the code was not authored here. All three findings re-verify against current head: backup.rs 301-352 still unlinks <dest>-wal/<dest>-shm at step 5 before fallible chmod/persist/fsync steps; persister.rs 721-731 still commits write_changeset_in_one_tx without re-running apply_secure_permissions so lazily materialised WAL/SHM sidecars retain process-umask modes; asset_locks.rs 19-62 still inserts (op, entry) without checking op == entry.out_point/account_index consistency that decode_row 104-111 then hard-fails on. They should be landed on #3625 so they don't reach v3.1-dev through this stacked PR.
    • Follow-up: Address on PR #3625 (feat/platform-wallet-sqlite-persistor) before it merges. Do not block this PR on them.
  • New-delta SQLite findings raised by codex (delete_wallet row-count-only footprint; restore_from drops EXCLUSIVE lock before persist; post-commit re-drain not a deletion barrier) also belong to #3625 — Codex flagged three additional SQLite-side issues in the latest delta: (a) delete_wallet (persister.rs ~458-513) snapshots only (table_name, row_count) so a peer's in-place row update between auto-backup and EXCLUSIVE acquisition passes the equality check and the pre-delete backup is silently stale; (b) restore_from (backup.rs 336-352) intentionally drops the destination's BEGIN EXCLUSIVE connection before tmp.persist(dest_db_path), with a comment that acknowledges a peer can grab the lock and commit to the old inode in that window only to be unlinked by the rename; (c) the post-commit take_for_flush (persister.rs ~552-559) is a point-in-time drain, not a synchronisation primitive, so a racing store() arriving just after the drain can leave buffered data that a subsequent flush resurrects. I confirmed (a) and (c) by reading the persister source; the comments at backup.rs 336-348 corroborate (b). All three live in code introduced by #3625, not by this PR.
    • Follow-up: Route to PR #3625 review. For (a) consider extending the footprint to include a per-table content hash or row-version. For (b) treat the rename as the atomicity boundary and hold the lock until immediately before persist (or accept that ConcurrentMutationDuringDelete-style detection is the contract). For (c) document that delete_wallet is not a store() barrier and require callers to quiesce writers, or hold the buffer lock across the cascade.
  • FFI block_on_worker + usize-laundering invariant in new platform-address asset-lock entry points — The new platform_address_wallet_fund_from_asset_lock_signer family (packages/rs-platform-wallet-ffi/src/platform_addresses/fund_from_asset_lock.rs) casts *mut SignerHandle/*mut MnemonicResolverHandle to usize so the captured future is Send + 'static, then resurrects the pointers inside the async block. Sound today because block_on_worker synchronously parks the FFI caller until the spawned task returns, pinning both handles alive for the call. If block_on_worker is ever changed to be non-blocking, the resurrected references become dangling on a different thread — classic UAF across the FFI boundary. The contract is documented in SAFETY comments but has no compile-time guard.
    • Follow-up: Open a follow-up hardening issue to wrap the usize-laundering pattern in an unsafe newtype carrying the blocking-call contract at the type level, and add a doc-asserted invariant to block_on_worker that any caller using this pattern depends on its blocking semantics.
  • Asset-lock funding orchestration (~690s worst-case latency budget) is not operator-tunablesubmit_with_cl_height_retry (orchestration.rs) and fund_from_asset_lock.rs hard-code latency constants summing to ~690s in the worst case. Not a security defect, but synchronous use from a UI thread is operationally problematic and an IS-rejection-style adversary can push every transaction to that ceiling. This is new code arriving via the #3671 (platform-address funding) merge, not authored on this PR's branch.
    • Follow-up: Track operator-tunable timeouts (PutSettings-style) for CL_FALLBACK_TIMEOUT, CL_HEIGHT_RETRY_DELAY, CL_HEIGHT_RETRY_BUDGET in a separate issue against the asset-lock orchestration code.
  • Upstream key_wallet::compute_wallet_id Debug/log hygiene (AR-7 / R3) — Acknowledged in the PR description as an accepted residual outside scope. The wallet_id is treated as a pseudonymous correlation key by this crate, but a careless {:?} upstream could spill it into logs. The PR's secrets_guard covers src/secrets/ only.
    • Follow-up: Audit key_wallet::compute_wallet_id and related types for Debug/Display/tracing exposure before wider rollout. Separate issue against the key-wallet crate.

Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment on lines +217 to +220
match fs::metadata(path) {
Ok(meta) => {
check_perms(&meta)?;
let bytes = fs::read(path)?;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: [carried-forward, STILL VALID] permission validation has metadata-to-read TOCTOU gap

Verified at current head. The sequence fs::metadata(path) → check_perms(&meta) → fs::read(path) resolves the path twice with two independent syscalls. An attacker with write access in the vault directory (the very scenario check_perms is meant to defend against, and compounded by the unfixed directory-mode finding) who can rename a permissive-mode file over the path between those calls makes the code validate one inode's mode and then parse a different inode's bytes. Open the file once via OpenOptions::new().read(true).open(path), then call metadata() + permission check on the open File, then read_to_end from that same descriptor — that pins the inode for the operation. The AEAD tag will still reject foreign content, but InsecurePermissions is supposed to be a hard gate.

source: ['claude', 'codex']

lklimek added 4 commits May 27, 2026 13:05
…serde adapter

Companion to hex_bytes for fixed-size byte fields. Lets serde validate
length at the deserialize seam instead of requiring a separate Vec<u8>
→ [u8; N] conversion type. Unit test asserts wire form (lowercase hex)
and that wrong-length / bad-hex errors carry both the offending size
and the expected N (no anonymous "invalid length").
… validates nonce length

EntryRecord existed only as a Vec<u8>-typed wire mirror of Entry. With
the hex_array serde adapter the [u8; NONCE_LEN] field self-validates
at deserialize time, so the wire mirror and its From/TryFrom
conversions are redundant. The AEAD-tag-length floor on `ciphertext`
stays as a post-parse structural check (one tight loop in deserialize)
so a short ciphertext keeps surfacing as MalformedVault — same failure
mode as before. Wire format byte-identical, asserted by a new
round-trip test that parses serialize() output through VaultFile and
re-serializes, expecting byte equality.
…gle Vault type

Same shape, was split runtime ([u8; N] fields) vs wire (Vec<u8>) only.
The hex_array adapter validates salt/verify_nonce widths at the serde
seam, so the parallel Vec<u8>-typed wire mirror is redundant and gone.

Vault now carries version, kdf, salt, verify_nonce, verify_ct, entries
in the documented order and serializes directly. `format::serialize`
takes a single &Vault; `format::deserialize` returns a single Vault.
Inner store code threads a single value through new_vault →
derive_and_verify → put/get/delete/rekey, dropping the (Header,
Vec<Entry>) tuple churn at every callsite. AAD construction is
unchanged (still typed via the (format_version, wallet_id, label)
triple — C1 invariant preserved).
Pure runtime/wire mirror with no validation value-add. KdfParams gains
serde derives + an `id: u8` field and now serializes directly as the
vault's `kdf` object. The algo-id check moves into
`KdfParams::enforce_bounds` — Argon2id is now the sole gate in one
place, alongside the m_kib/t/p range checks, and runs on every
`derive_key` call. Vault.kdf is `KdfParams` directly; the runtime
projection helper (`Vault::params`) is gone with the wire mirror.
Format-layer test updated to assert the new bounds-check semantics.
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

Cumulative incremental review of PR #3672 at head 9884f22. The latest 4-commit delta (KdfDescriptor→KdfParams collapse, Header/VaultFile→Vault collapse, Entry/EntryRecord collapse, const-generic hex_array adapter) is a structural refactor that preserves the on-disk format and adds parse-time width validation; I verified it introduces no new defects. Status of the 6 prior findings against current head: all 6 are STILL VALID (none FIXED, OUTDATED, or INTENTIONALLY DEFERRED). The two blocking issues — rekey() updating the store-wide passphrase after rewriting only one wallet's vault, and put/delete read-modify-write without locking — remain the merge blockers; the four suggestion-tier defense-in-depth gaps (Windows check_perms stub, unhardened vault directory permissions, unbounded fs::read before authentication, metadata→read TOCTOU) also persist. No new latest-delta findings.

🔴 2 blocking | 🟡 4 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:270-314: [carried-forward, STILL VALID] rekey() switches the store-wide passphrase after rewriting only one wallet's vault
  Verified at head 9884f22725 against mod.rs:270-314. `EncryptedFileStoreInner` holds a single store-wide `passphrase: SecretString` (line 67) but `rekey(wallet_id, new_passphrase)` only re-encrypts the vault file at `self.vault_path(&wallet_id)` (lines 280-310) before unconditionally executing `self.passphrase = new_passphrase` (line 312). In any directory hosting vaults for wallets A and B opened under one store, `rekey(A, new)` leaves vault B's bytes encrypted under the old passphrase while every subsequent `read_vault`/`derive_and_verify` for B derives from the new one — vault B is bricked from this handle, surfacing as `WrongPassphrase` indistinguishable from real corruption. The missing-vault branch at lines 276-279 is worse: it sets `self.passphrase = new_passphrase` and returns `Ok(())` with no on-disk re-encryption, silently rotating the global passphrase against zero vaults and locking out every populated wallet in the directory. The newly added `Arc::get_mut`/`Busy` guard at line 96 only prevents the swap while live credentials hold the inner Arc — it does not bound the passphrase scope. Fix: either move passphrase ownership per-vault, or re-encrypt every `*.pwsvault` in `self.dir` atomically under the new passphrase, or refuse `rekey` when more than one vault file exists in `dir`.
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:316-389: [carried-forward, STILL VALID] put()/delete() lose concurrent updates via unsynchronized read-modify-write
  Verified at head 9884f22725 against mod.rs:317-389. `EncryptedFileStoreInner::put` (lines 317-336) and `::delete` (lines 373-389) both take `&self` and implement updates as `read_vault → mutate Vec<VaultEntry> → write_vault`. `CredentialStoreApi::build` (line 512) hands out multiple `EncryptedFileCredential`s that each clone the same `Arc<EncryptedFileStoreInner>`, and the public `put_bytes`/`delete_bytes` (lines 106-133) also take `&self`. There is no in-process mutex around the read-modify-write window and no inter-process advisory lock (e.g. `fcntl`/`flock`/`fs2::FileExt`). Two threads (or two processes opening the same directory) concurrently calling `put(W, "A", ...)` and `put(W, "B", ...)` each read the same baseline `vault.entries`, append their own entry, and the later `tempfile::persist` atomically replaces the file — the earlier write is silently lost. The same hazard applies to delete-vs-put and to concurrent rehydration vs key-import flows in the wallet app. The atomic rename gives torn-write protection but no compare-and-swap semantics. Fix: take a `Mutex`/`RwLock` (per dir, or per `(dir, wallet_id)`) spanning the entire read→write block in `EncryptedFileStoreInner`, plus an OS advisory file lock around the same window if multi-process use is supported.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:546-550: [carried-forward, STILL VALID] Windows skips the pre-existing vault permissions check entirely
  Verified at head 9884f22725. The non-Unix `check_perms` at mod.rs:547-550 is a stub returning `Ok(())`, with the explicit TODO at line 546 (`Windows ACL read-check is not yet implemented; tracked in PR #3672.`). The PR description and SECRETS.md threat model claim insecure pre-existing vault files are rejected, but on Windows a `.pwsvault` file with an overly broad DACL opens successfully — the documented invariant does not hold cross-platform. The companion `set_restrictive_perms` Windows stub (lines 559-562) is a no-op too, so at-rest hardening on Windows reduces to whatever ACL `tempfile` happens to apply. Either implement an ACL inspection on Windows (`GetSecurityInfo`/`GetEffectiveRightsFromAclW`) that approximates owner-only access, fail closed when the file backend is opened on non-Unix, or document the gap in SECRETS.md under accepted residuals (it is not currently listed).
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:73-79: [carried-forward, STILL VALID] Vault directory permissions are never hardened or validated
  Verified at head 9884f22725. `EncryptedFileStore::open` at mod.rs:73-79 calls `fs::create_dir_all(&dir)` with no permission tightening on a newly-created directory and no validation of an existing one. Per `create_dir_all`, the directory mode is governed by the process umask, so under the common umask 022 the parent can be world-readable / world-traversable. Even though the vault files themselves are 0600, listing the directory leaks the wallet-id hex filenames (which directly tag the user's wallets) and the per-file mtimes (which leak credential write activity to inotify or directory listings) — a real metadata side-channel for threat A1 (other local user). For consistency with the 0600 file invariant, the directory should be set to 0700 on creation and a pre-existing directory with any bit in 0o077 should at least surface a typed warning/error.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:216-226: [carried-forward, STILL VALID] read_vault() reads the entire file before enforcing any size bound
  Verified at head 9884f22725. `read_vault` at mod.rs:216-226 calls `fs::read(path)` (line 220) immediately after the permission check, with no explicit upper bound, no header sniff, no KDF validation, and no AEAD verification before allocating. `format::deserialize` then does `serde_json::from_slice` over the entire allocation and parses every entry. A malicious replacement or accidentally enormous `.pwsvault` (multi-GiB) forces an unbounded `Vec<u8>` allocation before any cryptographic check fires — a local DoS / OOM vector for any process that can write the vault directory or a hostile synced backup that can replace the file. The format-layer KDF ceiling (`ARGON2_MAX_M_KIB = 1 GiB`) bounds compute cost but not disk-read cost. Fix: check `meta.len()` against an explicit ceiling (e.g. 1-16 MiB — comfortably above any realistic vault) and error with a typed `MalformedVault`/`VaultTooLarge` before `fs::read`.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:217-220: [carried-forward, STILL VALID] permission validation has a metadata→read TOCTOU gap
  Verified at head 9884f22725. The sequence at mod.rs:217-220 is `fs::metadata(path) → check_perms(&meta) → fs::read(path)`, which resolves the path twice. An attacker able to swap the directory entry between those two syscalls (e.g. by swapping a symlink target) can have `check_perms` validate inode A's 0600 mode and `fs::read` consume bytes from a different inode whose mode was never checked — defeating the entire point of the `InsecurePermissions` invariant the doc-comment promises. Low practical exploitability under the documented threat model (an attacker who can rewrite the parent directory mid-call is already partially in control), but the mitigation is cheap and the invariant is otherwise hard. Fix: open once with `File::open(path)`, then take `File::metadata` on the open handle and read from that same handle so every check binds to one inode.
Out-of-scope follow-up suggestions (3)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • AR-7 / R3 — upstream key-wallet::compute_wallet_id Debug/log hygiene — Already disclosed in the PR's 'Known limitations and accepted risks'. Lives in the key-wallet crate, not in the secrets/ backend. Not introduced by this PR; flagged only so it is not lost in the downstream PR cascade.
    • Follow-up: Open a separate hardening issue against key-wallet to scrub Debug/log surfaces that could echo wallet-id-adjacent secret-derived data, before wider wallet rollout.
  • Downstream PR cascade (#3692 / #3693) needs rebase to the new CredentialStoreApi seam — The PR description tracks this explicitly — the adapter referenced in downstream PRs needs to wrap the upstream CredentialStoreApi rather than the retired bespoke SecretStore trait. Not a defect in this PR.
    • Follow-up: Rebase #3692 and #3693 after this PR lands and update seed_provider_adapter.rs to the new seam.
  • Operator-doc gap for headless-Linux fail-closed defaultdefault_credential_store() returns NoDefaultStore on headless Linux, which is the correct fail-closed behavior. Operators deploying to mobile-on-server / CI without anticipating the headless case will see wallet rehydration fail at boot; this is a docs issue rather than a defect.
    • Follow-up: Add a SECRETS.md / runbook entry pointing operators at EncryptedFileStore for headless Linux.

Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/crypto.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/format.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
Comment thread packages/rs-platform-wallet-storage/src/secrets/file/mod.rs Outdated
lklimek and others added 3 commits May 27, 2026 15:41
…OU (Wave 1 / CMT-001..004)

Four security/concurrency fixes landed together so the file backend's
read-modify-write span is safe against in-process and cross-process
races, oversized payloads, and inode-swap windows:

* CMT-001 — every `put`/`delete`/`rekey` now runs under a per-wallet
  in-process `Mutex<()>` AND a cross-process `flock`-style exclusive
  lock on a sidecar `<wallet>.pwsvault.lock` file. The sidecar is held
  via `fd-lock` 4.0.4 (chosen over `fs2`/`fs4` because the sqlite arm's
  hardening tests grep for those literals in this crate's source and
  manifest — `fd-lock` has no such collision). Lock contention past a
  5 s wait budget surfaces `FileStoreError::Busy`. The `Busy` variant
  now documents the two recoverable causes (outstanding credentials,
  lock contention). A new test plants a sidecar lock from this thread
  and asserts the in-process `put` budget out into `Busy`.

* CMT-002 — `EncryptedFileStore::open` tightens the vault directory to
  `0700` on Unix after `create_dir_all`. A pre-existing dir with looser
  bits is logged at warn level and tightened in place rather than
  refused outright: the canonical `tempfile::tempdir()` / umask-022
  `mkdir` bootstrap used throughout tests would otherwise reject. After
  the tighten, a non-`0700` mode surfaces `InsecurePermissions`.

* CMT-003 — vault files larger than a new `MAX_VAULT_SIZE_BYTES`
  (128 MiB) fail BEFORE `fs::read` allocates. Surfaces a typed
  `FileStoreError::VaultTooLarge { found, max }`. A unit test sparse-
  truncates a real vault past the cap and asserts the projection.

* CMT-004 — `read_vault` eliminates the metadata→read TOCTOU by
  opening the file with `O_NOFOLLOW` once and driving perms, size cap,
  and content read from the SAME fd. `libc` is added as a Unix-only
  dep for the open flag. A symlink swap during the window now reads
  the inode the open captured.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…gh file backend (Wave 2 / CMT-005,006,008,009)

Four paired type-tightenings so the bare-byte view of a secret lives
only inside the function that absolutely needs it. The lossy
conversions now happen at exactly two seams (the upstream
`CredentialApi::set_secret` `&[u8]` arrival and `CredentialApi::
get_secret` `Vec<u8>` return) and nowhere else.

* CMT-005 + CMT-006 — `crypto::derive_key` takes `&SecretString`
  instead of `&[u8]`. The `expose_secret().as_bytes()` call moves
  inside `derive_key`, so callers (`new_vault`, `derive_and_verify`)
  pass `&self.passphrase` / `passphrase` directly with no
  intervening bare byte slice.

* CMT-008 — `EncryptedFileStoreInner::get` and `get_bytes` return
  `Result<Option<SecretBytes>, _>` (was `Vec<u8>`). `crypto::open`
  already returns `SecretBytes`, so the value propagates without an
  intervening Vec. `SecretStore::get` drops the `SecretBytes::new`
  rewrap (the value is already typed). The single
  `.expose_secret().to_vec()` lives at the upstream
  `CredentialApi::get_secret` SPI seam — the only place the SPI
  contract demands a bare `Vec<u8>`.

* CMT-009 — `put_bytes` and `inner.put` take `&SecretBytes` (was
  `&[u8]`). Chosen over adding `SecretBytes: AsRef<[u8]>`: the secret
  wrappers deliberately omit `Deref`/`AsRef` per their own rustdoc to
  block accidental widening (a downstream `Display`/`Debug` on a
  borrowed slice would still leak bytes-as-numbers — not the
  passphrase as text, but still leakage). Signature change keeps the
  opacity intact. The lone `.expose_secret()` call inside `put` flows
  straight into `crypto::seal`, so the bare-buffer window is one
  statement. `SecretStore::set` drops its `.expose_secret()` at the
  seam; `CredentialApi::set_secret` wraps the incoming `&[u8]` into
  `SecretBytes::from_slice` immediately — the same allocation the
  AEAD seal would have made anyway, with mlock + zeroize-on-drop for
  the brief window before seal consumes it.

The lock-contention test from Wave 1 is updated to pass `SecretBytes`
through the new `inner.put` signature.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…<label, body> (Wave 3 / CMT-010,011)

The vault's `entries` field switches from `Vec<Entry>` to
`BTreeMap<String, EntryBody>`. `Entry` is renamed to `EntryBody` and
loses its `label` field — the map key carries the label, so the same
data is no longer stored in two places.

* CMT-010 — the wire shape becomes a JSON object keyed by label
  (`"entries": {"<label>": {"nonce": "...", "ciphertext": "..."}}`)
  instead of an array of `{label, nonce, ciphertext}` objects. Per
  the user's "no wire-format compat needed" direction; first ship.
  Documented in the module rustdoc + the on-disk schema example.

* CMT-011 — every `put`/`get`/`delete`/`rekey` callsite swaps Vec
  semantics for map semantics:
    - `put` → `entries.insert(label, EntryBody { nonce, ciphertext })`
      (replaces in one step; the old `retain` + `push` two-step is
      gone, taking with it any chance of an inconsistent intermediate
      state if a panic landed between them).
    - `get` → `entries.get(label)` (O(log n), no linear scan).
    - `delete` → `entries.remove(label).is_none()` returns the same
      boolean the previous `len()` diff did, in one operation.
    - `rekey` walks `(label, body)` pairs and inserts into the new
      map; the `reserve_exact(len)` Vec-only hint is dropped (BTree
      has no equivalent).

A new `duplicate_label_in_wire_is_collapsed_by_object_semantics`
test pins the load-bearing JSON-object invariant: even if a malicious
file plants duplicate `"seed":{...}` keys, JSON / serde_json collapse
them on parse, so the type-level "one entry per label" guarantee can
never be violated on the wire. The byte-stability test is updated
for the new shape (object-keyed, no `"label":` field).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…deferral (Wave 4 / CMT-007,012,020)

Three low-risk readability fixes and a documented intentional gap for
the Windows ACL coverage:

* CMT-012 — split the immediately-invoked closure in `write_vault`
  into a real `do_write_vault` method. The warn-on-error rendering
  now hangs off a normal `.inspect_err(...)` call on a method call
  instead of an IIFE pattern that added one level of indirection for
  no benefit. Same atomicity contract, fewer reading hops.

* CMT-020 — extract the duplicate decrypt→corruption projection
  from `rekey` and `get` into a free `entry_decrypt_or_corruption`
  helper. Both call sites previously inlined the same `match err {
  Decrypt => warn+Corruption, other => other }` block — collapsed
  into one helper that the rustdoc explains as "the verify-token
  already passed, so a tag failure here is corruption". Single
  source of truth for the projection, no behaviour change.

* CMT-007 — formalize the Windows `check_perms` stub as
  `INTENTIONAL`. The Unix branches enforce mode `0600` (files) and
  `0700` (dirs); Windows ACL parity requires `GetSecurityInfo` via
  `windows-acl` / `winapi` and is out of scope for the secrets-
  feature landing. The comment now references the follow-up issue
  #3754 and the matching
  CMT-002 dir stub picks up the same reference. Operators on Windows
  MUST set ACLs manually until the follow-up lands.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov
Copy link
Copy Markdown

codecov Bot commented May 27, 2026

Codecov Report

❌ Patch coverage is 91.25077% with 142 lines in your changes missing coverage. Please review.
✅ Project coverage is 87.10%. Comparing base (64a85a8) to head (6ca1bc1).
⚠️ Report is 2 commits behind head on feat/platform-wallet-sqlite-persistor.

Files with missing lines Patch % Lines
...es/rs-platform-wallet-storage/src/secrets/store.rs 68.83% 48 Missing ⚠️
...rs-platform-wallet-storage/src/secrets/file/mod.rs 94.12% 44 Missing ⚠️
...s/rs-platform-wallet-storage/src/secrets/secret.rs 79.54% 36 Missing ⚠️
...-platform-wallet-storage/src/secrets/file/error.rs 91.78% 6 Missing ⚠️
packages/rs-platform-wallet-storage/src/lib.rs 0.00% 5 Missing ⚠️
.../rs-platform-wallet-storage/src/secrets/keyring.rs 84.61% 2 Missing ⚠️
...platform-wallet-storage/src/secrets/file/format.rs 99.56% 1 Missing ⚠️
Additional details and impacted files
@@                            Coverage Diff                            @@
##           feat/platform-wallet-sqlite-persistor    #3672      +/-   ##
=========================================================================
- Coverage                                  87.17%   87.10%   -0.07%     
=========================================================================
  Files                                       2607     2638      +31     
  Lines                                     319489   323964    +4475     
=========================================================================
+ Hits                                      278507   282203    +3696     
- Misses                                     40982    41761     +779     
Components Coverage Δ
dpp 87.71% <ø> (-0.01%) ⬇️
drive 85.95% <ø> (ø)
drive-abci 89.60% <ø> (ø)
sdk ∅ <ø> (∅)
dapi-client ∅ <ø> (∅)
platform-version ∅ <ø> (∅)
platform-value 92.20% <ø> (+0.03%) ⬆️
platform-wallet ∅ <ø> (∅)
drive-proof-verifier 49.16% <ø> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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

Cumulative incremental review at head f4a9a92 vs prior 9884f22. Prior finding statuses: #1 (rekey passphrase desync) STILL VALID; #2 (RMW races) FIXED via with_vault_lock (mod.rs:239-269); #3 (Windows perm-check) INTENTIONALLY DEFERRED with explicit marker + tracked issue #3754; #4 (dir perms) FIXED on Unix via set_restrictive_dir_perms (mod.rs:788-808); #5 (read_vault size bound) FIXED via pre-allocation cap check (mod.rs:336-344); #6 (metadata→read TOCTOU) FIXED via open_no_follow + fd-derived metadata (mod.rs:330-347, 760-767). One blocking carried-forward finding remains plus two small new latest-delta observations (with_vault_lock error collapsing all try_write errors to Busy; minor symlink-follow surfaces in helpers). REQUEST_CHANGES on the rekey bug.

🔴 1 blocking | 🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:410-452: [carried-forward, STILL VALID] rekey() swaps store-wide passphrase after re-encrypting only one wallet's vault
  Re-verified at head f4a9a92a (mod.rs:410-452). `EncryptedFileStoreInner` still owns a single store-wide `passphrase: SecretString` (mod.rs:87), used by `derive_and_verify` (mod.rs:306-318) for every vault in the directory. `rekey(wallet_id, new_passphrase)` re-encrypts only `self.vault_path(&wallet_id)` (lines 422-448) inside `with_vault_lock`, then unconditionally executes `self.passphrase = new_passphrase` at line 450 — outside the per-wallet lock and irrespective of how many other vault files share the directory.

  The module-level doc (mod.rs:3) and `vault_path` (mod.rs:205-207) confirm the intended layout is multiple `{wallet_id}.pwsvault` files in one directory under a single store handle. Two distinct exploitable shapes survive Waves 1–4:

    1. **Multi-wallet store lockout.** A directory holding `wallet_A.pwsvault` and `wallet_B.pwsvault`: caller invokes `rekey(A, new_pp)`. Only A is re-encrypted under `new_pp`; B's on-disk vault still carries the old salt sealed under the old passphrase. Every subsequent read/put/delete on B runs `derive_and_verify` with `new_pp`, fails the verify-token AEAD tag, and surfaces `WrongPassphrase` — silent denial-of-access to data the user actually owns.

    2. **Missing-target-vault passphrase swap.** The `None` arm at lines 424-428 returns `Ok(())` from the closure without touching disk, but control still falls through to `self.passphrase = new_passphrase`. So `rekey(unknown_wallet, new_pp)` silently re-passphrases the store handle without re-encrypting any existing sibling vaults.

  The in-line comment at lines 425-426 documents the swap as intentional, but the `passphrase` field is store-wide while the rekey is per-wallet — the contract is internally inconsistent for any directory containing more than one vault. Wave-1 locking and the outer `Arc::get_mut` gate only protect against concurrent writers; they do not change the per-wallet-rewrite vs store-wide-replace asymmetry. Fix options: (a) iterate every `*.pwsvault` in the directory under all locks and re-encrypt in lockstep; (b) move the passphrase to a per-wallet store (one wallet per directory); (c) enforce at `open()` that the dir contains no more than one vault file and document the single-wallet invariant.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:257-267: [new] with_vault_lock collapses every io::Error from try_write into Busy
  `with_vault_lock` (mod.rs:257-267) loops on `rw.try_write()` and matches `Err(_)` once for the retry branch and once for the terminal `FileStoreError::Busy` branch — every `io::Error` variant is treated as contention. `fd_lock::RwLock::try_write` returns `ErrorKind::WouldBlock` only for genuine lock contention; other errno classes (EBADF, EIO, kernel lock-table exhaustion, fs eviction) are real I/O faults. As written those faults are silently retried until the wait budget expires and then projected to `Busy`, hiding both the original errno and the appropriate `FileStoreError::Io` channel. The `Busy` retry hint is also misleading for non-contention faults. Inspect `err.kind()` and only treat `WouldBlock` as contention; surface anything else as `Err(FileStoreError::Io(err))` immediately. Diagnostic-quality issue, not a correctness one.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:731-740: [carried-forward, INTENTIONALLY DEFERRED] Windows skips pre-existing vault permissions check
  Re-verified at head f4a9a92a. The non-Unix `check_perms` (mod.rs:737-740) and `set_restrictive_dir_perms` (mod.rs:816-819) are still no-op stubs. The prior TODO has been promoted to explicit `INTENTIONAL(CMT-007)` / `INTENTIONAL(CMT-002)` markers (lines 731-736, 810-815) referencing tracked follow-up https://github.com/dashpay/platform/issues/3754, and the `EncryptedFileStore::open` doc comment now flags the Windows operator responsibility. Reclassified from STILL VALID to INTENTIONALLY DEFERRED for this review — not a regression, but the A1/A4/A6 at-rest guarantees in SECRETS.md remain Unix-only until #3754 lands. Surfacing for visibility, not blocking.

Comment on lines +410 to +452
fn rekey(
&mut self,
wallet_id: WalletId,
new_passphrase: SecretString,
) -> Result<(), FileStoreError> {
// The `&mut self` arrival gates in-process races (the outer
// `EncryptedFileStore::rekey` proves exclusive `Arc` ownership
// via `Arc::get_mut`). The cross-process advisory lock added in
// CMT-001 guards the read→re-encrypt→write span against a peer
// process; `with_vault_lock` also takes the in-process per-
// wallet mutex so a future refactor that loses the `&mut self`
// channel cannot silently regress the safety.
let path = self.vault_path(&wallet_id);
self.with_vault_lock(&wallet_id, || {
let Some(old_vault) = self.read_vault(&path)? else {
// No vault on disk yet: the new passphrase becomes the
// active one for any future write (set below the lock).
return Ok(());
};
let old_key = self.derive_and_verify(&wallet_id, &old_vault)?;
let (mut new_vault, new_key) = self.new_vault(&wallet_id, &new_passphrase)?;

for (label, body) in &old_vault.entries {
let aad = format::aad(format::FORMAT_VERSION, wallet_id.as_bytes(), label);
let pt = entry_decrypt_or_corruption(
&wallet_id,
label,
crypto::open(&old_key, &body.nonce, &aad, &body.ciphertext),
)?;
let (nonce, ct) = crypto::seal(&new_key, &aad, pt.expose_secret())?;
new_vault.entries.insert(
label.clone(),
EntryBody {
nonce,
ciphertext: ct,
},
);
}
self.write_vault(&path, &new_vault)
})?;
self.passphrase = new_passphrase;
Ok(())
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🔴 Blocking: [carried-forward, STILL VALID] rekey() swaps store-wide passphrase after re-encrypting only one wallet's vault

Re-verified at head f4a9a92 (mod.rs:410-452). EncryptedFileStoreInner still owns a single store-wide passphrase: SecretString (mod.rs:87), used by derive_and_verify (mod.rs:306-318) for every vault in the directory. rekey(wallet_id, new_passphrase) re-encrypts only self.vault_path(&wallet_id) (lines 422-448) inside with_vault_lock, then unconditionally executes self.passphrase = new_passphrase at line 450 — outside the per-wallet lock and irrespective of how many other vault files share the directory.

The module-level doc (mod.rs:3) and vault_path (mod.rs:205-207) confirm the intended layout is multiple {wallet_id}.pwsvault files in one directory under a single store handle. Two distinct exploitable shapes survive Waves 1–4:

  1. Multi-wallet store lockout. A directory holding wallet_A.pwsvault and wallet_B.pwsvault: caller invokes rekey(A, new_pp). Only A is re-encrypted under new_pp; B's on-disk vault still carries the old salt sealed under the old passphrase. Every subsequent read/put/delete on B runs derive_and_verify with new_pp, fails the verify-token AEAD tag, and surfaces WrongPassphrase — silent denial-of-access to data the user actually owns.

  2. Missing-target-vault passphrase swap. The None arm at lines 424-428 returns Ok(()) from the closure without touching disk, but control still falls through to self.passphrase = new_passphrase. So rekey(unknown_wallet, new_pp) silently re-passphrases the store handle without re-encrypting any existing sibling vaults.

The in-line comment at lines 425-426 documents the swap as intentional, but the passphrase field is store-wide while the rekey is per-wallet — the contract is internally inconsistent for any directory containing more than one vault. Wave-1 locking and the outer Arc::get_mut gate only protect against concurrent writers; they do not change the per-wallet-rewrite vs store-wide-replace asymmetry. Fix options: (a) iterate every *.pwsvault in the directory under all locks and re-encrypt in lockstep; (b) move the passphrase to a per-wallet store (one wallet per directory); (c) enforce at open() that the dir contains no more than one vault file and document the single-wallet invariant.

source: ['claude', 'codex']

Comment on lines +257 to +267
let mut rw = fd_lock::RwLock::new(lock_file);
let deadline = Instant::now() + LOCK_WAIT_BUDGET;
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: [new] with_vault_lock collapses every io::Error from try_write into Busy

with_vault_lock (mod.rs:257-267) loops on rw.try_write() and matches Err(_) once for the retry branch and once for the terminal FileStoreError::Busy branch — every io::Error variant is treated as contention. fd_lock::RwLock::try_write returns ErrorKind::WouldBlock only for genuine lock contention; other errno classes (EBADF, EIO, kernel lock-table exhaustion, fs eviction) are real I/O faults. As written those faults are silently retried until the wait budget expires and then projected to Busy, hiding both the original errno and the appropriate FileStoreError::Io channel. The Busy retry hint is also misleading for non-contention faults. Inspect err.kind() and only treat WouldBlock as contention; surface anything else as Err(FileStoreError::Io(err)) immediately. Diagnostic-quality issue, not a correctness one.

source: ['claude']

Comment on lines +731 to +740
// INTENTIONAL(CMT-007): Windows ACL read-check deferred to a follow-up
// PR — tracked at https://github.com/dashpay/platform/issues/3754. Vault
// dir/file mode hardening on Windows requires GetSecurityInfo via
// `windows-acl` or `winapi`; out of scope for the secrets-feature
// landing. Operators on Windows MUST set ACLs manually until the
// follow-up lands.
#[cfg(not(unix))]
fn check_perms(_meta: &fs::Metadata) -> Result<(), FileStoreError> {
Ok(())
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: [carried-forward, INTENTIONALLY DEFERRED] Windows skips pre-existing vault permissions check

Re-verified at head f4a9a92. The non-Unix check_perms (mod.rs:737-740) and set_restrictive_dir_perms (mod.rs:816-819) are still no-op stubs. The prior TODO has been promoted to explicit INTENTIONAL(CMT-007) / INTENTIONAL(CMT-002) markers (lines 731-736, 810-815) referencing tracked follow-up #3754, and the EncryptedFileStore::open doc comment now flags the Windows operator responsibility. Reclassified from STILL VALID to INTENTIONALLY DEFERRED for this review — not a regression, but the A1/A4/A6 at-rest guarantees in SECRETS.md remain Unix-only until #3754 lands. Surfacing for visibility, not blocking.

source: ['claude', 'codex']

lklimek and others added 2 commits May 27, 2026 16:18
…ain feature

apple-native-keyring-store 1.0.0 emits compile_error! on macOS unless
one of `keychain` or `protected` is enabled — latent since the dep was
introduced, only fires on macOS targets. Linux CI never tripped it;
the macOS Rust workspace tests job did at run 26515837284.

Picked `keychain` for the standard Keychain backend matching the
SecretStore SPI v1 contract. `protected` (user-presence variant) can
be added as an opt-in later.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… submodule

With the `keychain` feature enabled (added in f0f69fa), Store moves
to `apple_native_keyring_store::keychain::Store` — not the crate root.
Sibling backends (dbus-secret-service / linux-keyutils / windows-native)
all put Store at the root, so this is an asymmetric path; documented in
the call-site comment.

The earlier macOS cascade reported 4 PersistenceError From<String>
errors in platform-wallet-ffi/persistence.rs as a side-effect — those
disappear once the Store path resolves (verified locally via
`cargo check -p platform-wallet-ffi`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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

Prior finding reconciliation for f0f69fa: carried forward STILL VALID prior findings: #2:with-vault-lock-collapses-io-errors-to-busy; fixed/outdated prior findings: #1:file-store-rekey-store-wide-passphrase-single-wallet; intentionally deferred prior findings: #3:windows-vault-permissions-intentionally-deferred. Latest-delta findings are listed separately when present.

Latest delta (f4a9a92f0f69fa) is a one-line Cargo.toml fix enabling the keychain feature on apple-native-keyring-store to satisfy the upstream crate's mandatory feature gate for macOS builds. No new defects introduced in the delta. Two prior in-scope findings remain valid at the current head and are carried forward: (1) rekey() still mutates the store-wide self.passphrase after re-encrypting only one wallet's vault, a data-loss bug for multi-wallet stores in this PR's new public API; (2) with_vault_lock still maps every try_write Err(_) to Busy, hiding genuine I/O failures. The Windows ACL gap is intentionally deferred to follow-up #3754.

🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:257-267: with_vault_lock collapses every try_write I/O error into Busy
  Verified at f0f69fa (mod.rs:259-267): the loop matches `Err(_) if Instant::now() < deadline` for retry and the bare `Err(_)` arm at line 265 returns `FileStoreError::Busy`. `fd_lock::RwLock::try_write` returns `io::Error` that may carry kinds other than `WouldBlock` (EIO, EBADF on a closed lock fd, EACCES after a permission flip on the lock file, ENOSPC during lock-state changes, etc.). All of those genuine failures get hidden under `Busy`, which the SPI maps to a transient/retryable signal — operators see a misleading 'someone else holds the lock' instead of the real fault, and consumers can spin on a permanent failure. Match `e.kind() == ErrorKind::WouldBlock` (optionally also `Interrupted`) for the retry/`Busy` path and propagate other `io::Error`s through the existing `FileStoreError::Io` path so the `tracing` and operator diagnostics added elsewhere in this PR aren't undone here.

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`:
- [SUGGESTION] lines 257-267: with_vault_lock collapses every try_write I/O error into Busy
  Verified at f0f69fa (mod.rs:259-267): the loop matches `Err(_) if Instant::now() < deadline` for retry and the bare `Err(_)` arm at line 265 returns `FileStoreError::Busy`. `fd_lock::RwLock::try_write` returns `io::Error` that may carry kinds other than `WouldBlock` (EIO, EBADF on a closed lock fd, EACCES after a permission flip on the lock file, ENOSPC during lock-state changes, etc.). All of those genuine failures get hidden under `Busy`, which the SPI maps to a transient/retryable signal — operators see a misleading 'someone else holds the lock' instead of the real fault, and consumers can spin on a permanent failure. Match `e.kind() == ErrorKind::WouldBlock` (optionally also `Interrupted`) for the retry/`Busy` path and propagate other `io::Error`s through the existing `FileStoreError::Io` path so the `tracing` and operator diagnostics added elsewhere in this PR aren't undone here.

Reviewed commit: f0f69fae

Comment on lines +257 to +267
let mut rw = fd_lock::RwLock::new(lock_file);
let deadline = Instant::now() + LOCK_WAIT_BUDGET;
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: with_vault_lock collapses every try_write I/O error into Busy

Verified at f0f69fa (mod.rs:259-267): the loop matches Err(_) if Instant::now() < deadline for retry and the bare Err(_) arm at line 265 returns FileStoreError::Busy. fd_lock::RwLock::try_write returns io::Error that may carry kinds other than WouldBlock (EIO, EBADF on a closed lock fd, EACCES after a permission flip on the lock file, ENOSPC during lock-state changes, etc.). All of those genuine failures get hidden under Busy, which the SPI maps to a transient/retryable signal — operators see a misleading 'someone else holds the lock' instead of the real fault, and consumers can spin on a permanent failure. Match e.kind() == ErrorKind::WouldBlock (optionally also Interrupted) for the retry/Busy path and propagate other io::Errors through the existing FileStoreError::Io path so the tracing and operator diagnostics added elsewhere in this PR aren't undone here.

Suggested change
let mut rw = fd_lock::RwLock::new(lock_file);
let deadline = Instant::now() + LOCK_WAIT_BUDGET;
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
};
let mut rw = fd_lock::RwLock::new(lock_file);
let deadline = Instant::now() + LOCK_WAIT_BUDGET;
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock
&& Instant::now() < deadline =>
{
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Err(FileStoreError::Busy);
}
Err(e) => return Err(FileStoreError::Io(e)),
}
};

source: ['claude', 'codex']

…d path

Carried over from PR #3743 CMT-007 (B7) — the From<String> impl was
removed from PersistenceError but 4 sites in the #[cfg(feature =
\"shielded\")] block of FfiPersistence::load() still used the
.to_string().into() / format!(...).into() pattern. Linux primary CI hid
this because --all-features only runs on the backup matrix (which was
skipped); macOS workspace-tests with --all-features always expanded the
shielded cfg and failed.

Replaced all 4 sites with PersistenceError::backend(...), matching the
pattern already in use at line 1296 for the non-shielded path.

Verified locally via cargo check -p platform-wallet-ffi --features shielded.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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

Prior finding reconciliation for f0f69fa: carried forward STILL VALID prior findings: #1:file-store-rekey-store-wide-passphrase-single-wallet, #2:with-vault-lock-collapses-io-errors-to-busy; fixed/outdated prior findings: none; intentionally deferred prior findings: #3:windows-vault-permissions-intentionally-deferred.

Latest delta (f4a9a92f0f69fa) only enables the keychain feature on the macOS apple-native-keyring-store dependency, and I did not find a new defect in that delta. The unresolved prior rekey data-loss bug and the lock-error taxonomy issue are still present at this head and are carried forward; the Windows ACL gap remains explicitly deferred to #3754.

🔴 1 blocking | 🟡 1 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [BLOCKING] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:410-452: rekey() swaps the store-wide passphrase after re-encrypting only one wallet's vault
  Verified at f0f69fae (mod.rs:410-452). `EncryptedFileStoreInner` still owns a single store-wide `passphrase: SecretString` (line 87), and `derive_and_verify` uses it for every vault in the directory. `rekey(wallet_id, new_passphrase)` computes `path = self.vault_path(&wallet_id)`, re-encrypts only that wallet's vault under the new key, writes only that one path, then assigns `self.passphrase = new_passphrase`. Any other existing wallet vault in the same store remains sealed under the old passphrase but future operations now derive with the new one, so rekeying one wallet can make the rest of the store unreadable until reopened with the old passphrase. For the new multi-wallet SecretStore surface, that is a data-loss bug.
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:257-267: with_vault_lock collapses every try_write I/O error into Busy
  Verified at f0f69fae (mod.rs:259-267): the lock loop matches `Err(_) if Instant::now() < deadline` for retry and a bare `Err(_)` returns `FileStoreError::Busy`. `fd_lock::RwLock::try_write` can surface real `io::Error`s as well as `WouldBlock`; those failures are now hidden as a transient contention signal, which can make operators/consumers retry a permanent lock-file or filesystem fault instead of seeing the real error.
Out-of-scope follow-up suggestions (1)

These are valid observations, but they are outside this PR's scope and should be handled in separate issues or author/maintainer-requested PRs rather than blocking this review.

  • Windows skips the pre-existing vault permissions check — The non-Unix permission helpers are still no-op stubs, but the current head explicitly marks the gap as INTENTIONAL(CMT-007) / INTENTIONAL(CMT-002) and tracks it in follow-up issue #3754, so this is intentionally deferred rather than a fresh blocker for this push.
    • Follow-up: Handle via the referenced Windows ACL follow-up (#3754).

Reviewed commit: f0f69fae

Note: this supersedes my earlier same-SHA review body, which incorrectly classified prior rekey finding #1 as fixed while the current file still shows it is valid.

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

Prior finding reconciliation for 48b0749: carried forward STILL VALID prior findings: #1:with-vault-lock-collapses-io-errors-to-busy; fixed/outdated prior findings: none; intentionally deferred prior findings: none. Latest-delta findings are listed separately when present.

Cumulative wallet-secret-storage PR (file vault + OS-keyring backends) remains cryptographically sound. The latest f0f69fa..48b0749 delta is a 6-line macOS-only fix moving Store under apple_native_keyring_store::keychain — verified correct, no new defects. Two carried/identified suggestions stand: the prior finding about with_vault_lock collapsing every fd_lock::try_write io::Error into Busy is STILL VALID and unchanged at mod.rs:259-265, and the OS-keyring build_os helper skips the crate's documented label allowlist before passing into native credential stores.

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:259-266: with_vault_lock reports every try_write I/O error as transient Busy
  The retry loop matches `Err(_) if Instant::now() < deadline` and the terminal `Err(_) => return Err(FileStoreError::Busy)` arm catches every `io::Error` kind, not just `WouldBlock`. `fd_lock::RwLock::try_write` returns plain `io::Error`, so genuine non-contention failures — `EACCES` after a permission flip on the lock file, `EBADF` on a closed fd, `EIO`, `ENOSPC` — get reported as `FileStoreError::Busy`, which the SPI projection maps to `KeyringError::NoStorageAccess` (a retryable signal) and which the `FileStoreError::Busy` doc-comment explicitly limits to two recoverable causes (rekey-with-outstanding-cred and cross-process advisory-lock contention). Operators see a misleading 'someone else holds the lock' for permanent faults and consumers can spin on a non-retryable failure. Match only `WouldBlock` for retry/Busy and bubble every other `io::Error` through the existing `From<io::Error>` path.

In `packages/rs-platform-wallet-storage/src/secrets/store.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/store.rs:123-130: build_os skips the label allowlist before passing into native keyring backends
  `validate.rs:1-6` documents the crate contract that `label` is reject-not-sanitize against `^[A-Za-z0-9._-]{1,64}$` *before any backend maps it to a filename or a keyring attribute*, and the file backend honors this everywhere (mod.rs:467, 495, 518, 595, 612, 626, 667). `build_os` does not: it forwards the caller-supplied `label` straight into `store.build(&svc, label, None)`, so non-ASCII, control characters, path separators, NULs, or overlong inputs reach the per-platform credential store and get accepted, normalized, or rejected differently depending on backend (Secret Service vs Keychain vs Credential Manager vs linux-keyutils). The result is cross-platform behavior drift and loss of the documented reject-before-mapping invariant. Validate at the seam, mirroring the file backend.

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`:
- [SUGGESTION] lines 259-266: with_vault_lock reports every try_write I/O error as transient Busy
  The retry loop matches `Err(_) if Instant::now() < deadline` and the terminal `Err(_) => return Err(FileStoreError::Busy)` arm catches every `io::Error` kind, not just `WouldBlock`. `fd_lock::RwLock::try_write` returns plain `io::Error`, so genuine non-contention failures — `EACCES` after a permission flip on the lock file, `EBADF` on a closed fd, `EIO`, `ENOSPC` — get reported as `FileStoreError::Busy`, which the SPI projection maps to `KeyringError::NoStorageAccess` (a retryable signal) and which the `FileStoreError::Busy` doc-comment explicitly limits to two recoverable causes (rekey-with-outstanding-cred and cross-process advisory-lock contention). Operators see a misleading 'someone else holds the lock' for permanent faults and consumers can spin on a non-retryable failure. Match only `WouldBlock` for retry/Busy and bubble every other `io::Error` through the existing `From<io::Error>` path.

In `packages/rs-platform-wallet-storage/src/secrets/store.rs`:
- [SUGGESTION] lines 123-130: build_os skips the label allowlist before passing into native keyring backends
  `validate.rs:1-6` documents the crate contract that `label` is reject-not-sanitize against `^[A-Za-z0-9._-]{1,64}$` *before any backend maps it to a filename or a keyring attribute*, and the file backend honors this everywhere (mod.rs:467, 495, 518, 595, 612, 626, 667). `build_os` does not: it forwards the caller-supplied `label` straight into `store.build(&svc, label, None)`, so non-ASCII, control characters, path separators, NULs, or overlong inputs reach the per-platform credential store and get accepted, normalized, or rejected differently depending on backend (Secret Service vs Keychain vs Credential Manager vs linux-keyutils). The result is cross-platform behavior drift and loss of the documented reject-before-mapping invariant. Validate at the seam, mirroring the file backend.

Reviewed commit: 48b0749c

Comment on lines +259 to +266
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: with_vault_lock reports every try_write I/O error as transient Busy

The retry loop matches Err(_) if Instant::now() < deadline and the terminal Err(_) => return Err(FileStoreError::Busy) arm catches every io::Error kind, not just WouldBlock. fd_lock::RwLock::try_write returns plain io::Error, so genuine non-contention failures — EACCES after a permission flip on the lock file, EBADF on a closed fd, EIO, ENOSPC — get reported as FileStoreError::Busy, which the SPI projection maps to KeyringError::NoStorageAccess (a retryable signal) and which the FileStoreError::Busy doc-comment explicitly limits to two recoverable causes (rekey-with-outstanding-cred and cross-process advisory-lock contention). Operators see a misleading 'someone else holds the lock' for permanent faults and consumers can spin on a non-retryable failure. Match only WouldBlock for retry/Busy and bubble every other io::Error through the existing From<io::Error> path.

Suggested change
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
let mut rw = fd_lock::RwLock::new(lock_file);
let deadline = Instant::now() + LOCK_WAIT_BUDGET;
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
if Instant::now() >= deadline {
return Err(FileStoreError::Busy);
}
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(e) => return Err(FileStoreError::from(e)),
}
};

source: ['claude', 'codex']

Comment on lines +123 to +130
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: build_os skips the label allowlist before passing into native keyring backends

validate.rs:1-6 documents the crate contract that label is reject-not-sanitize against ^[A-Za-z0-9._-]{1,64}$ before any backend maps it to a filename or a keyring attribute, and the file backend honors this everywhere (mod.rs:467, 495, 518, 595, 612, 626, 667). build_os does not: it forwards the caller-supplied label straight into store.build(&svc, label, None), so non-ASCII, control characters, path separators, NULs, or overlong inputs reach the per-platform credential store and get accepted, normalized, or rejected differently depending on backend (Secret Service vs Keychain vs Credential Manager vs linux-keyutils). The result is cross-platform behavior drift and loss of the documented reject-before-mapping invariant. Validate at the seam, mirroring the file backend.

Suggested change
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let label = super::validate::validated_label(label).map_err(FileStoreError::from)?;
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}

source: ['codex']

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

Prior finding reconciliation for 6ca1bc1: carried forward STILL VALID prior findings: #1:with-vault-lock-reports-try-write-io-errors-as-busy, #2:build-os-skips-label-allowlist-before-native-keyring; fixed/outdated prior findings: none; intentionally deferred prior findings: none. Latest-delta findings are listed separately when present.

Latest delta (6ca1bc1) is a small, correct error-construction refactor in rs-platform-wallet-ffi/src/persistence.rs (drops From<String> lift in favor of explicit PersistenceError::backend(...)). No new defects introduced. Both prior findings remain STILL VALID at HEAD: with_vault_lock collapses every try_write io::Error to Busy, and build_os bypasses the documented label allowlist on the OS-keyring arm. Carried forward as suggestions; no blockers.

🟡 2 suggestion(s)

🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.

In `packages/rs-platform-wallet-storage/src/secrets/file/mod.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/file/mod.rs:259-267: with_vault_lock collapses every try_write io::Error into Busy
  `fd_lock::RwLock::try_write` returns a plain `io::Error` whose `ErrorKind` distinguishes lock contention (`WouldBlock`) from real I/O faults (`PermissionDenied`, `BadFileDescriptor`, EIO, etc.). The current loop matches `Err(_)` in both the retry and terminal arms, so any non-contention failure is busy-spun until `LOCK_WAIT_BUDGET` expires and then surfaced to callers as `FileStoreError::Busy`. This violates the variant's documented contract ("another process is mid-write, retry") and hides real fs failures (permissions/quota/tampered sidecar) behind a transient retry signal, prompting operators to retry on errors that retry cannot fix. The `FileStoreError::Io(io::Error)` variant already exists via `#[from]`; only `ErrorKind::WouldBlock` should map to `Busy`, everything else should propagate as `Io`.

In `packages/rs-platform-wallet-storage/src/secrets/store.rs`:
- [SUGGESTION] packages/rs-platform-wallet-storage/src/secrets/store.rs:123-130: build_os bypasses the documented label allowlist on the OS-keyring arm
  The secrets module documents a reject-not-sanitize label contract (`^[A-Za-z0-9._-]{1,64}$`, enforced in `secrets/validate.rs`) intended to fire before any backend maps a label to a filename or keyring attribute (CWE-22/CWE-20). The File arm enforces it at every entry point (put_bytes/get_bytes/delete_bytes), but `build_os` — the sole entry path for `SecretStore::Os::{set,get,delete}` — hands the raw caller-supplied `label` directly to `store.build(&svc, label, None)`. Whatever validation happens after that is per-backend (macOS Keychain, Windows Credential Manager, libsecret, linux-keyutils all differ), so labels containing whitespace, control characters, path separators, or oversize strings that the File arm rejects may be accepted on some platforms. This breaks the cross-backend invariant that the same `(service, label)` resolves identically and weakens the OS-keyring trust boundary. Enforce `validated_label` in `build_os` so the policy is uniform across arms.

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`:
- [SUGGESTION] lines 259-267: with_vault_lock collapses every try_write io::Error into Busy
  `fd_lock::RwLock::try_write` returns a plain `io::Error` whose `ErrorKind` distinguishes lock contention (`WouldBlock`) from real I/O faults (`PermissionDenied`, `BadFileDescriptor`, EIO, etc.). The current loop matches `Err(_)` in both the retry and terminal arms, so any non-contention failure is busy-spun until `LOCK_WAIT_BUDGET` expires and then surfaced to callers as `FileStoreError::Busy`. This violates the variant's documented contract ("another process is mid-write, retry") and hides real fs failures (permissions/quota/tampered sidecar) behind a transient retry signal, prompting operators to retry on errors that retry cannot fix. The `FileStoreError::Io(io::Error)` variant already exists via `#[from]`; only `ErrorKind::WouldBlock` should map to `Busy`, everything else should propagate as `Io`.

In `packages/rs-platform-wallet-storage/src/secrets/store.rs`:
- [SUGGESTION] lines 123-130: build_os bypasses the documented label allowlist on the OS-keyring arm
  The secrets module documents a reject-not-sanitize label contract (`^[A-Za-z0-9._-]{1,64}$`, enforced in `secrets/validate.rs`) intended to fire before any backend maps a label to a filename or keyring attribute (CWE-22/CWE-20). The File arm enforces it at every entry point (put_bytes/get_bytes/delete_bytes), but `build_os` — the sole entry path for `SecretStore::Os::{set,get,delete}` — hands the raw caller-supplied `label` directly to `store.build(&svc, label, None)`. Whatever validation happens after that is per-backend (macOS Keychain, Windows Credential Manager, libsecret, linux-keyutils all differ), so labels containing whitespace, control characters, path separators, or oversize strings that the File arm rejects may be accepted on some platforms. This breaks the cross-backend invariant that the same `(service, label)` resolves identically and weakens the OS-keyring trust boundary. Enforce `validated_label` in `build_os` so the policy is uniform across arms.

Reviewed commit: 6ca1bc16

Comment on lines +259 to +267
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
};
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: with_vault_lock collapses every try_write io::Error into Busy

fd_lock::RwLock::try_write returns a plain io::Error whose ErrorKind distinguishes lock contention (WouldBlock) from real I/O faults (PermissionDenied, BadFileDescriptor, EIO, etc.). The current loop matches Err(_) in both the retry and terminal arms, so any non-contention failure is busy-spun until LOCK_WAIT_BUDGET expires and then surfaced to callers as FileStoreError::Busy. This violates the variant's documented contract ("another process is mid-write, retry") and hides real fs failures (permissions/quota/tampered sidecar) behind a transient retry signal, prompting operators to retry on errors that retry cannot fix. The FileStoreError::Io(io::Error) variant already exists via #[from]; only ErrorKind::WouldBlock should map to Busy, everything else should propagate as Io.

Suggested change
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(_) if Instant::now() < deadline => {
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(_) => return Err(FileStoreError::Busy),
}
};
let _file_guard = loop {
match rw.try_write() {
Ok(guard) => break guard,
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock
&& Instant::now() < deadline =>
{
std::thread::sleep(LOCK_POLL_INTERVAL);
}
Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
return Err(FileStoreError::Busy)
}
Err(e) => return Err(FileStoreError::from(e)),
}
};

source: ['claude', 'codex']

Comment on lines +123 to +130
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

🟡 Suggestion: build_os bypasses the documented label allowlist on the OS-keyring arm

The secrets module documents a reject-not-sanitize label contract (^[A-Za-z0-9._-]{1,64}$, enforced in secrets/validate.rs) intended to fire before any backend maps a label to a filename or keyring attribute (CWE-22/CWE-20). The File arm enforces it at every entry point (put_bytes/get_bytes/delete_bytes), but build_os — the sole entry path for SecretStore::Os::{set,get,delete} — hands the raw caller-supplied label directly to store.build(&svc, label, None). Whatever validation happens after that is per-backend (macOS Keychain, Windows Credential Manager, libsecret, linux-keyutils all differ), so labels containing whitespace, control characters, path separators, or oversize strings that the File arm rejects may be accepted on some platforms. This breaks the cross-backend invariant that the same (service, label) resolves identically and weakens the OS-keyring trust boundary. Enforce validated_label in build_os so the policy is uniform across arms.

Suggested change
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}
fn build_os(
store: &Arc<dyn CredentialStoreApi + Send + Sync>,
service: &WalletId,
label: &str,
) -> Result<Entry, FileStoreError> {
let label = super::validate::validated_label(label).map_err(FileStoreError::from)?;
let svc = format!("{SERVICE_PREFIX}{}", service.to_hex());
store.build(&svc, label, None).map_err(map_spi)
}

source: ['claude', 'codex']

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