Skip to content

feat(wallet): unify wallet secret storage on a no-serialization raw-SecretBytes vault seam#865

Draft
lklimek wants to merge 25 commits into
docs/platform-wallet-migration-designfrom
security/secret-handling-hardening
Draft

feat(wallet): unify wallet secret storage on a no-serialization raw-SecretBytes vault seam#865
lklimek wants to merge 25 commits into
docs/platform-wallet-migration-designfrom
security/secret-handling-hardening

Conversation

@lklimek

@lklimek lklimek commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Why this PR exists

What was done

  • One secret seam (src/wallet_backend/secret_seam.rs): all three secret classes — HD seed, imported single key, identity private key — store raw bytes through a single put/get/delete_secret chokepoint into the upstream SecretStore vault. No DET-side serialization of plaintext.
  • No-serialization invariant, compiler-enforced: SecretBytes has no Serialize, so no persisted struct can embed a plaintext secret. Guarded by compile_fail doctests + a source-audit test.
  • DET's per-wallet AES-GCM envelopes dropped (retained decode-only as legacy migration readers).
  • Identity keys never resident: PrivateKeyData::InVault placeholder; fetched per-signature through the async Signerresolve_private_key_bytesSecretAccess::with_secret(IdentityKey) chokepoint and zeroized on scope exit.
  • Crash-safe dual-format migration: no-password seeds/keys migrate eagerly on load; password-protected wallets migrate lazily at the existing unlock dialog (one prompt, no second dialog). Order is always vault + sidecar before legacy delete; a secret in neither form raises a loud typed error — never silent key loss.
  • User disclosure: a one-time per-wallet Warning banner explains the interim at-rest change.
  • Folded fixes: ClosedSingleKey redacting Debug; no-password material now lives in zeroizing SecretBytes.

⚠️ Accepted interim regression (by design)

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 (grep TODO(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 --all clean; cargo clippy --all-features --all-targets -- -D warnings clean.
  • cargo test --all-features --workspace: 965 lib + 150 kittest + others pass, 0 failed; 2 compile-fail invariant doctests pass.
  • det-cli standalone smoke green.
  • New coverage: no-serialization invariant guard, raw round-trip per class, eager + lazy migration, write-fault no-loss ordering, legacy-format read, headless split, identity residency, on-disk no-leak, identity-key delete.
  • Independent security audit: SHIP — 0 CRITICAL / HIGH / MEDIUM; all funds-safety invariants verified.
  • Live TS-SIGN-E2E-01 (sign + broadcast from a migrated InVault identity, #[ignore]): corrected so it genuinely exercises the path — materializes a Clear master key from the HD seed, migrates it to InVault, 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 the InVault path, tracked separately. Reaches full PASS once a funding wallet derives in-window.

Breaking changes

  • On-disk: existing wallets and identities are migrated transparently via dual-format readers. Password-protected wallets stop prompting for their password after migration (disclosed via the one-time banner).

Checklist

  • fmt + clippy clean
  • tests green (offline suite)
  • design + test spec committed (docs/ai-design/2026-06-19-secret-storage-seam/)
  • independent security audit (SHIP)
  • live TS-SIGN-E2E-01 on funded testnet — corrected & ready; blocked on the e2e funding-wallet rehydration gap (not the InVault path)

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 24 commits June 18, 2026 15:03
… 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
@coderabbitai

coderabbitai Bot commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 664f5959-53a0-446f-9875-973b536d587b

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 security/secret-handling-hardening

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.

…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant