In scope: threats the design must defend against
Each row has a corresponding invariant in the codebase and a test that fails
loudly if the defence regresses. Invariant identifiers are listed in the
Invariants section and enforced by npm run verify.
| Threat | Mitigation | Invariant |
|---|---|---|
| Malicious or compromised RNG | Dice-only entropy path, hand-reproducible derivation, HKDF combiner that survives one bad source | INV-ENT-1..4 |
| Key exfiltration through signature nonces | RFC 6979 deterministic ECDSA, BIP-340 Schnorr with aux_rand fixed to 32 zero bytes, third-party reproducibility verifier |
INV-SIG-1, INV-SIG-2 |
| Change address substitution in a malicious PSBT | Every change output re-derived from a registered descriptor and compared exactly | INV-PSBT-2 |
| Fee drain | Fee derived from inputs minus outputs and shown in sats, BTC, sat/vB, and as a percentage of spend, with a warning above a threshold (a warning, not a block). A segwit v0 input whose PSBT does not carry the transaction it spends blocks until overridden, because its amount could be understated | INV-PSBT-4, INV-PSBT-5, INV-PSBT-17 |
| Sighash abuse | SIGHASH_ALL and SIGHASH_DEFAULT enforced, anything else refused unless advanced mode is explicitly enabled for that one operation and never persisted |
INV-PSBT-3 |
| Hidden timelock or RBF state | nLockTime and nSequence surfaced in human terms on the review screen |
INV-PSBT-7 |
| Signing for a script we do not own | Refuse any input whose script does not match a registered descriptor | INV-PSBT-1 |
| Hostile input parsing (PSBT, QR, SD, snapshot) | Strict size limits, defensive parsers, fuzzing, fail closed | INV-PSBT-1 |
| Network exfiltration | No network code paths at all, enforced by a lint rule and a runtime listener assertion, not by convention. The one listener is a loopback bridge on 127.0.0.1, whose host is a constant | INV-NET-1, INV-NET-2, INV-NET-3, INV-BRIDGE-1 |
| Key material reaching the frontend | Frontend receives only xpubs, addresses, descriptors, and PSBTs. Asserted against serialized responses. | INV-KEY-1 |
| Data remanence in memory | Typed Secret wrapper with explicit dispose(), tested for zeroization. Its use is enforced by convention and review, not by lint, and there is no heap snapshot test |
INV-KEY-2 |
| Data remanence on disk | No swap, tmpfs for scratch, seed encrypted at rest under Argon2id and AES-256-GCM | INV-KEY-2, INV-STORE-1 |
| A coordinator substituting a cosigner key | Registration re-derives this device's key and refuses a quorum it is not in; fingerprints are displayed, never trusted | INV-MULTI-6, INV-MULTI-7 |
| Offline guessing of a stolen card | Argon2id at 64 MiB, parameters authenticated so they cannot be weakened in the file | INV-STORE-3 |
| Supply chain tampering | Exact version pins, committed lockfile, ignore-scripts=true, SBOM, dependency review on every lockfile change |
INV-BUILD-1 |
| Build tampering | Reproducible builds, manifest root hash displayed at boot and comparable against one you compute from the source. Nothing has been released, so there is no published hash to compare against yet | INV-BUILD-1 |
| Casual physical access | Seed encrypted under an Argon2id-derived key, passphrase gate, failed-attempt counter that erases the sealed blob | INV-STORE-1, INV-STORE-4 |
| Operator error | Address verification mode, descriptor checksums, forced scroll-through review, fingerprint display before funds actions | INV-INTEROP-1 |
| Vendor lock-in becoming a loss vector | Every wallet recoverable from the mnemonic and a standard descriptor with third-party software, proved in CI against Bitcoin Core | INV-INTEROP-1 |
| A message signature being replayable as a transaction | BIP-322 signs a transaction pair that cannot exist on chain: to_spend references an all-zeros outpoint at index 0xffffffff, so the signature commits to something no consensus rule will ever accept. The older signmessage scheme, used for legacy addresses, is not a transaction at all: it commits to a fixed magic string and the message, which no sighash algorithm ever produces |
INV-MSG-5, INV-MSG-11 |
| A proof of one scheme being accepted as a proof of the other | The signing scheme is chosen from the script type and the verifying scheme from the address, never from a parameter a caller passes, so no request can ask for one and receive the other. A legacy address presented to the BIP-322 verifier is named as the wrong scheme rather than reported as malformed | INV-MSG-12, INV-DAEMON-23 |
| A valid signature by the wrong key reading as a proof | The key is checked against the address before the signature is checked. This matters most in the legacy scheme, where recovery ALWAYS produces some valid public key, so the signature verifying means nothing until the recovered key is shown to produce the address being claimed | INV-MSG-9, INV-MSG-12 |
| An unlocked device being left, with a seed resident in daemon memory | The wallet closes and the seed is zeroized after ten minutes with nobody touching the screen. Only a touch resets the clock: an IPC request does not, because a screen that polls would otherwise hold the seed open indefinitely. See the section on an unlocked device that has been left for the three things this does not do | INV-IDLE-1, INV-IDLE-2 |
| A message that displays differently from what is signed | Refused before anything is derived, on the review path and on the signing path, rather than signed with a warning | INV-MSG-1, INV-MSG-7 |
| A label rendering as text it does not contain, beside an amount on the signing screen | Refused rather than repaired, so a file that tried is visible as a skipped line instead of being quietly cleaned up | INV-LABEL-3 |
| A backup file being a second copy of the money that nobody realised was one | Seedless by default; including the seed changes the action and states what it means beforehand and again on the written file | INV-BACKUP-1 |
| A backup's outer envelope lying about what is inside it | Network, seed presence and identity are read from inside the ciphertext; the header is authenticated as AAD, so editing it fails the tag rather than changing what is restored | INV-BACKUP-2, INV-BACKUP-3 |
| A BIP-85 child being treated as independent of its parent | The screen states, beside the words themselves, that anyone holding the parent mnemonic derives every child it has produced, and names rolling dice as the alternative | INV-BIP85-2 |
On the exfiltration threat specifically
A signing device that produces randomised signatures can leak your private key a few bits at a time, through nothing but valid, correctly verifying transactions. Grind the nonce until some chosen bits of the signature encode part of the key, publish the transaction, and an observer who knows the scheme reads the key off the blockchain over a few dozen spends. Nothing on the device looks wrong, and the signatures are valid.
The defence is determinism. Given the same key and the same message, the signature must be byte-identical every time, and any third party who has the seed must be able to confirm it. There is then no free space in the signature to hide anything in.
This is why aux_rand is fixed to zero for Schnorr rather than randomised. BIP-340
permits randomised aux_rand and it defends against certain fault attacks, but it
also reopens exactly the covert channel above. For an air-gapped signer that a
user is expected to be able to audit, verifiability is worth more than fault
resistance. This is a deliberate trade and it is stated here because it is a
real one, not a free win.
Rendered from docs/THREAT-MODEL.md, the same bytes that ship with the code. SHA-256 d932f13db7fe836f45194ab98069c47dc556d4f29b2cfbf0ef1ab3bf7b334ffd