Decisions
ADR-0011: Mnemonic-sealed backup files; sessions reset, never exported
- Status: Accepted
- Date: 2026-07-12
Context
The storage spec (07 §4) and identity model (06 §5) promise an encrypted single-file backup: identity + contacts + history + session-reset markers, guarded by a BIP-39-style mnemonic, restoring on a new device with sessions re-handshaking. Implementing it forces four shape decisions:
- What seals the file, exactly? "BIP-39-style mnemonic" names a UX, not a key schedule.
- Where does the wordlist come from? Every existing Rust mnemonic crate is a new supply-chain edge for what is, at bottom, 2048 frozen public-domain words and a checksum rule.
- What happens to live ratchet sessions? The spec says they are deliberately not portable, but the restored device must become reachable again without waiting for the user to send first: peers keep transmitting on ratchets the new device never held. ("Session-reset markers" existed in the spec with no defined mechanism.)
- Can the archived prekey bundles even re-handshake? A stored contact bundle's one-time prekey was consumed by the original handshake; the peer deleted its secret, so a re-handshake referencing it is silently dropped.
Decision
Format (KKR1, in kult-store): magic ‖ Argon2id cost params ‖ salt ‖ sealed(postcard payload). The mnemonic is the standard BIP-39 encoding of 32
random bytes (24 English words, SHA-256 checksum); that entropy feeds the
existing derive_kek (Argon2id, params carried in the header so a
mobile-profile export restores anywhere) and the sealed blob is an ordinary
StorageKey AEAD envelope. Wrong mnemonic and corrupted file are
indistinguishable: uniform AEAD failure, no oracle. The wordlist and codec
live in-tree in kult-crypto (no_std, KAT-tested against the reference
vectors); no new dependency.
The same header and AEAD construction is versioned by magic as payload domains
grow: KKR2 added sender-key group identities/history, KKR3 added F5
user-authored local metadata (organization, drafts, preferences, and custom
icons), and KKR4 adds sealed note-to-self history. Restore remains
backward-compatible with all earlier payload shapes;
live cryptographic/session state and re-creatable caches remain excluded.
Contents: identity, contacts (bundles, hints, petnames, verification state), full message history, and the peers holding a live session at export time, recorded as session-reset markers. Excluded on purpose: ratchet state (resurrecting old message keys is a correctness and security hazard), own prekey secrets (a restored vault must never honor a one-time prekey twice), queues and stashes (in-flight envelopes belong to the dead sessions; the senders' end-to-end retries are the source of reliability).
Restore (kult-node): builds a fresh store under a new passphrase, mints
a fresh prekey vault, and on the first tick turns each reset marker into a
proactive re-handshake, an empty first flight the receiver treats as
session maintenance (no phantom message, no receipt), emitted through the
existing SessionEstablished event. Because the archived bundle's one-time
prekey is spent, reset-marked initiations use OPK-less PQXDH
(VerifiedBundle::without_opk), the same mode DHT-published bundles already
use, on both the tick path and a send racing ahead of it.
Alternatives considered
- Depend on a mnemonic crate (
bip39,tiny-bip39): rejected: the entire artifact is a frozen wordlist plus ~60 lines of bit-packing; a dependency adds audit surface and cargo-deny friction for zero code we'd keep. - Derive the backup key straight from the mnemonic via BIP-39's own PBKDF2 seed step: rejected: PBKDF2-SHA512@2048 is far weaker than the Argon2id profile the store already standardizes on, and wallet-seed compatibility is a non-goal (this phrase guards a file, not a keychain).
- Export ratchet sessions so restore needs no re-handshake: rejected by the spec and by the ratchet contract: replayed/forked ratchet state can resurrect message keys and desynchronize both ends invisibly.
- Lazy re-handshake only (first outbound send re-keys, no markers): rejected: a restored user who only receives would silently miss everything until they happened to reply; peers' sends land on tokens the new device cannot ever claim.
- Full-database file copy as the backup: rejected: it drags sessions, prekeys, and queues along (all wrong, above), pins the backup to the store passphrase instead of a mnemonic, and turns every schema change into a restore-compatibility hazard; a typed payload is the documented, versioned export format the spec demands.
Consequences
kult/kultdgainbackup(RPC + CLI, file written 0600, mnemonic shown exactly once) and--restore FILEfirst-run startup;kult-ffimirrors both (export_backup,restoreconstructor), so every shell inherits the feature: the M5 "backup/restore round-trips" acceptance line is now code.- Messages in flight across a restore are honestly lost (their session died with the old device); senders see undelivered states and their retries ride the fresh session after the automatic re-key. No fake continuity.
- A restored node listens on new addresses; peers with stale hints reach it again via the DHT republish (or out-of-band, as the tests do). Hint staleness is a pre-existing property of moving devices, not introduced here.
- Anyone holding both the file and the phrase owns the identity; the docs already state this trade plainly (06 §5: no one can recover it for you, including us).