Skip to content

Keycard firmware branch updates#375

Open
ethermachine wants to merge 121 commits into
3rdIteration:copilot/add-keycard-applet-supportfrom
ethermachine:dev
Open

Keycard firmware branch updates#375
ethermachine wants to merge 121 commits into
3rdIteration:copilot/add-keycard-applet-supportfrom
ethermachine:dev

Conversation

@ethermachine

Copy link
Copy Markdown

Updates the Keycard / SeedKeeper-only firmware branch from ethermachine/dev.

local-build and others added 30 commits May 8, 2026 17:40
…mode

Keycard / Ethereum
- Centralise DEFAULT_ETH_PATH in helpers/ethereum/__init__.py.
- Extract path/pubkey/PIN helpers from keycard_views.py into the new
  helpers/keycard/ui_helpers.py (keycard_views shrinks 747 -> 670 lines;
  underscore-prefixed aliases retained for backward compatibility).
- AGENTS.md gains a full "Ethereum + Keycard integration" section
  (module layout, protocol parameters, pairing storage, threat model,
  extension points, hardware verification recipe).
- Add tests/test_keycard_address.py (EIP-55 vectors, 8 cases) and
  tests/test_keycard_ui_helpers.py (path/TLV/wipe/session, 14 cases).

Stealth boot mode
- Two HIDDEN settings: STEALTH_BOOT (default Disabled) and
  STEALTH_UNLOCK_SEQUENCE (default 5xKEY_UP, 3..12 keys validated).
- New seedsigner.stealth package: UnlockBuffer (suffix-match) and
  SnakeGame (resolution-aware, threaded input, panic exit on
  KEY1+KEY2+KEY3 held 10 s). Stealth modules MUST NOT import seed/
  Keycard code; documented in AGENTS.md.
- Controller.start() runs the game before OpeningSplashView when the
  setting is enabled; firmware boots normally once the unlock combo
  matches.
- Tools menu gains "Stealth boot" -> ToolsStealthBootView (toggle +
  edit-sequence record/confirm).
- Tests: test_stealth_unlock.py (16), test_stealth_snake.py (7),
  test_stealth_boot_integration.py (5).

168 ETH/Keycard/Stealth tests pass; core seed/settings/PSBT/tools-flow
tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Storage (helpers/keycard/pairing_storage.py)
- Per-UID encrypted blobs at <microSD>/keycard_pairing_<fp>.bin where
  fp is the first 16 hex chars of SHA-256(instance_uid).
- save() writes to per-UID path by default and removes the legacy
  single-card file once a per-UID copy is on disk.
- load() takes instance_uid, tries the per-UID path first then falls
  back to the legacy file (only if the embedded UID matches), so users
  with an existing keycard_pairing.bin keep working through the
  transition.
- New list_pairings(), remove(instance_uid=...), remove_all(),
  fingerprint_for_uid().

Cache (controller.py)
- controller.keycard_pairings: dict[bytes, PairingInfo] indexed by
  instance_uid for the boot. New helpers get_pairing_for /
  set_pairing_for / forget_pairing_for / forget_all_pairings.
- last_keycard_uid tracks the most-recently-seen card.
- Back-compat property keycard_pairing returns the pairing for the
  last-seen UID (so views still upgrading keep working).

Auto-switch (helpers/keycard/ui_helpers.py)
- open_unlocked_session now SELECTs first to discover which card is in
  the reader, then looks up its pairing in the controller's cache.
  Raises new KeycardCardChangedError(instance_uid) if the inserted
  card has no pairing for this boot. Sign/Export/Generate/Unpair
  views catch it and route to ToolsKeycardPairView.
- New identify_inserted_card() helper for views that need only the
  UID (e.g. Forget current).

Views (views/keycard_views.py)
- ToolsKeycardPairView: SELECT first; if a pairing for the inserted
  card is already cached this boot, skip the password prompt entirely.
- ToolsKeycardForgetSavedPairingView: now a list (Forget current /
  Forget all / per-fingerprint entry) backed by list_pairings().
  Two new helper views handle the destructive paths with proper
  warning screens.
- Sign/Export/Generate/Unpair: catch KeycardCardChangedError and
  redirect to PairView; Unpair also drops the cache entry and
  per-UID storage file on success.

Tests
- tests/test_keycard_pairing_storage.py: +13 cases covering
  fingerprint determinism, two-card save/load isolation,
  list/remove/remove_all, and legacy fallback + migration.
- tests/test_keycard_session_auto_switch.py (new): 4 cases covering
  auto-switch happy path, card-not-paired raises, and back-to-back
  card-swap with cached pairings.
- tests/test_keycard_ui_helpers.py: TestOpenUnlockedSession rewritten
  for the SELECT-first flow plus a new TestIdentifyInsertedCard.

186 keycard/eth/stealth tests pass; core seed/settings/PSBT/tools-flow
tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase B - Factory reset
- New INS_FACTORY_RESET (0xFD) with magic P1=0xAA P2=0x55 in
  helpers/keycard/commands.py + factory_reset() builder + matching
  KeycardClient.factory_reset(). Cleartext APDU (no secure channel
  required); the magic in P1/P2 is the on-card sanity check.
- New ToolsKeycardFactoryResetView under Tools > Keycard with a Dire
  warning, executes the wipe, then drops the device's local pairing
  blobs (pairing_storage.remove_all() + controller.forget_all_pairings)
  so nothing on the device references the now-blank card.
- Graceful handling of older applets: SW=0x6Dxx surfaces a clear
  "Update applet, or use keycard-shell on PC" message.
- tests/test_keycard_factory_reset.py: APDU layout, magic constants
  pinned, happy-path transmit, unsupported-applet error path.

Phase C - Import seedphrase to card (LOAD_KEY P1=0x02)
- New LOAD_KEY_P1_BIP39_SEED (0x02) constant + load_bip39_seed(seed64)
  builder in commands.py + KeycardClient.load_bip39_seed() that routes
  through the secure channel (PIN-protected).
- New ToolsKeycardImportSeedView monolithic flow (try/finally
  guarantees wipe of every intermediate buffer):
    1. Dire warning that the seed leaves the device once.
    2. Source picker: Scan SeedQR / Type 12 words / Type 24 words.
    3. Mnemonic captured into freshly-allocated strings via
       "".join(word) so wipes never touch the BIP-39 wordlist
       (CLAUDE.md guidance).
    4. Optional BIP-39 passphrase prompt.
    5. Master fingerprint shown for user verification (root.my_fingerprint).
    6. PIN prompt, _open_unlocked_session (auto-switch aware),
       client.load_bip39_seed.
    7. Cleanup: wipe each word, passphrase bytearray, 64-byte seed,
       PIN bytearray on every exit path.
- The import flow is isolated from controller.in_memory_seeds; the
  seed never lands in the standard SeedSigner seed-management state.
- tests/test_keycard_load_key.py: APDU layout, length validation,
  protected-channel routing, BIP-39 reference vector master
  fingerprint pinned (abandon...about => 73c5da0a).

196 ETH+Keycard+Stealth tests pass; core seed/settings/PSBT/tools-flow
tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New module helpers/keycard/global_platform.py
- ISD AIDs (Visa + Mastercard candidates), default ISD keys (404142...4F).
- SCP02 (i='15') secure channel with C-MAC security level (0x01):
  - INITIALIZE UPDATE / EXTERNAL AUTHENTICATE handshake.
  - Session key derivation via 3DES-CBC over standardised constants
    (0182 ENC, 0101 MAC, 0181 DEK).
  - Cryptogram + retail MAC (ISO 9797-1 alg 3, padding method 2).
  - Per-command MAC chaining: next ICV = single-DES-encrypt(prev MAC,
    K1_MAC) per the GP 2.1.1 spec.
- High-level operations:
  - list_instances(channel) via GET STATUS P1=0x40, TLV parser for
    E3/4F/9F70/C5 entries.
  - install_for_install(channel, package, applet, instance, ...).
  - delete_aid(channel, aid, with_related=True).
- C-MAC only (no C-ENC): INSTALL/DELETE/GET STATUS carry no secrets.

Controller wiring (controller.py)
- New active_keycard_aid attribute (defaults to Status published AID).
- All client.select() callers in keycard_views.py + ui_helpers.py now
  pass aid=controller.active_keycard_aid so multi-instance setups can
  target a specific applet without further code change.

Manage instances UI (views/keycard_views.py)
- New menu entry "Manage instances" -> ToolsKeycardInstancesMenuView
  with four sub-flows:
  - List instances: read GET STATUS, show AIDs.
  - Switch active: pick a Keycard applet AID and set
    controller.active_keycard_aid for the session.
  - Create instance: INSTALL [for install] under a free AID
    (auto-suggested by bumping the last byte from existing).
  - Delete instance: pick an instance, DELETE with related; if
    deleted AID was active, fall back to the default.

Tests (tests/test_keycard_global_platform.py)
- ISO 9797 padding, session-key derivation determinism + arg
  validation, cryptogram order-sensitivity, retail MAC length + ICV
  chaining, ICV-update determinism.
- INITIALIZE UPDATE response parser (success + length rejection).
- Full SCP02 handshake against a self-consistent _MockCard:
  - Successful open() yields three distinct 16-byte session keys.
  - Card cryptogram mismatch raises GpProtocolError.
  - First authenticated command after open carries CLA=0x84 + 8-byte
    CMAC suffix.
- INSTALL [for install] APDU layout + short-AID rejection.
- DELETE APDU layout (P2=0x80 with related).
- GET STATUS TLV parser handles two-instance payload.

AGENTS.md
- New "Multi-instance management (GlobalPlatform / SCP02)" section
  covering pre-conditions, protocol parameters, AID conventions,
  active-instance semantics, and the threat model added by relying
  on default ISD keys.

214 ETH+Keycard+Stealth tests pass (18 new in test_keycard_global_platform.py);
core seed/settings/PSBT/tools-flow tests remain green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ent-based reader

- _error_destination now skips the failing view so OK on the error
  screen returns to the menu instead of looping back into the same
  wait_for_card timeout (the user got stuck and had to power-cycle).
- SeedAddPassphraseScreen / SeedEncryptedQRMnemonicIDScreen treat
  KEY_LEFT on the back-arrow as "go back" -- previously ignored, only
  KEY_PRESS exited.
- prompt_for_pin opens directly on the digits keyboard.
- reader.py uses CardRequest.waitforcard() (PC/SC SCardGetStatusChange,
  same primitive as keycard-cli/keycard-shell) for native multi-slot
  detection; default timeout 5s -> 15s.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- ButtonListScreen has no status_headline field; three call sites
  (stealth menu, stealth save, sign ETH overview) were passing it and
  crashing with TypeError. Stealth menu drops the kwarg; the other two
  switch to LargeIconStatusScreen so headline + body actually render.
- Sign ETH overview was computing a kind/chain/path/address body and
  silently discarding it; now passed as text.
- Keycard reader follows the established multi-source pattern from
  helpers.seedkeeper_utils: release_other_smartcard_holders disconnects
  any held Satochip_Connector, kicks/relaunches gpg scdaemon, and
  re-arms ifdnfc when the PN532 interface is selected. Wait_for_card
  retries connect() across the full timeout budget so transient PC/SC
  contention no longer surfaces as the raw "Card not connected" text.
- Sign ETH error path clears eth_sign_request / eth_signature in both
  except branches and routes the user to MainMenu via clear_history,
  ending the loop where pressing OK on the error popped back to the
  Overview with stale state.
- Adds reader retry/release tests and a screen-kwargs validation test
  that asserts ButtonListScreen does not own status_headline (would
  have caught all three TypeError sites at unit-test time).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
release_other_smartcard_holders() was killing scdaemon and immediately
relaunching it, recreating the very PC/SC lock contention it was trying
to clear -- subsequent connect() calls failed with
CardConnectionException("card not connected") whether or not a card was
inserted, surfacing as the generic "Card not reachable" error. Drop the
relaunch (gpg-agent re-spawns scdaemon on demand) so wait_for_card()
can grab the exclusive handle.

Also disconnect half-open PC/SC connections inside the wait_for_card()
retry loop, otherwise we leak handles for the full 5s budget and poison
later attempts in the same session. And route KeycardErrorView's OK
explicitly to ToolsKeycardMenuView with clear_history=True so error
recovery is independent of any back-stack state set up by the failing
sub-flow.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After confirming the unlock-sequence recording the user landed back on
ToolsStealthBootView via skip_current_view, but every screen pushed
during the recording flow was still on the back stack -- pressing Back
from the menu re-entered the Record screen. Drop everything pushed
since (and including) the previous ToolsStealthBootView so Back from
the menu correctly returns to Tools.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
wait_for_card() returned service.connection from CardRequest.waitforcard().
That connection is bound to the CardRequest's PC/SC hcontext, which
PCSCCardRequest.__del__ releases via SCardReleaseContext as soon as the
local `request`/`service` fall out of scope on return. The hcard the
caller received was therefore invalid by the time it called transmit(),
and pyscard raised CardConnectionException("Card not connected") -- the
exact message the user kept seeing whenever a card was actually inserted.

Detect the active reader from the waitforcard service, then build our
own connection via Reader.createConnection() so the hcontext is owned
by the caller. Add per-retry warning logs and tighten the half-open
handle cleanup to cover both the service connection and the new one.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three issues:

(1) After Save, leaving the stealth menu required two Done presses.
The cleanup loop removed Record/Confirm from the back stack, but the
return missed skip_current_view=True so the controller left a duplicate
ToolsStealthBootView entry; the first Done popped the duplicate and
landed back on the same menu. Pop with skip_current_view=True.

(2) Add a "View current" button so users can review the recorded
sequence without having to re-record. Also distinguish "(none)" from
"(invalid)" instead of conflating both as "(invalid)".

(3) Stealth could be enabled while the unlock sequence was still the
trivial 5xKEY_UP default. Change the default to "" and refuse the
toggle ON when no sequence is recorded -- the firmware boots into a
game and we don't want a coincidence to lock a user out.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
… reachable"

Add `classify_card_error()` to helpers/keycard/ui_helpers.py and route
every card-side catch block through it. Three views (Status, Pair,
Forget current) used to title every exception "Card not reachable" and
dump str(exc) into the body, so a card present at a non-default AID
(SELECT -> SW=0x6A82) was indistinguishable from no-card-in-reader.

The classifier maps NoReaderError, NoCardError, KeycardCardChangedError,
SecureChannelError, PairingStorageError, and APDUError (with branches
for 0x6A82 / 0x6982 / 0x6985 / 0x6Dxx / 0x63Cx) onto short, accurate
(title, body) pairs that fit ~60 chars per line per CLAUDE.md.

Operation-specific catches (Generate / Sign / Export / Push / UNPAIR /
INIT / PAIR / Reset / GP-list/switch/create/delete) keep their titles
when the cause is unknown but switch to a more diagnostic title (e.g.
"Wrong PIN", "Applet not found") when the classifier recognises the
SW. 14 new unit tests cover every mapping row.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes the loop for Rabby / MetaMask Mobile QR-Hardware-Wallet pairing
and fixes three bugs uncovered while testing on a freshly-initialised
keycard-shell card:

* Sign-ETH crashed at the final QR display because PSBTSignedQRDisplayScreen
  doesn't exist; switched to QRDisplayScreen (same screen used by PSBT).
* Card status / Factory reset / Pair / Init failed with "Applet not
  found / try Manage instances" when the inserted card's instance AID
  suffix wasn't 0x0101. select_with_autodetect now probes 0x0101..0x010F
  on 6A82 and updates active_keycard_aid transparently.
