For TextArea-based informational screens (especially ButtonListScreen flows), keep body copy to a maximum of ~120 characters total, split across no more than 2 lines (roughly ~60 characters per line). This aligns with existing info screens such as the restart/power-off messages, which fit cleanly on the display.
If additional detail is needed, prefer a second screen instead of longer text.
The display is 240×240 pixels. Key layout constants (from GUIConstants):
| Constant | Value | Notes |
|---|---|---|
TOP_NAV_HEIGHT |
48 px | Title bar at the top of every screen |
BUTTON_HEIGHT |
32 px | Height of a standard bottom button |
EDGE_PADDING |
8 px | Padding around screen edges / below buttons |
COMPONENT_PADDING |
8 px | Default gap between stacked components |
LIST_ITEM_PADDING |
4 px | Tighter gap (use between closely-related lines) |
For a ButtonListScreen with is_bottom_list=True and a single button, the bottom button area occupies BUTTON_HEIGHT + EDGE_PADDING = 40 px from the bottom of the screen, so content must stay within the top 200 px (y < 200).
When stacking multiple TextArea components (e.g. on LargeIconStatusScreen subclasses), calculate the cumulative screen_y + height to make sure the last component ends above the button zone. If content is tight, use LIST_ITEM_PADDING (4 px) instead of COMPONENT_PADDING (8 px) between closely-related lines (e.g. a derivation path and its warning label).
- Keep
src/seedsigner/hardware/io_config.jsonanddocs/io_config.mdconsistent whenever pin mappings or profile details are changed. - If the JSON and documentation conflict and the correct source of truth is unclear, explicitly ask the user how they want the conflict resolved before finalizing changes.
Settings.get_instance() in src/seedsigner/models/settings.py initialises settings in three layers, applied in order so that each layer overrides the previous:
- Code defaults —
SettingsDefinition.get_defaults() - Platform-detected defaults — hardware-specific values (display config, camera rotation) computed once via
get_platform_default_*()methods - User-persisted settings — loaded from the settings JSON file on disk
Because user settings are applied last, they naturally take priority over platform defaults without any additional guard logic.
Platform defaults are computed into a platform_defaults dict at the top of get_instance(). When a settings file (or template) is loaded, platform_defaults are merged in as fallbacks for any keys the file doesn't contain (loaded.setdefault(key, value)), then settings.update(loaded) applies everything at once. When no file is loaded (test environment or first boot without template), platform defaults are written directly to settings._data.
- User-persisted settings must always take priority over platform-detected defaults. The current layered approach guarantees this: platform defaults are merged as fallbacks before user settings are applied.
- When adding a new platform-detected default, add it to the
platform_defaultsdict inget_instance():No additional guard logic is needed —platform_defaults = { SettingsConstants.SETTING__DISPLAY_CONFIGURATION: Settings.get_platform_default_display_config(), SettingsConstants.SETTING__CAMERA_ROTATION: Settings.get_platform_default_camera_rotation(), SettingsConstants.SETTING__MY_NEW_SETTING: Settings.get_platform_default_my_new_setting(), }
loaded.setdefault()ensures the user's saved value wins when present.
| Setting constant | Platform default method |
|---|---|
SETTING__DISPLAY_CONFIGURATION |
get_platform_default_display_config() |
SETTING__CAMERA_ROTATION |
get_platform_default_camera_rotation() |
Any new hardware-detected setting that can also be changed by the user must be added to this table and to the platform_defaults dict. Failing to do so will cause the platform default to not be applied.
Because this project handles private key material for an air-gapped signer, security takes precedence over convenience. Treat all entropy and key-handling paths as high-risk code.
- Use only cryptographically secure RNG sources/APIs; never use non-CSPRNG functions for seed/key generation.
- Mix entropy sources conservatively (never reduce effective entropy via lossy transforms or truncation).
- Do not log, print, serialize, or persist raw entropy, seed bytes, or private keys—even in debug mode.
- Prefer deterministic, reviewed key-derivation standards (e.g., BIP39/BIP32 flows already used in repo) over custom schemes.
- Add explicit comments when code assumes a minimum entropy size/security level.
- Keep secret material in memory for the shortest time possible.
- Avoid copies of secret data (including implicit copies via string conversions, repr/debug formatting, or temporary buffers).
- Zeroize/wipe secret buffers as soon as they are no longer needed.
- Store secrets in mutable byte buffers when possible (so they can be wiped), not immutable strings.
- Ensure error paths and early returns also wipe sensitive intermediates.
wipe_string() (in secure_delete.py) uses ctypes.memset to zero a Python string's internal buffer in place. Because Python interns and shares string objects, this will corrupt any other reference to the same object. The BIP-39 and SLIP-39 wordlists (bip39.WORDLIST, shamir_mnemonic.wordlist.WORDLIST) are global, module-level lists of interned strings—if a word looked up from one of these lists is stored directly and later wiped, the corresponding entry in the global wordlist is permanently destroyed, breaking all subsequent mnemonic entry/matching for that word.
Rules:
- Never store a direct reference to an element of
bip39.WORDLIST(or any shared/global wordlist) in a list or object that may later be passed towipe_list()orwipe_string(). - Create an independent copy with
"".join(word). This builds a newstrobject whose memory is separate from the wordlist. str(word)andword[:]do NOT create copies forstrobjects—Python returns the same object. Only"".join(word)(or equivalentstrconcatenation that forces a new allocation) is safe.- When reviewing or writing code that reads from a wordlist and stores the result in a list, always apply the
"".join(...)pattern at the point of storage.
Currently protected call sites (as of this writing):
| File | Function / line | What it stores |
|---|---|---|
seed_storage.py |
update_pending_mnemonic() |
BIP-39 word into _pending_mnemonic |
seed_storage.py |
update_pending_slip39_share() |
SLIP-39 word into _pending_slip39_share |
decode_qr.py |
SeedQrDecoder.add() (SeedQR path) |
BIP-39 word into seed_phrase |
decode_qr.py |
SeedQrDecoder.add() (four-letter path) |
BIP-39 word into words |
mnemonic_generation.py |
calculate_checksum() |
Temp final word (wordlist[0]) appended to caller's list |
tools_views.py |
_bip39_words_from_entropy() |
BIP-39 word into password word list |
Any new code path that looks up a word from a shared wordlist and stores it in a list that could be wiped must follow the same "".join(word) pattern and should include a regression test that wipes the list and asserts the global wordlist is still intact (see test_seedqr.py::test_seedqr_decode_does_not_corrupt_wordlist for an example).
- On screen transitions, cancellations, exceptions, and shutdown/restart flows, clear in-memory seed/key state.
- Ensure temporary files are never used for entropy/key material; if unavoidable, they must be securely deleted immediately.
- Verify object caches/singletons do not retain secret state longer than required.
- Treat all external input (QR payloads, SD card files, settings imports) as untrusted.
- Validate and constrain input using strict allowlists, length checks, and format checks before processing.
- Never build shell commands by string concatenation with untrusted input.
- Prefer
subprocess.run([...], shell=False, check=True)patterns over shell invocation. - If shell scripts are required, quote variables defensively and avoid eval-like constructs.
- Do not execute dynamic code from user-provided payloads, descriptors, labels, or metadata.
- Prefer standard-library or well-reviewed cryptographic primitives already used by the project.
- Do not introduce new crypto dependencies or algorithms without explicit justification in the PR description.
- Keep reproducibility and deterministic builds in mind for security-sensitive changes.
The codebase supports multiple seed types (see src/seedsigner/models/seed.py). They share a Seed base class but differ in critical ways. Always use seed.get_root(network) to obtain the BIP-32 root key — never call bip32.HDKey.from_seed(seed.seed_bytes) directly, because some seed types have seed_bytes = None.
| Type | seed_bytes |
get_root(network) |
Notes |
|---|---|---|---|
Seed (BIP39) |
64-byte BIP39 seed (from mnemonic + passphrase) | Derives root from seed_bytes with network version |
Standard path; passphrase changes seed_bytes |
XprvSeed |
None |
Returns pre-parsed _root HDKey (network param ignored) |
No mnemonic, no seed bytes — root key is the only secret |
ElectrumSeed |
PBKDF2-derived bytes | Inherited from Seed |
Overrides script_override, derivation_override, detect_version |
AezeedSeed |
Aezeed entropy | Inherited from Seed |
Decrypted from aezeed ciphertext |
Slip39Seed |
SLIP-39 master secret | Inherited from Seed |
Recovered from share combination |
Rules when writing code that handles seeds:
- Never access
seed.seed_bytesdirectly for key derivation. Callseed.get_root(network)instead.XprvSeed.seed_bytesisNoneand will crashbip32.HDKey.from_seed(). - Check for feature support before using seed-type-specific features. For example,
seed.mnemonic_listis empty forXprvSeed;seed.seedqr_supportedisFalseforXprvSeed,ElectrumSeed, andSlip39Seed. - Respect method overrides.
ElectrumSeedoverridesderivation_override(),script_override, anddetect_version(). Always call these through the seed object rather than assuming BIP39 defaults. - When working with non-Seed-like objects (e.g. in helper functions that may receive mock objects or raw HDKeys), use the pattern
if hasattr(seed, "get_root"): root = seed.get_root()with a fallback tobip32.HDKey.from_seed(seed.seed_bytes). - Test with multiple seed types. Any new feature touching key derivation, BIP85, address verification, or PSBT signing should be tested with at least
Seed(BIP39) andXprvSeedto catchseed_bytes = Noneissues.
For changes touching entropy, seed generation/import, key derivation, signing, or secret storage:
- Add/extend tests for both success and failure/cleanup paths.
- For seed creation/loading features, test all supported workflows for consistent behavior and fault tolerance.
- Prefer shared code paths across workflows (scan/manual/import) instead of duplicating seed-handling logic.
- Document threat assumptions and failure modes in code comments or PR notes.
- Call out any remaining risk tradeoffs explicitly.
SeedSigner must produce identical results regardless of the host locale or input method. Follow these rules when processing user-supplied or externally-sourced strings:
- BIP39 / SLIP39 / Electrum passphrases and mnemonics must be NFKD-normalized (already done in
seed.py). Do not change the normalization form. - Encrypted QR code passwords (
kef.pyCipherclass) are NFKD-normalized before PBKDF2 key derivation, ensuring the same password produces the same encryption key regardless of platform (macOS typically stores NFD, Linux/Windows use NFC). - Display strings shown to the user should be NFC-normalized (also already done in
seed.pyviapassphrase_display/mnemonic_display_str).
The following table lists every code path where user-supplied strings feed into key derivation, encryption, or deterministic output, and whether NFKD normalization is applied:
| Code path | Normalized? | Where |
|---|---|---|
| BIP-39 passphrase | ✅ NFKD | Seed.set_passphrase() in seed.py |
| BIP-39 mnemonic | ✅ NFKD | Seed.__init__() in seed.py |
| SLIP-39 passphrase | ✅ NFKD | Slip39Seed.set_slip39_passphrase() in seed.py |
| Electrum passphrase | ✅ NFKD + lower() | ElectrumSeed.normalize_electrum_passphrase() in seed.py |
| Aezeed passphrase | ✅ NFKD (inherited) | Via Seed.set_passphrase() before reaching aezeed.decode_mnemonic() |
| Encrypted QR password | ✅ NFKD | Cipher.__init__() in kef.py |
| Dice / coin-flip entropy | N/A (ASCII-only) | Input constrained to 1-6 / 0-1 by keyboard UI |
| GPG name / email | N/A (ASCII keyboard) | Input constrained to ASCII by on-screen keyboard |
| GPG expiration dates | ✅ dash-normalized | _normalize_date_input() in tools_views.py |
When adding a new code path that derives keys or produces deterministic output from user-supplied strings, always NFKD-normalize the input before encoding to bytes.
- When parsing dates from user input, always use
_normalize_date_input()(intools_views.py) to replace non-ASCII dashes (fullwidth\uff0d, en-dash\u2013, em-dash\u2014, Unicode minus\u2212) with ASCII hyphen-minus before callingstrptime/fromisoformat. - When converting user-provided numeric strings use
try/except ValueErroraroundint()/float()instead of pre-checking with.isdigit(). Python's.isdigit()returnsTruefor non-ASCII Unicode digit characters (e.g. superscript¹²³) thatint()/float()cannot convert, so the pre-check gives a false positive and the subsequent conversion raisesValueError. - If an ASCII-only digit check is truly needed, combine
.isascii()and.isdigit(), or test membership in"0123456789".
- Never rely on locale-dependent behaviour (
str.lower()with Turkish İ,strftimewith locale month names, etc.) for data that affects derivation, signing, or deterministic output. - QR-scanned data, settings QRs, and file-imported data should all be treated as untrusted byte strings; decode as UTF-8 with error handling before further processing.
- When adding new user-input parsing, add tests that exercise at least one non-ASCII variant (e.g. a fullwidth digit, a non-ASCII dash) to catch locale-dependent regressions.
- Be aware that the same Unicode character can have multiple representations (e.g.
écan be U+00E9 [NFC] or U+0065 U+0301 [NFD]). macOS file-system APIs and some input methods produce NFD; most other systems produce NFC. NFKD normalization collapses both forms into a single canonical byte sequence.