Data Storage on Specter-DIY
This document lists all data persisted by Specter-DIY, where it is stored,
and how it is protected. It reflects the current implementation in src/
(keystore, hosts, apps) — if you find a mismatch, please report it (see
SECURITY.md).
Storage Areas
The device uses three persistent storage areas, plus one volatile one:
- Internal Flash (
/flash) — keystore secrets, PIN state, saved mnemonics, network selection - QSPI Flash (
/qspi) — wallet data, host settings, global settings - SD Card (
/sd) — optional mnemonic backups, exported files - SDRAM ramdisk (
/sdram, volatile) — temporary storage for untrusted host data (e.g. PSBTs being processed); gone at power-off, never persisted
The Device Secret
On first boot the device generates a single 32-byte random secret stored
unencrypted at /flash/keystore/secret. Its confidentiality relies
entirely on the MCU's readout protection (see
security-model.md). It is the root of all derived
keys:
| Derived key | Derivation | Purpose |
|---|---|---|
| Anti-phishing key | tagged_hash("auth", secret) |
Generates the words shown during PIN entry |
| Settings key | tagged_hash("settings key", secret) |
Encrypts global and host settings |
| User key | tagged_hash("userkey", secret) |
Encrypts Liquid asset labels; basis of the device UID |
| UID | hexlify(tagged_hash("uid", userkey)[:4]).decode() |
4-byte device identifier, hex-encoded as 8 characters |
With the smartcard keystore, the user key is instead derived as
tagged_hash("userkey", secret + card_pubkey) — i.e. it is unique per
device and card, and so is the UID.
The anti-phishing words are generated per entered PIN digit using HMAC-SHA256 of the derived key and the BIP-39 wordlist. They are not stored — computed fresh each time you type your PIN.
Internal Flash (/flash)
| Data | Path | Format | Protection |
|---|---|---|---|
| Device secret | /flash/keystore/secret |
32 raw bytes | None (relies on MCU readout protection) |
| PIN state | /flash/keystore/pin |
JSON: {"pin": hex, "pin_attempts_max": N, "pin_attempts_left": N} |
AEAD with device secret |
| Encryption secret | /flash/keystore/enc_secret |
32 random bytes | AEAD with PIN-derived key |
| Saved mnemonics ("reckless" mode) | /flash/keystore/reckless.<name> |
Mnemonic string | AEAD with encryption secret |
| Network selection | /flash/network |
Plain text, e.g. main, test, liquidv1 |
None |
The PIN itself is stored as an HMAC-SHA256 keyed with
tagged_hash("pin", secret) — without the device secret it cannot be
brute-forced offline. The encryption secret (enc_secret) is a random key
used to encrypt saved mnemonics and SD-card key backups; it is itself
encrypted with pin_secret = tagged_hash("pin", secret + pin), derived
from both the device secret and your PIN — so changing your PIN only
requires re-encrypting this one key.
QSPI Flash (/qspi)
Wallet Data
Each wallet lives in /qspi/wallets/<fingerprint_hex>/<network>/<n>/ with
two files:
| File | Content | Protection |
|---|---|---|
descriptor |
Full descriptor string, e.g. wpkh([fp/path]xpub/{0,1}/*) |
AEAD with idkey (m/0x1D' from seed) |
meta |
JSON: {"gaps": [20, ...], "name": "My Wallet", "unused_recv": 0} |
AEAD with idkey |
The xpub is embedded inside the descriptor string. gaps tracks the
maximum derived address index per branch (receive/change) plus the gap
limit of 20; unused_recv tracks the next unused receive address.
Wallet names are stored in the meta file. When importing a wallet via
descriptor, if the input contains &, everything before the last & is
parsed as the name (e.g. "My Wallet&wpkh(...)").
Liquid Asset Labels (/qspi/wallets/uid<8_hex>/assets_<network>)
User-defined labels for Liquid assets: JSON mapping asset hex IDs to label strings. Encrypted with the user key.
Host Settings (/qspi/hosts/<ClassName>.settings)
| Host | Stored fields |
|---|---|
USBHost.settings |
{"enabled": true/false} (default: disabled) |
QRHost.settings |
{"enabled", "aim", "light", "sound", "raw_fix_applied"} |
SDHost.settings |
{"enabled": true/false} |
All encrypted with the settings key. The marker file
/qspi/hosts/.qr_factory_reset_done tracks whether the QR scanner's
initial factory reset was performed.
Global Settings (/qspi/global/global.settings)
JSON: {"experimental": {"taproot": true/false}}. Encrypted with the
settings key.
SD Card (Optional)
| File | Content | Protection |
|---|---|---|
specterdiy<hex_id>.<name> |
Mnemonic backup | AEAD with encryption secret |
<first_word>.txt |
Plaintext mnemonic export | None |
bip85-*.txt |
BIP-85 derived mnemonics / keys | None |
| xpub / descriptor exports | Various formats (.txt / .json) |
None |
The SD card mnemonic backups use a device-specific prefix
(specterdiy<hex_id>, derived from the device secret via
tagged_hash("sdid", secret)) and are encrypted with the encryption
secret — so only this device can recognize and decrypt them. Everything
else on the card is only written when you explicitly choose an export, and
is unencrypted.
Smartcard (Optional)
With the smartcard keystore, the card stores a small TLV blob containing the seed entropy (the raw bytes of your mnemonic). Two variants:
- Encrypted (device-bound): AEAD with key
tagged_hash("scenc", device_secret); a fingerprinttagged_hash("scid", device_secret)[:4]lets the device detect data written by a different device. If the device is wiped, the card's data becomes permanently inaccessible. - Plaintext (portable): same container format but with a publicly known constant key, so the card works on any Specter-DIY after PIN entry.
Access to the card requires the card PIN; the attempt limit is enforced by the card itself and bricks the card when exhausted. The seed never touches the MCU's internal flash in this mode.
What Is NOT Persisted
- Entropy pool: lives in RAM only, fed from the hardware TRNG and touchscreen input
- XPubs: derived on-demand from the mnemonic; only descriptor strings containing them are stored
- Anti-phishing words: computed on-the-fly from the device secret and the PIN digits entered so far
- Mnemonic in temporary seed mode: if you never save your key, it lives only in RAM and is gone at power-off
- BIP-39 passphrase: never stored anywhere; entered whenever the key is loaded
- Untrusted host data (PSBTs, descriptors being processed): only in the volatile SDRAM ramdisk
Encryption Details
There is no AES-GCM in the firmware. All persistent "AEAD" blobs use
Specter-DIY's own aead_encrypt / aead_decrypt helper construction
(src/helpers.py):
- Encryption: AES-CBC with a random 16-byte IV and
0x80-padding - Authentication: HMAC-SHA256 over
associated_data || iv || ciphertext, appended to the blob (encrypt-then-MAC); verified before decryption - Subkeys: separate AES and HMAC keys are derived from the
caller-provided key via
tagged_hash("aes", key)andtagged_hash("hmac", key)(BIP-340-style tagged hashes)
The idkey used for wallet data is the private key at derivation path
m/0x1D' from your seed — so wallet data is tied to your specific
mnemonic: it stays unreadable without the seed, and tampering is detected
when the files are loaded.