* PAIR with wrong password used to surface as "Cannot open secure
  channel"; classify_card_error now returns "Wrong password" for the
  cryptogram-mismatch SecureChannelError specifically.

Behaviour additions:

* ToolsKeycardPairView tries "KeycardDefaultPairing" silently before
  prompting (matches keycard-shell / keycard-cli defaults). PAIR
  failures don't consume slots, so this is free for cards with custom
  passwords. Success screen indicates default vs custom.
* New ToolsKeycardPairWalletView (replaces ToolsKeycardExportPubkeyView,
  with backwards-compat alias). Within one PIN-verified session,
  derives master/parent/account/first-receive, computes parent and
  source fingerprints (HASH160 of compressed pubkeys), encodes the BIP-44
  ETH HD account at m/44'/60'/0' as a crypto-hdkey UR with chain code,
  origin and children=0/* — what Rabby and MetaMask Mobile (Keystone
  SDK) ingest to import a QR hardware wallet. The textual checksum
  address at m/44'/60'/0'/0/0 is shown as a secondary screen for visual
  verification.
* New EthHDKeyQrEncoder in encode_qr.py and extract_extended_pubkey
  helper in ui_helpers.py (parses tag 0x82 chain code alongside 0x80
  pubkey from the EXPORT KEY extended template).
* Wipe path through "".join(pwd) avoids interning hazard when the
  default-password fast path normalises a module-level constant.

Tests: 16 new in test_keycard_eth_pair_wallet.py covering the encoder
round-trip via urtypes.crypto.HDKey.from_cbor, the TLV parser for the
extended export, select_with_autodetect's fall-through and update of
active_keycard_aid, and the new wrong-password classification. All 130
existing keycard/ethereum tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Status applet appends the applet-level SW1/SW2 to every protected
response BEFORE padding + AES-CBC encryption. unwrap_response was
returning the full decrypted plaintext, so the inner SW was silently
ignored: VERIFY_PIN with a wrong PIN "succeeded" and only the next
operation surfaced the failure as the cryptic "Parse fail resp=6985".

Now we read the trailing two bytes as the inner SW and raise
APDUError(sw) when != 0x9000. The IV chain still advances on error so
subsequent commands stay in lockstep with the card.

User-visible effect:
- Wrong PIN fails on submission with "Wrong PIN - N tries left".
- Operations that hit SW=6985/63CX/6A82/... are now routed through
  classify_card_error instead of falling through to pubkey-parse code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds a background CardMonitor observer that wipes the cached PIN of
the active card the instant it is removed from the reader. This closes
the "yank-and-sniff" window where a session was still authenticated
even though no card was present.

- helpers/card_monitor.py: pyscard CardMonitor observer, daemon thread
  owned by pyscard. Wipes Keycard PIN cache + Satochip/SeedKeeper
  session on removal. ATR/reader_name deliberately not logged.
- controller.py: per-UID keycard_pins (bytearray) + keycard_wallets_data
  cache. Independent copies so wiping caller's PIN does not destroy
  the cache; set_pin_for wipes the previous bytearray in place.
  Started CardMonitor at boot.
- settings_definition.py: new SETTING__AUTO_PIN_ON_INSERT (Advanced,
  default Disabled) - toast prompts for PIN on card insert. Default
  for SETTING__CACHE_SCARD_PIN flipped to Enabled (Disabled =
  re-prompt on every Home navigation).
- platform defaults flipped:
  PERSISTENT_SETTINGS Disabled -> Enabled,
  CAMERA_ROTATION 180 -> 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Top-level Keycard menu reduced from 10 entries to 5 daily-use ops +
two submenus (Setup, Manage):

  Keycard
  +-- Sign ETH            (was: "Sign ETH transaction")
  +-- View wallets
  +-- Export xpub         (was: "Export xpub to wallet")
  +-- Setup >             [new: Initialise card / Generate key / Import seed]
  +-- Manage >            [new: Status / Change PIN / Instances / Advanced]
      +-- Advanced >      [shortened labels]

ToolsKeycardPinManagementMenuView removed (was a one-entry indirection
to Change PIN; Change PIN now hangs directly off Manage). Error bodies
in classify_card_error / ToolsKeycardPairView updated to reference the
new menu paths ("Run Setup > Initialise card first", "Try Manage >
Instances to switch active AID").

Other Keycard work bundled in this snapshot:
- Change PIN flow (ToolsKeycardChangePinView): captures the new PIN
  twice via the digits keyboard, runs CHANGE_PIN through the secure
  channel, drops the cached PIN so the next op re-prompts. Tests in
  test_keycard_change_pin.py.
- ui_helpers.py: prompt_for_pin / prompt_for_text / select_with_auto-
  detect / open_unlocked_session / classify_card_error consolidated
  out of keycard_views.py. PIN-prompt cancel propagates as
  KeycardPinPromptCancelled.
- crypto.py / commands.py / responses.py / client.py: misc hardening
  - init_encrypted APDU (one-shot ECDH+AES-CBC INIT for blank cards)
  - factory_reset client method (INS=0xFD, magic P1/P2)
  - bigger TLV parser for SELECT (8F/80/02/02/8E/8D fields)
  - ECDSA DER signature normalisation (32-byte left-pad, sign-byte
    strip)
- global_platform.py: SCP02 i=15 with C-MAC against the default ISD
  keys for multi-instance management. INITIALIZE_UPDATE / EXTERNAL_
  AUTHENTICATE / INSTALL[for install] / DELETE / GET_STATUS. 18 unit
  tests in test_keycard_global_platform.py.

Tests:
- test_keycard.py: 4 new unwrap_response cases (SW strip on success,
  wrong-PIN raise, 6985 raise, IV advance on error) + 4 PAIR /
  init_encrypted / factory_reset cases.
- test_keycard_views.py: routing smoke tests for the 4 new menus,
  plus an assertion that the stale PinManagementMenuView class is gone.
- test_keycard_ui_helpers.py: 6A82 error body now asserts the new
  "Manage > Instances" copy.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- gui/screens/seed_screens.py: SeedAddPassphraseScreen and
  SeedEncryptedQRMnemonicIDScreen now honour ``initial_keyboard`` in
  _run() (digits / symbols / ABC). The dispatch loop matched the
  rendered keyboard only by coincidence before; opening with the
  digits keyboard (PIN entry) and pressing toggle would jump to the
  wrong layout.
- gui/toast.py + gui/components.py: ID_CARD FontAwesome glyph and
  toast plumbing for the "card inserted" prompt driven by the new
  CardMonitor.
- views/view.py: CardsMenuView entry point so the smartcard flows are
  reachable from Home without going through Tools (mirrors
  ToolsSmartcardMenuView). Initialise blank card routes to
  CardManagementView.
- views/tools_views.py: Smartcard Tools menu now exposes Keycard,
  Common, SeedKeeper, Satochip in a consistent order.
- hardware/buttons.py + hardware/displays/desktop_display.py: minor
  fixes for the desktop emulator (button mapping consistency, display
  config logging).
- tests/test_controller.py: keycard_pins lifecycle tests
  (set/get/forget/forget_all + wipe-after-set + per-UID isolation).
- tests/test_flows_seed.py + tests/test_flows_tools.py: small fixture
  adjustments for the new CardsMenuView routing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single entry point under Cards (SeedKeeper / Satochip / Keycard);
Tools > Smartcard Tools and CardManagementView dropped. Each app View
now runs a card_probe gate that routes to CardWaitScreen when no card
is present and to the matching setup wizard when the applet is
uninstantiated. Card insert/remove fan out through new Controller
listeners; a CardRemovedToast mirrors CardInsertedToast. Adds a
user-tunable Screensaver timeout setting (Off + minute presets) via
a Controller property so changes take effect without restart.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Probe SELECT-probes Keycard/Satochip/SeedKeeper AIDs to render
a check/empty-box on each Cards entry, so the user sees at a glance
what is installed. When the per-app gate finds the applet missing,
it now offers "Install applet" instead of dead-ending: a kind-aware
CardsInstallAppletView shells `seedkeeper_utils.run_globalplatform`
with the proven `--install`/`--load`+`--create` recipes against the
.cap files baked at <microsd>/javacard-cap/.

Each app's Advanced submenu gains an "Uninstall applet" entry. Keycard
uses the native GP secure channel + delete_aid for atomic package
removal; Satochip and SeedKeeper shell gp.jar. A new top-level
"Factory reset card" entry tries the GP-authenticated wipe first and
falls back to Keycard's cleartext FACTORY_RESET when ISD keys are
rotated, with a clear notice that Satochip/SeedKeeper need a PC for
that case.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* test_bip85_pgp_cli: extend the Brainpool curve `group_order` shim
  to P-384 and P-512 (pgpy ships them as abstract); RFC 5639 values.
* test_io_config_profiles: desktop runtime profile default is
  desktop_320x240, update the assertion to match the source of truth
  in `Settings.get_platform_default_display_config`.
* test_settings::test_reset_settings: previously asserted DISABLED
  against PERSISTENT_SETTINGS, which now defaults to ENABLED — pick a
  setting (SLIP39_SEEDS) whose default differs from the value we set,
  and assert post-reset matches `SettingsDefinition.default_value` so
  the test cannot drift again.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Keycard: probe_installed_applets now iterates instance suffixes
0x01..0x0F (preferring controller.active_keycard_aid first), so
keycard-shell cards on a non-default suffix render the install
checkmark on first visit rather than only after entering the menu.

SeedKeeper: writes on v0.2 cards now use type=BIP39 mnemonic
subtype 0 instead of Masterseed subtype 1. The Seedkeeper-iOS app
only parses bip39Mnemonic/password types, so revealing entries
written by our device previously crashed in its parser. Read path
keeps the Masterseed parser for backward compat.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…→setup

init_satochip() used to prompt for the existing PIN before calling
card_get_status(), so a fresh applet showed a meaningless "Card PIN"
screen (any value accepted) before the real "Card Uninitialised — set a
device PIN" + "New Card PIN" flow. Defer the PIN prompt until the
status check confirms setup_done=True, and unify cache-miss/card-changed
handling in one place.

Also route CardsInstallAppletView directly into the per-applet setup
view (ToolsKeycardInitView / ToolsSatochipSetupView /
ToolsSeedkeeperSetupView) with a "Set up" button on the success screen,
instead of going to the kind-specific menu and letting the gate
re-probe and re-redirect.

Keycard flow unchanged — its gate already routes uninitialised cards
straight to ToolsKeycardInitView with no PIN prompt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
After the install-success screen ("Set a device PIN to continue") routes
straight into the setup view, init_satochip() showed a second
"Card Uninitialised — Set a device PIN to complete Card Setup" warning
before the "New Card PIN" keyboard. Two screens carrying the same
message, back-to-back. Drop the warning; the keyboard's "New Card PIN"
title is enough context for both the install-flow and the direct-entry
case (Cards → Satochip/SeedKeeper on an uninstantiated card).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three Cards fixes, all SeedKeeper-flavoured:

1. SECRET_TYPE_PASSWORD writes now include the trailing `login_size(0)`
   and `url_size(0)` bytes required by the applet's Specifications.md.
   Without them, the iOS Satochip app's Password parser reads past the
   buffer and crashes on Reveal — and on the secret-list refresh it
   does right after change-PIN succeeds, which is why change-PIN
   appeared to crash too. Fixes both write sites (Tools → SeedKeeper →
   Save Password to Card, and the SLIP39-share-as-Password path in the
   seed save flow). Existing entries written by previous firmware
   remain malformed; wipe + re-save them.

2. CardsInstallAppletView now calls disconnect_smartcard_connections()
   between the successful gp.jar install and the routing to the setup
   view. The pre-refactor flow bounced through the kind-specific menu
   which re-probed via run_card_gate (releasing readers, killing
   scdaemon); c80aea5a's direct-to-setup hop skipped that, leaving a
   stale connector and held scdaemon — symptom was "Set up" doing
   nothing or hanging after a factory-reset+reinstall.

3. Restore the SeedKeeper storage chooser (4/8/16/32/64 KB → 0FFF /
   1FFF / 3FFF / 7FFF / FFFF) that PR 3rdIteration#189 added to the pre-dfca575f
   DIY-tools install view and that the Cards refactor dropped. Default
   highlighted is 8 KB (1FFF), matching the prior implicit default.

Also folds in two pre-existing working-tree tweaks in tools_views.py:
the dead PACKAGE_AID constant in ToolsSatochipUninstallAppletView is
removed, and the SeedKeeper --delete AID typo `5365656b6565706572`
("Seekeeper") is fixed to `536565644b6565706572` ("SeedKeeper").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The old _attempt_globalplatform_wipe ran the whole reset through the
in-tree Python SCP02 stack and called gp.delete_aid() per enumerated
AID. That issues a single DELETE APDU and bubbles the card's SW as an
exception — fine for most applets, but SeedKeeper rejects the
one-shot DELETE. The exception was caught silently and surfaced as
"1 skipped" in the factory-reset report, leaving the applet in place.

Replicate what the per-applet ToolsSeedkeeperUninstallAppletView does:
keep the SCP02 channel as an auth probe (so rotated ISD keys still
fall back to the soft-reset path), keep gp.list_instances for
enumeration, but disconnect the Python channel before deleting and
shell out to `gp.jar --delete <AID> -force` per AID. -force is what
makes gp.jar paper over the SWs the Python stack treats as fatal,
and the per-applet Uninstall flow already proves it works for
SeedKeeper, Satochip and Keycard alike.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…files

(A) Factory reset now drives entirely off probe_installed_applets +
gp.jar — no Python SCP02 channel. The previous flow opened SCP02 via
the in-tree Python stack purely to enumerate applets, then disconnected
with pyscard's default LEAVE disposition and called gp.jar per AID.
The card retained ISD-selected + session-counter state from the Python
side; gp.jar's subsequent SCP02 handshake produced cards that rejected
DELETE with SW=0x6985 ("Could not delete X. Some app still active?").
Same gp.jar command from a cold start (Tools → SeedKeeper → Advanced →
Uninstall) always worked — the only difference was the prior Python
SCP02 priming. Switching the enumerate step to cleartext SELECT via
probe_installed_applets removes the priming entirely. For Keycard we
pass the 7-byte package AID A0000008040001 so a single --delete -force
wipes the multi-instance package atomically.

(B) Earlier factory-reset builds (before this change) deleted Keycard
instances individually but never the package, leaving the load file as
an orphan. probe_installed_applets's SELECT to instance AIDs then
reports Keycard as "not installed", but gp.jar --load on a fresh install
hits "Applet loading not allowed". Add -force to every install command
template (satochip --install, seedkeeper --install, keycard --load) so
a stale load file is wiped before the new one is loaded. Idempotent
when there's nothing to override.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Splits Controller.on_card_removed into wipe_card_session_secrets (runs
immediately, never debounced — leaking secrets across cards is far worse
than an extra PIN re-entry) and dispatch_card_removed_event (toast +
listener fan-out, debounced in CardMonitor to absorb the spurious
removed/inserted bounces common on contactless and loose USB readers).
CardsMenuView now registers as a card-event listener and re-probes /
re-renders when a card is inserted or removed while the menu is on
screen, so the checkmark state stays in sync without a back-out round
trip.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ory reset

The probe only SELECTs against instance AIDs, so an orphan 7-byte
Keycard package (load file with no instances) is invisible to both the
Cards menu and the factory-reset target picker. Install then hits
"Applet loading not allowed" because gp.jar's --load -force fails to
clean up the stale package on some cards. Add an unconditional
gp.jar --delete A0000008040001 -force before --load (silenced via a new
suppress_failure_dialog kwarg on run_globalplatform so a clean card
doesn't show a spurious "Failed" toast) and always include Keycard in
the factory-reset target list so the orphan is reachable from the
existing wipe path too. Per-step error trailer added to install failures
so a future regression is diagnosable without peeking at stderr.

Also fixes two stale assertions in test_controller's defaults check
(PERSISTENT_SETTINGS and CAMERA_ROTATION defaults changed upstream).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
``RET_CODE__BACK_BUTTON`` and ``HardwareButtonsConstants.OVERRIDE`` are
both numerically ``1000``, so the previous check intercepted every back
press as a card-event refresh, dropped the user back into the same menu,
and the next ENTER landed on SeedKeeper. Discriminate via the listener
flag instead: only ``trigger_override`` sets ``refresh_requested[0]``, so
checking it first cleanly separates a real card event from a back press.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Keycard-3.2.cap ships three applets (signing, NDEF, Cash) and our
install recipe was creating all three. The NDEF instance uses the
ISO/IEC 14443 Type-4 standard AID (D2760000850101), which iOS Core
NFC auto-SELECTs on tag discovery. On a card that also has SeedKeeper
loaded, the Seedkeeper-iOS app then talks to the wrong applet on the
secret-reveal path and the app crashes (the label is still readable
because it comes from an independent code path that hits SeedKeeper
directly). Our Python firmware was unaffected because select_with_
autodetect always SELECTs the signing AID by name.

Reduce both install paths (CardsInstallAppletView and the legacy
ToolsDIYInstallAppletView) to create only the signing instance
(A0000008040001010101). The Cash applet is dropped at the same time —
it's only used by Status Pay and no wallet or tool we target uses it.

No migration UI needed: the unconditional `gp.jar --delete
A0000008040001 -force` we already run before --load cascade-deletes
the prior signing + NDEF + Cash instances on the same package, so an
affected card is cleaned up by simply reinstalling.

Tests in test_cards_install_and_wipe.py updated to expect the new
3-step recipe (pre-delete + --load + 1 × --create).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ethermachine and others added 29 commits June 15, 2026 01:57
Controller.VERSION -> '0.1.1'; CHANGELOG: this release is 0.1.1 and the prior Keycard-Edition-B1 entry is renamed 0.1.0, matching the GitHub release names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Controller.VERSION carries the brand + version on the opening splash (release/tag stay 0.1.1).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Swap handling hardening across multi-card / multi-instance sessions:

- open_unlocked_session now detects a card/instance swap synchronously at
  SELECT (compares the freshly-SELECTed instance_uid against the new
  Controller.last_authenticated_keycard_uid marker) and scrubs the previous
  card's session secrets before touching the new card. This is the
  reader-independent backstop for the unreliable PC/SC 'removed' event
  (notably NFC/PN532 via ifdnfc). The marker is distinct from
  last_keycard_uid (which enumeration/name-resolution mutate) and is cleared
  by forget_all_pins().

- KeycardCardChangedError in the ETH/BTC sign/export finalize handlers now
  follows the no-card retry-without-rescan principle: keep the scanned
  request / PSBT / message and route via _pair_then_resume(...) ->
  ToolsKeycardPairView(next_destination=...), which resumes the originating
  step after a successful pair (both hops skip_current_view so the back
  stack ends up as if the pairing detour never happened).

- SETTING__CACHE_SCARD_PIN default flipped Disabled -> Enabled: the PIN is
  reused across Home for fewer prompts, dropped only on swap/removal/Lock
  card/instance-switch/wipe-timer (all ungated). Help text + 9-locale
  catalogs updated to match.

Cover: test_keycard_session_auto_switch.py, test_controller.py,
test_keycard_views.py. Docs: AGENTS.md threat-model + no-card sections.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ToolsKeycardInstancesCreateView now sets active_keycard_aid to the
freshly-installed AID after a successful INSTALL. The new instance is
uninitialised, so this makes the announced "Run Init next" actually target
it: select_with_autodetect first-tries the active AID, so without the switch
the Init wizard would re-SELECT the previous (already-initialised) instance
and bail with "Already initialised", forcing the user through Switch instance
first. Symmetric with the delete path, which falls the active AID back to the
default when the active instance is removed.

Cover: test_create_auto_switches_active_instance. Docs: AGENTS.md menu
ordering + "Active instance for the session" notes corrected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…byte tools

- calldata_decoder: thread the tx `value` into pages_for_calldata so the
  decoded view can reflect native-value transfers alongside the call
- keycard_views (ETH sign): render a non-UTF8 personal_sign payload as
  0x-prefixed hex instead of raw hex that looks like text
- function_registry: extend known selectors; add tests
- scripts/: fetch_4byte_signatures.py + probe_eth_signature_coverage.py
  + fetch_eth_signatures.md to refresh/measure 4byte selector coverage

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the single hard-coded Snake boot game with a pluggable "games
console": boot shows a game-select menu and dispatches to a mini-game,
returning to the menu on game-over. The configured unlock sequence still
exits the whole console into the firmware from anywhere (menu or any
game), and the KEY1+KEY2+KEY3 10s panic exit still disables stealth boot.

- stealth/base.py: BaseStealthGame contract (reset/tick_ms/step/
  handle_key/draw_frame); shared render + render_game_over + grid layout
- stealth/console.py: StealthConsole owns the input thread, UnlockBuffer
  (fed every key, everywhere), panic, game registry and play loop;
  KEY1 returns to the menu mid-game
- stealth/snake.py: reparented onto BaseStealthGame; walls now WRAP
  (modulo) instead of killing — self-collision is the only death
- stealth/game_2048.py, tetris.py, dino.py: three new games
- controller.start(): launch StealthConsole().run() instead of SnakeGame
- Tools > Stealth boot: inline description via new
  DescriptionButtonListScreen; translated into all 9 catalogs (.po/.mo)
- tests: wrap-around, 2048/tetris/dino mechanics, console menu/unlock/
  KEY1/panic; boot-integration updated to StealthConsole

Import isolation preserved (test_stealth_import_guard scans the package).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The top menu probed instance count via _probe_keycard_instance_count,
which deliberately returned None for 0/1 instances (>=2 only); the
authoritative _count_keycard_instances meant to cache an exact 1 is dead
code. So a single-instance card never reached count==1: "Switch instance"
stayed visible and titles kept the noisy "· Inst N" suffix.

The fast probe already SELECTs every valid slot (1..MAX, both AID forms),
which this firmware/keycard-cli are the only minters of, so it reliably
distinguishes "exactly 1". Make it return the exact count (len(slots) or
None) — still no ISD handshake on menu entry.

Add _instance_title_suffix(): a user-assigned name always shows; the auto
"Inst N" is dropped only when count==1; unknown count keeps the label.
Apply to all 6 instance-scoped menu titles plus the Wallets/Addresses
list tags. Destructive "Wipe {}?" keeps a concrete identifier.

l10n: add bare Pairing/Manage Inst/Wallets/Addresses to all 9 catalogs
(derived from the existing compound strings); recompile .mo.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nces)

The blank-card install minted the first signing instance at the 10-byte
legacy AID A0000008040001010101 (pinned to commands.APPLET_AID), while
"Create instance" (_next_free_instance_aid) mints the 9-byte canonical
form A000000804000101+slot that real Status cards / keycard-cli use. So
the first instance ended ...010101 and the second ...0102 — inconsistent.

Unify on the 9-byte canonical form: APPLET_AID and the blank-card
--create both become A00000080400010101 (slot 1). select_with_autodetect
still probes the 10-byte legacy form, so cards already in the field keep
resolving via one extra 0x6A82+probe. The smoke test's bare client.select()
now probes both forms on 0x6A82 too (no Controller needed).

Tests: pin APPLET_AID to the 9-byte value; slot-1 mint now equals
APPLET_AID (old "must differ from 10-byte default" invariant dropped);
blank-card create assertion updated. Legacy-card autodetect already covered
by test_probes_legacy_10byte_form.

Hardware verification still required (smoke test on 9-byte + legacy cards,
blank-card install, create 2nd instance) before relying on this on-device.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump Controller.VERSION to Keycard-Edition-0.1.2 (shown on the opening
splash via views/screensaver.py) and add the 0.1.2 CHANGELOG entry
covering the work since 0.1.1: 9-byte AID unification, instance
auto-activate, single-instance menu cleanup, reader-independent card-swap
detection + retry-without-rescan, the stealth games console, and the ETH
calldata decoding improvements.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…menu entry

The PC/SC `removed` event is unreliable on contactless readers (NFC/PN532
via ifdnfc), and the synchronous swap detection only lived inside
open_unlocked_session() — which the View wallets / View addresses flows
SKIP when the address cache is warm (they serve keycard_wallets_data
straight from the per-AID cache without touching the card). Result: after
swapping cards, the previous card's addresses were still shown.

Fix: extend the synchronous, reader-independent identity check to the
choke-points that display/reuse cached card data, instead of trying to
fix the unreliable async event.

- ui_helpers: extract detect_card_swap(controller, instance_uid) from
  open_unlocked_session (behaviour-preserving); it wipes session secrets
  on a UID mismatch and deliberately does NOT claim an authenticated
  session (never sets last_authenticated_keycard_uid). Add
  verify_active_card_unchanged() — a cheap SELECT-only guard built on
  identify_inserted_card + detect_card_swap.
- card_probe.run_card_gate: run detect_card_swap on every Keycard menu
  entry, reusing the SELECT the card probe already did (zero extra I/O),
  so re-entering the menus wipes stale state for a swapped card.
- keycard_views: guard the warm-cache branch of ToolsKeycardWalletsListView
  (ETH) and ToolsKeycardBtcAddressesListView (BTC); on a detected swap the
  cache is wiped and the view re-runs (skip_current_view) to re-derive for
  the new card; no-card routes via _no_card_toast_or_error.
- Tests: new TestCardSwapDetection (10 cases) covering detect_card_swap,
  the warm-cache guard (swap / same-card / no-card, ETH+BTC) and the menu
  gate; fix test_card_probe _make_view to init last_authenticated_keycard_uid
  to None (real controller default) so the gate's swap check doesn't trip.
- AGENTS.md: document the three swap-detection choke-points.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
iOS coexistence warning: replace the blocking DireWarningScreen with a
subtle non-blocking InfoToast (_toast_ios_coexistence) on both install
paths (CardsInstallAppletView + install_seedkeeper_applet). The old gate
was too invasive — it fires correctly even for an uninitialised Keycard
instance, since the iOS crash depends only on the Keycard package being
present, not on init state.

Low-space check: stop false-positiving on a card that already holds the
Keycard package. required_nv_for_install now estimates the real on-card
footprint (real_nv_footprint = cap_size * CAP_NV_FOOTPRINT_RATIO 0.70)
instead of the raw .cap size, which over-estimates by ~1.3-1.5x (it
bundles Debug/Descriptor/Export components stripped at load). Add a
LOW_SPACE_MARGIN_BYTES (2048) tolerance so borderline fits don't warn.
The same ratio feeds estimated_used_nv (Storage occupation bar).

Show the card's free memory as 'Free: X KB' on the SeedKeeper Select
Storage chooser (DescriptionButtonListScreen), reading CardMemory once
and threading it into _warn_if_low_space(..., mem=) to avoid a 2nd query.

l10n: add 'Free: {} KB' and 'iOS Seedkeeper may crash' to all 9 catalogs
(+recompile .mo). Update tests and AGENTS.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ooser

- ETH derivation-scheme chooser (Standard m/44'/60'/0'/0/i vs Ledger Live
  m/44'/60'/i'/0/0) for View wallets + Connect software wallet, with
  per-scheme address-cache namespacing and a Ledger Live account picker.
- Import hex (NGRAVE) Type-hex path gains a 24/12-word length chooser that
  caps input via the new KeyboardScreen.max_input_length + GroupedHexEntryDisplay.
- Seedkeeper backup at creation simplified to the single iOS-compatible
  'BIP39 mnemonic' format (drop the non-round-tripping Password option).
- l10n: new strings propagated across all 9 catalogs.

Tests run in Docker/CI (local env lacks embit/Cryptodome).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Held buttons no longer 'run on': the input loop now drains the whole queue
each frame and throttles per-key game/menu actions to ~10/s, so a held key
stops the instant it is released. Previously a background thread dumped every
HardwareButtons.wait_for() repeat into an unbounded queue while the game loop
consumed one per frame, so a held button built a backlog that kept acting for
seconds after release (most visible as Tetris soft-drop overshoot). The unlock
combo still sees every raw key, so repeated-key combos keep matching.

Menu redesigned ('arcade cards'): real OpenSans typography via a cached
stealth_font() helper, per-game accent swatches, highlighted selected card,
header + footer hints. All decorations are drawn as shapes (OpenSans has no
geometric-shape glyphs); the styled path falls back to a plain render if a
font ever fails, so the boot screen can't crash.

Full visual refresh: game-over screen and every in-game HUD label use the
font; 2048 tile numbers are sized to the cell.

Tests: de-flood/no-backlog, distinct-keys, raw-unlock regression, menu render
(57 stealth tests pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…icker

Reword the derivation-scheme chooser (shared by View wallets and Connect
software wallet): "Default (this device)" -> "Standard (BIP-44)", title
"Wallet type" -> "Account layout", and replace the awkward description with
"Wallets number accounts differently. Match your app."

Make the Ledger Live export picker paginated and show each account's address
so the user can verify the account before exporting. Drop the static, 10-cap
ToolsKeycardEthLedgerAccountView and add a mode="export" to
ToolsKeycardWalletsListView, which already derives m/44'/60'/N'/0/0 under the
Ledger Live scheme with per-AID caching, swap detection and "Next 10" paging.
Rows render as "N:0x..addr.." and selecting one exports that account's xpub.

Update the routing/derivation tests and the two warm-cache swap tests to
thread the new mode. Replace the three changed msgids across all 9 catalogs
(+ messages.pot) with translations and recompile the .mo files.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the hardcoded 4-instance cap with a memory-aware limit. The card
reports only free NV (GET DATA 0xFF21), so per-instance cost is estimated
and refined by a self-calibrated free-NV delta around INSTALL.

- MAX_KEYCARD_INSTANCES 4 -> 16 (firmware ceiling, not hardware).
- view.py: KEYCARD_INSTANCE_NV_ESTIMATE_BYTES (3 KB, calibratable) +
  keycard_instance_nv_estimate() + estimate_remaining_instances().
- Controller.keycard_measured_instance_nv (card-specific, wiped on swap).
- Create flow: soft "Create anyway" warning when no room + "~N more fit"
  on confirm; self-calibrates from the INSTALL free-NV delta.
- Storage view/screen: "~N more instances fit" line.
- Fast menu probe made bounded (canonical-only, early-exit at 2) so the
  raised ceiling doesn't re-introduce the menu-entry stall; exact census
  stays in the management flows (_probe_keycard_instance_count_exact).
- l10n: 4 new strings across all 9 catalogs (+ recompiled .mo).
- Tests + AGENTS.md docs updated.

NOTE: KEYCARD_INSTANCE_NV_ESTIMATE_BYTES is a conservative placeholder;
calibrate on hardware (free-NV before/after a real create+init).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…erwrite guard

This instance now holds only safe per-instance management (Change PIN,
Unblock PIN, Rename, Lock) plus a "Set up / reset ›" pointer. The four
destructive ops (Generate key, Import seed, Initialise instance, Factory
reset) move one level down into the new ToolsKeycardSetupResetMenuView,
so the routine path can't fat-finger a key overwrite.

Generate key / Import seed now warn explicitly before replacing an
existing key: _instance_key_present does a SELECT-only read of the
unauthenticated key_uid and the warning becomes "Replace key?" when a key
is loaded. The probe is advisory — any failure (incl. no card) falls back
to the generic warning and lets the downstream flow surface real card
errors, so the entry views gain no new no-card branch. Added
select_inserted_card (factored from identify_inserted_card) to expose the
full SelectResult without a second round-trip.

Tests: This-instance routing (5/4 counts), no-destructive-or-pairing
guard, new setup-reset routing + overwrite-guard tests; setup-chain
reorg-reachability now routes through the submenu; unblock-pin index fix.
Docs: AGENTS.md menu-organisation section. l10n: 5 new strings added to
all 9 catalogs and .mo recompiled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…& MAC compares

Audit of the Keycard/SeedKeeper firmware confirmed no secret material reaches
persistent storage. Findings were all in-RAM hygiene + small hardening:

- F1: the seed import/generate/backup flows wiped their byte buffers and word
  lists but left the *joined* plaintext mnemonic string and the *decoded*
  passphrase string (each holding the whole secret in one immutable allocation)
  unwiped until GC. Wipe both via wipe_string() in a finally on every exit path
  in ToolsKeycardImportSeedView, ToolsKeycardImportSeedkeeperPushView,
  ToolsKeycardGenerateSeedLoadView, and ToolsKeycardSeedkeeperSaveRunView (the
  last gained a try/finally; its int-list copies are overwrite-then-clear'd,
  best-effort). Controller pending_* copies stay under the existing
  error-vs-user-exit rule, so the Seedkeeper "Both" flow is unaffected.

- F2: decode_seedkeeper_seed_secret() read length-prefixed fields off an
  untrusted card with no bounds checks (IndexError on a malformed/hostile
  secret) and accepted any entropy length. Bounds-check every field and reject
  non-canonical BIP-39 entropy lengths, returning None instead of raising.

- F3: use hmac.compare_digest for the secure-channel response MAC check and the
  KEF encrypted-QR auth tag (constant-time).

Tests: new tests/test_keycard_secret_wipe.py (4 flows + wordlist integrity) and
malformed-payload cases in test_keycard_seedkeeper_import.py. Full suite green
(1195 passed, 3 skipped). AGENTS.md updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Bump Controller.VERSION to Keycard-Edition-0.1.3 (shown on the opening
splash via views/screensaver.py) and add the 0.1.3 CHANGELOG entry
covering the work since 0.1.2: in-RAM secret-wipe hardening + Seedkeeper
decode bounds-checks + constant-time MAC compares, the destructive ops
moved into a Set up / reset submenu with a "Replace key?" overwrite
guard, the memory-aware instance limit (cap 16 + free-space estimate),
the ETH account-layout chooser + paginated Ledger Live picker, the
NGRAVE hex length chooser, the stealth held-button fix + arcade menu,
the non-blocking iOS toast + recalibrated low-space check, and card-swap
detection extended to the warm address-cache views and menu entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…, step back a word)

Republish 0.1.3 with two changes on top of the previous (broken) build:

- Create instance regression: read the card's free-NV in the clear BEFORE the
  SCP02 secure channel opens. The prior build issued an un-MAC'd GET DATA over
  the open channel, which desynced it so the next INSTALL was rejected with
  0x6982 "Security status not satisfied". _open_isd_channel now takes
  read_free_nv= and returns (channel, instances, connection, free_nv); the
  post-INSTALL self-calibration (also a read over the open channel) is dropped
  in favour of the conservative per-instance estimate.

- Seed word entry (Import seed > Type words): D-pad navigation skips greyed-out
  letters and the cursor jumps to the next still-available key (opt-in
  Keyboard.skip_inactive_keys); after locking a letter the cursor moves off a
  now-greyed key onto the first available letter. The back arrow steps to the
  previous word, re-opened pre-filled for editing, instead of discarding all
  progress (only backs out of the flow on word 3rdIteration#1).

Tests: new tests/test_keyboard_skip_inactive.py; extended
tests/test_keycard_import_entry.py (back-one-word) and tests/test_keycard_views.py
(free-NV read before open). Full suite: 1204 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… uninitialised-instance loop

Import seed (Tools > Keycard … > Import seed) UX fixes plus a real bug fix:

- Reorder source menu so "Scan SeedQR" is last; manual entry first.
- Rename "Type words" → "BIP39 seed phrase".
- Word-entry keyboard: stop auto-skipping greyed keys (skip_inactive_keys
  = False). Greying stays as a visual hint but the cursor lands on every
  key, so letters are reachable.
- Fix the uninitialised-instance import loop: creating an instance auto-
  activates a fresh (no-PIN) instance; going straight to Import > From
  SeedKeeper reached LOAD_KEY on an uninitialised card, which raised
  KeycardNotInitialisedError and the SeedKeeper handler looped back to the
  re-insert step. Now both Import seed and Generate key probe init state up
  front (_instance_initialised) and route to the Init wizard via a "Set up
  first" explainer. The SeedKeeper push also catches KeycardNotInitialised-
  Error explicitly (wipes pending seed, clear error) as a backstop so it can
  never loop.

Tests: redirect-to-Init for both entry views, back-button behaviour, no
redirect on probe failure, and a no-loop regression for the SeedKeeper push.
Updated source-index assumptions in two existing import tests. New UI strings
translated into all 9 catalogs + .mo recompiled.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The "≈N more instances fit" estimate over-promised: it read only free
EEPROM, but transient RAM is the binding resource. A field card refused a
5th instance (SW=0x6F00) with ~45 KB EEPROM free; an on-device GET DATA
0xFF21 read confirmed free volatile RAM = 303 B (exhausted), NV = 46596 B,
7 applets. Our 0xFF21 parser was correct — we were just reading the wrong
resource and never learning from the failure.

- estimate_remaining_instances now gates on free RAM (tag 0x83) too:
  min(slots, NV, RAM). free_volatile 0/None skips the RAM term (tag absent
  != no RAM), so cards lacking the tag keep prior NV+slot behaviour.
- Install failure with a memory-class SW (0x6A84 or, in the install
  context, 0x6F00) sets Controller.keycard_install_full -> estimate clamps
  to 0 for the session (ground truth beats any estimate). New helper
  is_install_memory_failure; classify_card_error maps 0x6A84 -> "Card full".
  Reset on card swap.
- Self-calibration: after a successful INSTALL, re-read free NV+RAM on a
  fresh clear channel and store sanity-bounded per-instance deltas
  (keycard_measured_instance_nv / _volatile).
- UX: Storage shows free RAM; create-confirm/warning say "Card appears
  full" instead of "≈0 more fit"; "Create anyway" override kept.
- New KEYCARD_INSTANCE_VOLATILE_ESTIMATE_BYTES=512 (> measured 303 B floor).
- l10n: 5 new strings added + compiled across all 9 catalogs.

Tests: RAM-gate + 0/None fallback + real-card(46596/303)->0, install
clamp on 0x6A84/0x6F00, is_install_memory_failure, wipe resets
calibration. Full suite: 1227 passed, 3 skipped.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…EY error

A transient card error (secure-channel break, PC/SC hiccup, VERIFY stumble)
at the final LOAD_KEY push used to discard the validated seed and bounce the
user back to the menu — forcing a full re-type of the NGRAVE hex / mnemonic.
A second attempt usually succeeds because the card link is fresh.

Both push paths now loop: a transient error shows a Retry/Cancel screen and,
on Retry, re-opens a fresh session and re-sends LOAD_KEY reusing the already
derived seed64 — never re-typing. The seed is wiped only on success, an
explicit Cancel, or a genuine card swap.

- ToolsKeycardImportSeedView (Scan / Type words / Import hex): retry loop.
- ToolsKeycardGenerateSeedLoadView (on-card Generate): same loop; the pending
  mnemonic/passphrase survive retries (an on-card-generated seed with no paper
  backup would otherwise be unrecoverable).

Cover: tests/test_keycard_import_retry.py — both flows, error->retry->success
keeps the words/pending and error->cancel discards and exits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On the three screens that instruct the user to pull the card mid-flow
(Seedkeeper backup Both/Another-card, and the import-from-Seedkeeper
insert/reinsert), the card-removed redirect-to-Home and its 'Card removed'
toast would dead-end the 2nd backup/import. Add a declarative classname
allowlist (Controller._CARD_VIEW_REDIRECT_EXEMPT_CLASSNAMES) checked via
_top_is_card_swap_view() in _on_card_removed_redirect and
dispatch_card_removed_event; the secret wipe still fires (never touches the
pending seed). Cover: tests/test_cards_menu_view.py::TestCardRemovedRedirect.
The Clone Card Secrets flow was a pure union (add new by fingerprint,
never delete the destination). Add a scope chooser (All Secrets / Single
Secret) and, for All, a mode chooser:

- Add New (keep existing): the prior append behaviour (default, safe).
- Replace destination: erase every secret on the destination
  (seedkeeper_reset_secret per header), then copy -> destination becomes an
  exact copy of the source. Guarded by a DireWarning that counts what will be
  deleted and tells the user to use a different card; a v1 destination is
  refused (can't delete); cancel deletes nothing; source is fully read before
  the destination is touched.
- Single Secret: read source, pick one (reusing the View/Delete-Secrets label
  scheme), copy it (append, skip-if-exists).

Security: new _wipe_collected_secrets() scrubs the plaintext payloads held in
RAM across the card swap on every exit (success/cancel/error).

i18n: 17 new strings added to messages.pot and all 9 catalogs, .mo recompiled.
Tests: tests/test_seedkeeper_clone.py (routing, replace-wipe, v1 guard,
cancel-no-delete, single-copy, RAM scrub). Two-card hardware run still pending.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…skip

Single-secret copy used the same fingerprint dedup as bulk clone and would
report 'Imported: 0, Skipped Existing: 1' when the destination already held a
secret with the SAME CONTENT (Seedkeeper fingerprints are sha256(content)[:8],
not name-based) -- confusing when the match is under a different label.

- Single copy now names the existing entry and asks 'Already on the
  destination as {label}. Copy anyway?' (Copy Anyway / Skip) instead of
  silently skipping. The named label is also a diagnostic: the source secret's
  own label would reveal a source/destination mix-up.
- Defensive zero-fingerprint guard: a placeholder '00000000' is never treated
  as a duplicate (excluded from the dest map and the dedup test).
- logger.info() prints the copy counts + destination fingerprints to aid
  on-device debugging.
- Bulk All-Secrets copy keeps the silent append dedup (correct for bulk).

Tests: tests/test_seedkeeper_clone.py (copy-anyway imports, skip does not,
zero-fp not deduped); routing assertions updated for the confirm_duplicates arg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
pysatochip's card_disconnect() closes the card connection but leaves the
connector's RemovalObserver on pyscard's process-wide CardMonitor. That
stale observer auto-reconnects to the next inserted card and starves a
freshly-built connector across a physical swap — the "Unable to find
seedkeeper" failure (no PIN prompt) on the 2nd card of a "Both" backup.

Add seedkeeper_utils.teardown_satochip_connector() (card_disconnect +
deleteObserver, each guarded) and route disconnect_smartcard_connections /
init_satochip / card_probe / reader.release_other_smartcard_holders through
it. The Seedkeeper "Both" save tears down save 1's connector while card 1
is still inserted, before prompting the swap.
… first try

On the first session-op of a boot no pairing is cached yet, so the final
LOAD_KEY push was itself that first op: it raised KeycardCardChangedError,
the bare Destination(ToolsKeycardPairView) silently paired (v3.2 ephemeral
PSK), then returned BackStackView — re-running the view from the top and
discarding the just-typed seed while the card stayed empty. That's the
recurring "import doesn't save on the first try" bug.

ToolsKeycardImportSeedView / ToolsKeycardGenerateKeyView now call
_ensure_card_paired() before any seed is collected (SELECT-only probe →
_pair_then_resume pairs and re-enters the same view). The
ImportSeedkeeperPush / GenerateSeedLoad push steps handle a mid-flow
KeycardCardChangedError via _pair_then_resume without wiping
pending_keycard_* (the resumed view re-reads the mnemonic).
Cover: test_keycard_import_pairing.py + happy-path in the retry suite.
Two fixes under Tools > Keycard > Settings > Manage Instances > Create:

1. Chain into Init. After a successful create the flow returned to the deep
   Instances submenu instead of running Init, stranding the user (Init only
   happened after manual back-nav). ToolsKeycardInstancesCreateView now
   returns Destination(ToolsKeycardInitView, skip_current_view=True); the
   pre-existing active_keycard_aid switch makes Init target the new
   (uninitialised) instance.

2. Monotonic "≈N more fit". The estimate could jump *upward* (3→6) as
   instances were added: the post-install self-calibration measured a
   per-instance RAM cost smaller than the constant (RAM is reclaimed once
   the INSTALL buffers free), inflating the RAM headroom. Remove the
   self-calibration (keycard_measured_instance_* + _record_instance_nv_
   measurement); the per-instance helpers return fixed conservative
   constants. Add view.apply_capacity_cap + Controller.keycard_capacity_
   estimate: the predicted total capacity only ratchets down, so a max-4
   card now reads 3 → 2 → 1 → "Card appears full". Delete resets the cap and
   the install-full clamp (a freed slot makes room again).

Free EEPROM/RAM + instance count are still read live from the card; only
the per-instance cost is a constant (the card never reports per-app usage).

Cover: test_keycard_storage.py::TestApplyCapacityCap, test_keycard_views.py
::TestCreateInstanceMemoryGate (cap anchors/clamps, Delete reset) +
test_create_auto_switches_active_instance (Init destination),
test_controller.py (wipe resets cap+clamp).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…setting, context-aware spinner

- Remove the Donate screen/button and 11 unused/legacy settings left over from
  the on-device-seed era (Xpub export/details, BIP32 account prompt, Seed word
  lengths, Plaintext/Encrypted QR, privacy/dire warnings, BitBox/Passport/
  TAPSIGNER backups) plus the now-empty Load Backup Files submenu and the
  vestigial Smartcard support toggle (smartcard tools are always available now).
- Remove the Bitcoin-network setting (this fork is mainnet-only): hardwire
  mainnet in the BTC amount component and drop the orphaned NetworkMismatchErrorView.
- Context-aware loading spinner: parametrize LoadingScreenThread (neutral KeyCard
  default + BTC/ETH presets), generate keycard_60x60.png, and thread the chain
  context through ScanView -> ScanScreen so Bitcoin/Ethereum scans and address
  derivation show their own logo instead of always the Ethereum one.
- Settings back-compat preserved: removed keys are silently ignored on load
  (settings.json and SettingsQR). Tests updated for the removed settings; add
  tests/test_loading_spinner.py and a settings back-compat regression test.
- Bump VERSION to Keycard-Edition-0.1.4 and add the 0.1.4 CHANGELOG entry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@3rdIteration

Copy link
Copy Markdown
Owner

Hey thanks for the PR.

There are many, many conflicts with this, probably due to some work I did on it over the last week or two, but can you rebase against the latest dev? (The functionality is there in B11 if you want to check it out)

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.

2 participants