diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 70d957f0..66bc996b 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -401,6 +401,15 @@ def parse_junit(path): # Tests whose whole point is the ordered on-device review sequence — render # every review screen in order (who/what/why), not a single "best" thumbnail. FULL_SEQUENCE_TESTS = { + # The additive invariant IS an ordered-sequence claim: the decoded screens + # are additional and the baseline raw review still follows them. Showing a + # best-of-3 sample would hide exactly the thing being proved. + ('test_msg_ethereum_clearsign_additive', + 'test_successful_decode_still_runs_the_raw_review'), + ('test_msg_ethereum_clearsign_additive', + 'test_v2_schema_decode_still_runs_the_raw_review'), + ('test_msg_ethereum_clearsign_additive', + 'test_failed_signature_falls_back_to_the_unverified_review'), ('test_msg_ethereum_clear_signing', 'test_binding_happy_path_signs_and_recovers'), ('test_msg_ethereum_clear_signing', 'test_clearsign_erc20_approve_unlimited'), ('test_msg_ethereum_clear_signing', 'test_clearsign_uniswap_v2_eth_to_token'), @@ -487,7 +496,7 @@ def _arg_shown(a): _V_CATALOG_TESTS = _v_catalog_tests(start_id=17) SECTIONS = [ - ('S', 'Display Binding - What the Device Signs Is What It Shows', '7.14.2', + ('J', 'Display Binding - What the Device Signs Is What It Shows', '7.14.2', 'The 7.14.2 security release changed what reaches the OLED on the signing paths. Every ' 'defect it fixed was a case of the device hashing bytes it never rendered, or rendering ' 'text it could not vouch for. These tests exist to capture those screens: a passing wire ' @@ -508,7 +517,7 @@ def _arg_shown(a): 'and their evidence is the Failure on the wire plus the absence of a ButtonRequest.', ], [ - ('S1', 'test_msg_ethereum_erc20_0x_signtx', 'test__sign_transformERC20', + ('J1', 'test_msg_ethereum_erc20_0x_signtx', 'test__sign_transformERC20', '0x transformERC20 raw disclosure', 'A 1480-byte transformERC20 payload exceeds one 1024-byte chunk. The device must NOT ' 'clear-sign it as a token swap, because the bytes past the initial chunk are hashed ' @@ -516,18 +525,18 @@ def _arg_shown(a): 'count shown must be the FULL length (1480), not the chunk length (1024) - a short ' 'count would under-report what is being signed.', ['Raw contract data screen showing the full byte count']), - ('S2', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_0x_swap_ERC20_to_ETH', + ('J2', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_0x_swap_ERC20_to_ETH', '0x sellToUniswap names both assets', 'Clear-signing is only honest when BOTH token words resolve to known assets. This ' 'payload resolves (USDC -> ETH) and must name both sides with real amounts. The ' 'failure this guards is a screen naming a DEX while showing no amount.', ['Swap screen naming both assets and amounts']), - ('S3', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_longdata_swap', + ('J3', 'test_msg_ethereum_erc20_0x_signtx', 'test_sign_longdata_swap', 'Long 0x calldata stays disclosed', 'Calldata spanning multiple chunks must not silently lose its tail from the display ' 'while remaining inside the signature.', ['Contract data screen']), - ('S8', 'test_msg_ethereum_signing_guards', + ('J8', 'test_msg_ethereum_signing_guards', 'test_contract_handler_streamed_calldata_signs_full_data', 'Streamed calldata is fully covered', 'Calldata delivered across several chunks must be hashed in full and disclosed in full. ' @@ -536,25 +545,25 @@ def _arg_shown(a): 'screen can be captured for it yet - the screenshot list stays empty until the gate ' 'opens, rather than declaring an expectation nothing can satisfy.', []), - ('S9', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id', + ('J9', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id', 'Omitted chain_id is refused before any screen', 'Without a chain_id the device cannot name the network, and a signature would be ' 'pre-EIP-155 - replayable on every EVM chain. The refusal happens before the first ' 'confirm(), so NO screen is drawn and no ButtonRequest is emitted. The empty ' 'screenshot list below is the assertion.', []), - ('S10', 'test_verify_typed_data', 'test_structured_eip712_is_refused', + ('J10', 'test_verify_typed_data', 'test_structured_eip712_is_refused', 'Structured EIP-712 is closed by default', 'The legacy JSON parser could not guarantee that every displayed value was the ' 'canonical value being hashed, and one screen took its title from the attacker-supplied ' 'domain name. The feature is withdrawn rather than shipped with a screen it could not ' 'vouch for: zero screens, refusal on the wire.', []), - ('S11', 'test_msg_binance_sign_tx', 'test_transfer', + ('J11', 'test_msg_binance_sign_tx', 'test_transfer', 'Binance denom renders in full', 'A long denom must render completely and must not overflow the formatting buffer.', ['Transfer screen showing the full denom']), - ('S12', 'test_msg_ping', 'test_ping_long_body_is_paged', + ('J12', 'test_msg_ping', 'test_ping_long_body_is_paged', 'A long body is paged, not clipped', 'A body that will not fit one screen is shown across several, with the page number ' 'in the title. Before 7.14.2 the device drew what fitted and stopped - no ellipsis, ' @@ -563,7 +572,7 @@ def _arg_shown(a): 'remainder is now actually reachable. The press DURATIONS (click to page, hold to ' 'approve) are not assertable in an emulator with no physical button.', ['Numbered page screens covering the whole body']), - ('S13', 'test_msg_ping', 'test_ping_short_body_is_not_paged', + ('J13', 'test_msg_ping', 'test_ping_short_body_is_not_paged', 'A body that fits is not paged', 'The control for S12. A fitting body must still take exactly one screen with an ' 'unnumbered title - otherwise a pager that numbered every confirmation, making ' @@ -2229,7 +2238,7 @@ def _arg_shown(a): ('D6', 'test_msg_bip85', 'test_bip85_invalid_word_count', 'Invalid count rejected', 'Word counts other than 12/18/24 are refused.', []), ]), - ('D', 'Display Disclosure - What Is Shown Is What Is Signed', '7.14.2', + ('Q', 'Display Disclosure - What Is Shown Is What Is Signed', '7.14.2', 'The single property behind every display/sign divergence found in the 7.14.2 audit: two ' 'requests whose SIGNED BYTES differ must not produce IDENTICAL screens. If two payloads render ' 'the same pixels, whatever separates them was invisible when the user approved, and the ' @@ -2250,38 +2259,528 @@ def _arg_shown(a): 'the property; the failure under test is signing it while looking identical to the benign case.', ], [ - ('D1', 'test_msg_display_disclosure', 'test_bytes_past_an_embedded_nul_are_disclosed', + ('Q1', 'test_msg_display_disclosure', 'test_bytes_past_an_embedded_nul_are_disclosed', 'Bytes after a NUL are shown', 'A protobuf bytes field is not a NUL-terminated string. Rendering it with "%s" stops at the ' 'first NUL while the signature covers message.size bytes, so a payload like ' '"benign login\\0 AND APPROVE TRANSFER" displays only the benign prefix. This asserts the ' 'two payloads do not present identically.', ['Message screen, plain', 'Message screen, NUL-suffixed']), - ('D2', 'test_msg_display_disclosure', 'test_bytes_past_whitespace_padding_are_disclosed', + ('Q2', 'test_msg_display_disclosure', 'test_bytes_past_whitespace_padding_are_disclosed', 'Whitespace cannot hide signed text', 'Whitespace is the cheapest way to push content out of view: a leading space costs zero ' 'pixels once a line has wrapped, so padding can make an over-long body measure as fitting ' 'while the tail is neither shown nor dropped from the signature.', ['Message screen, short', 'Message screen, padded']), - ('D3', 'test_msg_display_disclosure', 'test_bytes_past_the_first_screen_are_disclosed', + ('Q3', 'test_msg_display_disclosure', 'test_bytes_past_the_first_screen_are_disclosed', 'Content beyond one screen is not silently dropped', 'Whether the device pages the remainder, states how much is hidden, or refuses is not ' 'asserted - only that a long payload with a distinct tail does not look identical to a ' 'short one.', ['Message screen, fits', 'Message screen, overlong']), - ('D4', 'test_msg_display_disclosure', 'test_newline_padding_does_not_collapse_the_screen', + ('Q4', 'test_msg_display_disclosure', 'test_newline_padding_does_not_collapse_the_screen', 'Line counting cannot be overflowed', 'Line counting is a security boundary once it gates a truncation warning. A body carrying ' 'many newlines exercises the row counter rather than the character count; if that counter ' 'wraps, an arbitrarily long body reports as fitting.', ['Message screen, one line', 'Message screen, newline-padded']), - ('D5', 'test_msg_display_disclosure', 'test_signing_shows_at_least_one_screen', + ('Q5', 'test_msg_display_disclosure', 'test_signing_shows_at_least_one_screen', 'Guard: the comparisons are not vacuous', 'Every other test in this section compares screen sequences. A flow that produced no ' 'ButtonRequest would make two payloads compare equal as empty tuples and pass while showing ' 'the user nothing. This asserts at least one non-blank screen is actually displayed.', ['Control message screen']), ]), + ('F', 'Clear-Sign Provider Context - Additive Invariant', '7.15.0', + 'Clear-signing is annotation, not authority. A provider signer is loaded at runtime by the ' + 'host (LoadClearsignSigner: RAM-only, user-confirmed, dropped on reboot) and is NOT verified ' + 'by KeepKey, so its decoded who/what/why screens must be ADDED to the ordinary unverified ' + 'review, never substituted for it. A runtime schema that could suppress the amount screen, ' + 'the raw-calldata screen or the fee screen would be a screen-substitution oracle: a friendly ' + '"supply 10.5 DAI to Aave" on the glass with arbitrary bytes under the signature. ' + 'lib/firmware/ethereum.c forces needs_confirm and data_needs_confirm back to TRUE whenever ' + 'the metadata came from a loaded signer; the else-branch that is allowed to suppress is ' + 'reserved for a future firmware-PINNED key and has no reachable input in this build. Every ' + 'test below proves this by MEASUREMENT rather than by model: it signs the same transaction ' + 'twice against the same device state, records the raw 2048-byte OLED framebuffer at every ' + 'ButtonRequest, and requires the no-metadata baseline frames to reappear byte-for-byte as the ' + 'tail of the clear-signed run. Adjacent sections cover "no metadata -> blind sign", replay ' + 'rejection and cancel-clears-metadata; none of them proves the raw review FOLLOWS a ' + 'SUCCESSFUL decode.', + [ + 'ADDITIVE RULE: a runtime provider may ADD screens. It may never REMOVE one.', + '', + 'Measured on the Aave V3 supply() fixture (132 bytes of real ABI calldata, AdvancedMode on):', + '- baseline, no metadata : 3 screens - Send / Confirm Ethereum Data / Transaction', + '- v1 metadata VERIFIED : 10 screens - Identity, "Call: supply", Contract, one screen', + ' per attested argument (4), THEN the same 3 baseline screens', + '- v2 static schema VERIFIED : 13 screens - 7 decoded, then the same 3 baseline screens', + '- signature fails to verify : 3 screens - byte-identical to the baseline. The device does', + ' NOT refuse, and shows NO partial decoded information.', + '', + 'The tail comparison is a byte-for-byte framebuffer match, so it is immune to pagination and', + 'to value-dependent rendering: whatever the baseline drew, the clear-signed run must draw.', + '', + 'Phase 1 ships with every built-in verification slot zeroed, so a VERIFIED blob can only come', + 'from a runtime-loaded signer and the suppression branch cannot be reached. F5 has an EMPTY', + 'screenshot list on purpose: rejecting metadata draws nothing at all.', + ], + [ + ('F1', 'test_msg_ethereum_clearsign_additive', + 'test_successful_decode_still_runs_the_raw_review', + 'A successful decode adds screens, replaces none', + 'The headline invariant. A runtime provider clear-signs a real Aave V3 supply() call, and ' + 'the decoded identity/method/contract/argument screens are followed by the SAME ' + 'amount, raw-calldata and fee screens the device draws with no metadata at all - proven by ' + 'signing the identical transaction twice and requiring the three baseline frames to ' + 'reappear byte-for-byte at the tail. The signature still recovers to this device over this ' + 'exact digest, so the screens shown were bound to the transaction signed.', + ['Identity screen naming the loaded signer and its fingerprint', + 'Decoded argument screens (protocol / asset / amount / onBehalfOf)', + 'Raw contract data screen, unchanged from the baseline', + 'Fee screen']), + ('F2', 'test_msg_ethereum_clearsign_additive', + 'test_v2_schema_decode_still_runs_the_raw_review', + 'v2 static schema is additive too', + 'v2 is where suppression would be most tempting: the blob attests a decode shape and no ' + 'tx_hash, so the reserved branch drops the raw review outright and keeps the amount screen ' + 'only if the schema moves value. For a runtime signer that branch is not taken. Decoded ' + 'against the Aave fixture rather than an ERC-20 transfer on purpose - a recognized token ' + 'contract has no raw-data screen in its own baseline, so it could not show that the raw ' + 'review survives.', + ['Decoded screens with values read from the calldata being signed (amount: 10.5 DAI)', + 'Raw contract data screen, unchanged from the baseline', + 'Fee screen']), + ('F3', 'test_msg_ethereum_clearsign_additive', + 'test_failed_signature_falls_back_to_the_unverified_review', + 'A payload that fails to verify falls back, it does not refuse', + 'One tampered byte inside the signed region makes the blob MALFORMED. The device must then ' + 'behave exactly as if no metadata had ever been sent: the ordinary unverified review, no ' + 'refusal, and no partial decoded information on the glass. The assertion is that the whole ' + 'signing run is frame-for-frame identical to the baseline - any decoded screen would be a ' + 'frame the baseline does not contain.', + ['Amount/recipient screen identical to the no-metadata baseline', + 'Raw contract data screen identical to the no-metadata baseline', + 'Fee screen identical to the no-metadata baseline']), + ('F4', 'test_msg_ethereum_clearsign_additive', + 'test_no_runtime_slot_can_reach_the_suppression_branch', + 'Every runtime key slot stays additive', + 'The suppression branch is gated on a signer that is NOT runtime-loaded. All four key slots ' + 'are loaded at runtime and each in turn produces a VERIFIED decode that is still followed ' + 'by the complete baseline review, so no slot is a privileged one. A slot that suppressed ' + 'would surface here as a missing tail frame.', + ['Identity screen for each loaded slot', + 'Raw contract data screen after every slot\'s decode']), + ('F5', 'test_msg_ethereum_clearsign_additive', + 'test_no_slot_verifies_without_a_runtime_load', + 'No firmware-pinned signer exists to suppress anything', + 'The complementary half. With no signer loaded, a correctly signed blob addressed to each ' + 'of the four slots comes back MALFORMED: this build carries no built-in verification key, ' + 'so the branch that may suppress the raw review has no reachable input. Sending metadata ' + 'draws no screen, so the empty screenshot list below is the assertion.', + []), + ]), + ('I', 'Session and Trust Lifetime', '7.15.0', + 'Clear-signing works by trusting somebody else. A provider key loaded with LoadClearsignSigner ' + 'decides which transactions the device is willing to describe in words, and AdvancedMode decides ' + 'whether the device will sign contract data it cannot describe at all. Neither is a decision a ' + 'user should still be living with tomorrow. Both are session state by design: AdvancedMode is a ' + 'policy the storage writer refuses to persist, and loaded signers are RAM slots that no code path ' + 'writes to flash. Design intent is not evidence, so this section revokes them for real - it ' + 'restarts the firmware process with its flash image intact, which is a reboot and not a wipe, and ' + 'watches what comes back.', + [ + 'LIFETIME RULE: trust granted by a button press dies with the session that granted it.', + '', + 'The two claims under test, and where they live:', + '- AdvancedMode is session-scoped. Storage flags bit 12 is written as zero and ignored on', + ' read at four sites in storage.c; policy.h calls the bit BURNED because firmware <= 7.15', + ' would read a reused bit as "blind signing enabled".', + '- Loaded signers are RAM only. session_clear() calls signed_metadata_clear_signers()', + ' unconditionally, so Initialize and ClearSession both drop them; a reboot drops them', + ' because they were never anywhere else.', + '', + 'The asymmetry between the two is deliberate and is asserted, not assumed: Initialize drops', + 'the signer but LEAVES AdvancedMode armed (hosts send Initialize before nearly every', + 'operation, so disarming there would demand a button press each time), while ClearSession', + 'drops both.', + '', + 'READING THE POWER-CYCLE TESTS: on the emulator flash_erase_word() is compiled out, so the', + 'sectors that storage_commit() abandons keep their "stor" magic and find_active_storage()', + 'may boot into a record two commits stale. A test that ignored this would read every policy', + 'back OFF for the wrong reason and pass against firmware that persisted it. Each power-cycle', + 'test therefore sets a MARKER policy (Experimental) after the state under test and commits', + 'until every sector carries it; the marker coming back is what licenses any conclusion about', + 'AdvancedMode, and the surviving seed and label are what distinguish a reboot from a wipe.', + ], + [ + ('I1', 'test_msg_session_trust_lifetime', + 'test_advanced_mode_is_off_after_power_cycle', + 'AdvancedMode does not survive a reboot', + 'AdvancedMode and Experimental are neighbouring bits of the same storage flags word, set by ' + 'the same ApplyPolicies message and written by the same storage_writeStorageV16Plaintext ' + 'call. Both are turned on, Experimental second, and the firmware is restarted with its flash ' + 'image untouched. Experimental must come back - proving flash survived AND that the record ' + 'read at boot was written while AdvancedMode was armed - and AdvancedMode must be OFF. A ' + 'device that inherited the policy from flash would boot with blind signing already enabled ' + 'and no confirmation, which is precisely why bit 12 was retired.', + ['Enable Policy: AdvancedMode', 'Enable Policy: Experimental (marker, four commits)']), + ('I2', 'test_msg_session_trust_lifetime', + 'test_advanced_mode_survives_initialize_but_not_clear_session', + 'Initialize keeps the policy, ClearSession revokes it', + 'session_clear_impl() disarms AdvancedMode only when clear_pin is set: ClearSession passes ' + 'true, Initialize passes false. This pins the asymmetry from both sides. If Initialize ever ' + 'started disarming, every host that sends it before an operation would demand a fresh ' + 'confirmation and the policy would be unusable; if ClearSession ever stopped, an explicit ' + 'lock would leave the blind-signing capability armed behind it.', + ['Enable Policy: AdvancedMode']), + ('I3', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_initialize', + 'Session teardown drops the loaded signer', + 'A signer is loaded, verified live, and then Initialize is sent. The metadata blob that was ' + 'VERIFIED becomes MALFORMED. AdvancedMode is asserted still ON immediately before that probe, ' + 'so the policy gate cannot be what refused it - the slot is empty. An ordinary GetFeatures is ' + 'sent first as the negative control: if merely exchanging messages dropped signers, the ' + 'teardown assertion would be proving nothing.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey"]), + ('I4', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_clear_session', + 'ClearSession revokes both halves of the trust', + 'ClearSession is the explicit lock, and it must take the provider key with it. Straight ' + 'afterwards the metadata message is refused outright ("AdvancedMode required") - that Failure ' + 'is the policy gate and says nothing about the slot, so the policy is re-armed with a bare ' + 'ApplyPolicies (no Initialize, which would clear the slot by itself) and the blob probed ' + 'again. MALFORMED is the assertion: the signer itself is gone.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Home screen at the refusal - the AdvancedMode gate draws no screen of its own', + 'Enable Policy: AdvancedMode (re-armed to isolate the slot)']), + ('I5', 'test_msg_session_trust_lifetime', 'test_signer_dropped_by_power_cycle', + 'Reboot drops the loaded signer', + 'RAM-only should make this true by construction, but "by construction" is exactly what a ' + 'persistence bug breaks, and the report should carry the reboot rather than infer it. The ' + 'marker policy is set AFTER the signer is loaded, so the record the device boots into is one ' + 'that was written while the signer was live - the record a firmware that persisted signers ' + 'would have persisted them into. Seed, label and marker all come back; the signer does not.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Enable Policy: Experimental (marker, four commits)', + 'Enable Policy: AdvancedMode (re-armed after the reboot to isolate the slot)']), + ('I6', 'test_msg_session_trust_lifetime', + 'test_disabling_advanced_mode_makes_signer_inert_not_erased', + 'Disabling AdvancedMode suspends the signer, it does not revoke it', + 'MEASURED, and it contradicts the shorthand that disabling AdvancedMode clears loaded ' + 'signers. Turning the policy off does make the signer unusable - every consumer in ' + 'signed_metadata.c refuses a runtime slot while the policy is off, so metadata fails closed. ' + 'But nothing erases the slot: storage_setPolicy() flips a bit and only session_clear() calls ' + 'signed_metadata_clear_signers(). Sending the bare ApplyPolicies to turn the policy back on ' + 'brings the old signer straight back to VERIFIED, and the expected-response list asserts ' + 'exactly one ButtonRequest for that - the "Trust CI Test ... NOT verified by KeepKey" consent ' + 'is provably NOT re-shown. The host API hides this because apply_policy() follows every ' + 'policy change with Initialize, and it is the Initialize that clears the slot (I3). Release ' + 'consequence: a user who disables AdvancedMode to drop a provider has suspended it, not ' + 'revoked it, and the screen that re-arms it names the policy but never the signer it silently ' + 'reinstates.', + ['Enable Policy: AdvancedMode', + "Load Clearsigner: Trust 'CI Test' (fingerprint) ... NOT verified by KeepKey", + 'Disable Policy: AdvancedMode', + 'Home screen at the refusal - the metadata message fails closed with no screen', + 'Enable Policy: AdvancedMode - the ONLY confirm shown on re-arming; no second trust screen']), + ]), + ('L', 'Bitcoin-Only Variant', '7.15.0', + 'KK_BITCOIN_ONLY=ON builds a second shipping product out of the same tree: coins.def keeps ' + 'only Bitcoin and Testnet, messagemap.def drops every altcoin handler, KK_ZCASH_PRIVACY is ' + 'forced OFF, and transaction.c takes a BITCOIN_ONLY arm on the OP_RETURN path that confirms ' + 'raw bytes instead of decoding a THORChain memo. Until this section none of it had a test and ' + 'CI only ever ran the multi-chain emulator, so an entire shipping product was audited by ' + 'nothing. These tests never skip: each asserts the behaviour that is correct for the variant ' + 'it is talking to, so a run against the regular image proves the strip did NOT leak into the ' + 'multi-chain product, and a run against the bitcoin-only image proves it happened. The ' + 'variant is identified from GetCoinTable, not from features.firmware_variant -- L3 explains ' + 'why that field cannot be trusted.', + [ + 'PRODUCT: two build products, one tree. Regular = every coin family plus Zcash Orchard.', + 'Bitcoin-only = Bitcoin + Testnet, no altcoins, no shielded Zcash, no ERC-20 token table.', + 'STRIPPED BY NAME: coinByName() must refuse Litecoin/Dogecoin/BCH/Zcash/DigiByte/Dash --', + ' "bitcoin-only" is not "UTXO-only", and a silent fallback to Bitcoin parameters would', + ' hand back an xpub with the wrong version bytes under an altcoin label.', + 'STRIPPED BY MESSAGE: an absent handler answers Failure_UnexpectedMessage from the board', + ' dispatcher, draws nothing, and leaves the message loop usable.', + 'OP_RETURN: no memo parser is linked, so a THORChain memo is disclosed as the bytes', + ' themselves. The OMNI branch sits ABOVE the #if and must still decode.', + 'REFUSAL: refusing the raw OP_RETURN screen returns -1 from compile_output(), which must', + ' surface as ActionCancelled with no signature and no further screens.', + ], + [ + ('L1', 'test_msg_bitcoin_only_variant', 'test_bitcoin_signing_survives_the_strip', + 'Bitcoin still signs, byte for byte', + 'The one thing the bitcoin-only product must still do. Stripping coins, handlers and the ' + 'Orchard engine touches coins.def, messagemap.def, fsm.c and the AES table selection; any ' + 'of them going wrong surfaces here first. The signature is compared against the exact ' + 'vector test_msg_signtx.test_one_one_fee pins on the multi-chain build, so both products ' + 'must produce identical transactions from the same seed. The two review screens are ' + 'asserted as well: a signing test alone cannot see a dropped confirmation.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L2', 'test_msg_bitcoin_only_variant', 'test_coin_table_is_bitcoin_and_testnet_only', + 'The coin table is the product boundary', + 'GetCoinTable must report exactly two coins, Bitcoin and Testnet, with no ERC-20 tokens ' + '(TOKENS_COUNT is 0 and `tokens` is not linked at all). A host enumerating coins is the ' + 'only way a user learns what the device will sign, so the count and the names are part ' + 'of the product, not an implementation detail. On the regular image the same test ' + 'asserts the table is larger -- the strip must not leak.', + []), + ('L3', 'test_msg_bitcoin_only_variant', 'test_firmware_variant_names_the_bitcoin_only_product', + 'features.firmware_variant must name the product', + 'FAILED ON THE BITCOIN-ONLY IMAGE AS MEASURED, and the failure is the finding. ' + 'firmware_variant is the only wire-visible product identifier and the whole pyk suite ' + 'gates on it: common.requires_fullFeature() skips a test when it reads "KeepKeyBTC" or ' + '"EmulatorBTC". The bitcoin-only emulator reported plain "Emulator", so ' + 'requires_fullFeature() is dead code and every altcoin test in the directory runs ' + 'against a bitcoin-only image and fails instead of skipping. Section X of this report ' + 'states the KeepKeyBTC contract as fact. variant_getName() has two arms and only the ' + 'EMULATOR one returns a literal; the hardware arm takes the model variant name from ' + 'variant_getInfo() and has no BITCOIN_ONLY case at all, so bitcoin-only HARDWARE reports ' + 'exactly what a multi-chain device of the same model reports. The assertion is by ' + 'suffix, not against a fixed string, so it stays honest for both arms.', + []), + ('L4', 'test_msg_bitcoin_only_variant', 'test_altcoin_message_handlers_are_absent', + 'Every stripped chain refuses without drawing', + 'Thirteen probes -- Ethereum, Cosmos, Osmosis, Nano, EOS, THORChain, Maya, Ripple, ' + 'Binance, TRON, TON, Solana, Hive -- must each answer Failure_UnexpectedMessage, the ' + 'board dispatcher\'s answer for a message type that is not in the map. The two ways this ' + 'goes wrong are a half-linked handler (wrong failure, or a hang) and one that renders ' + 'before refusing: a bitcoin-only device must never draw a chain it cannot sign. The ' + 'framebuffer is compared byte-for-byte across all thirteen for exactly that reason, and ' + 'a Ping afterwards proves the message loop is not wedged. The screenshot list is ' + 'deliberately empty -- the evidence is that nothing was drawn.', + []), + ('L5', 'test_msg_bitcoin_only_variant', 'test_altcoin_coin_names_are_refused', + 'Stripped coins are refused by name', + 'The other half of the boundary. GetPublicKey is a Bitcoin-family message and stays in ' + 'the map, so coinByName() is what has to say no: Litecoin, Dogecoin, BitcoinCash, Zcash, ' + 'DigiByte and Dash must each come back Failure_Other "Invalid coin name" rather than ' + 'falling through to Bitcoin\'s parameters and returning an xpub with the wrong version ' + 'bytes under an altcoin label. Bitcoin and Testnet must still work.', + []), + ('L6', 'test_msg_bitcoin_only_variant', 'test_zcash_privacy_is_compiled_out', + 'Zcash privacy is compiled out with the coin', + 'The Orchard engine is the largest thing in the image and its handlers live behind ' + 'ZCASH_PRIVACY, not BITCOIN_ONLY -- the two gates are tied together in CMakeLists, not ' + 'in the source, so nothing in C would catch that wiring breaking. ZcashGetOrchardFVK and ' + 'ZcashDisplayAddress must be unknown messages, and transparent Zcash must be gone from ' + 'the coin table in the same breath, so no Zcash path of either kind survives.', + []), + ('L7', 'test_msg_bitcoin_only_variant', 'test_op_return_thorchain_memo_is_confirmed_raw', + 'A THORChain memo is disclosed raw, not decoded', + 'The arm the alpha merge added to compile_output(). With no memo parser linked, a memo ' + 'the multi-chain image explains -- swap, asset, destination, affiliate -- is shown on the ' + 'bitcoin-only image as the bytes themselves. That is the right answer (a decode the image ' + 'cannot perform must never be faked) but it had never been executed, because CI runs only ' + 'the multi-chain emulator. Screen counts are measured, not modelled: bitcoin-only shows ' + 'exactly three requests (output, raw OP_RETURN, SignTx) while the regular image expands ' + 'the same memo into strictly more ConfirmOutput screens. Both must sign a script carrying ' + 'the memo verbatim, so disclosure and signature are pinned to the same bytes.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'CONFIRM OP_RETURN: SWAP:ETH.ETH:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L8', 'test_msg_bitcoin_only_variant', 'test_op_return_refusal_cancels_the_signature', + 'Refusing the OP_RETURN screen aborts the signature', + 'The BITCOIN_ONLY arm returns -1 when confirm_data is refused, and the multi-chain arm ' + 'has its own CANCELLED path that must not answer a refusal by asking again on a second ' + 'screen. Both must surface as Failure_ActionCancelled with no signature, and the flow ' + 'must stop AT the refused screen -- a SignTx request afterwards would mean the refusal ' + 'was recorded and then ignored.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'CONFIRM OP_RETURN: the memo screen the user refuses']), + ('L9', 'test_msg_bitcoin_only_variant', 'test_omni_op_return_is_still_decoded', + 'The shared OMNI branch survived the strip', + 'compile_output() tests for an "omni" prefix ABOVE the BITCOIN_ONLY split, so an OMNI ' + 'simple send is still decoded into a sentence on the bitcoin-only image. The regression ' + 'guarded against is the new #else swallowing the OMNI case, silently downgrading a ' + 'decoded amount to a hex dump. Proved by contrast rather than by OCR: the same twenty ' + 'bytes with the leading "o" changed to "p" are no longer OMNI and fall through to the ' + 'raw-data screen, so the two screens must differ and the decoded one must be the sparser ' + 'of the two. Both payloads ride in ONE transaction, as two data outputs, because L11 ' + 'makes a second signing in the same session impossible.', + ['CONFIRM OMNI: Do you want to send 1 OMNI?', + 'CONFIRM OP_RETURN: 706D6E6900000000000000010000000005F5E100 -- the same bytes, raw', + 'Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1', + 'TRANSACTION: send 0.0039 BTC from your wallet, including a 0.0001 BTC fee']), + ('L10', 'test_msg_bitcoin_only_variant', 'test_repeated_transaction_is_allowed_without_op_return', + 'An exact repeat is not a duplicate', + 'The control for L11. compile_output() carries an anti-malware check (txin_check.c): warn ' + 'when a transaction pays the same amount to the same address as the previous one but was ' + 'built from DIFFERENT inputs, which is what a host rewriting a segwit txid looks like. An ' + 'exact repeat -- same outputs AND same inputs -- is not that and is deliberately allowed. ' + 'Signing it twice here pins that, so the refusal in L11 cannot be explained away as the ' + 'duplicate guard doing its job.', + []), + ('L11', 'test_msg_bitcoin_only_variant', 'test_op_return_does_not_poison_the_duplicate_detector', + 'An OP_RETURN output must not poison the duplicate detector', + 'FAILS ON BOTH PRODUCTS, and the failure is the finding. Sign a transaction whose last ' + 'output is OP_RETURN, then sign the transaction L10 just proved is allowed, and the ' + 'device answers "WARNING: DUPLICATE TRANSACTION! Already signed a tx with the same ' + 'outputs. To try again, unplug/replug KeepKey." and aborts. signing.c calls ' + 'txin_dgst_final() once per output, but txin_dgst_save_and_reset() -- the only thing that ' + 're-initialises the SHA-256 context -- is reached only on the pay-to-address path; an ' + 'OP_RETURN output returns before it. So a transaction ending in OP_RETURN leaves the ' + 'context finalised and never re-initialised, the next transaction\'s inputs are hashed ' + 'into a finalised context, and its digest no longer matches while amount and address ' + 'still do -- precisely the (same outputs, different inputs) pattern the check exists to ' + 'flag. Fail-safe, in that it refuses rather than signs, but it refuses a legitimate ' + 'transaction and demands a replug, and every OP_RETURN-terminated transaction arms it: ' + 'that is every THORChain and Maya swap the wallet builds. Nothing had caught it because ' + 'common.KeepKeyTest wipes the device in setUp, so no existing test signs two transactions ' + 'in one session.', + ['Send 0.0038 BTC to 1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1 (first transaction)', + 'CONFIRM OP_RETURN: the memo that arms the detector', + 'WARNING: DUPLICATE TRANSACTION! Already signed a tx with the same outputs']), + ]), + ('U', 'Storage Upgrade Preservation', '7.15.0', + 'A signed UPGRADE must never wipe. A DOWNGRADE wipes, and that is correct. Those two ' + 'sentences are the whole policy (docs/StorageVersionGate.md), and until this section ' + 'nothing in the suite tested either half - every other test creates storage with the ' + 'firmware under test and never crosses a release boundary, which is exactly where this ' + 'class of defect lives. The mechanism is one function: storage_init() hands whatever is in ' + 'flash to storage_fromFlash(), and if version_from_int() does not recognise the version it ' + 'returns StorageVersion_NONE, the load reports SUS_Invalid, and storage_init() runs ' + 'storage_reset() + storage_commit(). No prompt, no warning - the wallet is gone at boot. ' + 'The flash format this build reads and writes is V17, the same format shipped in v7.14.1. ' + '7.15 reverted the RC27 bump to V19 (commit 6bebde7b2) because one boot silently migrated ' + '17 to 19 and from that moment no downgrade was possible without a wipe; V18, the ' + 'clear-sign identity block, is dead, and the V19 serializer survives only behind ' + 'STORAGE_PIN_KDF_V19 == 0. U5 pins that V17 as a literal, on purpose: the compile-time ' + 'assert compares two numbers in the same header, and raising the baseline to make a build ' + 'compile is the edit the SOP calls its highest-severity review item.', + [ + 'THE RULE: recognise every version any shipped firmware ever wrote, and never lower', + 'STORAGE_VERSION. Both ways of breaking it compile cleanly and pass every other test:', + '- lowering STORAGE_VERSION below a version that has shipped;', + '- deleting, reordering or renumbering an entry in storage_versions.inc.', + '', + 'The reverse direction is NOT a defect. Older firmware cannot read a newer record, so a', + 'DOWNGRADE lands on SUS_Invalid and resets. Do not "fix" that: the reset is what stops', + 'an attacker flashing an older, validly signed image with a known extraction bug and', + 'keeping the seed.', + '', + 'HOW THESE TESTS REACH THE GATE: it only runs at boot, and no host message can reboot', + 'the device. SoftReset (messages.proto type 89) has no messagemap entry and no handler', + 'body, and fsm_msgDebugLinkFlashDump() is compiled out under EMULATOR, so the emulator', + 'can neither be restarted nor have its flash read over the wire. U1-U4 therefore start', + 'their OWN kkemu on their own port pair and own its emulator.img, which lib/emulator/', + 'setup.c mmaps as the flash array. Killing that process and starting it again IS a', + 'power cycle, and restamping the version word in the image is what an arriving device', + 'presents: a record whose header says one version while the firmware says another.', + '', + 'WHAT THIS SECTION DOES NOT COVER, stated plainly:', + '- No signed image is involved. The bootloader preserves storage only when SIG_FLAG is', + ' set, the firmware being replaced was officially signed, and the new image verifies.', + ' An unsigned development or RC build fails two of those by construction, so "the', + ' upgrade did not wipe" is finally proven only with a signed build on a production', + ' device.', + '- U2 restamps a record THIS build wrote rather than replaying one 7.14.x wrote, so the', + ' V16 reader runs but the older LAYOUTS (V1-V15) and their fallthrough chain do not.', + '- U1-U4 SKIP wherever no kkemu binary can be started. The CI python-keepkey image', + ' (scripts/emulator/python-keepkey.Dockerfile) copies the source but never builds the', + ' emulator, so as the pipeline stands today only U5-U8 run in CI. A skipped U1-U4 in', + ' this report means the release was NOT audited for upgrade preservation.', + ], + [ + ('U1', 'test_storage_version_gate', 'test_reboot_preserves_the_wallet', + 'A power cycle keeps the wallet', + 'The boundary the ordinary storage tests never cross. Every other test lives inside ' + 'one session, where the wallet is a RAM shadow; only a power cycle re-runs ' + 'storage_init() and proves the bytes committed to flash were both written and ' + 'readable. The PIN is load-bearing: the seed lives in encrypted_sec and the key that ' + 'decrypts it is only ever stored wrapped by the PIN, so an address that still derives ' + 'after the reboot proves the wrapped key, its fingerprint and the ciphertext all ' + 'round-tripped together. This test is also the control for U2 - a record already at ' + 'STORAGE_VERSION reports SUS_Valid, so nothing is rewritten at boot, and the flash ' + 'image is asserted byte-identical across the restart.', + ['Wipe Device confirm (the arrangement wipes before loading the seed)', + 'Import Recovery Sentence confirm', + 'Home screen after the power cycle: locked, wallet still present', + 'Bitcoin Account #0 / Address #0 showing the same address as before the reboot']), + ('U2', 'test_storage_version_gate', 'test_v16_blob_upgrades_without_wiping', + 'A V16 wallet upgrades, it does not wipe', + 'The policy in one test: the device arrives carrying the format written by the release ' + 'it is leaving, and the incoming firmware must READ it rather than reset it. ' + 'storage_fromFlash() takes case StorageVersion_16, reads through storage_readV16(), ' + 'restamps the record V17 and reports SUS_Updated, which storage_init() answers with a ' + 'commit - a migration, not a wipe. The V16 record is built from the four things that ' + 'actually differ between the formats: the version stamp, flags bits 18/19 ' + '(authdata_initialized / authdata_encrypted), authdata_fingerprint at +469, and the ' + '512-byte V16 ciphertext against the 1024-byte V17 one. The same address behind the ' + 'same PIN is the assertion; it can only derive if the wrapped storage key unwrapped, ' + 'the V16 ciphertext decrypted and the seed came back byte-identical. A surviving ' + 'wallet alone would not prove the V16 branch ran, so the test also asserts flash was ' + 'written at boot - the side effect only SUS_Updated has.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen after the migrating boot: wallet still present', + 'Bitcoin Account #0 / Address #0 - the same address the V16 record held']), + ('U3', 'test_storage_version_gate', 'test_unrecognised_version_wipes_on_boot', + 'An unrecognised version wipes, deliberately', + 'The half of the policy nobody should be tempted to soften. A device that has run ' + 'newer firmware carries a newer stamp; older firmware cannot read it, so ' + 'version_from_int() returns StorageVersion_NONE and storage_init() resets. That reset ' + 'is the rollback protection: without it an attacker could flash an older, validly ' + 'signed image with a known extraction bug and keep the seed. The stamp used is one ' + 'past the version this build just committed - measured from the device, not read out ' + 'of the header - which is exactly what the next format bump will look like from here. ' + 'The device must come up with no wallet, no PIN and no label.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen after the boot that reset storage: no wallet']), + ('U4', 'test_storage_version_gate', 'test_bitcoin_only_band_refuses_without_wiping', + 'A bitcoin-only wallet is refused, not destroyed', + 'Seeds created under bitcoin-only firmware are stamped in a reserved band (10000 + the ' + 'normal version). Multi-chain firmware must not load one - that seed was never meant ' + 'to be multi-chain-exposed - but it must also leave it alone: SUS_BitcoinOnlyLocked ' + 'resets only the RAM shadow, and storage_commit() returns early while btc_only_locked, ' + 'so flash is never touched. Three assertions, in order of what they cost you: the ' + 'device comes up locked and uninitialized; the storage sector is byte-for-byte what it ' + 'was, everywhere except the stamp the test itself changed; and once the band stamp is ' + 'removed the wallet boots again and derives the original address. Without the third, ' + '"refuse rather than wipe" would be a claim about intent rather than about bytes.', + ['Wipe Device confirm', 'Import Recovery Sentence confirm', + 'Home screen while locked out by the bitcoin-only band: no wallet', + 'Bitcoin Account #0 / Address #0 after the band stamp is removed - the wallet is back']), + ('U5', 'test_storage_version_gate', 'test_active_flash_format_is_v17', + 'This build writes flash format V17', + 'An independent witness for the number the whole gate turns on. The compile-time ' + 'assert in storage.c compares STORAGE_VERSION against STORAGE_VERSION_LAST_SHIPPED - ' + 'two values in the same header, editable in one commit - so it cannot notice a release ' + 'that raises both. 7.15 deliberately reverted to V17; if V19 (or anything else) ' + 're-lands, this test fails and the bump has to be argued for in review rather than ' + 'discovered in the field. Reads the firmware sources, so it runs even where no ' + 'emulator can be restarted. No screen: it never touches the device, and the empty ' + 'list below says so.', + []), + ('U6', 'test_storage_version_gate', 'test_version_never_drops_below_a_shipped_release', + 'The version never goes backwards or into the band', + 'Lowering STORAGE_VERSION wipes every device upgrading FROM a shipped release: its ' + 'record stops being recognised, so the gate maps it to StorageVersion_NONE and ' + 'storage_init() resets. The version must also stay below STORAGE_VERSION_BTC_ONLY_BASE ' + '(10000), or a multi-chain wallet would be stamped into the band that multi-chain ' + 'firmware refuses to load - locking the wallet out of its own firmware.', + []), + ('U7', 'test_storage_version_gate', + 'test_version_ladder_is_contiguous_and_ends_at_storage_version', + 'storage_versions.inc is append-only', + 'The enum is emitted in .inc order after StorageVersion_NONE = 0, which is what makes ' + 'StorageVersion_N == N. Delete or renumber an entry and version_from_int() quietly ' + 'loses that case, wiping every device carrying it. This asserts the ladder is ' + 'contiguous from 1 and that its last entry is STORAGE_VERSION - the two properties the ' + 'in-tree static asserts depend on.', + []), + ('U8', 'test_storage_version_gate', 'test_every_ladder_version_has_a_reader', + 'Every ladder version has a reader case', + 'The failure the static asserts do NOT cover. They pin the enum to its own numbering ' + 'and say nothing about the switch in storage_fromFlash(). Drop a case and control ' + 'falls out of the switch to return SUS_Invalid, which storage_init() answers with ' + 'storage_reset() - every device carrying that version is wiped on upgrade and the ' + 'build stays green.', + []), + ]), ] # --------------------------------------------------------------- diff --git a/tests/test_msg_bitcoin_only_variant.py b/tests/test_msg_bitcoin_only_variant.py new file mode 100644 index 00000000..3f8327b9 --- /dev/null +++ b/tests/test_msg_bitcoin_only_variant.py @@ -0,0 +1,650 @@ +"""Bitcoin-only variant -- the product boundary, measured over the wire. + +KK_BITCOIN_ONLY=ON builds a second shipping product: coins.def keeps only +Bitcoin and Testnet, messagemap.def drops every altcoin handler, ZCASH_PRIVACY +is forced OFF, and lib/firmware/transaction.c takes a BITCOIN_ONLY arm on the +OP_RETURN path that confirms raw bytes instead of decoding a THORChain memo. +None of that had a test, and CI only ever ran the multi-chain emulator -- so +the whole variant was unaudited. + +NOTHING HERE SKIPS. Each test asserts the behaviour that is correct for the +variant it is talking to, so it is evidence on both builds: on the bitcoin-only +image it proves the strip happened, and on the regular image it proves the +strip did NOT happen (a guard that leaked into the multi-chain product would +fail here just as loudly). `requires_fullFeature()` is deliberately not used -- +see test_firmware_variant_names_the_bitcoin_only_product for why it cannot +work. + +The variant is identified by GetCoinTable, not by features.firmware_variant: +the coin table comes from coins.def, which is a different mechanism from the +message map, the Zcash gate and the OP_RETURN arm that the other tests probe, +so nothing here is circular. +""" + +import binascii +import time +import unittest + +import common + +import keepkeylib.messages_pb2 as proto +import keepkeylib.types_pb2 as proto_types +from keepkeylib.client import CallException + +from keepkeylib import messages_binance_pb2 as messages_binance +from keepkeylib import messages_cosmos_pb2 as messages_cosmos +from keepkeylib import messages_eos_pb2 as messages_eos +from keepkeylib import messages_ethereum_pb2 as messages_eth +from keepkeylib import messages_hive_pb2 as messages_hive +from keepkeylib import messages_mayachain_pb2 as messages_maya +from keepkeylib import messages_nano_pb2 as messages_nano +from keepkeylib import messages_osmosis_pb2 as messages_osmosis +from keepkeylib import messages_ripple_pb2 as messages_ripple +from keepkeylib import messages_solana_pb2 as messages_solana +from keepkeylib import messages_thorchain_pb2 as messages_thorchain +from keepkeylib import messages_ton_pb2 as messages_ton +from keepkeylib import messages_tron_pb2 as messages_tron +from keepkeylib import messages_zcash_pb2 as messages_zcash + + +# tx d5f65ee8... input 0 is 0.0039 BTC; the vector every other Bitcoin test in +# this directory spends, and it is in txcache/, so nothing here needs network. +PREV_HASH = binascii.unhexlify( + 'd5f65ee80147b4bcc70b75e4bbf2d7382021b871bd8867ef8fa525ef50864882') +PREV_INDEX = 0 +INPUT_AMOUNT = 390000 +OUT_ADDRESS = '1MJ2tj2ThBE62zXbBYA5ZaN3fdve5CPAz1' +OUT_AMOUNT = 380000 # 0.0001 BTC fee + +# A well-formed THORChain swap memo. The multi-chain firmware parses this and +# renders who/what/how-much; the bitcoin-only firmware has no parser linked and +# must disclose the bytes themselves. +THORCHAIN_MEMO = (b'SWAP:ETH.ETH:' + b'0x41e5560054824ea6b0732e656e3ad64e20e94e45:420:kk:75') + +# OMNI simple send, 1.00000000 OMNI. The OMNI branch of compile_output() sits +# ABOVE the #if BITCOIN_ONLY, so it must survive the strip untouched. +OMNI_SIMPLE_SEND = binascii.unhexlify('6f6d6e6900000000000000010000000005f5e100') +# The same 20 bytes with the 'o' of "omni" changed to 'p', so the OMNI prefix +# test fails and the payload falls through to the raw-data confirmation. +NOT_OMNI = b'p' + OMNI_SIMPLE_SEND[1:] + +# A BIP-44 path that is valid on every chain probed below, so a refusal can +# only be the message type being absent, never a path rejection. +BIP44_PATH = [2147483692, 2147483708, 2147483648, 0, 0] + +# Matches client.SCREENSHOT_SETTLE_SECONDS. The firmware writes ButtonRequest +# immediately BEFORE drawing, so read_layout() must be given time to settle or +# it returns the previous screen. +BUTTON_RENDER_SETTLE_SECONDS = 0.5 + + +def lit_pixels(layout): + """Count set pixels in a raw 2048-byte OLED framebuffer. + + read_layout() returns the framebuffer, not text, and there is no glyph + decoder in this repo. Screen assertions here are therefore structural: a + screen that draws nothing, and two screens that draw identically, are both + detectable without OCR. + """ + total = 0 + for b in layout: + if isinstance(b, str): + b = ord(b) + total += bin(b).count('1') + return total + + +class TestBitcoinOnlyVariant(common.KeepKeyTest): + + def setUp(self): + super(TestBitcoinOnlyVariant, self).setUp() + self.requires_firmware("7.15.0") + self.screens = [] + # Refuse (press NO) on the Nth ButtonRequest of the current flow; + # None means confirm everything. + self.refuse_on = None + self._install_screen_capture() + + # ------------------------------------------------------------------ + # helpers + # ------------------------------------------------------------------ + + def _install_screen_capture(self): + """Record the framebuffer at each ButtonRequest, before it is acked.""" + original = self.client.callback_ButtonRequest + + def capture(msg): + # Unconditional settle, unlike client.callback_ButtonRequest's + # SCREENSHOT-gated sleep: these are structural assertions that must + # hold on every run, not just screenshot runs. + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) + self.screens.append((msg.code, self.client.debug.read_layout())) + self.client.button = (self.refuse_on != len(self.screens)) + return original(msg) + + self.client.callback_ButtonRequest = capture + + def _reset_screens(self): + self.screens = [] + self.client.button = True + + def _confirm_codes(self): + return [code for code, _ in self.screens] + + def _screen(self, index): + return self.screens[index][1] + + def _is_bitcoin_only(self): + """Identify the product from coins.def, over the wire. + + Deliberately NOT features.firmware_variant: that field does not + distinguish the two builds at all (see + test_firmware_variant_names_the_bitcoin_only_product). + """ + return self.client.call(proto.GetCoinTable()).num_coins == 2 + + def _coin_names(self): + table = self.client.call(proto.GetCoinTable()) + end = min(table.num_coins, table.chunk_size) + chunk = self.client.call(proto.GetCoinTable(start=0, end=end)) + return [entry.coin_name for entry in chunk.table] + + def _data_output(self, op_return_data): + return proto_types.TxOutputType(op_return_data=op_return_data, + amount=0, + script_type=proto_types.PAYTOOPRETURN) + + def _sign(self, outputs): + inp = proto_types.TxInputType(address_n=[0], prev_hash=PREV_HASH, + prev_index=PREV_INDEX) + return self.client.sign_tx('Bitcoin', [inp], outputs) + + def _sign_with_op_return(self, op_return_data): + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + return self._sign([out_pay, self._data_output(op_return_data)]) + + def _probe(self, msg): + """Send one message and return the response, leaving the device idle.""" + resp = self.client.call_raw(msg) + self.client.call_raw(proto.Initialize()) + return resp + + def _assert_unknown_message(self, name, resp): + self.assertTrue( + isinstance(resp, proto.Failure), + "%s: expected a Failure on the bitcoin-only image, got %s" + % (name, type(resp).__name__)) + self.assertTrue( + resp.code == proto_types.Failure_UnexpectedMessage, + "%s: expected Failure_UnexpectedMessage (the handler is not in the " + "message map at all); got code %d %r" + % (name, resp.code, resp.message)) + + def _assert_handler_present(self, name, resp): + self.assertTrue( + not (isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_UnexpectedMessage), + "%s: the multi-chain image answered Failure_UnexpectedMessage, so " + "a BITCOIN_ONLY guard leaked into the regular product" % name) + + # ------------------------------------------------------------------ + # L1 -- Bitcoin still signs + # ------------------------------------------------------------------ + + def test_bitcoin_signing_survives_the_strip(self): + """The one thing the bitcoin-only product must still do. + + Stripping coins, message handlers and the Zcash engine touches + coins.def, messagemap.def, fsm.c and the AES table selection. Any of + those going wrong shows up here first: the signature is compared + against the exact vector test_msg_signtx.test_one_one_fee pins on the + multi-chain build, so the two products must produce byte-identical + Bitcoin transactions from the same seed. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + inp = proto_types.TxInputType(address_n=[0], prev_hash=PREV_HASH, + prev_index=PREV_INDEX) + out = proto_types.TxOutputType(address=OUT_ADDRESS, amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + _, serialized_tx = self.client.sign_tx('Bitcoin', [inp], [out]) + + self.assertEqual( + binascii.hexlify(serialized_tx), + '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb4470' + '1e85ef6d5000000006b4830450221009a0b7be0d4ed3146ee262b42202841834' + '698bb3ee39c24e7437df208b8b7077102202b79ab1e7736219387dffe8d615bbd' + 'ba87e11477104b867ef47afed1a5ede7810121023230848585885f63803a0a8ae' + 'cdd6538792d5c539215c91698e315bf0253b43dffffffff0160cc050000000000' + '1976a914de9b2a8da088824e8fe51debea566617d851537888ac00000000') + + # One output review, then the whole-transaction confirmation. Measured, + # not modelled: a silently dropped output screen is exactly the failure + # a signing test alone cannot see. + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, + proto_types.ButtonRequest_SignTx]) + for index in range(len(self.screens)): + self.assertGreater(lit_pixels(self._screen(index)), 200) + + # ------------------------------------------------------------------ + # L2 -- the coin table IS the product boundary + # ------------------------------------------------------------------ + + def test_coin_table_is_bitcoin_and_testnet_only(self): + """coins.def under BITCOIN_ONLY keeps exactly two entries. + + "Bitcoin-only" is not "UTXO-only": Litecoin, Dogecoin, Bitcoin Cash and + transparent Zcash are all stripped too, and ERC-20 tokens leave the + table entirely (TOKENS_COUNT is 0 and `tokens` is not linked). A host + that enumerates coins is the only way a user learns what the device + will sign, so the count and the names are both part of the product. + """ + table = self.client.call(proto.GetCoinTable()) + names = self._coin_names() + + if self._is_bitcoin_only(): + self.assertEqual(table.num_coins, 2) + self.assertEqual(names, ['Bitcoin', 'Testnet']) + else: + self.assertGreater(table.num_coins, 2) + self.assertTrue('Ethereum' in names or len(names) > 2, + "multi-chain image reported %r" % (names,)) + + # ------------------------------------------------------------------ + # L3 -- the variant string + # ------------------------------------------------------------------ + + def test_firmware_variant_names_the_bitcoin_only_product(self): + """features.firmware_variant must distinguish the two products. + + It is the only wire-visible product identifier, and the whole test + suite gates on it: common.requires_fullFeature() skips a test when + firmware_variant is "KeepKeyBTC" or "EmulatorBTC". + + variant_getName() has two arms. Under EMULATOR it returns a literal; + otherwise it returns the model's variant name from variant_getInfo(), + and THAT arm has no BITCOIN_ONLY case at all -- a bitcoin-only device + reports whatever a multi-chain device of the same model reports. So + this is asserted by suffix rather than against a fixed string: the + contract is that the two products are distinguishable, on the emulator + and on hardware alike. + + If it fails, requires_fullFeature() is dead code and every altcoin test + in this directory runs -- and fails -- against a bitcoin-only image + instead of skipping. + """ + self.client.init_device() + variant = self.client.features.firmware_variant + + if self._is_bitcoin_only(): + self.assertTrue( + variant.endswith('BTC'), + "coins.def carries only Bitcoin+Testnet, so this is the " + "bitcoin-only product, but firmware_variant is %r. " + "common.requires_fullFeature() compares against 'KeepKeyBTC'/" + "'EmulatorBTC' and therefore never skips anything." % variant) + else: + self.assertTrue( + not variant.endswith('BTC'), + "multi-chain image reported the bitcoin-only variant %r" + % variant) + + # ------------------------------------------------------------------ + # L4 -- altcoin handlers are absent, not broken + # ------------------------------------------------------------------ + + def test_altcoin_message_handlers_are_absent(self): + """Every stripped chain must refuse cleanly and leave the screen alone. + + messagemap.def drops these MSG_IN entries under BITCOIN_ONLY, so the + board-level dispatcher answers Failure_UnexpectedMessage without ever + reaching a handler. The two things that could go wrong are a handler + that is half-linked (wrong failure, or a hang) and one that draws + something before refusing -- a bitcoin-only device must never render a + chain it cannot sign. The framebuffer is compared byte-for-byte across + all fifteen probes for exactly that reason. + """ + self.setup_mnemonic_nopin_nopassphrase() + probes = [ + ('EthereumGetAddress', messages_eth.EthereumGetAddress(address_n=BIP44_PATH)), + ('CosmosGetAddress', messages_cosmos.CosmosGetAddress(address_n=BIP44_PATH)), + ('OsmosisGetAddress', messages_osmosis.OsmosisGetAddress(address_n=BIP44_PATH)), + ('NanoGetAddress', messages_nano.NanoGetAddress(address_n=BIP44_PATH)), + ('EosGetPublicKey', messages_eos.EosGetPublicKey(address_n=BIP44_PATH)), + ('ThorchainGetAddress', messages_thorchain.ThorchainGetAddress(address_n=BIP44_PATH)), + ('MayachainGetAddress', messages_maya.MayachainGetAddress(address_n=BIP44_PATH)), + ('RippleGetAddress', messages_ripple.RippleGetAddress(address_n=BIP44_PATH)), + ('BinanceGetAddress', messages_binance.BinanceGetAddress(address_n=BIP44_PATH)), + ('TronGetAddress', messages_tron.TronGetAddress(address_n=BIP44_PATH)), + ('TonGetAddress', messages_ton.TonGetAddress(address_n=BIP44_PATH)), + ('SolanaGetAddress', messages_solana.SolanaGetAddress(address_n=BIP44_PATH)), + ('HiveGetPublicKey', messages_hive.HiveGetPublicKey(address_n=BIP44_PATH)), + ] + + bitcoin_only = self._is_bitcoin_only() + home_before = self.client.debug.read_layout() + + for name, msg in probes: + resp = self._probe(msg) + if bitcoin_only: + self._assert_unknown_message(name, resp) + else: + self._assert_handler_present(name, resp) + + if bitcoin_only: + time.sleep(BUTTON_RENDER_SETTLE_SECONDS) + home_after = self.client.debug.read_layout() + self.assertEqual(bytes(home_before), bytes(home_after)) + + # The device is still usable after all of that: a refusal must not + # wedge the message loop. + self.assertEqual(self.client.call(proto.Ping(message='alive')).message, + 'alive') + + # ------------------------------------------------------------------ + # L5 -- stripped coin NAMES are refused + # ------------------------------------------------------------------ + + def test_altcoin_coin_names_are_refused(self): + """A stripped coin is refused by name, on a handler that still exists. + + GetPublicKey is a Bitcoin-family message and stays in the message map, + so this is the other half of the boundary: coinByName() must fail for + every coin the image no longer carries, rather than falling back to + Bitcoin's parameters and handing back an xpub with the wrong version + bytes under a Litecoin label. + """ + self.setup_mnemonic_nopin_nopassphrase() + bitcoin_only = self._is_bitcoin_only() + account = [2147483692, 2147483648, 2147483648] + + for name in ('Bitcoin', 'Testnet'): + resp = self._probe(proto.GetPublicKey(address_n=account, + coin_name=name)) + self.assertTrue(isinstance(resp, proto.PublicKey), + "%s must always be supported; got %s" + % (name, type(resp).__name__)) + + for name in ('Litecoin', 'Dogecoin', 'BitcoinCash', 'Zcash', + 'DigiByte', 'Dash'): + resp = self._probe(proto.GetPublicKey(address_n=account, + coin_name=name)) + if bitcoin_only: + self.assertTrue( + isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_Other, + "%s is not in the bitcoin-only coin table, so it must be " + "refused by name; got %s" % (name, type(resp).__name__)) + else: + self.assertTrue(isinstance(resp, proto.PublicKey), + "%s must work on the multi-chain image; got %s" + % (name, type(resp).__name__)) + + # ------------------------------------------------------------------ + # L6 -- Zcash privacy is compiled out + # ------------------------------------------------------------------ + + def test_zcash_privacy_is_compiled_out(self): + """KK_ZCASH_PRIVACY is forced OFF whenever KK_BITCOIN_ONLY is ON. + + The Orchard engine is the largest thing in the image and its handlers + live behind ZCASH_PRIVACY, not BITCOIN_ONLY, so the two gates are wired + together in CMakeLists rather than in the source. If that wiring ever + breaks, the bitcoin-only image ships a shielded-Zcash signer it does + not have the coin table to support -- and the transparent side is gone + too, so 'Zcash' is refused as a coin name in the same breath. + """ + self.setup_mnemonic_nopin_nopassphrase() + bitcoin_only = self._is_bitcoin_only() + probes = [ + ('ZcashGetOrchardFVK', + messages_zcash.ZcashGetOrchardFVK(address_n=BIP44_PATH)), + ('ZcashDisplayAddress', + messages_zcash.ZcashDisplayAddress(address_n=BIP44_PATH)), + ] + for name, msg in probes: + resp = self._probe(msg) + if bitcoin_only: + self._assert_unknown_message(name, resp) + else: + self._assert_handler_present(name, resp) + + resp = self._probe(proto.GetAddress( + address_n=[2147483692, 2147483781, 2147483648, 0, 0], + coin_name='Zcash')) + if bitcoin_only: + self.assertTrue( + isinstance(resp, proto.Failure) + and resp.code == proto_types.Failure_Other, + "transparent Zcash must be gone from the coin table too; got %s" + % type(resp).__name__) + else: + self.assertTrue(isinstance(resp, proto.Address), + "multi-chain image refused transparent Zcash: %s" + % type(resp).__name__) + + # ------------------------------------------------------------------ + # L7 -- the BITCOIN_ONLY arm of the OP_RETURN path + # ------------------------------------------------------------------ + + def test_op_return_thorchain_memo_is_confirmed_raw(self): + """The arm added to compile_output() by the alpha merge. + + transaction.c wraps the THORChain memo decode in `#if !BITCOIN_ONLY` + and confirms the raw OP_RETURN bytes in the #else. So a memo that the + multi-chain image explains -- swap, asset, destination, affiliate -- + is shown on the bitcoin-only image as the bytes themselves. That is the + right answer (a decode the image cannot perform must not be faked), but + it had never been executed: CI runs only the multi-chain emulator. + + The screen count is measured, not modelled. Bitcoin-only: one output + review, one raw OP_RETURN screen, one SignTx -- three. Multi-chain: the + same memo expands to several decoded screens, so the count is strictly + higher. Either way the signed script must carry the memo verbatim, so + the disclosure and the signature are pinned to the same bytes. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + _, serialized_tx = self._sign_with_op_return(THORCHAIN_MEMO) + + # OP_RETURN -- what was signed. + expected_script = (b'\x6a' + bytes([len(THORCHAIN_MEMO)]) + + THORCHAIN_MEMO) + self.assertTrue( + expected_script in serialized_tx, + "the signed script must carry the memo bytes verbatim") + + confirm_outputs = [c for c in self._confirm_codes() + if c == proto_types.ButtonRequest_ConfirmOutput] + + if self._is_bitcoin_only(): + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_ConfirmOutput, # raw OP_RETURN + proto_types.ButtonRequest_SignTx]) + op_return_screen = self._screen(1) + # It has to actually draw the memo: a blank or near-blank screen + # here would mean the user approved bytes they never saw. + self.assertGreater(lit_pixels(op_return_screen), 400) + self.assertNotEqual(bytes(op_return_screen), + bytes(self._screen(0))) + else: + self.assertGreater( + len(confirm_outputs), 2, + "the multi-chain image must decode the memo into its own " + "screens; %d ConfirmOutput screen(s) means it fell through to " + "the raw-data path" % len(confirm_outputs)) + + def test_op_return_refusal_cancels_the_signature(self): + """Refusing the OP_RETURN screen must abort, on both products. + + The BITCOIN_ONLY arm returns -1 from compile_output() when confirm_data + is refused, and the multi-chain arm has its own THORCHAIN_MEMO_CANCELLED + path that must not answer a refusal by asking again on a second screen. + Both must surface as Failure_ActionCancelled with no signature, and the + flow must stop AT the refused screen -- a SignTx request afterwards + would mean the refusal was recorded and then ignored. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + self.refuse_on = 2 # the screen after the pay-to-address review + + try: + self._sign_with_op_return(THORCHAIN_MEMO) + self.fail("the device signed a transaction whose OP_RETURN output " + "the user refused") + except CallException as exc: + self.assertEqual(exc.args[0], proto_types.Failure_ActionCancelled) + + self.assertEqual(len(self.screens), 2) + self.assertTrue( + proto_types.ButtonRequest_SignTx not in self._confirm_codes(), + "the flow reached the SignTx confirmation after the user refused " + "an output") + + # ------------------------------------------------------------------ + # L9 -- the shared OMNI branch survived the strip + # ------------------------------------------------------------------ + + def test_omni_op_return_is_still_decoded(self): + """The OMNI branch sits above the #if and must be untouched. + + compile_output() tests for an "omni" prefix BEFORE the BITCOIN_ONLY + split, so an OMNI simple send is still decoded into "Do you want to + send 1.0 OMNI?" on the bitcoin-only image. The regression this guards + against is the new #else swallowing the OMNI case, which would silently + downgrade a decoded amount to a hex dump. + + Proved by contrast rather than by OCR: the same twenty bytes with the + leading 'o' changed to 'p' are no longer OMNI and fall through to the + raw-data confirmation. The two screens must differ, and the decoded one + must be the sparser of the two -- one short sentence against forty hex + digits. + + Both payloads ride in ONE transaction, as two data outputs, rather than + in two signings. That is not stylistic: a transaction ending in + OP_RETURN poisons the duplicate-transaction detector, so a second + signing in the same session is refused (see + test_op_return_does_not_poison_the_duplicate_detector). + """ + self.setup_mnemonic_nopin_nopassphrase() + self._reset_screens() + + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + self._sign([self._data_output(OMNI_SIMPLE_SEND), + self._data_output(NOT_OMNI), + out_pay]) + + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # OMNI, decoded + proto_types.ButtonRequest_ConfirmOutput, # same bytes, raw + proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_SignTx]) + + omni_screen = self._screen(0) + raw_screen = self._screen(1) + + self.assertNotEqual(bytes(omni_screen), bytes(raw_screen)) + self.assertGreater(lit_pixels(omni_screen), 200) + self.assertGreater(lit_pixels(raw_screen), lit_pixels(omni_screen)) + + + # ------------------------------------------------------------------ + # L10/L11 -- the duplicate-transaction detector and OP_RETURN + # ------------------------------------------------------------------ + + def test_repeated_transaction_is_allowed_without_op_return(self): + """The control for the test below: an exact repeat is NOT a duplicate. + + compile_output() carries an anti-malware check (txin_check.c): warn + when a transaction pays the SAME amount to the SAME address as the + previous one but was built from DIFFERENT inputs, which is what host + malware rewriting a segwit txid looks like. An exact repeat -- same + outputs AND same inputs -- is not that, and is deliberately allowed. + + This is signed twice from the same input here to pin that, so the + refusal in the next test cannot be explained away as the duplicate + guard doing its job. + """ + self.setup_mnemonic_nopin_nopassphrase() + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + + _, first = self._sign([out_pay]) + _, second = self._sign([out_pay]) + self.assertEqual(binascii.hexlify(first), binascii.hexlify(second)) + + def test_op_return_does_not_poison_the_duplicate_detector(self): + """An OP_RETURN output must not falsely condemn the next transaction. + + Found while exercising the BITCOIN_ONLY arm above and NOT caused by it: + it reproduces identically on the multi-chain build, because the code is + shared. Sign a transaction whose LAST output is OP_RETURN, then sign + the transaction the test above just proved is allowed -- and the device + answers "WARNING: DUPLICATE TRANSACTION! Already signed a tx with the + same outputs. To try again, unplug/replug KeepKey." and aborts. + + Mechanism. signing.c calls txin_dgst_final() once per output, and + compile_output() calls txin_dgst_save_and_reset() -- the only thing + that re-initialises the SHA-256 context -- only on the pay-to-address + path. An OP_RETURN output returns before it. So a transaction ending + in OP_RETURN leaves the context finalised and never re-initialised, and + the NEXT transaction's inputs are hashed into a finalised context. Its + digest no longer matches, while the amount and address still do, which + is exactly the (same outputs, different inputs) pattern the check + exists to flag. + + The failure is fail-safe -- it refuses rather than signs -- but it + refuses a legitimate transaction and tells the user to replug, and + every OP_RETURN-terminated transaction arms it. That is every + THORChain/Maya swap the wallet builds. + + Nothing caught it because common.KeepKeyTest wipes the device in + setUp, so no existing test signs two transactions in one session. + """ + self.setup_mnemonic_nopin_nopassphrase() + + out_pay = proto_types.TxOutputType(address=OUT_ADDRESS, + amount=OUT_AMOUNT, + script_type=proto_types.PAYTOADDRESS) + + self._reset_screens() + self._sign([out_pay, self._data_output(THORCHAIN_MEMO)]) + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, # pay-to-address + proto_types.ButtonRequest_ConfirmOutput, # OP_RETURN + proto_types.ButtonRequest_SignTx]) + + self._reset_screens() + try: + self._sign([out_pay]) + except CallException as exc: + self.fail( + "after an OP_RETURN-terminated transaction the device refused " + "the next one with %r; its review screens were %r -- a " + "ConfirmOutput followed by the ButtonRequest_Other of the " + "duplicate-transaction warning. The same transaction signs " + "twice in a row when no OP_RETURN precedes it." + % (exc.args, self._confirm_codes())) + + self.assertEqual( + self._confirm_codes(), + [proto_types.ButtonRequest_ConfirmOutput, + proto_types.ButtonRequest_SignTx]) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 5a31aeb2..f1775816 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -1102,7 +1102,8 @@ def test_advanced_mode_gate(self): to=AAVE_V3_POOL, value=0, data=data, chain_id=1) self.fail("Expected Failure — blind signing disabled") except CallException as e: - self.assertIn("Blind signing disabled", str(e)) + self.assertIn("Arbitrary contract data signing disabled by policy", + str(e)) # ON → raw-data confirm path → signs self.client.apply_policy("AdvancedMode", 1) @@ -1156,7 +1157,8 @@ def test_cancel_clears_metadata_not_reused(self): to=AAVE_V3_POOL, value=0, data=data, chain_id=chain_id) self.fail("Expected Failure — stale metadata must not be reused") except CallException as e: - self.assertIn("Blind signing disabled", str(e)) + self.assertIn("Arbitrary contract data signing disabled by policy", + str(e)) # ── LoadClearsignSigner — the phase-1 trust path ─────────────────── diff --git a/tests/test_msg_ethereum_clearsign_additive.py b/tests/test_msg_ethereum_clearsign_additive.py new file mode 100644 index 00000000..4bea9d3b --- /dev/null +++ b/tests/test_msg_ethereum_clearsign_additive.py @@ -0,0 +1,363 @@ +""" +EVM Clear Signing — the ADDITIVE INVARIANT. + +The whole clear-sign tier rests on one property: + + A runtime-loaded provider may ADD screens. It may never REMOVE one. + +A provider signer is loaded at runtime (LoadClearsignSigner, RAM-only, +user-confirmed) and is NOT verified by KeepKey. Its metadata is therefore +annotation, not authority: after the decoded who/what/why screens the device +must still run the ordinary unverified review — the amount/recipient screen, +the raw-calldata screen and the fee screen a user would have seen with no +metadata at all. If a lying provider could suppress any of those, a runtime +schema would be a screen-substitution oracle: "supply 10.5 DAI to Aave" on the +glass, arbitrary calldata under the signature. + +lib/firmware/ethereum.c:828 is where this is enforced: + + if (signed_metadata_from_loaded_signer()) { + needs_confirm = true; /* forced back ON */ + data_needs_confirm = true; /* forced back ON */ + } else { + needs_confirm = signed_metadata_schema_moves_value(); + data_needs_confirm = false; /* raw review SUPPRESSED */ + } + +The else-branch is reserved for a future firmware-PINNED signer and must not be +reachable by anything a host can load today. + +HOW THESE TESTS MEASURE SCREENS +------------------------------- +Screen counts are never modelled here, they are compared. Every test signs the +SAME transaction twice against the SAME device state — once with no metadata +(the baseline) and once with metadata — and records the raw 2048-byte OLED +framebuffer at each ButtonRequest (ScreenRecorder below, which reads the layout +before the debuglink auto-press). The proof of "nothing was removed" is that +the baseline frames reappear BYTE-FOR-BYTE as the tail of the clear-signed run. +That is immune to pagination and to value-dependent rendering: whatever the +baseline drew, the clear-signed run must still draw, in the same order, last. + +Existing coverage in test_msg_ethereum_clear_signing.py is adjacent but not +this: V5 covers "no metadata -> blind sign", V10 covers replay rejection, V12 +covers cancel-clears-metadata. None of them proves the raw review FOLLOWS a +SUCCESSFUL decode. +""" + +import time +import unittest + +try: + import common +except ImportError: + import sys, os + sys.path.insert(0, os.path.dirname(__file__)) + import common + +from keepkeylib.signed_metadata import ( + serialize_metadata, + serialize_schema_metadata, + sign_metadata, + eth_sighash_legacy, + # aliased: a module-level name starting with 'test_' would be + # collected as a test function by pytest. + test_signer_compressed_pubkey as signer_pubkey, + ARG_FORMAT_ADDRESS, + ARG_FORMAT_AMOUNT, + ARG_FORMAT_TOKEN_AMOUNT, + CLASSIFICATION_VERIFIED, + CLASSIFICATION_MALFORMED, +) +from keepkeylib.tools import parse_path + +# Fixtures and helpers shared with the main clear-sign suite. Imported rather +# than duplicated so a change to the reference vectors cannot leave this +# section quietly testing a different transaction than the atlas describes. +from test_msg_ethereum_clear_signing import ( + AAVE_V3_POOL, + AAVE_SUPPLY_SELECTOR, + CI_SIGNER_ALIAS, + DEFAULT_ARGS, + DEVICE_PATH, + TEST_KEY_ID, + aave_supply_calldata, + recover_eth_signer, +) + +# METADATA_MAX_KEYS in include/keepkey/firmware/signed_metadata.h. +METADATA_MAX_KEYS = 4 + +# The Aave V3 supply() transaction every additive test signs. Real ABI +# calldata (selector + 4 x 32-byte words), so the metadata below binds a +# genuine transaction rather than a toy payload. +TX = dict(chain_id=1, nonce=7, gas_price=20000000000, gas_limit=200000, + value=0) +SUPPLY_AMOUNT = 10500000000000000000 # 10.5 DAI (18 decimals) + + +class ScreenRecorder(object): + """Record the OLED framebuffer of every confirm screen an operation draws. + + Wraps callback_ButtonRequest: reads the layout over DebugLink BEFORE the + normal auto-press (which would replace the screen), then delegates to the + original callback so screenshot capture and the button press still happen + exactly as they do in every other test. + """ + + # The firmware emits ButtonRequest immediately before drawing; the same + # settle used by the screenshot path (client.SCREENSHOT_SETTLE_SECONDS) + # keeps a half-drawn frame out of the comparison. + SETTLE = 0.3 + + def __init__(self, client): + self.client = client + self.frames = [] # list of (ButtonRequestType, 2048-byte layout) + + def __enter__(self): + original = self.client.callback_ButtonRequest + + def record(msg): + time.sleep(self.SETTLE) + self.frames.append((msg.code, bytes(self.client.debug.read_layout()))) + return original(msg) + + # Instance attribute shadows the bound method; client.call() resolves + # the handler with getattr(self, 'callback_ButtonRequest'). + self.client.callback_ButtonRequest = record + return self + + def __exit__(self, *exc): + del self.client.callback_ButtonRequest + return False + + @property + def codes(self): + return [code for code, _ in self.frames] + + @property + def layouts(self): + return [layout for _, layout in self.frames] + + +def bound_supply_metadata(tx_hash, key_id=TEST_KEY_ID): + """v1 metadata committing to a specific real Aave supply() sighash.""" + return sign_metadata(serialize_metadata( + chain_id=TX['chain_id'], + contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, + tx_hash=tx_hash, + method_name='supply', + args=DEFAULT_ARGS, + key_id=key_id, + )) + + +class TestClearSignAdditiveInvariant(common.KeepKeyTest): + """A runtime provider adds screens; it never removes one.""" + + def setUp(self): + super().setUp() + self.requires_firmware("7.15.0") + self.requires_message("EthereumTxMetadata") + self.requires_message("LoadClearsignSigner") + self.setup_mnemonic_nopin_nopassphrase() + # AdvancedMode is required both for the raw-calldata review to be + # reachable at all and for a runtime signer to verify anything. + # apply_policy() re-Initializes, which clears RAM-only signers, so it + # must come BEFORE any load_clearsign_signer() call. + self.client.apply_policy("AdvancedMode", 1) + self.n = parse_path(DEVICE_PATH) + self.data = aave_supply_calldata(SUPPLY_AMOUNT) + self.tx_hash = eth_sighash_legacy( + TX['nonce'], TX['gas_price'], TX['gas_limit'], AAVE_V3_POOL, + TX['value'], self.data, TX['chain_id']) + + def _load_signer(self, key_id=TEST_KEY_ID, alias=CI_SIGNER_ALIAS): + self.client.load_clearsign_signer( + key_id=key_id, pubkey=signer_pubkey(), alias=alias) + + def _sign_supply(self): + return self.client.ethereum_sign_tx( + n=self.n, to=AAVE_V3_POOL, data=self.data, **TX) + + def _record_supply(self): + """Sign the fixture tx, returning (ScreenRecorder, (v, r, s)).""" + with ScreenRecorder(self.client) as rec: + sig = self._sign_supply() + return rec, sig + + def _assert_recovers(self, sig, tx_hash=None): + sig_v, sig_r, sig_s = sig + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + signer = recover_eth_signer(sig_r, sig_s, sig_v, + tx_hash or self.tx_hash, TX['chain_id']) + self.assertEqual(signer, self.client.ethereum_get_address(self.n)) + + def _assert_baseline_survives(self, baseline, observed): + """The core assertion: every baseline screen still appears, unchanged, + in order, as the TAIL of the clear-signed run.""" + self.assertTrue(len(observed.frames) > len(baseline.frames)) + self.assertEqual(observed.frames[-len(baseline.frames):], + baseline.frames) + # And the extra frames really are extra — no baseline screen was + # merely re-drawn earlier to pad the count. + added = observed.frames[:-len(baseline.frames)] + for code, layout in added: + self.assertTrue(layout not in baseline.layouts) + + # ── the invariant ──────────────────────────────────────────────── + + def test_successful_decode_still_runs_the_raw_review(self): + """A VERIFIED v1 decode from a runtime provider ADDS its who/what/why + screens in front of the ordinary unverified review — it replaces none + of them. + + Measured on the emulator for this fixture: the baseline (no metadata) + run draws 3 screens — amount/recipient, raw contract data, fee. The + clear-signed run draws 10: identity, 'Call: supply', contract address, + one screen per attested argument (4), then the SAME 3 baseline frames, + byte-for-byte. 3 + num_args is the structural minimum from + signed_metadata_confirm_screens(); pagination can only raise it. + """ + self._load_signer() + self._drop_setup_screenshots() + + # Baseline: the exact same transaction with no metadata in play. + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + blob = bound_supply_metadata(self.tx_hash) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + + self._assert_baseline_survives(baseline, observed) + # Identity + method + contract + one screen per attested argument. + added = len(observed.frames) - len(baseline.frames) + self.assertTrue(added >= 3 + len(DEFAULT_ARGS)) + + def test_failed_signature_falls_back_to_the_unverified_review(self): + """Metadata whose signature does not verify must leave the signing + flow EXACTLY as it was: the ordinary unverified review, no refusal and + no partial decoded information. + + The device classifies the tampered blob MALFORMED and the subsequent + signing run draws frames byte-identical to the baseline — which is the + strongest available statement of 'nothing decoded leaked onto the + glass', since any decoded screen would be a frame the baseline does + not contain. + """ + self._load_signer() + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + tampered = bytearray(bound_supply_metadata(self.tx_hash)) + tampered[10] ^= 0xFF # inside the signed region + resp = self.client.ethereum_send_tx_metadata( + signed_payload=bytes(tampered), metadata_version=1, + key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + self.assertEqual(observed.frames, baseline.frames) + + def test_no_runtime_slot_can_reach_the_suppression_branch(self): + """Every key slot is additive, so signed_metadata_from_loaded_signer() + is true for every VERIFIED blob this firmware can produce. + + The suppression else-branch is gated on a signer that is NOT runtime- + loaded. This test walks all METADATA_MAX_KEYS slots: each one is loaded + at runtime and each one still shows the full baseline review after its + decode. A slot that suppressed would be caught as a missing tail frame. + """ + for key_id in range(METADATA_MAX_KEYS): + self._load_signer(key_id=key_id, alias='CI Slot %d' % key_id) + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + for key_id in range(METADATA_MAX_KEYS): + with self.subTest(key_id=key_id): + blob = bound_supply_metadata(self.tx_hash, key_id=key_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=key_id) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + observed, sig = self._record_supply() + self._assert_recovers(sig) + self._assert_baseline_survives(baseline, observed) + + def test_no_slot_verifies_without_a_runtime_load(self): + """The complementary half: with no signer loaded, NO slot verifies + anything, so there is no firmware-pinned signer in this build that + could take the suppression branch. + + Phase 1 ships with every built-in METADATA_PUBKEYS slot zeroed; + metadata_pubkey_for() returns NULL for an unloaded slot and + signed_metadata_process() classifies MALFORMED. Sending metadata draws + nothing, so the empty screenshot list for this test is deliberate — the + setUp policy-confirm frame is dropped below so the capture directory + stays empty rather than offering an unrelated screen as evidence. + """ + self._drop_setup_screenshots() + for key_id in range(METADATA_MAX_KEYS): + with self.subTest(key_id=key_id): + blob = bound_supply_metadata(self.tx_hash, key_id=key_id) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=key_id) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + + def test_v2_schema_decode_still_runs_the_raw_review(self): + """The v2 (static schema) path is additive too. + + v2 is where suppression would be most tempting: the schema attests a + decode shape and no tx_hash, so the else-branch drops the raw review + outright (data_needs_confirm = false) and keeps the amount screen only + if signed_metadata_schema_moves_value(). For a runtime signer that + branch is not taken — the decoded screens are followed by the SAME + amount, raw-calldata and fee screens the baseline drew. + + Deliberately schema-decoded against the Aave supply() fixture rather + than an ERC-20 transfer: a recognized token contract has no raw-data + screen in its own baseline (the token path already skips it), so it + could not show that the raw review survives. + """ + self._load_signer() + self._drop_setup_screenshots() + + baseline, sig = self._record_supply() + self._assert_recovers(sig) + + # Same 132-byte supply() calldata, described as a 4-word static + # schema: the device decodes the values from the bytes it signs. + v2_args = [ + {'name': 'asset', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'decimals': 18, 'symbol': 'DAI'}, + {'name': 'onBehalfOf', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'referral', 'format': ARG_FORMAT_AMOUNT}, + ] + blob = sign_metadata(serialize_schema_metadata( + chain_id=TX['chain_id'], contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, method_name='supply', + args=v2_args, timestamp=0, key_id=TEST_KEY_ID)) + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + observed, sig = self._record_supply() + self._assert_recovers(sig) + self._assert_baseline_survives(baseline, observed) + added = len(observed.frames) - len(baseline.frames) + self.assertTrue(added >= 3 + len(v2_args)) + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_msg_ethereum_signtx.py b/tests/test_msg_ethereum_signtx.py index 3e1c7309..1c64064a 100644 --- a/tests/test_msg_ethereum_signtx.py +++ b/tests/test_msg_ethereum_signtx.py @@ -101,7 +101,10 @@ def test_ethereum_signtx_data(self): def test_ethereum_blind_sign_blocked(self): """AdvancedMode OFF + contract data = device refuses to sign (7.15+). - OLED shows 'Blind signing disabled' then Failure. + OLED shows the blind-sign refusal, then Failure. The wire message is + 7.14.2's "Arbitrary contract data signing disabled by policy", which + replaced alpha's shorter "Blind signing disabled" -- it names WHICH + policy refused and what it refused. """ self.requires_firmware("7.15.0") self.requires_fullFeature() @@ -121,7 +124,8 @@ def test_ethereum_blind_sign_blocked(self): ) self.fail("Expected Failure -- blind signing should be blocked") except CallException as e: - self.assertIn("Blind signing disabled", str(e)) + self.assertIn("Arbitrary contract data signing disabled by policy", + str(e)) def test_ethereum_blind_sign_allowed(self): """AdvancedMode ON + contract data = device shows BLIND SIGNATURE warning (7.15+). diff --git a/tests/test_msg_resetdevice.py b/tests/test_msg_resetdevice.py index 278f7e09..385f878e 100644 --- a/tests/test_msg_resetdevice.py +++ b/tests/test_msg_resetdevice.py @@ -193,12 +193,20 @@ def test_reset_device_dice(self): self.assertEqual(' '.join(mnemonic), expected_mnemonic) def test_reset_reentry_disarms_entropy_ack(self): - """An aborted reset must not leave EntropyAck armed. + """An abandoned reset must never leave EntropyAck armed. - Regression: reset_init aborts (dice cancel, PIN mismatch, ...) left - awaiting_entropy set from an earlier run while zeroing int_entropy, - so a following EntropyAck derived the seed from + Regression this guards: reset_init aborts (dice cancel, PIN mismatch, + ...) left awaiting_entropy set from an earlier run while zeroing + int_entropy, so a following EntropyAck derived the seed from sha256(0*32 || host_bytes) -- entirely host-chosen. + + 7.15 closes it EARLIER and more strongly than the original fix did. + #429 replaced the separate awaiting_entropy flag with a single armed + (kind) ceremony, and setup_stage() now REFUSES to open a second + ceremony on top of an armed one. So the re-entry this test used to + perform is rejected outright rather than being allowed and then + disarmed -- there is no second ceremony to leave armed. Both halves are + asserted below: the refusal, and then the original property. """ self.requires_firmware("7.15.0") self.client.wipe_device() @@ -212,7 +220,9 @@ def test_reset_reentry_disarms_entropy_ack(self): label='first')) self.assertIsInstance(ret, proto.EntropyRequest) - # Re-enter with dice, then abort from the host. + # Re-entry is REFUSED while a ceremony is armed. This is the #429 + # guard; before it, the second ResetDevice was accepted and the code + # had to remember to disarm the first one. ret = self.client.call_raw(proto.ResetDevice(display_random=False, strength=256, passphrase_protection=False, @@ -220,8 +230,10 @@ def test_reset_reentry_disarms_entropy_ack(self): language='english', label='second', dice_entropy=True)) - self.assertIsInstance(ret, proto.ButtonRequest) - self.assertEqual(ret.code, proto_types.ButtonRequest_DiceRoll) + self.assertIsInstance(ret, proto.Failure) + self.assertIn('middle of setup', ret.message) + + # Abandon the FIRST ceremony the way the host is told to. ret = self.client.call_raw(proto.Cancel()) self.assertIsInstance(ret, proto.Failure) diff --git a/tests/test_msg_session_trust_lifetime.py b/tests/test_msg_session_trust_lifetime.py new file mode 100644 index 00000000..2baeeb3d --- /dev/null +++ b/tests/test_msg_session_trust_lifetime.py @@ -0,0 +1,460 @@ +""" +Session and Trust Lifetime — provider trust must die on its own. + +Two claims in the 7.15 clear-sign design have never been tested end to end: + + 1. AdvancedMode is SESSION state, never a flash bit. storage.c writes bit 12 + of the storage flags word as zero and ignores it on read (four sites: + storage_writeStorageV11, storage_readStorageV11, + storage_writeStorageV16Plaintext, storage_readStorageV16Plaintext), each + with a comment saying the policy is session-scoped now. The only proof of + that is a power cycle: enable it, restart the firmware, and it must be OFF + while everything else in the same flags word survives. + + 2. A runtime clear-sign signer (LoadClearsignSigner) lives in RAM only and is + revoked by session teardown. session_clear() calls + signed_metadata_clear_signers() unconditionally, so both Initialize + (clear_pin=false) and ClearSession (clear_pin=true) drop it, and a reboot + drops it by construction. + +MODELLING A POWER CYCLE. The emulator's flash is an mmap of `emulator.img` in +its working directory (lib/emulator/setup.c). Killing and relaunching the +process WITHOUT touching that file is a REBOOT: flash contents survive, RAM and +every session variable do not. Deleting the image first would be a FACTORY WIPE +instead, and a wipe proves nothing here — every policy reads back off on a blank +device whether or not it was ever persisted. _power_cycle() therefore keeps the +image, and each power-cycle test asserts a persisted control value came back to +prove the flash really did survive the restart. + +WHY THE POLICY CALLS ARE RAW. ProtocolMixin.apply_policy() sends Initialize +afterwards to refresh Features, and Initialize is itself one of the teardown +paths under test — using it would clear the signer as a side effect and make +every assertion below vacuous. _apply_policy_raw() sends the bare ApplyPolicies +and reads state back with GetFeatures, which touches no session state. + +test_msg_ethereum_clear_signing.py covers loading a signer, the persist=true +refusal and the wipe path. Nothing here duplicates that: this file is only +about how loaded trust DIES. +""" + +from __future__ import print_function + +import os +import subprocess +import time +import unittest + +import common +import config + +from keepkeylib import messages_pb2 as proto +from keepkeylib import types_pb2 as proto_types +from keepkeylib.client import CallException, KeepKeyDebuglinkClient +from keepkeylib.transport_udp import UDPTransport +from keepkeylib.signed_metadata import ( + ARG_FORMAT_STRING, + CLASSIFICATION_MALFORMED, + CLASSIFICATION_VERIFIED, + serialize_metadata, + sign_metadata, + # aliased: pytest would otherwise collect the helper as a test function + test_signer_compressed_pubkey as signer_compressed_pubkey, +) + +# Same CI slot/alias the clear-sign suite uses. Phase-1 firmware ships with no +# built-in keys, so slot 3 is empty until LoadClearsignSigner fills it. +TEST_KEY_ID = 3 +CI_SIGNER_ALIAS = 'CI Test' + +AAVE_V3_POOL = bytes.fromhex('7d2768de32b0b80b7a3454c06bdac94a69ddc7a9') +AAVE_SUPPLY_SELECTOR = bytes.fromhex('617ba037') +PROBE_ARGS = [ + {'name': 'protocol', 'format': ARG_FORMAT_STRING, 'value': b'Aave V3'}, +] + + +def probe_blob(): + """A VERIFIED-classification blob signed by the CI test key for slot 3. + + Used only as an oracle for "is the signer still in the slot?": the device + answers VERIFIED while the slot holds the matching pubkey and MALFORMED once + it does not. No transaction is signed, so no tx_hash binding is needed. + """ + return sign_metadata(serialize_metadata( + chain_id=1, + contract_address=AAVE_V3_POOL, + selector=AAVE_SUPPLY_SELECTOR, + tx_hash=b'\x00' * 32, + method_name='supply', + args=PROBE_ARGS, + key_id=TEST_KEY_ID, + )) + + +def _emulator_process(port): + """(pid, exe, cwd) of the process BOUND to udp/port, or None. + + Skips this test client's own connected socket, which lsof also reports on + the same port but as a `local->remote` pair rather than a bare bind. + """ + try: + out = subprocess.run(['lsof', '-nP', '-iUDP:%d' % port, '-Fpn'], + capture_output=True, text=True).stdout + except FileNotFoundError: + raise RuntimeError( + "lsof is required to find and restart the emulator for the " + "power-cycle tests; install it or run these against a device you " + "can power-cycle by hand") + pid = None + for line in out.splitlines(): + if line.startswith('p'): + pid = int(line[1:]) + elif line.startswith('n') and pid is not None: + name = line[1:] + if '->' in name or not name.endswith(':%d' % port): + continue + exe = subprocess.run(['ps', '-o', 'comm=', '-p', str(pid)], + capture_output=True, text=True).stdout.strip() + cwd_out = subprocess.run( + ['lsof', '-a', '-p', str(pid), '-d', 'cwd', '-Fn'], + capture_output=True, text=True).stdout + cwd = None + for cwd_line in cwd_out.splitlines(): + if cwd_line.startswith('n'): + cwd = cwd_line[1:] + return pid, exe, cwd + return None + + +class TestSessionTrustLifetime(common.KeepKeyTest): + + MIN_FIRMWARE = "7.15.0" + + def setUp(self): + super(TestSessionTrustLifetime, self).setUp() + self.requires_firmware(self.MIN_FIRMWARE) + + # ── helpers ──────────────────────────────────────────────────────── + + def _apply_policy_raw(self, name, enabled): + """ApplyPolicies with NO trailing Initialize. See module docstring.""" + return self.client.call(proto.ApplyPolicies( + policy=[proto_types.PolicyType(policy_name=name, enabled=enabled)])) + + def _policy(self, name): + """Read a policy back with GetFeatures — touches no session state.""" + features = self.client.call(proto.GetFeatures()) + for policy in features.policies: + if policy.policy_name == name: + return policy.enabled + self.fail("no such policy: %s" % name) + + def _signer_still_loaded(self): + """VERIFIED => slot 3 still holds the CI signer; MALFORMED => empty. + + Requires AdvancedMode ON: fsm_msgEthereumTxMetadata refuses outright + without it, which is a different answer from "the slot is empty" and is + asserted separately where it matters. + """ + resp = self.client.ethereum_send_tx_metadata( + signed_payload=probe_blob(), metadata_version=1, + key_id=TEST_KEY_ID) + return resp.classification + + def _assertClassification(self, expected, why): + """assertEqual with a message. common.KeepKeyTest narrows assertEqual to + two positional args, so the reason a lifetime assertion matters would + otherwise be lost at the point it fails.""" + got = self._signer_still_loaded() + self.assertTrue(got == expected, + "%s (classification %d, expected %d)" % (why, got, expected)) + + def _arm_session(self): + """Seed the device, turn AdvancedMode on, load the CI signer, and prove + the signer really is live before anything tries to revoke it.""" + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, pubkey=signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS) + self._assertClassification( + CLASSIFICATION_VERIFIED, + "the CI signer did not take — nothing below can be evidence about " + "revoking trust that was never armed") + + def _persist_marker_across_all_sectors(self): + """Set the Experimental policy, then commit enough times that EVERY + storage sector holds a record written after it was set. + + This exists because of a real emulator/firmware interaction that would + otherwise make every power-cycle assertion below vacuous. + storage_commit() calls wear_leveling_shift(), so consecutive commits + land in FLASH_STORAGE1 -> 2 -> 3 -> 1, and each commit erases the + sector it leaves. On the emulator flash_erase_word() is compiled out + entirely (keepkey_flash.c is `#ifndef EMULATOR`), so the abandoned + sectors keep their "stor" magic — and find_active_storage() takes the + FIRST sector carrying that magic. A rebooted emulator therefore reads + whichever record last happened to land in STORAGE1, which can be two + commits stale. + + Consequence if ignored: an AdvancedMode bit written one commit before + the restart lands in STORAGE2 or STORAGE3, boot reads the older + STORAGE1 record, and the policy reads back OFF for a reason that has + nothing to do with it being session-scoped. The test would pass on a + firmware that persisted it. Padding the commits removes the ambiguity, + and the Experimental marker is what proves it was removed: it is set + AFTER AdvancedMode, so any record containing it was written while + AdvancedMode was on in RAM. Assert the marker came back before + asserting anything about AdvancedMode. + """ + for _ in range(4): + self._apply_policy_raw("Experimental", True) + + def _power_cycle(self): + """Kill and relaunch the firmware, KEEPING its flash image. + + This is a reboot, not a wipe: emulator.img is left alone, so anything + committed to flash comes back and anything that only lived in RAM does + not. There is no protocol message that reboots a KeepKey, so on a + transport that is not a local UDP emulator this fails loudly rather than + skipping — a skipped lifetime test is indistinguishable from a passing + one in the report, and that is exactly how a real defect stayed hidden + for a release. + """ + if config.TRANSPORT is not UDPTransport: + self.fail("power cycle requires the local UDP emulator; on real " + "hardware this is an operator step (unplug/replug) and " + "must be recorded as manual evidence, not skipped") + + port = int(str(config.TRANSPORT_ARGS[0]).split(':')[1]) + found = _emulator_process(port) + self.assertIsNotNone( + found, "no emulator process is bound to udp/%d" % port) + pid, exe, cwd = found + + self.client.close() + subprocess.run(['kill', str(pid)]) + for _ in range(100): + if _emulator_process(port) is None: + break + time.sleep(0.1) + self.assertIsNone(_emulator_process(port), + "emulator pid %d did not exit" % pid) + + env = dict(os.environ) + env['KEEPKEY_UDP_PORT'] = str(port) + subprocess.Popen([exe], cwd=cwd, env=env, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + + # Wait for the new instance to answer before reconnecting. + deadline = time.time() + 20 + while time.time() < deadline: + if _emulator_process(port) is not None: + break + time.sleep(0.1) + self.assertIsNotNone(_emulator_process(port), + "emulator did not come back on udp/%d" % port) + time.sleep(0.5) + + transport = config.TRANSPORT(*config.TRANSPORT_ARGS, + **config.TRANSPORT_KWARGS) + debug_transport = config.DEBUG_TRANSPORT(*config.DEBUG_TRANSPORT_ARGS, + **config.DEBUG_TRANSPORT_KWARGS) + client = KeepKeyDebuglinkClient(transport) + client.set_debuglink(debug_transport) + client.screenshot_dir = getattr(self.client, 'screenshot_dir', None) + client.screenshot_id = getattr(self.client, 'screenshot_id', 0) + self.client = client + self.client.init_device() + + # ── 1. AdvancedMode lifetime ─────────────────────────────────────── + + def test_advanced_mode_is_off_after_power_cycle(self): + """AdvancedMode must not survive a reboot, and the control must. + + Experimental and AdvancedMode are neighbouring bits of the SAME storage + flags word (11 and 12), set by the SAME ApplyPolicies message, written + by the SAME storage_writeStorageV16Plaintext call. Turning both on and + rebooting separates a persisted policy from a session one: Experimental + comes back, AdvancedMode must not. Experimental is set AFTER + AdvancedMode, so the record it came back from was written while + AdvancedMode was armed — bit 12 was offered to the writer and dropped. + The seed and label surviving are the second control: without them a + reboot would be indistinguishable from a factory wipe, which turns every + policy off for the wrong reason. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + self._persist_marker_across_all_sectors() + self.assertTrue(self._policy("AdvancedMode")) + self.assertTrue(self._policy("Experimental")) + + self._power_cycle() + + self.assertTrue(self.client.features.initialized, + "reboot lost the seed — this modelled a wipe, not a " + "power cycle, and proves nothing about persistence") + self.assertEqual(self.client.features.label, 'test') + self.assertTrue(self._policy("Experimental"), + "the marker policy did not come back, so the record " + "read at boot predates the AdvancedMode change and no " + "conclusion about bit 12 can be drawn from it") + self.assertFalse(self._policy("AdvancedMode"), + "AdvancedMode came back ON after a power cycle — it " + "is being persisted to flash, which storage.c " + "explicitly forbids (bit 12 is burned)") + + def test_advanced_mode_survives_initialize_but_not_clear_session(self): + """The asymmetry in session_clear() is deliberate; pin it down. + + session_clear_impl() disarms AdvancedMode only when clear_pin is set. + ClearSession passes true, Initialize passes false. Hosts send + Initialize before nearly every operation, so disarming there would cost + a fresh button press each time; ClearSession is an explicit lock and + must revoke the capability. If this ever inverts, blind signing either + becomes unusable or outlives the lock. + """ + self.setup_mnemonic_nopin_nopassphrase() + self._apply_policy_raw("AdvancedMode", True) + + self.client.call(proto.Initialize()) + self.assertTrue(self._policy("AdvancedMode"), + "Initialize disarmed AdvancedMode — every host sends " + "it routinely, so the policy would be unusable") + + self.client.clear_session() + self.assertFalse(self._policy("AdvancedMode"), + "ClearSession left AdvancedMode armed — an explicit " + "lock must revoke the blind-signing capability") + + # ── 2. Loaded-signer lifetime ────────────────────────────────────── + + def test_signer_dropped_by_initialize(self): + """Session teardown revokes the signer while the policy stays armed. + + The MALFORMED here is unambiguous: AdvancedMode is asserted still ON + immediately before the probe, so the metadata gate cannot be what + refused it — the slot is empty. The GetFeatures probe first is the + negative control: merely exchanging messages must NOT drop a signer, or + this test would pass for the wrong reason. + """ + self._arm_session() + + self.client.call(proto.GetFeatures()) + self._assertClassification( + CLASSIFICATION_VERIFIED, + "an ordinary message dropped the signer; the teardown assertion " + "below would then prove nothing") + + self.client.call(proto.Initialize()) + self.assertTrue(self._policy("AdvancedMode")) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer survived session teardown — runtime trust must not " + "outlive the session that consented to it") + + def test_signer_dropped_by_clear_session(self): + """ClearSession revokes both halves of the trust. + + Right after the lock the metadata message is refused outright, because + ClearSession also disarmed AdvancedMode — that Failure is the policy + gate, not evidence about the slot. Re-arming the policy WITHOUT an + Initialize isolates the slot: MALFORMED then means the signer itself is + gone. + """ + self._arm_session() + + self.client.clear_session() + + with self.assertRaises(CallException) as ctx: + self._signer_still_loaded() + self.assertIn("AdvancedMode required", str(ctx.exception)) + + self._apply_policy_raw("AdvancedMode", True) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer survived ClearSession — an explicit lock left provider " + "trust loaded in RAM") + + def test_signer_dropped_by_power_cycle(self): + """Reboot drops the signer; the seed proves it was a reboot. + + Loaded signers are RAM only, so this should be true by construction — + but "by construction" is exactly the claim a persist=true bug would + break, and the report needs the reboot on record rather than inferred. + Storage is preserved (see _power_cycle), so the surviving seed, label + and marker policy rule out a wipe having done the work. The marker is + set after the signer is loaded, so the record the device boots into is + one that was written while the signer was live — if a build ever did + persist signers, this is the record it would have persisted them into. + """ + self._arm_session() + self._persist_marker_across_all_sectors() + + self._power_cycle() + + self.assertTrue(self.client.features.initialized, + "reboot lost the seed — this modelled a wipe, not a " + "power cycle") + self.assertEqual(self.client.features.label, 'test') + self.assertTrue(self._policy("Experimental"), + "the marker policy did not come back, so flash was not " + "preserved across the restart") + self.assertFalse(self._policy("AdvancedMode")) + + self._apply_policy_raw("AdvancedMode", True) + self._assertClassification( + CLASSIFICATION_MALFORMED, + "the signer came back after a power cycle — it was written to flash") + + def test_disabling_advanced_mode_makes_signer_inert_not_erased(self): + """MEASURED behaviour, and it is NOT "disabling AdvancedMode clears the + signer". + + Turning the policy off does make the signer unusable: every consumer in + signed_metadata.c (signed_metadata_process, _verify_attestation, + _signer_fingerprint) refuses a runtime slot while AdvancedMode is off, + so the metadata message fails closed. But nothing erases the slot — + storage_setPolicy() only flips a policy bit, and only session_clear() + calls signed_metadata_clear_signers(). Turn the policy back on and the + old signer verifies again, with NO second trust screen: the expected + response list below is exactly one ApplyPolicies ButtonRequest and a + Success, so the "Trust 'CI Test' (…) NOT verified by KeepKey" consent is + provably not re-shown. + + Why the host path looks otherwise: ProtocolMixin.apply_policy() follows + every policy change with Initialize, and it is that Initialize — not the + policy change — that clears the signer (test_signer_dropped_by_initialize). + A host that sends the bare message gets the behaviour asserted here. + + Consequence to weigh at release: 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. + """ + self._arm_session() + + self._apply_policy_raw("AdvancedMode", False) + with self.assertRaises(CallException) as ctx: + self._signer_still_loaded() + self.assertIn("AdvancedMode required", str(ctx.exception)) + + with self.client: + self.client.set_expected_responses([ + proto.ButtonRequest( + code=proto_types.ButtonRequest_ApplyPolicies), + proto.Success(), + ]) + self._apply_policy_raw("AdvancedMode", True) + + self._assertClassification( + CLASSIFICATION_VERIFIED, + "the signer did NOT survive the policy toggle. That is stricter " + "than the code path allows today, so something changed: re-read " + "storage_setPolicy() and signed_metadata_clear_signers() before " + "loosening this assertion") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_sign_typed_data.py b/tests/test_sign_typed_data.py index 26dbf90d..dc583ab3 100644 --- a/tests/test_sign_typed_data.py +++ b/tests/test_sign_typed_data.py @@ -75,21 +75,29 @@ def test_ethereum_sign_x402_eip3009(self): }, } - # Structured EIP-712 is clear-signable with blind signing disabled. + # 7.14.2 DISABLED structured EIP-712 outright, pending canonical + # display hardening: the device could not prove that what it rendered + # was what it hashed. This vector is the x402 EIP-3009 + # TransferWithAuthorization payment flow, and it is currently REFUSED + # rather than signed. + # + # The expected hashes are retained below the refusal, unused, because + # they are independent reference values from the EIP-712 V4 encoder and + # are what this test should assert again the day the display hardening + # lands. Deleting them would lose the only checked-in oracle for this + # vector. See docs/security/ for the 7.16 structured-EIP-712 item. self.client.apply_policy('AdvancedMode', False) - response = self.client.ethereum_sign_typed_data( - tools.parse_path("m/44'/60'/0'/0/0"), typed_data) + with self.assertRaises(CallException) as ctx: + self.client.ethereum_sign_typed_data( + tools.parse_path("m/44'/60'/0'/0/0"), typed_data) + self.assertIn("Structured EIP-712 disabled", str(ctx.exception)) - # Hashes are independent reference values from the EIP-712 V4 encoder. - self.assertEqual( - binascii.hexlify(response.domain_separator_hash), - b"71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818") - self.assertEqual( - binascii.hexlify(response.message_hash), - b"ccb8d59d2e8a63beafb02887b4c9dd2f79d3527df4167f8c6b36e3e43cf373be") - self.assertEqual(response.address, - "0x73d0385F4d8E00C5e6504C6030F47BF6212736A8") - self.assertEqual(len(response.signature), 65) + # Re-enable when structured EIP-712 returns: + # domain_separator_hash + # 71f17a3b2ff373b803d70a5a07c046c1a2bc8e89c09ef722fcb047abe94c9818 + # message_hash + # ccb8d59d2e8a63beafb02887b4c9dd2f79d3527df4167f8c6b36e3e43cf373be + # address 0x73d0385F4d8E00C5e6504C6030F47BF6212736A8, 65-byte signature def test_ethereum_sign_typed_data_hash(self): self.requires_fullFeature() @@ -119,7 +127,10 @@ def sign(test): self.client.apply_policy('AdvancedMode', False) with self.assertRaises(CallException) as ctx: sign(txtests['tests'][0]) - self.assertIn('disabled by policy', str(ctx.exception)) + # The firmware names the remedy rather than just the refusal: + # "Enable AdvancedMode to blind-sign typed hashes". + self.assertIn('Enable AdvancedMode to blind-sign typed hashes', + str(ctx.exception)) self.client.apply_policy('AdvancedMode', True) try: diff --git a/tests/test_storage_version_gate.py b/tests/test_storage_version_gate.py new file mode 100644 index 00000000..b04d5f64 --- /dev/null +++ b/tests/test_storage_version_gate.py @@ -0,0 +1,726 @@ +# This file is part of the KeepKey project. +# +# Storage version gate -- upgrade preservation and downgrade wipe. +# +# Policy, from docs/StorageVersionGate.md, in two sentences: +# +# A signed UPGRADE must never wipe. A DOWNGRADE wipes, and that is correct. +# +# Nothing in this suite tested either half before this file. Both directions +# are release blockers: a wiping upgrade destroys every field wallet with no +# prompt, and a downgrade that DOESN'T wipe would let an attacker roll back to +# an older signed image with a known extraction bug and keep the seed. +# +# How the wipe happens, mechanically (lib/firmware/storage.c): +# +# storage_init() -> storage_fromFlash() -> version_from_int(raw_version) +# An unrecognised version returns StorageVersion_NONE, storage_fromFlash() +# returns SUS_Invalid, and storage_init() runs storage_reset() + +# storage_commit(). No prompt, no warning -- the wallet is gone at boot. +# +# So "does this firmware recognise the version in flash?" IS the whole +# question, and every test below is a way of asking it. +# +# --------------------------------------------------------------------------- +# What runs where, and why the emulator can prove any of this at all +# --------------------------------------------------------------------------- +# +# The version gate only runs at BOOT. There is no host-driven reboot: the +# SoftReset message (messages.proto type 89) has no entry in +# lib/firmware/messagemap.def, and fsm_msgDebugLinkFlashDump() is compiled out +# under #ifndef EMULATOR, so the emulator can neither be rebooted nor have its +# flash read over the wire. The only way to cross the boot boundary is to own +# the emulator process and its flash image file. +# +# That is what TestStorageUpgradePreservation does: it starts its OWN kkemu on +# its OWN port pair in its OWN temp directory, so it never touches whichever +# emulator the rest of the suite is talking to. Killing the process and +# starting it again on the same emulator.img IS a power cycle -- lib/emulator/ +# setup.c mmaps that file as the flash array, so every flash write survives. +# +# Restamping the version word in that image is not "faking an upgrade". It +# reproduces exactly what an arriving device presents to the incoming +# firmware: a blob whose header says one version while the firmware compiled +# in says another. It does NOT exercise the layout migration chain, because +# the bytes under the stamp were written by this build -- see +# test_v16_blob_upgrades_without_wiping for how far that is taken, and the +# module docstring in the report section for what is still untested. +# +# TestStorageVersionGateSource needs no device at all: it reads the firmware +# sources and asserts the gate's own invariants. Those tests run everywhere, +# including CI, so this section is never completely dark. + +from __future__ import print_function + +import glob +import os +import re +import shutil +import socket +import struct +import subprocess +import sys +import tempfile +import time +import unittest + +_HERE = os.path.dirname(os.path.abspath(__file__)) +if _HERE not in sys.path: + sys.path.insert(0, _HERE) +_PYKEEPKEY = os.path.dirname(_HERE) +if _PYKEEPKEY not in sys.path: + sys.path.insert(0, _PYKEEPKEY) + + +# --------------------------------------------------------------------------- +# Flash layout constants +# --------------------------------------------------------------------------- +# Emulator flash file offsets. lib/emulator/setup.c mmaps emulator.img at +# FLASH_ORIGIN (0x08000000), so a flash address maps to file offset +# address - 0x08000000. The three storage sectors come from +# flash_sector_map[] in include/keepkey/board/memory.h. +SECTOR_OFFSETS = (0x4000, 0x8000, 0xC000) # FLASH_STORAGE1/2/3 +SECTOR_RECORD_LEN = 2572 # sizeof(flash_temp) in storage_commit() + +# STORAGE_MAGIC_STR, include/keepkey/board/keepkey_board.h +STORAGE_MAGIC = b"stor" + +# Metadata is 44 bytes; the Storage record starts right after it, and its +# first word is the version. Everything below is (44 + offset-within-Storage), +# with the inner offsets taken from storage_readStorageV16Plaintext() and +# storage_readStorageV17() in lib/firmware/storage.c -- NOT from docs/ +# Storage.md, whose V17 table has a stale byte count. +OFF_VERSION = 44 + 0 +OFF_FLAGS = 44 + 4 +OFF_AUTHDATA_FINGERPRINT = 44 + 469 # 32 bytes, V17 only +OFF_ENCSEC_VERSION = 44 + 1497 +OFF_ENCSEC = 44 + 1501 +V16_ENCSEC_SIZE = 512 # lib/firmware/storage.h +V17_ENCSEC_SIZE = 1024 + +FLAG_HAS_SEC_FINGERPRINT = 1 << 14 +FLAG_AUTHDATA_INITIALIZED = 1 << 18 +FLAG_AUTHDATA_ENCRYPTED = 1 << 19 + +# include/keepkey/firmware/storage.h +STORAGE_VERSION_BTC_ONLY_BASE = 10000 + +MNEMONIC_ALL = " ".join(["all"] * 12) +LABEL = "storagegate" +PIN = "1234" +BIP44_ADDRESS_N = [2147483692, 2147483648, 2147483648, 0, 0] # m/44'/0'/0'/0/0 + + +# --------------------------------------------------------------------------- +# Firmware source access +# --------------------------------------------------------------------------- + +def _repo_root(): + """Directory of the firmware checkout this python-keepkey lives under.""" + d = _HERE + for _ in range(8): + if os.path.isfile(os.path.join(d, "lib", "firmware", "storage.c")): + return d + parent = os.path.dirname(d) + if parent == d: + break + d = parent + return None + + +_ROOT = _repo_root() + + +def _read_source(rel): + assert _ROOT, ( + "firmware sources not found above %s -- the storage version gate is a " + "property of lib/firmware/storage.c and cannot be checked without it" % _HERE + ) + with open(os.path.join(_ROOT, rel)) as f: + return f.read() + + +def _define(text, name): + """Value of a simple integer #define, tolerating a line continuation. + + STORAGE_VERSION is written as `#define STORAGE_VERSION \\\n 17 /* ... */`, + so the continuation has to be folded before matching. + """ + folded = text.replace("\\\n", " ") + m = re.search(r"^\s*#\s*define\s+" + name + r"\b\s+(\d+)", folded, re.M) + assert m, "no integer #define %s found" % name + return int(m.group(1)) + + +# --------------------------------------------------------------------------- +# Emulator process management +# --------------------------------------------------------------------------- + +def _find_emulator(): + """Locate a kkemu binary this test can start and stop. + + KK_EMULATOR_BIN wins. Otherwise look where the two build recipes put it: + scripts/emulator/Dockerfile configures in-source (bin/kkemu at the repo + root), while local work uses an out-of-tree build-* directory. build-emu is + named before the generic glob on purpose -- a bitcoin-only build stamps its + own wallets into the reserved band, which is a different device under + test_bitcoin_only_band_refuses_without_wiping. + """ + env = os.environ.get("KK_EMULATOR_BIN") + if env: + return env if os.access(env, os.X_OK) else None + if not _ROOT: + return None + candidates = [os.path.join(_ROOT, "bin", "kkemu"), + os.path.join(_ROOT, "build-emu", "bin", "kkemu")] + candidates += sorted(glob.glob(os.path.join(_ROOT, "build*", "bin", "kkemu"))) + for c in candidates: + if os.access(c, os.X_OK): + return c + return None + + +_EMULATOR_BIN = _find_emulator() + +_NO_EMULATOR = ( + "no kkemu binary to start and stop (looked at $KK_EMULATOR_BIN, " + "/bin/kkemu, /build*/bin/kkemu). The version gate only runs at " + "boot, and there is no host-driven reboot -- SoftReset is unimplemented and " + "DebugLinkFlashDump is compiled out under EMULATOR -- so these tests must " + "own the emulator process. In CI the python-keepkey container is built from " + "scripts/emulator/python-keepkey.Dockerfile, which copies the source but " + "never builds the emulator, so this section is UNPROVEN there until that " + "image ships a kkemu." +) + + +def _free_port_pair(): + """A UDP port p where p and p+1 are both free (kkemu uses p and p+1).""" + for _ in range(200): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + s.bind(("127.0.0.1", 0)) + p = s.getsockname()[1] + finally: + s.close() + if p % 2: + continue + t = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + try: + t.bind(("127.0.0.1", p + 1)) + except socket.error: + continue + finally: + t.close() + return p + raise RuntimeError("no free UDP port pair for the emulator") + + +class Emulator(object): + """One kkemu process over one flash image, restartable. + + The image is the whole point: lib/emulator/setup.c mmaps emulator.img over + the firmware's flash array, so halting the process and booting it again + replays storage_init() against exactly the bytes the previous run left. + """ + + def __init__(self, workdir): + self.workdir = workdir + self.port = _free_port_pair() + self.img = os.path.join(workdir, "emulator.img") + self.proc = None + + # -- process ------------------------------------------------------------ + + def _ping(self): + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.settimeout(0.5) + try: + s.sendto(b"PINGPING", ("127.0.0.1", self.port)) + return s.recv(8) == b"PONGPONG" + except socket.error: + return False + finally: + s.close() + + def boot(self): + assert self.proc is None, "already booted" + env = dict(os.environ, KEEPKEY_UDP_PORT=str(self.port)) + with open(os.path.join(self.workdir, "emu.log"), "ab") as log: + self.proc = subprocess.Popen( + [_EMULATOR_BIN], cwd=self.workdir, env=env, stdout=log, + stderr=subprocess.STDOUT) + for _ in range(100): + time.sleep(0.1) + if self.proc.poll() is not None: + raise RuntimeError( + "emulator exited rc=%s before answering; see %s" + % (self.proc.returncode, os.path.join(self.workdir, "emu.log"))) + if self._ping(): + return + raise RuntimeError("emulator did not answer PINGPING on port %d" % self.port) + + def halt(self): + """Power cycle, not a graceful shutdown -- flash keeps whatever + storage_commit() already wrote, which is what a real yank does.""" + if self.proc is None: + return + if self.proc.poll() is None: + self.proc.terminate() + try: + self.proc.wait(timeout=10) + except Exception: + self.proc.kill() + self.proc.wait() + self.proc = None + time.sleep(0.2) + + # -- client ------------------------------------------------------------- + + def client(self, method, pin=None): + """Debuglink client bound to THIS emulator. + + Deliberately does not go through tests/config.py: that module picks + HID/WebUSB when a real KeepKey is plugged in, which would send these + wipes at somebody's hardware wallet. + """ + from keepkeylib.client import KeepKeyDebuglinkClient + from keepkeylib.transport_udp import UDPTransport + + c = KeepKeyDebuglinkClient(UDPTransport("127.0.0.1:%d" % self.port)) + c.set_debuglink(UDPTransport("127.0.0.1:%d" % (self.port + 1))) + c.setup_debuglink(button=True, pin_correct=True) + _screenshots_to(c, method) + if pin: + _teach_pin(c, pin) + return c + + # -- flash image -------------------------------------------------------- + + def image(self): + with open(self.img, "rb") as f: + return f.read() + + def active_sector(self): + """Offset find_active_storage() would pick: FIRST sector with the magic. + + lib/board/memory.c scans FLASH_STORAGE1..3 in order and takes the first + one whose first four bytes are "stor". Order matters, not recency. + """ + img = self.image() + for off in SECTOR_OFFSETS: + if img[off:off + 4] == STORAGE_MAGIC: + return off + return None + + def sector(self, off): + return self.image()[off:off + SECTOR_RECORD_LEN] + + def patch(self, off, rel, data): + assert self.proc is None, "patch the image only while the device is off" + with open(self.img, "r+b") as f: + f.seek(off + rel) + f.write(data) + f.flush() + os.fsync(f.fileno()) + + def read_u32(self, off, rel): + return struct.unpack("/), + and must be set before the first ButtonRequest: the wipe and load confirms + are captured by the client's own callback, and without this they land in + the SCREENSHOT_DIR root where _build_frame_census() cannot see them. + + Set here rather than by conftest.py because these tests do not inherit + common.KeepKeyTest -- its setUp() builds a client from config.py and wipes + whatever that resolves to -- so the conftest hook never fires for them. + """ + if os.environ.get("KEEPKEY_SCREENSHOT") != "1": + return + d = os.path.join(os.environ.get("SCREENSHOT_DIR", "screenshots"), + "storage_version_gate", method) + if not os.path.isdir(d): + os.makedirs(d) + client.screenshot_dir = d + client.screenshot_id = len(glob.glob(os.path.join(d, "btn*.png"))) + + +def _capture(client): + """Grab the OLED as it stands. The confirm screens capture themselves on + ButtonRequest; the home screen after a boot has no button behind it, so it + has to be asked for.""" + if os.environ.get("KEEPKEY_SCREENSHOT") != "1": + return + client._capture_oled() + + +# --------------------------------------------------------------------------- +# The gate's own invariants, read out of the firmware sources +# --------------------------------------------------------------------------- + +class TestStorageVersionGateSource(unittest.TestCase): + """No device needed. These are the checks that survive a CI runner which + cannot restart an emulator, so the section is never entirely unmeasured.""" + + def setUp(self): + self.h = _read_source("include/keepkey/firmware/storage.h") + self.c = _read_source("lib/firmware/storage.c") + self.inc = _read_source("lib/firmware/storage_versions.inc") + self.version = _define(self.h, "STORAGE_VERSION") + self.last_shipped = _define(self.h, "STORAGE_VERSION_LAST_SHIPPED") + + def test_active_flash_format_is_v17(self): + """alpha writes V17, the same format shipped v7.14.1. + + This literal is an INDEPENDENT witness, on purpose. The compile-time + assert in storage.c compares STORAGE_VERSION against + STORAGE_VERSION_LAST_SHIPPED -- two numbers in the same header, both + editable in one commit, and raising LAST_SHIPPED to make a build + compile is the exact edit docs/StorageVersionGate.md calls the highest + severity review item in the file. + + 7.15 reverted the flash format from V19 back to V17 (6bebde7b2). V19 + migrated 17 -> 19 on the first boot, with no prompt, after which no + downgrade was possible without a wipe; V18's clear-sign identity block + is dead. The V19 serializer is still in the tree behind + STORAGE_PIN_KDF_V19 == 0. If a release re-lands it, this test must + fail and the bump must be argued for, not discovered in the field. + """ + self.assertEqual( + 17, self.version, + "STORAGE_VERSION is %d, not the V17 format 7.15 reverted to. A bump " + "is a deliberate release act (docs/StorageVersionGate.md): confirm " + "the reader chain, the anti-rollback story, and the release notes, " + "then update this test." % self.version) + self.assertEqual(17, self.last_shipped) + + def test_version_never_drops_below_a_shipped_release(self): + """Lowering STORAGE_VERSION wipes every device upgrading FROM a shipped + release: its blob's version stops being recognised, so the gate maps it + to StorageVersion_NONE and storage_init() resets. The version must also + stay under the bitcoin-only band, or a multi-chain wallet would be + stamped into the band that multi-chain firmware refuses to load.""" + self.assertGreaterEqual(self.version, self.last_shipped) + self.assertLess(self.version, STORAGE_VERSION_BTC_ONLY_BASE) + + def test_version_ladder_is_contiguous_and_ends_at_storage_version(self): + """storage_versions.inc may only ever be APPENDED to. + + The enum is emitted in .inc order after StorageVersion_NONE = 0, so a + contiguous 1..N list is what makes StorageVersion_N == N. Deleting or + renumbering an entry silently drops a version from version_from_int() + and wipes every device carrying it. + """ + entries = [int(m) for m in re.findall( + r"STORAGE_VERSION_(?:ENTRY|LAST)\s*\(\s*(\d+)\s*\)", self.inc)] + self.assertTrue(entries, "no version entries parsed from the ladder") + self.assertEqual(list(range(1, len(entries) + 1)), entries, + "storage_versions.inc is not contiguous from 1") + last = re.findall(r"STORAGE_VERSION_LAST\s*\(\s*(\d+)\s*\)", self.inc) + self.assertEqual([str(self.version)], last) + + def test_every_ladder_version_has_a_reader(self): + """Every version in the ladder needs a case in storage_fromFlash(). + + This is the failure the static asserts do NOT cover. They pin the enum + to its own numbering; they say nothing about the switch. Drop a case + and control falls out of the switch to `return SUS_Invalid` -- which + storage_init() answers with storage_reset(). Every device carrying that + version is wiped on upgrade, and the build stays green. + """ + body = self.c.split("StorageUpdateStatus storage_fromFlash", 1) + self.assertEqual(2, len(body), "storage_fromFlash not found") + cases = set(int(m) for m in re.findall( + r"case\s+StorageVersion_(\d+)\s*:", body[1])) + missing = sorted(set(range(1, self.version + 1)) - cases) + self.assertEqual([], missing, + "storage_fromFlash has no case for version(s) %s -- a " + "device carrying one is wiped at boot" % missing) + + +# --------------------------------------------------------------------------- +# Behaviour across a real power cycle +# --------------------------------------------------------------------------- + +@unittest.skipIf(_EMULATOR_BIN is None, _NO_EMULATOR) +class TestStorageUpgradePreservation(unittest.TestCase): + + def setUp(self): + self.method = self.id().split(".")[-1] + self.workdir = tempfile.mkdtemp(prefix="kk-storage-gate-") + self.addCleanup(shutil.rmtree, self.workdir, True) + self.emu = Emulator(self.workdir) + self.addCleanup(self.emu.halt) + + # -- shared arrangement ------------------------------------------------- + + def _create_wallet(self): + """Boot a virgin device, load a known seed behind a PIN, record the + address, and power it off. Returns the address.""" + self.emu.boot() + c = self.emu.client(self.method) + try: + c.wipe_device() + c.load_device_by_mnemonic( + mnemonic=MNEMONIC_ALL, pin=PIN, passphrase_protection=False, + label=LABEL, language="english") + c.init_device() + self.assertTrue(c.features.initialized) + addr = c.get_address("Bitcoin", BIP44_ADDRESS_N) + finally: + c.close() + self.emu.halt() + + off = self.emu.active_sector() + self.assertIsNotNone( + off, "no storage sector carries the %r magic after a wallet was " + "created -- nothing was persisted" % STORAGE_MAGIC) + return addr, off + + def _make_v16_blob(self, off): + """Rewrite the committed V17 record as the V16 record a 7.14.x device + would be carrying when it arrives for this upgrade. + + Only the four things that actually differ between the two formats, + per storage_readStorageV17() vs storage_readStorageV16(): + + * the version stamp; + * flags bits 18/19 (authdata_initialized / authdata_encrypted) -- + V16 has no authenticator section, so both are clear; + * authdata_fingerprint at +469, reserved bytes in V16; + * encrypted_sec is 512 bytes in V16, 1024 in V17. The upper half is + the authenticator block, which a V16 device never wrote. + + Bit 14 (has_sec_fingerprint) is cleared too, and that is not cosmetic: + the fingerprint is taken over 1024 bytes when encrypted_sec_version > + 16 and over 512 when it is not, so a V17 fingerprint can never match a + V16 read. A real V16 blob carries a V16 fingerprint; we cannot forge + one without the storage key, so we present a device that never had + one -- storage_secMigrate() then recomputes and stores it, which is the + same path a genuinely older wallet takes. + """ + flags = self.emu.read_u32(off, OFF_FLAGS) + self.emu.write_u32(off, OFF_FLAGS, flags & ~( + FLAG_HAS_SEC_FINGERPRINT | FLAG_AUTHDATA_INITIALIZED + | FLAG_AUTHDATA_ENCRYPTED)) + self.emu.patch(off, OFF_AUTHDATA_FINGERPRINT, b"\x00" * 32) + self.emu.patch(off, OFF_ENCSEC + V16_ENCSEC_SIZE, + b"\x00" * (V17_ENCSEC_SIZE - V16_ENCSEC_SIZE)) + self.emu.write_u32(off, OFF_ENCSEC_VERSION, 16) + self.emu.write_u32(off, OFF_VERSION, 16) + + # -- tests -------------------------------------------------------------- + + def test_reboot_preserves_the_wallet(self): + """The boundary docs/StorageVersionGate.md says the ordinary tests never + cross. Everything else in this suite lives inside one session, where the + wallet is a RAM shadow; only a power cycle re-runs storage_init() and + proves the bytes in flash were both written and readable. + + The PIN is load-bearing. The seed lives in encrypted_sec, and the key + that decrypts it is only ever stored wrapped by the PIN. An address + that still derives after the reboot proves the wrapped key, its + fingerprint and the ciphertext all round-tripped together. + """ + addr, off = self._create_wallet() + self.assertEqual(17, self.emu.read_u32(off, OFF_VERSION), + "this build committed a storage version other than 17") + + before = self.emu.image() + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + # Steady state: storage_fromFlash() returns SUS_Valid for a record + # already at STORAGE_VERSION, so storage_init() commits nothing. + # This is also the control for the migration test below, where the + # same comparison is what proves the V16 branch ran. + self.assertEqual(before, self.emu.image(), + "booting an already-current record rewrote flash") + _capture(c) + self.assertTrue(c.features.initialized, "the wallet did not survive") + self.assertEqual(LABEL, c.features.label) + self.assertTrue(c.features.pin_protection) + # show_display so the recovered address is ON SCREEN, not just on + # the wire: the OLED frame is the report's evidence that the same + # wallet came back. + self.assertEqual( + addr, c.get_address("Bitcoin", BIP44_ADDRESS_N, + show_display=True)) + finally: + c.close() + + def test_v16_blob_upgrades_without_wiping(self): + """A V16 wallet, booted by V17 firmware, keeps its seed. + + This is the whole policy in one test: the device arrives carrying the + format the release it is leaving wrote, and the incoming firmware must + read it rather than reset it. storage_fromFlash() takes + case StorageVersion_16, reads through storage_readV16(), restamps the + record V17 and reports SUS_Updated, which storage_init() answers with a + commit -- a migration, not a wipe. + + The same address, behind the same PIN, is the assertion. It can only + derive if the wrapped storage key unwrapped, the 512-byte V16 + ciphertext decrypted, and the seed came back byte-identical. + """ + addr, off = self._create_wallet() + self._make_v16_blob(off) + self.assertEqual(16, self.emu.read_u32(off, OFF_VERSION)) + + before = self.emu.image() + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + # A surviving wallet alone would not prove the V16 branch ran -- + # a V17 record decodes to the same wallet. The migration is what + # is under test, so assert the side effect only it has: SUS_Updated + # makes storage_init() commit at boot, where SUS_Valid writes + # nothing (asserted as the control in the reboot test above). + self.assertNotEqual( + before, self.emu.image(), + "nothing was written to flash at boot, so storage_fromFlash " + "did not report SUS_Updated and case StorageVersion_16 never " + "ran -- this test is not exercising the migration") + _capture(c) + self.assertTrue( + c.features.initialized, + "V17 firmware WIPED a V16 wallet at boot -- every device " + "upgrading from 7.14.x loses its seed") + self.assertEqual(LABEL, c.features.label) + self.assertEqual( + addr, c.get_address("Bitcoin", BIP44_ADDRESS_N, + show_display=True), + "the V16 wallet survived the boot but derives a DIFFERENT " + "address -- the migration corrupted the seed, which is worse " + "than a wipe because nothing announces it") + finally: + c.close() + + def test_unrecognised_version_wipes_on_boot(self): + """A downgrade wipes, deliberately -- do not "fix" this. + + A device that has run newer firmware carries a newer stamp. Older + firmware cannot read it, so version_from_int() returns + StorageVersion_NONE and storage_init() resets. That is the property + that stops an attacker flashing an older, validly signed image with a + known extraction bug and keeping the seed. + + One past the version this build just committed is the tightest + possible case, and it is measured from the device rather than read out + of the header: it is exactly what the next format bump will look like + to this firmware. + """ + addr, off = self._create_wallet() + unknown = self.emu.read_u32(off, OFF_VERSION) + 1 + self.emu.write_u32(off, OFF_VERSION, unknown) + + self.emu.boot() + c = self.emu.client(self.method) + try: + c.init_device() + _capture(c) + self.assertFalse( + c.features.initialized, + "a storage record stamped v%d -- which this firmware does not " + "recognise -- was loaded anyway. Rollback protection is gone: " + "an older signed image would keep the seed." % unknown) + self.assertFalse(c.features.pin_protection) + self.assertNotEqual(LABEL, c.features.label) + finally: + c.close() + + def test_bitcoin_only_band_refuses_without_wiping(self): + """A bitcoin-only wallet is refused, and REFUSING IS NOT WIPING. + + Seeds created under bitcoin-only firmware are stamped in a reserved + band (10000 + the normal version). Multi-chain firmware must not load + one -- the seed was never meant to be multi-chain-exposed -- but it + must also leave it alone: SUS_BitcoinOnlyLocked resets only the RAM + shadow, and storage_commit() returns early while btc_only_locked, so + flash is never touched. Reflashing bitcoin-only firmware recovers the + wallet; leaving requires an explicit wipe. + + Three assertions, in order of what they cost you if they fail: the + device is locked, the sector is byte-for-byte what it was, and the + wallet comes back once the stamp is the multi-chain one again. + """ + addr, off = self._create_wallet() + self.assertLess( + self.emu.read_u32(off, OFF_VERSION), STORAGE_VERSION_BTC_ONLY_BASE, + "this emulator already stamps its wallets into the bitcoin-only " + "band, so it is not the multi-chain firmware this test is about") + before = self.emu.sector(off) + self.emu.write_u32( + off, OFF_VERSION, + STORAGE_VERSION_BTC_ONLY_BASE + self.emu.read_u32(off, OFF_VERSION)) + + self.emu.boot() + c = self.emu.client(self.method) + try: + c.init_device() + _capture(c) + self.assertFalse( + c.features.initialized, + "multi-chain firmware loaded a wallet stamped in the " + "bitcoin-only band") + finally: + c.close() + self.emu.halt() + + after = self.emu.sector(off) + self.assertEqual( + before[:OFF_VERSION] + before[OFF_VERSION + 4:], + after[:OFF_VERSION] + after[OFF_VERSION + 4:], + "the locked boot MODIFIED the bitcoin-only record. The wallet is " + "supposed to stay recoverable by reflashing bitcoin-only firmware") + + self.emu.write_u32(off, OFF_VERSION, + self.emu.read_u32(off, OFF_VERSION) + - STORAGE_VERSION_BTC_ONLY_BASE) + self.emu.boot() + c = self.emu.client(self.method, pin=PIN) + try: + c.init_device() + self.assertTrue(c.features.initialized) + self.assertEqual( + addr, c.get_address("Bitcoin", BIP44_ADDRESS_N, + show_display=True), + "the refused wallet did not come back intact, so 'refuse " + "rather than wipe' did not actually preserve anything") + finally: + c.close() + + +if __name__ == "__main__": + unittest.main()