feat(wallet): unify wallet secret storage on a no-serialization raw-SecretBytes vault seam#865
Draft
lklimek wants to merge 25 commits into
Draft
Conversation
… stays live) Implements the DET-side foundation for Option A (keep-alive + SPV/coordinator restart-in-place), staged so the branch is Q3-safe at every commit. The live reconnect path is UNCHANGED (drop+rebuild + await_persister_released barrier), which is immune to the upstream platform_address_sync restart race because it builds fresh coordinator instances each reconnect. What this adds (dormant until the flip): - coordinator_gate.rs: make CoordinatorGate re-armable. action is now Mutex<Option<StartAction>> (was OnceLock) and a reset() clears action + fired + masternodes_ready, so a reused gate re-fires the coordinators on a reconnect. masternodes_ready is cleared because a restart re-syncs the masternode list from scratch; coordinators must re-wait for Synced or they fire proofs before quorums exist and self-ban. - wallet_backend/mod.rs: StartLatch::reset() re-arms the one-shot start latch; WalletBackend::stop_in_place() stops SPV (pwm.spv().stop()) and quiesces the 3 coordinators via their Arc accessors WITHOUT pwm.shutdown() (which would cancel+join the non-restartable wallet-event adapter), then re-arms the start latch + gate so start() can restart on the SAME backend. ensure_wallet_backend already fast-paths on a populated slot, so a reconnect reuses the instance. - Tests: reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart (run normally); reconnect_restart_in_place_reuses_backend (#[ignore], asserts same-backend reuse + restart, no AlreadyOpen). HARD DEPENDENCY (why this is gated, not wired): restart-in-place re-start()s the SAME platform_address_sync instance, which lacks the background_generation guard its siblings have in the pinned platform rev 925b109. Against the guard-less rev a rapid reconnect can leak an uncancellable / duplicate platform-address loop (Q3). So stop_spv is NOT switched to restart-in-place and the barrier is NOT deleted here. A TODO at the stop_spv flip site documents the activation: land the upstream guard in branch fix/wallet-core-derived-rehydration (dashpay/platform #3828 tracks it), cargo update the platform crates to a rev that contains it, then flip stop_spv to stop_in_place and delete await_persister_released. Out of scope (noted): DET-subtask cancellation (Marvin V-1) — under keep-alive the retained persister Arc makes a lingering subtask harmless for AlreadyOpen; still worth doing later for clean app-exit. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…barrier Flips the same-network disconnect/reconnect to restart-in-place (Option A) and removes the interim B-2 release barrier. Builds on the machinery landed in the previous commit (re-armable CoordinatorGate, re-runnable StartLatch, WalletBackend::stop_in_place). - stop_spv (src/context/wallet_lifecycle.rs) no longer takes + drops the WalletBackend. It keeps the backend (and its Arc<SqlitePersister>) wired in the AppContext slot and calls backend.stop_in_place().await — stop the SPV run loop + quiesce the 3 coordinators, re-arm the start latch + gate — then settles the indicator. The persister DB is never closed/reopened, so the reconnect cannot hit WalletStorageError::AlreadyOpen by construction. - Reconnect reuses the SAME backend: ensure_wallet_backend fast-paths on the populated slot (no WalletBackend::new, no SqlitePersister::open) and start() re-runs on the re-armed latch. - Deleted the B-2 await_persister_released barrier + its offline test: under keep-alive the persister is never released, so the barrier's open-probe would itself hit AlreadyOpen and spin to its 5s timeout every reconnect — it is mutually exclusive with restart-in-place. - Removed AppContext::take_wallet_backend (its only caller was the old stop_spv). - Network SWITCH is unaffected: it uses a per-network context with a different persister path and never calls stop_spv. Tests: - reconnect_restart_in_place_reuses_backend (now runs, not ignored): drives the real stop_spv -> ensure_wallet_backend_and_start_spv path; asserts the same backend pointer across disconnect->connect, latch/gate re-armed, no AlreadyOpen. The Q3 timing race is NOT asserted here (see below). - stop_spv_unwires... renamed to stop_spv_in_place_keeps_backend_and_disconnects_indicator and asserts the backend stays wired + latch re-armed. - Removed the obsolete rebuild test reconnect_after_stop_rebuilds_fresh_backend_and_restarts. TODO(dashpay/platform#3828): restart-in-place RUNTIME safety depends on the platform_address_sync background_generation guard being in the pinned rev. platform_address_sync (rev 925b109) clears its cancel slot unconditionally, so a rapid reconnect can leak an uncancellable platform-address sync loop (Q3). identity_sync and shielded_sync already carry the guard. The DET code compiles and the start/stop/quiesce/accessor APIs all exist on 925b109 — only the Q3 timing race is unsafe until the guard lands. Finalize once it merges on branch fix/wallet-core-derived-rehydration: cargo update -p platform-wallet -p platform-wallet-storage -p dash-sdk then re-run live reconnect validation. (Not done here — the user coordinates the upstream fix.) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…esign' into docs/platform-wallet-migration-design
…r platform_address_sync guard The restart-in-place reconnect (stop_spv keeps the WalletBackend + Arc<SqlitePersister> alive and restarts SPV + the 3 coordinators on the SAME instance; B-2 barrier retired) was wired in e084b7a but its RUNTIME safety depended on the upstream platform_address_sync background_generation guard, which the pinned platform rev 925b109 lacked (Q3: a rapid reconnect could leak an uncancellable / duplicate platform-address sync loop). That guard has now landed on branch fix/wallet-core-derived-rehydration (dashpay/platform#3828, head b4506492): platform_address_sync now carries a background_generation: AtomicU64 field, bumps it in start(), and gates the exiting thread's `*background_cancel = None` clear on `background_generation.load(Acquire) == my_gen` — matching identity_sync / shielded_sync. Verified in the vendored source. This commit finalizes Option A: - Bump the pinned platform crates 925b109 -> b4506492 (cargo update -p dash-sdk -p platform-wallet -p platform-wallet-storage; Cargo.lock only). - Remove the now-resolved TODO(dashpay/platform#3828) at the stop_spv call site and the conditional "until the guard lands" caveats in stop_in_place's SAFETY doc and the reconnect_restart_in_place_reuses_backend test doc — restated as the guard now being present in the pinned rev. Merges origin/docs/platform-wallet-migration-design (PR #863, blocking progress overlay) were integrated in the preceding merge commit; #863 touched no restart-in-place file, so the integration was conflict-free. Validated in-process (offline): reconnect_restart_in_place_reuses_backend (same backend reused across disconnect->connect, no AlreadyOpen, SPV+coordinators restart, latch/gate re-armed), stop_spv_in_place_keeps_backend_and_disconnects_indicator, reset_re_arms_gate_for_restart_in_place, start_latch_reset_allows_restart. Still requires live network (left #[ignore]d): the backend-e2e B-reconnect connect->disconnect->connect against a synced testnet, which exercises the Q3 timing race the offline tests cannot force. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e spec) UX disclosure spec by Diziet; 30-case TDD test spec by Marvin. Design reference for the secret-storage raw-SecretBytes seam re-architecture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…(T2,T4) Crikey, here's the one socket every wallet secret will squeeze through. T2 — new wallet_backend/secret_seam.rs: SecretSeam over raw SecretBytes with put_secret/get_secret/delete_secret, a no-encryption pass-through to the upstream vault TODAY. Every put/get body carries the greppable `TODO(per-secret-encryption):` tag so wiring real per-secret encryption later is a localized change. Prompt-free — the passphrase requirement lives only in the retained legacy readers, never here. No-serialization guard mechanism: compile_fail doctests (no new deps — static_assertions/trybuild stay out of Cargo.toml). One asserts a newtype cannot derive Serialize over a SecretBytes; one asserts serde_json::to_string on a SecretBytes is rejected. If upstream ever adds Serialize to SecretBytes these start compiling and the canary fires (TS-INV-01). TS-INV-02 round-trips a SecretBytes through the real signatures (compiler is the assertion). T4 — TaskError variants (no String fields, typed #[source]): SecretSeam, SecretSeamMissing (loud funds-safety miss), IdentityKeyVault, IdentityKeyMissing. Promote the private assert_no_leak (hex + decimal-array) into a shared wallet_backend/leak_test_support.rs so the seam/sidecar/QI/Debug leak cases reuse one impl instead of copy-pasting. TS-NOLEAK-01: the on-disk vault file holds no raw secret in either form. Tests: 6 seam unit + 2 compile-fail doctests, all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
ClosedSingleKey derived Debug and its encrypted_private_key holds the raw 32 key bytes in the no-password / pre-migration shape — a derived Debug dumped them as a decimal byte array straight into logs. Hand-write a redacting Debug mirroring ClosedKeyItem / SingleKeyEntry: key_hash + lengths, never the bytes. Parents SingleKeyData / SingleKeyWallet are safe by delegation. TS-DBG-01 asserts via the shared assert_no_leak_bytes (hex AND decimal-array — the decimal form is the one the pre-fix Debug leaked) at all three levels. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
Identity private keys get a non-resident home. New PrivateKeyData::InVault appended at bincode index 4 — discriminants 0-3 (AlwaysClear/Clear/Encrypted/ AtWalletDerivationPath) are untouched, so blobs written before it still decode (TS-RESID-02 round-trips all four pre-existing variants + InVault). Redacting Debug/Display arms (carries no bytes — trivially clean). KeyStorage probes: - is_in_vault / public_key_for — a vault placeholder reports true yet still surfaces its public key for display + signing-key selection. - take_plaintext_for_vault — rewrites every Clear/AlwaysClear to InVault and returns the raw bytes (Zeroizing) the migration must store in the vault FIRST (vault-before-blob order). Wallet-derived + encrypted keys untouched — they were never plaintext-at-rest. get/get_resolve_local gain an InVault arm (resolve through the vault, not locally). key_info_screen gains degraded InVault arms (securely-stored notice; full JIT view/sign via dedicated identity-key WalletTasks is the T8 follow-up). Promote the private assert_no_leak + distinctive_secret to the shared leak_test_support helper (no fork). TS-RESID-01 / TS-NOLEAK-03: post-migration KeyStorage has only InVault, and the re-encoded blob leaks neither secret in hex nor decimal-array form. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…hema-gated (T5) Non-secret metadata moves out of the per-wallet seed envelope into the sidecar. WalletMeta gains uses_password + password_hint. Because WalletMeta is positional bincode behind the DetKv envelope, #[serde(default)] alone is NOT forward-compatible (R-SCHEMA) — so a real version gate: WALLET_META_VERSION (v2) framed as [version | bincode] at the WalletMetaView boundary, plus a retained decode-only WalletMetaV1. decode_versioned detects v2 / v1-framed / bare-legacy and migrates a v1 blob into v2 (defaults uses_password=false), never positionally misparsing it. The global DetKv SCHEMA_VERSION is deliberately untouched (it governs every payload, not just WalletMeta). TS-META-01 covers all three shapes. ImportedKey gains public_key_bytes (the compressed SEC1 PUBLIC key) so the locked-render cold-boot path can rebuild a protected key's display wallet without the secret — moved out of the SingleKeyEntry vault blob ahead of the raw-seam migration. NON-secret; #[serde(default)] for old entries. write_wallet_meta now carries uses_password/password_hint from the open Wallet; the legacy-table drain (finish_unwire) defaults them (the authoritative flag is read from the envelope at the migrating unlock). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
- leak_test_support: drop redundant inner #![cfg(test)] (mod.rs already gates it). - encrypted_key_storage: factor take_plaintext_for_vault's return into the VaultBoundKey type alias (clippy::type_complexity). - wallet_hydration bench: carry the new WalletMeta password fields. - nightly-fmt whitespace. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 944 lib + 146 + 10 + 3 + 2 pass, 0 fail; 2 compile_fail doctests pass; det-cli standalone smoke (network-info / tools / core-wallets-list) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…ess (T3)
The chokepoint learns identity keys and goes seam-first for everyone.
- SecretScope::IdentityKey { identity_id:[u8;32], target, key_id } (DET-opaque;
KeyID is just u32, PrivateKeyTarget is a DET model enum). identity_key_label()
builds identity_key_priv.<m|v|o>.<key_id> — a stable one-char target tag keeps
the label inside the upstream allowlist.
- SecretPlaintext::IdentityKey + expose_identity_key; Plaintext::IdentityKey.
Borrowed-only, zeroizing, never resident — same hygiene as the other kinds.
- decrypt_jit is now SEAM-FIRST for all three classes: the raw label wins; the
retained legacy reader (decrypt_hd_seed / SingleKeyEntry::decrypt) is the
migration fallback for HD seeds and single keys. IdentityKey reads raw via the
seam → loud IdentityKeyMissing if absent (never silent).
- scope_has_passphrase: a migrated raw secret reports false (the password no
longer gates it); only a not-yet-migrated legacy entry can still be protected;
IdentityKey is always false → prompt-free fast-path → headless/MCP signing works.
- DetSigner treats an IdentityKey plaintext as a raw single key (same secp256k1
shape, no derivation tree).
Tests: TS-FAST-01 (identity key resolves prompt-free, ask_count 0,
can_resolve_without_prompt true), IdentityKeyMissing is loud, TS-LEGACY-01
(legacy envelope served when raw absent), raw-wins-over-legacy precedence. The
pre-existing protected-HD/single-key tests now exercise the legacy fallback.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…rites (T6) Secrets start landing raw. No DET envelope for the new write paths. - New wallet_backend/identity_key_store.rs: IdentityKeyView with store/get/delete + store_all/delete_all over raw 32 bytes via SecretSeam (scope = identity_id, label identity_key_priv.<m|v|o>.<key_id>). NO StoredIdentityKey envelope — the InVault marker in the QI blob is the only on-disk trace. store_all is the migration's vault-first writer (call before the blob rewrite); delete_all backs purge_identity_scope. - WalletSeedView gains set_raw/get_raw/delete_raw (raw 64-byte seed under seed.raw.v1 via the seam) + legacy_envelope_get (retained decode-only reader). - write_seed_envelope now branches: a no-password wallet writes the RAW seed (encrypted_seed_slice() is verbatim the seed); a password wallet keeps the legacy AES-GCM envelope at creation and migrates lazily at unlock (T7). - import_wif_with_passphrase: unprotected import writes RAW 32 bytes under the existing single_key_priv.<addr> label (no SingleKeyEntry framing); protected import keeps the legacy SingleKeyEntry (lazy-migrates at unlock). The locked-render pubkey rides in the ImportedKey sidecar (the T5 field). SingleKeyEntry::decode treats a bare 32-byte blob as unprotected, so a raw-written key still rebuilds + opens at cold boot. Tests: identity_key_store round-trip / scope+target isolation / store_all+ delete_all; seed raw round-trip independent of the legacy label; single-key unprotected import is exactly 32 raw bytes (no framing) and signs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…ete (T7)
This is the part that actually moves secrets. Funds-safety ordering throughout.
Resolver (mod.rs): resolve_private_key_bytes gains the InVault route — keyed by
is_in_vault/public_key_for, it fetches the raw bytes per-use via
with_secret(IdentityKey{...}) (prompt-free). No chokepoint wired ⇒ fail closed
(WalletLocked); bytes never resident.
EAGER migration on load (dialog-free):
- Identity keys (identity_db::migrate_identity_keys_to_vault, run per identity
in load_identities_filtered): take_plaintext_for_vault → IdentityKeyView
store_all (vault FIRST) → rewrite the QI blob with InVault. Vault-write
failure restores the resident plaintext for this session and defers; a
blob-rewrite failure is re-detected and retried next load. Idempotent.
- No-password HD seeds (hydration::reconstruct_wallet): raw seam wins
(precedence raw > legacy); a no-password legacy envelope is re-stored raw
(set_raw, vault FIRST) then deleted. reconstruct_from_envelope extracted so
the raw and legacy paths share the xpub-decode + build tail.
LAZY migration on unlock (one prompt, the unlock the user already does):
promote_and_maybe_migrate_hd_seed re-stores the just-decrypted legacy seed raw
(set_raw before delete) inside the borrowed Zeroizing scope and reports
migrated=true; handle_wallet_unlocked then flips WalletMeta.uses_password=false
and shows the one-time disclosure (T8 Copy A/D).
Delete: forget_wallet_local_state now deletes BOTH the raw seed and the legacy
envelope (a wallet may be in either form) — closes a wipe gap where a migrated
no-password seed would survive removal. identity_db.clear_identity_vault_keys
drains an identity's raw vault keys on single-delete + devnet sweep.
Loud, never silent: a seed in neither form ⇒ TaskError::SecretSeamMissing
(was WalletNotFound) on both scope_has_passphrase and decrypt_jit.
Tests: TS-EAGER-01/04 (no-pw seed migrates + idempotent), TS-CRASH-01 read
(raw wins, legacy cleaned), TS-MISS-01 (SecretSeamMissing loud). Updated 5
wallet_lifecycle removal/clear tests to assert the raw seed (the new at-rest
form) in BOTH precondition and post-delete. wallet_lifecycle 38, hydration 10,
identity_db 16, encrypted_key_storage 4 — all green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…sure (T8)
Real JIT for vault-backed identity keys, and the per-key migration notice.
Two new WalletTasks + handlers, opening with_secret(IdentityKey{...}):
- DeriveIdentityKeyForDisplay → derive_identity_key_for_display: fetches the raw
key JIT, returns only the WIF (Secret).
- SignMessageWithIdentityKey → sign_message_with_identity_key: signs in the
backend, returns only the public Base64 envelope.
New result variants IdentityKeyForDisplay / IdentityMessageSigned (identity-
flavored — carry identity_id/target/key_id, not a meaningless seed_hash).
key_info_screen: the InVault arms are now real — "View Private Key" queues
DeriveIdentityKeyForDisplay and renders the returned WIF/hex via the existing
render_decrypted_key_grid; "Sign" queues SignMessageWithIdentityKey. The
degraded placeholders are gone. display_task_result handles both new results.
Single-key protected lazy migration + Copy B: verify_passphrase now re-stores
the just-decrypted protected entry raw under the same label (upsert replaces the
AES-GCM framing) and clears the persistent has_passphrase flag, returning a
migrated bool. verify_single_key_passphrase surfaces the one-time per-key
disclosure (Copy B — text DISTINCT from the wallet Copy A so set_global's dedup
keeps both) on migration. decrypt_jit's sign path also lazy-migrates
(migrate_single_key_to_raw + in-memory flag flip) — idempotent defense-in-depth.
SingleKeyView::clear_passphrase_flag persists the flip to the sidecar.
Tests: TS-LAZY-03 — protected single key migrates via the chokepoint, the vault
holds raw 32 bytes after, and a second resolve under a never-prompt host is
prompt-free with the WIF-plaintext bytes. secret_access 24 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
- secret_access: drop explicit_auto_deref on set_raw(seed_hash, seed) — a &Zeroizing<[u8;64]> auto-derefs to &[u8;64]. - nightly-fmt whitespace across the touched files. Gate: cargo +nightly fmt --all clean; cargo clippy --all-features --all-targets -D warnings clean; cargo test --all-features --workspace = 957 lib + 146 + 10 + 3 + 2 pass, 0 fail, 1 ignored (funded-testnet TS-SIGN-E2E-01); 2 compile_fail doctests pass; det-cli standalone smoke (network-info / core-wallets-list / tools) all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…decars The real defect QA caught (PROJ-001/002/003 + SEC-003): appending fields to a positional-bincode DetKv value is format-breaking, and my T5 framing made it WORSE — WalletMeta writes went through kv.put::<Vec<u8>>(versioned-frame) and reads through kv.get::<Vec<u8>>, which type-confuses an OLD kv.put::<WalletMeta> blob (decodes the alias's UTF-8 bytes AS the Vec) → alias/is_main silently lost. ImportedKey appended public_key_bytes with no legacy reader → old keys vanish from the picker. Fix (one policy for both sibling sidecars): drop the hand-rolled version byte (SEC-003: it could collide with a bincode length varint — a 1/2-char alias). Instead lean on the DetKv schema envelope + try-decode-both: - write the current shape directly (kv.put::<WalletMeta> / ::<ImportedKey>); - on read, try the current shape; on a bincode Decode error (an old blob runs out of bytes for the appended fields) fall back to the legacy shape (WalletMetaV1 / ImportedKeyV1, decode-only) and RE-STORE in the new shape. Order is load-bearing and tested: the 6-field struct CANNOT decode a 4-field blob (runs past end), so "new first, then V1" never mis-promotes. A DetKv schema-version mismatch stays a hard error; only Decode triggers the fallback. Removes the now-dead encode_versioned/decode_versioned/WALLET_META_VERSION (PROJ-002 — the unreachable legacy branch + its overclaiming test are gone; the legacy path is now live via the view and tested end-to-end). Tests: model leg (ts_meta_01) asserts the order-sensitivity + the SEC-003 1/2-char-alias collision case; view legs (old_wallet_meta_blob_*, old_imported_key_blob_*) write an OLD blob exactly as the base branch did, read it back through the view preserving every field, and confirm re-store in the new shape. wallet::meta 3, wallet_meta 13, single_key all green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…oss (QA-002/003/005) Refactor the eager identity-key migration core out of AppContext into a free fn migrate_keystore_to_vault(secret_store, id, qi, persist) returning a KeystoreMigration outcome, so the funds-safety logic is unit-testable with a bare SecretStore + a controllable persist closure (no full AppContext). QA-002 — migration is vault-FIRST: the persist closure asserts the raw keys are already in the vault and the blob being persisted is InVault-only; the AtWalletDerivationPath key is untouched; zero plaintext remains; idempotent (second run = Nothing). QA-005 — write-fault no-loss (the write half CRASH-01's read half misses): with the vault parent dir chmod'd read-only so store_all fails, the migration restores the resident plaintext keystore byte-for-byte, does NOT call persist, and reports VaultWriteFailed — keys never lost on a mid-write fault. (#[cfg(unix)].) QA-003 — identity-key deletion is scoped + isolated: delete_all over the victim's (target,key_id) set removes its vault keys while a second identity's key under the same (target,key_id) is untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…(QA-004) The protected-wallet-unlock test asserted only upstream registration. Add the secret post-conditions the lazy migration is actually for: after handle_wallet_unlocked the raw seed is written and equals the true 64-byte seed, the legacy envelope.v1 is deleted, WalletMeta.uses_password flipped false, and a SECOND resolve through a never-prompt chokepoint over the now-raw vault returns the seed with zero prompts (the migrated wallet is permanently prompt-free). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
… (QA-001)
New #[ignore] backend-e2e test: migrate the shared identity's plaintext signing
keys to the vault (PrivateKeyData::InVault, exactly as the eager load-path
migration does), assert residency (zero Clear/AlwaysClear remain), wire the
chokepoint, then build + sign + broadcast an IdentityUpdateTransition. Signing
runs through the async QualifiedIdentity Signer → resolve_private_key_bytes →
with_secret(IdentityKey{..}) — the JIT free-rider path. A successful broadcast
+ the new key appearing on Platform proves the InVault MASTER key signed live
without ever being resident.
Requires E2E_WALLET_MNEMONIC + live DAPI/SPV; run command + RUST_MIN_STACK in
the header. Compiles + registered in main.rs; left #[ignore] for a manual/live
run during QA.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…ey errors, lift signed-message helper PROJ-004 (security): take_plaintext_for_vault now zeroizes the resident Clear/AlwaysClear array BEFORE the InVault overwrite drops it — de-residenting the key is the function's whole purpose, so it must wipe the source, not just the moved-out copy. PROJ-005: IdentityKeyView::store/get/delete now map the generic seam error to the identity-flavored TaskError::IdentityKeyVault (previously a producerless variant), so an identity-key vault failure surfaces with identity-specific banner copy. Wrong-length stays SecretDecryptFailed. QA-DEDUP-01: lift dash_signed_message (the recoverable-envelope builder) from sign_message_with_key.rs to backend_task/wallet/mod.rs as pub(crate); both the wallet-key and identity-key signers now call it instead of two drifting copies. The recovery-header round-trip tests move alongside the shared helper. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
…ak (SEC-001/002) SEC-001 (TS-INV-03): source-text audit over the changed secret-path modules — no Serialize/Encode struct may name a plaintext-key field (SecretBytes, Zeroizing<[u8, [u8;32], [u8;64]). Catches the bare-Vec/array plaintext bypass the compile_fail doctests can't (they only catch an embedded SecretBytes). The module list mirrors the blast-radius table; ciphertext fields are deliberately not flagged. Passes — the invariant holds today and now has a regression guard. SEC-002 (TS-NOLEAK-02): assert the encoded WalletMeta + ImportedKey sidecar blobs contain neither secret (hex AND decimal-array via the shared assert_no_leak_bytes), and that the ImportedKey's PUBLIC key IS present (locked render needs it). Canary coverage — the sidecars structurally hold no secret. Plus a clarifying "// no secret to (de)crypt" note at delete_secret instead of an encryption TODO. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
Extract the interim at-rest disclosure copy into pure pub fns (wallet_migration_notice / single_key_migration_notice) + pub INTERIM_AT_REST_DETAILS, re-exported from context, so the exact copy is testable without an AppState and i18n-extractable. Both callsites now use them. New tests/kittest/disclosure_banner.rs (QA-007): Copy A and Copy B each render as Warning banners naming the wallet/key, the ⚠ icon shows (not color-only), the two copies are DISTINCT (so set_global's text-dedup keeps both when a wallet and a key migrate in one session), and all copy (A/B/D) is jargon-free (no AES/vault/seam/encryption/0600). 4 tests green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
… (QA-DOC/DOC) QA-DOC-01: strip ephemeral review IDs from comments I authored in the secret-seam surface — "Smythe must-fix #3/#4/#5", "Q-HEADLESS", "(F-2)", "6a2818cd" — keeping the rationale prose. (Pre-existing PROJ-010/TC-W-*/F43/F63 in code outside this PR's diff are left untouched to avoid scope creep.) QA-DOC-02: drop the "Promoted from…" history line in leak_test_support.rs (belongs in git, not the module header). QA-DOC-03: secret_access module-header resolution order now lists the unprotected fast-path as an explicit step 2 (cache → unprotected → prompt), matching the three-branch body. DOC-001: CLAUDE.md wallet_backend bullet now points at secret_seam.rs as the single secret chokepoint + the TODO(per-secret-encryption): grep convention + the design dir. DOC-002: user-stories WAL-006 gains the post-migration no-password-prompt note; WAL-025 "modern encrypted vault" → "on-device secret vault" (no longer asserts encryption that is presently absent — the accepted interim regression). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
Whitespace-only reformat (cargo +nightly fmt --all) of the files touched while closing the QA findings. No behavioral change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
Contributor
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml 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 |
…ault JIT path The shared_identity() fixture registers a wallet-derived identity, so its keys are PrivateKeyData::AtWalletDerivationPath and take_plaintext_for_vault() (which migrates only Clear/AlwaysClear) correctly found nothing — the test panicked in setup before reaching the path under test. Add materialize_master_key_as_clear(): derive the master key's raw bytes from the HD seed through the real with_secret(SecretScope::HdSeed) chokepoint (identity index 0, key 0) and insert_non_encrypted() them as Clear, so the migration carries a genuine plaintext key into the vault as InVault and the JIT signing path produces a signature whose bytes match the on-chain master key. The !taken.is_empty() assertion is unweakened; no signer stub, no mocked broadcast. Stays #[ignore]: the live broadcast additionally needs a funding wallet that derives within its rehydrated window (the e2e funding step hit the known core-wallet gap-window/rehydration limitation, unrelated to the InVault path). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019cMrX7YiMeFXUjswbM5jo6
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why this PR exists
det-app.sqlite(HIGH,bee9c055);ClosedSingleKey's derivedDebugcould print raw key bytes (6a2818cd); no-password key material sat in non-zeroized memory (f0d946ed). The storage layer freely serialized plaintext secrets through serde.det-app.sqliterecovers identity private keys verbatim — full control of the on-chain identity — with no user mitigation available (PrivateKeyData::Encryptedhad no constructor).docs/platform-wallet-migration-design) — it depends on that branch'sSecretStore/SecretAccess/wallet_backendengine, so the base is set accordingly.What was done
src/wallet_backend/secret_seam.rs): all three secret classes — HD seed, imported single key, identity private key — store raw bytes through a singleput/get/delete_secretchokepoint into the upstreamSecretStorevault. No DET-side serialization of plaintext.SecretByteshas noSerialize, so no persisted struct can embed a plaintext secret. Guarded bycompile_faildoctests + a source-audit test.PrivateKeyData::InVaultplaceholder; fetched per-signature through the asyncSigner→resolve_private_key_bytes→SecretAccess::with_secret(IdentityKey)chokepoint and zeroized on scope exit.Warningbanner explains the interim at-rest change.ClosedSingleKeyredactingDebug; no-password material now lives in zeroizingSecretBytes.Dropping DET's AES-GCM leaves the empty-passphrase upstream vault as the only at-rest layer, so migrated password-protected wallets are obfuscation-only at rest (file mode
0600+ vault) until upstream per-secret encryption lands. This is disclosed to the user and tracked upstream; the seam is the single wiring point (grepTODO(per-secret-encryption):). The global-vs-per-wallet passphrase decision (e0a8f4b1) is the unblocker. Design + migration:docs/ai-design/2026-06-19-secret-storage-seam/.Testing
cargo +nightly fmt --allclean;cargo clippy --all-features --all-targets -- -D warningsclean.cargo test --all-features --workspace: 965 lib + 150 kittest + others pass, 0 failed; 2 compile-fail invariant doctests pass.det-clistandalone smoke green.TS-SIGN-E2E-01(sign + broadcast from a migratedInVaultidentity,#[ignore]): corrected so it genuinely exercises the path — materializes aClearmaster key from the HD seed, migrates it toInVault, then JIT-signs (assertion unweakened, no stubs). A live run validated the network path (on-chain identity registration succeeded on testnet) but the full broadcast is currently blocked by a core-wallet funding-wallet rehydration gap in the e2e harness — unrelated to theInVaultpath, tracked separately. Reaches full PASS once a funding wallet derives in-window.Breaking changes
Checklist
docs/ai-design/2026-06-19-secret-storage-seam/)TS-SIGN-E2E-01on funded testnet — corrected & ready; blocked on the e2e funding-wallet rehydration gap (not theInVaultpath)Attribution
🤖 Co-authored by Claudius the Magnificent AI Agent