Skip to content

Add SeedKeeper keystore support#21

Open
Amperstrand wants to merge 4 commits intohardwareintheloopfrom
seedkeeper
Open

Add SeedKeeper keystore support#21
Amperstrand wants to merge 4 commits intohardwareintheloopfrom
seedkeeper

Conversation

@Amperstrand
Copy link
Copy Markdown
Owner

Summary

Adds SeedKeeper — a smartcard-based keystore that stores encrypted BIP39 mnemonics on JavaCard — as a new keystore option in Specter-DIY.

  • SeedKeeper extends RAMKeyStore directly, mirroring the existing MemoryCard architecture (no shared JavaCardKeyStore base class)
  • Satochip secure channel (ECDH + AES-CBC + HMAC-SHA1) for encrypted card communication
  • Multi-secret support — card holds multiple BIP39 secrets; user selects which to load at boot
  • Read-only — secrets are generated/stored on the card, not saved from the device

New files

File Purpose
src/keystore/seedkeeper.py SeedKeeper keystore (RAMKeyStore subclass)
src/keystore/javacard/applets/satochip_securechannel.py ECDH + AES-CBC + HMAC-SHA1
src/keystore/javacard/applets/seedkeeper_applet.py APDU commands (PIN, secrets, status)
src/keystore/javacard/card_scanner.py Card AID probe utility
src/gui/screens/debug_info.py Firmware version + card/applet status screen
test/integration/tests/test_seedkeeper.py 6 HIL tests (skipUnless SeedKeeper)

Modified files

File Change
src/keystore/memorycard.py Add disconnect() in except block of is_available()
src/main.py Import SeedKeeper, add after MemoryCard in keystores list
src/specter.py DebugInfoScreen, sleep_ms(50) in polling, init_apps()+mainmenu after unlock
src/hil.py TEST_SECRETS / TEST_ALL_SECRETS HIL commands
test/hil/controller.py _detect_keystore(), _load_seedkeeper(), _select_secret_by_label()

Key design decisions

  1. HMAC-SHA1 in pure Python — MicroPython's hmac module lacks SHA1; implemented via hashlib.sha1() (C-accelerated from uhashlib usermod). See MicroPython hmac module lacks SHA1 support #20
  2. get_word=None for SeedKeeper PIN screen — no anti-phishing words (card doesn't support them)
  3. init_apps() after unlock — USB commands need keystore-loaded apps. See init_apps() must be called after keystore unlock for USB commands #18
  4. Debug traces — conditional on hil_test_mode, no impact on production firmware

Testing

All tests use the abandon * 11 + about mnemonic (fingerprint 73c5da0a). The SeedKeeper card must be provisioned with this mnemonic under label abandon.

make hil        # Build HIL firmware (boot/main + HIL=1)
make hilflash   # Build + flash
make hiltest    # Run integration tests
Suite Result
test_basic.py (3 tests) 3/3 pass
test_seedkeeper.py (6 tests) 6/6 pass
test_with_rpc.py (14 tests) 11+ pass (2 pre-existing failures)

Related

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 80c0fdcbbe

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread test/hil/controller.py Outdated
Comment on lines +502 to +503
if self.keystore_type == "seedkeeper":
self._load_seedkeeper()
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Compare HIL keystore type without case sensitivity

_detect_keystore() returns the firmware-reported class name (e.g., SeedKeeper), but this branch compares against lowercase "seedkeeper", so SeedKeeper detection falls through to _load_internal_flash(). In that scenario the test harness configures the wrong unlock flow while still marking keystore_type as SeedKeeper (via .lower() in has_seedkeeper()), which makes SeedKeeper tests run against an internally loaded mnemonic path instead of the card path.

Useful? React with 👍 / 👎.

Comment on lines +278 to +282
await self.load_mnemonic()
await self.show(
Alert("Success!", "Your key is loaded from SeedKeeper.", button_text="OK")
)
return True
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Respect load_mnemonic result before showing success

load_mnemonic() explicitly returns False on card/app errors, but this menu path ignores the return value and always shows a success alert and returns True. If loading fails (or secret selection is canceled), the UI still reports success and the caller may reinitialize apps as if a new key was loaded, leading to misleading state and stale key usage.

Useful? React with 👍 / 👎.

Comment on lines +130 to +134
card_iv = encrypted_response[:16]
data_size = int.from_bytes(encrypted_response[16:18], 'big')
ciphertext = encrypted_response[18:18 + data_size]

cipher = aes(self.aes_key, 2, card_iv)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Verify MAC before decrypting secure-channel responses

Response handling decrypts ciphertext immediately and never validates any message authentication tag, even though request wrapping includes a MAC. With no integrity check, a faulty or malicious card/link can alter encrypted response bytes and, if padding remains syntactically valid, the device may accept corrupted secret material (including mnemonic data) without detection.

Useful? React with 👍 / 👎.

Amperstrand added 3 commits March 20, 2026 11:09
When MemoryCard.is_available() fails (wrong card type, card removed
during probe, etc), the connection is left open. This blocks all
subsequent keystores from detecting because connect() fails when
already connected.

Add disconnect() in the except block so the connection is always
released, allowing the next keystore in the polling list to probe.

This is a latent upstream bug that becomes visible when multiple
smartcard-based keystores are listed (e.g. MemoryCard + SeedKeeper).
Adds SeedKeeper as a smartcard-based keystore option. SeedKeeper
stores encrypted BIP39 mnemonics on JavaCard and communicates via
the Satochip secure channel protocol (ECDH + AES-CBC + HMAC-SHA1).

Key implementation details:
- HMAC-SHA1 via hashlib.sha1() (MicroPython hmac module lacks SHA1 support)
- PKCS#7 padding for Satochip protocol compliance
- 20ms stabilization delay in is_available() for USART settling
- Conditional debug traces guarded by hil_test_mode
- Add ping() for connection health monitoring via get_card_status
- Reset secure channel and disconnect on lock()
- Incorporate card pubkey into userkey for per-card isolation
- Add hexid property for card fingerprint display
- Add 'Use a different card' to storage menu
- Fix pin_attempts_left to return 0 instead of None on error
- Fix Makefile echo quoting for POSIX shell compatibility
- Add import_secret (INS 0xA1) to seedkeeper_applet.py with multi-step
  INIT/PROCESS/FINALIZE protocol for BIP39 entropy import
- Add TEST_IMPORT_SECRET, TEST_DELETE_SECRET, TEST_CARD_RESET HIL commands
- Fix PIN error handling: use ISO 7816 0x63cX format (not 0x9c02)
- Fix controller keystore detection: case-insensitive comparison
- Handle blank SeedKeeper (no BIP39 secrets) in firmware unlock flow
- Add SeedKeeper storage button to init menu
- Add card management helpers to HardwareController:
  card_import_bip39(), card_delete_secret(), card_delete_all_secrets()
- Add lifecycle HIL tests: delete/restore, import bacon, delete all
- Fix _card_reset to re-init secure channel after GPIO power cycle
- Add hexid property and userkey with card pubkey binding
- Fix Makefile echo quoting for POSIX shell compatibility
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant