Skip to content

feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692

Draft
Claudius-Maginificent wants to merge 52 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-rehydration
Draft

feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692
Claudius-Maginificent wants to merge 52 commits into
feat/platform-wallet-sqlite-persistorfrom
feat/platform-wallet-rehydration

Conversation

@Claudius-Maginificent
Copy link
Copy Markdown
Collaborator

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


STACKED PR — review diff against feat/platform-wallet-storage-secrets (PR #3672), not v3.1-dev.

Merge order: #3625 (feat/platform-wallet-sqlite-persistor) → #3672 (feat/platform-wallet-storage-secrets) → this PR.

The wallet_id sign-gate that this PR's earlier iterations bundled has been extracted to PR #3735 (security patch targeting v3.1-dev directly). Land #3735 first; the standard merge-up cycle will pull the gate into this PR's lineage naturally.


Issue being fixed or feature implemented

After the SQLite persister landed (#3625), restarting the wallet app required a full re-scan from birth height — the DB held all the data but nothing reconstituted live wallets from it. This PR closes that gap.

The user story matches how the real iOS host works. The app launches with the Keychain locked. There is no seed in memory. The wallet UI needs to come back instantly with all balances, UTXOs, identities, and asset-lock state — without prompting the user to unlock — so they can see their funds, scroll their history, and decide whether to act. Only when they do act (sign a transaction, register an identity key) does the Keychain unlock and the seed arrive, gated to that one operation. This was validated against dashwallet-ios (swift-sdk-integration branch): loadFromPersistor() is zero-arg, called at app launch with locked Keychain; signing flows take the MnemonicResolverHandle vtable on demand.

The implementation reflects that: load is seedless and watch-only. Every persisted wallet comes back as Wallet::new_watch_only(...) — no key material derived, no signing capability, no seed touched. Wrong-seed detection moves to the sign path — covered by the companion security PR #3735 against v3.1-dev.

What was done?

Seedless watch-only load (rs-platform-wallet)

PlatformWalletManager::load_from_persistor() reconstructs each persisted wallet from the keyless ClientWalletStartState:

pub async fn load_from_persistor(&self) -> Result<LoadOutcome, PlatformWalletError>

For each wallet in the persisted wallets map, the manager:

  • Builds an AccountCollection from the account_manifest: one Account::from_xpub(parent_wallet_id, account_type, account_xpub, network) per AccountRegistrationEntry.
  • Constructs Wallet::new_watch_only(network, wallet_id, accounts)key_wallet::WalletType::WatchOnly variant, no Mnemonic/Seed variant, no key bytes anywhere.
  • Routes the keyless CoreChangeSet (UTXOs, tx records, IS-locks, sync watermarks) into the wallet via the existing apply_persisted_core_state(...) path, which correctly handles non-BIP44 topologies (CoinJoin-only / DashPay) via all_funding_accounts_mut() — the F2 silent-zero balance fix carries through.

A wallet whose persisted rows fail to decode is skipped, not silently mis-loaded. LoadOutcome.skipped carries (WalletId, SkipReason::CorruptPersistedRow { kind: CorruptKind }) where CorruptKind is MissingManifest | MalformedXpub | DecodeError(String). A PlatformEvent::WalletSkippedOnLoad { wallet_id, reason } fires per skip. One corrupt row never aborts the rest. The caller receives Ok(LoadOutcome) (non-empty skipped is success, not an error).

New schema readers

Item Reader Notes
A1 schema::accounts::load_state Reads account_registrations + pools; decodes AccountRegistrationEntry; no Wallet built
B schema::core_state::load_state Bulk reconstructs ManagedWalletInfo — UTXOs, tx records, IS-locks, derived-address flags, sync watermarks, last_applied_chain_lock; routes UTXOs to the first funds-bearing account of any topology (no BIP44 assumption); no silent zero balance
A2 schema::asset_locks::load_unconsumed Status-predicate reader excluding terminal Consumed rows at SQL level (WHERE status NOT IN ('consumed'))

FFI

// before — earlier iteration of this PR (now removed)
int32_t platform_wallet_manager_load_from_persistor(
    const PlatformWalletManagerHandle* manager,
    const PlatformWalletPersisterHandle* persister,
    const ResolverSeedProvider* resolver,
    LoadOutcomeFFI* out_outcome);

// after
int32_t platform_wallet_manager_load_from_persistor(
    const PlatformWalletManagerHandle* manager,
    const PlatformWalletPersisterHandle* persister,
    LoadOutcomeFFI* out_outcome);

The resolver arg is gone — load is purely watch-only. LoadOutcomeFFI surfaces loaded_count / skipped_count / skipped[] so the host can retry skipped wallets after a corruption-fix flow.

Swift wrapper

PlatformWalletManager.swift::loadFromPersistor() aligns to the new 2-arg + outparam C signature (passes nil for the outcome ptr — the iOS host doesn't surface skip reasons to the UI today).

No V002 migration

Every column required for this phase is in V001. No SQL migration is added.

Not in this PR

How Has This Been Tested?

cargo fmt --all --check
cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings
cargo check --workspace
cargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi
cargo test --doc -p platform-wallet -p platform-wallet-storage

Result: 410 tests passed, 0 failed, 8 ignored. Doctests: 3 passed, 0 failed, 1 ignored.

Targeted suite (packages/rs-platform-wallet/tests/rehydration_load.rs):

  • RT-WO — persist N wallets, drop, reopen, load_from_persistor(); assert every wallet comes back as Wallet::WatchOnly with correct wallet_id, accounts, balances. No seed ever touched.
  • RT-Corrupt — feed a corrupt manifest blob for one wallet; assert that wallet appears in LoadOutcome.skipped with CorruptPersistedRow, the other wallets load cleanly, exactly one PlatformEvent::WalletSkippedOnLoad fires.
  • RT-Z — assert LoadOutcome, SkipReason, WalletSkippedOnLoad payloads carry no key material in Display or Debug.

Persister-side readers:

cargo test -p platform-wallet-storage --test sqlite_accounts_reader \
                                       --test sqlite_core_state_reader \
                                       --test sqlite_asset_locks_filter \
                                       --test sqlite_load_wiring \
                                       --test sqlite_load_reconstruction

13/13 tc_p4_* passes including corruption-is-hard-error variants.

Breaking Changes

This PR rewrites a load path that was added in earlier commits of this same PR (and has never shipped). There are no breaking changes against v3.1-dev. For reviewers tracking the in-PR evolution:

  • PlatformWalletManager::load_from_persistor() no longer takes a &dyn SeedProvider (the trait itself was deleted — MnemonicResolverHandle is the on-demand contract).
  • ClientWalletStartState no longer carries a Wallet field (assembled in the manager via Wallet::new_watch_only).
  • FFI dropped the 3rd resolver arg from platform_wallet_manager_load_from_persistor.

No ! in the title because this is additive capability on an unreleased API — v3.1-dev carries none of the previous PR-internal shapes.

AR-7 hygiene

Load path eliminates AR-7 entirely — the manager never constructs WalletType::Mnemonic|Seed, only WalletType::WatchOnly (no key material). AR-7's residual Debug concern was about derived Wallet values on the load path; that path no longer derives.

Sign path keeps AR-7 discipline (Zeroizing + non_secure_erase()); the sign-time wallet_id gate that enforces it ships in PR #3735.

Checklist:

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

Note on the checklist item above: no ! in the title because no public API on v3.1-dev changes. The FFI signature change is internal to this PR branch (never released).

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Co-authored by Claudius the Magnificent AI Agent


Rebuild note (2026-05-25): History rewritten to remove the sign-gate code that was extracted to PR #3735. The 5-commit minimal rework on top of the original PR-1 rehydration work yields a focused diff: watch-only load via Wallet::new_watch_only, FFI resolver-arg drop, Swift wrapper align. The sign-time wallet_id gate ships via #3735 against v3.1-dev.

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 20, 2026

Important

Review skipped

Draft detected.

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: 9610843c-0cee-449a-bc76-16983fd7c47b

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-rehydration

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.

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>
lklimek and others added 11 commits May 20, 2026 17:10
schema::accounts::load_state reads account_registrations rows back into
a deterministic Vec<AccountRegistrationEntry> manifest — the account-set
oracle and per-account xpub cross-check source for rehydration. Mints no
Wallet, fail-hard on a corrupt blob. RT: sqlite_accounts_reader (3 tests).

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…wrong-seed gate (S)

- platform-wallet: storage-agnostic SeedProvider trait with zeroizing,
  Debug-redacted SecretPhrase/SecretSeed newtypes (M-DONT-LEAK-TYPES);
  SeedUnavailable/SecretStoreErrorKind structural projections.
- manager::rehydrate::rehydrate_wallet: fail-closed, constant-time
  wrong-seed gate (compute_wallet_id recompute + per-account xpub
  cross-check via subtle) yielding typed WrongSeedForDatabase that
  carries only the two 32-byte ids. AR-7 noted at the call site.
- manager::rehydrate::apply_persisted_core_state: keyless CoreChangeSet
  → ManagedWalletInfo apply (balance no-silent-zero contract).
- load_from_persistor signature → (&dyn SeedProvider) -> LoadOutcome;
  seed-unavailable ⇒ skip (continue before insert, LoadOutcome.skipped,
  PlatformEvent::WalletSkippedOnLoad); wrong seed ⇒ hard-fail.
- ClientWalletStartState made keyless by type (no Wallet/seed field).
- platform-wallet-storage: secrets-gated CredentialStoreSeedProvider
  adapter over `keyring_core::api::CredentialStoreApi` (mnemonic→seed
  label order, no secret in logs/errors). File-backend WrongPassphrase
  is recovered via `downcast_failure` on the cross-SPI marker so the
  operator-actionable case survives the seam.

RT: seed_provider (4) + rehydrate (3) unit tests, secrets_seed_provider
_adapter (10). secrets_scan/secrets_guard still green.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
core_state::load_state rebuilds the keyless CoreChangeSet projection
(unspent UTXOs with address recovered from script+network, tx records,
IS-locks, sync watermarks) for one wallet — the safety-critical balance
source. spent rows excluded; fail-hard on a corrupt blob. Documents the
reconstructed-vs-deferred split: last_applied_chain_lock /
per-account-attribution / coinbase flags re-warm on first post-load
sync (the no-V001-column deviation from dev-plan §5 is recorded inline).

RT-2 (sqlite_core_state_reader): a non-zero balance survives
store→drop→reopen→load→apply, reconstructed exact in the confirmed
bucket — the no-silent-zero contract proven end-to-end. 4 tests.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…on reader (A2)

asset_locks::load_unconsumed excludes terminal 'consumed' rows at the
SQL level so a spent one-shot lock never resurrects as actionable on
rehydration (A04/A08); historical rows stay on disk via load_state.
Corrects the factually-wrong list_active doc-comment (consumed locks do
NOT leave via AssetLockChangeSet::removed — they upsert and persist).

RT-4 (sqlite_asset_locks_filter): mix incl. terminal Consumed — row
still on disk, absent from filtered feed, non-terminal survive. 2 tests.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…d (C)

SqlitePersister::load() now populates ClientStartState.wallets with the
keyless per-wallet payload (network, birth_height, account_manifest,
core_state, identity_manager, Consumed-filtered unused_asset_locks) via
the A1/B/A2 readers + identities::load_state. Return type carries no
Wallet/seed by construction. Real wallets_rehydrated tracing count;
LOAD_UNIMPLEMENTED shrunk to the genuinely-deferred set
(contacts/identity_keys/last_applied_chain_lock); load() rustdoc
corrected.

RT (sqlite_load_wiring): keyless payload round-trips, empty DB stays
empty, metadata-only wallet still present. 3 tests.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
rehydration_load: load_from_persistor through a real
PlatformWalletManager (mock SDK, in-memory keyless persister, test
SeedProvider) —
- seed round-trip: wallet registered + signing-capable by construction;
- RT-W: present-but-wrong seed ⇒ WrongSeedForDatabase, NOT in skipped,
  NO WalletSkippedOnLoad event, wallet absent;
- RT-S: seed absent ⇒ skip (other wallets load, skipped wallet ABSENT
  from manager, LoadOutcome.skipped + exactly one WalletSkippedOnLoad
  event, Ok), then recoverable on a fresh targeted re-load;
- RT-S(ii): KeyringLocked ⇒ StoreUnavailable skip;
- RT-Z: no seed byte leaks into LoadOutcome / SkipReason /
  WrongSeedForDatabase Display+Debug.
5 tests.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…tion (F)

sqlite_load_reconstruction: header rewritten (no longer 'blocked on
upstream Wallet::from_persisted'); tc_p4_006/tc_p4_007 now assert
wallets_rehydrated=N / pending=0 and a populated wallets payload;
tc_p4_012 asserts O(1)-per-wallet + small constant shared overhead
(no brittle magic-number pin) instead of the old fixed-2. All 13 green.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… SELECTs (F)

The full-rehydration readers (accounts/core_state load_state) use
prepare() for one-shot SELECTs by design; add them to
READ_ONLY_PREPARE_ALLOWED so tc_p1_003 (writers must use
prepare_cached) stays green without weakening the writer-side rule.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… doc (F2,F3)

F2 (MEDIUM): apply_persisted_core_state previously routed persisted
UTXOs only into the first BIP44 account, silently dropping ALL UTXOs
(→ Ok + balance 0) for CoinJoin-only / non-BIP44 topologies. Now route
into the wallet's first funds-bearing account of ANY topology (BIP44/
BIP32/CoinJoin/DashPay) via all_funding_accounts_mut(); the wallet
total stays exact (it is a sum). A wallet with persisted UTXOs but no
funds account at all fails closed with the new typed
PlatformWalletError::RehydrationTopologyUnsupported (wallet_id +
utxo_count, no key material) instead of a silent zero. Signature is
now Result<(), PlatformWalletError>.

F3 (LOW): moved the last_applied_chain_lock bullet from the
'Reconstructed' to the 'Deferred' rustdoc section (it is always None
from disk — no V001 column).

RT: f2_no_bip44_wallet_nonzero_balance_survives_reopen (CoinJoin-only,
9_000_000 duffs) fail→pass; RT-2 + B-2/B-3/B-4 still green.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
F4 (LOW): the plain '!=' wallet_id re-check after insert_wallet was
shadowed-dead — the constant-time rehydrate_wallet gate already proves
compute_wallet_id() == expected_wallet_id pre-insert and a mismatch is
the typed fail-closed WrongSeedForDatabase. The legacy check only
emitted a weaker untyped WalletCreation error and confused readers;
removed. Also wires the F2 apply_persisted_core_state Result into the
hard-fail/rollback path. RT-W still passes (typed WrongSeedForDatabase
from the real gate unaffected).

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…PI (F1)

F1 (HIGH): workspace no longer compiled against the new
load_from_persistor signature / keyless ClientWalletStartState.

- New ResolverSeedProvider wraps the existing Swift MnemonicResolver-
  Handle vtable (same mechanism as sign_with_mnemonic_resolver) as a
  SeedProvider — minimal correct seed source, no second secret path,
  no mnemonic round-tripping. Chosen over SecretStoreSeedProvider
  because the iOS host already owns the resolver, not a SecretStore.
- build_wallet_start_state now projects its reconstructed wallet +
  wallet_info into the keyless ClientWalletStartState shape
  (account_manifest from accounts, core_state CoreChangeSet from the
  restored UTXO set + sync watermarks); the local Wallet is dropped
  (manager re-derives from the resolver seed + runs the wrong-seed
  gate).
- platform_wallet_manager_load_from_persistor gains a resolver param
  and an optional *mut LoadOutcomeFFI out-param: the LoadOutcome is no
  longer silently discarded — every skipped (wallet_id, reason) is
  logged AND surfaced (loaded_count/skipped_count/skipped[]) so the
  host can retry the skipped set. New platform_wallet_load_outcome_free
  releases the heap array.

Acceptance: cargo check --workspace AND --all-features both exit 0.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
lklimek and others added 12 commits May 22, 2026 14:58
…bridge; distinguish Corruption from WrongPassphrase

Collapse the two-error-type split into a single `FileStoreError` enum and
delete `error_bridge.rs` entirely. The boxed-marker downcast machinery
(`FileStoreFailure`, `into_keyring`, `downcast_failure`,
`marker_from_message`, `bad_format`) is replaced by a plain
`impl From<FileStoreError> for keyring_core::Error`. The SPI projection
is lossy by design: `WrongPassphrase`/`Busy` ride in `NoStorageAccess`
with the typed error boxed as the source (still recoverable by
downcast); the corruption/format family collapses into `BadStoreFormat`.

Stop mapping AEAD tag failures to `WrongPassphrase` once the header
verify-token has already passed. In `get()` and `rekey()`, an entry tag
failure means corruption or tampering, so it now maps to the new
`Corruption` variant. The internal `Decrypt` signal stays crate-private
to the crypto seam and is translated at the call sites that hold the
vault context.

New tests prove the distinction: a bit-flipped entry ciphertext after a
correct unlock yields `Corruption`, while a genuinely wrong passphrase
still yields `WrongPassphrase`; the `Busy` no-panic rekey test is kept.

BREAKING CHANGE: `FileStoreFailure` and `downcast_failure` are removed
from the public surface; consumers recover structure from the typed
`FileStoreError` or by downcasting `keyring_core::Error::NoStorageAccess`.

Refs CMT-004 CMT-005 CMT-006 CMT-011

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… and dangling mlock on empty SecretBytes

Delete `SecretString`'s custom `Drop`. It formed a `&mut [u8]` over the
uninitialized `len..cap` region via `from_raw_parts_mut`, which is UB
even when only writing. `Zeroizing<String>` already wipes the full
capacity on drop, so the custom Drop was redundant; removing it makes
`SecretString` symmetric with `SecretBytes`. Field order (`inner` before
`_lock`) still wipes the buffer while it is mlock'ed.

Guard `SecretBytes::new`'s `region::lock` on `capacity() > 0`: an empty
`Vec`'s `as_ptr()` is dangling, and locking a forced length of 1 over it
invoked an OS call on an invalid address. Drop the dead `bytes.zeroize()`
after `std::mem::take` — the move transferred the allocation, leaving
nothing to wipe.

Add an empty-`SecretBytes` construction test; the ignored full-capacity
wipe tests still pass with the custom Drop gone.

Refs CMT-001 CMT-003

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…d two-step parse (CMT-007)

Replace the hand-rolled binary `format.rs` with a serde_json vault: a
top-level `version` key, lax `VersionProbe` for the dispatch gate, then a
strict `deny_unknown_fields` `VaultFile` payload for the compiled-in
FORMAT_VERSION. Byte fields (salt, nonce, verify_ct, ciphertext) are
lowercase hex (no new base64 dep); Argon2 params are JSON numbers.

Smythe's binding conditions:
- C1: `aad()`/`verify_aad()` unchanged; the JSON `version` is never
  routed into AAD — documented as the AAD-determinism invariant.
- C2/SEC-001: add Argon2 upper bounds (ARGON2_MAX_M_KIB = 1 GiB,
  ARGON2_MAX_T = 16); rename `enforce_floors` -> `enforce_bounds`, gated
  in `derive_key` BEFORE Params::new / hash_password_into, so an inflated
  m_kib fails before any allocation and before verify-token derivation.
- C3: `VersionProbe` lax; `VaultFile`/`KdfDescriptor`/`EntryRecord`
  `deny_unknown_fields`.
- C4: explicit post-parse `kdf.id == KDF_ID_ARGON2ID` check.
- C5/SEC-003: all serde_json errors mapped to MalformedVault /
  VersionUnsupported with the source discarded; regression test asserts
  no input bytes leak into the rendered error.
- C6/SEC-002: every byte-field length validated post-deserialize
  (salt/nonce widths, verify_ct/ciphertext >= AEAD tag); wrong length =>
  MalformedVault, never a panic in copy_from_slice.
- C7: version stays 2 (clean pre-release break); no committed
  `*.pwsvault` fixtures exist; roundtrip + bad-version tests ported to
  the JSON path.

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

Replace the POSIX-only `O_EXCL`-temp + `fs::rename` + dir-fsync writer
with `tempfile::NamedTempFile::persist`, the crate's existing idiom
(sqlite/backup.rs). The old `rename`-over-existing path failed on the
second write on Windows; `persist` replaces atomically on win/mac/linux,
amd64+arm.

Smythe's binding conditions:
- C8: `NamedTempFile::new_in(parent)` keeps the temp in the destination's
  directory so `persist` is never cross-volume.
- C9: do not loosen the temp perms (tempfile is owner-private on all
  OSes); on Unix additionally pin 0600 before writing. Windows DACL work
  deferred for v1.
- C10/C11: order is write -> sync_all (all OSes) -> persist ->
  `#[cfg(unix)]` parent-dir fsync; never pre-remove the destination; on
  persist failure the temp drops and self-cleans (no manual remove race).
  Comment notes Windows relies on NTFS metadata journaling for dir
  durability.
- C12: drop the `COUNTER` static + `std::process::id()` temp naming.
- C13: `check_perms` read-check stays `#[cfg(unix)]`; added a
  `// TODO(CMT-009)` for the deferred Windows ACL read-check.

Regression test `second_write_over_existing_vault_succeeds` exercises the
replace-over-existing path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…tBytes, never raw bytes (CMT-002)

Add `SecretStore` as the public, never-leaking secrets entry point.
`get` yields a zeroizing `SecretBytes` (a raw `Vec<u8>` never crosses the
boundary); `set` takes `&SecretBytes` so callers cannot pass an unwrapped
buffer. The `File` arm delegates to new inherent typed methods on
`EncryptedFileStore`, returning `FileStoreError` losslessly so
`WrongPassphrase` vs `Corruption` vs `Busy` stay distinct. The `Os` arm
projects `keyring_core::Error` best-effort into the new
`FileStoreError::OsKeyring { kind }` payload-free discriminant. The
internal `CredentialApi`/`CredentialStoreApi` SPI impls are unchanged;
`SecretStore` wraps them.

Docs (SECRETS.md, lib.rs, secrets/mod.rs) present `SecretStore` as the
consumer front door with keyring_core as the internal SPI.

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

Drop the boxed-marker recovery in `From<FileStoreError> for
keyring_core::Error`: the SPI seam is now lossy and string-only, with no
`Box<dyn>` round-trip. The lossless `WrongPassphrase`/`Corruption`/`Busy`
distinction lives on the typed `SecretStore` path. Repoint the in-crate
SPI tests that recovered the typed error through `NoStorageAccess` onto
the typed path, asserting only the lossy projection at the seam.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ore NoStorageAccess for lossless SPI recovery

Revert the string-only `From<FileStoreError> for keyring_core::Error`:
`WrongPassphrase` / `Busy` now box the single typed `FileStoreError`
itself into `NoStorageAccess`, so external keyring_core-SPI consumers
recover the variant losslessly via
`source().downcast_ref::<FileStoreError>()`. No second type is
reintroduced (FileStoreFailure stays deleted), satisfying the original
error.rs objection. The `BadStoreFormat` group has no box slot, so it
carries only a secret-free string and stays fully typed on the
`SecretStore` path. Seam tests assert downcast recovery and the
secret-free BadStoreFormat rendering.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…re __secrets-test-helpers (CMT-008)

The in-RAM MemoryCredentialStore test double had no consumer outside its
own module. Its behaviors (label rejection, namespacing, zeroizing
storage) are already covered by the tempdir-backed EncryptedFileStore
tests, so the store and its dedicated `__secrets-test-helpers` feature
are retired. The dev-dependency self-reference uses `__test-helpers`,
not the secrets one, so nothing else needs it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pose_secret guard scan (CMT-012/010)

CMT-012: `hex::decode_to_slice` accepts uppercase, so `parse_service`
now rejects any A-F before decoding — the service string is always
constructed lowercase via `WalletId::to_hex`, making lowercase a clean
parse invariant. Adds a test that an uppercase-hex service is rejected
and the lowercase form of the same bytes is accepted.

CMT-010: the expose_secret leak guard joined only a 2-line window, so a
3+-line `tracing::…(field = expose_secret(), …)` call slipped through.
The scan now groups whole statements (concatenating until parens
balance and a `;`/`{` is seen) so the sink and `expose_secret` land in
one window. Adds a non-vacuous planted 3-line case the widened scan
catches and the old 2-line window would have missed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ader (CMT-013/014)

CMT-013: removes internal finding-ID and rework narration (SEC-00N,
EDIT-N, CMT-NNN, "trimmed fork of", "the defect: used to…") from
comments across src/secrets/, keeping the present-state behavior and
requirement-spec rationale. Comments describe what IS, not the journey.

CMT-014: removes the embedded MIT license-text block atop secret.rs
(first-party, same org, matching license) and replaces the module
header with a one-line doc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… failures (Display-only, secret-free)

Library-idiom + security-event logging only; no blanket error! at
routine return sites.

- Swallowed mlock failures in secret.rs (3 sites) move from debug! to
  warn!: they are .ok()-swallowed and caller-invisible, yet
  security-relevant (the secret may be swappable to disk or land in a
  core dump). Display `{e}` only.
- Corruption/tamper detected in get()/rekey() (post-verify AEAD tag
  failure → Corruption): error! with the non-secret wallet-id/label,
  Display only, never the secret or the raw keyring error.
- Vault write failure in write_vault: warn! with the io error's
  Display; paths are caller-supplied non-secret.

Never `{:?}` a keyring_core::Error and never log a secret wrapper; all
new lines use `%` Display, so the EDIT-2 no-debug-format guard still
passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…_secrets-test-helpers references (QA-002)

The `__secrets-test-helpers` feature and its `secrets::MemoryCredentialStore`
in-RAM test double were removed in the keyring_core SPI rework. Remove the
stale feature row from the README Cargo features table and replace the
obsolete backend bullet in SECRETS.md with the current test pattern: a
tempdir-backed `EncryptedFileStore` (or `SecretStore::file`) constructed via
`tempfile::tempdir()`, available under the default `secrets` feature with no
special flag required.

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

🔁 Seedless-load rework landed — 34532e57d5..6f22c0e9f5

Per design validation against dashwallet-ios swift-sdk-integration: real iOS host loads watch-only at app launch (Keychain locked) and signs on-demand via the existing MnemonicResolverHandle vtable. The original seeded-load model (load takes &dyn SeedProvider, derives Wallet per persisted id, runs constant-time wrong-seed gate at load) did not match the real consumer. Pushed 7 commits flipping the model. Existing PR description above is stale — supersedes it for the deltas below.

What's new

  • load_from_persistor() is now seedlesspub async fn load_from_persistor(&self) -> Result<LoadOutcome, PlatformWalletError>. Manager reconstructs each persisted wallet via Wallet::new_watch_only(network, wallet_id, accounts), with accounts assembled from the keyless account_manifest (one Account::from_xpub(...) per registration entry). No seed touched on the load path.
  • Wrong-seed gate moved to the sign pathcrate::sign_gate::verify_seed_matches_wallet_id(root_pub, expected_wallet_id) -> bool (constant-time via subtle::ConstantTimeEq). Wired into all 4 resolver-fed FFI sign entrypoints:
    • sign_with_mnemonic_resolver.rs::dash_sdk_sign_with_mnemonic_resolver_and_path
    • identity_derive_and_persist.rs::dash_sdk_derive_and_persist_identity_keys
    • derive_identity_key_at_slot.rs::dash_sdk_derive_identity_key_at_slot_with_resolver
    • shielded_sync.rs::platform_wallet_manager_bind_shielded
  • SeedProvider trait + adapter + FFI shim deletedMnemonicResolverHandle (which already existed) is the sole on-demand signing contract. Files removed: rs-platform-wallet/src/seed_provider.rs, rs-platform-wallet-ffi/src/rehydration_seed_provider.rs, rs-platform-wallet-storage/src/secrets/seed_provider_adapter.rs (+ its test).
  • FFI signature: platform_wallet_manager_load_from_persistor(manager, persister, *mut LoadOutcomeFFI) — dropped the 3rd resolver arg (reverts b7508a0d47's 3-arg shape).
  • Swift PlatformWalletManager.swift aligns to the 2-arg + outparam C signature (passes nil for the outcome ptr).

ABI deltas (new in this PR, no v3.1-dev impact)

  • New result code ErrorWrongSeedForWallet = 13 in PlatformWalletFFIResultCode.
  • SkippedWalletFFI::reason_code family remapped to 100/101/102 for the new CorruptKind variants (MissingManifest, MalformedXpub, DecodeError). The old 0/1/2 (seed-availability) are gone — those skip-triggers don't exist anymore.

AR-7 hygiene

  • Load path eliminates AR-7 entirely — manager never constructs WalletType::Mnemonic|Seed; only WalletType::WatchOnly (no key material). AR-7's residual Debug concern was about derived Wallet values on the load path; that path no longer derives.
  • Sign path keeps AR-7 disciplineZeroizing + non_secure_erase() on the gate's throwaway master key on the mismatch path, before returning the error tag. No {:?} over any secret type.

Test reshape

  • tests/rehydration_load.rs — RT-WO, RT-Corrupt, RT-Z (watch-only construction, corrupt-row hard-fail, secret-hygiene). Wrong-seed scenarios removed from this file (no seed at load anymore).
  • Co-located gate tests in each of the 4 FFI sign entrypoints:
    • sign_with_mnemonic_resolver: happy + wrong + full-buffer-zero assertion
    • identity_derive_and_persist: wrong + persister-callback-never-fires assertion
    • derive_identity_key_at_slot: happy + wrong (asserts populated out_row on happy, zero/null fields on mismatch)
    • shielded_sync: wrong + null-resolver-handle-rejected (happy path requires full SDK + coordinator + commitment-tree — covered structurally by gate-fires-before-storage-touch assertion)
  • New sign_gate::tests unit module exercises CT helper directly.

What survived (unchanged by this rework)

  • Keyless PersistedWalletData / ClientWalletStartState (commit b9af9935)
  • A1/B/A2 schema readers (accounts::load_state, core_state::load_state, asset_locks::load_unconsumed)
  • F2 no-BIP44 silent-zero balance fix (commit 96a9aa90)
  • F4 dead post-insert wallet_id re-check removal (commit 62bd4754)
  • WrongSeedForDatabase typed error (re-homed to sign path)

Verification

  • cargo fmt --all --check: pass
  • cargo clippy -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi --all-targets -- -D warnings: pass
  • cargo check --workspace: pass
  • cargo test -p platform-wallet -p platform-wallet-storage -p platform-wallet-ffi: 414 passed, 0 failed, 7 ignored
  • cargo test --doc -p platform-wallet -p platform-wallet-storage: 3 passed, 1 ignored
  • cargo check --workspace --all-features: fails on pre-existing ShieldedChangeSet: Serialize/Deserialize gap at changeset.rs:914, verified reproducible on origin/feat/platform-wallet-rehydration@34532e57 — out of scope for this rework

🤖 Co-authored by Claudius the Magnificent AI Agent

@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): add full wallet rehydration from persistor + seed (skip-and-report) feat(platform-wallet): watch-only rehydration from persistor + wallet_id sign-gate (seedless load) May 25, 2026
lklimek and others added 5 commits May 25, 2026 13:10
…atch_only

load_from_persistor now rebuilds every persisted wallet watch-only from
its keyless AccountRegistrationEntry manifest (Wallet::new_watch_only)
and applies the keyless core-state projection on top. No seed material
is touched on the load path: signing keys are derived on demand later
through the MnemonicResolverHandle sign entrypoints, which carry the
fail-closed wrong-seed gate themselves.

Drops the SeedProvider port + WalletSecret/SecretPhrase/SecretSeed
payloads (and the storage CredentialStoreSeedProvider adapter that fed
them) — load no longer needs the abstraction. WrongSeedForDatabase
stays on PlatformWalletError for the sign-path gate. RT suite reshapes
to RT-WO (watch-only round-trip) + RT-Corrupt (per-row decode skip with
SkipReason::CorruptPersistedRow{kind: CorruptKind::MissingManifest}) +
RT-Z (no key material in any LoadOutcome / SkipReason surface).

apply_persisted_core_state and its F2/F3/F4 fixes are unchanged.

AR-7 residual risk on the load path is eliminated (no Wallet of a
signing type is constructed during load, so its Debug-leak surface is
gone from this path).

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…stor

platform_wallet_manager_load_from_persistor is now a 2-arg call
(manager_handle, out_outcome). The Swift host never passed a real
resolver at load time anyway — load is watch-only, signing keys are
derived later on demand through the same MnemonicResolverHandle vtable
the per-call sign entrypoints already use (next commit lands the
wallet_id gate there).

Drops the MnemonicResolverHandle → platform_wallet::SeedProvider
adapter (rehydration_seed_provider.rs); no consumer left.

LoadOutcomeFFI.SkippedWalletFFI.reason_code reshapes to the new
CorruptKind family (100/101/102) — ABI-bump for #3692 since the seed-
availability codes (0/1/2) it previously carried are gone with the
seedless load path.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…edless FFI

platform_wallet_manager_load_from_persistor is now (handle,
out_outcome) — the resolver argument is gone with the seedless-load
rework. Pass nil for out_outcome (Swift doesn't surface skipped
wallets yet; corrupt rows are logged on the Rust side).

Doc string refreshed to reflect Wallet::new_watch_only as the
underlying load primitive and the on-demand-signing + wrong-seed-gate
contract on the resolver-fed sign entrypoints.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
… sign-gate split

The wrong-seed detection moves off the load path and onto the resolver-fed
FFI sign entrypoints. That gate + its coverage now ships in PR #3735
(security patch against v3.1-dev), not here. Drop the dangling reference
to the never-existed `sign_wrong_seed_gate.rs` file and point readers at
PR #3735 instead.

Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
@lklimek lklimek force-pushed the feat/platform-wallet-rehydration branch from 0e92cb4 to f57b117 Compare May 25, 2026 11:21
@Claudius-Maginificent Claudius-Maginificent changed the title feat(platform-wallet): watch-only rehydration from persistor + wallet_id sign-gate (seedless load) feat(platform-wallet): watch-only rehydration from persistor (seedless load) May 25, 2026
lklimek added 5 commits May 25, 2026 14:41
…istor' into feat/platform-wallet-storage-secrets

# Conflicts:
#	Cargo.lock
…m-wallet-rehydration

# Conflicts:
#	packages/rs-platform-wallet-storage/src/sqlite/schema/asset_locks.rs
#	packages/rs-platform-wallet-storage/src/sqlite/schema/core_state.rs
…istor' into feat/platform-wallet-storage-secrets
Base automatically changed from feat/platform-wallet-storage-secrets to feat/platform-wallet-sqlite-persistor May 29, 2026 11:28
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.

2 participants