feat(platform-wallet): watch-only rehydration from persistor (seedless load)#3692
Conversation
…ppers, error, validation, MemoryStore
Group A (Tasks 1–3) of the secret-storage feature. All gated behind the
opt-in `secrets` Cargo feature (never enabled by `default`).
Task 1 — `secrets::secret`: `SecretString` (trimmed MIT fork of
dash-evo-tool `Secret`, the egui `TextBuffer`/`take()` leak path deleted
by construction — SEC-REQ-3.8.1/3.8.2) + net-new byte-oriented
`SecretBytes`. Redacting `Debug`, no `Display`/`Deref`/`Serialize`,
full-capacity zeroize on drop, best-effort `region` mlock,
`subtle::ConstantTimeEq` on `SecretBytes`. The only `unsafe` is the
forked full-capacity wipe in `Drop`, confined behind a narrow
`#[allow(unsafe_code)]` + `// SAFETY:` proof — `#![deny(unsafe_code)]`
stays crate-wide (SEC-REQ-4.8).
Task 2 — `secrets::error::SecretStoreError`: concrete `thiserror` enum,
no boxed dyn error (SEC-REQ-4.4 / TC-082), no `#[non_exhaustive]`, no
secret/passphrase/plaintext/source in any variant, static `#[error]`
strings. `secrets::validate`: 32-byte `WalletId` newtype +
`^[A-Za-z0-9._-]{1,64}$` label allowlist, reject-not-sanitize
(SEC-REQ-4.3, CWE-22/20).
Task 3 — `secrets::store::SecretStore` trait (`get` returns
`Option<SecretBytes>`, never bare `Vec<u8>` — SEC-REQ-4.1) +
`MemoryStore` test double, gated by `__secrets-test-helpers` so it is
unreachable from production builds (SEC-REQ-2.3.1/2.3.2). `src/lib.rs`
slot activated; `secrets` feature wires only the RustSec-clean pinned
crypto (argon2=0.5.3, chacha20poly1305=0.10.1, zeroize=1.8.2,
subtle=2.6.1, region=3.0.2, getrandom; keyring-core 4.x split). MSRV
1.92 verified to compile the full dep set (`aes-gcm` omitted).
`Send + Sync` / object-safety compile-asserts added.
Satisfies SEC-REQ 3.1, 3.2, 3.3, 3.5, 3.6, 3.8.1, 3.8.2, 4.1, 4.2,
4.3, 4.4, 4.5, 4.6, 4.8, 2.0.3, 2.3.1, 2.3.2.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…a20-Poly1305 vault
Group B Task 4. `secrets::file::{mod,format,crypto}`:
- Argon2id KDF (`argon2 0.5.3`): floors m≥19456 KiB / t≥2 / p=1 enforced
before any derivation; shipped default 64 MiB / t=3; params + 32-byte
CSPRNG salt stored in the versioned header (SEC-REQ-2.2.1/.2/.3/.4).
- XChaCha20-Poly1305 (`chacha20poly1305 0.10.1`): fresh random 24-byte
nonce per `put` (counter forbidden); combined decrypt so no
unverified plaintext is ever materialized (SEC-REQ-2.2.5/.6/.8).
- AAD = canonical length-prefixed `format_version‖wallet_id‖label`,
defeating blob-swap / version-rollback (SEC-REQ-2.2.7).
- Self-describing magic+version header; unknown version refused, fail
closed (SEC-REQ-2.2.9).
- 0600 at creation via O_EXCL + fchmod before any ciphertext byte;
pre-existing loose perms refused; atomic temp→fsync→rename→dir-fsync;
temp holds only ciphertext, removed on failure (SEC-REQ-2.2.10/.11).
- Atomic rekey: fresh salt + fresh per-entry nonces, no `.bak`
(SEC-REQ-2.2.12). Passphrase held in `SecretString`, never persisted,
zeroized on drop; derived key recomputed per op, never retained
(SEC-REQ-2.2.13).
Satisfies SEC-REQ 2.0.1, 2.0.2, 2.0.4, 2.2.1–2.2.13, 4.1.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ring-core 4.x split)
Group B Task 5. `secrets::keyring::KeyringStore` over the keyring 4.x
split: `keyring-core 1.0.0` API + per-platform store crates
(linux-keyutils / dbus-secret-service / apple-native / windows-native),
all exact-pinned, RustSec-clean, MSRV-1.92-verified.
- Namespacing: service `dash.platform-wallet-storage`, account
`{wallet_id_hex}:{label}` — two wallets cannot collide, a different
app cannot silently read; only the non-secret index appears in
keyring attributes (SEC-REQ-2.1.2, CWE-312).
- Fail-closed: headless / no Secret Service / no D-Bus → typed
`BackendUnavailable`; locked → typed error. Never `unwrap`, never a
silent plaintext / weaker-store fallback (SEC-REQ-2.1.3/.4 / AR-4).
- keyring-core's bare `Vec<u8>` from `get_secret` is wrapped into
`SecretBytes` and the intermediate zeroized immediately
(SEC-REQ-3.1/4.1).
- Per-OS threat-coverage rustdoc on the type (SEC-REQ-2.0.4 / 2.1.3).
Backend selection is an explicit operator decision — no auto-fallback
between KeyringStore and EncryptedFileStore (SEC-REQ-2.1.3 / AR-4).
Satisfies SEC-REQ 2.0.1, 2.0.4, 2.1.1, 2.1.2, 2.1.3, 2.1.4.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…egration tests Group B Task 6. `tests/secrets_guard.rs` (SEC-REQ-4.5.1): positive string-level scan of `src/secrets/` asserting no logging/formatting sink (`tracing::*`/`println!`/`format!`/`panic!`/…) is paired with an `expose_secret()` result — the guard `tests/secrets_scan.rs` deliberately does NOT cover this tree. Green on the clean tree; fails the moment a secret is routed to a sink. `tests/secrets_api.rs`: `get` returns `Option<SecretBytes>` (type binding, never `Vec<u8>` — SEC-REQ-4.1); `dyn SecretStore` object-safety / positive build guard (SEC-REQ-4.5); no boxed dyn error in `src/secrets/` (TC-082 parity, comment-aware); error `Display` is static and secret-free (SEC-REQ-2.0.1/3.3, CWE-209); wrapper `Debug` redacted at the boundary (SEC-REQ-3.3). `MemoryStore` intentionally unreachable from this external test crate (SEC-REQ-2.3.1). Satisfies SEC-REQ 4.5, 4.5.1. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…secrets crypto deps Group B Task 8 (SEC-REQ-4.7). The existing `rustsec/audit-check` already audits the full `Cargo.lock` — which now pins the `secrets`-gated crypto (argon2/chacha20poly1305/zeroize/subtle/region/ keyring-core + per-platform stores), so they are advisory-checked even though `default` does not enable `secrets`. This adds a `cargo-deny check advisories --all-features` job so the feature-conditional dependency graph is exercised explicitly, plus a workspace `deny.toml` (advisory ignore kept in sync with `.cargo/audit.toml`). Locally verified: `cargo audit` exits 0; none of the secrets crypto pins carry any RustSec advisory (confirms Smythe §7 first-hand). The only flagged item, RUSTSEC-2025-0141 (bincode unmaintained), is a pre-existing unrelated wasm-sdk/dpp dependency, not in the secrets path. Satisfies SEC-REQ 4.7. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…d atomic vault write C1 (HIGH, Marvin QA-001): a `put`/`get`/`delete`/`rekey` against an EXISTING vault with a passphrase deriving a DIFFERENT key than the vault was created with previously wrote a mismatched-key entry and returned Ok, producing an unreadable mixed-key vault. The header now carries a passphrase-verification token: an XChaCha20-Poly1305 seal of a fixed constant under the header-Argon2id-derived key, AAD-bound to `(format_version, wallet_id, "\0verify")` (the leading-NUL label is disjoint from every allowlisted entry label, so the token can never alias a real slot). Every operation on an existing vault derives the key from the supplied passphrase and verifies the token FIRST; a mismatch fails the Poly1305 tag (constant-time, no extra compare, no plaintext on failure) and returns `SecretStoreError::WrongPassphrase` before any entry is read, written, or deleted. New vaults write the token at creation; `rekey` verifies the old token and writes a fresh one. `format_version` bumped 1→2; v1/v2 cross-reads fail closed via the existing `VersionUnsupported` path. C6 (LOW, Smythe SEC-RA-001): `write_vault` no longer swallows the directory-fsync result — it is propagated as a typed error so the atomic temp→fsync→rename→dir-fsync chain (SEC-REQ-2.2.11) is fully enforced. C7 (LOW, Marvin QA-004): the temp file now uses a unique name (`pid` + monotonic counter) created with `O_EXCL` and the destination is never pre-removed, so a crash can never leave the vault absent and concurrent writers cannot collide on a fixed temp name. The atomic rename + fsync ordering is unchanged. Tests (red→green, file/mod.rs): wrong-pass `put` to existing vault ⇒ `Err(WrongPassphrase)` + vault still readable with the correct pass + rejected slot never written; wrong-pass `get`/`delete` ⇒ `Err(WrongPassphrase)` + vault unmutated; correct pass round-trips unchanged. The two wrong-pass tests were FAILED before this fix and pass after; format (de)serialize round-trips the token fields. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…ringLocked; correct keyring-core attribution
C3 (MED, Adams PROJ-002 / Marvin QA-003): `map_keyring_err` collapsed
keyring-core's `NoStorageAccess` into `BackendUnavailable`, leaving
`SecretStoreError::KeyringLocked` dead. Per keyring-core 1.0.0 docs,
`NoStorageAccess` covers the locked-collection case ("it might be that
the credential store is locked"), so it now maps to `KeyringLocked`,
enabling the unlock-retry UX (SEC-REQ-2.1.4). Genuinely-absent backends
(`NoDefaultStore` / `PlatformFailure`) stay `BackendUnavailable`.
Added `locked_keyring_maps_to_keyring_locked` asserting the locked,
absent, and not-found mappings.
C5 (LOW, Adams PROJ-003 / Marvin QA-004): the module header said
"keyring-core 4.x split" — inaccurate. Reworded to state the lib is
`keyring-core 1.0.0` plus the per-platform store crates; the `keyring`
4.x crate is the sample CLI and is not a dependency. No dependency
change.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…roizes on drop C4 (MED, Smythe SEC-RA-002 / Adams PROJ-004 / Marvin QA-002): the rustdoc claimed stored values sit in `SecretBytes`, but the map held a bare `Vec<u8>` that never zeroized — code contradicted the doc. Fixed the code (not the doc): the backing map is now `HashMap<(WalletId,String), SecretBytes>`, closing SEC-REQ-2.3.2 so even test memory is wiped on drop. Added `stored_value_is_zeroizing_ wrapper` (type-binding assertion) + a `needs_drop::<SecretBytes>()` compile-time guard. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…rgo.toml comment C5 (LOW, Adams PROJ-003 / Marvin QA-004): the per-platform-store dependency comment said "keyring-core 4.x split". Reworded to state accurately that `keyring-core 1.0.0` is the API and the per-platform crates provide the backends (the `keyring` 4.x crate is the sample CLI and is intentionally not depended on). No dependency change. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…etStore API C2 (MED, Adams PROJ-001): the trait sketch was stale/dangerous — `get -> Option<Vec<u8>>` (the exact CRITICAL leak SEC-REQ-4.1 forbids) and the false "feature flag exists today but flips no code" line. Rewritten to the delivered API: `get -> Result<Option<SecretBytes>, SecretStoreError>`, accurate `put`/`delete` signatures, the real backends (KeyringStore/EncryptedFileStore/MemoryStore with their fail-closed / gating semantics), and the now-true statement that enabling `secrets` activates the module. Present-state only, no history narration; no forbidden token introduced into `src/sqlite/schema/` or `migrations/`. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…ult-on Removes the cargo-deny advisories CI job and its `deny.toml` config in favour of the existing `rustsec/audit-check` job. Once `secrets` is in the default feature set, `Cargo.lock` unconditionally pins the RustSec-clean crypto stack (`argon2`/`chacha20poly1305`/`zeroize`/ `subtle`/`region`/`keyring-core` + per-platform store crates) so a single audit run covers them all (SEC-REQ-4.7). `secrets` joins `sqlite`+`cli` as a default feature. Dev-dependency on self adds `default-features = false` so the off-state CI invocation (`--no-default-features --features sqlite,cli`) actually exercises the secrets-disabled graph — otherwise the dev-dep view would silently re-enable defaults for every integration test. New `tests/secrets_off_state.rs` is the runtime D4 guard: gated `#[cfg(not(feature = "secrets"))]`, it builds against the persister surface only and asserts the off-state graph stays consumable. T1+T2 land atomically — cargo-deny removal coincides with secrets going default-on so crypto pins never drop out of audit scope between commits. Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…backends
Retires the crate-local `SecretStore` trait + `SecretStoreError` enum
and rebuilds the `secrets` submodule on
`keyring_core::api::{CredentialApi, CredentialStoreApi}` — the upstream
SPI shipped by `keyring-core 1.0.0`. The `EncryptedFileStore`'s
security construction (Argon2id + XChaCha20-Poly1305 + AAD verify
token + 0600 + atomic temp→rename + dir-fsync + zeroize) is preserved
byte-for-byte; only the trait surface changes.
API-shape mapping (Nagatha §1, variant A — the `:` delimiter is rejected
by the label allowlist):
service = "dash.platform-wallet-storage/" + hex(wallet_id)
user = label
Per-task content:
- **T3** `src/secrets/file/error.rs` — new `FileStoreError` enum
(`Decrypt`, `WrongPassphrase`, `KdfFailure`, `VersionUnsupported`,
`MalformedVault`, `InvalidLabel`, `InsecurePermissions`, `Io`).
Static `#[error]` strings only; no secret in any variant.
`src/secrets/file/error_bridge.rs` — `FileStoreFailure` unit-only
marker (Smythe EDIT-3: no `String`/`Vec<u8>`/`Path` fields permitted,
enforced via a compile-time `Copy` assertion) boxed inside
`keyring_core::Error::NoStorageAccess` (WrongPassphrase) or
rendered into `BadStoreFormat`'s static `String` payload. The
`downcast_failure` helper recovers the marker for D1(b).
- **T4** `src/secrets/file/mod.rs` — `EncryptedFileStore` implements
`CredentialStoreApi`; per-`(service, user)` entries implement
`CredentialApi`. The store is held behind an internal `Arc` so
long-lived credentials can outlive the public handle. `delete` honors
upstream's `NoEntry`-if-absent contract (D3). `service` parsing
rejects mismatch with `Invalid("service", _)`; `validated_label` runs
at `build` time AND every `CredentialApi` op (defence in depth,
M-2). All twelve in-module security tests port one-for-one through
the SPI (NoEntry for absence, downcast for typed-error checks).
- **T5** `src/secrets/keyring.rs` — `KeyringStore` wrapper retired in
favour of the bare `default_credential_store() -> Result<Arc<dyn
CredentialStoreApi + Send + Sync>, keyring_core::Error>` constructor.
Headless / unknown OS / D-Bus-less Linux → `NoDefaultStore` per D2
(typed, single SPI error). Never panics, never falls back.
- **T7** `src/secrets/memory.rs` — `MemoryStore` → `MemoryCredentialStore`
implementing `CredentialStoreApi`. Internal map keys on
`(service, user)` strings, values remain `SecretBytes` (SEC-REQ-2.3.2).
Still gated behind `__secrets-test-helpers`.
- **T8** `src/lib.rs` — object-safety + `Send + Sync` assertions now
target `keyring_core::Error` and `dyn CredentialStoreApi + Send +
Sync`. `src/secrets/mod.rs` re-exports the new surface; `pub use
SecretStore` / `SecretStoreError` retired.
- **Tests** — `tests/secrets_api.rs` rewritten against the SPI; the
`Vec<u8> → SecretBytes::new` consumer-seam pattern (Smythe EDIT-1:
no named intermediate `Vec` binding) is the type-shape assertion.
`tests/secrets_guard.rs` extended with the EDIT-2 EDIT-2 guard:
no `{{:?}}`-debug-format paired with `keyring_core::Error` in
`src/secrets/` (since `BadEncoding`/`BadDataFormat` embed raw
`Vec<u8>`). All twelve `EncryptedFileStore` security invariants
pass on the new API.
`tests/secrets_seed_provider_adapter.rs` and the
`seed_provider_adapter.rs` source file are NOT landed on this branch:
the `SeedProvider`/`WalletSecret`/`SeedUnavailable` types they consume
live in `rs-platform-wallet` on PR #3692, not on this base. The
rewritten adapter will land on PR #3692's rebase onto this tip — see
the rework report.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…core SPI
Rewrites SECRETS.md as the present-state spec for the secrets
submodule on the upstream `keyring_core::api` SPI:
- Drops the retired `SecretStore` trait listing.
- Documents the `service = "dash.platform-wallet-storage/" + hex(wid)`,
`user = label` key shape with the allowlist precondition.
- Memory hygiene section codifies Smythe EDIT-1: `SecretBytes::new(...)`
is the consumer-seam wrapper, no named intermediate `Vec` binding.
- Backends section: `EncryptedFileStore` + `default_credential_store()`
+ test-only `MemoryCredentialStore`.
- Cross-SPI error bridge: `FileStoreFailure` unit-only marker (EDIT-3
constraint stated as load-bearing), `downcast_failure` recovery
path, EDIT-2 `{:?}`-format ban on `keyring_core::Error` documented
with its enforcement test.
- Audit hooks section adds `secrets_off_state` (D4) and rephrases
`secrets_guard` to cover both leak sinks.
- Cargo features paragraph notes `secrets` is default-on; cargo-deny
removal is noted via the lockfile-is-audit-coverage rationale.
`src/lib.rs` crate-level doc retouched to point at the new SPI and
backend names (the prior "SecretStore reserved" phrasing retired).
`tests/secrets_scan.rs` exemption comment rephrased to describe the
present state.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…rface `tests/secrets_default_on_compiles.rs` (M-S4) — a build-only assertion that the default feature set (`secrets` in) re-exports every public type/function in the `secrets` submodule. Names: `EncryptedFileStore`, `SecretBytes`, `SecretString`, `WalletId`, `FileStoreError`, `FileStoreFailure`, `SERVICE_PREFIX`, `default_credential_store`, `keyring_core::Error`. Compiling the test target is the assertion; the body never exercises a backend. Pairs with `tests/secrets_off_state.rs` (D4 — runtime proof under `--no-default-features --features sqlite,cli` that the surface compiles out and the persister still links). Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
…EDIT-4)
QA-501 (MEDIUM, EDIT-4 forward-compat): `SecretBytes`/`SecretString`
retained `impl PartialEq`/`Eq` despite EDIT-4's binding intent. The
impls delegated to constant-time compares so today's behaviour is
safe, but leaving `==` reachable means future bridge code could
inherit a non-constant-time path or a length-leaking shortcut without
review noticing.
EDIT-4 says: no `==` on secret bytes, no exception. Strip the impls
and let `subtle::ConstantTimeEq::ct_eq` be the only equality path.
- `secret.rs` — removed `impl PartialEq for SecretBytes` /
`impl Eq for SecretBytes` and `impl PartialEq for SecretString` /
`impl Eq for SecretString`. `SecretString` gains an
`impl ConstantTimeEq` so callers keep a constant-time-safe
equivalence path (was previously implicit inside `PartialEq::eq`).
- Public rustdoc on both types names `PartialEq`/`Eq` in the "not
implemented" list and points callers at `ConstantTimeEq::ct_eq`.
- `compile_fail` doc-test on each type asserts that `a == b` does NOT
compile — durable forward-compat guard. If a future change adds
`PartialEq` back, the doc-test starts compiling and the test fails.
- Test callers migrated:
- `secret_string_eq_is_value_based` →
`secret_string_ct_eq_is_value_based`, asserts via
`bool::from(a.ct_eq(&b))`.
- `secret_bytes_constant_time_eq` drops its trailing
`assert_eq!(a, b)` / `assert_ne!(a, c)` lines (the prior
ct_eq-based assertions above them already covered the same
invariant).
Workspace-wide grep confirmed no other `==`/`assert_eq!` callers on
`SecretBytes`/`SecretString` exist.
Co-Authored-By: Claudius the Magnificent (1M context) <noreply@anthropic.com>
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>
b573fca to
b7508a0
Compare
…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>
🔁 Seedless-load rework landed —
|
…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>
0e92cb4 to
f57b117
Compare
…m-wallet-rehydration
…istor' into feat/platform-wallet-storage-secrets # Conflicts: # Cargo.lock
…rm-wallet-storage-secrets
…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
…rets' into feat/platform-wallet-rehydration
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-integrationbranch):loadFromPersistor()is zero-arg, called at app launch with locked Keychain; signing flows take theMnemonicResolverHandlevtable 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 againstv3.1-dev.What was done?
Seedless watch-only load (
rs-platform-wallet)PlatformWalletManager::load_from_persistor()reconstructs each persisted wallet from the keylessClientWalletStartState:For each wallet in the persisted
walletsmap, the manager:AccountCollectionfrom theaccount_manifest: oneAccount::from_xpub(parent_wallet_id, account_type, account_xpub, network)perAccountRegistrationEntry.Wallet::new_watch_only(network, wallet_id, accounts)—key_wallet::WalletType::WatchOnlyvariant, noMnemonic/Seedvariant, no key bytes anywhere.CoreChangeSet(UTXOs, tx records, IS-locks, sync watermarks) into the wallet via the existingapply_persisted_core_state(...)path, which correctly handles non-BIP44 topologies (CoinJoin-only / DashPay) viaall_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.skippedcarries(WalletId, SkipReason::CorruptPersistedRow { kind: CorruptKind })whereCorruptKindisMissingManifest | MalformedXpub | DecodeError(String). APlatformEvent::WalletSkippedOnLoad { wallet_id, reason }fires per skip. One corrupt row never aborts the rest. The caller receivesOk(LoadOutcome)(non-emptyskippedis success, not an error).New schema readers
schema::accounts::load_stateaccount_registrations+ pools; decodesAccountRegistrationEntry; noWalletbuiltschema::core_state::load_stateManagedWalletInfo— 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 balanceschema::asset_locks::load_unconsumedConsumedrows at SQL level (WHERE status NOT IN ('consumed'))FFI
The resolver arg is gone — load is purely watch-only.
LoadOutcomeFFIsurfacesloaded_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 (passesnilfor 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
ClientWalletStartStatehas no contacts slot; wiring requires a changeset-shape change. Deferred to PR-3 (feat(platform-wallet): add contacts and identity-key rehydration (item G) #3693).v3.1-devdirectly). Once merged + merge-up, the gate ships with this PR's lineage too.How Has This Been Tested?
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):load_from_persistor(); assert every wallet comes back asWallet::WatchOnlywith correctwallet_id, accounts, balances. No seed ever touched.LoadOutcome.skippedwithCorruptPersistedRow, the other wallets load cleanly, exactly onePlatformEvent::WalletSkippedOnLoadfires.LoadOutcome,SkipReason,WalletSkippedOnLoadpayloads carry no key material inDisplayorDebug.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_reconstruction13/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 —MnemonicResolverHandleis the on-demand contract).ClientWalletStartStateno longer carries aWalletfield (assembled in the manager viaWallet::new_watch_only).platform_wallet_manager_load_from_persistor.No
!in the title because this is additive capability on an unreleased API —v3.1-devcarries none of the previous PR-internal shapes.AR-7 hygiene
Load path eliminates AR-7 entirely — the manager never constructs
WalletType::Mnemonic|Seed, onlyWalletType::WatchOnly(no key material). AR-7's residualDebugconcern was about derivedWalletvalues 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:
For repository code-owners and collaborators only
🤖 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 againstv3.1-dev.