Skip to content

docs(adr): record the sealed-wallet at-rest encryption design as ADR 0012 - #2496

Draft
zancas wants to merge 1 commit into
devfrom
docs/sealed-wallet-plan
Draft

docs(adr): record the sealed-wallet at-rest encryption design as ADR 0012#2496
zancas wants to merge 1 commit into
devfrom
docs/sealed-wallet-plan

Conversation

@zancas

@zancas zancas commented Jul 21, 2026

Copy link
Copy Markdown
Member

Closes nothing yet — this is the documentation half of #2494, which tracks the implementation. This PR preserves the complete sealed-wallet design so the plan survives independent of any session: ADR 0012, five glossary terms, and the .agent-plans stream declaration. Implementation is deliberately deferred.

The design being recorded

The goal is to protect all on-disk wallet data from any reader of the device's storage, with unlock delegated to the consumer (biometrics on mobile, a passphrase on the CLI). A grilling session on 2026-07-21 resolved every branch of the decision tree:

Trust boundary. zingolib defines the sealed wallet-file format and performs all encryption; its entire key interface is a raw 32-byte Unlock Key presented at open time. zingo-mobile guards that key with the platform keystore behind a biometric prompt; zingo-cli derives it from a passphrase. The library never sees a passphrase, a biometric, or a keystore. (Rejected: biometric APIs in the library; trusting OS file encryption alone.)

Envelope encryption. A random inner data-encryption key encrypts the wallet bytes with ChaCha20-Poly1305; the Unlock Key wraps only that inner key, so rekeying rewraps one blob without rewriting the wallet. After open, only the inner key stays in memory (secrecy-held, zeroized on drop), letting the autosave task re-seal without the Unlock Key.

One seal, no tier split. The seal covers the whole file. The background-sync trade-off is the consumer's key-release policy — a keystore key minted "available after first device unlock" preserves background sync; "require per-use authentication" trades it for strength. (Rejected: a two-tier sync/spend format, which doubles serialization, API, and save paths for a knob the keystore already provides.)

Opt-in, sticky, reversible. Plaintext remains legitimate; a sealed file carries a magic header with format version and KDF identifier, and anything else parses as legacy. Once sealed, every save re-seals and only an explicit unseal goes back. (Rejected: mandatory sealing; irreversible sealing — the seed restores anywhere, so irreversibility only bricks files.)

Four operations, no mid-session lock. Open, seal(key), rekey(new_key), unseal(key). No locked-while-alive state: the zecwallet-era lock poisoned every method with a key-absent error path, and dropping the client reaches the same security state.

CLI KDF. PBKDF2 from the in-tree crate (HMAC-SHA-512, hundreds of thousands of iterations, per-wallet salt in the header), honestly documented as weaker than argon2, which would need a dependency ratification; the header's KDF identifier permits a later argon2 format without a break. The mobile path never touches a KDF.

No new dependencies. Everything builds from crates already in Cargo.lock: chacha20poly1305, blake2b_simd, getrandom, secrecy, zeroize, pbkdf2.

The rest of the disk. The wallet file is the only artifact zingolib writes to the wallet directory. Nym mixnet state stays off disk (new_ephemeral()), pinned as an invariant by the ADR. The debug log is starved of secrets rather than encrypted, and file logging stays off by default for connected use.

Contents

  • docs/adr/0012-sealed-wallet-at-rest-encryption.md — the decision record with the rejected alternatives.
  • zingolib/CONTEXT.md — glossary entries for Sealed Wallet, Unlock Key, Unlock, Seal / Unseal, and Rekey in the Persistence section.
  • .agent-plans/sealed-wallet.md — the stream declaration and file claims for the eventual implementation.

Caveats for review

The ADR is numbered 0012 while dev holds ADRs through 0008: numbers 0009–0011 belong to the in-flight nym stream, which this ADR references (ADR 0011 and the new_ephemeral() construction do not exist on dev yet). If the nym stream's docs land first this merges cleanly; if this merges first, the references are forward-looking until it does.

🤖 Generated with Claude Code

…0012

A grilling session on 2026-07-21 resolved the design for encrypting all
on-disk wallet data. The library owns a sealed wallet-file format opened
by a caller-supplied raw 32-byte Unlock Key; biometrics, keystores, and
passphrases stay in the consumers. Envelope encryption over
ChaCha20-Poly1305 makes rekeying cheap, one seal covers the whole file
with the background-sync trade-off delegated to the consumer's keystore
policy, sealing is opt-in and sticky with a deliberate unseal, and the
CLI derives its key with in-tree PBKDF2 behind a KDF identifier. Nym
state stays ephemeral by pinned invariant, and the log is starved of
secrets rather than encrypted. The scheme adds no dependencies.

This commit records the decisions only: ADR 0012, five glossary terms in
the zingolib CONTEXT (Sealed Wallet, Unlock Key, Unlock, Seal / Unseal,
Rekey), and the .agent-plans stream declaration. Implementation is
deliberately deferred and tracked in issue #2494. The ADR numbering
leaves 0009-0011 to the in-flight nym stream, which this ADR references.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
zancas added a commit that referenced this pull request Jul 23, 2026
The Pool Activation ADR declares a second pool-to-upgrade mapping
anywhere in the workspace a defect, and add_shard_ranges still carried
one: a three-arm ShieldedPool ladder over raw activation_height calls.
It and the sapling-era boundary lookup in final_tree_sizes now derive
through PoolActivation::of, which removes the last NetworkUpgrade
reference from the sync state module entirely.

The ADR also moves from 0012 to 0014, with its two code references
updated: 0010 through 0013 are claimed by in-flight branches (the
OP_RETURN pipeline, the typed-error arc, the sealed-wallet draft in
PR #2496, and the viewmodel extraction), so keeping 0012 would collide
with whichever merges next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant