Skip to content

test(7.15): consolidated Python harness, bindings, and device coverage - #197

Open
BitHighlander wants to merge 191 commits into
masterfrom
reconcile/upstream-sync
Open

test(7.15): consolidated Python harness, bindings, and device coverage#197
BitHighlander wants to merge 191 commits into
masterfrom
reconcile/upstream-sync

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

Purpose

This is the single master-targeting Python release PR for firmware 7.15 / RC18. It consolidates the original #197 work, all seven commits from #199, and the review/test commits that previously had no PR. No history was rewritten; reconcile/upstream-sync was advanced normally.

Contents

  • regenerated 7.15 protocol bindings;
  • Hive sign-message and sign-operations coverage;
  • clearsign v2 and identity-icon contract coverage;
  • Maya signature recovery and release-report coverage;
  • Hive phase-2/3 screenshot selection;
  • Osmosis client-path fixes, wire-review invariants, denomination binding, and corrected report mapping;
  • Ethereum typed-hash policy-gate coverage;
  • host and exact-firmware regression coverage for RC18 clearsign persistence;
  • Zcash ZIP-32 seed-fingerprint helpers and device tests;
  • complete RC18 PCZT host flow for transparent outputs, transparent inputs, and Orchard actions;
  • an RC18 report catalog aligned with the regular-with-Orchard plus bitcoin-only release shape.

RC18 Zcash contract

The regular/full RC18 firmware includes Orchard privacy. Only the bitcoin-only build compiles non-Bitcoin features out; there is no separate Zcash artifact.

zcash_sign_pczt implements the exact firmware 7.15 conversation:

  • sends all transaction header fields and transparent component counts;
  • streams transparent outputs, then transparent inputs, then Orchard actions only when requested;
  • requires every Orchard action to declare is_spend explicitly;
  • rejects host-supplied transparent sighashes;
  • rejects duplicate, missing, or out-of-range device requests;
  • reads the deferred transparent signature response before the final PCZT response;
  • treats Orchard signatures as a compact list: one 64-byte RedPallas signature for each real spend, in ascending action order; dummy shield/change actions produce no device signature.

Deterministic scripted-flow tests cover all-dummy shield, mixed deshield, private Orchard send, compact signature ordering, malformed requests, signature-count mismatches, and preflight rejection.

Protocol provenance

Validation

  • exact-head Python CI run 30218738108 is green on addc0242847cd7be9c402980498b321b344bef34;
  • deterministic RC18 PCZT contract tests pass locally and in CI;
  • the report catalog validator resolves every 7.15 entry against the exact firmware JUnit artifact;
  • the corrected report regenerates as 306 tests: 303 passed, 3 skipped, 0 failed, 0 pending;
  • Orchard is represented accurately as part of the regular product with 21/21 cataloged checks, not as withheld/default-off;
  • firmware run 30217661980 proved the restored crypto fork projection green before this report-only Python repin;
  • firmware run 30218813159 pins this exact Python head and is fully green as the final dependency-graph rehearsal;
  • that exact run collected 593 Python tests (581 passed, 12 skipped, 0 failed), passed the 187-test screenshot selection, and captured 897 OLED PNGs;
  • its generated PDF reports 303/306 passed, 3 skipped, 0 failed, and Zcash Orchard 21/21 in the regular product.

Review and merge gates

  • singular master-targeting branch contains all intended Python release work;
  • deterministic RC18 PCZT contract tests are mandatory in GitHub CI;
  • current Python GitHub Actions CI is green;
  • release report matches the actual Orchard product shape and current test names;
  • device-protocol add 0x swap signing tests #112 human-reviewed and merged;
  • repin this PR to the resulting canonical device-protocol master commit and verify generated bindings;
  • human approval and merge of this PR;
  • repin firmware #320 to the canonical dependency merge commits and rerun final exact-head CI.

Do not merge this PR until the protocol review and canonical repin are complete.

BitHighlander and others added 30 commits April 28, 2026 19:11
Same firmware as the standalone UDP kkemu binary, loaded in-process via
ctypes. Lets python-keepkey exercise the firmware contract that the
keepkey-vault FFI path imposes — most importantly, the caller-driven
polling model (no daemon thread to call kkemu_poll for you).

- keepkeylib/transport_dylib.py: DylibState (process-wide singleton over
  ctypes-loaded libkkemu) + DylibTransport (one per iface 0/1).
  Pumps kkemu_poll on every read/write so the firmware actually makes
  forward progress on caller turns.
- tests/config.py: KK_TRANSPORT=dylib KK_DYLIB=/path/to/libkkemu.dylib
  routes the same fixture to the FFI transport instead of UDP.
- tests/test_dylib_confirm_flow.py: regression for the confirm-flow
  contract (Initialize, WipeDevice, LoadDevice, GetAddress). Skipped
  unless KK_TRANSPORT=dylib so it won't break the default UDP run.

Reproduces the keepkey-vault hang deterministically: Initialize round-
trips fine, wipe_device hangs because confirm_helper busy-loops on a
ButtonAck the dylib silently consumed but never delivered. Caught in
~10s, no electrobun / bun stack required.

Run:
  cd tests && KK_TRANSPORT=dylib KK_DYLIB=.../libkkemu.dylib \
    PYTHONPATH=..:../keepkeylib python3 -m pytest \
    test_dylib_confirm_flow.py -v
…ntics

The existing test_dylib_confirm_flow covers the caller-driven polling
contract — Initialize / Wipe / LoadDevice / GetAddress — but never asks
the firmware for a layout. Two changes that just landed in the firmware
emulator runtime PR (BitHighlander/keepkey-firmware#217) need functional
coverage that confirm-flow doesn't provide:

1. RINGBUF_CAPACITY in lib/emulator/ringbuf.h was bumped from 32 to
   128. DebugLinkState's 2048-byte `layout` plus the rest of the message
   serializes to ~44 HID reports through the output ring; the previous
   capacity left effective room for 31 reports, so screenshot capture
   truncated mid-layout (msg_debug_write ignores emulatorSocketWrite's
   0-on-full return).

2. fsm_msgDebugLinkGetState in lib/firmware/fsm_msg_debug.h now does a
   single display_refresh() instead of force_animation_start() +
   animate(). The old form overwrote static layouts with stale animation
   frames or no-ops depending on queue state, so screenshots captured
   something different from what the user was seeing.

Both fixes are functionally invisible to the existing test suite. Without
these tests, regressing either change ships green.

This commit adds:

- tests/test_dylib_screenshot.py — four tests:
    * test_layout_round_trip_fits_through_ring   (RINGBUF_CAPACITY)
    * test_layout_repeated_reads_no_truncation   (RINGBUF_CAPACITY)
    * test_layout_stable_across_idle_reads       (canvas semantics)
    * test_layout_features_dont_corrupt_capture  (iface separation)

  Constructs a fresh KeepKeyDebuglinkClient against the dylib singleton
  WITHOUT going through common.KeepKeyTest.setUp — that fixture wipes the
  device on every test and exercises the confirm-flow path that
  test_dylib_confirm_flow is itself a pending regression for. Reading a
  layout doesn't require any of that; we just init and ask DebugLink for
  the home-screen capture.

- tests/config.py — explicit-transport precedence fix:

  Previously HID/WebUSB were always autodetected first. With a real
  KeepKey plugged in, KK_TRANSPORT=dylib was silently overridden — the
  dylib regression suite would either route to hardware or crash on
  hid.pyx. Now the explicit env var (KK_TRANSPORT=dylib) skips hardware
  enumeration entirely, the dylib path runs as requested, and the default
  (no env var set) falls back to the existing UDP behavior.

Verified locally:

  cmake -DKK_EMULATOR=1 -DKK_BUILD_DYLIB=1 -DKK_DEBUG_LINK=ON \
        -DCMAKE_POLICY_VERSION_MINIMUM=3.5 -B build-emu .
  cmake --build build-emu --target kkemulator_dylib

  KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \
    PYTHONPATH=keepkeylib:. python -m pytest tests/test_dylib_screenshot.py
  ======================== 4 passed in 0.36s ========================

Out of scope: SignTx + other multi-step flows that go through
confirm_helper. They share the same hang as test_dylib_confirm_flow's
test_load_device_with_auto_confirm — copying the pattern would just
produce a second red regression for the same underlying firmware bug,
not new coverage. Once the confirm-flow regression goes green, signtx
expansion is a follow-up.
…ANSPORT, split confirm-flow setUp

Three findings from review of PR #14:

#1 (High) test_dylib_confirm_flow used common.KeepKeyTest.setUp which calls
   wipe_device() — the same path the file's pending regression is for.
   Hangs in setUp can't be classified by xfail or interrupted by
   pytest-timeout, so test_features_round_trip ("just Initialize") was
   actually wipe + Initialize. Refactored to construct
   KeepKeyDebuglinkClient directly in setUp (matching test_dylib_screenshot's
   pattern), moved wipe + load_device into the one pending test.

   Tried the reviewer-suggested @pytest.mark.xfail(strict=True) +
   @pytest.mark.timeout combo. pytest-timeout (both signal and thread
   methods) cannot interrupt the C-level kkemu_poll busy-loop — the
   hang locks up the entire test runner instead of failing the test.
   Switched to @unittest.skip with explicit rationale documenting
   exactly that, plus the promotion path: when firmware lands the
   confirm fix, drop the skip; if a future change makes kkemu_poll
   GIL-friendly, switch back to xfail+timeout.

#2 (Medium) tests/config.py treated any non-empty KK_TRANSPORT as
   "explicit" and skipped HID/WebUSB autodetect, but only "dylib" was
   actually handled. A typo like KK_TRANSPORT=dyllib silently fell
   through to UDP with hardware disabled. Now scoped to a
   _KNOWN_TRANSPORTS set; unsupported values raise at config import,
   surfacing typos at test collection time. Verified end-to-end:
   `KK_TRANSPORT=dyllib pytest test_msg_signtx.py` now errors on
   collection with the typo'd value in the message.

#3 (Medium/Low) DylibTransport.ready_to_read appended raw frame bytes
   to read_buffer but DylibTransport._pump_one stripped the leading '?'
   HID marker first. Inconsistent stripping corrupts multi-frame message
   reassembly: _read_headers can scan a stray '?' from one chunk into
   the middle of contiguous payload bytes from another, decoding the
   wrong message-type / length.

   Centralised the read+strip into a private _poll_and_stash helper
   shared by both ready_to_read (no sleep) and _pump_one (sleeps on
   miss). Now the buffer always contains continuation+payload bytes
   only; the leading '?' is stripped at the single point of stashing.
   Trailing HID padding zeros from short messages are still tolerated
   by _read_headers' magic-character search.

Verified locally:

  KK_TRANSPORT=dylib KK_DYLIB=build-emu/lib/libkkemu.dylib \
    pytest tests/test_dylib_screenshot.py tests/test_dylib_confirm_flow.py
  ================== 5 passed, 1 skipped in 0.15s ==================
Wires the ZIP-32 §6.1 seed fingerprint binding into the python-keepkey
client to mirror the firmware-side validation.

  device-protocol submodule
    - URL: keepkey/device-protocol -> BitHighlander/device-protocol
      (zcash work pins to fork master while seed_fingerprint sits in
      long-term review for upstream; revert when upstream merges.)
    - pin: d0b8d80 -> 4337c452 (BitHighlander/master with PR #27 merged).
    - messages_zcash_pb2.py regenerated via docker_build_pb.sh
      (kktech/firmware:v8 → libprotoc 3.5.1, the canonical toolchain).
      Selective regen — other pb2 files are intentionally NOT
      regenerated because they currently include content from
      BitHighlander/device-protocol open PRs (#18 SolanaTokenInfo,
      #19 TRON clear-signing, #20 TON clear-signing, #21
      EthereumTxMetadata). Until those merge, regenerating them
      against current master would back out work that the existing
      python-keepkey client relies on.

  keepkeylib/zcash.py (new)
    calculate_seed_fingerprint(seed) -> 32 bytes
      Pure-Python helper. BLAKE2b-256("Zcash_HD_Seed_FP",
      I2LEBSP_8(len) || seed). Matches the firmware C
      implementation byte-for-byte and the keystone3-firmware
      reference vector
        seed = 000102...1f
        fp   = deff604c246710f7176dead02aa746f2fd8d5389f7072556dcb555fdbe5e3ae3

  keepkeylib/client.py
    zcash_display_address  — add expected_seed_fingerprint kwarg
    zcash_sign_pczt         — add expected_seed_fingerprint kwarg
    Both pass through unchanged when the kwarg is None
    (backward compatible).

  tests/test_msg_zcash_seed_fingerprint.py (new)
    Pure-Python helper:
      - reference vector (Keystone3 cross-check)
      - rejects all-zero, all-0xFF, short, long
    Device-backed:
      - GetOrchardFVK returns non-empty seed_fingerprint
      - fingerprint stable across accounts (bound to seed, not account)
      - DisplayAddress: matching expected_seed_fingerprint succeeds,
        response carries seed_fingerprint
      - DisplayAddress: wrong expected_seed_fingerprint rejected
      - DisplayAddress: omitting expected_seed_fingerprint still works
      - SignPCZT: wrong expected_seed_fingerprint rejected before
        any signing crypto runs
Addresses review of PR #15.

Test structure
  Helper tests (no device) move to a dedicated module:
    tests/test_zcash_seed_fingerprint_helper.py
  This module deliberately does NOT import common, transport, or any
  protobuf bindings, so it runs on a stock dev box:
    pytest tests/test_zcash_seed_fingerprint_helper.py
  The previous file inherited common.KeepKeyTest, whose setUp wipes
  the device — pytest -k 'helper' was never actually offline.

Client wrapper coverage
  Device-backed tests now go through the public client helpers
  (self.client.zcash_display_address(... expected_seed_fingerprint=...)
  and self.client.zcash_sign_pczt(... expected_seed_fingerprint=...))
  rather than building raw protobuf messages with self.client.call().
  Confirms the kwarg pass-through end-to-end.

New test
  test_device_fingerprint_matches_python_helper: cross-checks the
  device-computed fingerprint against the python-keepkey helper for
  the same seed (all-allallall mnemonic, empty passphrase). Ties the
  firmware C, python-keepkey helper, and ZIP-32 §6.1 reference vector
  to the same byte-for-byte output.
feat(zcash): seed_fingerprint client + tests
… ≤ 7.14.0)

Pairs the device, signs a 1550-byte EIP-1559 transaction with the
all-all-all test mnemonic, and asserts that ECDSA recovery against the
canonical type-2 pre-image yields the device's own address.

Catches a firmware/ethereum.c ordering bug present in 7.x.0 .. 7.14.0
where the empty access-list byte (0xC0) — which closes the EIP-1559 RLP
body and must be the last byte fed to keccak before signing — was being
hashed inside ethereum_signing_init() right after the initial 1024-byte
data chunk, BEFORE the host had a chance to send the remaining
EthereumTxAck frames. For any tx whose data exceeded the single-chunk
threshold, the resulting pre-image was:

  keccak( ...header...
          || data_len_prefix
          || data[0..1024]
          || 0xC0           (bug: should be after ALL data)
          || data[1024..end] )

The signature was mathematically valid for that mangled hash so RPCs
accepted the broadcast, but the recovered signer was a wrong-but-
deterministic address. The mempool dropped the tx because the recovered
"from" had no balance / wrong nonce. Production symptom: every Uniswap
Universal Router swap, Permit2 batch, and large multicall hung at
"Confirm in wallet."

Single-chunk transactions (<= 1024 bytes) escaped the bug only by
accident — the misplaced 0xC0 happened to land at the end anyway.

Recovery-based assertion (eth-keys, eth-utils.keccak) — works on any
seed, no golden vectors to capture, the test asserts the actual
invariant: "signature recovers to the signer." Fails on broken
firmware, passes on 7.14.1+.

CI: eth-keys added to the existing pip install line; ships a pure-Python
keccak via eth-utils so no native deps are required.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
requires_message("EthereumTxAck") sends an empty EthereumTxAck as a
discovery probe. The firmware (correctly) rejects that with
Failure_UnexpectedMessage because we're not mid-sign, which skips the
test before the actual assertion runs.

requires_firmware("7.2.1") is sufficient — EthereumTxAck has been part
of the protocol since EIP-1559 support landed in 7.2.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
eth-utils ships keccak via the eth-hash adapter, which auto-selects
between pycryptodome and pysha3 at import time. Without either backend
installed, importing keccak raises:

  ImportError: None of these hashing backends are installed:
  ['pycryptodome', 'pysha3'].

The new EIP-1559 chunked-data regression test imports keccak from
eth_utils to build the canonical type-2 pre-image, so it failed at
import rather than at the recovery assertion. Adding pycryptodome to
the existing pip-install line fixes it.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
KeepKeyTest overrides unittest's assertEqual with a 2-arg version
(common.py:104) that doesn't accept the optional msg parameter — passing
one raises:

  TypeError: KeepKeyTest.assertEqual() takes 3 positional arguments
  but 4 were given

Print the regression diagnostic before asserting instead. Pytest captures
stdout on failure, so the divergence (expected vs recovered, canonical
hash, sig values) still surfaces in the failure report.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
Upstreaming this test as a permanent regression guard rather than a
one-shot bug catcher. Bumping requires_firmware from 7.2.1 (the version
where EIP-1559 support originally landed) to 7.14.1 (the first version
where the access-list ordering bug is fixed) so CI on broken builds
skips this test instead of flagging a known-broken state as a new
regression.

The header comment already documents the affected range
(7.x.0 .. 7.14.0) and the fix landing in 7.14.1.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
…gnition

- Bump device-protocol submodule to 8f80bcd (adds memo field to RippleSignTx)
- Update messages_ripple_pb2.py with memo field (field 7, optional string)
  compatible with protobuf==3.20.3 (old-format serialized_pb descriptor)
- Add test_sign_with_thorchain_memo in test_msg_ripple_sign_tx.py:
  verifies serialized XRPL tx ends with canonical Memos array binary
  (F9 EA 7D <len> <memo> E1 F1), requires firmware 7.14.2
- Add test_msg_ethereum_thorchain_deposit.py:
  covers legacy deposit() 0x1fece7b4 selector, new depositWithExpiry()
  0x44bc937b selector (requires 7.14.2, no AdvancedMode), and verifies
  non-THORChain addresses are still blocked without AdvancedMode
feat(7.14.2): XRP THORChain memo + EVM depositWithExpiry tests
* feat(hive): add Hive blockchain support

- messages_hive_pb2.py — generated from messages-hive.proto (IDs 1600-1603)
- hive.py — get_public_key / sign_tx client helpers
- mapping.py — register HiveGetPublicKey, HivePublicKey, HiveSignTx, HiveSignedTx wire IDs
- client.py — hive_get_public_key / hive_sign_tx methods on ProtocolMixin

* feat(hive): add HiveGetPublicKeys, HiveSignAccountCreate, HiveSignAccountUpdate

- messages_hive_pb2.py: regenerated from updated proto; now includes all 10
  message types (HiveGetPublicKey/Keys, HivePublicKey/Keys, HiveSignTx/ed,
  HiveSignAccountCreate/ed, HiveSignAccountUpdate/ed). Added role field to
  HiveGetPublicKey.

- mapping.py: register wire IDs 1604-1609 for the six new message types.

- hive.py: add get_public_keys(), sign_account_create(), sign_account_update()
  helpers. get_public_key() gains optional role parameter.

- client.py: add hive_get_public_keys(), hive_sign_account_create(),
  hive_sign_account_update() mixin methods with @expect decorators.
…format

Regenerated using protoc from kktech/firmware:v15 (protobuf 3.17.3).
The previous version used the builder API (protobuf 3.20+) which is
incompatible with the 3.20.3 Python runtime pinned in CI.
Test bugs fixed (mirrors BitHighlander/keepkey-firmware alpha CI fixes):
- ETH THORChain deposit: assertIn(sig_v, [27,28]) -> [37,38] (EIP-155 chain_id=1)
- XRP no-memo check: b'\xf9' -> b'\xf9\xea' (0xF9 appears in DER sigs naturally)
- Zcash FVK validation: skipTest until feature lands in firmware
Covers the full Hive message surface (all 5 firmware handlers) using the
standard 12-word seed (mnemonic12, "alcohol ... aisle"):
- HiveGetPublicKey   — active-role key format + 33-byte raw
- HiveGetPublicKeys  — 4 distinct STM role keys; single/bulk agreement
- HiveSignTx         — transfer (op 2), signature recovers to active key
- HiveSignAccountCreate — account_create (op 9), recovers to owner key + binds
  the 4 device keys and account name into the signed bytes
- HiveSignAccountUpdate — account_update (op 10), recovers to owner key

Account-op tests are self-validating: they recover the signer from the 65-byte
device signature over SHA256(chain_id || serialized_tx) and assert it equals
the device-derived key — exercising the device and validating the attestation
digest (keepkey-vault docs/HIVE-ATTESTATION-DIGEST-SPEC.md). No golden vector
required; recovery is an independent check. Hive was the one alpha-firmware
feature with full firmware+client support and zero test coverage.
Addresses review: substring-presence was too weak — a role swap (both keys
present), a creator rewrite, or an amount change could still pass.

Add a cursor-based Graphene reader matching the firmware append_* layout exactly
(incl. account_update's 0x01 optional-present flags, asset symbol padding, and
the no-wrapper memo_key) and rewrite all three signing tests to parse and assert
each field at its expected position + assert_end() for no trailing bytes:
- transfer: from / to / amount / precision / symbol / memo
- account_create: fee / creator / name / owner|active|posting authority slots / memo_key
- account_update: account / each replacement key in its slot / memo_key

Recovery assertions retained. Parser validated offline against hand-built
firmware-format bytes.
test(hive): vendored SLIP-0048 multi-key + account-op device tests
KeepKeyTest overrides assertEqual(self, lhs, rhs) with no msg parameter, so the
3-arg calls raised TypeError. Verified: all 5 tests pass against the feature/hive
emulator (build-emu/bin/kkemu, fw 7.15.0) — get_public_key(s), sign_tx,
sign_account_create, sign_account_update, with signature recovery + full
serialized_tx field-binding.
test(hive): fix assertEqual signature — all 5 hive tests green on emulator
…ests

Integration-test layer for the firmware Insight clear-signing feature
(keepkey-firmware feat/evm-clear-signing-alpha, PR #257).

signed_metadata.py:
- Fix the key_id/slot footgun: serialize_metadata defaults key_id=3, the
  DEBUG_LINK CI slot whose pubkey == firmware METADATA_PUBKEYS[3] (the test
  signer derives to slot 3, NOT slot 0). Production/Pioneer callers must pass
  key_id=0 explicitly. assert_test_key_matches_slot3() pins this invariant.
- sign_metadata fails loud if `ecdsa` is missing (was a silent zero-signature
  that firmware would reject as MALFORMED, disguising the real cause). Signs the
  identical byte range firmware hashes (version..key_id, excl. sig+recovery).
- Add pure-python keccak256 + EIP-155/EIP-1559 RLP sighash helpers so a metadata
  blob's tx_hash binds the REAL signing digest. Cross-checked against the device:
  recovering an existing erc20-approve signature over eth_sighash_legacy yields
  the test mnemonic's m/44'/60'/0'/0/0 address.

test_msg_ethereum_clear_signing.py:
- All vectors use key_id=3.
- New offline (verified green here, 12/12): slot-3 pubkey assertion, key_id=3
  default, keccak256 known vectors.
- New device-class cases (run on the kkemu/DEBUG_LINK emulator): tx_hash binding
  happy path (signs + recovers correct signer), replay reject (metadata bound to
  tx A, sign tx B → "Metadata does not match signed transaction", no signature),
  AdvancedMode gate (OFF+unknown→reject, ON→sign, native ERC-20 unaffected), and
  cancel-clears-metadata (stale blob not reused).

Offline portion verified with PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION=python.
Device-class cases require the firmware emulator (libkkemu) + DEBUG_LINK.
… SECTIONS

The feature ships in the 7.15.0 firmware tree, so gate the device tests at
7.15.0 (was 7.15.1, which left them dormant on the current build).

- test setUp: requires_firmware 7.15.1 -> 7.15.0.
- generate-test-report.py SECTIONS 'V' (EVM Clear-Signing) min_firmware
  7.15.1 -> 7.15.0; add V9-V12 mapping the new device-class tests
  (full tx-hash binding happy path, replay reject, AdvancedMode gate,
  cancel-clears-metadata) with OLED screenshot expectations so the
  report-driven Phase-1 capture includes them.

Verified on the containerized kkemu emulator (docker compose, CI-faithful):
all 28 clear-signing tests pass; OLED screenshots captured for the verified
flow (INSIGHT VERIFIED icon + decoded method/contract/args), the replay
reject, and the AdvancedMode gate.
test(insight): EVM clear-signing integration tests + metadata signer
…tract gate)

Covers the firmware Ethereum signing pre-image / clear-sign correctness guards
(firmware PR BitHighlander/keepkey-firmware#255, merged to alpha):
- type=2 without chain_id is rejected (chain_id over-declared the RLP header)
- type=2 with max_fee but no max_priority_fee still signs (priority is a
  mandatory 0x80-encoded field; Stage 1 and Stage 2 must agree)
- type=2 carrying only gas_price, and legacy carrying max_fee_per_gas, rejected
- a contract clear-sign handler selector with calldata streamed beyond the
  initial chunk signs the full data via the generic path instead of confirming
  a prefix (screen-level assertion verified on-device/emulator)
BitHighlander and others added 22 commits August 21, 2026 15:02
…e exists

R-4.1 -- provider-attested Solana lookup-table accounts -- had four tests and
ZERO presence in the atlas. It is a headline 7.15 feature, and an auditor
reading the PDF would have found no evidence it works, which is the exact
failure the atlas exists to prevent.

S26-S29 catalog them:

  S26  attested accounts are shown AND the blind-sign warning survives
  S27  a signature that does not verify changes nothing
  S28  an attestation cannot be replayed onto another transaction
  S29  with no signer loaded, a well-formed attestation is inert

S26 declares screens, so the filter picks it up and the screenshot leg captures
it -- the atlas is the single source of truth for OLED capture, so cataloguing
it is what makes the evidence exist.

The Solana section background now states the gap this closes. It described the
44-character address fix and stopped, which left S24 ("v0 with address-table
lookups requires AdvancedMode") reading like a design choice rather than the
open problem it is: the device cannot resolve a table it has never seen, so it
routed those transactions to the blind-sign gate and SIGNED ACCOUNTS IT NEVER
SHOWED. S26-S29 are the answer, and the section now says so.

MUST_RUN_MODULES becomes version-aware. It was a flat set, so listing a
7.15-only module would have failed every older-firmware run for a feature that
legitimately cannot exist yet -- module -> the version from which a skip
becomes a failure. test_msg_solana_lut_attestation is listed at 7.15.0: all
four gate on requires_message('LoadClearsignSigner'), so if provider loading
regressed they would all skip and the report would certify a feature it never
exercised. Silence becomes a failure.
feat(report): catalog KKSOLSW1, and make a skip fail where the feature exists
I6 previously MEASURED and documented a gap instead of closing it: turning
AdvancedMode off left the loaded provider in RAM, and the test's own docstring
said so --

    a user who disables AdvancedMode to revoke a provider has not revoked it,
    only suspended it. Re-enabling the policy costs one button press whose
    screen names the policy and never names the signer it silently re-arms.

That also contradicted docs/security/clearsign-provider-tier.md, which lists
"disabling AdvancedMode" among the events that clear identities. One of the two
had to move, and the doc was right: 7.15 is safe without any key-management
programme precisely because trust dies on its own, and a revocation that only
suspends is not one.

The firmware side is four lines in fsm_msgApplyPolicies (firmware PR). This
flips the assertion to match: after the policy round-trip the signer must be
GONE, and the bare-message expected-response list (one ButtonRequest, one
Success) proves trust cannot be restored by a policy toggle at all -- coming
back costs a fresh LoadClearsignSigner consent, which is the screen that names
the alias and fingerprint.

Renamed to say what it now asserts. The atlas entry follows.
test(I6): disabling AdvancedMode must revoke the signer, not suspend it
…USED

The worst kind of catalog defect, and it shipped green in the PDF.

E16b read:

  "x402 EVM EIP-3009 payment clear-signs structured data"
  "The device computes the EIP-712 hashes itself and displays the Base
   Sepolia USDC domain plus every TransferWithAuthorization field: payer,
   recipient, exact value, validity window and nonce."
  screens: ['USDC domain fields', 'TransferWithAuthorization fields']

The test underneath had already been rewritten to assert the opposite:

  with self.assertRaises(CallException) as ctx:
      self.client.ethereum_sign_typed_data(...)
  self.assertIn("Structured EIP-712 disabled", str(ctx.exception))

So the row was PASSING while proving refusal. Anyone reading the report --
which is the point of the report -- would have concluded that x402 EVM payments
clear-sign on this firmware. They do not. 7.14.2 disabled the structured path
because the JSON parser could not guarantee the displayed value was the value
being hashed, and it is still disabled.

The screenshot audit could not catch this. The test captures a PNG from the
apply_policy confirm, so "declared screens but captured none" never fired --
the audit proves a screen was captured, not that it is the screen declared.
Worth knowing about that gate's reach.

E16 was collateral: it described the hashed path as the legacy fallback and
pointed at E16b for "the separate device-parsed path", a path that does not
run. Rewritten to say what is true and load-bearing -- the hashed path is the
ONLY working EIP-712 path, and every signature a KeepKey produces today,
Permit2 approvals included, is blind-signed behind AdvancedMode.

E16b now documents the refusal, keeps the empty screen list (a refusal draws
nothing; the evidence is the Failure on the wire), and records that the V4
reference hashes stay in the fixture as the vector to re-assert when the
streaming implementation lands.
fix(atlas): E16b claimed x402 clear-signs; the test asserts it is REFUSED
The third implementation of the same protocol, and the point of it is that
there are now three: firmware C, hdwallet TypeScript, and this. Two
implementations built to one spec can share a misreading and agree with each
other forever; a third that disagrees turns that into a test failure.

Mirrors packages/hdwallet-keepkey/src/eip712Streaming.ts deliberately, function
for function, so a divergence shows up as a failing test in one of them rather
than as a bad signature in the field. Verified against the TS behaviour:

  uint256 max   -> ff * 32          (the unlimited approval the old path refused)
  int16 -2      -> fffe             (two's complement at the declared width)
  uint0256      -> refused, "Non-canonical integer width"
  uint256[0]    -> refused, "Malformed array dimension"
  uint          -> refused, "Integer type must state its width"
  bytes032      -> refused, "Non-canonical bytes width"

Bindings regenerated with the PINNED protoc 3.5.1 in kktech/firmware:v8, the
way build_pb.sh does it, producing old-style _descriptor.FileDescriptor output.
Not with a modern protoc: that produced AddSerializedFile bindings that the
Alpine 3.8 / Python 3.6 CI container cannot load, and it broke every alpha run
until it was reverted.

Two notes for whoever runs this next:

- the image's `python` is Python 2 and has protobuf; `python3` does not. Install
  it explicitly.
- protobuf 3.20.3 is NOT available for that image's python3 -- the index tops
  out at 4.21.0rc2 with 3.19.6 the last usable 3.x. Anything pinning 3.20.3
  will fail to resolve.
Four cases against real firmware in the emulator, all passing.

The one that matters: the device's own domainSeparator and messageHash for the
canonical Mail/Person document equal the values published by
assets/eip-712/Example.js in ethereum/EIPs.

  domainSeparator f2cee375fa42b42143804025fc449deafd50cc031ca257e0b194a650a912090f
  messageHash     c52c0ee5d84264471806290a3f2c4cecfc5490626bf912d01f240d7a274b371e

Both numbers come from OUTSIDE this repository, and that is the whole point.
The firmware C, the hdwallet TypeScript and the python client were written by
one hand against one reading of the spec, so three of them agreeing proves the
reading is self-consistent and nothing more. A shared misreading would produce
three consistent WRONG answers. It cannot produce these two.

It also exercises the nested-struct path for real: Mail references Person
twice, so the walk pushes a child frame, derives Person's typeHash through its
own closure, folds it to 32 bytes and hands it back to the parent -- machinery
that until now had only been reasoned about. Forty round trips.

The other three:

- an array of structs walks end to end. Arrays hash WITHOUT a typeHash prefix,
  so getting that wrong yields a digest no verifier reproduces rather than an
  error anyone would notice.
- a fixed dimension must match the document. It is part of the type string and
  therefore of typeHash, and the device only ever learns the count from us --
  accept a different one and it signs a document whose type declares another,
  with nothing downstream able to tell.
- AdvancedMode gates the endpoint.

The walk helper answers only what the device asks, in the order it asks. The
host chooses nothing, which is the property under test as much as the hashes
are.
feat(eip712): python client for the device-driven walk
Four entries for the walk, and the first section in this catalog whose expected
values come from OUTSIDE the repository.

TD1 asserts the domainSeparator and messageHash published by
assets/eip-712/Example.js in ethereum/EIPs -- the reference implementation the
spec links to -- republished by Example.sol, by eth-sig-util's V3 and V4
snapshots, and by Mrtenz/eip-712.

That distinction is the section's reason to exist. The firmware C, the hdwallet
TypeScript and the python client were written by one hand against one reading
of the spec, so three of them agreeing proves the reading is self-consistent
and nothing more. A shared misreading produces three consistent WRONG answers.
It cannot produce those two numbers.

Hardware evidence recorded in the notes, 2026-08-21, K1-14AM, unsigned build of
the 7.15 line:

  - nine screens, one per leaf, all correct on operator review
  - 42-character addresses rendered IN FULL. That is the truncation class that
    shipped as a bug at >42 chars, and it is the one claim the emulator's
    framebuffer genuinely cannot settle
  - the published hashes matched on silicon, not just in the emulator
  - device address 0x73d0385F4d8E00C5e6504C6030F47BF6212736A8, identical to
    the emulator, so key derivation agrees too

TD2 covers arrays, which were refused outright until a kilobyte came back from
MAX_DECODE_SIZE: at 13 KB the ARM image missed the linker's runtime-reserve
gate by 204 bytes, at 12 KB it clears by 812. TD3 covers a fixed dimension
being checked against the document -- the count is the only thing the device is
ever told, so accepting a wrong one signs a type nobody declared. TD4 covers
the AdvancedMode gate.

The section id is two characters because all 26 letters were taken. The catalog
keys on a string, so it costs nothing.
feat(atlas): section TD, structured EIP-712, with hardware evidence
U5 asserted 17. 7.16 needs a format bump for passkey credentials, so the test
has to move -- and the whole reason it asserts a LITERAL is that moving it must
cost somebody an argument. Here is the argument, in the docstring where the
next person will find it.

WHY 20 AND NOT 18. 18 was the clear-sign identity block, 19 the PIN-KDF
migration. Both were ACTIVE, not drafted: e109404ee made 19 live and 6bebde7b2
reverted the format to V17 for 7.15. Devices that ran alpha builds in that
window carry blobs stamped 18 or 19 whose layout has nothing to do with
passkeys. Reusing 18 would make 7.16 PARSE one as CTAP2 state -- not refuse it,
not wipe it, misread it. 20 is unburned.

READER CHAIN. V17 -> storage_readV17, restamped. V20 -> storage_readV20. No
reader for 18 or 19: they stay in the ladder because the enum is positional and
removing an entry renumbers everything after it, but a blob stamped with either
falls to the default and the device wipes. Documented behaviour for an
unrecognised format, and strictly better than misparsing one.

ANTI-ROLLBACK. Once a device writes V20, installing 7.15 -- which knows only to
V17 -- maps the blob to StorageVersion_NONE and storage_init resets it. The
device wipes. Normal downgrade behaviour, stated here so it is a known
consequence rather than a field report. A signed upgrade never wipes.

RELEASE NOTE, drafted so it is not invented under time pressure at tag:
  "7.16 changes the on-device storage format to hold passkey credentials.
   Upgrading preserves your wallet. Downgrading to 7.15 or earlier will ERASE
   it -- back up your recovery phrase before downgrading."

New test U5b asserts 18 and 19 have NO dispatch case. The absence is what sends
a burned blob to the wipe path, and an absence is exactly what gets undone by
someone tidying a switch statement. Asserted rather than assumed.
test(storage): argue the V20 bump, and assert 18/19 stay unreadable
My own U5 update asserted STORAGE_VERSION_LAST_SHIPPED == 20, which is wrong in
the specific way storage.h warns about two lines above the constant:

    "Bump this baseline when a release ships, in the release commit, never to
     make a build compile: lowering it is the exact edit that turns every
     upgrade in the field into a silent wipe."

LAST_SHIPPED is the high-water mark of what is IN THE FIELD, not of what sits
in the tree. 7.15 shipped V17; 7.16 has shipped nothing. Asserting 20 would
have forced the next person to raise the baseline to make the test pass -- the
edit the gate exists to prevent, arrived at by way of the gate itself.

The compile-time assert only requires STORAGE_VERSION >= LAST_SHIPPED, and
20 >= 17 holds, so the raise was never needed. It moves in the release commit
that tags 7.16.
fix(test): LAST_SHIPPED stays 17 until 7.16 actually ships
…ce text

Two gates were checking something other than what they claimed, and both
went green on a branch where the thing they gate was absent.

requires_message() asks whether python-keepkey's OWN bindings define a
message. That is a property of the pinned submodule, not of the firmware
under test, so it passes on every branch regardless. The structured
EIP-712 suite used it, and on feat/passkeys-7.16 -- which has no
eip712_stream.c at all -- four tests failed as though the feature were
broken rather than absent.

Replaced with requires_structured_eip712(), which probes the device:
firmware without the walk answers the opening message with
Failure_UnexpectedMessage. Any OTHER failure deliberately does NOT skip,
because "present but misbehaving" must never be mistaken for "absent" --
that is how a skipped test becomes a silent pass.

test_burned_versions_have_no_reader asserted the ABSENCE of a
`case StorageVersion_18:` label, reasoning that falling to the default is
what sends a burned format to the wipe path. There is no default:
storage_fromFlash omits one deliberately so -Werror=switch names any
version we forget. So an unlisted version does not fall anywhere, it
breaks the ARM build -- which is exactly what happened.

Now asserts the real property: the labels exist, and what they dispatch
to is SUS_Invalid with no storage_readVxx behind them. Verified the test
is not vacuous by injecting a reader and watching it fail.
…urce-greps

fix(tests): gate on firmware capability, not host bindings or source text
…0 minutes

The integration job has ended "cancelled" at exactly 30 minutes on every
master run for at least six merges, while a green check named "Integration
Tests" sat next to it. Four defects stacked.

THE HANG. The emulator segfaulted mid-suite -- the service log reads
"Application Version 7.10.0 / Segmentation fault (core dumped)" -- and
transport_udp.py never called settimeout(), so _raw_read() blocked in
recv() until something outside killed the process. 243 of 662 tests ran in
6 seconds, then 29 minutes of nothing. The next file in collection order
is test_msg_ethereum_erc20_uniswap_liquidity.py, which matches the known
Uniswap liquidity defect, so the crash is probably reproducible and this
change is what will let anyone see it.

Now raises IOError naming the device, the port and the timeout. Verified
against a socket that is BOUND but never answers -- a crashed emulator
whose container still holds the port, which is the case ICMP does not
cover: 3.0s and a named error, where before it blocked indefinitely.
KK_UDP_TIMEOUT overrides; 0 disables for interactive debugging.

THE FALSE GREEN. mikepenz/action-junit-report was given check_name, which
makes it publish a SEPARATE check run through the Checks API. Its
require_tests default is 'false', so the absent junit.xml a killed pytest
leaves behind reported conclusion:success -- created already-completed, so
started_at == completed_at, the zero duration. Now annotate_only with
require_tests and fail_on_failure on, so it annotates and never mints a
verdict of its own.

CANCELLED IS NOT A FAILURE. A job-level timeout ends the job "cancelled",
which reads as an infrastructure blip; the "Fail on test failure" step
correctly evaluated to failure and was overridden. pytest now carries a
10-minute STEP timeout, so a hang is reported as what it is, with the job
backstop lowered 30 -> 14.

CYCLE TIME. Added a concurrency group with cancel-in-progress so a new
push supersedes the old run rather than both burning a runner.

NOT FIXED HERE, and it is the reason none of this was caught: master has
NO branch protection at all -- `gh api .../branches/master/protection`
returns 404 and rulesets is []. A required check whose conclusion is
"cancelled" would have blocked every one of these merges.
…ing Docker

Two more defects behind the same 30-minute wall, both found by tracing the
crash rather than by reading the workflow.

THE SEGFAULT IS A STALE IMAGE, NOT A FIRMWARE BUG. CI's service container
is `kktech/kkemu:latest`, a FLOATING tag whose current image was built
2026-03-12 and reports firmware 7.10.0 -- six minor versions behind the
suite that runs against it. 7.10.0's zxliquidtx.c formats the Uniswap
deadline with ctime(); the test vectors carry a JavaScript MILLISECOND
timestamp, which as time_t is ~year 53234, and on the image's Alpine 3.8
musl that segfaults. Reproduced inside the image directly. Current
firmware does not call ctime at all -- it snprintf's PRIu64 -- and all
three tests PASS against a locally built 7.15.0.

So the tests were right the whole time. Worse, 80 tests gate on
requires_firmware("7.15.0") and have been SILENTLY SKIPPING against that
image, and it predates -DKK_CLEARSIGN_TEST_ROOT=ON entirely.

Added a version gate that runs before pytest and fails closed if the
emulator is older than the suite. "It answered a ping" is not "it is the
right firmware", and a floating tag cannot tell you which you have.

A TEST WAS KILLING THE DOCKER DAEMON. test_msg_session_trust_lifetime's
_power_cycle() finds "the process bound to udp/11044" with lsof and kills
it. When the emulator runs in a container that process is the port
forwarder -- docker-proxy or dockerd on Linux, com.docker.backend on
macOS -- in a different pid namespace from kkemu, which never appears in
the host namespace at all. Killing it does not reboot anything: it removes
the port forward, and every later test blocks forever on a socket that
will never answer.

It took Docker Desktop down three separate times on this machine tonight
while we were building firmware, which is how it was found.

_emulator_process() now refuses to return any pid whose basename is not
kkemu, so _power_cycle takes its documented skip instead. No coverage is
deleted and the uniswap tests are untouched -- they are correct.

Measured healthy suite runtime: 83.64s for 656 tests, 4 failed, 627
passed, 31 skipped. The job budget was 30 minutes. pytest now bounded at 8
minutes, job backstop 15.
The job pulled kktech/kkemu:latest -- a FLOATING tag whose image was
built 2026-03-12 and reports firmware 7.10.0, five months and six minor
versions behind the suite running against it. That one fact caused every
symptom: 80 tests gating on requires_firmware("7.15.0") skipped in
silence, and one unskipped test drove a ctime() path that segfaults on
that image and does not exist in current firmware.

Publishing a fresher image would only reset the clock and wait for the
same failure. Building from source removes the class -- the emulator under
test is, by construction, the firmware the tests were written against, and
there is nothing to publish, pin, or remember to refresh.

python-keepkey is a submodule OF the firmware repo, so the job now checks
out BitHighlander/keepkey-firmware@alpha alongside it and overlays THIS
checkout of python-keepkey over the pinned one -- otherwise it would test
whatever revision firmware happens to pin rather than the PR under review.

The version gate from the previous commit stays. It is now a belt-and-
braces check rather than the only defence, and it still earns its place:
it catches the day someone points this at a branch that has regressed.

Cost: one emulator build per run, bounded at 20 minutes. Measured healthy
suite runtime is 83.64s, so the build dominates -- and that is the right
trade against a job that spent 30 minutes producing no signal at all.
`submodules: recursive` on the firmware checkout tries to clone
trezor-firmware's micropython vendor tree, whose lib/lwip lives on
git.savannah.gnu.org. That host serves DUMB HTTP and cannot satisfy the
shallow clone actions/checkout asks for:

  fatal: dumb http transport does not support shallow capabilities
  fatal: Failed to recurse into submodule path 'deps/crypto/trezor-firmware'

Nothing in the emulator build needs micropython. The firmware repo's own
CI inits exactly the paths it needs, non-recursively, for this same
reason -- so do that here. deps/python-keepkey is supplied by the overlay
step instead, which is the point of the overlay: test THIS checkout, not
whatever revision firmware pins.
…m the firmware tree

Two failures the emulator-from-source build finally exposed. Both were
always there; the job never got far enough to show them.

requires_structured_eip712() referenced _proto.Failure_UnexpectedMessage.
messages_pb2 has no such attribute -- the FailureType enum is generated
into types_pb2 -- so the helper raised AttributeError and took all four
structured EIP-712 tests down with it. My error, from the commit that
added the helper.

Worth recording alongside it: I claimed in that commit that
requires_message() "only asks whether python-keepkey's own bindings define
a message". That is wrong. It scans the modules AND then probes the
device, skipping on Failure code 1. I stopped reading at the module scan.
The helper is still the better gate -- it names the capability instead of
a message and does not depend on serialising an empty probe -- but it is
an improvement, not a fix for something broken.

The storage-version-gate tests assert against lib/firmware/storage.c,
which they locate by walking UP from the test directory. Run from a
standalone python-keepkey checkout there is no firmware above them and
five tests failed claiming the sources were missing. pytest now runs from
the OVERLAID copy inside the firmware tree, where they resolve -- which
is also the copy the emulator was built from, so the tests and the device
now come from one tree rather than two.
BitHighlander and others added 2 commits August 22, 2026 00:56
…anch

python-keepkey is ONE submodule shared by every firmware branch, and CI
now builds the emulator from whichever branch is under test. So a test
pinned to one branch's version reports a failure whose only cause is
which branch you are on. Two did:

  test_active_flash_format_is_v20            assertEqual(20, version)
  test_burned_versions_are_dispatched...     "case StorageVersion_18:"

Both true on the passkeys branch, both FALSE on the 7.15 line, where
STORAGE_VERSION is 17 and nothing is burned. A third, at the reboot test,
was invisible only because CI has no emulator -- and pk-fix already
carried a local patch flipping its 17 to 20, so the rot was being papered
over branch by branch.

A test that reads a source file has to assert properties of what it read.
The ladder, the burned set, LAST_SHIPPED and which versions have readers
are now all derived per tree.

Burnedness cannot be inferred from storage.c alone: deleting the reader
for a SHIPPED version would silently reclassify it as burned and the
suite would bless the wipe. So two independent files are cross-checked --
storage_versions.inc DECLARES burned, storage.c DEMONSTRATES it (returns
SUS_Invalid, no reader) -- and set equality between them is asserted.
test_no_shipped_version_is_burned is the anchor: burned intersected with
[1..LAST_SHIPPED] must be empty, so the declaration can never authorise
wiping a format that reached hardware.

One number is still written down, STORAGE_VERSION_LAST_SHIPPED_FLOOR = 17,
and it is a FLOOR rather than an equality on purpose. 7.15 shipping V17 is
finished history and cannot become false, so it survives 7.16 raising the
constant. assertEqual(17, last_shipped) was the wrong shape: it goes false
the day 7.16 ships, so it rots and gets "fixed" by whoever it inconveniences
-- and lowering LAST_SHIPPED is the highest-severity item in
docs/StorageVersionGate.md, with both operands of its static assert living
in the same header where one commit reaches both.

Verified on BOTH trees from one file: 10 passed / 5 skipped against the
7.15 line, 15 passed against the 7.16 line. Not vacuous: 10 mutations
injected into throwaway copies, 9 fail loudly; the one that passes is a
complete deliberate bump (header + ladder + case + reader), which is
exactly what should pass.
fix(ci): a crashed emulator fails in seconds instead of hanging 30 minutes
BitHighlander added a commit to BitHighlander/keepkey-firmware that referenced this pull request Aug 22, 2026
The previous pin carried a storage-version gate that asserted
STORAGE_VERSION == 20 and the literal "case StorageVersion_18:". Both are
true on the 7.16 passkeys branch and both are FALSE here, where
STORAGE_VERSION is 17 and nothing is burned -- so develop was pinning a
test suite guaranteed to fail against its own firmware.

006142da70e4 derives the ladder, the burned set and LAST_SHIPPED from the
tree under test instead. Verified green on both lines from one file: 10
passed / 5 skipped here, 15 passed on the 7.16 tree.

It also carries the integration-CI repair, which matters for a release
branch: that job had been ending "cancelled" at exactly 30 minutes with
zero assertions run, behind a check that reported success. It now
finishes in 2m46s with 636 passed, 32 skipped, 0 failed, against an
emulator built from current firmware rather than a five-month-old
published image.

Still the head of the open upstream PR keepkey/python-keepkey#197, which
was fast-forwarded to this commit first, so the pin stays resolvable for
an upstream reviewer.
BitHighlander added a commit to BitHighlander/keepkey-firmware that referenced this pull request Aug 22, 2026
The previous pin carried a storage-version gate that asserted
STORAGE_VERSION == 20 and the literal "case StorageVersion_18:". Both are
true on the 7.16 passkeys branch and both are FALSE here, where
STORAGE_VERSION is 17 and nothing is burned -- so develop was pinning a
test suite guaranteed to fail against its own firmware.

006142da70e4 derives the ladder, the burned set and LAST_SHIPPED from the
tree under test instead. Verified green on both lines from one file: 10
passed / 5 skipped here, 15 passed on the 7.16 tree.

It also carries the integration-CI repair, which matters for a release
branch: that job had been ending "cancelled" at exactly 30 minutes with
zero assertions run, behind a check that reported success. It now
finishes in 2m46s with 636 passed, 32 skipped, 0 failed, against an
emulator built from current firmware rather than a five-month-old
published image.

Still the head of the open upstream PR keepkey/python-keepkey#197, which
was fast-forwarded to this commit first, so the pin stays resolvable for
an upstream reviewer.
BitHighlander and others added 2 commits August 22, 2026 01:49
The report catalog names the test that evidences each requirement, so
renaming a test orphans its section. Three were left dangling:

  U5  test_active_flash_format_is_v20        -> missing
  U5b test_burned_versions_have_no_reader    -> missing
  U8  test_every_ladder_version_has_a_reader -> missing

They are not a mechanical rename, because all three encoded the same
false premise: that an unhandled storage version "falls out of the switch"
to a default. storage_fromFlash() has NO default case, deliberately, so
that -Werror=switch names any version nobody handled. An unlisted version
does not fall anywhere -- it fails the ARM build, which is what actually
happened on the passkeys branch.

  U5  -> test_last_shipped_never_moves_backwards. The role U5 described --
         "an independent witness for the number the whole gate turns on",
         because the static assert compares two constants in one header
         that one commit can raise together -- is now the LAST_SHIPPED
         ratchet. Its old title asserted V20, which is true on 7.16 and
         false on 7.15; the ratchet is true on both.
  U5b -> test_burned_versions_are_dispatched_to_the_wipe_path. The label
         must EXIST; what must not exist is a reader behind it.
  U8  -> test_every_shipped_version_has_a_reader. Scoped to SHIPPED on
         purpose: a burned format legitimately has none, so "every ladder
         version has a reader" would make burning one impossible to
         express. test_no_shipped_version_is_burned is what stops that
         scoping becoming a loophole.

Verified every catalog reference resolves to a test that exists -- 280
entries, all green -- rather than only the three I touched.
fix(atlas): repoint three catalog entries, and correct what they claim
BitHighlander added a commit to BitHighlander/keepkey-firmware that referenced this pull request Aug 22, 2026
The previous pin carried a storage-version gate that asserted
STORAGE_VERSION == 20 and the literal "case StorageVersion_18:". Both are
true on the 7.16 passkeys branch and both are FALSE here, where
STORAGE_VERSION is 17 and nothing is burned -- so develop was pinning a
test suite guaranteed to fail against its own firmware.

006142da70e4 derives the ladder, the burned set and LAST_SHIPPED from the
tree under test instead. Verified green on both lines from one file: 10
passed / 5 skipped here, 15 passed on the 7.16 tree.

It also carries the integration-CI repair, which matters for a release
branch: that job had been ending "cancelled" at exactly 30 minutes with
zero assertions run, behind a check that reported success. It now
finishes in 2m46s with 636 passed, 32 skipped, 0 failed, against an
emulator built from current firmware rather than a five-month-old
published image.

Still the head of the open upstream PR keepkey/python-keepkey#197, which
was fast-forwarded to this commit first, so the pin stays resolvable for
an upstream reviewer.
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