Invariants
The full list lives in the spec files and is checked by make verify. The
security-critical ones are below.
Every row here is enforced by a spec and its tests unless it is marked
Planned, which means the design is on record and nothing implements it yet. A
planned invariant is a statement about a future build and protects you from
nothing today. make invariant-claims checks that this page and the spec files
agree about which is which, so a row cannot quietly become a promise the code
does not keep.
| ID | Statement |
|---|---|
| INV-NET-1 | The daemon binds only to a Unix domain socket or 127.0.0.1. No listener exists on any external interface. |
| INV-NET-2 | No source file imports http, https, net, dgram, dns, or fetch outside an allowlisted loopback IPC layer. Enforced by lint. |
| INV-NET-3 | The frontend CSP is default-src 'none' and connect-src 'self', so nothing can be fetched from anywhere. No CDN, no remote fonts, no telemetry. Three narrower allowances exist and are asserted by make device-csp: 'wasm-unsafe-eval' in script-src for the QR decoder, data: in img-src for the codes this device draws, and blob: in media-src for the camera preview. None of them can reach the network. |
| INV-BRIDGE-1 | The loopback bridge that serves the kiosk browser binds 127.0.0.1 and nothing else. The host is a constant in packages/daemon/src/bridge/server.ts, not a parameter, so no caller and no environment variable can widen it. It exists because a page in a browser speaks HTTP and cannot open a Unix socket. Not numbered INV-NET-4: that id is taken by a core invariant about Bitcoin network version bytes, which is a different meaning of the word sharing the same prefix. |
| INV-KEY-1 | Private key material and seed bytes never leave the daemon process, with three named exceptions: seed.reveal, which shows a mnemonic only between generation and confirmation and never for a seed loaded from storage; bip85.derive, which returns a hardened child mnemonic because writing it down is the point of BIP-85, capped per unlock; and backup.create with includeSeed, which seals the seed under a caller-chosen passphrase and says so in its response. |
| INV-KEY-2 | All buffers holding secrets are zeroized after use, through a typed Secret wrapper with explicit dispose(). |
| INV-SIG-1 | Every ECDSA signature uses RFC 6979 deterministic nonces. Every Schnorr signature uses BIP-340 with aux_rand set to 32 zero bytes. |
| INV-SIG-2 | The same seed and the same PSBT produce byte-identical output, confirmed against an independent implementation. |
| INV-PSBT-1 | The device refuses to sign any input whose script cannot be matched to a registered descriptor. |
| INV-PSBT-2 | Any output not provably derivable from a registered descriptor at a known change path is displayed as an external payment. |
| INV-PSBT-3 | Sighash types other than SIGHASH_ALL / SIGHASH_DEFAULT are refused unless advanced mode is explicitly enabled, per operation, never persisted. |
| INV-BUILD-1 | npm run verify must pass before the app starts. On failure the UI shows the error and refuses to load the wallet. |
| INV-ENT-7 | No file in packages/core or packages/daemon calls Math.random(), and the frontend calls neither it nor crypto.getRandomValues(). Entropy is collected in the daemon or it is not collected. Enforced by lint. |
| INV-UI-102 | A verdict field that crossed the IPC boundary is compared to true rather than read for truthiness, so a value that is not a boolean reads as no. Enforced by lint on the frontend, where responses are cast to an interface without being checked. |
| INV-WALLET-1 | packages/wallet may import packages/core, never the reverse. Removing it leaves a functional signer. |
| INV-WALLET-2 | Planned, phase 5, not enforced today. The wallet layer proposes but never signs. packages/wallet does not exist yet, so there is nothing to constrain. |
| INV-INTEROP-1 | Every wallet is fully recoverable from the BIP-39 mnemonic plus a standard descriptor, with third-party software and no nullroute code. Drilled in CI against a real Bitcoin Core on regtest for p2wpkh, sh(wpkh), p2pkh and p2tr, and for a 2-of-3 quorum, both wsh(sortedmulti) and taproot tr(NUMS, sortedmulti_a), with three distinct seeds where two separate devices sign in sequence. |
| INV-INTEROP-2 | A quorum is recoverable from its descriptor, which mnemonics alone cannot replace. The drill asserts that one signature does NOT finalise a 2-of-3, so a threshold that silently became 1 fails the build rather than shipping. |
| INV-MULTI-6 | A multisig descriptor in which this device holds no key is refused at registration, rather than producing a wallet that can receive and never spend. |
| INV-MULTI-7 | Quorum membership is decided by key material. A key origin claiming this device's fingerprint does not make a stranger's key ours. |
| INV-STORE-1 | A seed is never written to disk in a form readable without the passphrase. |
| INV-STORE-2 | A wrong passphrase fails authentication and returns nothing. It never produces plaintext. |
| INV-STORE-3 | The key derivation parameters are authenticated, so editing them in the file breaks the open rather than weakening the next guess. |
| INV-STORE-4 | Consecutive failed unlocks are counted, and passing the limit erases the sealed blob before the error is raised. |
| INV-STORE-5 | A store is never left half written and an existing wallet is never silently overwritten. |
| INV-IDLE-1 | Only a touch resets the idle clock. Any other IPC request, however many arrive, leaves the deadline where it was. |
| INV-IDLE-2 | A request arriving after the deadline finds the wallet locked and the seed zeroized, not merely unreachable. |
| INV-IDLE-3 | Remaining time never reads as more than the window and never reads as negative, whatever the system clock does. |
| INV-DURESS-1 | Planned, phase 7, not enforced today. The encrypted store does not reveal how many profiles exist or which slots are in use. |
| INV-DURESS-2 | Planned, phase 7, not enforced today. Unlock latency is independent of which PIN was entered and whether it was correct. |
Rendered from docs/THREAT-MODEL.md, the same bytes that ship with the code. SHA-256 d932f13db7fe836f45194ab98069c47dc556d4f29b2cfbf0ef1ab3bf7b334ffd