From 3d20db32ea341d95ae6e80d6ea059f67078d3854 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 3 Jul 2026 00:14:18 -0500 Subject: [PATCH 01/26] report: 10 fixes to the 7.15 PDF test report Tied to the 7.15 release (bitcoin-only + Zcash-privacy split, PR #282): 1. Zcash section prose claimed shielded FVK/PCZT/unified-address are "all live"; they are WITHHELD on the default build (KK_ZCASH_PRIVACY off pending audit). Retitled to "Zcash Shielded (Orchard)" and rewrote the background to say the tests skip BY DESIGN (build-flag gated), not for missing/broken support. 2. Skip accounting conflated design-skips with "pending" and labelled a supported-but-withheld chain "no firmware support yet". Now counts skip vs missing separately, a build with 0 failures/0 missing headlines GREEN ("N/M PASSED, K skipped (withheld)"), and withheld sections get their own "Withheld on this build (build-flag gated; skipped by design)" header. 3. Added a "Zcash Transparent" section (Y) for the t-address path that actually ships and passes (test_msg_signtx_zcash); removed the duplicate B28 that had it buried under Bitcoin. 4. Device-Specs appendix: added FIRMWARE VARIANTS (KeepKeyBTC/EmulatorBTC) and SEED LOCK blocks; qualified the curve list (Pallas only on KK_ZCASH_PRIVACY=ON). 5. _pick_best_frame now falls back to a readable frame instead of an "OLED needed" placeholder when nothing lands in the density band (single dense QR/address frames were dropped); removed dead _is_setup_frame. 6. _lookup dropped its bare-method fallback (a cross-module method-name collision could render a never-run test as PASS, defeating --validate-junit); all SECTIONS modules are test_msg_* so mod::meth always resolves. 7. Non-Latin-1 punctuation (em dashes etc.) was rendering as '?'; added an _ascii sanitizer at the PDF content-stream boundary. 8. Removed stale "deferred to 7.15+" copy from the TRON/TON sections of a report that IS firmware 7.15.0. 9. ver_t() tolerates pre-release tags (7.15.0-rc3) and short versions instead of crashing. 10. Replaced an unprofessional test-fixture memo rendered on an OLED screenshot with a neutral value (recomputed the expected signature). Verified: emulator report run green (475 passed, 43 skipped, 0 failed); generated report shows 18 sections, 199 passed / 20 skipped (withheld) / 0 pending, --validate-junit passes. --- scripts/generate-test-report.py | 203 ++++++++++++++++++++++---------- tests/test_msg_cosmos_signtx.py | 4 +- 2 files changed, 141 insertions(+), 66 deletions(-) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index e7fa6fdd..6b916190 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -87,7 +87,7 @@ def add_page(self, lines, w=612, h=792): y, sz, txt = item[0], item[1], item[2] style = item[3] if len(item) > 3 else False color = item[4] if len(item) > 4 else None - txt = txt.replace('\\','\\\\').replace('(','\\(').replace(')','\\)') + txt = _ascii(txt).replace('\\','\\\\').replace('(','\\(').replace(')','\\)') if color: ops.append(f'{color[0]} {color[1]} {color[2]} rg') if style == 'ding': @@ -153,6 +153,20 @@ def write(self, path): CHECK = '\x34' CROSS = '\x38' +# Map non-Latin-1 Unicode punctuation to ASCII so it survives the PDF content +# stream (encoded latin-1); em-dashes etc. were rendering as '?'. +_ASCII_MAP = { + '—': '-', '–': '-', '→': '->', '←': '<-', + '’': "'", '‘': "'", '“': '"', '”': '"', + '…': '...', '•': '*', '₿': 'BTC', '≤': '<=', + '≥': '>=', '±': '+/-', +} +def _ascii(s): + for k, v in _ASCII_MAP.items(): + if k in s: + s = s.replace(k, v) + return s + class PB: def __init__(self, pdf): self.pdf = pdf; self.lines = []; self.y = 755 @@ -189,10 +203,18 @@ def finish(self): self._flush() def _lookup(results, mod, meth): - """Look up test result by module::method (precise), then bare method (fallback).""" - return results.get(f'{mod}::{meth}') or results.get(meth) or '' - -def ver_t(s): return tuple(int(x) for x in s.replace('v','').split('.')[:3]) + """Look up a test result by module::method. Every SECTIONS module is a + test_msg_* module, so parse_junit always emits a 'mod::meth' key -- there is + no bare-method fallback (it let a cross-module method-name collision render a + never-run test as PASS, defeating the --validate-junit release gate).""" + return results.get(f'{mod}::{meth}', '') + +def ver_t(s): + # Defensive: tolerate pre-release tags (7.15.0-rc3), 'v' prefixes and short + # versions ('7.15' -> (7,15,0)) so report/filter/validate never crash. + s = str(s).split('-')[0].replace('v', '') + parts = (s.split('.') + ['0', '0', '0'])[:3] + return tuple(int(''.join(ch for ch in p if ch.isdigit()) or '0') for p in parts) def ver_ge(a, b): return ver_t(a) >= ver_t(b) def _w(text, n=95): words, lines, cur = text.split(), [], '' @@ -202,24 +224,6 @@ def _w(text, n=95): if cur: lines.append(cur) return lines -def _is_setup_frame(path): - """Check if a screenshot is a setUp noise frame (IMPORT RECOVERY, WIPE, or blank/logo).""" - try: - pixels, w, h = _read_png_pixels(path) - # Count non-zero pixels -- blank/logo frames have very few or very specific patterns - lit = sum(1 for b in pixels if b > 128) - total = w * h - # Very blank (< 5% lit) = idle/logo screen - if lit < total * 0.05: - return True - # Check for "IMPORT RECOVERY" text by looking at pixel density in top-left region - # setUp always shows this screen -- it's ~20% lit with specific pattern - # Real test screens vary widely, so we check the raw bytes for known patterns - # Simple heuristic: if first 2 btn frames match, skip them (setUp wipe + load) - return False - except: - return False - def _frame_lit_ratio(path): """Fraction of lit pixels in an OLED PNG, or None if unreadable.""" try: @@ -247,19 +251,27 @@ def _pick_best_frame(test_dir, btn_files): if not btn_files: return None scored = [] + readable = [] for f in btn_files: r = _frame_lit_ratio(os.path.join(test_dir, f)) if r is None: continue + readable.append(f) # Blank/near-blank (idle, lock) or near-full (logo/inverted) = noise. if r < 0.02 or r > 0.55: continue scored.append((r, f)) - if not scored: - return None - # Most content-rich meaningful frame. - scored.sort() - return os.path.join(test_dir, scored[-1][1]) + if scored: + # Most content-rich meaningful frame. + scored.sort() + return os.path.join(test_dir, scored[-1][1]) + # Nothing landed in the meaningful density band, but we DID capture a + # readable frame (e.g. a dense QR / address screen brighter than the band, + # or a single-frame test). Show it rather than a bogus "OLED needed" + # placeholder when a real screenshot exists. + if readable: + return os.path.join(test_dir, readable[-1]) + return None def detect_fw(): try: @@ -409,13 +421,27 @@ def _arg_shown(a): '- Input: single capacitive button (confirm/reject)', '- USB: micro-B, HID + WebUSB transports, HID fallback', '- Storage: BIP-39 seed encrypted in isolated flash region', - '- Curves: secp256k1, ed25519, NIST P-256, Pallas (Zcash)', + '- Curves: secp256k1, ed25519, NIST P-256 (Pallas/Zcash only on KK_ZCASH_PRIVACY=ON builds)', '', 'SECURITY MODEL:', '- All private key operations happen on-device, keys never leave', '- Every transaction output displayed on OLED for user verification', '- PIN grid randomized on each prompt (position-based, not digit-based)', '- BIP-39 passphrase creates hidden wallets (plausible deniability)', + '', + 'FIRMWARE VARIANTS (7.15, PR #282):', + '- Full multi-chain (default): all coin families; firmware_variant = model name', + '- Bitcoin-only (KK_BITCOIN_ONLY): only Bitcoin + Testnet; all altcoin and', + ' shielded-Zcash handlers stripped; firmware_variant = KeepKeyBTC (EmulatorBTC', + ' on the emulator). Clients gate multi-chain-only tests on this string.', + '- Zcash shielded (KK_ZCASH_PRIVACY): adds the Orchard/Pallas engine; default OFF', + ' pending external audit. Mutually exclusive with KK_BITCOIN_ONLY.', + '', + 'SEED LOCK (7.15, PR #282):', + '- A seed created under bitcoin-only firmware is stamped in a reserved storage-', + ' version band. Multi-chain firmware refuses to load it and requires an explicit', + ' wipe (wipe-to-exit); the seed is never exposed to stripped-out code. Old', + ' multi-chain firmware treats the band as unknown and resets.', ], []), ('C', 'Core - Device Lifecycle', '7.0.0', @@ -696,11 +722,7 @@ def _arg_shown(a): 'Sign Dash transaction', 'Dash special transaction types (InstantSend-compatible).', []), ('B27', 'test_msg_signtx_grs', 'test_one_one_fee', 'Sign Groestlcoin tx', 'GRS uses Groestl hash instead of SHA-256d for tx hashing.', []), - ('B28', 'test_msg_signtx_zcash', 'test_transparent_one_one', - 'Sign Zcash transparent tx', - 'Zcash transparent transactions use Overwinter/Sapling serialization format with ' - 'version group IDs and expiry height.', - ['Zcash tx confirm']), + # Zcash transparent signing moved to its own section Y (Zcash Transparent). ]), ('E', 'Ethereum', '7.0.0', @@ -1099,7 +1121,7 @@ def _arg_shown(a): ('T', 'TRON', '7.14.0', 'NEW: TRON with secp256k1 signing, base58 addresses. Blind-sign via raw_data. ' - 'Structured reconstruct-then-sign and TRC-20 clear-signing deferred to 7.15+.', + 'Structured reconstruct-then-sign and TRC-20 clear-signing deferred to a future release.', [ 'ADDRESS: m/44\'/195\'/0\'/0/0 -> full 34-char base58 TRON address', 'BLIND-SIGN: Raw protobuf data -> hash + sign', @@ -1122,7 +1144,7 @@ def _arg_shown(a): ('N', 'TON', '7.14.0', 'NEW: TON v4r2 wallet contracts. Ed25519 signing with structured field display. ' 'Blind-sign for raw transactions. Memo/comment support. ' - 'Full clear-sign with cell tree reconstruction deferred to 7.15+.', + 'Full clear-sign with cell tree reconstruction deferred to a future release.', [ 'ADDRESS: m/44\'/607\'/0\' -> full 48-char base64url TON address', 'STRUCTURED: Amount + address + memo shown as display context -> sign', @@ -1147,14 +1169,48 @@ def _arg_shown(a): 'Missing fields rejected', 'Incomplete data refused.', []), ]), - ('Z', 'Zcash Orchard', '7.14.0', - 'NEW: Shielded transactions via PCZT streaming. Orchard hides sender, recipient, and amount ' - 'using ZK proofs. Raw seed access (ZIP-32 Orchard derivation uses BIP-39 seed + Pallas curve). ' - 'Full Viewing Key (FVK) export for watch-only wallets, unified-address display with an ' - 'on-device seed-fingerprint attestation (ZIP-32 §6.1). NOTE: pure shielded Orchard action ' - 'signing (Z5-Z7) is deferred past 7.15 — legacy sighash needs header/orchard digests not yet ' - 'in firmware; those tests skip with that reason and do not block release. Transparent->Orchard ' - 'shielding, FVK export, address display and fingerprint binding are all live.', + ('Y', 'Zcash Transparent', '7.0.0', + 'Transparent t-address Zcash (send/receive) over the generic Bitcoin UTXO signing path with ' + 'Overwinter/Sapling-v4 branch handling. This is the Zcash functionality that ships ENABLED on ' + 'the default 7.15.0 build -- t1.../t3... addresses sign like Bitcoin (SECP256K1) with a ' + 'FeeOverThreshold guard. No shielded/Orchard engine is involved; contrast with section Z ' + '(shielded), which is withheld behind KK_ZCASH_PRIVACY.', + [ + 'INPUT: TxInputType over the Zcash coin (t-address, SECP256K1)', + 'METADATA: version_group_id + branch_id for the target upgrade', + 'CONFIRM: amount + destination on the OLED, then sign each input', + 'FEE GUARD: an implausibly high fee triggers a confirmation prompt', + ], + [ + ('Y1', 'test_msg_signtx_zcash', 'test_transparent_one_one', + 'Transparent 1-in 1-out', + 'Sign a standard transparent Zcash spend; the device shows the amount and destination ' + 't-address before producing a signature over the overwinter sighash.', + ['Zcash send confirm']), + ('Y2', 'test_msg_signtx_zcash', 'test_transparent_one_one_fee_too_high', + 'High-fee guard', + 'An implausibly high fee triggers the FeeOverThreshold confirmation before signing.', + []), + ('Y3', 'test_msg_signtx_zcash', 'test_shieldedIn_one_one_fee_1', + 'Transparent spend (fee scenario 1)', + 'Despite the legacy method name, this signs a transparent input/output over the same ' + 'overwinter path (no Orchard).', + []), + ('Y4', 'test_msg_signtx_zcash', 'test_shieldedIn_one_one_fee_2', + 'Transparent spend (fee scenario 2)', + 'Second transparent fee scenario over the overwinter path.', + []), + ]), + + ('Z', 'Zcash Shielded (Orchard)', '7.14.0', + 'Shielded Orchard (PCZT streaming, Full Viewing Key export, unified-address display with an ' + 'on-device ZIP-32 Sec 6.1 seed-fingerprint attestation) is WITHHELD on the default 7.15.0 build. ' + 'It is compile-gated behind the KK_ZCASH_PRIVACY build flag, which is DEFAULT-OFF pending an ' + 'external audit of the Orchard/Pallas engine. On this build the firmware does not register the ' + 'Zcash* shielded messages, so every test in this section SKIPS BY DESIGN (the requires_message ' + 'probe returns Failure_UnexpectedMessage) -- this is a deliberate policy hold, NOT missing or ' + 'broken support. To exercise these, build the KK_ZCASH_PRIVACY=ON variant. Transparent t-address ' + 'Zcash IS live and shipping -- see section Y (Zcash Transparent).', [ 'FVK: Derive ak, nk, rivk components via ZIP-32 Orchard path', 'ADDRESS: Device derives its own unified address + shows it; optional seed-fingerprint pin', @@ -1261,36 +1317,54 @@ def render(output_path, fw_version, results, screenshot_dir=None): active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)] # Separate specs section (no tests) from test sections specs = [s for s in active if not s[5]] - # Sections with results first, pending sections at bottom. - # Within each group: existing chains first (proven), then new features. - has_results = [s for s in active if s[5] and any(_lookup(results, t[1], t[2]) for t in s[5])] - no_results = [s for s in active if s[5] and not any(_lookup(results, t[1], t[2]) for t in s[5])] - test_sections = has_results + no_results + + # Classify each section by its strongest per-test outcome so the report + # distinguishes "ran and passed/failed" from "skipped by design (build-flag + # or policy gated, e.g. KK_ZCASH_PRIVACY-off shielded Zcash)" from "no result + # at all". A design-skip is NOT missing firmware support. + def _section_state(s): + st = [_lookup(results, t[1], t[2]) for t in s[5]] + if any(x in ('pass', 'fail', 'error') for x in st): + return 'tested' + if any(x == 'skip' for x in st): + return 'withheld' # only skips -> intentionally gated on this build + return 'pending' # nothing ran -> feature not present + tested = [s for s in active if s[5] and _section_state(s) == 'tested'] + withheld = [s for s in active if s[5] and _section_state(s) == 'withheld'] + pending = [s for s in active if s[5] and _section_state(s) == 'pending'] + test_sections = tested + withheld + pending total = sum(len(s[5]) for s in test_sections) - passed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) == 'pass') - failed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) in ('fail','error')) - skipped = total - passed - failed + passed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) == 'pass') + failed = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) in ('fail','error')) + skipped = sum(1 for s in test_sections for t in s[5] if _lookup(results, t[1], t[2]) == 'skip') + missing = total - passed - failed - skipped # Title pb.text(20, 'KeepKey Firmware Test Report', bold=True) pb.gap(2) - if passed == total and total > 0: - pb.text(11, f'Firmware {fw_version} | {ts} | ALL {total} TESTS PASSED', bold=True, color=GREEN) - elif failed > 0: + if failed > 0: pb.text(11, f'Firmware {fw_version} | {ts} | {failed} FAILED of {total} tests', bold=True, color=RED) + elif missing == 0 and total > 0: + # Everything that exists ran green; remaining are deliberate design-skips. + extra = f', {skipped} skipped (withheld)' if skipped else '' + pb.text(11, f'Firmware {fw_version} | {ts} | {passed}/{total} PASSED{extra}', bold=True, color=GREEN) else: - pb.text(10, f'Firmware {fw_version} | {ts} | {total} tests: {passed} passed, {skipped} pending') + parts = [f'{passed} passed'] + if skipped: parts.append(f'{skipped} skipped') + if missing: parts.append(f'{missing} pending') + pb.text(10, f'Firmware {fw_version} | {ts} | {total} tests: {", ".join(parts)}') pb.gap(6) pb.text(12, 'Sections', bold=True) - _shown_tested = _shown_pending = False + _hdr_withheld = _hdr_pending = False for letter, title, mf, _, _, tests in test_sections: - has_any = any(_lookup(results, t[1], t[2]) for t in tests) + state = _section_state((letter, title, mf, None, None, tests)) is_new = ver_t(mf) > (7, 10, 0) - if has_any and not _shown_tested: - _shown_tested = True - elif not has_any and not _shown_pending: - pb.text(9, f' --- Pending (no firmware support yet) ---', bold=True, color=GRAY) - _shown_pending = True + if state == 'withheld' and not _hdr_withheld: + pb.text(9, ' --- Withheld on this build (build-flag gated; skipped by design) ---', bold=True, color=GRAY) + _hdr_withheld = True + elif state == 'pending' and not _hdr_pending: + pb.text(9, ' --- Pending (no firmware support yet) ---', bold=True, color=GRAY) + _hdr_pending = True tag = ' [NEW]' if is_new else '' p = sum(1 for t in tests if _lookup(results, t[1], t[2]) == 'pass') if p == len(tests) and len(tests) > 0: @@ -1401,7 +1475,8 @@ def render(output_path, fw_version, results, screenshot_dir=None): pb.finish() pdf.write(output_path) - print(f'{output_path}: fw={fw_version}, {len(active)} sections, {total} tests ({passed} passed, {failed} failed, {skipped} pending)') + print(f'{output_path}: fw={fw_version}, {len(active)} sections, {total} tests ' + f'({passed} passed, {failed} failed, {skipped} skipped, {missing} pending)') def screenshot_filter(fw_version): """Return pytest -k expression for tests with non-empty screenshot expectations. diff --git a/tests/test_msg_cosmos_signtx.py b/tests/test_msg_cosmos_signtx.py index 5ca12076..703ed36f 100644 --- a/tests/test_msg_cosmos_signtx.py +++ b/tests/test_msg_cosmos_signtx.py @@ -61,10 +61,10 @@ def test_cosmos_sign_tx_memo(self): "cosmos18vhdczjut44gpsy804crfhnd5nq003nz0nf20v", 8675309 )], - memo="Epstein didn't kill himself.", + memo="test memo", sequence=3 ) - self.assertEqual(hexlify(signature.signature), "9f2434543bc4afd2fc7bb43db05facdd6d529aa7c467ef0d41e1c2954f68db9942b8eb431cf27b52d1b3d914bbde076960179b7f426bd1a182448bb9c245009c") + self.assertEqual(hexlify(signature.signature), "db0e8039f2cd0b7d06527074a7e9079b5cd3d973f3090e04a685cfef0f145a9262dd828faa421027e583dd58fa5c6942c1f7c82fd53e54fb668fe0ebe5f83a12") self.assertEqual(hexlify(signature.public_key), "03bee3af30e53a73f38abc5a2fcdac426d7b04eb72a8ebd3b01992e2d206e24ad8") From 5307888be7b6f39381e4eb9b77bf02d6ce289d9e Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 3 Jul 2026 01:00:59 -0500 Subject: [PATCH 02/26] clearsign v2: static-schema serializer, tests, and PDF report coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the python-keepkey test-framework support for firmware v2 (static schema, METADATA_VERSION_SCHEMA): a blob that attests only the decode schema (no tx_hash, no arg values) so it can be signed once offline — the device decodes the arg values from the calldata it signs. - keepkeylib/signed_metadata.py: serialize_schema_metadata() (v2 wire format) + schema_calldata() (ABI fixed-word calldata a v2 schema decodes). - tests: TestClearSignV2SchemaOffline (7 offline byte-format tests incl. a frozen body snapshot as a drift gate) + TestClearSignV2Device (on-device decode+sign+ recover, gated requires_firmware('7.16.0') so it skips until a v2 firmware ships). - report: VS1-VS5 in the EVM Clear-Signing section documenting v2 (no tx_hash, static decimals/symbol, frozen format, fixed-word scope, on-device round-trip). Mirrors firmware feat/clearsign-static-schema (keepkey-firmware PR #284). Co-Authored-By: Claude Fable 5 --- keepkeylib/signed_metadata.py | 105 ++++++++++++++ scripts/generate-test-report.py | 45 ++++++ tests/test_msg_ethereum_clear_signing.py | 173 +++++++++++++++++++++++ 3 files changed, 323 insertions(+) diff --git a/keepkeylib/signed_metadata.py b/keepkeylib/signed_metadata.py index 902f31c3..acad0960 100644 --- a/keepkeylib/signed_metadata.py +++ b/keepkeylib/signed_metadata.py @@ -246,6 +246,111 @@ def serialize_metadata( return bytes(buf) +# ── v2: static schema (no tx_hash, no values; device decodes calldata) ── +# +# METADATA_VERSION_SCHEMA blobs attest only HOW to decode a curated +# (chainId, contract, selector): the method label and, per argument, a name + +# display format (+ static decimals/symbol for token amounts). They carry NO +# tx_hash and NO argument values — the device decodes the values from the exact +# calldata it is about to sign. Signed once, OFFLINE; no per-tx signer. +# +# Firmware format (parse_v2_args in lib/firmware/signed_metadata.c): +# version(1)=0x02 + chain_id(4 BE) + contract(20) + selector(4) + +# method_len(2 BE) + method + num_args(1) + +# [per arg: name_len(1) + name + display_format(1) + +# (if TOKEN_AMOUNT: decimals(1) + symbol_len(1) + symbol)] + +# classification(1) + timestamp(4 BE) + key_id(1) + signature(64) + recovery(1) +# +# Supported display formats (fixed single ABI word at offset 4 + 32*i): +# ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT. +METADATA_VERSION_SCHEMA = 2 + + +def serialize_schema_metadata( + chain_id: int, + contract_address: bytes, + selector: bytes, + method_name: str, + args: list, + classification: int = CLASSIFICATION_VERIFIED, + timestamp: int = None, + key_id: int = 3, +) -> bytes: + """Serialize a v2 (static schema) metadata payload (unsigned). + + Args mirror serialize_metadata(), minus tx_hash. Each entry of `args` is a + dict: {name, format, [decimals, symbol]} — NO 'value' (the device decodes it + from the calldata). `decimals`/`symbol` are required for TOKEN_AMOUNT and + ignored otherwise. Call sign_metadata() on the result. + """ + if timestamp is None: + timestamp = int(time.time()) + + assert len(contract_address) == 20 + assert len(selector) == 4 + assert len(method_name.encode('utf-8')) <= 64 + assert len(args) <= 8 + + buf = bytearray() + buf.append(METADATA_VERSION_SCHEMA) + buf.extend(struct.pack('>I', chain_id)) + buf.extend(contract_address) + buf.extend(selector) + + name_bytes = method_name.encode('utf-8') + buf.extend(struct.pack('>H', len(name_bytes))) + buf.extend(name_bytes) + + buf.append(len(args)) + for arg in args: + arg_name = arg['name'].encode('utf-8') + assert len(arg_name) <= 32 + buf.append(len(arg_name)) + buf.extend(arg_name) + + fmt = arg['format'] + assert fmt in (ARG_FORMAT_ADDRESS, ARG_FORMAT_AMOUNT, + ARG_FORMAT_TOKEN_AMOUNT), \ + 'v2 supports only fixed-word ADDRESS/AMOUNT/TOKEN_AMOUNT' + buf.append(fmt) + if fmt == ARG_FORMAT_TOKEN_AMOUNT: + sym = arg['symbol'].encode('ascii') + assert 0 < len(sym) <= 10 and sym.isalnum() + assert 0 <= arg['decimals'] <= 36 + buf.append(arg['decimals']) + buf.append(len(sym)) + buf.extend(sym) + + buf.append(classification) + buf.extend(struct.pack('>I', timestamp)) + buf.append(key_id) + + return bytes(buf) + + +def schema_calldata(selector: bytes, args: list) -> bytes: + """ABI-encode the calldata a v2 schema decodes: selector + one 32-byte head + word per arg. ADDRESS -> left-zero-padded 20-byte address; AMOUNT / + TOKEN_AMOUNT -> big-endian uint256. Used to build a tx whose calldata the + device will decode against a serialize_schema_metadata() blob. + + Each arg dict needs 'format' plus a concrete value: 'address' (20 bytes) for + ADDRESS, or 'amount' (int) for AMOUNT/TOKEN_AMOUNT. + """ + data = bytearray(selector) + for arg in args: + fmt = arg['format'] + if fmt == ARG_FORMAT_ADDRESS: + addr = arg['address'] + assert len(addr) == 20 + data.extend(b'\x00' * 12 + addr) + elif fmt in (ARG_FORMAT_AMOUNT, ARG_FORMAT_TOKEN_AMOUNT): + data.extend(int(arg['amount']).to_bytes(32, 'big')) + else: + raise AssertionError('unsupported v2 arg format %r' % fmt) + return bytes(data) + + def sign_metadata(payload: bytes, private_key: bytes = None) -> bytes: """Sign the canonical binary payload and return the complete signed blob. diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 6b916190..7a1b3886 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -1028,6 +1028,51 @@ def _arg_shown(a): 'device accepts them; deviate by one byte and it refuses.' % ( len(CLEARSIGN_FLOWS) if CLEARSIGN_FLOWS else 0), []), + + # ── v2 static schema (no online signer) ────────────────────── + # v2 attests only the decode SCHEMA (no tx_hash, no arg values); the + # DEVICE decodes the argument values from the calldata it signs. This + # removes the per-tx online signer: the catalog is signed once, offline. + # Offline format tests run every cycle; the on-device decode test is + # gated to the release that ships v2 (METADATA_VERSION_SCHEMA). + ('VS1', 'test_msg_ethereum_clear_signing', 'test_layout_has_no_tx_hash', + 'v2 schema blob carries no tx_hash / no values', + 'The v2 (static schema) blob attests only how to decode a curated ' + '(chainId, contract, selector): method + per-arg name/format (+ static ' + 'decimals/symbol). It has NO committed tx_hash and NO argument values — ' + 'so it can be signed ONCE, offline, and served from a CDN with no hot ' + 'key. The device decodes the values itself from the calldata it signs.', + []), + ('VS2', 'test_msg_ethereum_clear_signing', + 'test_token_arg_carries_static_decimals_symbol_not_value', + 'v2 token arg = static decimals/symbol, value decoded on-device', + 'A TOKEN_AMOUNT arg encodes the token\'s static decimals + symbol (a ' + 'property of the contract), but NOT the amount — the amount is decoded ' + 'from the calldata word on-device, then rendered "1.5 USDC".', + []), + ('VS3', 'test_msg_ethereum_clear_signing', 'test_frozen_body_snapshot', + 'v2 wire format frozen vs firmware parser', + 'The canonical v2 body\'s length + sha256 are frozen, so the ' + 'serializer can never drift from firmware\'s parse_v2_args() undetected ' + '— the same byte-parity discipline the v1 reference vectors use.', + []), + ('VS4', 'test_msg_ethereum_clear_signing', 'test_rejects_dynamic_format', + 'v2 scope: fixed-word types only', + 'v2 decodes fixed single ABI words (ADDRESS / AMOUNT / TOKEN_AMOUNT) — ' + 'approve/transfer/transferFrom and fixed-arg calls. Dynamic types ' + '(string/bytes/arrays) are rejected by the serializer and fall to the ' + 'blind-sign path on-device; a bounded dynamic decoder is future work.', + []), + ('VS5', 'test_msg_ethereum_clear_signing', + 'test_v2_transfer_decodes_signs_and_recovers', + 'v2 on-device: decode from calldata, sign, recover', + 'END-TO-END with AdvancedMode OFF: a v2 transfer() schema blob + a real ' + 'transfer(to, amount) tx. The device decodes to/amount from the calldata ' + 'and clear-signs; the signature recovers to this device\'s signer over ' + 'the tx digest — so the who/what/why shown was bound to the exact tx, ' + 'with no tx_hash. The offline format tests above pin the wire format ' + 'the device decodes.', + ['Clearsign warning', 'v2 decoded transfer to/amount', 'Sign transaction']), ]), ('G', 'Hive', '7.15.0', diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index c91c0dd8..5a69358b 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -36,6 +36,8 @@ from keepkeylib.signed_metadata import ( serialize_metadata, + serialize_schema_metadata, + schema_calldata, sign_metadata, build_test_metadata, token_amount_value, @@ -45,6 +47,7 @@ ARG_FORMAT_BYTES, ARG_FORMAT_STRING, ARG_FORMAT_TOKEN_AMOUNT, + METADATA_VERSION_SCHEMA, CLASSIFICATION_VERIFIED, CLASSIFICATION_OPAQUE, CLASSIFICATION_MALFORMED, @@ -696,6 +699,121 @@ def test_catalog_uses_only_hexfree_formats(self): (flow['key'], arg['name'])) +# ═══════════════════════════════════════════════════════════════════════ +# v2 static-schema blobs (offline) — no device required +# +# v2 attests only the decode SCHEMA (no tx_hash, no arg values); the device +# decodes the argument values from the calldata it signs. These offline tests +# pin the wire format serialize_schema_metadata() emits so it can never drift +# from firmware's parse_v2_args() / decode_v2_args() undetected. +# ═══════════════════════════════════════════════════════════════════════ + +# transfer(to, amount) on USDC — the canonical v2 fixture. amount is a token +# amount (6 decimals, "USDC"); the value is NOT in the blob, it is decoded from +# the calldata word by the device. +USDC_ADDRESS = bytes.fromhex('a0b86991c6218b36c1d19d4a2e9eb0ce3606eb48') +ERC20_TRANSFER_SELECTOR = bytes.fromhex('a9059cbb') +V2_SCHEMA_ARGS = [ + {'name': 'to', 'format': ARG_FORMAT_ADDRESS}, + {'name': 'amount', 'format': ARG_FORMAT_TOKEN_AMOUNT, + 'decimals': 6, 'symbol': 'USDC'}, +] + + +def _v2_transfer_blob(): + body = serialize_schema_metadata( + chain_id=1, contract_address=USDC_ADDRESS, + selector=ERC20_TRANSFER_SELECTOR, method_name='transfer', + args=V2_SCHEMA_ARGS, timestamp=0, key_id=TEST_KEY_ID) + return body, sign_metadata(body) + + +class TestClearSignV2SchemaOffline(unittest.TestCase): + """Offline byte-format tests for the v2 static-schema serializer.""" + + def test_version_byte_is_schema(self): + body, _ = _v2_transfer_blob() + self.assertEqual(body[0], METADATA_VERSION_SCHEMA) + + def test_layout_has_no_tx_hash(self): + """v2 body = version(1)+chain(4)+contract(20)+selector(4)+method... — + the selector sits at offset 25, immediately after the contract, with NO + 32-byte tx_hash in between (that is the whole point of v2).""" + body, _ = _v2_transfer_blob() + self.assertEqual(body[1:5], b'\x00\x00\x00\x01') # chain_id + self.assertEqual(body[5:25], USDC_ADDRESS) # contract + self.assertEqual(body[25:29], ERC20_TRANSFER_SELECTOR) # selector @25 + # method_len(2) + 'transfer'(8) then num_args + self.assertEqual(body[29:31], b'\x00\x08') + self.assertEqual(body[31:39], b'transfer') + self.assertEqual(body[39], len(V2_SCHEMA_ARGS)) + + def test_token_arg_carries_static_decimals_symbol_not_value(self): + """The token arg encodes name + format + decimals + symbol, and NO + value — decimals/symbol are static (a property of the contract), the + amount is decoded on-device from the calldata.""" + body, _ = _v2_transfer_blob() + # after num_args @39: arg0 'to' = len(1)+'to'(2)+format(1) = 4 bytes + p = 40 + self.assertEqual(body[p], 2) # name_len 'to' + self.assertEqual(body[p + 1:p + 3], b'to') + self.assertEqual(body[p + 3], ARG_FORMAT_ADDRESS) + p += 4 + # arg1 'amount' = len(1)+'amount'(6)+format(1)+decimals(1)+symlen(1)+'USDC'(4) + self.assertEqual(body[p], 6) + self.assertEqual(body[p + 1:p + 7], b'amount') + self.assertEqual(body[p + 7], ARG_FORMAT_TOKEN_AMOUNT) + self.assertEqual(body[p + 8], 6) # decimals + self.assertEqual(body[p + 9], 4) # symbol_len + self.assertEqual(body[p + 10:p + 14], b'USDC') + + def test_signed_blob_is_body_plus_65(self): + body, blob = _v2_transfer_blob() + self.assertEqual(len(blob), len(body) + 65) + + def test_frozen_body_snapshot(self): + """Freeze the canonical v2 UNSIGNED body's length + sha256. The body is + key-independent (no signature) and deterministic (timestamp=0), so this + is a pure wire-format drift gate: it trips iff serialize_schema_metadata() + changes the bytes, which must stay in lockstep with firmware's + parse_v2_args(). (The signature is exercised separately.)""" + body, _ = _v2_transfer_blob() + got = (len(body), hashlib.sha256(body).hexdigest()) + self.assertEqual(got, V2_BODY_SNAPSHOT, + 'v2 body drift: only update V2_BODY_SNAPSHOT if the wire ' + 'format intentionally changed (and firmware too)') + + def test_calldata_matches_schema_shape(self): + """schema_calldata() builds selector + one 32-byte word per arg, so the + device decodes exactly num_args words (the structural binding).""" + cd = schema_calldata(ERC20_TRANSFER_SELECTOR, [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ]) + self.assertEqual(len(cd), 4 + 32 * 2) + self.assertEqual(cd[:4], ERC20_TRANSFER_SELECTOR) + self.assertEqual(cd[4:16], b'\x00' * 12) # address left-padding + self.assertEqual(cd[16:36], VITALIK) + self.assertEqual(int.from_bytes(cd[36:68], 'big'), 1500000) + + def test_rejects_dynamic_format(self): + """v2 only encodes fixed single-word types; STRING/BYTES are rejected by + the serializer (they have no fixed on-chain word).""" + with self.assertRaises(AssertionError): + serialize_schema_metadata( + chain_id=1, contract_address=USDC_ADDRESS, + selector=ERC20_TRANSFER_SELECTOR, method_name='x', + args=[{'name': 'label', 'format': ARG_FORMAT_STRING}]) + + +# Frozen len + sha256 of the canonical v2 UNSIGNED transfer body (timestamp=0, +# key-independent). Regenerate ONLY on an intentional wire-format change: +# python3 -c "from tests.test_msg_ethereum_clear_signing import _v2_transfer_blob; \ +# import hashlib; b,_=_v2_transfer_blob(); print(len(b), hashlib.sha256(b).hexdigest())" +V2_BODY_SNAPSHOT = ( + 64, '01a24001460f8a69684f3d2a10f75b14e7449d8912a3833f7f8758e8fccadc05') + + # ═══════════════════════════════════════════════════════════════════════ # Device tests — require KeepKey connected with test firmware # ═══════════════════════════════════════════════════════════════════════ @@ -1102,6 +1220,61 @@ def test_load_signer_key_id_out_of_range_rejected(self): alias=CI_SIGNER_ALIAS) +class TestClearSignV2Device(common.KeepKeyTest): + """Device integration for v2 (static schema) blobs. + + A v2 blob attests only the decode schema; the device decodes the argument + values from the calldata it signs. This exercises the full round-trip: load + signer -> send v2 metadata -> sign a matching transfer() tx -> the signature + recovers to this device's signer over the tx digest (so the who/what/why + shown was bound to the exact tx, with no committed tx_hash). + + v2 (METADATA_VERSION_SCHEMA) lands in the in-progress 7.15.0 line, so this + runs against the develop firmware alongside the v1 clear-sign device tests. + """ + + V2_FIRMWARE = "7.15.0" + + def setUp(self): + super().setUp() + self.requires_firmware(self.V2_FIRMWARE) + self.requires_message("EthereumTxMetadata") + self.requires_message("LoadClearsignSigner") + self.setup_mnemonic_nopin_nopassphrase() + self.client.load_clearsign_signer( + key_id=TEST_KEY_ID, pubkey=test_signer_compressed_pubkey(), + alias=CI_SIGNER_ALIAS) + self._drop_setup_screenshots() + + def test_v2_transfer_decodes_signs_and_recovers(self): + self.client.apply_policy("AdvancedMode", 0) + self._drop_setup_screenshots() + n = parse_path(DEVICE_PATH) + chain_id, nonce, gas_price, gas_limit, value = 1, 3, 20000000000, 250000, 0 + # transfer(to=VITALIK, amount=1.5 USDC) — the device decodes both from + # the calldata using the v2 schema (address word + token-amount word). + args = [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ] + data = schema_calldata(ERC20_TRANSFER_SELECTOR, args) + _, blob = _v2_transfer_blob() + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=USDC_ADDRESS, value=value, data=data, chain_id=chain_id) + self.assertIsNotNone(sig_r) + self.assertIsNotNone(sig_s) + tx_hash = eth_sighash_legacy(nonce, gas_price, gas_limit, USDC_ADDRESS, + value, data, chain_id) + signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + # ═══════════════════════════════════════════════════════════════════════ # Dynamically generate one full-confirm device test per CLEARSIGN_FLOWS # entry (mirrors keepkey-sdk tests/evm-clearsign): every real-world flow a From 4ceec4605bbfdebc9a942b4183aed5054dce6c3e Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 6 Jul 2026 16:44:44 -0300 Subject: [PATCH 03/26] fix(tron): gate legacy dummy-raw_data tests behind AdvancedMode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit test_tron_sign_transfer_legacy_raw_data / test_tron_sign_deterministic / test_tron_sign_different_accounts use a hand-rolled, truncated raw_data blob that was never a fully valid TransferContract — it exercised the old unconditional blind-sign path. Firmware's new raw_data clear-sign parser correctly fails to decode it and falls back to the opaque blind-sign path, which now requires AdvancedMode. Enable the policy for these three tests; they're testing signature plumbing (shape/determinism/per-account uniqueness), not contract-content parsing. --- tests/test_msg_tron_signtx.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_msg_tron_signtx.py b/tests/test_msg_tron_signtx.py index 8deeec26..60d5c83f 100644 --- a/tests/test_msg_tron_signtx.py +++ b/tests/test_msg_tron_signtx.py @@ -79,7 +79,11 @@ def test_tron_sign_transfer_structured(self): self.assertFalse(all(b == 0 for b in resp.signature)) def test_tron_sign_transfer_legacy_raw_data(self): - """Test legacy blind-sign with raw_data field.""" + """Test legacy blind-sign with raw_data field. + + This raw_data is a hand-rolled blob, not a real TransferContract, so + the raw_data clear-sign parser can't decode it — it falls to the + opaque blind-sign path, which requires AdvancedMode.""" self.requires_fullFeature() self.setup_mnemonic_allallall() @@ -94,7 +98,9 @@ def test_tron_sign_transfer_legacy_raw_data(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + self.client.apply_policy('AdvancedMode', True) resp = self.client.call(msg) + self.client.apply_policy('AdvancedMode', False) # Should have a 65-byte signature self.assertEqual(len(resp.signature), 65) @@ -196,6 +202,8 @@ def test_tron_sign_deterministic(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + # Not a decodable TransferContract — opaque blind-sign, needs AdvancedMode. + self.client.apply_policy('AdvancedMode', True) resp1 = self.client.call(msg1) msg2 = tron_messages.TronSignTx( @@ -203,6 +211,7 @@ def test_tron_sign_deterministic(self): raw_data=raw_data, ) resp2 = self.client.call(msg2) + self.client.apply_policy('AdvancedMode', False) self.assertEqual(len(resp1.signature), 65) self.assertEqual(len(resp2.signature), 65) @@ -225,6 +234,8 @@ def test_tron_sign_different_accounts(self): address_n=parse_path("m/44'/195'/0'/0/0"), raw_data=raw_data, ) + # Not a decodable TransferContract — opaque blind-sign, needs AdvancedMode. + self.client.apply_policy('AdvancedMode', True) resp_acct0 = self.client.call(msg_acct0) msg_acct1 = tron_messages.TronSignTx( @@ -232,6 +243,7 @@ def test_tron_sign_different_accounts(self): raw_data=raw_data, ) resp_acct1 = self.client.call(msg_acct1) + self.client.apply_policy('AdvancedMode', False) self.assertEqual(len(resp_acct0.signature), 65) self.assertEqual(len(resp_acct1.signature), 65) From 99f1e06f85ffbe84ce309f3d3e8707522b7e393d Mon Sep 17 00:00:00 2001 From: highlander Date: Mon, 6 Jul 2026 16:30:19 -0300 Subject: [PATCH 04/26] fix(solana): split versioned-v0 test into static-verified and ALT-opaque cases The old test_solana_sign_versioned_v0_opaque built a v0 tx whose instruction only touched static accounts (no address table lookups) and asserted it must be rejected without AdvancedMode. That's the pre-clearsign assumption; firmware now treats static-only v0 messages as fully verifiable (same as legacy) and clear-signs them directly. Split into: - test_solana_sign_versioned_v0_static_verified: static-only v0 clear-signs without AdvancedMode. - test_solana_sign_versioned_v0_opaque: a v0 tx whose instruction resolves an account via the address lookup table (genuinely unverifiable) still requires AdvancedMode. --- tests/test_msg_solana_signtx.py | 69 +++++++++++++++++++++++++++++++-- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index 2aa1a34c..efb9d91f 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -625,9 +625,11 @@ def test_solana_path_wrong_coin_type(self): # Versioned transaction test # ================================================================ - def test_solana_sign_versioned_v0_opaque(self): - """Versioned v0 transaction (first byte 0x80) — should require AdvancedMode - for blind/opaque signing since firmware cannot parse address lookup tables.""" + def test_solana_sign_versioned_v0_static_verified(self): + """Versioned v0 transaction whose instructions only touch static + accounts (no address lookup table references) is exactly as + verifiable as a legacy message — it clear-signs without requiring + AdvancedMode.""" self.requires_fullFeature() self.setup_mnemonic_allallall() @@ -672,7 +674,66 @@ def test_solana_sign_versioned_v0_opaque(self): raw_tx = bytes(tx) - # Without AdvancedMode, versioned tx should be rejected + self.client.apply_policy('AdvancedMode', False) + resp = self.client.call(messages.SolanaSignTx( + address_n=parse_path("m/44'/501'/0'/0'"), + raw_tx=raw_tx, + )) + self.assertEqual(len(resp.signature), 64) + self.assertFalse(all(b == 0 for b in resp.signature)) + + def test_solana_sign_versioned_v0_opaque(self): + """Versioned v0 transaction whose instruction reaches into an address + lookup table (an account index at or beyond the static account + count) cannot be verified on-device — requires AdvancedMode for + blind/opaque signing.""" + self.requires_fullFeature() + self.setup_mnemonic_allallall() + + from_pubkey = self._get_from_pubkey() + + system_program = self.SYSTEM_PROGRAM + blockhash = b'\xBB' * 32 + lookup_table = b'\x33' * 32 + + tx = bytearray() + tx.append(0x80) # version prefix: v0 + + # Header + tx.append(1) # num_required_sigs + tx.append(0) # num_readonly_signed + tx.append(1) # num_readonly_unsigned + + # 2 static accounts — the transfer destination is resolved via the + # address lookup table below, not listed here. + tx.append(2) + tx.extend(from_pubkey) + tx.extend(system_program) + + # Recent blockhash + tx.extend(blockhash) + + # 1 instruction referencing account index 2 — beyond the 2 static + # accounts, so it resolves via the address lookup table. + tx.append(1) + tx.append(1) # program_id index (system_program) + tx.append(2) # 2 account indices + tx.append(0) # from (static) + tx.append(2) # to (external — loaded from the ALT) + instr_data = struct.pack(' Date: Fri, 3 Jul 2026 16:22:43 -0500 Subject: [PATCH 05/26] =?UTF-8?q?report:=2010=20fixes=20to=20the=207.15=20?= =?UTF-8?q?PDF=20test=20report=20=E2=80=94=20release-gate=20coverage=20gap?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of fork develop CI run 28679110349 (PR #284 clearsign v2 static- schema, 526 junit tests) against the who/what/why standard for every major daily-driver EVM tx format. Ten fixes: 1. Lower 8 stale requires_firmware("7.15.1") gates to 7.15.0 (signing guards x5, blind_sign_blocked/allowed, bip39-word-reject) — the behaviors ship in the 7.15.0 line under test; the gate zeroed out the EIP-1559 wrong-signer regression suite and the blind-sign policy negative path on every 7.15.0 CI run. 2. Document the ETH-section amount-unit rule (Wei below 1 gwei, scaled ETH/ticker above) so toy conformance-vector screenshots aren't misread as a "never wei" violation. 3. Disclose EIP-712 typed-data hash-signing as a known gap (E16): the device signs two host-computed hashes with zero readable domain/ message display — a daily-driver format with no who/what/why today. 4. Fix the ERC-4337 handleOps flow's overclaiming "innerCall: decoded separately, not raw" placeholder — the representative UserOp's inner callData is actually empty; the claim is now honest about what this flow does and does not prove (regenerated its frozen reference-blob snapshot to match). 5. Add 2 new on-device tests for the v2 static-schema headline security property, previously unit-tested but never proven on real device behavior: calldata/schema length-mismatch falls back to the blind-sign gate (VS6), and an unsupported dynamic arg format is independently rejected as MALFORMED by the device parser (VS7). 6. Surface the 3 silently-skipped Uniswap V2 liquidity tests (E17-E19) with their documented emulator-limitation reason instead of leaving them absent from SECTIONS entirely. 7. Surface the 3 passing-but-invisible THORChain-router EVM deposit tests (E20-E22), including the router-pin blind-sign-gate proof that closed a CRITICAL bypass. 8. Surface 6 passing-but-invisible malformed-blob rejection tests (V7a-V7f: empty/truncated/trailing-bytes/wrong-version/zero-sig/ empty-slot) — the WHY = fail-closed story had no rejection-path evidence in the release-gate document. 9. Fix the stale "7.15.1 NEW FEATURES" banner comment and the V8 context text that claimed "covered in 7.15.0+" while pointing at a test gated to 7.15.1 (a self-contradiction in the PDF's own prose, resolved by fix #1). 10. Fix print_clearsign_flows() (--flows external-signer reference dump): KeyError on flow['shows'] (no catalog flow has that key — regression from the move to the shared catalog module) and a missing required key_id arg to flow_blob(). Verified: all 6 edited files compile; SECTIONS loaded in the real firmware-checkout env (245 unique entries, 224->245, zero ID collisions); every new/changed (module, method) SECTIONS reference resolves to a real test function; all 51 REFERENCE_BLOB_SNAPSHOTS still match (only the ERC-4337 entry's hash/length changed, matching the ARG_FORMAT_STRING value edit in fix #4). Co-Authored-By: Claude Fable 5 --- keepkeylib/clearsign_catalog.py | 10 +- scripts/generate-test-report.py | 116 +++++++++++++++++++++- tests/test_msg_ethereum_clear_signing.py | 67 ++++++++++++- tests/test_msg_ethereum_signing_guards.py | 10 +- tests/test_msg_ethereum_signtx.py | 4 +- tests/test_msg_recoverydevice_cipher.py | 2 +- 6 files changed, 192 insertions(+), 17 deletions(-) diff --git a/keepkeylib/clearsign_catalog.py b/keepkeylib/clearsign_catalog.py index f78a5b4a..2f9fd395 100644 --- a/keepkeylib/clearsign_catalog.py +++ b/keepkeylib/clearsign_catalog.py @@ -954,10 +954,14 @@ def _bytes_tail(b): [{'name': 'sender', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x9406Cc6185a346906296840746125a0E44976454')}, {'name': 'nonce', 'format': ARG_FORMAT_STRING, 'value': b'UserOperation nonce: 12'}, {'name': 'beneficiary', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x' + '43' * 20)}, - {'name': 'innerCall', 'format': ARG_FORMAT_STRING, 'value': b'decoded separately, not raw'}], + {'name': 'innerCall', 'format': ARG_FORMAT_STRING, 'value': b'empty in this representative UserOp'}], why='A bundler-submitted meta-tx: the EntryPoint singleton validates and executes a batch of ' - 'smart-account operations; the inner callData (what the smart account will actually do) ' - 'must be decoded and shown, never left as an opaque blob one layer inside another.', + 'smart-account operations. KNOWN GAP, disclosed: this representative UserOp carries an ' + 'EMPTY inner callData (the array-of-dynamic-tuples nesting is beyond the current static ' + 'ABI encoder), so this flow proves sender/nonce/beneficiary are decoded but does NOT ' + 'prove the inner callData — what the smart account will actually do — is decoded. A real ' + 'UserOp with non-empty callData would need it decoded and shown, never left as an opaque ' + 'blob one layer inside another; that inner-decode capability is future work.', source='https://etherscan.io/address/0x0000000071727De22E5E9d8BAf0edAc6f37da032 (EntryPoint v0.7)', ), ) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 7a1b3886..6e795db2 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -727,8 +727,12 @@ def _arg_shown(a): ('E', 'Ethereum', '7.0.0', 'Ethereum covers native ETH transfers, ERC-20 tokens, EIP-1559 gas, personal message signing ' - '(EIP-191), and contract interactions. The device displays checksummed addresses (EIP-55), ' - 'values in ETH with 18-decimal precision, and gas parameters.', + '(EIP-191), and contract interactions. The device displays checksummed addresses (EIP-55) and ' + 'gas parameters. Amount UNIT rule: values below 1 gwei (1e9 wei) show as raw "Wei" (there is ' + 'no smaller human unit to scale to); values at or above 1 gwei show 18-decimal-scaled ETH (or ' + 'the chain-native ticker on other EVM chains). Some tests below use small conformance-vector ' + 'amounts (e.g. 10 wei) for deterministic-signature pinning — their OLED frames legitimately ' + 'show raw "Wei", not a display bug.', [ 'ETH TRANSFER: Show "Send X ETH to 0x..." -> show gas -> confirm -> sign with secp256k1', 'ERC-20: Decode transfer(to,amount) from contract data -> show token name + amount', @@ -791,6 +795,48 @@ def _arg_shown(a): '0x swap ETH to ERC-20', 'DEX aggregator swap via 0x protocol.', []), ('E15', 'test_msg_ethereum_cfunc', 'test_sign_execTx', 'Contract function call', 'Generic contract call signing.', []), + ('E16', 'test_sign_typed_data', 'test_ethereum_sign_typed_data_hash', + 'EIP-712 typed-data hash signing (legacy, no on-device display)', + 'KNOWN GAP, disclosed rather than hidden: EIP-712 (the standard behind wallet permits, ' + 'OpenSea listings, and DAO votes — a daily-driver format) is only supported at the ' + 'domain-separator-hash + message-hash level. The device signs two host-computed 32-byte ' + 'hashes; it does NOT parse or display the typed-data domain or message fields, so this ' + 'path shows the user no readable WHO/WHAT — it is effectively a blind hash-sign, not a ' + 'clear-sign. Full structured EIP-712 display is a firmware feature, not yet built.', + []), + ('E17', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_approve_liquidity_ETH', + 'Uniswap V2 add-liquidity approve (pending)', + 'PENDING, disclosed: known emulator limitation — an approve to an unknown (non-registry) ' + 'token contract cannot complete against the kkemu emulator (matches the sibling ' + 'add/remove-liquidity skips below); the device-firmware path is not in question, only ' + 'CI emulator coverage. Real-device testing is unaffected.', + []), + ('E18', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_add_liquidity_ETH', + 'Uniswap V2 add liquidity ETH+token (pending)', + 'PENDING, disclosed: same emulator limitation as E17 — a daily-driver LP-deposit flow ' + 'with no PDF proof on this build; tracked for real-device verification.', + []), + ('E19', 'test_msg_ethereum_erc20_uniswap_liquidity', 'test_sign_uni_remove_liquidity_ETH', + 'Uniswap V2 remove liquidity ETH+token (pending)', + 'PENDING, disclosed: same emulator limitation as E17.', + []), + ('E20', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_legacy_selector', + 'THORChain router deposit() (legacy selector)', + 'Cross-chain swap via the THORChain router contract — a daily-driver EVM<->THORChain ' + 'swap path, natively decoded (asset/amount/memo) without clear-sign metadata.', + []), + ('E21', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_with_expiry_selector', + 'THORChain router depositWithExpiry()', + 'Newer router selector variant with an expiry field; same native decode path.', + []), + ('E22', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_with_expiry_non_thor_address_blind_sign_blocked', + 'THORChain router call to a non-pinned address is blind-sign gated', + 'WHY it can be trusted: the router CONTRACT ADDRESS is pinned; a call shaped like a ' + 'THORChain deposit but sent to an unpinned address is refused native decoding and falls ' + 'through to the ordinary blind-sign gate instead of being silently native-decoded — the ' + 'fix for the router-spoofing / blind-sign-bypass class of attack.', + ['Blind sign disabled (Blocked)']), ]), ('R', 'Ripple (XRP)', '7.0.0', @@ -920,7 +966,7 @@ def _arg_shown(a): 'cause fund loss or invalid transactions on the block-lattice.', [])]), - # ===== 7.15.1 NEW FEATURES ===== + # ===== 7.15.0 NEW FEATURES ===== ('V', 'EVM Clear-Signing', '7.15.0', 'The purpose of clear-signing: instead of blind-signing an opaque hash, the device screen ' 'answers WHO / WHAT / WHY before the user approves. WHO = the validated contract address ' @@ -963,6 +1009,24 @@ def _arg_shown(a): 'Signature verification math', 'Unit test for the metadata blob signature algorithm.', []), ('V7', 'test_msg_ethereum_clear_signing', 'test_tampered_blob_fails_verification', 'Tampered blob fails', 'Any byte change in the blob invalidates the signature.', []), + ('V7a', 'test_msg_ethereum_clear_signing', 'test_empty_payload_returns_malformed', + 'Empty metadata payload rejected', 'A zero-length blob classifies MALFORMED, never VERIFIED.', []), + ('V7b', 'test_msg_ethereum_clear_signing', 'test_truncated_payload_returns_malformed', + 'Truncated metadata payload rejected', + 'A blob cut short of the minimum structural size classifies MALFORMED.', []), + ('V7c', 'test_msg_ethereum_clear_signing', 'test_extra_trailing_bytes_returns_malformed', + 'Trailing garbage bytes rejected', + 'A blob with extra bytes appended past its declared structure classifies MALFORMED — ' + 'the parser cannot be tricked by appended data.', []), + ('V7d', 'test_msg_ethereum_clear_signing', 'test_wrong_version_returns_malformed', + 'Unknown version byte rejected', 'A blob with a version byte the firmware does not ' + 'recognize classifies MALFORMED rather than being guessed-parsed.', []), + ('V7e', 'test_msg_ethereum_clear_signing', 'test_zero_signature_returns_malformed', + 'All-zero signature rejected', 'A blob with a zeroed signature field classifies ' + 'MALFORMED — an attacker cannot skip signing by leaving the field blank.', []), + ('V7f', 'test_msg_ethereum_clear_signing', 'test_empty_key_slot_returns_malformed', + 'Metadata against an empty key slot rejected', + 'A blob referencing a signer slot with no key loaded classifies MALFORMED.', []), ('V8', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_allowed', 'Blind sign permitted (AdvancedMode ON)', 'Contract data with AdvancedMode enabled. Device allows signing. ' @@ -1014,6 +1078,37 @@ def _arg_shown(a): 'Empty, oversized, control-char and format-specifier aliases are rejected — the alias ' 'is rendered on the warning screen, so it cannot carry a display-spoofing payload.', []), + + # ── ethereum signing-path guards (the blind-sign policy negative + # half + the EIP-1559 type/fee/chain_id regression suite) ── + ('VG1', 'test_msg_ethereum_signtx', 'test_ethereum_blind_sign_blocked', + 'Blind sign refused (AdvancedMode OFF)', + 'Unknown contract data with AdvancedMode disabled is hard-rejected before any confirm ' + 'screen — the negative half of the V8 policy pair.', + ['Blind signing disabled (Failure)']), + ('VG2', 'test_msg_ethereum_signing_guards', 'test_eip1559_requires_chain_id', + 'EIP-1559 requires chain_id', + 'A type-2 tx with no chain_id would hash a garbage pre-image and recover the wrong ' + 'signer; the device rejects it outright instead of signing an unbroadcastable tx.', + []), + ('VG3', 'test_msg_ethereum_signing_guards', 'test_eip1559_no_priority_fee_signs', + 'EIP-1559 zero priority fee signs correctly', + 'Regression test for the non-canonical-RLP wrong-signer bug: a type-2 tx with zero/' + 'absent priority fee must still hash and sign to the correct device address.', + []), + ('VG4', 'test_msg_ethereum_signing_guards', 'test_type2_without_max_fee_rejected', + 'Type-2 tx without max_fee_per_gas rejected', '', []), + ('VG5', 'test_msg_ethereum_signing_guards', 'test_legacy_with_max_fee_rejected', + 'Legacy tx with max_fee_per_gas rejected', + 'Mixing legacy gas_price semantics with EIP-1559 fee fields is refused rather than ' + 'silently mis-hashed.', + []), + ('VG6', 'test_msg_ethereum_signing_guards', + 'test_contract_handler_streamed_calldata_signs_full_data', + 'Streamed calldata signs the full payload', + 'A contract-clear-sign handler must not confirm only the first chunk while signing ' + 'unshown streamed bytes after it.', + []), ] + _V_CATALOG_TESTS + [ ('V%d' % (17 + len(_V_CATALOG_TESTS)), 'test_msg_ethereum_clear_signing', 'test_clearsign_batch_all_payloads', @@ -1073,6 +1168,21 @@ def _arg_shown(a): 'with no tx_hash. The offline format tests above pin the wire format ' 'the device decodes.', ['Clearsign warning', 'v2 decoded transfer to/amount', 'Sign transaction']), + ('VS6', 'test_msg_ethereum_clear_signing', + 'test_v2_calldata_length_mismatch_falls_back_to_blind_sign_gate', + 'v2 decode-mismatch falls back to blind-sign (fail-closed)', + 'THE headline v2 security property: schema says 2 words, calldata carries 3. ' + 'decode_v2_args\' structural completeness check fails, so the device does NOT ' + 'clear-sign a decode that would not match what it is about to sign — it falls ' + 'through to the ordinary blind-sign gate, and AdvancedMode OFF hard-rejects it.', + ['Blind signing disabled (Failure)']), + ('VS7', 'test_msg_ethereum_clear_signing', + 'test_v2_unsupported_arg_format_returns_malformed', + 'v2 unsupported arg format rejected at blob load', + 'A hand-crafted v2 blob using an unsupported dynamic format (STRING) — the kind the ' + 'Python serializer itself refuses to build — is independently rejected by the ' + 'device\'s own parser as MALFORMED, before any calldata is even considered.', + []), ]), ('G', 'Hive', '7.15.0', diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 5a69358b..d37497a8 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -643,7 +643,7 @@ def test_keccak256_known_vectors(self): 'permit2-permit-transfer-from': ('c0fde596537a6bf1e53b98d3746638b4249a7a90d8196fe4a9f40f711729ec84', 276), 'across-spokepool-depositv3': ('ab185113f0b47ef5f6e1fab6a6839df8b71bf8d48796afee64a61ba8b336ac01', 311), 'safe-exectransaction': ('00a523f8e02d196db7213813edfbeee2a707679b026c6c6b6f8af88d35bf4889', 274), - 'erc4337-entrypoint-v0.7-handleops': ('0b44fc0f98727877a1d6bd1346300d9fe4b537e48d901002ea241d01b79c52cd', 281), + 'erc4337-entrypoint-v0.7-handleops': ('29ac50a7c18a4e145d058c75dc9cb6232e875af79006766be0baf5cb674ba04f', 289), 'eip7702-setcode-authorization': ('0518442c7172b8c57fcbd09ded11b54e1d20076c4b5e79a7490c4ae9c2096a18', 299), } @@ -1274,6 +1274,66 @@ def test_v2_transfer_decodes_signs_and_recovers(self): signer = recover_eth_signer(sig_r, sig_s, sig_v, tx_hash, chain_id) self.assertEqual(signer, self.client.ethereum_get_address(n)) + def test_v2_calldata_length_mismatch_falls_back_to_blind_sign_gate(self): + """The headline v2 security property: a blob's schema says 2 words, + but the calldata actually being signed carries 3. decode_v2_args' + structural completeness check (total calldata bytes must equal + exactly 4 + 32*num_args) fails, matches_tx returns false, and the tx + falls through to the ordinary blind-sign path — with AdvancedMode + OFF that is a hard reject, never a clear-signed-but-wrong display.""" + self.client.apply_policy("AdvancedMode", 0) + self._drop_setup_screenshots() + n = parse_path(DEVICE_PATH) + chain_id, nonce, gas_price, gas_limit, value = 1, 3, 20000000000, 250000, 0 + args = [ + {'format': ARG_FORMAT_ADDRESS, 'address': VITALIK}, + {'format': ARG_FORMAT_TOKEN_AMOUNT, 'amount': 1500000}, + ] + # calldata carries one EXTRA 32-byte word beyond the 2-arg schema. + data = schema_calldata(ERC20_TRANSFER_SELECTOR, args) + (b'\x00' * 32) + _, blob = _v2_transfer_blob() + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_VERIFIED) + + with self.assertRaises(CallException) as ctx: + self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=USDC_ADDRESS, value=value, data=data, chain_id=chain_id) + self.assertIn("Blind signing disabled", str(ctx.exception)) + + def test_v2_unsupported_arg_format_returns_malformed(self): + """v2 supports only fixed single-word ADDRESS/AMOUNT/TOKEN_AMOUNT arg + formats (decode_v2_args has no dynamic-type support, by design). The + Python serializer refuses to BUILD a STRING-format v2 blob (see the + offline test_rejects_dynamic_format), but a malicious or buggy host + could still hand-craft the raw bytes — the device's own parser must + independently reject an unsupported v2 arg format as MALFORMED at + blob-load time, before any calldata is even seen.""" + self._drop_setup_screenshots() + body = bytearray() + body.append(METADATA_VERSION_SCHEMA) + body.extend((1).to_bytes(4, 'big')) # chain_id + body.extend(USDC_ADDRESS) + body.extend(ERC20_TRANSFER_SELECTOR) + name = b'transfer' + body.extend(len(name).to_bytes(2, 'big')) + body.extend(name) + body.append(1) # num_args + arg_name = b'label' + body.append(len(arg_name)) + body.extend(arg_name) + body.append(ARG_FORMAT_STRING) # unsupported in v2 + body.append(CLASSIFICATION_VERIFIED) + body.extend((0).to_bytes(4, 'big')) # timestamp + body.append(TEST_KEY_ID) + blob = sign_metadata(bytes(body)) + + resp = self.client.ethereum_send_tx_metadata( + signed_payload=blob, metadata_version=1, key_id=TEST_KEY_ID) + self.assertEqual(resp.classification, CLASSIFICATION_MALFORMED) + # ═══════════════════════════════════════════════════════════════════════ # Dynamically generate one full-confirm device test per CLEARSIGN_FLOWS @@ -1320,12 +1380,13 @@ def print_clearsign_flows(): for flow in CLEARSIGN_FLOWS: print() print('[%s] %s' % (flow['key'], flow['method'])) - print(' shows : %s' % flow['shows']) + shows = ', '.join('%s=%r' % (a['name'], a.get('value')) for a in flow['args']) + print(' shows : %s' % shows) print(' to : 0x%s' % flow['to'].hex()) print(' value : %d' % flow['value']) print(' calldata : 0x%s' % flow['data'].hex()) print(' tx_hash : 0x%s' % flow_tx_hash(flow).hex()) - print(' blob : %s' % flow_blob(flow, timestamp=REFERENCE_TIMESTAMP).hex()) + print(' blob : %s' % flow_blob(flow, TEST_KEY_ID, timestamp=REFERENCE_TIMESTAMP).hex()) def print_test_vectors(): diff --git a/tests/test_msg_ethereum_signing_guards.py b/tests/test_msg_ethereum_signing_guards.py index 11e14da8..83b9416c 100644 --- a/tests/test_msg_ethereum_signing_guards.py +++ b/tests/test_msg_ethereum_signing_guards.py @@ -27,7 +27,7 @@ def test_eip1559_requires_chain_id(self): """type=2 with no chain_id: Stage 1 counts chain_id as 1 byte but hash_rlp_number(0) hashes nothing -> over-declared list header -> wrong/garbage signer. The device must reject rather than sign it.""" - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) @@ -49,7 +49,7 @@ def test_eip1559_no_priority_fee_signs(self): absent it must encode as the empty integer (0x80). Stage 1 always counts it, so Stage 2 must always hash it -- the device must still produce a valid signature (not desync the list header).""" - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( @@ -69,7 +69,7 @@ def test_type2_without_max_fee_rejected(self): """Typed prefix (0x02) is chosen from msg.type but the fee fields from has_max_fee_per_gas. A type=2 tx carrying only gas_price would sign a malformed (legacy-fee-in-1559-envelope) field list -> reject.""" - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) @@ -88,7 +88,7 @@ def test_type2_without_max_fee_rejected(self): def test_legacy_with_max_fee_rejected(self): """A legacy tx (type omitted) carrying max_fee_per_gas would hash two fee fields into a legacy structure -> reject the mismatch.""" - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) @@ -117,7 +117,7 @@ def test_contract_handler_streamed_calldata_signs_full_data(self): the screen-level assertion (no 'Sablier' clear-sign summary appears for streamed calldata) is verified on-device / on the emulator via DebugLink layout.""" - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) diff --git a/tests/test_msg_ethereum_signtx.py b/tests/test_msg_ethereum_signtx.py index 192f8fcf..501b36d8 100644 --- a/tests/test_msg_ethereum_signtx.py +++ b/tests/test_msg_ethereum_signtx.py @@ -100,7 +100,7 @@ def test_ethereum_blind_sign_blocked(self): OLED shows 'Blind signing disabled' then Failure. """ - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 0) @@ -124,7 +124,7 @@ def test_ethereum_blind_sign_allowed(self): OLED shows 'BLIND SIGNATURE' before signing. """ - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() self.client.apply_policy("AdvancedMode", 1) diff --git a/tests/test_msg_recoverydevice_cipher.py b/tests/test_msg_recoverydevice_cipher.py index b72279fd..1521393e 100644 --- a/tests/test_msg_recoverydevice_cipher.py +++ b/tests/test_msg_recoverydevice_cipher.py @@ -174,7 +174,7 @@ def test_invalid_bip39_word_rejected(self): BIP-39 wordlist must return Failure immediately. Requires firmware 7.15.1+ (per-word validation). """ - self.requires_firmware("7.15.1") + self.requires_firmware("7.15.0") ret = self.client.call_raw(proto.RecoveryDevice(word_count=12, passphrase_protection=False, pin_protection=False, From c101137cacf94530e34d3e85144adf884fc41fe5 Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 3 Jul 2026 16:35:00 -0500 Subject: [PATCH 06/26] fix(clearsign): shorten erc4337 innerCall disclosure to fit 32-byte v1 cap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the previous commit failed: test_clearsign_erc4337_entrypoint_v0_7_handleops and test_clearsign_batch_all_payloads both got MALFORMED instead of VERIFIED (AssertionError: 2 != 1). Root cause: legacy v1 ARG_FORMAT_STRING values are hard-capped at 32 bytes (arg_value_ok(), lib/firmware/signed_metadata.c:138 — "legacy formats keep their original 32-byte cap"). My disclosure string from the previous commit ("empty in this representative UserOp", 35 bytes) exceeded it; the device rejected the whole blob at parse time. Shortened to "empty (representative)" (22 bytes) — same honest disclosure, fits the cap. Regenerated the flow's frozen reference-blob snapshot and verified all 51 REFERENCE_BLOB_SNAPSHOTS match, plus added a blanket check that no catalog STRING arg exceeds 32 bytes. Co-Authored-By: Claude Fable 5 --- keepkeylib/clearsign_catalog.py | 2 +- tests/test_msg_ethereum_clear_signing.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/keepkeylib/clearsign_catalog.py b/keepkeylib/clearsign_catalog.py index 2f9fd395..5d283ec7 100644 --- a/keepkeylib/clearsign_catalog.py +++ b/keepkeylib/clearsign_catalog.py @@ -954,7 +954,7 @@ def _bytes_tail(b): [{'name': 'sender', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x9406Cc6185a346906296840746125a0E44976454')}, {'name': 'nonce', 'format': ARG_FORMAT_STRING, 'value': b'UserOperation nonce: 12'}, {'name': 'beneficiary', 'format': ARG_FORMAT_ADDRESS, 'value': addr('0x' + '43' * 20)}, - {'name': 'innerCall', 'format': ARG_FORMAT_STRING, 'value': b'empty in this representative UserOp'}], + {'name': 'innerCall', 'format': ARG_FORMAT_STRING, 'value': b'empty (representative)'}], why='A bundler-submitted meta-tx: the EntryPoint singleton validates and executes a batch of ' 'smart-account operations. KNOWN GAP, disclosed: this representative UserOp carries an ' 'EMPTY inner callData (the array-of-dynamic-tuples nesting is beyond the current static ' diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index d37497a8..911cee78 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -643,7 +643,7 @@ def test_keccak256_known_vectors(self): 'permit2-permit-transfer-from': ('c0fde596537a6bf1e53b98d3746638b4249a7a90d8196fe4a9f40f711729ec84', 276), 'across-spokepool-depositv3': ('ab185113f0b47ef5f6e1fab6a6839df8b71bf8d48796afee64a61ba8b336ac01', 311), 'safe-exectransaction': ('00a523f8e02d196db7213813edfbeee2a707679b026c6c6b6f8af88d35bf4889', 274), - 'erc4337-entrypoint-v0.7-handleops': ('29ac50a7c18a4e145d058c75dc9cb6232e875af79006766be0baf5cb674ba04f', 289), + 'erc4337-entrypoint-v0.7-handleops': ('218c253b00780eeeb4f47b343feba7fafe2ecf3441f32afbd13e555cd56db6d2', 276), 'eip7702-setcode-authorization': ('0518442c7172b8c57fcbd09ded11b54e1d20076c4b5e79a7490c4ae9c2096a18', 299), } From e0587c063bf3651c7335bf062f6ada3be89732e0 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 7 Jul 2026 20:21:43 -0300 Subject: [PATCH 07/26] test(solana): gate versioned-v0 tests on requires_firmware(7.15.0) Solana versioned (v0) transaction parsing (solana_parseVersionedTx) landed in the 7.15 line. The two v0 tests asserted against it unconditionally, so on a pre-7.15 firmware (e.g. a stacked release PR that hasn't yet added v0 support) the 0x80 version prefix is rejected as a legacy tx (Failure_SyntaxError) and the tests FAIL instead of skipping. Add the same requires_firmware(7.15.0) gate the bip85 tests use so they skip cleanly on <7.15 and run on 7.15+. --- tests/test_msg_solana_signtx.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index efb9d91f..f27ea523 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -630,6 +630,7 @@ def test_solana_sign_versioned_v0_static_verified(self): accounts (no address lookup table references) is exactly as verifiable as a legacy message — it clear-signs without requiring AdvancedMode.""" + self.requires_firmware("7.15.0") # Solana versioned (v0) parsing landed in 7.15 self.requires_fullFeature() self.setup_mnemonic_allallall() @@ -687,6 +688,7 @@ def test_solana_sign_versioned_v0_opaque(self): lookup table (an account index at or beyond the static account count) cannot be verified on-device — requires AdvancedMode for blind/opaque signing.""" + self.requires_firmware("7.15.0") # Solana versioned (v0) parsing landed in 7.15 self.requires_fullFeature() self.setup_mnemonic_allallall() From 4c3f1585abe6278b84b4ad5548f902e5c42e2c7d Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 8 Jul 2026 02:17:19 -0300 Subject: [PATCH 08/26] test(ton): enable AdvancedMode for TonSignTx tests Firmware now gates length-only blind TON transaction signing behind the AdvancedMode policy (same fence as TonSignMessage and Solana/TRON opaque signing). --- tests/test_msg_ton_signtx.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tests/test_msg_ton_signtx.py b/tests/test_msg_ton_signtx.py index 8ce3a962..a85022a7 100644 --- a/tests/test_msg_ton_signtx.py +++ b/tests/test_msg_ton_signtx.py @@ -75,6 +75,7 @@ def test_ton_sign_structured(self): """ self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() @@ -100,6 +101,7 @@ def test_ton_sign_with_memo(self): """Test TON transfer with a text memo (blind-sign path).""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() @@ -123,6 +125,7 @@ def test_ton_sign_legacy_raw_tx(self): """Test legacy blind-sign with raw_tx field.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) raw_tx = b'\x00' * 64 @@ -138,6 +141,7 @@ def test_ton_sign_missing_fields_rejected(self): """Test that incomplete structured fields are rejected.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) msg = ton_messages.TonSignTx( address_n=parse_path(TON_PATH), @@ -151,6 +155,7 @@ def test_ton_sign_deterministic(self): """Test that signing the same message produces same signature.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-deterministic').digest() * 2 # 64 bytes @@ -181,6 +186,7 @@ def test_ton_sign_empty_raw_tx(self): """Empty raw_tx (0 bytes) should be rejected by firmware.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) msg = ton_messages.TonSignTx( address_n=parse_path(TON_PATH), @@ -194,6 +200,7 @@ def test_ton_sign_oversized_raw_tx(self): """raw_tx of 1025 bytes exceeds proto max (1024) and should be rejected.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) raw_tx = b'\xAB' * 1025 @@ -209,6 +216,7 @@ def test_ton_sign_with_empty_memo(self): """Empty memo string should be accepted (memo is optional text).""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-empty-memo').digest() * 2 # 64 bytes @@ -230,6 +238,7 @@ def test_ton_sign_with_long_memo(self): """Memo of 120 characters (near max_size 121) should be accepted.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-long-memo').digest() * 2 # 64 bytes @@ -252,6 +261,7 @@ def test_ton_sign_workchain_zero(self): """Explicit workchain=0 (basechain) in TonSignTx.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-workchain-zero').digest() * 2 # 64 bytes @@ -279,6 +289,7 @@ def test_ton_sign_workchain_default(self): """ self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-workchain-default').digest() * 2 # 64 bytes @@ -315,6 +326,7 @@ def test_ton_sign_different_accounts(self): """Signing with different account paths must produce different signatures.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + self.client.apply_policy('AdvancedMode', True) dest_addr = make_ton_address() raw_tx = hashlib.sha256(b'test-ton-different-accounts').digest() * 2 # 64 bytes From 560b89747de30ee44edb6bc3732e00cdfe3c49aa Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 8 Jul 2026 12:38:46 -0300 Subject: [PATCH 09/26] test(mayachain): use the real Maya ETH Router v4 in eth swap/liquidity tests Firmware MAYA_ROUTER was corrected from the dead d89dce57.. to the Etherscan-verified v4 e3985e6b..46d, so these ETH swaps must target the real router to be clear-signed (else they fall to the AdvancedMode blind-sign gate). Assertions are structural; no exact-vector regen needed. --- tests/test_msg_mayachain_signtx.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_msg_mayachain_signtx.py b/tests/test_msg_mayachain_signtx.py index 8a0bae22..7cd97ccb 100644 --- a/tests/test_msg_mayachain_signtx.py +++ b/tests/test_msg_mayachain_signtx.py @@ -78,7 +78,7 @@ def test_sign_eth_btc_swap(self): gas_price=0x5FB9ACA00, gas_limit=0x186A0, value=0x00, - to=unhexlify('d89dce570de35a6f42d3bca7dba50a6d89bfc2a2'), # Maya router (firmware-pinned) + to=unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d'), # Maya router v4 (firmware-pinned) address_type=0, chain_id=1, data=unhexlify('1fece7b4' + @@ -128,7 +128,7 @@ def test_sign_eth_add_liquidity(self): gas_price=0x5FB9ACA00, gas_limit=0x186A0, value=0x00, - to=unhexlify('d89dce570de35a6f42d3bca7dba50a6d89bfc2a2'), # Maya router (firmware-pinned) + to=unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d'), # Maya router v4 (firmware-pinned) address_type=0, chain_id=1, data=unhexlify('1fece7b4' + From 02fa3ea89e83291e969c1d3580a0b945972d13d8 Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 14 Jul 2026 21:02:15 -0300 Subject: [PATCH 10/26] test(hive): negative coverage for SLIP-48 path enforcement + memo limit - reject foreign path (BIP-44), wrong network (3054'), unassigned role - memo >440 fails with the specific error; 440 boundary still signs+recovers - config.py: KK_FORCE_UDP=1 local-only escape hatch to reach the UDP emulator with a real device plugged in (not for CI) --- tests/config.py | 7 +++- tests/test_msg_hive.py | 78 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/tests/config.py b/tests/config.py index cca59765..8de09c0e 100644 --- a/tests/config.py +++ b/tests/config.py @@ -44,7 +44,12 @@ (_explicit_transport, sorted(_KNOWN_TRANSPORTS)) ) -if _explicit_transport == "dylib": +if os.getenv("KK_FORCE_UDP") == "1": + # Local-only escape hatch: skip HID/WebUSB autodetect so tests hit the + # UDP emulator even with a real KeepKey plugged in. NOT for CI. + hid_devices = [] + webusb_devices = [] +elif _explicit_transport == "dylib": # Skip HID/WebUSB autodetect — dylib is opt-in by env var. Without # this skip, a connected real KeepKey would win over the explicit # request and the dylib regression suite would route to hardware. diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 082fb418..5badae09 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -303,5 +303,83 @@ def test_hive_sign_account_update(self): r.assert_end() + def _transfer_kwargs(self, **overrides): + """Baseline valid HiveSignTx args; override per negative case.""" + kw = dict( + address_n=hive_path(ROLE_ACTIVE), + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, + sender="kktester", + recipient="kkrecipient", + amount=1000, + decimals=3, + asset_symbol="HIVE", + memo="kktest", + ) + kw.update(overrides) + return kw + + def _assert_sign_tx_fails(self, message_fragment, **overrides): + from keepkeylib.client import CallException + with self.assertRaises(CallException) as ctx: + hive.sign_tx(self.client, **self._transfer_kwargs(**overrides)) + self.assertIn(message_fragment, str(ctx.exception)) + + def test_hive_sign_transfer_rejects_foreign_path(self): + """A path outside SLIP-0048 (e.g. BIP-44 BTC) must be rejected before + signing — a compromised host cannot obtain a Hive signature with a + key from another coin's derivation tree.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.setup_mnemonic_nopin_nopassphrase() + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=parse_path("m/44'/0'/0'/0/0"), + ) + + def test_hive_sign_transfer_rejects_wrong_network(self): + """Wrong SLIP-0048 network index (registry 3054' instead of the + de-facto 13') must be rejected — keys must be Ledger-compatible.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.setup_mnemonic_nopin_nopassphrase() + h = 0x80000000 + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=[h + 48, h + 3054, h + ROLE_ACTIVE, h, h], + ) + + def test_hive_sign_transfer_rejects_wrong_role(self): + """Role index outside {owner, active, memo, posting} must be rejected.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.setup_mnemonic_nopin_nopassphrase() + h = 0x80000000 + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=[h + 48, h + 13, h + 2, h, h], # role 2' is unassigned + ) + + def test_hive_sign_transfer_rejects_long_memo(self): + """Memo over the 440-byte serialization limit must fail with a + specific error, not a generic signing failure.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.setup_mnemonic_nopin_nopassphrase() + self._assert_sign_tx_fails("memo too long", memo="x" * 441) + + def test_hive_sign_transfer_max_memo_ok(self): + """A memo of exactly 440 bytes still signs (boundary check).""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignTx") + self.setup_mnemonic_nopin_nopassphrase() + active = hive.get_public_key(self.client, hive_path(ROLE_ACTIVE), show_display=False) + resp = hive.sign_tx(self.client, **self._transfer_kwargs(memo="x" * 440)) + self.assertEqual(len(resp.signature), 65) + self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key) + + if __name__ == "__main__": unittest.main() From 35555d7f127d6be0b96d6b9e4a981130dba50d2b Mon Sep 17 00:00:00 2001 From: highlander Date: Tue, 14 Jul 2026 21:53:19 -0300 Subject: [PATCH 11/26] test(hive): role-exact path enforcement per operation Review follow-up on keepkey-firmware#305: transfers must reject owner/ memo/posting paths (post-HF28 hived drops higher-role substitution; the cold owner key must never sign a transfer), and account_create/update must reject non-owner paths (the attestation contract recovers to the device owner key). --- tests/test_msg_hive.py | 49 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 5badae09..968b6092 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -351,16 +351,53 @@ def test_hive_sign_transfer_rejects_wrong_network(self): address_n=[h + 48, h + 3054, h + ROLE_ACTIVE, h, h], ) - def test_hive_sign_transfer_rejects_wrong_role(self): - """Role index outside {owner, active, memo, posting} must be rejected.""" + def test_hive_sign_transfer_rejects_non_active_roles(self): + """Transfers must sign with the active key ONLY. Post-HF28 hived no + longer accepts higher-role substitution, so an owner/memo/posting + signature would be rejected at broadcast — and the cold owner key + must never be spent on a transfer. Unassigned roles reject too.""" self.requires_firmware("7.15.0") self.requires_message("HiveSignTx") self.setup_mnemonic_nopin_nopassphrase() - h = 0x80000000 - self._assert_sign_tx_fails( - "Invalid Hive SLIP-0048 path", - address_n=[h + 48, h + 13, h + 2, h, h], # role 2' is unassigned + for role in (ROLE_OWNER, ROLE_MEMO, ROLE_POSTING, 2): # 2' unassigned + self._assert_sign_tx_fails( + "Invalid Hive SLIP-0048 path", + address_n=hive_path(role), + ) + + def test_hive_sign_account_ops_reject_non_owner_roles(self): + """account_create/account_update must sign with the owner key ONLY — + the sponsor's attestation check recovers to the device OWNER key, and + account_update replaces the owner authority itself.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignAccountCreate") + self.requires_message("HiveSignAccountUpdate") + self.requires_message("HiveGetPublicKeys") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + keys = hive.get_public_keys(self.client, account_index=0, show_display=False) + tx_kw = dict( + chain_id=HIVE_CHAIN_ID, + ref_block_num=12345, + ref_block_prefix=67890, + expiration=1700000000, ) + with self.assertRaises(CallException) as ctx: + hive.sign_account_create( + self.client, address_n=hive_path(ROLE_ACTIVE), + creator="kksponsor", new_account_name="kktestacct", + fee_amount=3000, owner_key=keys.owner_key, + active_key=keys.active_key, posting_key=keys.posting_key, + memo_key=keys.memo_key, **tx_kw) + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) + with self.assertRaises(CallException) as ctx: + hive.sign_account_update( + self.client, address_n=hive_path(ROLE_ACTIVE), + account="kktestacct", new_owner_key=keys.owner_key, + new_active_key=keys.active_key, + new_posting_key=keys.posting_key, + new_memo_key=keys.memo_key, **tx_kw) + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) def test_hive_sign_transfer_rejects_long_memo(self): """Memo over the 440-byte serialization limit must fail with a From 8e0607f43ebefc43850c5b58a83ed7163754d59c Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 00:16:41 -0300 Subject: [PATCH 12/26] feat(hive): sign_message (HiveSignMessage 1614/1615) + signBuffer contract tests 7 new tests: posting login recovery, all-4-roles, non-printable buffer, 1024 boundary, oversize reject, bad-path fence, chain-id-prefix collision reject. --- keepkeylib/hive.py | 8 +++ keepkeylib/mapping.py | 3 + keepkeylib/messages_hive_pb2.py | 94 +++++++++++++++++++++++- tests/test_msg_hive.py | 124 ++++++++++++++++++++++++++++++++ 4 files changed, 228 insertions(+), 1 deletion(-) diff --git a/keepkeylib/hive.py b/keepkeylib/hive.py index 8222ba68..9c7df1b2 100644 --- a/keepkeylib/hive.py +++ b/keepkeylib/hive.py @@ -32,6 +32,14 @@ def sign_tx(client, address_n, chain_id, ref_block_num, ref_block_prefix, })) +def sign_message(client, address_n, message): + """Keychain signBuffer contract: sig over SHA256(raw message bytes) only — + no chain_id prepend, no message prefix.""" + if isinstance(message, str): + message = message.encode('utf-8') + return client.call(proto.HiveSignMessage(address_n=address_n, message=message)) + + def sign_account_create(client, address_n, chain_id, ref_block_num, ref_block_prefix, expiration, creator, new_account_name, fee_amount=3000, owner_key='', active_key='', posting_key='', memo_key=''): diff --git a/keepkeylib/mapping.py b/keepkeylib/mapping.py index 954c0539..329f4ee4 100644 --- a/keepkeylib/mapping.py +++ b/keepkeylib/mapping.py @@ -114,6 +114,9 @@ def check_missing(): 1607: ('HiveSignedAccountCreate', hive_proto), 1608: ('HiveSignAccountUpdate', hive_proto), 1609: ('HiveSignedAccountUpdate', hive_proto), + # 1610-1613 reserved: NEAR + 1614: ('HiveSignMessage', hive_proto), + 1615: ('HiveSignedMessage', hive_proto), } for wire_id, (msg_name, mod) in _hive_wire_ids.items(): msg_class = getattr(mod, msg_name, None) diff --git a/keepkeylib/messages_hive_pb2.py b/keepkeylib/messages_hive_pb2.py index 1d12c922..c4ae6499 100644 --- a/keepkeylib/messages_hive_pb2.py +++ b/keepkeylib/messages_hive_pb2.py @@ -19,7 +19,7 @@ name='messages-hive.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x13messages-hive.proto\"I\n\x10HiveGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0c\n\x04role\x18\x03 \x01(\r\";\n\rHivePublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x16\n\x0eraw_public_key\x18\x02 \x01(\x0c\"C\n\x11HiveGetPublicKeys\x12\x18\n\raccount_index\x18\x01 \x01(\r:\x01\x30\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"^\n\x0eHivePublicKeys\x12\x11\n\towner_key\x18\x01 \x01(\t\x12\x12\n\nactive_key\x18\x02 \x01(\t\x12\x10\n\x08memo_key\x18\x03 \x01(\t\x12\x13\n\x0bposting_key\x18\x04 \x01(\t\"\xd6\x01\n\nHiveSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x10\n\x08\x64\x65\x63imals\x18\t \x01(\r\x12\x14\n\x0c\x61sset_symbol\x18\n \x01(\t\x12\x0c\n\x04memo\x18\x0b \x01(\t\"8\n\x0cHiveSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\x8e\x02\n\x15HiveSignAccountCreate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x63reator\x18\x06 \x01(\t\x12\x18\n\x10new_account_name\x18\x07 \x01(\t\x12\x11\n\towner_key\x18\x08 \x01(\t\x12\x12\n\nactive_key\x18\t \x01(\t\x12\x13\n\x0bposting_key\x18\n \x01(\t\x12\x10\n\x08memo_key\x18\x0b \x01(\t\x12\x12\n\nfee_amount\x18\x0c \x01(\x04\"C\n\x17HiveSignedAccountCreate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\xf0\x01\n\x15HiveSignAccountUpdate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x06 \x01(\t\x12\x15\n\rnew_owner_key\x18\x07 \x01(\t\x12\x16\n\x0enew_active_key\x18\x08 \x01(\t\x12\x17\n\x0fnew_posting_key\x18\t \x01(\t\x12\x14\n\x0cnew_memo_key\x18\n \x01(\t\"C\n\x17HiveSignedAccountUpdate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\x42\x39\n#com.shapeshift.keepkey.lib.protobufB\x12KeepKeyMessageHive') + serialized_pb=_b('\n\x13messages-hive.proto\"I\n\x10HiveGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0c\n\x04role\x18\x03 \x01(\r\";\n\rHivePublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x16\n\x0eraw_public_key\x18\x02 \x01(\x0c\"C\n\x11HiveGetPublicKeys\x12\x18\n\raccount_index\x18\x01 \x01(\r:\x01\x30\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"^\n\x0eHivePublicKeys\x12\x11\n\towner_key\x18\x01 \x01(\t\x12\x12\n\nactive_key\x18\x02 \x01(\t\x12\x10\n\x08memo_key\x18\x03 \x01(\t\x12\x13\n\x0bposting_key\x18\x04 \x01(\t\"\xd6\x01\n\nHiveSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x10\n\x08\x64\x65\x63imals\x18\t \x01(\r\x12\x14\n\x0c\x61sset_symbol\x18\n \x01(\t\x12\x0c\n\x04memo\x18\x0b \x01(\t\"8\n\x0cHiveSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\x8e\x02\n\x15HiveSignAccountCreate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x63reator\x18\x06 \x01(\t\x12\x18\n\x10new_account_name\x18\x07 \x01(\t\x12\x11\n\towner_key\x18\x08 \x01(\t\x12\x12\n\nactive_key\x18\t \x01(\t\x12\x13\n\x0bposting_key\x18\n \x01(\t\x12\x10\n\x08memo_key\x18\x0b \x01(\t\x12\x12\n\nfee_amount\x18\x0c \x01(\x04\"C\n\x17HiveSignedAccountCreate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\xf0\x01\n\x15HiveSignAccountUpdate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x06 \x01(\t\x12\x15\n\rnew_owner_key\x18\x07 \x01(\t\x12\x16\n\x0enew_active_key\x18\x08 \x01(\t\x12\x17\n\x0fnew_posting_key\x18\t \x01(\t\x12\x14\n\x0cnew_memo_key\x18\n \x01(\t\"C\n\x17HiveSignedAccountUpdate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"5\n\x0fHiveSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x01(\x0c\":\n\x11HiveSignedMessage\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x42\x39\n#com.shapeshift.keepkey.lib.protobufB\x12KeepKeyMessageHive') ) @@ -614,6 +614,82 @@ serialized_end=1251, ) + +_HIVESIGNMESSAGE = _descriptor.Descriptor( + name='HiveSignMessage', + full_name='HiveSignMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignMessage.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='message', full_name='HiveSignMessage.message', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1253, + serialized_end=1306, +) + + +_HIVESIGNEDMESSAGE = _descriptor.Descriptor( + name='HiveSignedMessage', + full_name='HiveSignedMessage', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedMessage.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='public_key', full_name='HiveSignedMessage.public_key', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1308, + serialized_end=1366, +) + DESCRIPTOR.message_types_by_name['HiveGetPublicKey'] = _HIVEGETPUBLICKEY DESCRIPTOR.message_types_by_name['HivePublicKey'] = _HIVEPUBLICKEY DESCRIPTOR.message_types_by_name['HiveGetPublicKeys'] = _HIVEGETPUBLICKEYS @@ -624,6 +700,8 @@ DESCRIPTOR.message_types_by_name['HiveSignedAccountCreate'] = _HIVESIGNEDACCOUNTCREATE DESCRIPTOR.message_types_by_name['HiveSignAccountUpdate'] = _HIVESIGNACCOUNTUPDATE DESCRIPTOR.message_types_by_name['HiveSignedAccountUpdate'] = _HIVESIGNEDACCOUNTUPDATE +DESCRIPTOR.message_types_by_name['HiveSignMessage'] = _HIVESIGNMESSAGE +DESCRIPTOR.message_types_by_name['HiveSignedMessage'] = _HIVESIGNEDMESSAGE _sym_db.RegisterFileDescriptor(DESCRIPTOR) HiveGetPublicKey = _reflection.GeneratedProtocolMessageType('HiveGetPublicKey', (_message.Message,), dict( @@ -696,6 +774,20 @@ )) _sym_db.RegisterMessage(HiveSignedAccountUpdate) +HiveSignMessage = _reflection.GeneratedProtocolMessageType('HiveSignMessage', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNMESSAGE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignMessage) + )) +_sym_db.RegisterMessage(HiveSignMessage) + +HiveSignedMessage = _reflection.GeneratedProtocolMessageType('HiveSignedMessage', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDMESSAGE, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedMessage) + )) +_sym_db.RegisterMessage(HiveSignedMessage) + DESCRIPTOR.has_options = True DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('\n#com.shapeshift.keepkey.lib.protobufB\022KeepKeyMessageHive')) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 968b6092..197abdd2 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -418,5 +418,129 @@ def test_hive_sign_transfer_max_memo_ok(self): self.assertEqual(recover_compressed(resp.serialized_tx, resp.signature), active.raw_public_key) + # ── Message signing (Keychain signBuffer contract) ──────────────────── + + def _recover_message_signer(self, message, sig65): + """Recover the compressed signer pubkey from a HiveSignedMessage. + + The signBuffer contract: digest = SHA256(message bytes) ONLY — no + chain_id prepend (unlike transactions), no message prefix. This + recovery is exactly what a Hive dApp does to verify a login. + """ + self.assertEqual(len(sig65), 65, "Hive signature must be 65 bytes") + recid = sig65[0] - 31 + self.assertTrue(0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0]) + digest = hashlib.sha256(message).digest() + candidates = VerifyingKey.from_public_key_recovery_with_digest( + sig65[1:], digest, SECP256k1, hashfunc=hashlib.sha256, + sigdecode=sigdecode_string + ) + return candidates[recid].to_string("compressed") + + def test_hive_sign_message_posting(self): + """dApp login: a posting-key signBuffer signature recovers to the + posting key — both against the response public_key and against an + independently derived HiveGetPublicKey for the same path.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + challenge = b'{"login":"skatehive","ts":1700000000,"nonce":"abc123"}' + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), challenge) + + self.assertEqual(len(resp.signature), 65) + self.assertEqual(len(resp.public_key), 33) + self.assertEqual(resp.public_key, posting.raw_public_key) + self.assertEqual(self._recover_message_signer(challenge, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_all_roles(self): + """All four SLIP-0048 roles may sign a message (Keychain lets dApps + request Posting, Active, or Memo); each signature recovers to that + role's key and to no other role's.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = b"kk role check" + seen = set() + for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING): + expected = hive.get_public_key(self.client, hive_path(role), show_display=False) + resp = hive.sign_message(self.client, hive_path(role), message) + self.assertEqual(resp.public_key, expected.raw_public_key) + self.assertEqual(self._recover_message_signer(message, resp.signature), + expected.raw_public_key) + seen.add(resp.public_key) + self.assertEqual(len(seen), 4, "role keys must be distinct") + + def test_hive_sign_message_nonprintable_bytes(self): + """Raw (non-printable) buffers sign too — Keychain accepts serialized + Buffer payloads, shown on-device as a hex preview.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = bytes(range(0, 48)) # starts 0x00... — nothing like the chain id + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertEqual(self._recover_message_signer(message, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_max_length_ok(self): + """A message of exactly 1024 bytes (the proto cap) still signs.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = b"x" * 1024 + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertEqual(self._recover_message_signer(message, resp.signature), + posting.raw_public_key) + + def test_hive_sign_message_rejects_oversize(self): + """1025 bytes must fail (nanopb max_size cap — the proto and handler + agree on 1024).""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + with self.assertRaises(CallException): + hive.sign_message(self.client, hive_path(ROLE_POSTING), b"x" * 1025) + + def test_hive_sign_message_rejects_bad_paths(self): + """Foreign trees, wrong network index, and unassigned roles must all + be rejected — same fence as the transaction handlers.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + h = 0x80000000 + bad_paths = [ + parse_path("m/44'/0'/0'/0/0"), # BIP-44 BTC + [h + 48, h + 3054, h + ROLE_POSTING, h, h], # registry 3054', not 13' + hive_path(2), # unassigned role 2' + [h + 48, h + 13, h + ROLE_POSTING, h], # short path + ] + for path in bad_paths: + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, path, b"login challenge") + self.assertIn("Invalid Hive SLIP-0048 path", str(ctx.exception)) + + def test_hive_sign_message_rejects_chain_id_prefix(self): + """A 'message' that begins with the mainnet chain id would hash to a + broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)). + The firmware must refuse the collision.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + from keepkeylib.client import CallException + disguised_tx = HIVE_CHAIN_ID + b"\x39\x30" + b"\x00" * 40 + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, hive_path(ROLE_ACTIVE), disguised_tx) + self.assertIn("chain ID", str(ctx.exception)) + + if __name__ == "__main__": unittest.main() From 591dba832be460960af07997e3c4804df0c75157 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 00:38:41 -0300 Subject: [PATCH 13/26] test(hive): drop msg arg from assertEqual (KeepKeyTest override takes 2 args) --- tests/test_msg_hive.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 197abdd2..73e4c647 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -427,7 +427,8 @@ def _recover_message_signer(self, message, sig65): chain_id prepend (unlike transactions), no message prefix. This recovery is exactly what a Hive dApp does to verify a login. """ - self.assertEqual(len(sig65), 65, "Hive signature must be 65 bytes") + # NB: common.KeepKeyTest overrides assertEqual without the msg param. + self.assertEqual(len(sig65), 65) recid = sig65[0] - 31 self.assertTrue(0 <= recid <= 3, "unexpected recovery header byte %d" % sig65[0]) digest = hashlib.sha256(message).digest() @@ -472,7 +473,7 @@ def test_hive_sign_message_all_roles(self): self.assertEqual(self._recover_message_signer(message, resp.signature), expected.raw_public_key) seen.add(resp.public_key) - self.assertEqual(len(seen), 4, "role keys must be distinct") + self.assertEqual(len(seen), 4) # role keys must be distinct def test_hive_sign_message_nonprintable_bytes(self): """Raw (non-printable) buffers sign too — Keychain accepts serialized From ed8cbdf92097945239cb2a084edb909247c520b8 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 01:23:54 -0300 Subject: [PATCH 14/26] =?UTF-8?q?test(hive):=20review=20follow-ups=20?= =?UTF-8?q?=E2=80=94=20owner'=20rejected=20for=20signBuffer,=20>128B=20pri?= =?UTF-8?q?ntable=20boundary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - all_roles now covers the three Keychain-exposed roles (posting/active/memo) - owner' path moved to the reject fence - new: 300-byte printable message signs via the hex-preview confirm --- tests/test_msg_hive.py | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 73e4c647..f0b6c282 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -457,23 +457,23 @@ def test_hive_sign_message_posting(self): posting.raw_public_key) def test_hive_sign_message_all_roles(self): - """All four SLIP-0048 roles may sign a message (Keychain lets dApps - request Posting, Active, or Memo); each signature recovers to that - role's key and to no other role's.""" + """The three Keychain-exposed roles (Posting/Active/Memo) may sign; + each signature recovers to that role's key and to no other's. + owner' is rejected — see test_hive_sign_message_rejects_bad_paths.""" self.requires_firmware("7.15.0") self.requires_message("HiveSignMessage") self.setup_mnemonic_nopin_nopassphrase() message = b"kk role check" seen = set() - for role in (ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING): + for role in (ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING): expected = hive.get_public_key(self.client, hive_path(role), show_display=False) resp = hive.sign_message(self.client, hive_path(role), message) self.assertEqual(resp.public_key, expected.raw_public_key) self.assertEqual(self._recover_message_signer(message, resp.signature), expected.raw_public_key) seen.add(resp.public_key) - self.assertEqual(len(seen), 4) # role keys must be distinct + self.assertEqual(len(seen), 3) # role keys must be distinct def test_hive_sign_message_nonprintable_bytes(self): """Raw (non-printable) buffers sign too — Keychain accepts serialized @@ -488,6 +488,20 @@ def test_hive_sign_message_nonprintable_bytes(self): self.assertEqual(self._recover_message_signer(message, resp.signature), posting.raw_public_key) + def test_hive_sign_message_long_printable_ok(self): + """Printable text over the 128-byte display budget still signs — it + routes through the hex-preview confirm (never silently truncated + text), and the signature covers every byte.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignMessage") + self.setup_mnemonic_nopin_nopassphrase() + + message = (b"benign preamble. " * 20)[:300] # printable, > 128 bytes + posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) + resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertEqual(self._recover_message_signer(message, resp.signature), + posting.raw_public_key) + def test_hive_sign_message_max_length_ok(self): """A message of exactly 1024 bytes (the proto cap) still signs.""" self.requires_firmware("7.15.0") @@ -522,6 +536,7 @@ def test_hive_sign_message_rejects_bad_paths(self): parse_path("m/44'/0'/0'/0/0"), # BIP-44 BTC [h + 48, h + 3054, h + ROLE_POSTING, h, h], # registry 3054', not 13' hive_path(2), # unassigned role 2' + hive_path(ROLE_OWNER), # owner' not a Keychain signBuffer role [h + 48, h + 13, h + ROLE_POSTING, h], # short path ] for path in bad_paths: From a4332c7799fe92dd48bbcacd4a251c9afac15576 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 12:49:12 -0300 Subject: [PATCH 15/26] feat(hive): sign_operations (HiveSignOperations 1616/1617) + parsed-ops test suite Own Graphene mini-serializer (dhive-equivalent) so parser and serializer bugs can't cancel out. 9 new tests: vote/downvote (+default chain id), unicode-body post, custom_json posting+active, 2/9/10 permanent exclusion, malformed structure (op count 0/5, extensions, trailing, overlong varint, weight range), role fences (active/memo/owner on posting tx, posting on active tx, mixed-tier, both-auths), oversize. --- keepkeylib/hive.py | 10 ++ keepkeylib/mapping.py | 2 + keepkeylib/messages_hive_pb2.py | 94 +++++++++++++- tests/test_msg_hive.py | 210 ++++++++++++++++++++++++++++++++ 4 files changed, 315 insertions(+), 1 deletion(-) diff --git a/keepkeylib/hive.py b/keepkeylib/hive.py index 9c7df1b2..c8758b89 100644 --- a/keepkeylib/hive.py +++ b/keepkeylib/hive.py @@ -40,6 +40,16 @@ def sign_message(client, address_n, message): return client.call(proto.HiveSignMessage(address_n=address_n, message=message)) +def sign_operations(client, address_n, serialized_tx, chain_id=None): + """Sign a host-serialized Graphene transaction (HiveSignOperations). + Firmware parses the bytes and clear-signs the phase-1 op table + (vote, comment, custom_json); digest = SHA256(chain_id || tx).""" + kwargs = dict(address_n=address_n, serialized_tx=serialized_tx) + if chain_id is not None: + kwargs['chain_id'] = chain_id + return client.call(proto.HiveSignOperations(**kwargs)) + + def sign_account_create(client, address_n, chain_id, ref_block_num, ref_block_prefix, expiration, creator, new_account_name, fee_amount=3000, owner_key='', active_key='', posting_key='', memo_key=''): diff --git a/keepkeylib/mapping.py b/keepkeylib/mapping.py index 329f4ee4..5b851dc0 100644 --- a/keepkeylib/mapping.py +++ b/keepkeylib/mapping.py @@ -117,6 +117,8 @@ def check_missing(): # 1610-1613 reserved: NEAR 1614: ('HiveSignMessage', hive_proto), 1615: ('HiveSignedMessage', hive_proto), + 1616: ('HiveSignOperations', hive_proto), + 1617: ('HiveSignedOperations', hive_proto), } for wire_id, (msg_name, mod) in _hive_wire_ids.items(): msg_class = getattr(mod, msg_name, None) diff --git a/keepkeylib/messages_hive_pb2.py b/keepkeylib/messages_hive_pb2.py index c4ae6499..c83b6460 100644 --- a/keepkeylib/messages_hive_pb2.py +++ b/keepkeylib/messages_hive_pb2.py @@ -19,7 +19,7 @@ name='messages-hive.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x13messages-hive.proto\"I\n\x10HiveGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0c\n\x04role\x18\x03 \x01(\r\";\n\rHivePublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x16\n\x0eraw_public_key\x18\x02 \x01(\x0c\"C\n\x11HiveGetPublicKeys\x12\x18\n\raccount_index\x18\x01 \x01(\r:\x01\x30\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"^\n\x0eHivePublicKeys\x12\x11\n\towner_key\x18\x01 \x01(\t\x12\x12\n\nactive_key\x18\x02 \x01(\t\x12\x10\n\x08memo_key\x18\x03 \x01(\t\x12\x13\n\x0bposting_key\x18\x04 \x01(\t\"\xd6\x01\n\nHiveSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x10\n\x08\x64\x65\x63imals\x18\t \x01(\r\x12\x14\n\x0c\x61sset_symbol\x18\n \x01(\t\x12\x0c\n\x04memo\x18\x0b \x01(\t\"8\n\x0cHiveSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\x8e\x02\n\x15HiveSignAccountCreate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x63reator\x18\x06 \x01(\t\x12\x18\n\x10new_account_name\x18\x07 \x01(\t\x12\x11\n\towner_key\x18\x08 \x01(\t\x12\x12\n\nactive_key\x18\t \x01(\t\x12\x13\n\x0bposting_key\x18\n \x01(\t\x12\x10\n\x08memo_key\x18\x0b \x01(\t\x12\x12\n\nfee_amount\x18\x0c \x01(\x04\"C\n\x17HiveSignedAccountCreate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\xf0\x01\n\x15HiveSignAccountUpdate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x06 \x01(\t\x12\x15\n\rnew_owner_key\x18\x07 \x01(\t\x12\x16\n\x0enew_active_key\x18\x08 \x01(\t\x12\x17\n\x0fnew_posting_key\x18\t \x01(\t\x12\x14\n\x0cnew_memo_key\x18\n \x01(\t\"C\n\x17HiveSignedAccountUpdate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"5\n\x0fHiveSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x01(\x0c\":\n\x11HiveSignedMessage\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x42\x39\n#com.shapeshift.keepkey.lib.protobufB\x12KeepKeyMessageHive') + serialized_pb=_b('\n\x13messages-hive.proto\"I\n\x10HiveGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\x12\x0c\n\x04role\x18\x03 \x01(\r\";\n\rHivePublicKey\x12\x12\n\npublic_key\x18\x01 \x01(\t\x12\x16\n\x0eraw_public_key\x18\x02 \x01(\x0c\"C\n\x11HiveGetPublicKeys\x12\x18\n\raccount_index\x18\x01 \x01(\r:\x01\x30\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"^\n\x0eHivePublicKeys\x12\x11\n\towner_key\x18\x01 \x01(\t\x12\x12\n\nactive_key\x18\x02 \x01(\t\x12\x10\n\x08memo_key\x18\x03 \x01(\t\x12\x13\n\x0bposting_key\x18\x04 \x01(\t\"\xd6\x01\n\nHiveSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0c\n\x04\x66rom\x18\x06 \x01(\t\x12\n\n\x02to\x18\x07 \x01(\t\x12\x0e\n\x06\x61mount\x18\x08 \x01(\x04\x12\x10\n\x08\x64\x65\x63imals\x18\t \x01(\r\x12\x14\n\x0c\x61sset_symbol\x18\n \x01(\t\x12\x0c\n\x04memo\x18\x0b \x01(\t\"8\n\x0cHiveSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\x8e\x02\n\x15HiveSignAccountCreate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x63reator\x18\x06 \x01(\t\x12\x18\n\x10new_account_name\x18\x07 \x01(\t\x12\x11\n\towner_key\x18\x08 \x01(\t\x12\x12\n\nactive_key\x18\t \x01(\t\x12\x13\n\x0bposting_key\x18\n \x01(\t\x12\x10\n\x08memo_key\x18\x0b \x01(\t\x12\x12\n\nfee_amount\x18\x0c \x01(\x04\"C\n\x17HiveSignedAccountCreate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"\xf0\x01\n\x15HiveSignAccountUpdate\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rref_block_num\x18\x03 \x01(\r\x12\x18\n\x10ref_block_prefix\x18\x04 \x01(\r\x12\x12\n\nexpiration\x18\x05 \x01(\r\x12\x0f\n\x07\x61\x63\x63ount\x18\x06 \x01(\t\x12\x15\n\rnew_owner_key\x18\x07 \x01(\t\x12\x16\n\x0enew_active_key\x18\x08 \x01(\t\x12\x17\n\x0fnew_posting_key\x18\t \x01(\t\x12\x14\n\x0cnew_memo_key\x18\n \x01(\t\"C\n\x17HiveSignedAccountUpdate\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x02 \x01(\x0c\"5\n\x0fHiveSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x01(\x0c\":\n\x11HiveSignedMessage\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\"P\n\x12HiveSignOperations\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x10\n\x08\x63hain_id\x18\x02 \x01(\x0c\x12\x15\n\rserialized_tx\x18\x03 \x01(\x0c\")\n\x14HiveSignedOperations\x12\x11\n\tsignature\x18\x01 \x01(\x0c\x42\x39\n#com.shapeshift.keepkey.lib.protobufB\x12KeepKeyMessageHive') ) @@ -690,6 +690,82 @@ serialized_end=1366, ) + +_HIVESIGNOPERATIONS = _descriptor.Descriptor( + name='HiveSignOperations', + full_name='HiveSignOperations', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='address_n', full_name='HiveSignOperations.address_n', index=0, + number=1, type=13, cpp_type=3, label=3, + has_default_value=False, default_value=[], + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='chain_id', full_name='HiveSignOperations.chain_id', index=1, + number=2, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='serialized_tx', full_name='HiveSignOperations.serialized_tx', index=2, + number=3, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1368, + serialized_end=1448, +) + + +_HIVESIGNEDOPERATIONS = _descriptor.Descriptor( + name='HiveSignedOperations', + full_name='HiveSignedOperations', + filename=None, + file=DESCRIPTOR, + containing_type=None, + fields=[ + _descriptor.FieldDescriptor( + name='signature', full_name='HiveSignedOperations.signature', index=0, + number=1, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + ], + extensions=[ + ], + nested_types=[], + enum_types=[ + ], + options=None, + is_extendable=False, + syntax='proto2', + extension_ranges=[], + oneofs=[ + ], + serialized_start=1450, + serialized_end=1491, +) + DESCRIPTOR.message_types_by_name['HiveGetPublicKey'] = _HIVEGETPUBLICKEY DESCRIPTOR.message_types_by_name['HivePublicKey'] = _HIVEPUBLICKEY DESCRIPTOR.message_types_by_name['HiveGetPublicKeys'] = _HIVEGETPUBLICKEYS @@ -702,6 +778,8 @@ DESCRIPTOR.message_types_by_name['HiveSignedAccountUpdate'] = _HIVESIGNEDACCOUNTUPDATE DESCRIPTOR.message_types_by_name['HiveSignMessage'] = _HIVESIGNMESSAGE DESCRIPTOR.message_types_by_name['HiveSignedMessage'] = _HIVESIGNEDMESSAGE +DESCRIPTOR.message_types_by_name['HiveSignOperations'] = _HIVESIGNOPERATIONS +DESCRIPTOR.message_types_by_name['HiveSignedOperations'] = _HIVESIGNEDOPERATIONS _sym_db.RegisterFileDescriptor(DESCRIPTOR) HiveGetPublicKey = _reflection.GeneratedProtocolMessageType('HiveGetPublicKey', (_message.Message,), dict( @@ -788,6 +866,20 @@ )) _sym_db.RegisterMessage(HiveSignedMessage) +HiveSignOperations = _reflection.GeneratedProtocolMessageType('HiveSignOperations', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNOPERATIONS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignOperations) + )) +_sym_db.RegisterMessage(HiveSignOperations) + +HiveSignedOperations = _reflection.GeneratedProtocolMessageType('HiveSignedOperations', (_message.Message,), dict( + DESCRIPTOR = _HIVESIGNEDOPERATIONS, + __module__ = 'messages_hive_pb2' + # @@protoc_insertion_point(class_scope:HiveSignedOperations) + )) +_sym_db.RegisterMessage(HiveSignedOperations) + DESCRIPTOR.has_options = True DESCRIPTOR._options = _descriptor._ParseOptions(descriptor_pb2.FileOptions(), _b('\n#com.shapeshift.keepkey.lib.protobufB\022KeepKeyMessageHive')) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index f0b6c282..3fe08b4b 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -30,6 +30,7 @@ """ import hashlib +import struct import unittest import common @@ -46,9 +47,12 @@ # SLIP-0048 roles (hardened offsets within the role component). ROLE_OWNER, ROLE_ACTIVE, ROLE_MEMO, ROLE_POSTING = 0, 1, 3, 4 +HIVE_OP_VOTE = 0 +HIVE_OP_COMMENT = 1 HIVE_OP_TRANSFER = 2 HIVE_OP_ACCOUNT_CREATE = 9 HIVE_OP_ACCOUNT_UPDATE = 10 +HIVE_OP_CUSTOM_JSON = 18 def hive_path(role, account_index=0): @@ -75,6 +79,54 @@ def recover_compressed(serialized_tx, sig65): return candidates[recid].to_string("compressed") +# ── Independent Graphene serializer for HiveSignOperations tests ────────── +# dhive-equivalent byte building, written here so firmware parser bugs can't +# cancel out against firmware serializer bugs. + +def _varint(n): + out = b"" + while True: + b_ = n & 0x7F + n >>= 7 + if n: + out += bytes([b_ | 0x80]) + else: + return out + bytes([b_]) + + +def _string(s): + if isinstance(s, str): + s = s.encode("utf-8") + return _varint(len(s)) + s + + +def _ops_tx(op_blobs, ref_num=12345, ref_prefix=67890, expiration=1700000000, + ext=b"\x00", opcount=None): + """header + varint op count + ops + extensions (default: empty).""" + head = struct.pack("4 ops, nonzero extensions, trailing bytes, overlong + varint, out-of-range weight — each refused with a specific error.""" + self.requires_firmware("7.15.0") + self.requires_message("HiveSignOperations") + self.setup_mnemonic_nopin_nopassphrase() + + vote = _op_vote("kkvoter", "author", "permlink", 100) + self._assert_ops_fails("op count", _ops_tx([], opcount=0)) + self._assert_ops_fails("op count", _ops_tx([vote] * 5)) + self._assert_ops_fails("extensions must be empty", + _ops_tx([vote], ext=b"\x01")) + self._assert_ops_fails("trailing bytes", _ops_tx([vote]) + b"\x00") + # op_count as an overlong 6-byte varint encoding of 1 + head = struct.pack(" 2048) + from keepkeylib.client import CallException + with self.assertRaises(CallException): + hive.sign_operations(self.client, hive_path(ROLE_POSTING), tx, + chain_id=HIVE_CHAIN_ID) + def test_hive_sign_message_rejects_chain_id_prefix(self): """A 'message' that begins with the mainnet chain id would hash to a broadcastable TRANSACTION digest (tx digest = SHA256(chain_id || tx)). From 15d95eccc8908a994d37834f62dc10b98d49a230 Mon Sep 17 00:00:00 2001 From: highlander Date: Wed, 15 Jul 2026 12:54:17 -0300 Subject: [PATCH 16/26] test(hive): oversize tx must actually exceed the 2048 proto cap --- tests/test_msg_hive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 3fe08b4b..6924ee0f 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -746,7 +746,7 @@ def test_hive_sign_ops_rejects_oversize(self): self.requires_firmware("7.15.0") self.requires_message("HiveSignOperations") self.setup_mnemonic_nopin_nopassphrase() - big_body = b"x" * 2000 + big_body = b"x" * 2100 tx = _ops_tx([_op_comment("", "cat", "kkauthor", "perm", "", big_body, "")]) self.assertTrue(len(tx) > 2048) from keepkeylib.client import CallException From a322ef11fb4415fedda1a7d574c22adb8c9f8035 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 16 Jul 2026 17:55:52 -0300 Subject: [PATCH 17/26] fix(review): regenerate bindings for the post-#36 protocol; verify Maya signatures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses three review findings on this stack. 1) Bindings were generated from a STALE protocol. The device-protocol submodule was pinned at 2ec999a9 — the same commit that lacks LoadClearsignSigner's icon fields and Hive 1614–1617 — so build_pb.sh regenerated incomplete bindings. Bump the pin to f7b4580 and regenerate: - messages_ethereum_pb2.py: LoadClearsignSigner now exposes icon, icon_width, icon_height, persist (was key_id/pubkey/alias only, so constructing with icon raised ValueError). - messages_pb2.py: real MessageType_HiveSign{Message,Operations} / Signed{Message,Operations} = 1614–1617 constants. mapping.py's manual wire-id table was masking their absence. client.load_clearsign_signer() now accepts icon/icon_width/icon_height/ persist and documents the RLE contract (was RAM-only, no icon). 2) The icon wire contract was mis-documented (device-protocol#36 fixes the proto). Adds TestClearsignSignerIcon: a reference RLE decoder traced from draw_bitmap_mono_rle(), the published golden vector (03 FF FF 00, w=2 h=2 -> FF FF FF 00), RUN/LITERAL packets, n==0 and truncation rejects, the icon/dims/persist round-trip, and text-only identities. Also asserts the arithmetic that forces RLE: a packed 1bpp 64x64 needs 512 bytes > the 384-byte cap, so the previously documented packed format was impossible. 3) The two Maya EVM tests asserted only v and 32-byte r/s lengths, so a wrong digest, calldata or key would still pass. (The weakening predates 560b897 — its parent already had the structural asserts; the router change inherited them.) Replace with recover_eth_signer(): rebuild the EIP-155 sighash from the exact tx fields, recover the signer from (v,r,s), and assert it equals the device's own address for the path. Verified offline: a good signature recovers to the signer, and tampered calldata does NOT — so the tests now actually fail on a wrong digest. Recovery keeps them correct across router changes without re-freezing r/s vectors. Offline suites: 26 passed. --- device-protocol | 2 +- keepkeylib/client.py | 31 ++++++- keepkeylib/messages_ethereum_pb2.py | 58 ++++++++---- keepkeylib/messages_pb2.py | 32 ++++++- tests/test_msg_ethereum_clear_signing.py | 107 +++++++++++++++++++++++ tests/test_msg_mayachain_signtx.py | 95 ++++++++++++++------ 6 files changed, 274 insertions(+), 51 deletions(-) diff --git a/device-protocol b/device-protocol index 2ec999a9..f7b45807 160000 --- a/device-protocol +++ b/device-protocol @@ -1 +1 @@ -Subproject commit 2ec999a9b2e5174da5981e85f66845a97cdaa877 +Subproject commit f7b458078cf9249ac706bd1089f109a5a2ea8696 diff --git a/keepkeylib/client.py b/keepkeylib/client.py index 4fdb5449..f2bb12b3 100644 --- a/keepkeylib/client.py +++ b/keepkeylib/client.py @@ -711,16 +711,39 @@ def ethereum_send_tx_metadata(self, signed_payload, metadata_version, key_id): return self.call(msg) @expect(proto.Success) - def load_clearsign_signer(self, key_id, pubkey, alias): + def load_clearsign_signer(self, key_id, pubkey, alias, icon=None, + icon_width=None, icon_height=None, persist=None): """Load a runtime clearsign signer (compressed pubkey + alias) into a - key slot. Triggers a mandatory on-device confirmation; RAM-only, the - signer is gone on reboot. Metadata verified by a loaded signer shows - a warning screen naming the alias before every clearsign page.""" + key slot. Triggers a mandatory on-device confirmation. Metadata verified + by a loaded signer shows a warning screen naming the alias before every + clearsign page. + + icon (optional, <= 384 bytes) is an identity logo shown on the trust + screen. It is RUN-LENGTH ENCODED with byte-valued pixels -- NOT a packed + 1bpp bitmap (a packed 64x64 would need 512 bytes and cannot fit the cap). + Read n = int8(data[i++]): n > 0 emits the single following value byte n + times; n < 0 emits the next (-n) value bytes once each; n == 0 is + invalid. Pixels fill row-major until icon_width*icon_height are emitted. + See LoadClearsignSigner.icon in messages-ethereum.proto for the grammar + and a golden vector; the decoder of record is draw_bitmap_mono_rle() in + keepkey-firmware lib/board/draw.c. icon_width/icon_height are required + with icon and must each be 1..64; omit all three for a text-only identity. + + persist=True also writes the identity to flash so it survives reboot; + the default is RAM-only (gone on reboot).""" msg = eth_proto.LoadClearsignSigner( key_id=key_id, pubkey=pubkey, alias=alias, ) + if icon is not None: + msg.icon = icon + if icon_width is not None: + msg.icon_width = icon_width + if icon_height is not None: + msg.icon_height = icon_height + if persist is not None: + msg.persist = persist return self.call(msg) @session diff --git a/keepkeylib/messages_ethereum_pb2.py b/keepkeylib/messages_ethereum_pb2.py index a4f5efcd..a20d679a 100644 --- a/keepkeylib/messages_ethereum_pb2.py +++ b/keepkeylib/messages_ethereum_pb2.py @@ -20,7 +20,7 @@ name='messages-ethereum.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x17messages-ethereum.proto\x1a\x0btypes.proto\"=\n\x12\x45thereumGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"7\n\x0f\x45thereumAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\x0c\x12\x13\n\x0b\x61\x64\x64ress_str\x18\x02 \x01(\t\"\x95\x03\n\x0e\x45thereumSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x11\n\tgas_price\x18\x03 \x01(\x0c\x12\x11\n\tgas_limit\x18\x04 \x01(\x0c\x12\n\n\x02to\x18\x05 \x01(\x0c\x12\r\n\x05value\x18\x06 \x01(\x0c\x12\x1a\n\x12\x64\x61ta_initial_chunk\x18\x07 \x01(\x0c\x12\x13\n\x0b\x64\x61ta_length\x18\x08 \x01(\r\x12\x14\n\x0cto_address_n\x18\t \x03(\r\x12(\n\x0c\x61\x64\x64ress_type\x18\n \x01(\x0e\x32\x12.OutputAddressType\x12\x10\n\x08\x63hain_id\x18\x0c \x01(\r\x12\x17\n\x0fmax_fee_per_gas\x18\r \x01(\x0c\x12 \n\x18max_priority_fee_per_gas\x18\x0e \x01(\x0c\x12\x13\n\x0btoken_value\x18\x64 \x01(\x0c\x12\x10\n\x08token_to\x18\x65 \x01(\x0c\x12\x16\n\x0etoken_shortcut\x18\x66 \x01(\t\x12\x0f\n\x07tx_type\x18g \x01(\r\x12\x0c\n\x04type\x18h \x01(\rJ\x04\x08\x0b\x10\x0c\"\x8c\x01\n\x11\x45thereumTxRequest\x12\x13\n\x0b\x64\x61ta_length\x18\x01 \x01(\r\x12\x13\n\x0bsignature_v\x18\x02 \x01(\r\x12\x13\n\x0bsignature_r\x18\x03 \x01(\x0c\x12\x13\n\x0bsignature_s\x18\x04 \x01(\x0c\x12\x0c\n\x04hash\x18\x05 \x01(\x0c\x12\x15\n\rsignature_der\x18\x06 \x01(\x0c\"#\n\rEthereumTxAck\x12\x12\n\ndata_chunk\x18\x01 \x01(\x0c\"V\n\x12\x45thereumTxMetadata\x12\x16\n\x0esigned_payload\x18\x01 \x01(\x0c\x12\x18\n\x10metadata_version\x18\x02 \x01(\r\x12\x0e\n\x06key_id\x18\x03 \x01(\r\"F\n\x13\x45thereumMetadataAck\x12\x16\n\x0e\x63lassification\x18\x01 \x02(\r\x12\x17\n\x0f\x64isplay_summary\x18\x02 \x01(\t\"D\n\x13LoadClearsignSigner\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12\x0e\n\x06pubkey\x18\x02 \x01(\x0c\x12\r\n\x05\x61lias\x18\x03 \x01(\t\"9\n\x13\x45thereumSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\"L\n\x15\x45thereumVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\">\n\x18\x45thereumMessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"_\n\x15\x45thereumSignTypedHash\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1d\n\x15\x64omain_separator_hash\x18\x02 \x02(\x0c\x12\x14\n\x0cmessage_hash\x18\x03 \x01(\x0c\"\x8b\x01\n\x1a\x45thereumTypedDataSignature\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x02(\t\x12\x1d\n\x15\x64omain_separator_hash\x18\x03 \x01(\x0c\x12\x14\n\x0chas_msg_hash\x18\x04 \x02(\x08\x12\x14\n\x0cmessage_hash\x18\x05 \x01(\x0c\"\x85\x01\n\x16\x45thereum712TypesValues\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x13\n\x0b\x65ip712types\x18\x02 \x02(\t\x12\x17\n\x0f\x65ip712primetype\x18\x03 \x02(\t\x12\x12\n\neip712data\x18\x04 \x02(\t\x12\x16\n\x0e\x65ip712typevals\x18\x05 \x02(\rB4\n\x1a\x63om.keepkey.deviceprotocolB\x16KeepKeyMessageEthereum') + serialized_pb=_b('\n\x17messages-ethereum.proto\x1a\x0btypes.proto\"=\n\x12\x45thereumGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x14\n\x0cshow_display\x18\x02 \x01(\x08\"7\n\x0f\x45thereumAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\x0c\x12\x13\n\x0b\x61\x64\x64ress_str\x18\x02 \x01(\t\"\x95\x03\n\x0e\x45thereumSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x11\n\tgas_price\x18\x03 \x01(\x0c\x12\x11\n\tgas_limit\x18\x04 \x01(\x0c\x12\n\n\x02to\x18\x05 \x01(\x0c\x12\r\n\x05value\x18\x06 \x01(\x0c\x12\x1a\n\x12\x64\x61ta_initial_chunk\x18\x07 \x01(\x0c\x12\x13\n\x0b\x64\x61ta_length\x18\x08 \x01(\r\x12\x14\n\x0cto_address_n\x18\t \x03(\r\x12(\n\x0c\x61\x64\x64ress_type\x18\n \x01(\x0e\x32\x12.OutputAddressType\x12\x10\n\x08\x63hain_id\x18\x0c \x01(\r\x12\x17\n\x0fmax_fee_per_gas\x18\r \x01(\x0c\x12 \n\x18max_priority_fee_per_gas\x18\x0e \x01(\x0c\x12\x13\n\x0btoken_value\x18\x64 \x01(\x0c\x12\x10\n\x08token_to\x18\x65 \x01(\x0c\x12\x16\n\x0etoken_shortcut\x18\x66 \x01(\t\x12\x0f\n\x07tx_type\x18g \x01(\r\x12\x0c\n\x04type\x18h \x01(\rJ\x04\x08\x0b\x10\x0c\"\x8c\x01\n\x11\x45thereumTxRequest\x12\x13\n\x0b\x64\x61ta_length\x18\x01 \x01(\r\x12\x13\n\x0bsignature_v\x18\x02 \x01(\r\x12\x13\n\x0bsignature_r\x18\x03 \x01(\x0c\x12\x13\n\x0bsignature_s\x18\x04 \x01(\x0c\x12\x0c\n\x04hash\x18\x05 \x01(\x0c\x12\x15\n\rsignature_der\x18\x06 \x01(\x0c\"#\n\rEthereumTxAck\x12\x12\n\ndata_chunk\x18\x01 \x01(\x0c\"V\n\x12\x45thereumTxMetadata\x12\x16\n\x0esigned_payload\x18\x01 \x01(\x0c\x12\x18\n\x10metadata_version\x18\x02 \x01(\r\x12\x0e\n\x06key_id\x18\x03 \x01(\r\"F\n\x13\x45thereumMetadataAck\x12\x16\n\x0e\x63lassification\x18\x01 \x02(\r\x12\x17\n\x0f\x64isplay_summary\x18\x02 \x01(\t\"\x8c\x01\n\x13LoadClearsignSigner\x12\x0e\n\x06key_id\x18\x01 \x01(\r\x12\x0e\n\x06pubkey\x18\x02 \x01(\x0c\x12\r\n\x05\x61lias\x18\x03 \x01(\t\x12\x0c\n\x04icon\x18\x04 \x01(\x0c\x12\x12\n\nicon_width\x18\x05 \x01(\r\x12\x13\n\x0bicon_height\x18\x06 \x01(\r\x12\x0f\n\x07persist\x18\x07 \x01(\x08\"9\n\x13\x45thereumSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\"L\n\x15\x45thereumVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\">\n\x18\x45thereumMessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"_\n\x15\x45thereumSignTypedHash\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1d\n\x15\x64omain_separator_hash\x18\x02 \x02(\x0c\x12\x14\n\x0cmessage_hash\x18\x03 \x01(\x0c\"\x8b\x01\n\x1a\x45thereumTypedDataSignature\x12\x11\n\tsignature\x18\x01 \x02(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x02(\t\x12\x1d\n\x15\x64omain_separator_hash\x18\x03 \x01(\x0c\x12\x14\n\x0chas_msg_hash\x18\x04 \x02(\x08\x12\x14\n\x0cmessage_hash\x18\x05 \x01(\x0c\"\x85\x01\n\x16\x45thereum712TypesValues\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x13\n\x0b\x65ip712types\x18\x02 \x02(\t\x12\x17\n\x0f\x65ip712primetype\x18\x03 \x02(\t\x12\x12\n\neip712data\x18\x04 \x02(\t\x12\x16\n\x0e\x65ip712typevals\x18\x05 \x02(\rB4\n\x1a\x63om.keepkey.deviceprotocolB\x16KeepKeyMessageEthereum') , dependencies=[types__pb2.DESCRIPTOR,]) @@ -461,6 +461,34 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon', full_name='LoadClearsignSigner.icon', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon_width', full_name='LoadClearsignSigner.icon_width', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='icon_height', full_name='LoadClearsignSigner.icon_height', index=5, + number=6, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='persist', full_name='LoadClearsignSigner.persist', index=6, + number=7, type=8, cpp_type=7, label=1, + has_default_value=False, default_value=False, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -473,8 +501,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=908, - serialized_end=976, + serialized_start=909, + serialized_end=1049, ) @@ -511,8 +539,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=978, - serialized_end=1035, + serialized_start=1051, + serialized_end=1108, ) @@ -556,8 +584,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1037, - serialized_end=1113, + serialized_start=1110, + serialized_end=1186, ) @@ -594,8 +622,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1115, - serialized_end=1177, + serialized_start=1188, + serialized_end=1250, ) @@ -639,8 +667,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1179, - serialized_end=1274, + serialized_start=1252, + serialized_end=1347, ) @@ -698,8 +726,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1277, - serialized_end=1416, + serialized_start=1350, + serialized_end=1489, ) @@ -757,8 +785,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=1419, - serialized_end=1552, + serialized_start=1492, + serialized_end=1625, ) _ETHEREUMSIGNTX.fields_by_name['address_type'].enum_type = types__pb2._OUTPUTADDRESSTYPE diff --git a/keepkeylib/messages_pb2.py b/keepkeylib/messages_pb2.py index a6989aab..d7b8712a 100644 --- a/keepkeylib/messages_pb2.py +++ b/keepkeylib/messages_pb2.py @@ -21,7 +21,7 @@ name='messages.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x0emessages.proto\x1a\x0btypes.proto\"\x0c\n\nInitialize\"\r\n\x0bGetFeatures\"\xaa\x04\n\x08\x46\x65\x61tures\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\x15\n\rmajor_version\x18\x02 \x01(\r\x12\x15\n\rminor_version\x18\x03 \x01(\r\x12\x15\n\rpatch_version\x18\x04 \x01(\r\x12\x17\n\x0f\x62ootloader_mode\x18\x05 \x01(\x08\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x16\n\x0epin_protection\x18\x07 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x08 \x01(\x08\x12\x10\n\x08language\x18\t \x01(\t\x12\r\n\x05label\x18\n \x01(\t\x12\x18\n\x05\x63oins\x18\x0b \x03(\x0b\x32\t.CoinType\x12\x13\n\x0binitialized\x18\x0c \x01(\x08\x12\x10\n\x08revision\x18\r \x01(\x0c\x12\x17\n\x0f\x62ootloader_hash\x18\x0e \x01(\x0c\x12\x10\n\x08imported\x18\x0f \x01(\x08\x12\x12\n\npin_cached\x18\x10 \x01(\x08\x12\x19\n\x11passphrase_cached\x18\x11 \x01(\x08\x12\x1d\n\x08policies\x18\x12 \x03(\x0b\x32\x0b.PolicyType\x12\r\n\x05model\x18\x15 \x01(\t\x12\x18\n\x10\x66irmware_variant\x18\x16 \x01(\t\x12\x15\n\rfirmware_hash\x18\x17 \x01(\x0c\x12\x11\n\tno_backup\x18\x18 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x19 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x1a \x01(\r\"*\n\x0cGetCoinTable\x12\r\n\x05start\x18\x01 \x01(\r\x12\x0b\n\x03\x65nd\x18\x02 \x01(\r\"L\n\tCoinTable\x12\x18\n\x05table\x18\x01 \x03(\x0b\x32\t.CoinType\x12\x11\n\tnum_coins\x18\x02 \x01(\r\x12\x12\n\nchunk_size\x18\x03 \x01(\r\"\x0e\n\x0c\x43learSession\"y\n\rApplySettings\x12\x10\n\x08language\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x16\n\x0euse_passphrase\x18\x03 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x04 \x01(\r\x12\x13\n\x0bu2f_counter\x18\x05 \x01(\r\"\x1b\n\tChangePin\x12\x0e\n\x06remove\x18\x01 \x01(\x08\"\x87\x01\n\x04Ping\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x19\n\x11\x62utton_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x05 \x01(\x08\"\x1a\n\x07Success\x12\x0f\n\x07message\x18\x01 \x01(\t\"6\n\x07\x46\x61ilure\x12\x1a\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x0c.FailureType\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\rButtonRequest\x12 \n\x04\x63ode\x18\x01 \x01(\x0e\x32\x12.ButtonRequestType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"\x0b\n\tButtonAck\"7\n\x10PinMatrixRequest\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.PinMatrixRequestType\"\x1b\n\x0cPinMatrixAck\x12\x0b\n\x03pin\x18\x01 \x02(\t\"\x08\n\x06\x43\x61ncel\"\x13\n\x11PassphraseRequest\"#\n\rPassphraseAck\x12\x12\n\npassphrase\x18\x01 \x02(\t\"\x1a\n\nGetEntropy\x12\x0c\n\x04size\x18\x01 \x02(\r\"\x1a\n\x07\x45ntropy\x12\x0f\n\x07\x65ntropy\x18\x01 \x02(\x0c\"\xa2\x01\n\x0cGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x02 \x01(\t\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"4\n\tPublicKey\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x0c\n\x04xpub\x18\x02 \x01(\t\"\xb3\x01\n\nGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\tcoin_name\x18\x02 \x01(\t:\x07\x42itcoin\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12+\n\x08multisig\x18\x04 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"\x1a\n\x07\x41\x64\x64ress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\t\"\x0c\n\nWipeDevice\"\xbb\x01\n\nLoadDevice\x12\x10\n\x08mnemonic\x18\x01 \x01(\t\x12\x19\n\x04node\x18\x02 \x01(\x0b\x32\x0b.HDNodeType\x12\x0b\n\x03pin\x18\x03 \x01(\t\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x15\n\rskip_checksum\x18\x07 \x01(\x08\x12\x13\n\x0bu2f_counter\x18\x08 \x01(\r\"\xe1\x01\n\x0bResetDevice\x12\x16\n\x0e\x64isplay_random\x18\x01 \x01(\x08\x12\x15\n\x08strength\x18\x02 \x01(\r:\x03\x32\x35\x36\x12\x1d\n\x15passphrase_protection\x18\x03 \x01(\x08\x12\x16\n\x0epin_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x11\n\tno_backup\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\"\x10\n\x0e\x45ntropyRequest\"\x1d\n\nEntropyAck\x12\x0f\n\x07\x65ntropy\x18\x01 \x01(\x0c\"\xff\x01\n\x0eRecoveryDevice\x12\x12\n\nword_count\x18\x01 \x01(\r\x12\x1d\n\x15passphrase_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x19\n\x08language\x18\x04 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x05 \x01(\t\x12\x18\n\x10\x65nforce_wordlist\x18\x06 \x01(\x08\x12\x1c\n\x14use_character_cipher\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\x12\x0f\n\x07\x64ry_run\x18\n \x01(\x08\"\r\n\x0bWordRequest\"\x17\n\x07WordAck\x12\x0c\n\x04word\x18\x01 \x02(\t\";\n\x10\x43haracterRequest\x12\x10\n\x08word_pos\x18\x01 \x02(\r\x12\x15\n\rcharacter_pos\x18\x02 \x02(\r\"?\n\x0c\x43haracterAck\x12\x11\n\tcharacter\x18\x01 \x01(\t\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\x82\x01\n\x0bSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x04 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"`\n\rVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\"6\n\x10MessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"v\n\x0e\x45ncryptMessage\x12\x0e\n\x06pubkey\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x14\n\x0c\x64isplay_only\x18\x03 \x01(\x08\x12\x11\n\taddress_n\x18\x04 \x03(\r\x12\x1a\n\tcoin_name\x18\x05 \x01(\t:\x07\x42itcoin\"@\n\x10\x45ncryptedMessage\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x0c\n\x04hmac\x18\x03 \x01(\x0c\"Q\n\x0e\x44\x65\x63ryptMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x0c\n\x04hmac\x18\x04 \x01(\x0c\"4\n\x10\x44\x65\x63ryptedMessage\x12\x0f\n\x07message\x18\x01 \x01(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\"\x8c\x01\n\x0e\x43ipherKeyValue\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\x12\x0f\n\x07\x65ncrypt\x18\x04 \x01(\x08\x12\x16\n\x0e\x61sk_on_encrypt\x18\x05 \x01(\x08\x12\x16\n\x0e\x61sk_on_decrypt\x18\x06 \x01(\x08\x12\n\n\x02iv\x18\x07 \x01(\x0c\"!\n\x10\x43ipheredKeyValue\x12\r\n\x05value\x18\x01 \x01(\x0c\"5\n\x10GetBip85Mnemonic\x12\x12\n\nword_count\x18\x01 \x02(\r\x12\r\n\x05index\x18\x02 \x02(\r\"!\n\rBip85Mnemonic\x12\x10\n\x08mnemonic\x18\x01 \x02(\t\"\xce\x01\n\x06SignTx\x12\x15\n\routputs_count\x18\x01 \x02(\r\x12\x14\n\x0cinputs_count\x18\x02 \x02(\r\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x12\n\x07version\x18\x04 \x01(\r:\x01\x31\x12\x14\n\tlock_time\x18\x05 \x01(\r:\x01\x30\x12\x0e\n\x06\x65xpiry\x18\x06 \x01(\r\x12\x14\n\x0coverwintered\x18\x07 \x01(\x08\x12\x18\n\x10version_group_id\x18\x08 \x01(\r\x12\x11\n\tbranch_id\x18\n \x01(\r\"\x85\x01\n\tTxRequest\x12\"\n\x0crequest_type\x18\x01 \x01(\x0e\x32\x0c.RequestType\x12&\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x15.TxRequestDetailsType\x12,\n\nserialized\x18\x03 \x01(\x0b\x32\x18.TxRequestSerializedType\"%\n\x05TxAck\x12\x1c\n\x02tx\x18\x01 \x01(\x0b\x32\x10.TransactionType\"+\n\x08RawTxAck\x12\x1f\n\x02tx\x18\x01 \x01(\x0b\x32\x13.RawTransactionType\"}\n\x0cSignIdentity\x12\x1f\n\x08identity\x18\x01 \x01(\x0b\x32\r.IdentityType\x12\x18\n\x10\x63hallenge_hidden\x18\x02 \x01(\x0c\x12\x18\n\x10\x63hallenge_visual\x18\x03 \x01(\t\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x04 \x01(\t\"H\n\x0eSignedIdentity\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\",\n\rApplyPolicies\x12\x1b\n\x06policy\x18\x01 \x03(\x0b\x32\x0b.PolicyType\"?\n\tFlashHash\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\x12\x11\n\tchallenge\x18\x03 \x01(\x0c\":\n\nFlashWrite\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\r\n\x05\x65rase\x18\x03 \x01(\x08\"!\n\x11\x46lashHashResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"5\n\x12\x44\x65\x62ugLinkFlashDump\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\"*\n\x1a\x44\x65\x62ugLinkFlashDumpResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x0b\n\tSoftReset\"\x0f\n\rFirmwareErase\"7\n\x0e\x46irmwareUpload\x12\x14\n\x0cpayload_hash\x18\x01 \x02(\x0c\x12\x0f\n\x07payload\x18\x02 \x02(\x0c\"#\n\x11\x44\x65\x62ugLinkDecision\x12\x0e\n\x06yes_no\x18\x01 \x02(\x08\"\x13\n\x11\x44\x65\x62ugLinkGetState\"\xd7\x02\n\x0e\x44\x65\x62ugLinkState\x12\x0e\n\x06layout\x18\x01 \x01(\x0c\x12\x0b\n\x03pin\x18\x02 \x01(\t\x12\x0e\n\x06matrix\x18\x03 \x01(\t\x12\x10\n\x08mnemonic\x18\x04 \x01(\t\x12\x19\n\x04node\x18\x05 \x01(\x0b\x32\x0b.HDNodeType\x12\x1d\n\x15passphrase_protection\x18\x06 \x01(\x08\x12\x12\n\nreset_word\x18\x07 \x01(\t\x12\x15\n\rreset_entropy\x18\x08 \x01(\x0c\x12\x1a\n\x12recovery_fake_word\x18\t \x01(\t\x12\x19\n\x11recovery_word_pos\x18\n \x01(\r\x12\x17\n\x0frecovery_cipher\x18\x0b \x01(\t\x12$\n\x1crecovery_auto_completed_word\x18\x0c \x01(\t\x12\x15\n\rfirmware_hash\x18\r \x01(\x0c\x12\x14\n\x0cstorage_hash\x18\x0e \x01(\x0c\"\x0f\n\rDebugLinkStop\";\n\x0c\x44\x65\x62ugLinkLog\x12\r\n\x05level\x18\x01 \x01(\r\x12\x0e\n\x06\x62ucket\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\"\x15\n\x13\x44\x65\x62ugLinkFillConfig\" \n\x0e\x43hangeWipeCode\x12\x0e\n\x06remove\x18\x01 \x01(\x08*\xdd>\n\x0bMessageType\x12 \n\x16MessageType_Initialize\x10\x00\x1a\x04\x90\xb5\x18\x01\x12\x1a\n\x10MessageType_Ping\x10\x01\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Success\x10\x02\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_Failure\x10\x03\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ChangePin\x10\x04\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_WipeDevice\x10\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_FirmwareErase\x10\x06\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_FirmwareUpload\x10\x07\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetEntropy\x10\t\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Entropy\x10\n\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_GetPublicKey\x10\x0b\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_PublicKey\x10\x0c\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_LoadDevice\x10\r\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_ResetDevice\x10\x0e\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_SignTx\x10\x0f\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_Features\x10\x11\x1a\x04\x98\xb5\x18\x01\x12&\n\x1cMessageType_PinMatrixRequest\x10\x12\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_PinMatrixAck\x10\x13\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_Cancel\x10\x14\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_TxRequest\x10\x15\x1a\x04\x98\xb5\x18\x01\x12\x1b\n\x11MessageType_TxAck\x10\x16\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_CipherKeyValue\x10\x17\x1a\x04\x90\xb5\x18\x01\x12\"\n\x18MessageType_ClearSession\x10\x18\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplySettings\x10\x19\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ButtonRequest\x10\x1a\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ButtonAck\x10\x1b\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetAddress\x10\x1d\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Address\x10\x1e\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EntropyRequest\x10#\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_EntropyAck\x10$\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_SignMessage\x10&\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_VerifyMessage\x10\'\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_MessageSignature\x10(\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1dMessageType_PassphraseRequest\x10)\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_PassphraseAck\x10*\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_RecoveryDevice\x10-\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_WordRequest\x10.\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_WordAck\x10/\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CipheredKeyValue\x10\x30\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EncryptMessage\x10\x31\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_EncryptedMessage\x10\x32\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_DecryptMessage\x10\x33\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_DecryptedMessage\x10\x34\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_SignIdentity\x10\x35\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_SignedIdentity\x10\x36\x1a\x04\x98\xb5\x18\x01\x12!\n\x17MessageType_GetFeatures\x10\x37\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumGetAddress\x10\x38\x1a\x04\x90\xb5\x18\x01\x12%\n\x1bMessageType_EthereumAddress\x10\x39\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EthereumSignTx\x10:\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_EthereumTxRequest\x10;\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_EthereumTxAck\x10<\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CharacterRequest\x10P\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_CharacterAck\x10Q\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_RawTxAck\x10R\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplyPolicies\x10S\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_FlashHash\x10T\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_FlashWrite\x10U\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_FlashHashResponse\x10V\x1a\x04\x98\xb5\x18\x01\x12(\n\x1eMessageType_DebugLinkFlashDump\x10W\x1a\x04\xa0\xb5\x18\x01\x12\x30\n&MessageType_DebugLinkFlashDumpResponse\x10X\x1a\x04\xa8\xb5\x18\x01\x12\x1f\n\x15MessageType_SoftReset\x10Y\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkDecision\x10\x64\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkGetState\x10\x65\x1a\x04\xa0\xb5\x18\x01\x12$\n\x1aMessageType_DebugLinkState\x10\x66\x1a\x04\xa8\xb5\x18\x01\x12#\n\x19MessageType_DebugLinkStop\x10g\x1a\x04\xa0\xb5\x18\x01\x12\"\n\x18MessageType_DebugLinkLog\x10h\x1a\x04\xa8\xb5\x18\x01\x12)\n\x1fMessageType_DebugLinkFillConfig\x10i\x1a\x04\xa8\xb5\x18\x01\x12\"\n\x18MessageType_GetCoinTable\x10j\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_CoinTable\x10k\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_EthereumSignMessage\x10l\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumVerifyMessage\x10m\x1a\x04\x90\xb5\x18\x01\x12.\n$MessageType_EthereumMessageSignature\x10n\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_ChangeWipeCode\x10o\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumSignTypedHash\x10p\x1a\x04\x90\xb5\x18\x01\x12\x30\n&MessageType_EthereumTypedDataSignature\x10q\x1a\x04\x98\xb5\x18\x01\x12,\n\"MessageType_Ethereum712TypesValues\x10r\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumTxMetadata\x10s\x1a\x04\x90\xb5\x18\x01\x12)\n\x1fMessageType_EthereumMetadataAck\x10t\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_LoadClearsignSigner\x10u\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_GetBip85Mnemonic\x10x\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_Bip85Mnemonic\x10y\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_RippleGetAddress\x10\x90\x03\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_RippleAddress\x10\x91\x03\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_RippleSignTx\x10\x92\x03\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_RippleSignedTx\x10\x93\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainGetAddress\x10\xf4\x03\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_ThorchainAddress\x10\xf5\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainSignTx\x10\xf6\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainMsgRequest\x10\xf7\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainMsgAck\x10\xf8\x03\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_ThorchainSignedTx\x10\xf9\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_EosGetPublicKey\x10\xd8\x04\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_EosPublicKey\x10\xd9\x04\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_EosSignTx\x10\xda\x04\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_EosTxActionRequest\x10\xdb\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_EosTxActionAck\x10\xdc\x04\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_EosSignedTx\x10\xdd\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_NanoGetAddress\x10\xbc\x05\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_NanoAddress\x10\xbd\x05\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_NanoSignTx\x10\xbe\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_NanoSignedTx\x10\xbf\x05\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_SolanaGetAddress\x10\xee\x05\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_SolanaAddress\x10\xef\x05\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_SolanaSignTx\x10\xf0\x05\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_SolanaSignedTx\x10\xf1\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_SolanaSignMessage\x10\xf2\x05\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_SolanaMessageSignature\x10\xf3\x05\x1a\x04\x98\xb5\x18\x01\x12\x30\n%MessageType_SolanaSignOffchainMessage\x10\xf4\x05\x1a\x04\x90\xb5\x18\x01\x12\x35\n*MessageType_SolanaOffchainMessageSignature\x10\xf5\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_BinanceGetAddress\x10\xa0\x06\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_BinanceAddress\x10\xa1\x06\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_BinanceGetPublicKey\x10\xa2\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinancePublicKey\x10\xa3\x06\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_BinanceSignTx\x10\xa4\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceTxRequest\x10\xa5\x06\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_BinanceTransferMsg\x10\xa6\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceOrderMsg\x10\xa7\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceCancelMsg\x10\xa8\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceSignedTx\x10\xa9\x06\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosGetAddress\x10\x84\x07\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_CosmosAddress\x10\x85\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosSignTx\x10\x86\x07\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRequest\x10\x87\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosMsgAck\x10\x88\x07\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_CosmosSignedTx\x10\x89\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_CosmosMsgDelegate\x10\x8a\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgUndelegate\x10\x8b\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgRedelegate\x10\x8c\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRewards\x10\x8d\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_CosmosMsgIBCTransfer\x10\x8e\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintGetAddress\x10\xe8\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintAddress\x10\xe9\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintSignTx\x10\xea\x07\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TendermintMsgRequest\x10\xeb\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintMsgAck\x10\xec\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintMsgSend\x10\xed\x07\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_TendermintSignedTx\x10\xee\x07\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_TendermintMsgDelegate\x10\xef\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgUndelegate\x10\xf0\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgRedelegate\x10\xf1\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintMsgRewards\x10\xf2\x07\x1a\x04\x98\xb5\x18\x01\x12/\n$MessageType_TendermintMsgIBCTransfer\x10\xf3\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisGetAddress\x10\xcc\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisAddress\x10\xcd\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisSignTx\x10\xce\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRequest\x10\xcf\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisMsgAck\x10\xd0\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSend\x10\xd1\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgDelegate\x10\xd2\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgUndelegate\x10\xd3\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgRedelegate\x10\xd4\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRewards\x10\xd5\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisMsgLPAdd\x10\xd6\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgLPRemove\x10\xd7\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgLPStake\x10\xd8\x08\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_OsmosisMsgLPUnstake\x10\xd9\x08\x1a\x04\x90\xb5\x18\x01\x12,\n!MessageType_OsmosisMsgIBCTransfer\x10\xda\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSwap\x10\xdb\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisSignedTx\x10\xdc\x08\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_MayachainGetAddress\x10\xb0\t\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_MayachainAddress\x10\xb1\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainSignTx\x10\xb2\t\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_MayachainMsgRequest\x10\xb3\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainMsgAck\x10\xb4\t\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_MayachainSignedTx\x10\xb5\t\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_ZcashSignPCZT\x10\x94\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashPCZTAction\x10\x95\n\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_ZcashPCZTActionAck\x10\x96\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ZcashSignedPCZT\x10\x97\n\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_ZcashGetOrchardFVK\x10\x98\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashOrchardFVK\x10\x99\n\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_ZcashTransparentInput\x10\x9a\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentSigned\x10\x9b\n\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_ZcashDisplayAddress\x10\x9c\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_ZcashAddress\x10\x9d\n\x1a\x04\x98\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentOutput\x10\x9e\n\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ZcashTransparentAck\x10\x9f\n\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TronGetAddress\x10\xf8\n\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TronAddress\x10\xf9\n\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_TronSignTx\x10\xfa\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_TronSignedTx\x10\xfb\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_TronSignMessage\x10\xfc\n\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TronMessageSignature\x10\xfd\n\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_TronVerifyMessage\x10\xfe\n\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TronSignTypedHash\x10\xff\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_TronTypedDataSignature\x10\x80\x0b\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_TonGetAddress\x10\xdc\x0b\x1a\x04\x90\xb5\x18\x01\x12!\n\x16MessageType_TonAddress\x10\xdd\x0b\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_TonSignTx\x10\xde\x0b\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TonSignedTx\x10\xdf\x0b\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TonSignMessage\x10\xe0\x0b\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_TonMessageSignature\x10\xe1\x0b\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_HiveGetPublicKey\x10\xc0\x0c\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_HivePublicKey\x10\xc1\x0c\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_HiveSignTx\x10\xc2\x0c\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_HiveSignedTx\x10\xc3\x0c\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_HiveGetPublicKeys\x10\xc4\x0c\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_HivePublicKeys\x10\xc5\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountCreate\x10\xc6\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountCreate\x10\xc7\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountUpdate\x10\xc8\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountUpdate\x10\xc9\x0c\x1a\x04\x98\xb5\x18\x01\x42,\n\x1a\x63om.keepkey.deviceprotocolB\x0eKeepKeyMessage') + serialized_pb=_b('\n\x0emessages.proto\x1a\x0btypes.proto\"\x0c\n\nInitialize\"\r\n\x0bGetFeatures\"\xaa\x04\n\x08\x46\x65\x61tures\x12\x0e\n\x06vendor\x18\x01 \x01(\t\x12\x15\n\rmajor_version\x18\x02 \x01(\r\x12\x15\n\rminor_version\x18\x03 \x01(\r\x12\x15\n\rpatch_version\x18\x04 \x01(\r\x12\x17\n\x0f\x62ootloader_mode\x18\x05 \x01(\x08\x12\x11\n\tdevice_id\x18\x06 \x01(\t\x12\x16\n\x0epin_protection\x18\x07 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x08 \x01(\x08\x12\x10\n\x08language\x18\t \x01(\t\x12\r\n\x05label\x18\n \x01(\t\x12\x18\n\x05\x63oins\x18\x0b \x03(\x0b\x32\t.CoinType\x12\x13\n\x0binitialized\x18\x0c \x01(\x08\x12\x10\n\x08revision\x18\r \x01(\x0c\x12\x17\n\x0f\x62ootloader_hash\x18\x0e \x01(\x0c\x12\x10\n\x08imported\x18\x0f \x01(\x08\x12\x12\n\npin_cached\x18\x10 \x01(\x08\x12\x19\n\x11passphrase_cached\x18\x11 \x01(\x08\x12\x1d\n\x08policies\x18\x12 \x03(\x0b\x32\x0b.PolicyType\x12\r\n\x05model\x18\x15 \x01(\t\x12\x18\n\x10\x66irmware_variant\x18\x16 \x01(\t\x12\x15\n\rfirmware_hash\x18\x17 \x01(\x0c\x12\x11\n\tno_backup\x18\x18 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x19 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x1a \x01(\r\"*\n\x0cGetCoinTable\x12\r\n\x05start\x18\x01 \x01(\r\x12\x0b\n\x03\x65nd\x18\x02 \x01(\r\"L\n\tCoinTable\x12\x18\n\x05table\x18\x01 \x03(\x0b\x32\t.CoinType\x12\x11\n\tnum_coins\x18\x02 \x01(\r\x12\x12\n\nchunk_size\x18\x03 \x01(\r\"\x0e\n\x0c\x43learSession\"y\n\rApplySettings\x12\x10\n\x08language\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\x16\n\x0euse_passphrase\x18\x03 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x04 \x01(\r\x12\x13\n\x0bu2f_counter\x18\x05 \x01(\r\"\x1b\n\tChangePin\x12\x0e\n\x06remove\x18\x01 \x01(\x08\"\x87\x01\n\x04Ping\x12\x0f\n\x07message\x18\x01 \x01(\t\x12\x19\n\x11\x62utton_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x1c\n\x14wipe_code_protection\x18\x05 \x01(\x08\"\x1a\n\x07Success\x12\x0f\n\x07message\x18\x01 \x01(\t\"6\n\x07\x46\x61ilure\x12\x1a\n\x04\x63ode\x18\x01 \x01(\x0e\x32\x0c.FailureType\x12\x0f\n\x07message\x18\x02 \x01(\t\"?\n\rButtonRequest\x12 \n\x04\x63ode\x18\x01 \x01(\x0e\x32\x12.ButtonRequestType\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\t\"\x0b\n\tButtonAck\"7\n\x10PinMatrixRequest\x12#\n\x04type\x18\x01 \x01(\x0e\x32\x15.PinMatrixRequestType\"\x1b\n\x0cPinMatrixAck\x12\x0b\n\x03pin\x18\x01 \x02(\t\"\x08\n\x06\x43\x61ncel\"\x13\n\x11PassphraseRequest\"#\n\rPassphraseAck\x12\x12\n\npassphrase\x18\x01 \x02(\t\"\x1a\n\nGetEntropy\x12\x0c\n\x04size\x18\x01 \x02(\r\"\x1a\n\x07\x45ntropy\x12\x0f\n\x07\x65ntropy\x18\x01 \x02(\x0c\"\xa2\x01\n\x0cGetPublicKey\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x02 \x01(\t\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"4\n\tPublicKey\x12\x19\n\x04node\x18\x01 \x02(\x0b\x32\x0b.HDNodeType\x12\x0c\n\x04xpub\x18\x02 \x01(\t\"\xb3\x01\n\nGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x1a\n\tcoin_name\x18\x02 \x01(\t:\x07\x42itcoin\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\x12+\n\x08multisig\x18\x04 \x01(\x0b\x32\x19.MultisigRedeemScriptType\x12\x33\n\x0bscript_type\x18\x05 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"\x1a\n\x07\x41\x64\x64ress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x02(\t\"\x0c\n\nWipeDevice\"\xbb\x01\n\nLoadDevice\x12\x10\n\x08mnemonic\x18\x01 \x01(\t\x12\x19\n\x04node\x18\x02 \x01(\x0b\x32\x0b.HDNodeType\x12\x0b\n\x03pin\x18\x03 \x01(\t\x12\x1d\n\x15passphrase_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x15\n\rskip_checksum\x18\x07 \x01(\x08\x12\x13\n\x0bu2f_counter\x18\x08 \x01(\r\"\xe1\x01\n\x0bResetDevice\x12\x16\n\x0e\x64isplay_random\x18\x01 \x01(\x08\x12\x15\n\x08strength\x18\x02 \x01(\r:\x03\x32\x35\x36\x12\x1d\n\x15passphrase_protection\x18\x03 \x01(\x08\x12\x16\n\x0epin_protection\x18\x04 \x01(\x08\x12\x19\n\x08language\x18\x05 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x06 \x01(\t\x12\x11\n\tno_backup\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\"\x10\n\x0e\x45ntropyRequest\"\x1d\n\nEntropyAck\x12\x0f\n\x07\x65ntropy\x18\x01 \x01(\x0c\"\xff\x01\n\x0eRecoveryDevice\x12\x12\n\nword_count\x18\x01 \x01(\r\x12\x1d\n\x15passphrase_protection\x18\x02 \x01(\x08\x12\x16\n\x0epin_protection\x18\x03 \x01(\x08\x12\x19\n\x08language\x18\x04 \x01(\t:\x07\x65nglish\x12\r\n\x05label\x18\x05 \x01(\t\x12\x18\n\x10\x65nforce_wordlist\x18\x06 \x01(\x08\x12\x1c\n\x14use_character_cipher\x18\x07 \x01(\x08\x12\x1a\n\x12\x61uto_lock_delay_ms\x18\x08 \x01(\r\x12\x13\n\x0bu2f_counter\x18\t \x01(\r\x12\x0f\n\x07\x64ry_run\x18\n \x01(\x08\"\r\n\x0bWordRequest\"\x17\n\x07WordAck\x12\x0c\n\x04word\x18\x01 \x02(\t\";\n\x10\x43haracterRequest\x12\x10\n\x08word_pos\x18\x01 \x02(\r\x12\x15\n\rcharacter_pos\x18\x02 \x02(\r\"?\n\x0c\x43haracterAck\x12\x11\n\tcharacter\x18\x01 \x01(\t\x12\x0e\n\x06\x64\x65lete\x18\x02 \x01(\x08\x12\x0c\n\x04\x64one\x18\x03 \x01(\x08\"\x82\x01\n\x0bSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0f\n\x07message\x18\x02 \x02(\x0c\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x33\n\x0bscript_type\x18\x04 \x01(\x0e\x32\x10.InputScriptType:\x0cSPENDADDRESS\"`\n\rVerifyMessage\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x1a\n\tcoin_name\x18\x04 \x01(\t:\x07\x42itcoin\"6\n\x10MessageSignature\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"v\n\x0e\x45ncryptMessage\x12\x0e\n\x06pubkey\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x14\n\x0c\x64isplay_only\x18\x03 \x01(\x08\x12\x11\n\taddress_n\x18\x04 \x03(\r\x12\x1a\n\tcoin_name\x18\x05 \x01(\t:\x07\x42itcoin\"@\n\x10\x45ncryptedMessage\x12\r\n\x05nonce\x18\x01 \x01(\x0c\x12\x0f\n\x07message\x18\x02 \x01(\x0c\x12\x0c\n\x04hmac\x18\x03 \x01(\x0c\"Q\n\x0e\x44\x65\x63ryptMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\r\n\x05nonce\x18\x02 \x01(\x0c\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x0c\n\x04hmac\x18\x04 \x01(\x0c\"4\n\x10\x44\x65\x63ryptedMessage\x12\x0f\n\x07message\x18\x01 \x01(\x0c\x12\x0f\n\x07\x61\x64\x64ress\x18\x02 \x01(\t\"\x8c\x01\n\x0e\x43ipherKeyValue\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x0b\n\x03key\x18\x02 \x01(\t\x12\r\n\x05value\x18\x03 \x01(\x0c\x12\x0f\n\x07\x65ncrypt\x18\x04 \x01(\x08\x12\x16\n\x0e\x61sk_on_encrypt\x18\x05 \x01(\x08\x12\x16\n\x0e\x61sk_on_decrypt\x18\x06 \x01(\x08\x12\n\n\x02iv\x18\x07 \x01(\x0c\"!\n\x10\x43ipheredKeyValue\x12\r\n\x05value\x18\x01 \x01(\x0c\"5\n\x10GetBip85Mnemonic\x12\x12\n\nword_count\x18\x01 \x02(\r\x12\r\n\x05index\x18\x02 \x02(\r\"!\n\rBip85Mnemonic\x12\x10\n\x08mnemonic\x18\x01 \x02(\t\"\xce\x01\n\x06SignTx\x12\x15\n\routputs_count\x18\x01 \x02(\r\x12\x14\n\x0cinputs_count\x18\x02 \x02(\r\x12\x1a\n\tcoin_name\x18\x03 \x01(\t:\x07\x42itcoin\x12\x12\n\x07version\x18\x04 \x01(\r:\x01\x31\x12\x14\n\tlock_time\x18\x05 \x01(\r:\x01\x30\x12\x0e\n\x06\x65xpiry\x18\x06 \x01(\r\x12\x14\n\x0coverwintered\x18\x07 \x01(\x08\x12\x18\n\x10version_group_id\x18\x08 \x01(\r\x12\x11\n\tbranch_id\x18\n \x01(\r\"\x85\x01\n\tTxRequest\x12\"\n\x0crequest_type\x18\x01 \x01(\x0e\x32\x0c.RequestType\x12&\n\x07\x64\x65tails\x18\x02 \x01(\x0b\x32\x15.TxRequestDetailsType\x12,\n\nserialized\x18\x03 \x01(\x0b\x32\x18.TxRequestSerializedType\"%\n\x05TxAck\x12\x1c\n\x02tx\x18\x01 \x01(\x0b\x32\x10.TransactionType\"+\n\x08RawTxAck\x12\x1f\n\x02tx\x18\x01 \x01(\x0b\x32\x13.RawTransactionType\"}\n\x0cSignIdentity\x12\x1f\n\x08identity\x18\x01 \x01(\x0b\x32\r.IdentityType\x12\x18\n\x10\x63hallenge_hidden\x18\x02 \x01(\x0c\x12\x18\n\x10\x63hallenge_visual\x18\x03 \x01(\t\x12\x18\n\x10\x65\x63\x64sa_curve_name\x18\x04 \x01(\t\"H\n\x0eSignedIdentity\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\x12\x12\n\npublic_key\x18\x02 \x01(\x0c\x12\x11\n\tsignature\x18\x03 \x01(\x0c\",\n\rApplyPolicies\x12\x1b\n\x06policy\x18\x01 \x03(\x0b\x32\x0b.PolicyType\"?\n\tFlashHash\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\x12\x11\n\tchallenge\x18\x03 \x01(\x0c\":\n\nFlashWrite\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0c\n\x04\x64\x61ta\x18\x02 \x01(\x0c\x12\r\n\x05\x65rase\x18\x03 \x01(\x08\"!\n\x11\x46lashHashResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"5\n\x12\x44\x65\x62ugLinkFlashDump\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\r\x12\x0e\n\x06length\x18\x02 \x01(\r\"*\n\x1a\x44\x65\x62ugLinkFlashDumpResponse\x12\x0c\n\x04\x64\x61ta\x18\x01 \x01(\x0c\"\x0b\n\tSoftReset\"\x0f\n\rFirmwareErase\"7\n\x0e\x46irmwareUpload\x12\x14\n\x0cpayload_hash\x18\x01 \x02(\x0c\x12\x0f\n\x07payload\x18\x02 \x02(\x0c\"#\n\x11\x44\x65\x62ugLinkDecision\x12\x0e\n\x06yes_no\x18\x01 \x02(\x08\"\x13\n\x11\x44\x65\x62ugLinkGetState\"\xd7\x02\n\x0e\x44\x65\x62ugLinkState\x12\x0e\n\x06layout\x18\x01 \x01(\x0c\x12\x0b\n\x03pin\x18\x02 \x01(\t\x12\x0e\n\x06matrix\x18\x03 \x01(\t\x12\x10\n\x08mnemonic\x18\x04 \x01(\t\x12\x19\n\x04node\x18\x05 \x01(\x0b\x32\x0b.HDNodeType\x12\x1d\n\x15passphrase_protection\x18\x06 \x01(\x08\x12\x12\n\nreset_word\x18\x07 \x01(\t\x12\x15\n\rreset_entropy\x18\x08 \x01(\x0c\x12\x1a\n\x12recovery_fake_word\x18\t \x01(\t\x12\x19\n\x11recovery_word_pos\x18\n \x01(\r\x12\x17\n\x0frecovery_cipher\x18\x0b \x01(\t\x12$\n\x1crecovery_auto_completed_word\x18\x0c \x01(\t\x12\x15\n\rfirmware_hash\x18\r \x01(\x0c\x12\x14\n\x0cstorage_hash\x18\x0e \x01(\x0c\"\x0f\n\rDebugLinkStop\";\n\x0c\x44\x65\x62ugLinkLog\x12\r\n\x05level\x18\x01 \x01(\r\x12\x0e\n\x06\x62ucket\x18\x02 \x01(\t\x12\x0c\n\x04text\x18\x03 \x01(\t\"\x15\n\x13\x44\x65\x62ugLinkFillConfig\" \n\x0e\x43hangeWipeCode\x12\x0e\n\x06remove\x18\x01 \x01(\x08*\x87@\n\x0bMessageType\x12 \n\x16MessageType_Initialize\x10\x00\x1a\x04\x90\xb5\x18\x01\x12\x1a\n\x10MessageType_Ping\x10\x01\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Success\x10\x02\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_Failure\x10\x03\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ChangePin\x10\x04\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_WipeDevice\x10\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_FirmwareErase\x10\x06\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_FirmwareUpload\x10\x07\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetEntropy\x10\t\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Entropy\x10\n\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_GetPublicKey\x10\x0b\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_PublicKey\x10\x0c\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_LoadDevice\x10\r\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_ResetDevice\x10\x0e\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_SignTx\x10\x0f\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_Features\x10\x11\x1a\x04\x98\xb5\x18\x01\x12&\n\x1cMessageType_PinMatrixRequest\x10\x12\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_PinMatrixAck\x10\x13\x1a\x04\x90\xb5\x18\x01\x12\x1c\n\x12MessageType_Cancel\x10\x14\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_TxRequest\x10\x15\x1a\x04\x98\xb5\x18\x01\x12\x1b\n\x11MessageType_TxAck\x10\x16\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_CipherKeyValue\x10\x17\x1a\x04\x90\xb5\x18\x01\x12\"\n\x18MessageType_ClearSession\x10\x18\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplySettings\x10\x19\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ButtonRequest\x10\x1a\x1a\x04\x98\xb5\x18\x01\x12\x1f\n\x15MessageType_ButtonAck\x10\x1b\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_GetAddress\x10\x1d\x1a\x04\x90\xb5\x18\x01\x12\x1d\n\x13MessageType_Address\x10\x1e\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EntropyRequest\x10#\x1a\x04\x98\xb5\x18\x01\x12 \n\x16MessageType_EntropyAck\x10$\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_SignMessage\x10&\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_VerifyMessage\x10\'\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_MessageSignature\x10(\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1dMessageType_PassphraseRequest\x10)\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_PassphraseAck\x10*\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_RecoveryDevice\x10-\x1a\x04\x90\xb5\x18\x01\x12!\n\x17MessageType_WordRequest\x10.\x1a\x04\x98\xb5\x18\x01\x12\x1d\n\x13MessageType_WordAck\x10/\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CipheredKeyValue\x10\x30\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EncryptMessage\x10\x31\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_EncryptedMessage\x10\x32\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_DecryptMessage\x10\x33\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_DecryptedMessage\x10\x34\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_SignIdentity\x10\x35\x1a\x04\x90\xb5\x18\x01\x12$\n\x1aMessageType_SignedIdentity\x10\x36\x1a\x04\x98\xb5\x18\x01\x12!\n\x17MessageType_GetFeatures\x10\x37\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumGetAddress\x10\x38\x1a\x04\x90\xb5\x18\x01\x12%\n\x1bMessageType_EthereumAddress\x10\x39\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_EthereumSignTx\x10:\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_EthereumTxRequest\x10;\x1a\x04\x98\xb5\x18\x01\x12#\n\x19MessageType_EthereumTxAck\x10<\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_CharacterRequest\x10P\x1a\x04\x98\xb5\x18\x01\x12\"\n\x18MessageType_CharacterAck\x10Q\x1a\x04\x90\xb5\x18\x01\x12\x1e\n\x14MessageType_RawTxAck\x10R\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_ApplyPolicies\x10S\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_FlashHash\x10T\x1a\x04\x90\xb5\x18\x01\x12 \n\x16MessageType_FlashWrite\x10U\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1dMessageType_FlashHashResponse\x10V\x1a\x04\x98\xb5\x18\x01\x12(\n\x1eMessageType_DebugLinkFlashDump\x10W\x1a\x04\xa0\xb5\x18\x01\x12\x30\n&MessageType_DebugLinkFlashDumpResponse\x10X\x1a\x04\xa8\xb5\x18\x01\x12\x1f\n\x15MessageType_SoftReset\x10Y\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkDecision\x10\x64\x1a\x04\xa0\xb5\x18\x01\x12\'\n\x1dMessageType_DebugLinkGetState\x10\x65\x1a\x04\xa0\xb5\x18\x01\x12$\n\x1aMessageType_DebugLinkState\x10\x66\x1a\x04\xa8\xb5\x18\x01\x12#\n\x19MessageType_DebugLinkStop\x10g\x1a\x04\xa0\xb5\x18\x01\x12\"\n\x18MessageType_DebugLinkLog\x10h\x1a\x04\xa8\xb5\x18\x01\x12)\n\x1fMessageType_DebugLinkFillConfig\x10i\x1a\x04\xa8\xb5\x18\x01\x12\"\n\x18MessageType_GetCoinTable\x10j\x1a\x04\x90\xb5\x18\x01\x12\x1f\n\x15MessageType_CoinTable\x10k\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_EthereumSignMessage\x10l\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumVerifyMessage\x10m\x1a\x04\x90\xb5\x18\x01\x12.\n$MessageType_EthereumMessageSignature\x10n\x1a\x04\x98\xb5\x18\x01\x12$\n\x1aMessageType_ChangeWipeCode\x10o\x1a\x04\x90\xb5\x18\x01\x12+\n!MessageType_EthereumSignTypedHash\x10p\x1a\x04\x90\xb5\x18\x01\x12\x30\n&MessageType_EthereumTypedDataSignature\x10q\x1a\x04\x98\xb5\x18\x01\x12,\n\"MessageType_Ethereum712TypesValues\x10r\x1a\x04\x90\xb5\x18\x01\x12(\n\x1eMessageType_EthereumTxMetadata\x10s\x1a\x04\x90\xb5\x18\x01\x12)\n\x1fMessageType_EthereumMetadataAck\x10t\x1a\x04\x98\xb5\x18\x01\x12)\n\x1fMessageType_LoadClearsignSigner\x10u\x1a\x04\x90\xb5\x18\x01\x12&\n\x1cMessageType_GetBip85Mnemonic\x10x\x1a\x04\x90\xb5\x18\x01\x12#\n\x19MessageType_Bip85Mnemonic\x10y\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_RippleGetAddress\x10\x90\x03\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_RippleAddress\x10\x91\x03\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_RippleSignTx\x10\x92\x03\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_RippleSignedTx\x10\x93\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainGetAddress\x10\xf4\x03\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_ThorchainAddress\x10\xf5\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainSignTx\x10\xf6\x03\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ThorchainMsgRequest\x10\xf7\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ThorchainMsgAck\x10\xf8\x03\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_ThorchainSignedTx\x10\xf9\x03\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_EosGetPublicKey\x10\xd8\x04\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_EosPublicKey\x10\xd9\x04\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_EosSignTx\x10\xda\x04\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_EosTxActionRequest\x10\xdb\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_EosTxActionAck\x10\xdc\x04\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_EosSignedTx\x10\xdd\x04\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_NanoGetAddress\x10\xbc\x05\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_NanoAddress\x10\xbd\x05\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_NanoSignTx\x10\xbe\x05\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_NanoSignedTx\x10\xbf\x05\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_SolanaGetAddress\x10\xee\x05\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_SolanaAddress\x10\xef\x05\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_SolanaSignTx\x10\xf0\x05\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_SolanaSignedTx\x10\xf1\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_SolanaSignMessage\x10\xf2\x05\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_SolanaMessageSignature\x10\xf3\x05\x1a\x04\x98\xb5\x18\x01\x12\x30\n%MessageType_SolanaSignOffchainMessage\x10\xf4\x05\x1a\x04\x90\xb5\x18\x01\x12\x35\n*MessageType_SolanaOffchainMessageSignature\x10\xf5\x05\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_BinanceGetAddress\x10\xa0\x06\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_BinanceAddress\x10\xa1\x06\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_BinanceGetPublicKey\x10\xa2\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinancePublicKey\x10\xa3\x06\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_BinanceSignTx\x10\xa4\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceTxRequest\x10\xa5\x06\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_BinanceTransferMsg\x10\xa6\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceOrderMsg\x10\xa7\x06\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_BinanceCancelMsg\x10\xa8\x06\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_BinanceSignedTx\x10\xa9\x06\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosGetAddress\x10\x84\x07\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_CosmosAddress\x10\x85\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosSignTx\x10\x86\x07\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRequest\x10\x87\x07\x1a\x04\x98\xb5\x18\x01\x12#\n\x18MessageType_CosmosMsgAck\x10\x88\x07\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_CosmosSignedTx\x10\x89\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_CosmosMsgDelegate\x10\x8a\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgUndelegate\x10\x8b\x07\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_CosmosMsgRedelegate\x10\x8c\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_CosmosMsgRewards\x10\x8d\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_CosmosMsgIBCTransfer\x10\x8e\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintGetAddress\x10\xe8\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintAddress\x10\xe9\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintSignTx\x10\xea\x07\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TendermintMsgRequest\x10\xeb\x07\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_TendermintMsgAck\x10\xec\x07\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TendermintMsgSend\x10\xed\x07\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_TendermintSignedTx\x10\xee\x07\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_TendermintMsgDelegate\x10\xef\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgUndelegate\x10\xf0\x07\x1a\x04\x98\xb5\x18\x01\x12.\n#MessageType_TendermintMsgRedelegate\x10\xf1\x07\x1a\x04\x98\xb5\x18\x01\x12+\n MessageType_TendermintMsgRewards\x10\xf2\x07\x1a\x04\x98\xb5\x18\x01\x12/\n$MessageType_TendermintMsgIBCTransfer\x10\xf3\x07\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisGetAddress\x10\xcc\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisAddress\x10\xcd\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisSignTx\x10\xce\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRequest\x10\xcf\x08\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_OsmosisMsgAck\x10\xd0\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSend\x10\xd1\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgDelegate\x10\xd2\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgUndelegate\x10\xd3\x08\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_OsmosisMsgRedelegate\x10\xd4\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgRewards\x10\xd5\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisMsgLPAdd\x10\xd6\x08\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_OsmosisMsgLPRemove\x10\xd7\x08\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_OsmosisMsgLPStake\x10\xd8\x08\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_OsmosisMsgLPUnstake\x10\xd9\x08\x1a\x04\x90\xb5\x18\x01\x12,\n!MessageType_OsmosisMsgIBCTransfer\x10\xda\x08\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_OsmosisMsgSwap\x10\xdb\x08\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_OsmosisSignedTx\x10\xdc\x08\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_MayachainGetAddress\x10\xb0\t\x1a\x04\x90\xb5\x18\x01\x12\'\n\x1cMessageType_MayachainAddress\x10\xb1\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainSignTx\x10\xb2\t\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_MayachainMsgRequest\x10\xb3\t\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_MayachainMsgAck\x10\xb4\t\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_MayachainSignedTx\x10\xb5\t\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_ZcashSignPCZT\x10\x94\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashPCZTAction\x10\x95\n\x1a\x04\x90\xb5\x18\x01\x12)\n\x1eMessageType_ZcashPCZTActionAck\x10\x96\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_ZcashSignedPCZT\x10\x97\n\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_ZcashGetOrchardFVK\x10\x98\n\x1a\x04\x90\xb5\x18\x01\x12&\n\x1bMessageType_ZcashOrchardFVK\x10\x99\n\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_ZcashTransparentInput\x10\x9a\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentSigned\x10\x9b\n\x1a\x04\x98\xb5\x18\x01\x12*\n\x1fMessageType_ZcashDisplayAddress\x10\x9c\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_ZcashAddress\x10\x9d\n\x1a\x04\x98\xb5\x18\x01\x12-\n\"MessageType_ZcashTransparentOutput\x10\x9e\n\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_ZcashTransparentAck\x10\x9f\n\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TronGetAddress\x10\xf8\n\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TronAddress\x10\xf9\n\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_TronSignTx\x10\xfa\n\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_TronSignedTx\x10\xfb\n\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_TronSignMessage\x10\xfc\n\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_TronMessageSignature\x10\xfd\n\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_TronVerifyMessage\x10\xfe\n\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_TronSignTypedHash\x10\xff\n\x1a\x04\x90\xb5\x18\x01\x12-\n\"MessageType_TronTypedDataSignature\x10\x80\x0b\x1a\x04\x98\xb5\x18\x01\x12$\n\x19MessageType_TonGetAddress\x10\xdc\x0b\x1a\x04\x90\xb5\x18\x01\x12!\n\x16MessageType_TonAddress\x10\xdd\x0b\x1a\x04\x98\xb5\x18\x01\x12 \n\x15MessageType_TonSignTx\x10\xde\x0b\x1a\x04\x90\xb5\x18\x01\x12\"\n\x17MessageType_TonSignedTx\x10\xdf\x0b\x1a\x04\x98\xb5\x18\x01\x12%\n\x1aMessageType_TonSignMessage\x10\xe0\x0b\x1a\x04\x90\xb5\x18\x01\x12*\n\x1fMessageType_TonMessageSignature\x10\xe1\x0b\x1a\x04\x98\xb5\x18\x01\x12\'\n\x1cMessageType_HiveGetPublicKey\x10\xc0\x0c\x1a\x04\x90\xb5\x18\x01\x12$\n\x19MessageType_HivePublicKey\x10\xc1\x0c\x1a\x04\x98\xb5\x18\x01\x12!\n\x16MessageType_HiveSignTx\x10\xc2\x0c\x1a\x04\x90\xb5\x18\x01\x12#\n\x18MessageType_HiveSignedTx\x10\xc3\x0c\x1a\x04\x98\xb5\x18\x01\x12(\n\x1dMessageType_HiveGetPublicKeys\x10\xc4\x0c\x1a\x04\x90\xb5\x18\x01\x12%\n\x1aMessageType_HivePublicKeys\x10\xc5\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountCreate\x10\xc6\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountCreate\x10\xc7\x0c\x1a\x04\x98\xb5\x18\x01\x12,\n!MessageType_HiveSignAccountUpdate\x10\xc8\x0c\x1a\x04\x90\xb5\x18\x01\x12.\n#MessageType_HiveSignedAccountUpdate\x10\xc9\x0c\x1a\x04\x98\xb5\x18\x01\x12&\n\x1bMessageType_HiveSignMessage\x10\xce\x0c\x1a\x04\x90\xb5\x18\x01\x12(\n\x1dMessageType_HiveSignedMessage\x10\xcf\x0c\x1a\x04\x98\xb5\x18\x01\x12)\n\x1eMessageType_HiveSignOperations\x10\xd0\x0c\x1a\x04\x90\xb5\x18\x01\x12+\n MessageType_HiveSignedOperations\x10\xd1\x0c\x1a\x04\x98\xb5\x18\x01\x42,\n\x1a\x63om.keepkey.deviceprotocolB\x0eKeepKeyMessage') , dependencies=[types__pb2.DESCRIPTOR,]) @@ -839,11 +839,27 @@ name='MessageType_HiveSignedAccountUpdate', index=201, number=1609, options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_HiveSignMessage', index=202, number=1614, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_HiveSignedMessage', index=203, number=1615, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_HiveSignOperations', index=204, number=1616, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')), + type=None), + _descriptor.EnumValueDescriptor( + name='MessageType_HiveSignedOperations', index=205, number=1617, + options=_descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')), + type=None), ], containing_type=None, options=None, serialized_start=5191, - serialized_end=13220, + serialized_end=13390, ) _sym_db.RegisterEnumDescriptor(_MESSAGETYPE) @@ -1050,6 +1066,10 @@ MessageType_HiveSignedAccountCreate = 1607 MessageType_HiveSignAccountUpdate = 1608 MessageType_HiveSignedAccountUpdate = 1609 +MessageType_HiveSignMessage = 1614 +MessageType_HiveSignedMessage = 1615 +MessageType_HiveSignOperations = 1616 +MessageType_HiveSignedOperations = 1617 @@ -4921,4 +4941,12 @@ _MESSAGETYPE.values_by_name["MessageType_HiveSignAccountUpdate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) _MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountUpdate"].has_options = True _MESSAGETYPE.values_by_name["MessageType_HiveSignedAccountUpdate"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignMessage"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignMessage"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedMessage"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedMessage"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignOperations"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignOperations"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\220\265\030\001')) +_MESSAGETYPE.values_by_name["MessageType_HiveSignedOperations"].has_options = True +_MESSAGETYPE.values_by_name["MessageType_HiveSignedOperations"]._options = _descriptor._ParseOptions(descriptor_pb2.EnumValueOptions(), _b('\230\265\030\001')) # @@protoc_insertion_point(module_scope) diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 911cee78..c34876ff 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -27,6 +27,8 @@ import hashlib import struct +from keepkeylib import messages_ethereum_pb2 as messages_eth + try: import common except ImportError: @@ -1429,6 +1431,111 @@ def print_test_vectors(): print('\n' + '═' * 72) + +def _decode_icon_rle(data, width, height): + """Reference decoder for LoadClearsignSigner.icon, traced from the decoder + of record: keepkey-firmware lib/board/draw.c draw_bitmap_mono_rle(). + + The icon is NOT a packed 1bpp bitmap (a packed 64x64 needs 512 bytes and the + wire cap is 384). It is run-length encoded with byte-valued pixels: + n = int8(data[i++]); n > 0 -> RUN: emit the single next value byte n times + n < 0 -> LITERAL: emit the next (-n) value bytes once each + n == 0 -> invalid + Pixels fill row-major until exactly width*height are emitted. + """ + seq = nonseq = i = 0 + out = [] + for _ in range(height): + for _ in range(width): + if i >= len(data): + raise ValueError("overrun reading RLE count") + if seq == 0 and nonseq == 0: + n = data[i] + n = n - 256 if n > 127 else n + i += 1 + if n == 0: + raise ValueError("n == 0 is invalid") + if n < 0: + nonseq, seq = -n, 0 + else: + seq = n + if i >= len(data): + raise ValueError("overrun reading RLE value") + out.append(data[i]) + if seq > 0: + seq -= 1 + if seq == 0: + i += 1 + else: + i += 1 + nonseq -= 1 + return out + + +class TestClearsignSignerIcon(unittest.TestCase): + """Offline coverage for the LoadClearsignSigner identity-icon wire contract. + + Regression guard for the review finding that the proto documented a packed + 1bpp row-major bitmap while firmware fed the bytes to an RLE decoder — a + client following the old doc rendered a garbled/absent logo on a TRUST screen. + """ + + ICON_MAX = 384 # METADATA_ICON_MAX / CLEARSIGN_ICON_MAX / proto max_size + + def test_packed_1bpp_cannot_fit_the_cap(self): + # Why the format must be RLE: the firmware accepts icon_width/height up + # to 64, but a packed 1bpp 64x64 needs 512 bytes > the 384-byte cap. + self.assertGreater((64 * 64) // 8, self.ICON_MAX) + + def test_golden_vector_matches_the_documented_decode(self): + # The golden vector published in messages-ethereum.proto. + self.assertEqual( + _decode_icon_rle(bytes([0x03, 0xFF, 0xFF, 0x00]), 2, 2), + [0xFF, 0xFF, 0xFF, 0x00], + ) + + def test_run_and_literal_packets(self): + self.assertEqual(_decode_icon_rle(bytes([0x04, 0xAB]), 4, 1), + [0xAB] * 4) # RUN + self.assertEqual(_decode_icon_rle(bytes([0xFD, 0x01, 0x02, 0x03]), 3, 1), + [0x01, 0x02, 0x03]) # LITERAL (-3) + + def test_zero_count_is_invalid(self): + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x00, 0xFF]), 1, 1) + + def test_truncated_stream_is_rejected(self): + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x08, 0xFF]), 4, 4) # claims 8, only 2 bytes + + def test_message_exposes_icon_dimensions_and_persist(self): + # Regression guard: the generated bindings previously carried only + # key_id/pubkey/alias, so constructing with icon raised ValueError. + icon = bytes([0x03, 0xFF, 0xFF, 0x00]) + msg = messages_eth.LoadClearsignSigner( + key_id=3, pubkey=b'\x02' * 33, alias="Pioneer", + icon=icon, icon_width=2, icon_height=2, persist=True, + ) + parsed = messages_eth.LoadClearsignSigner() + parsed.ParseFromString(msg.SerializeToString()) + self.assertEqual(parsed.icon, icon) + self.assertEqual(parsed.icon_width, 2) + self.assertEqual(parsed.icon_height, 2) + self.assertTrue(parsed.persist) + self.assertEqual(_decode_icon_rle(parsed.icon, parsed.icon_width, + parsed.icon_height), + [0xFF, 0xFF, 0xFF, 0x00]) + + def test_text_only_identity_omits_icon_fields(self): + msg = messages_eth.LoadClearsignSigner( + key_id=3, pubkey=b'\x02' * 33, alias="Pioneer") + parsed = messages_eth.LoadClearsignSigner() + parsed.ParseFromString(msg.SerializeToString()) + self.assertFalse(parsed.HasField('icon')) + self.assertFalse(parsed.HasField('icon_width')) + self.assertFalse(parsed.HasField('icon_height')) + + if __name__ == '__main__': import sys if '--vectors' in sys.argv: diff --git a/tests/test_msg_mayachain_signtx.py b/tests/test_msg_mayachain_signtx.py index 7cd97ccb..388ec372 100644 --- a/tests/test_msg_mayachain_signtx.py +++ b/tests/test_msg_mayachain_signtx.py @@ -7,6 +7,7 @@ import keepkeylib.messages_pb2 as proto import keepkeylib.types_pb2 as proto_types from keepkeylib.tools import parse_path +from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 DEFAULT_BIP32_PATH = "m/44h/931h/0h/0/0" @@ -23,6 +24,31 @@ def make_send(from_address, to_address, amount): } } +def recover_eth_signer(sig_v, sig_r, sig_s, chain_id, nonce, gas_price, + gas_limit, to, value, data): + """Recover the 20-byte signer address from an EIP-155 signature. + + Verifies the signature is over the EXACT tx the test intended (nonce, gas, + to, value, calldata, chain_id) AND was produced by the expected key -- + unlike a bare `len(r) == 32` structural check, which a wrong digest, wrong + calldata or wrong key would still satisfy. + """ + import ecdsa + from ecdsa.util import sigdecode_string + + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, data, + chain_id) + # EIP-155: v = recid + chain_id*2 + 35 + recid = sig_v - (chain_id * 2 + 35) + assert recid in (0, 1), "v=%d is not a valid EIP-155 recid for chain_id=%d" % (sig_v, chain_id) + + vks = ecdsa.VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, curve=ecdsa.SECP256k1, sigdecode=sigdecode_string) + vk = vks[recid] + pub = vk.to_string() # 64-byte uncompressed X||Y + return keccak256(pub)[-20:] + + class TestMsgMayaChainSignTx(common.KeepKeyTest): @unittest.skip("TODO: capture expected signatures from emulator") @@ -72,16 +98,10 @@ def test_sign_eth_btc_swap(self): self.requires_firmware("7.1.0") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=0x0, - gas_price=0x5FB9ACA00, - gas_limit=0x186A0, - value=0x00, - to=unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d'), # Maya router v4 (firmware-pinned) - address_type=0, - chain_id=1, - data=unhexlify('1fece7b4' + + address_n = [2147483692,2147483708,2147483648,0,0] + nonce, gas_price, gas_limit, value = 0x0, 0x5FB9ACA00, 0x186A0, 0x00 + to = unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d') # Maya router v4 (firmware-pinned) + data = unhexlify('1fece7b4' + '000000000000000000000000345b297ec83add7ff74d2f7933651bffa037d956' + # asgard vault address '0000000000000000000000000000000000000000000000000000000000000000' + # asset ETH '000000000000000000000000000000000000000000000065945acd2b867ef000' + # amount @@ -90,13 +110,25 @@ def test_sign_eth_btc_swap(self): # SWAP:BTC.BTC:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420 '535741503a4254432e4254433a30783431653535363030353438323465613662' + # mayachain transaction memo '30373332653635366533616436346532306539346534353a3432300000000000') - ) - # `to` updated to the firmware-pinned Maya router; exact r/s change - # with it, so assert structure here and regenerate exact vectors - # on-device. + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + value=value, to=to, address_type=0, chain_id=1, data=data) + # Verify the signature is over the EXACT tx above and by THIS device's + # key, rather than merely checking r/s lengths (which a wrong digest, + # wrong calldata or wrong key would also pass). Recovery keeps the test + # correct across router changes without re-freezing r/s vectors. self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 self.assertEqual(len(sig_r), 32) self.assertEqual(len(sig_s), 32) + signer = recover_eth_signer(sig_v, sig_r, sig_s, chain_id=1, nonce=nonce, + gas_price=gas_price, gas_limit=gas_limit, + to=to, value=value, data=data) + expected = self.client.ethereum_get_address(address_n) + if isinstance(expected, str): + expected = unhexlify(expected[2:] if expected.startswith('0x') else expected) + self.assertEqual(signer, expected, + "signature does not recover to the device's own signer " + "for this path -- wrong digest, calldata, or key") def test_sign_btc_add_liquidity(self): @@ -122,16 +154,10 @@ def test_sign_eth_add_liquidity(self): self.requires_firmware("7.9.1") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() - sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( - n=[2147483692,2147483708,2147483648,0,0], - nonce=0x0, - gas_price=0x5FB9ACA00, - gas_limit=0x186A0, - value=0x00, - to=unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d'), # Maya router v4 (firmware-pinned) - address_type=0, - chain_id=1, - data=unhexlify('1fece7b4' + + address_n = [2147483692,2147483708,2147483648,0,0] + nonce, gas_price, gas_limit, value = 0x0, 0x5FB9ACA00, 0x186A0, 0x00 + to = unhexlify('e3985e6b61b814f7cdb188766562ba71b446b46d') # Maya router v4 (firmware-pinned) + data = unhexlify('1fece7b4' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + '0000000000000000000000000000000000000000000000000000000000000000' + @@ -140,14 +166,25 @@ def test_sign_eth_add_liquidity(self): # ADD:ETH.ETH:0xc5b2608927ea95ed43f842f553e3a27b09c050e8:420 '4144443a4554482e4554483a3078633562323630383932376561393565643433' + '663834326635353365336132376230396330353065383a343230000000000000') - - ) - # `to` updated to the firmware-pinned Maya router; exact r/s change - # with it, so assert structure here and regenerate exact vectors - # on-device. + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=address_n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + value=value, to=to, address_type=0, chain_id=1, data=data) + # Verify the signature is over the EXACT tx above and by THIS device's + # key, rather than merely checking r/s lengths (which a wrong digest, + # wrong calldata or wrong key would also pass). Recovery keeps the test + # correct across router changes without re-freezing r/s vectors. self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 self.assertEqual(len(sig_r), 32) self.assertEqual(len(sig_s), 32) + signer = recover_eth_signer(sig_v, sig_r, sig_s, chain_id=1, nonce=nonce, + gas_price=gas_price, gas_limit=gas_limit, + to=to, value=value, data=data) + expected = self.client.ethereum_get_address(address_n) + if isinstance(expected, str): + expected = unhexlify(expected[2:] if expected.startswith('0x') else expected) + self.assertEqual(signer, expected, + "signature does not recover to the device's own signer " + "for this path -- wrong digest, calldata, or key") @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_remove_liquidity(self): From 03af8d1003a2c95c6a46fcb8b73b8099b03dcf59 Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 16 Jul 2026 18:19:37 -0300 Subject: [PATCH 18/26] fix(review): reference decoder must reject 0x80 like firmware; pin corrected proto MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference RLE decoder accepted a 128-byte literal (n = -128 / 0x80) because Python ints don't overflow — masking a real incompatibility: firmware's counter is int8_t, so -(-128) wraps to -128 and the packet is undecodable (assert on debug/emulator; negative-counter signed-overflow UB under NDEBUG). A decoder that accepts what the device cannot is worse than no reference at all. Mirror firmware exactly: reject n == -128 and n == 0. Adds the boundary tests the review asked for — 0x80 literal rejected, and the valid -127/127 boundaries still decode — plus a guard that the icon_width cap is the 40px text column (LEFT_MARGIN_WITH_ICON), not the 64px height, so the two can't be conflated. Re-pins device-protocol to 7182973 (the corrected contract: literals [-127,-1], 0x80 invalid, icon_width 1..40). Comment-only upstream, so the regenerated bindings are byte-identical — pb2 files unchanged. Firmware enforcement: BitHighlander/keepkey-firmware#310. Offline suites: 30 passed. --- device-protocol | 2 +- tests/test_msg_ethereum_clear_signing.py | 36 ++++++++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/device-protocol b/device-protocol index f7b45807..71829739 160000 --- a/device-protocol +++ b/device-protocol @@ -1 +1 @@ -Subproject commit f7b458078cf9249ac706bd1089f109a5a2ea8696 +Subproject commit 7182973919e88ac49cc219f30f17ec17488f9fde diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index c34876ff..0720ceea 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -1438,9 +1438,13 @@ def _decode_icon_rle(data, width, height): The icon is NOT a packed 1bpp bitmap (a packed 64x64 needs 512 bytes and the wire cap is 384). It is run-length encoded with byte-valued pixels: - n = int8(data[i++]); n > 0 -> RUN: emit the single next value byte n times - n < 0 -> LITERAL: emit the next (-n) value bytes once each - n == 0 -> invalid + n = int8(data[i++]); n in [1,127] -> RUN: emit the next value byte n times + n in [-127,-1] -> LITERAL: emit the next (-n) bytes once each + n == 0 -> invalid + n == -128 (0x80)-> invalid: firmware's counter is int8_t + and cannot represent 128, so the packet + is undecodable (it previously asserted / + ran with a negative counter under NDEBUG) Pixels fill row-major until exactly width*height are emitted. """ seq = nonseq = i = 0 @@ -1455,6 +1459,10 @@ def _decode_icon_rle(data, width, height): i += 1 if n == 0: raise ValueError("n == 0 is invalid") + if n == -128: + # Mirror firmware: -(-128) overflows int8_t. Accepting 128 + # here would mask a decoder incompatibility. + raise ValueError("n == -128 (0x80) is invalid: undecodable") if n < 0: nonseq, seq = -n, 0 else: @@ -1500,6 +1508,28 @@ def test_run_and_literal_packets(self): self.assertEqual(_decode_icon_rle(bytes([0xFD, 0x01, 0x02, 0x03]), 3, 1), [0x01, 0x02, 0x03]) # LITERAL (-3) + def test_literal_of_128_is_invalid(self): + # 0x80 => n = -128. Spec-valid under the original doc, but firmware's + # int8_t counter cannot represent 128: it asserted (debug) or decoded + # with a negative counter (NDEBUG). Both proto and firmware now reject. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x80]) + bytes([0xAA] * 128), 128, 1) + + def test_literal_of_127_is_the_valid_boundary(self): + data = bytes([0x81]) + bytes(range(127)) + self.assertEqual(_decode_icon_rle(data, 127, 1), list(range(127))) + + def test_run_of_127_is_the_valid_boundary(self): + self.assertEqual(_decode_icon_rle(bytes([0x7F, 0x5A]), 127, 1), + [0x5A] * 127) + + def test_icon_width_cap_is_the_text_column_not_the_height(self): + # icon_width <= 40 (LEFT_MARGIN_WITH_ICON), NOT 64: text begins at x=40 + # and the icon is drawn after it, so a wider icon would overwrite the + # alias/fingerprint/"NOT verified by KeepKey" warning. + LEFT_MARGIN_WITH_ICON = 40 + self.assertLess(LEFT_MARGIN_WITH_ICON, 64) + def test_zero_count_is_invalid(self): with self.assertRaises(ValueError): _decode_icon_rle(bytes([0x00, 0xFF]), 1, 1) From 25df24c3b29258a40f4a651cb430af0b54f4edad Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 16 Jul 2026 18:54:02 -0300 Subject: [PATCH 19/26] test(clearsign): make the icon reference decoder exact, like the firmware validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reference decoder repeated the drawing path's leniency: it filled w*h pixels and returned, so it accepted a final run that straddles the image (05ff at 2x2) and silently ignored trailing packets. A reference that accepts streams the device rejects is the same class of bug as the 0x80 case — it hands client authors an encoder that produces invalid icons. Mirror firmware's draw_bitmap_mono_rle_valid(): reject leftover run counters and any unconsumed input. Adds the straddling-run and trailing-packet tests. Firmware side: BitHighlander/keepkey-firmware#310. --- tests/test_msg_ethereum_clear_signing.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 0720ceea..00e7e73d 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -1477,6 +1477,14 @@ def _decode_icon_rle(data, width, height): else: i += 1 nonseq -= 1 + # Exactness, mirroring firmware's draw_bitmap_mono_rle_valid(): a run that + # straddles the end of the image, or packets trailing past the last pixel, + # are NOT well-formed. The drawing path fills the canvas and stops, so it + # cannot catch these -- the validator must. + if seq != 0 or nonseq != 0: + raise ValueError("run straddles the end of the image") + if i != len(data): + raise ValueError("trailing packets after the final pixel") return out @@ -1534,6 +1542,17 @@ def test_zero_count_is_invalid(self): with self.assertRaises(ValueError): _decode_icon_rle(bytes([0x00, 0xFF]), 1, 1) + def test_straddling_run_is_rejected(self): + # 05 FF for a 2x2: RUN of 5 into a 4-pixel image. The draw path would + # fill 4 and report success; the stream is not well-formed. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x05, 0xFF]), 2, 2) + + def test_trailing_packets_are_rejected(self): + # Exactly fills 2x2, then carries an unread packet. + with self.assertRaises(ValueError): + _decode_icon_rle(bytes([0x04, 0xFF, 0x01, 0xAA]), 2, 2) + def test_truncated_stream_is_rejected(self): with self.assertRaises(ValueError): _decode_icon_rle(bytes([0x08, 0xFF]), 4, 4) # claims 8, only 2 bytes From c4754827afd6d1e0f2abd6c5d18e7a220d55a27d Mon Sep 17 00:00:00 2001 From: highlander Date: Thu, 16 Jul 2026 19:19:26 -0300 Subject: [PATCH 20/26] fix(review): correct the public icon contract; fix the Maya recovery tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three review points, all confirmed against a full integration run. 1) The Maya tests were BROKEN, not merely unverified. Running rc10 against these pins surfaced it: `TypeError: assertEqual() takes 3 positional arguments but 4 were given` — common.KeepKeyTest overrides assertEqual with no msg param (the same trap 591dba8 already fixed once). Both sites failed, so the signature verification never actually ran. Drop the msg argument, and reuse the recovery helper already proven in test_msg_ethereum_clear_signing.py (same signature, hashfunc=None) instead of the bespoke one I added. Integration now: 546 passed, 0 failed (was 544 passed, 2 failed). 2) The public contract was contradictory. client.py still documented all negative counts (including 0x80) and both dimensions as 1..64. Corrected to literals [-127,-1] with 0x80 invalid, width 1..40 (LEFT_MARGIN_WITH_ICON), height 1..64, plus the exactness rule (no straddling run, whole input consumed) and an accurate persist description. 3) The "packed 64x64 needs 512 bytes" rationale is obsolete and is removed. 64px width is no longer legal, and a packed icon at the legal maximum (40x64) is 320 bytes — it WOULD fit the 384-byte cap. RLE is the format because draw_bitmap_mono_rle() is the decoder of record and every bundled image already uses it, not because packed wouldn't fit. The test now asserts that honestly rather than encoding dead arithmetic. Offline suites: 32 passed. --- keepkeylib/client.py | 48 +++++++++++------ tests/test_msg_ethereum_clear_signing.py | 31 ++++++----- tests/test_msg_mayachain_signtx.py | 67 ++++++++++-------------- 3 files changed, 79 insertions(+), 67 deletions(-) diff --git a/keepkeylib/client.py b/keepkeylib/client.py index f2bb12b3..3ae9a69f 100644 --- a/keepkeylib/client.py +++ b/keepkeylib/client.py @@ -713,24 +713,42 @@ def ethereum_send_tx_metadata(self, signed_payload, metadata_version, key_id): @expect(proto.Success) def load_clearsign_signer(self, key_id, pubkey, alias, icon=None, icon_width=None, icon_height=None, persist=None): - """Load a runtime clearsign signer (compressed pubkey + alias) into a - key slot. Triggers a mandatory on-device confirmation. Metadata verified - by a loaded signer shows a warning screen naming the alias before every + """Load a clearsign signer (compressed pubkey + alias) into a key slot. + Triggers a mandatory on-device confirmation. Metadata verified by a + loaded signer shows a warning screen naming the alias before every clearsign page. icon (optional, <= 384 bytes) is an identity logo shown on the trust - screen. It is RUN-LENGTH ENCODED with byte-valued pixels -- NOT a packed - 1bpp bitmap (a packed 64x64 would need 512 bytes and cannot fit the cap). - Read n = int8(data[i++]): n > 0 emits the single following value byte n - times; n < 0 emits the next (-n) value bytes once each; n == 0 is - invalid. Pixels fill row-major until icon_width*icon_height are emitted. - See LoadClearsignSigner.icon in messages-ethereum.proto for the grammar - and a golden vector; the decoder of record is draw_bitmap_mono_rle() in - keepkey-firmware lib/board/draw.c. icon_width/icon_height are required - with icon and must each be 1..64; omit all three for a text-only identity. - - persist=True also writes the identity to flash so it survives reboot; - the default is RAM-only (gone on reboot).""" + screen. It is RUN-LENGTH ENCODED with byte-valued pixels, NOT a packed + bitmap: draw_bitmap_mono_rle() in keepkey-firmware lib/board/draw.c is + the decoder of record, and it is what every bundled image already uses. + + Grammar -- read n = int8(data[i++]): + n in [1, 127] RUN : one value byte follows; emit it n times. + n in [-127, -1] LITERAL : (-n) value bytes follow; emit each once. + n == 0 : invalid. + n == -128 (0x80) : INVALID -- the device's run counter is + int8_t and cannot represent 128. Split a + 128-byte literal into two packets. + The stream must decode EXACTLY: no run may straddle the end of the + image, exactly icon_width*icon_height pixels are emitted (row-major), + and the whole input must be consumed -- trailing packets are rejected. + The device validates this before showing or storing the icon. See + LoadClearsignSigner.icon in messages-ethereum.proto for the grammar and + a golden vector. + + icon_width and icon_height are required with icon. + icon_width : 1..40 -- the confirm screen's icon column + (LEFT_MARGIN_WITH_ICON). Text begins at x=40 and the + icon is drawn after it, so a wider icon would paint over + the alias, fingerprint and the "NOT verified by KeepKey" + warning. Capped, not clipped. + icon_height : 1..64 -- the icon column is 64px tall. + Omit all three for a text-only identity. + + persist=True also writes the identity to flash, so it survives reboot + and is reloaded automatically. The default is RAM-only (gone on + reboot).""" msg = eth_proto.LoadClearsignSigner( key_id=key_id, pubkey=pubkey, diff --git a/tests/test_msg_ethereum_clear_signing.py b/tests/test_msg_ethereum_clear_signing.py index 00e7e73d..e6ae216f 100644 --- a/tests/test_msg_ethereum_clear_signing.py +++ b/tests/test_msg_ethereum_clear_signing.py @@ -1496,12 +1496,24 @@ class TestClearsignSignerIcon(unittest.TestCase): client following the old doc rendered a garbled/absent logo on a TRUST screen. """ - ICON_MAX = 384 # METADATA_ICON_MAX / CLEARSIGN_ICON_MAX / proto max_size - - def test_packed_1bpp_cannot_fit_the_cap(self): - # Why the format must be RLE: the firmware accepts icon_width/height up - # to 64, but a packed 1bpp 64x64 needs 512 bytes > the 384-byte cap. - self.assertGreater((64 * 64) // 8, self.ICON_MAX) + ICON_MAX = 384 # METADATA_ICON_MAX / CLEARSIGN_ICON_MAX / proto max_size + MAX_WIDTH = 40 # LEFT_MARGIN_WITH_ICON -- the confirm screen's icon column + MAX_HEIGHT = 64 # the icon column's height + + def test_geometry_caps_are_asymmetric(self): + # width is capped at the 40px text column, NOT at the 64px height: text + # begins at x=40 and the icon is drawn after it, so a wider icon paints + # over the alias/fingerprint/"NOT verified by KeepKey" warning. + self.assertLess(self.MAX_WIDTH, self.MAX_HEIGHT) + + def test_rle_is_the_format_of_record_not_a_size_workaround(self): + # Deliberately NOT justified by "packed wouldn't fit": at the legal max + # geometry a packed 1bpp icon is 40*64/8 = 320 bytes and WOULD fit the + # 384-byte cap. RLE is the format because draw_bitmap_mono_rle() is the + # decoder of record (shared with every bundled image) -- so the encoder + # contract is RLE regardless of what packed would cost. + self.assertLessEqual((self.MAX_WIDTH * self.MAX_HEIGHT) // 8, + self.ICON_MAX) def test_golden_vector_matches_the_documented_decode(self): # The golden vector published in messages-ethereum.proto. @@ -1531,13 +1543,6 @@ def test_run_of_127_is_the_valid_boundary(self): self.assertEqual(_decode_icon_rle(bytes([0x7F, 0x5A]), 127, 1), [0x5A] * 127) - def test_icon_width_cap_is_the_text_column_not_the_height(self): - # icon_width <= 40 (LEFT_MARGIN_WITH_ICON), NOT 64: text begins at x=40 - # and the icon is drawn after it, so a wider icon would overwrite the - # alias/fingerprint/"NOT verified by KeepKey" warning. - LEFT_MARGIN_WITH_ICON = 40 - self.assertLess(LEFT_MARGIN_WITH_ICON, 64) - def test_zero_count_is_invalid(self): with self.assertRaises(ValueError): _decode_icon_rle(bytes([0x00, 0xFF]), 1, 1) diff --git a/tests/test_msg_mayachain_signtx.py b/tests/test_msg_mayachain_signtx.py index 388ec372..7e3f6806 100644 --- a/tests/test_msg_mayachain_signtx.py +++ b/tests/test_msg_mayachain_signtx.py @@ -24,29 +24,24 @@ def make_send(from_address, to_address, amount): } } -def recover_eth_signer(sig_v, sig_r, sig_s, chain_id, nonce, gas_price, - gas_limit, to, value, data): - """Recover the 20-byte signer address from an EIP-155 signature. +def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id): + """Recover the 20-byte Ethereum signer from a legacy (EIP-155) signature. - Verifies the signature is over the EXACT tx the test intended (nonce, gas, - to, value, calldata, chain_id) AND was produced by the expected key -- - unlike a bare `len(r) == 32` structural check, which a wrong digest, wrong - calldata or wrong key would still satisfy. + Mirrors the helper proven in test_msg_ethereum_clear_signing.py. Verifying + recovery — rather than asserting r/s lengths — means a wrong digest, wrong + calldata or wrong key fails the test, and it stays correct across router + changes without re-freezing vectors. """ - import ecdsa - from ecdsa.util import sigdecode_string - - digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, data, - chain_id) - # EIP-155: v = recid + chain_id*2 + 35 - recid = sig_v - (chain_id * 2 + 35) - assert recid in (0, 1), "v=%d is not a valid EIP-155 recid for chain_id=%d" % (sig_v, chain_id) - - vks = ecdsa.VerifyingKey.from_public_key_recovery_with_digest( - sig_r + sig_s, digest, curve=ecdsa.SECP256k1, sigdecode=sigdecode_string) - vk = vks[recid] - pub = vk.to_string() # 64-byte uncompressed X||Y - return keccak256(pub)[-20:] + from ecdsa import VerifyingKey, SECP256k1, util + if chain_id: + rec = sig_v - (35 + 2 * chain_id) + else: + rec = sig_v - 27 + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + return keccak256(keys[rec].to_string())[-20:] class TestMsgMayaChainSignTx(common.KeepKeyTest): @@ -120,15 +115,12 @@ def test_sign_eth_btc_swap(self): self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 self.assertEqual(len(sig_r), 32) self.assertEqual(len(sig_s), 32) - signer = recover_eth_signer(sig_v, sig_r, sig_s, chain_id=1, nonce=nonce, - gas_price=gas_price, gas_limit=gas_limit, - to=to, value=value, data=data) - expected = self.client.ethereum_get_address(address_n) - if isinstance(expected, str): - expected = unhexlify(expected[2:] if expected.startswith('0x') else expected) - self.assertEqual(signer, expected, - "signature does not recover to the device's own signer " - "for this path -- wrong digest, calldata, or key") + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, 1) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1) + # ethereum_get_address returns the raw 20 bytes. NB: KeepKeyTest's + # assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) def test_sign_btc_add_liquidity(self): @@ -176,15 +168,12 @@ def test_sign_eth_add_liquidity(self): self.assertIn(sig_v, [37, 38]) # EIP-155 chain_id=1 self.assertEqual(len(sig_r), 32) self.assertEqual(len(sig_s), 32) - signer = recover_eth_signer(sig_v, sig_r, sig_s, chain_id=1, nonce=nonce, - gas_price=gas_price, gas_limit=gas_limit, - to=to, value=value, data=data) - expected = self.client.ethereum_get_address(address_n) - if isinstance(expected, str): - expected = unhexlify(expected[2:] if expected.startswith('0x') else expected) - self.assertEqual(signer, expected, - "signature does not recover to the device's own signer " - "for this path -- wrong digest, calldata, or key") + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, 1) + signer = recover_eth_signer(sig_r, sig_s, sig_v, digest, 1) + # ethereum_get_address returns the raw 20 bytes. NB: KeepKeyTest's + # assertEqual override takes no msg argument. + self.assertEqual(signer, self.client.ethereum_get_address(address_n)) @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_remove_liquidity(self): From 7231374c3fc79c39af3f5fd1daa379e0779cb4ac Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 15:59:15 -0300 Subject: [PATCH 21/26] test(hive): expect non-printable messages to be rejected Firmware now refuses non-printable Hive messages (domain separation closes the cross-chain message->transaction signature oracle), so the test that asserted a binary buffer signs is inverted to expect the SyntaxError. --- tests/test_msg_hive.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/tests/test_msg_hive.py b/tests/test_msg_hive.py index 6924ee0f..488a5e6c 100644 --- a/tests/test_msg_hive.py +++ b/tests/test_msg_hive.py @@ -528,17 +528,21 @@ def test_hive_sign_message_all_roles(self): self.assertEqual(len(seen), 3) # role keys must be distinct def test_hive_sign_message_nonprintable_bytes(self): - """Raw (non-printable) buffers sign too — Keychain accepts serialized - Buffer payloads, shown on-device as a hex preview.""" + """Non-printable buffers are REFUSED. A Hive transaction digest is + SHA256(chain_id || serialized_tx) over binary bytes, so a binary + "message" equal to C || tx would hash to a valid transaction signature + on any fork chain C. Restricting signable messages to printable ASCII + keeps them in a domain disjoint from every transaction preimage, closing + that cross-chain message->transaction signature oracle.""" self.requires_firmware("7.15.0") self.requires_message("HiveSignMessage") self.setup_mnemonic_nopin_nopassphrase() - message = bytes(range(0, 48)) # starts 0x00... — nothing like the chain id - posting = hive.get_public_key(self.client, hive_path(ROLE_POSTING), show_display=False) - resp = hive.sign_message(self.client, hive_path(ROLE_POSTING), message) - self.assertEqual(self._recover_message_signer(message, resp.signature), - posting.raw_public_key) + from keepkeylib.client import CallException + message = bytes(range(0, 48)) # non-printable bytes + with self.assertRaises(CallException) as ctx: + hive.sign_message(self.client, hive_path(ROLE_POSTING), message) + self.assertIn("printable", str(ctx.exception)) def test_hive_sign_message_long_printable_ok(self): """Printable text over the 128-byte display budget still signs — it From 78bd6ca84edd8b4531507d8cdbb693d47640c54c Mon Sep 17 00:00:00 2001 From: highlander Date: Fri, 17 Jul 2026 16:51:40 -0300 Subject: [PATCH 22/26] test: unchecked SPL transfer/approve require AdvancedMode; native ETH deposit uses address(0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Solana token transfer/approve/with_metadata now assert the unchecked variants are blocked without AdvancedMode and blind-sign with it (no signed mint to clear-sign against). - THORChain deposit fixture uses address(0) for native ETH — the only native form the pinned routers accept (0xEeee sentinel would revert on-chain). --- tests/test_msg_ethereum_thorchain_deposit.py | 4 +- tests/test_msg_solana_signtx.py | 50 ++++++++++++++++---- 2 files changed, 42 insertions(+), 12 deletions(-) diff --git a/tests/test_msg_ethereum_thorchain_deposit.py b/tests/test_msg_ethereum_thorchain_deposit.py index f6c3a5a9..177b01a5 100644 --- a/tests/test_msg_ethereum_thorchain_deposit.py +++ b/tests/test_msg_ethereum_thorchain_deposit.py @@ -27,7 +27,7 @@ def _build_deposit_calldata(memo): """Build deposit(address,address,uint256,string) calldata (legacy selector).""" selector = bytes.fromhex("1fece7b4") vault = bytes(12) + bytes.fromhex(THOR_ROUTER) - asset = bytes(12) + bytes.fromhex(ETH_NATIVE) + asset = bytes(32) # address(0): the only native-ETH form the routers accept amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH memo_offset = (4 * 32).to_bytes(32, "big") # offset = 128 memo_bytes = memo.encode("ascii") @@ -41,7 +41,7 @@ def _build_deposit_with_expiry_calldata(memo, expiry=9999999999): """Build depositWithExpiry(address,address,uint256,string,uint256) calldata.""" selector = bytes.fromhex("44bc937b") vault = bytes(12) + bytes.fromhex(THOR_ROUTER) - asset = bytes(12) + bytes.fromhex(ETH_NATIVE) + asset = bytes(32) # address(0): the only native-ETH form the routers accept amount = (500000000000000000).to_bytes(32, "big") # 0.5 ETH memo_offset = (5 * 32).to_bytes(32, "big") # offset = 160 (after expiry) expiry_b = expiry.to_bytes(32, "big") diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index f27ea523..82c23cd0 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -263,31 +263,51 @@ def _build_tx(self, from_pubkey, accounts, program_id, instr_data, extra_account # ================================================================ def test_solana_sign_token_transfer(self): - """SPL Token transfer — OLED shows 'Send [amount] tokens to [address]'.""" + """Unchecked SPL Transfer has no signed mint (the token being moved is + not provable), so it now requires AdvancedMode (blind-sign); only the + TransferChecked variant clear-signs.""" self.requires_fullFeature() self.setup_mnemonic_allallall() + from keepkeylib.client import CallException from_pubkey = self._get_from_pubkey() to_account = b'\x33' * 32 # destination token account - owner = from_pubkey # token owner = signer # SPL Token Transfer instruction: opcode=3 (u8) + amount (LE u64) instr_data = bytes([3]) + struct.pack(' Date: Fri, 17 Jul 2026 17:17:49 -0300 Subject: [PATCH 23/26] test(solana): CreateAccount/SetAuthority require AdvancedMode; StakeAuthorize clear-signs CreateAccount (owner+space not shown) and SetAuthority (takeover vector, None case not disclosed) are now gated behind AdvancedMode; StakeAuthorize clear-signs showing the role and new authority. --- tests/test_msg_solana_signtx.py | 56 +++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index 82c23cd0..53fc3dcc 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -309,6 +309,62 @@ def test_solana_sign_token_approve(self): self.assertEqual(len(resp.signature), 64) self.client.apply_policy('AdvancedMode', False) + def test_solana_sign_create_account_requires_advanced_mode(self): + """SystemProgram CreateAccount assigns the new account's owner program + and space (not shown on-screen), so it is gated behind AdvancedMode.""" + self.requires_fullFeature() + self.setup_mnemonic_allallall() + from keepkeylib.client import CallException + from_pubkey = self._get_from_pubkey() + new_account = b'\x55' * 32 + instr_data = struct.pack(' Date: Sat, 18 Jul 2026 01:59:23 -0300 Subject: [PATCH 24/26] =?UTF-8?q?test(maya):=20un-skip=20native=20signtx?= =?UTF-8?q?=20=E2=80=94=20digest-verified,=20no=20frozen=20vectors;=20sola?= =?UTF-8?q?na=20TransferChecked=20+=20attested-symbol=20OLED=20tests;=20re?= =?UTF-8?q?port:=20hive/solana/maya=20catalog=20+=20specificity=20frame=20?= =?UTF-8?q?picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mayachain: the 3 skipped native tests carried thorchain's copy-pasted signature vectors (could never pass). Rewritten self-verifying: host reconstructs the amino sign-doc byte-for-byte (mirrors mayachain.c; the identical construction reproduces thorchain's green frozen vector) and verifies the signature against it + the known device pubkey. - solana: TransferChecked clear-sign test (dedicated mint screen, no AdvancedMode) + signed-token-def test (LoadClearsignSigner attestation over mint/decimals/symbol -> 'signed by alias' screen). messages_solana_pb2 regenerated (protoc 3.5.1) with SolanaTokenInfo signature/signer_key_id; device-protocol pin -> 47e19d8 (matching proto). - report SECTIONS: Hive G6-G28 (sign-message printable-whitelist oracle fix, sign-ops, transfer fences), Solana S8/S12 captions corrected to the force-opaque behavior + S13-S24, Maya M4-M6 native entries, THOR H2/H4 + EVM E20/E21 deposit happy paths get capture hints; H2 + maya memos render full-sequence (every raw-memo page). - frame picker: blank/lock frames never shown; cross-test duplicate census (masked for the scroll-arrow animation) ranks test-specific frames above shared chrome, fixing the leaked IMPORT-RECOVERY/blank frames in section C. --- device-protocol | 2 +- keepkeylib/messages_solana_pb2.py | 42 ++- scripts/generate-test-report.py | 403 +++++++++++++++++++++++++---- tests/test_msg_mayachain_signtx.py | 258 +++++++----------- tests/test_msg_solana_signtx.py | 118 +++++++++ 5 files changed, 591 insertions(+), 232 deletions(-) diff --git a/device-protocol b/device-protocol index 71829739..47e19d8b 160000 --- a/device-protocol +++ b/device-protocol @@ -1 +1 @@ -Subproject commit 7182973919e88ac49cc219f30f17ec17488f9fde +Subproject commit 47e19d8b0816db20e15d9b1e27da83c70d5ed88d diff --git a/keepkeylib/messages_solana_pb2.py b/keepkeylib/messages_solana_pb2.py index cf8d5ed6..14410b17 100644 --- a/keepkeylib/messages_solana_pb2.py +++ b/keepkeylib/messages_solana_pb2.py @@ -19,7 +19,7 @@ name='messages-solana.proto', package='', syntax='proto2', - serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"A\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\"r\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') + serialized_pb=_b('\n\x15messages-solana.proto\"V\n\x10SolanaGetAddress\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x14\n\x0cshow_display\x18\x03 \x01(\x08\" \n\rSolanaAddress\x12\x0f\n\x07\x61\x64\x64ress\x18\x01 \x01(\t\"k\n\x0fSolanaTokenInfo\x12\x0c\n\x04mint\x18\x01 \x01(\x0c\x12\x0e\n\x06symbol\x18\x02 \x01(\t\x12\x10\n\x08\x64\x65\x63imals\x18\x03 \x01(\r\x12\x11\n\tsignature\x18\x04 \x01(\x0c\x12\x15\n\rsigner_key_id\x18\x05 \x01(\r\"r\n\x0cSolanaSignTx\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0e\n\x06raw_tx\x18\x03 \x01(\x0c\x12$\n\ntoken_info\x18\x04 \x03(\x0b\x32\x10.SolanaTokenInfo\"#\n\x0eSolanaSignedTx\x12\x11\n\tsignature\x18\x01 \x01(\x0c\"h\n\x11SolanaSignMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x0f\n\x07message\x18\x03 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x04 \x01(\x08\"?\n\x16SolanaMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\"\x9c\x01\n\x19SolanaSignOffchainMessage\x12\x11\n\taddress_n\x18\x01 \x03(\r\x12\x19\n\tcoin_name\x18\x02 \x01(\t:\x06Solana\x12\x12\n\x07version\x18\x03 \x01(\r:\x01\x30\x12\x16\n\x0emessage_format\x18\x04 \x01(\r\x12\x0f\n\x07message\x18\x05 \x01(\x0c\x12\x14\n\x0cshow_display\x18\x06 \x01(\x08\"G\n\x1eSolanaOffchainMessageSignature\x12\x12\n\npublic_key\x18\x01 \x01(\x0c\x12\x11\n\tsignature\x18\x02 \x01(\x0c\x42\x32\n\x1a\x63om.keepkey.deviceprotocolB\x14KeepKeyMessageSolana') ) @@ -129,6 +129,20 @@ message_type=None, enum_type=None, containing_type=None, is_extension=False, extension_scope=None, options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signature', full_name='SolanaTokenInfo.signature', index=3, + number=4, type=12, cpp_type=9, label=1, + has_default_value=False, default_value=_b(""), + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), + _descriptor.FieldDescriptor( + name='signer_key_id', full_name='SolanaTokenInfo.signer_key_id', index=4, + number=5, type=13, cpp_type=3, label=1, + has_default_value=False, default_value=0, + message_type=None, enum_type=None, containing_type=None, + is_extension=False, extension_scope=None, + options=None, file=DESCRIPTOR), ], extensions=[ ], @@ -142,7 +156,7 @@ oneofs=[ ], serialized_start=147, - serialized_end=212, + serialized_end=254, ) @@ -193,8 +207,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=214, - serialized_end=328, + serialized_start=256, + serialized_end=370, ) @@ -224,8 +238,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=330, - serialized_end=365, + serialized_start=372, + serialized_end=407, ) @@ -276,8 +290,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=367, - serialized_end=471, + serialized_start=409, + serialized_end=513, ) @@ -314,8 +328,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=473, - serialized_end=536, + serialized_start=515, + serialized_end=578, ) @@ -380,8 +394,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=539, - serialized_end=695, + serialized_start=581, + serialized_end=737, ) @@ -418,8 +432,8 @@ extension_ranges=[], oneofs=[ ], - serialized_start=697, - serialized_end=768, + serialized_start=739, + serialized_end=810, ) _SOLANASIGNTX.fields_by_name['token_info'].message_type = _SOLANATOKENINFO diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index 6e795db2..be1d0920 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -235,42 +235,98 @@ def _frame_lit_ratio(path): return None +def _frame_hash(path): + """Content hash of an OLED PNG with the top-right animation region masked + (the scroll arrow renders in a per-capture animation state, defeating + exact-byte comparison of otherwise identical screens). None if unreadable. + """ + try: + import hashlib + pixels, w, h = _read_png_pixels(path) + if not w or not h: + return None + px = bytearray(pixels) + for y in range(min(16, h)): + row = y * w + for x in range(max(0, w - 64), w): + px[row + x] = 0 + return hashlib.md5(bytes(px)).hexdigest() + except Exception: + return None + + +# hash -> number of distinct test dirs the frame appears in. 1 = the frame is +# unique to its test (its own content); large = generic device chrome shared +# across unrelated tests (load-device prompt, policy toggles, lock screens). +_FRAME_DIR_COUNTS = {} +# Hashes appearing in >= 3 distinct dirs — used to keep chrome out of the +# "extra frames" strip when a test has real content frames of its own. +_GENERIC_FRAME_HASHES = set() + +def _build_frame_census(screenshot_dir): + """Populate the cross-test frame census from every per-test capture dir.""" + _FRAME_DIR_COUNTS.clear() + _GENERIC_FRAME_HASHES.clear() + if not screenshot_dir or not os.path.isdir(screenshot_dir): + return + dirs_per_hash = {} + for mod in sorted(os.listdir(screenshot_dir)): + mod_dir = os.path.join(screenshot_dir, mod) + if not os.path.isdir(mod_dir): + continue + for meth in sorted(os.listdir(mod_dir)): + test_dir = os.path.join(mod_dir, meth) + if not os.path.isdir(test_dir): + continue + for f in os.listdir(test_dir): + if not f.startswith('btn'): + continue + h = _frame_hash(os.path.join(test_dir, f)) + if h: + dirs_per_hash.setdefault(h, set()).add(test_dir) + _FRAME_DIR_COUNTS.update((h, len(d)) for h, d in dirs_per_hash.items()) + _GENERIC_FRAME_HASHES.update( + h for h, dirs in dirs_per_hash.items() if len(dirs) >= 3) + + def _pick_best_frame(test_dir, btn_files): """Pick the best screenshot for a test. setUp noise (wipe/load frames) is removed at capture time for the signing tests (see reset_screenshots / setup_mnemonic_*), so the frames here are - the test's own operation confirms. We still drop blank/near-blank and - full-screen frames defensively, then prefer the most content-rich frame - (the address/amount/parameter screen carries more lit pixels than a plain - "Sign this transaction?" prompt). Returns None if nothing meaningful. - - ponytail: density heuristic, no OCR — a text-heavy idle screen could still - pass; capture-time reset is the real guard, this is the safety net. + the test's own operation confirms. Defensive layers on top: + - blank/near-blank frames (idle, lock glyph) are NEVER shown — a reject + that fires before any confirm UI gets no image, not a blank one; + - rank by how test-SPECIFIC a frame is (fewest other test dirs showing the + byte-identical screen), so shared chrome (the load-device prompt, policy + toggles) loses to the test's own screens, yet still renders when it IS + the content (gate tests whose every frame is shared chrome); + - density breaks ties (the address/amount screen carries more lit pixels + than a bare "Sign?" prompt); dense out-of-band frames (QR screens) are + a last resort behind in-band ones. + + ponytail: specificity census + density, no OCR — capture-time reset is the + real guard, this is the safety net. """ if not btn_files: return None - scored = [] - readable = [] + inband, dense = [], [] for f in btn_files: - r = _frame_lit_ratio(os.path.join(test_dir, f)) - if r is None: + p = os.path.join(test_dir, f) + r = _frame_lit_ratio(p) + if r is None or r < 0.02: + continue # unreadable or blank/lock — never show + if r > 0.55: + dense.append((r, f)) # QR/near-full: last resort, real content continue - readable.append(f) - # Blank/near-blank (idle, lock) or near-full (logo/inverted) = noise. - if r < 0.02 or r > 0.55: - continue - scored.append((r, f)) - if scored: - # Most content-rich meaningful frame. - scored.sort() - return os.path.join(test_dir, scored[-1][1]) - # Nothing landed in the meaningful density band, but we DID capture a - # readable frame (e.g. a dense QR / address screen brighter than the band, - # or a single-frame test). Show it rather than a bogus "OLED needed" - # placeholder when a real screenshot exists. - if readable: - return os.path.join(test_dir, readable[-1]) + h = _frame_hash(p) + inband.append((_FRAME_DIR_COUNTS.get(h, 1), -r, f)) + if inband: + inband.sort() + return os.path.join(test_dir, inband[0][2]) + if dense: + dense.sort() + return os.path.join(test_dir, dense[-1][1]) return None def detect_fw(): @@ -331,6 +387,11 @@ def parse_junit(path): ('test_msg_ethereum_clear_signing', 'test_clearsign_erc4337_entrypoint_v0_7_handleops'), ('test_msg_ethereum_clear_signing', 'test_clearsign_safe_exectransaction'), ('test_msg_ethereum_clear_signing', 'test_clearsign_permit2_permit_transfer_from'), + # Native THOR/MAYA memo hardening: the raw memo pager (MEMO 1/N .. N/N, + # complete memo bytes, sole memo gate) IS the security story — show every + # page for every memo variant, not a single best frame. + ('test_msg_thorchain_signtx', 'test_thorchain_sign_tx'), + ('test_msg_mayachain_signtx', 'test_mayachain_sign_tx_memos'), } def _v_catalog_tests(start_id=17): @@ -823,12 +884,16 @@ def _arg_shown(a): ('E20', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_legacy_selector', 'THORChain router deposit() (legacy selector)', 'Cross-chain swap via the THORChain router contract — a daily-driver EVM<->THORChain ' - 'swap path, natively decoded (asset/amount/memo) without clear-sign metadata.', - []), + 'swap path, natively decoded (asset/amount/memo) without clear-sign metadata. The ' + 'native amount shown is the signed msg.value (the ABI amount word is a router-ignored ' + 'hint and is never displayed as the send amount).', + ['Deposit amount (msg.value)', 'Full memo']), ('E21', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_with_expiry_selector', 'THORChain router depositWithExpiry()', - 'Newer router selector variant with an expiry field; same native decode path.', - []), + 'Newer router selector variant with an expiry field; same native decode path. The ABI ' + 'memo length word is read from the calldata (not assumed 64 bytes) and the padded memo ' + 'must end exactly at the calldata end.', + ['Deposit amount (msg.value)', 'Full memo']), ('E22', 'test_msg_ethereum_thorchain_deposit', 'test_deposit_with_expiry_non_thor_address_blind_sign_blocked', 'THORChain router call to a non-pinned address is blind-sign gated', @@ -897,11 +962,18 @@ def _arg_shown(a): ('H1', 'test_msg_thorchain_getaddress', 'test_thorchain_get_address', 'Derive THORChain address', 'Bech32 thor1... address.', []), ('H2', 'test_msg_thorchain_signtx', 'test_thorchain_sign_tx', - 'Sign THORChain tx', 'Native RUNE transfer with memo.', ['Memo display']), + 'Sign THORChain tx — raw memo paged in full (7 memo variants)', + 'Native RUNE transfer. The COMPLETE raw memo is paged on the OLED (MEMO 1/N..N/N, ' + '72-char pages) as the sole memo gate — no structured summary can hide trailing ' + 'content, and a reject on any page aborts signing. The frames below show every page ' + 'for each routed memo shape (SWAP/s/=/ADD/a/+ and bare-pool).', + ['Memo pages 1/N..N/N', 'Send + asset', 'Sign confirm']), ('H3', 'test_msg_thorchain_signtx', 'test_sign_btc_eth_swap', 'Sign BTC->ETH swap', 'Cross-chain swap via THORChain memo routing.', ['Swap memo']), ('H4', 'test_msg_2thorchain_signtx', 'test_thorchain_sign_tx_deposit', - 'Sign THORChain deposit', 'LP deposit transaction.', []), + 'Sign THORChain deposit', 'LP deposit transaction (MsgDeposit): asset, amount and the ' + 'full memo are displayed from the exact bytes being signed.', + ['Deposit asset + memo']), ]), ('M', 'Maya Protocol', '7.0.0', @@ -919,9 +991,29 @@ def _arg_shown(a): ('M1', 'test_msg_mayachain_getaddress', 'test_mayachain_get_address', 'Derive Maya address', 'Bech32 maya1... address.', []), ('M2', 'test_msg_mayachain_signtx', 'test_sign_btc_eth_swap', - 'Sign BTC-ETH swap via Maya', 'Cross-chain swap via Maya memo routing.', []), + 'Sign BTC-ETH swap via Maya', 'Cross-chain swap via Maya memo routing (BTC OP_RETURN ' + 'side).', []), ('M3', 'test_msg_mayachain_signtx', 'test_sign_eth_add_liquidity', - 'Sign swap via Maya', 'Cross-chain swap via Maya memo routing.', []), + 'Add liquidity via Maya router (EVM side)', + 'depositWithExpiry() to the firmware-pinned Maya router; the signature is recovered ' + 'to the device signer over the exact calldata.', []), + ('M4', 'test_msg_mayachain_signtx', 'test_mayachain_sign_tx', + 'Sign native CACAO MsgSend — raw memo paged', + 'Native CACAO transfer. Signature verified host-side against the amino sign-doc ' + 'digest (account/chain/fee/memo/amount/addresses all bound) and the known device ' + 'pubkey — no frozen vectors to go stale. The complete raw memo is paged on the OLED ' + '(thorchain_confirm_full_memo is the sole memo gate for native MAYA too).', + ['CACAO send confirm', 'Memo page', 'Sign confirm']), + ('M5', 'test_msg_mayachain_signtx', 'test_mayachain_sign_tx_memos', + 'Native memo variants — every routed shape paged in full', + 'Each memo shape MAYA routes on (SWAP/s/=/ADD/a/+ and bare-pool) signs, each ' + 'signature is bound to its exact memo bytes via the sign-doc digest, and every page ' + 'of every memo is displayed (frames below, in order).', + ['Memo pages 1/N..N/N per variant']), + ('M6', 'test_msg_mayachain_signtx', 'test_mayachain_remove_liquidity', + 'Native WITHDRAW memo', + 'WITHDRAW:pool:basis-points memo paged in full; signature digest-verified.', + ['WITHDRAW memo page']), ]), # Binance Chain (BNB) - REMOVED: chain deprecated, beacon chain shut down 2024. @@ -1188,13 +1280,21 @@ def _arg_shown(a): ('G', 'Hive', '7.15.0', 'NEW: Hive (Graphene) support with SLIP-0048 role derivation. Four role keys per account ' '(owner, active, posting, memo), each an STM-prefixed secp256k1 key. Signs Graphene ' - 'transactions — transfer, and the account-create / account-update authority operations ' - 'Pioneer uses to onboard sponsored accounts. Every signature recovers to the role key that ' - 'the transaction was signed under, and each serialized field is bound at its byte position.', + 'transactions — transfer, the account-create / account-update authority operations ' + 'Pioneer uses to onboard sponsored accounts, Keychain signBuffer message signing (dApp ' + 'login), and parsed generic operations (vote, comment, custom_json). Every signature ' + 'recovers to the role key it was signed under, each serialized field is bound at its byte ' + 'position, and every user-controlled string is paged IN FULL on the OLED (72-char ASCII ' + 'pages; non-ASCII shown as complete hex). Message signing is restricted to printable ' + 'ASCII: a Hive transaction digest is SHA256(chain_id || binary tx), so the printable-only ' + 'whitelist makes signable messages provably disjoint from every transaction preimage on ' + 'ANY fork chain — closing the message->transaction signature-oracle class.', [ 'KEYS: SLIP-0048 m/48\'/13\'/role\'/0\'/account\' -> STM-prefixed pubkey per role', - 'SIGN TX: Graphene serialize -> per-op confirm (amount + recipient) -> ECDSA sign', + 'SIGN TX: Graphene serialize -> per-op confirm (amount + recipient + full memo pages) -> ECDSA sign', 'ACCOUNT CREATE: attest 4 role authorities + new-account name -> owner-key signature', + 'SIGN MESSAGE: printable ASCII only -> role named + full message paged -> SHA256(msg) signed', + 'SIGN OPS: device re-parses the Graphene bytes; unrecognized ops are refused (no blind-sign)', ], [ ('G1', 'test_msg_hive', 'test_hive_get_public_key_active', @@ -1224,6 +1324,121 @@ def _arg_shown(a): 'account_update (op 10) signs and recovers to the owner key; the replacement ' 'authorities are bound to their slots so updating the wrong authority fails.', ['Account-update confirm']), + ('G6', 'test_msg_hive', 'test_hive_sign_transfer_max_memo_ok', + 'Max-length memo paged in full (boundary)', + 'A memo of exactly 440 bytes (the serialization limit) still signs, and the OLED ' + 'pages the COMPLETE memo (MEMO 1/7..7/7) — nothing is truncated behind a ' + 'benign-looking prefix.', + ['Memo pages 1/7..7/7']), + ('G7', 'test_msg_hive', 'test_hive_sign_transfer_rejects_long_memo', + 'Over-limit memo rejected', + 'A 441-byte memo fails with a specific "memo too long" error before any signing. ' + 'Rejection happens before any confirm UI, so there is no OLED frame — the proof is ' + 'the specific device error.', + []), + ('G8', 'test_msg_hive', 'test_hive_sign_transfer_rejects_foreign_path', + 'Foreign derivation paths rejected', + 'BIP-44 trees, wrong registry, unassigned roles and short paths are all refused for ' + 'transaction signing — the SLIP-0048 fence.', + []), + ('G9', 'test_msg_hive', 'test_hive_sign_transfer_rejects_wrong_network', + 'Wrong network index rejected', + 'A path whose network index is not Hive (13\') must not sign.', + []), + ('G10', 'test_msg_hive', 'test_hive_sign_transfer_rejects_non_active_roles', + 'Transfer requires the active role', + 'Transfers signed under owner/posting/memo paths are refused; only active\' moves ' + 'funds.', + []), + ('G11', 'test_msg_hive', 'test_hive_sign_message_posting', + 'Sign Hive message (dApp login)', + 'Keychain signBuffer contract: signature over SHA256(raw message bytes) with the ' + 'posting key. The device names the signing role and pages the full message text. The ' + 'signature recovers to the posting key — exactly what a Hive dApp verifies for login.', + ['Signing-role screen', 'Message text']), + ('G12', 'test_msg_hive', 'test_hive_sign_message_all_roles', + 'Message signing across roles', + 'Posting, active and memo roles may sign (owner\' is refused); each signature ' + 'recovers to that role\'s distinct key.', + ['Role + message screens']), + ('G13', 'test_msg_hive', 'test_hive_sign_message_long_printable_ok', + 'Long message paged in full', + 'Printable text over the display budget routes through 72-char pages — never ' + 'silently truncated — and the signature covers every byte.', + ['Message pages']), + ('G14', 'test_msg_hive', 'test_hive_sign_message_max_length_ok', + 'Max-length (1024 B) message', + 'A message of exactly 1024 bytes (the proto cap) pages and signs.', + ['1024-byte message paged']), + ('G15', 'test_msg_hive', 'test_hive_sign_message_nonprintable_bytes', + 'SECURITY: binary messages refused (oracle fix)', + 'A binary "message" equal to chain_id || serialized_tx would hash to a valid ' + 'TRANSACTION signature on any fork chain — an active-key fund-theft oracle. The ' + 'printable-ASCII whitelist refuses every binary buffer, making signable messages ' + 'provably disjoint from all transaction preimages. Rejection is pre-UI (no frame); ' + 'the proof is the "printable" device error.', + []), + ('G16', 'test_msg_hive', 'test_hive_sign_message_rejects_chain_id_prefix', + 'Chain-id-prefixed message refused', + 'Belt-and-suspenders subset of G15: a message starting with the Hive mainnet chain ' + 'id is refused outright.', + []), + ('G17', 'test_msg_hive', 'test_hive_sign_message_rejects_oversize', + 'Oversize message refused', + '1025 bytes must fail — the proto cap and the handler agree on 1024.', + []), + ('G18', 'test_msg_hive', 'test_hive_sign_message_rejects_bad_paths', + 'Message signing path fence', + 'Foreign trees, wrong network, unassigned roles, owner\' and short paths are all ' + 'refused — the same SLIP-0048 fence as transactions.', + []), + ('G19', 'test_msg_hive', 'test_hive_sign_ops_vote', + 'Parsed vote operation', + 'The device re-parses the Graphene bytes and displays voter, author, permlink and ' + 'weight from the exact bytes being signed — a host serializer bug can only produce a ' + 'rejection, never a silent wrong-sign.', + ['Vote op screens']), + ('G20', 'test_msg_hive', 'test_hive_sign_ops_comment', + 'Parsed comment operation', + 'Comment title and body are user-controlled strings — both paged in full (72-char ' + 'ASCII pages / complete hex for non-ASCII).', + ['Comment fields paged']), + ('G21', 'test_msg_hive', 'test_hive_sign_ops_custom_json_active', + 'Parsed custom_json (active)', + 'custom_json id and payload paged in full under the active role.', + ['custom_json paged']), + ('G22', 'test_msg_hive', 'test_hive_sign_ops_custom_json_posting', + 'Parsed custom_json (posting)', + 'Same shape under the posting role (the common dApp path).', + []), + ('G23', 'test_msg_hive', 'test_hive_sign_ops_downvote_and_default_chain_id', + 'Downvote + default chain id', + 'Negative weights display correctly and the default chain id binds the mainnet ' + 'digest.', + []), + ('G24', 'test_msg_hive', 'test_hive_sign_ops_role_fences', + 'Ops role fences', + 'vote/comment sign under posting\'; custom_json under its declared auth; memo\' and ' + 'owner\' never sign operations.', + []), + ('G25', 'test_msg_hive', 'test_hive_sign_ops_rejects_excluded_and_unknown_ops', + 'Unknown/excluded ops refused (no blind-sign)', + 'transfer-shaped and unrecognized operations inside SignOperations are refused — ' + 'there is no blind-sign fallback for Graphene bytes the device cannot display.', + []), + ('G26', 'test_msg_hive', 'test_hive_sign_ops_rejects_malformed_structure', + 'Malformed Graphene structure refused', + 'Truncated fields, wrong op counts and trailing bytes are all parse failures, not ' + 'sign-what-you-can.', + []), + ('G27', 'test_msg_hive', 'test_hive_sign_ops_rejects_oversize', + 'Oversize operations refused', + 'Payloads beyond the proto cap are refused before parsing.', + []), + ('G28', 'test_msg_hive', 'test_hive_sign_account_ops_reject_non_owner_roles', + 'Account authority ops require owner', + 'account_create / account_update sign only under the owner role.', + []), ]), ('S', 'Solana', '7.14.0', @@ -1253,9 +1468,11 @@ def _arg_shown(a): ('S7', 'test_msg_solana_signtx', 'test_solana_sign_deterministic', 'Deterministic signing', 'Same tx always produces same signature.', []), ('S8', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer', - 'SPL Token transfer', - 'Send SPL tokens to destination. OLED shows token amount and recipient address.', - ['Token amount + address']), + 'Unchecked SPL Transfer requires AdvancedMode', + 'Unchecked Transfer (op 3) carries NO signed mint — the device cannot prove which ' + 'token is moving, so it is forced through the AdvancedMode blind-sign gate (matching ' + 'Trezor and Ledger, which both reject it). Only TransferChecked clear-signs.', + ['Blind-sign gate']), ('S9', 'test_msg_solana_signtx', 'test_solana_sign_stake_delegate', 'Stake delegate', 'Delegate SOL to a validator for staking rewards. OLED shows delegate confirmation.', @@ -1269,9 +1486,79 @@ def _arg_shown(a): 'Set priority fee for transaction. OLED shows compute unit price.', ['Unit price']), ('S12', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer_with_metadata', - 'SPL Token with metadata', - 'Token transfer with SolanaTokenInfo (mint, symbol, decimals). OLED shows human-readable token name.', - ['Token name + amount']), + 'Host metadata does NOT bypass the unchecked-transfer gate', + 'An unchecked Transfer accompanied by host SolanaTokenInfo still requires ' + 'AdvancedMode: the mint is not part of the signed instruction, so the metadata is ' + 'unauthenticated and must not make the tx look clear-signable.', + ['Blind-sign gate']), + ('S13', 'test_msg_solana_signtx', 'test_solana_sign_token_transfer_checked', + 'TransferChecked clear-signs with the mint on its own screen', + 'TransferChecked (op 12) binds the mint in the signed instruction bytes. The device ' + 'shows "Token mint " on a DEDICATED screen before the amount — the ' + 'authenticated token identity cannot be pushed off-view by a host-controlled symbol ' + '— and decimals come from the signed instruction, never from the host. AdvancedMode ' + 'stays OFF.', + ['Token mint screen', 'Amount + symbol']), + ('S14', 'test_msg_solana_signtx', + 'test_solana_sign_token_transfer_checked_attested_symbol', + 'Signed token definition: symbol attested by a loaded signer', + 'The token_info carries a secp256k1 attestation over (mint, decimals, symbol) by a ' + 'signer loaded via LoadClearsignSigner — the same chain-agnostic trust anchor as EVM ' + 'clear-sign metadata (KeepKey\'s open equivalent of Trezor\'s CoSi-signed token ' + 'definitions). The device verifies it, requires the attested decimals to equal the ' + 'signed instruction\'s, and adds a \'Token "USDC" signed by \' ' + 'screen. An invalid attestation rejects the symbol outright (never falls back to the ' + 'claim).', + ['Load signer consent', 'Token mint screen', 'Signed-by alias + fingerprint']), + ('S15', 'test_msg_solana_signtx', 'test_solana_sign_token_approve', + 'Unchecked SPL Approve requires AdvancedMode', + 'Approve (op 4) hides the delegated token\'s mint — same gate as unchecked Transfer.', + ['Blind-sign gate']), + ('S16', 'test_msg_solana_signtx', + 'test_solana_sign_create_account_requires_advanced_mode', + 'CreateAccount requires AdvancedMode', + 'CreateAccount assigns the new account\'s owner program and space, which the screen ' + 'does not fully disclose — gated rather than partially clear-signed.', + ['Blind-sign gate']), + ('S17', 'test_msg_solana_signtx', + 'test_solana_sign_set_authority_requires_advanced_mode', + 'SetAuthority requires AdvancedMode', + 'SetAuthority hands over control of a mint/account (including the undistinguishable ' + '"clear authority" case) — an account-takeover vector, gated.', + ['Blind-sign gate']), + ('S18', 'test_msg_solana_signtx', 'test_solana_sign_stake_authorize_clearsigns', + 'StakeAuthorize clear-signs role + new authority', + 'Shows the stake account, the role being reassigned (staker/withdrawer) and the full ' + 'new authority address.', + ['Role + new authority']), + ('S19', 'test_msg_solana_signtx', 'test_solana_sign_stake_withdraw', + 'Stake withdraw shows the destination', + 'The withdrawal destination account is displayed in full — a host cannot silently ' + 'redirect withdrawn SOL.', + ['Withdraw + destination']), + ('S20', 'test_msg_solana_signtx', 'test_solana_sign_stake_deactivate', + 'Stake deactivate shows the stake account', + 'The acted-on stake account is named on-screen.', + ['Stake account']), + ('S21', 'test_msg_solana_signtx', 'test_solana_sign_multi_instruction_2x_transfer', + 'Multi-instruction: each instruction confirmed', + 'Two transfers in one tx produce INSTR 1/2 and INSTR 2/2 screens — nothing rides ' + 'along unconfirmed.', + ['INSTR 1/2 + 2/2']), + ('S22', 'test_msg_solana_signtx', + 'test_solana_sign_multi_instruction_transfer_and_memo', + 'Transfer + memo both shown', + 'A transfer with an attached memo instruction confirms both.', + ['Transfer + memo screens']), + ('S23', 'test_msg_solana_signtx', 'test_solana_sign_versioned_v0_static_verified', + 'Versioned (v0) tx with static keys clear-signs', + 'A v0-format tx whose accounts are all static parses and clear-signs like legacy.', + ['v0 instruction screens']), + ('S24', 'test_msg_solana_signtx', 'test_solana_sign_versioned_v0_opaque', + 'v0 with address-table lookups requires AdvancedMode', + 'Lookup-table accounts cannot be resolved on-device, so the tx routes to the ' + 'blind-sign gate.', + []), ]), ('T', 'TRON', '7.14.0', @@ -1468,6 +1755,7 @@ def _arg_shown(a): # --------------------------------------------------------------- def render(output_path, fw_version, results, screenshot_dir=None): pdf = PDF(); pb = PB(pdf) + _build_frame_census(screenshot_dir) ts = datetime.now().strftime('%Y-%m-%d %H:%M') active = [(l,t,mf,bg,fl,tests) for l,t,mf,bg,fl,tests in SECTIONS if ver_ge(fw_version, mf)] # Separate specs section (no tests) from test sections @@ -1592,17 +1880,36 @@ def _section_state(s): except Exception: pass # For multi-screen tests, show up to 2 more meaningful frames. - # setUp noise is already stripped at capture time, so every - # btn frame is a real operation screen; just drop blanks and - # the one already shown as `best`. + # setUp noise is already stripped at capture time; drop + # blanks, generic cross-test chrome, and the `best` frame. extra = [] for f in btn_files: p = os.path.join(test_dir, f) if p == best: continue r = _frame_lit_ratio(p) - if r is not None and 0.02 <= r <= 0.55: + if (r is not None and 0.02 <= r <= 0.55 and + _frame_hash(p) not in _GENERIC_FRAME_HASHES): extra.append(f) + if not extra: + # Every other frame is cross-test-shared. Outcome frames + # that FOLLOW the best one (a blocked-gate screen after + # the send preamble) are still this test's story — show + # moderately-shared ones; frames in 8+ dirs are pure + # chrome (policy toggles), and anything before `best` + # is setup noise. + seen_best = False + for f in btn_files: + p = os.path.join(test_dir, f) + if p == best: + seen_best = True + continue + if not seen_best: + continue + r = _frame_lit_ratio(p) + if (r is not None and 0.02 <= r <= 0.55 and + _FRAME_DIR_COUNTS.get(_frame_hash(p), 1) < 8): + extra.append(f) extra = extra[:2] for frame in extra: try: diff --git a/tests/test_msg_mayachain_signtx.py b/tests/test_msg_mayachain_signtx.py index 7e3f6806..dd460911 100644 --- a/tests/test_msg_mayachain_signtx.py +++ b/tests/test_msg_mayachain_signtx.py @@ -1,9 +1,13 @@ +import hashlib import unittest import common from base64 import b64encode from binascii import hexlify, unhexlify +from ecdsa import VerifyingKey, SECP256k1 +from ecdsa.util import sigdecode_string + import keepkeylib.messages_pb2 as proto import keepkeylib.types_pb2 as proto_types from keepkeylib.tools import parse_path @@ -11,6 +15,10 @@ DEFAULT_BIP32_PATH = "m/44h/931h/0h/0/0" +# Compressed secp256k1 pubkey for the standard test seed at m/44'/931'/0'/0/0. +# Proven by the (green) thorchain frozen-vector test over the same path/curve. +DEVICE_PUBKEY_HEX = b"031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3" + def make_send(from_address, to_address, amount): return { 'type': 'mayachain/MsgSend', @@ -46,29 +54,72 @@ def recover_eth_signer(sig_r, sig_s, sig_v, digest, chain_id): class TestMsgMayaChainSignTx(common.KeepKeyTest): - @unittest.skip("TODO: capture expected signatures from emulator") - def test_mayachain_sign_tx(self): - self.requires_firmware("7.9.1") - self.requires_fullFeature() - self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( + def _maya_send_digest(self, account_number, chain_id, fee, gas, memo, + amount, from_address, to_address, sequence): + """SHA256 of the amino StdSignDoc exactly as mayachain.c streams it. + + Byte-for-byte mirror of mayachain_signTxInit/UpdateMsgSend/Finalize + (denom "cacao", type "mayachain/MsgSend", from_address DERIVED BY THE + DEVICE — the host-supplied from_address is not part of the digest). + The identical construction for thorchain ("rune"/"thorchain/MsgSend") + reproduces that suite's green frozen vector, which pins this format. + """ + doc = ('{"account_number":"%s"' + ',"chain_id":"%s"' + ',"fee":{"amount":[{"amount":"%s","denom":"cacao"}],"gas":"%s"}' + ',"memo":"%s"' + ',"msgs":[{"type":"mayachain/MsgSend","value":{' + '"amount":[{"amount":"%s","denom":"cacao"}]' + ',"from_address":"%s"' + ',"to_address":"%s"' + '}}],"sequence":"%s"}') % ( + account_number, chain_id, fee, gas, memo, + amount, from_address, to_address, sequence) + return hashlib.sha256(doc.encode()).digest() + + def _sign_and_verify_send(self, memo, amount=10000, + to_address="maya1jvt443rvhq5h8yrna55yjysvhtju0el7mdujp3"): + """Sign a single-MsgSend maya tx and verify the signature against the + host-reconstructed sign-doc digest and the known device pubkey. A wrong + digest (any field not bound), wrong key, or wrong curve fails here — + no frozen signature vectors to go stale.""" + # The device derives the sign-doc from_address itself (mainnet "maya" + # prefix); fetch it so the host digest matches by construction. + device_address = self.client.mayachain_get_address( + parse_path(DEFAULT_BIP32_PATH)) + + resp = self.client.mayachain_sign_tx( address_n=parse_path(DEFAULT_BIP32_PATH), account_number=92, chain_id="mayachain", fee=3000, gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - memo="foobar", + msgs=[make_send(device_address, to_address, amount)], + memo=memo, sequence=3, - testnet = True + testnet=False, ) - self.assertEqual(hexlify(signature.signature), "164ea435b39444fa780e453ffe0d0ca07fa74a44272713a283f6297b951e06dc71575e83a6a5405b324c8bc187c50951f1d46fd58acadf060fdf23980d61488a") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - return + + self.assertEqual(hexlify(resp.public_key), DEVICE_PUBKEY_HEX) + self.assertEqual(len(resp.signature), 64) + digest = self._maya_send_digest( + account_number=92, chain_id="mayachain", fee=3000, gas=200000, + memo=memo, amount=amount, from_address=device_address, + to_address=to_address, sequence=3) + vk = VerifyingKey.from_string(unhexlify(DEVICE_PUBKEY_HEX), + curve=SECP256k1) + # Raises BadSignatureError if the device signed anything but this doc. + self.assertTrue(vk.verify_digest(resp.signature, digest, + sigdecode=sigdecode_string)) + + def test_mayachain_sign_tx(self): + """Native CACAO MsgSend with a plain memo; the full raw memo is paged + on the OLED before signing (thorchain_confirm_full_memo is the sole + memo gate for native MAYA).""" + self.requires_firmware("7.9.1") + self.requires_fullFeature() + self.setup_mnemonic_nopin_nopassphrase() + self._sign_and_verify_send(memo="foobar") def test_sign_btc_eth_swap(self): self.requires_firmware("7.9.1") @@ -88,7 +139,7 @@ def test_sign_btc_eth_swap(self): (signatures, serialized_tx) = self.client.sign_tx('Bitcoin', [inp1, ], [out1, ]) self.assertEqual(hexlify(serialized_tx), '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb44701e85ef6d5000000006b483045022100c1cf12191f0a50398dae21553d14d5c796ff3e2e1c378bce3d0a7d43fa9bdf4402201245f76291db518dd8b496b4406128ca0e07165c64d2fe927161eee17402f9c40121023230848585885f63803a0a8aecdd6538792d5c539215c91698e315bf0253b43dffffffff0100000000000000003d6a3b535741503a4554482e4554483a3078343165353536303035343832346561366230373332653635366533616436346532306539346534353a34323000000000') - + def test_sign_eth_btc_swap(self): self.requires_firmware("7.1.0") self.requires_fullFeature() @@ -141,7 +192,7 @@ def test_sign_btc_add_liquidity(self): (signatures, serialized_tx) = self.client.sign_tx('Bitcoin', [inp1, ], [out1, ]) self.assertEqual(hexlify(serialized_tx), '010000000182488650ef25a58fef6788bd71b8212038d7f2bbe4750bc7bcb44701e85ef6d5000000006b483045022100ed9206af5ba7fe82dda17cf20574197924a120be5b415f875f7d9880f4591e4202201081cb688cceadad65dc20e9843d910d895342ce9316f792b748b0e4a0f757870121023230848585885f63803a0a8aecdd6538792d5c539215c91698e315bf0253b43dffffffff0100000000000000005e6a4c5b4144443a4254432e4254433a74686f7270756231616464776e7065707132796e717435303066616733777978736a7576373537307178723872717470783933687733637071617178747778657379373675746774656d703a34323000000000') - + def test_sign_eth_add_liquidity(self): self.requires_firmware("7.9.1") self.requires_fullFeature() @@ -175,171 +226,40 @@ def test_sign_eth_add_liquidity(self): # assertEqual override takes no msg argument. self.assertEqual(signer, self.client.ethereum_get_address(address_n)) - @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_remove_liquidity(self): - self.requires_firmware("7.1.1") + """WITHDRAW memo: pool + basis points paged in full on the OLED.""" + self.requires_firmware("7.9.1") self.requires_fullFeature() self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - memo="WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:10000", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "13d8ab1a8514c6163064a3e097dd8c33d7063b5994f2ce1c71c691f6fdcf4f1e54860ca7c6d8a478e15b2b07274d9752d8df0af0cd48a6113adf9ecf881ff20e") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - return + self._sign_and_verify_send( + memo="WITHDRAW:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:10000") - @unittest.skip("TODO: capture expected signatures from emulator") def test_mayachain_sign_tx_memos(self): + """Every memo shape MAYA routes on (SWAP/s/=/ADD/a/+ and bare-pool) + signs, and each signature is bound to its exact memo bytes — a memo + substitution changes the sign-doc digest and fails verification.""" self.requires_firmware("7.9.1") self.setup_mnemonic_nopin_nopassphrase() - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + memos = [ # full memo - memo="SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "a1b9082c6817d4c80b82a2d955f2be26a39b8a5e6909c5fcc52114a5c5e5476e68df191c2be5c88e35ef3090c3bafbd44083e32fbf4d26a809218aeec42ec8a9") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "SWAP:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:420", # no limit, 's' for swap token - memo="s:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "77f24a90428d104fcb0b2bd5ffe1f05e800c032e01a0f1de883616ba8e26c3781044bc8ce1497d24b1b0997061ed664d378c62e04bac54b4ffe5699177c7387f") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "s:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45:", # swap to self, "=" for swap token - memo="=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7::420", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "67ca2ad82a276645bea14fa9ae7d3f947fefe15906f93a605387d21db37c51f46f2961b62efcb7762d9008b1dbb723b2156294f35031cdd16e8e6931f68e4844") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "=:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7::420", # swap to self, no limit - memo="SWAP:BTC.BTC", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "6e6908262ae5f268e104a567f64b4be18297cc68577962925a1dcbcc2333f7ba5a5446f623a774359d68335804e88448bf432c95dc9777b26effecb339a790a9") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], + "SWAP:BTC.BTC", # full memo - memo="ADD:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "186e81a054517ce4f5134fa5ed6acc6398bd15d5c58361babadd9087fafd7a9122c7978ecc6710f76bebd46df72523f3409c33af387473f61ef167575f11a68b") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - #'a' for add liquidity - memo="a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - #memo="a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "a98354ed6ee626603cd4416d314d1b875c5ab6a6af83fe1be05a6ac56d620e8f2322d500bba6a7f6e0e2fae810016ebc00be5a580766f171cd5f4a5b2e67263f") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - signature = self.client.mayachain_sign_tx( - address_n=parse_path(DEFAULT_BIP32_PATH), - account_number=92, - chain_id="mayachain", - fee=3000, - gas=200000, - msgs=[make_send( - "tthor1ls33ayg26kmltw7jjy55p32ghjna09zp6z69y8", - "tthor1jvt443rvhq5h8yrna55yjysvhtju0el7ldnwwy", - 10000 - )], - #"+" for add liquidity - memo="+:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", - sequence=3, - testnet = True - ) - self.assertEqual(hexlify(signature.signature), "0409d104aaafe400e86b6172811bf1b44b6cc0065c13df10083a86d02b13b8ce7d40a4935bc022c76dae4793223c0c7d8446c83acdbd8d0188d35d2b7b8e22fc") - self.assertEqual(hexlify(signature.public_key), "031519713b8b42bdc367112d33132cf14cedf928ac5771d444ba459b9497117ba3") - - return + "ADD:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + # 'a' for add liquidity + "a:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + # "+" for add liquidity + "+:ETH.USDT-0xdac17f958d2ee523a2206206994597c13d831ec7:0x41e5560054824ea6b0732e656e3ad64e20e94e45", + ] + for memo in memos: + self._sign_and_verify_send(memo=memo) if __name__ == '__main__': unittest.main() diff --git a/tests/test_msg_solana_signtx.py b/tests/test_msg_solana_signtx.py index 53fc3dcc..37362b57 100644 --- a/tests/test_msg_solana_signtx.py +++ b/tests/test_msg_solana_signtx.py @@ -661,6 +661,124 @@ def test_solana_sign_token_transfer_with_metadata(self): self.assertFalse(all(b == 0 for b in resp.signature)) self.client.apply_policy('AdvancedMode', False) + def test_solana_sign_token_transfer_checked(self): + """TransferChecked (op 12) CLEAR-SIGNS with AdvancedMode OFF: the mint + is part of the signed instruction bytes, so the device shows it on its + own dedicated OLED screen ("Token mint ") before the amount — + the authenticated token identity cannot be pushed off-view by a + host-controlled symbol. The (unattested) host token_info symbol is + shown next to the amount, and decimals come from the signed + instruction, never from the host.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.setup_mnemonic_allallall() + + from_pubkey = self._get_from_pubkey() + to_account = b'\x33' * 32 # destination token account + authority = b'\x44' * 32 # transfer authority + + # USDC mint (EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v) + usdc_mint = bytes([ + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34, + ]) + + # TransferChecked: opcode=12 (u8) + amount (LE u64) + decimals (u8); + # accounts [source, mint, destination, authority] + instr_data = bytes([12]) + struct.pack(' ' screen; decimals must + also match the signed instruction bytes or the symbol is not trusted. + Clear-signs with AdvancedMode OFF.""" + self.requires_firmware("7.15.0") + self.requires_fullFeature() + self.requires_message("LoadClearsignSigner") + self.setup_mnemonic_allallall() + import hashlib + from ecdsa import SigningKey, SECP256k1 + from ecdsa.util import sigencode_string + from keepkeylib.signed_metadata import ( + TEST_PRIVATE_KEY, test_signer_compressed_pubkey, + assert_test_key_matches_slot3) + + # Load the CI signer into slot 3 through the production trust path + # (device confirm auto-acked by debuglink) — phase 1 has no built-ins. + assert_test_key_matches_slot3() + self.client.load_clearsign_signer( + key_id=3, + pubkey=test_signer_compressed_pubkey(), + alias="CI Test", + ) + + from_pubkey = self._get_from_pubkey() + to_account = b'\x33' * 32 + authority = b'\x44' * 32 + usdc_mint = bytes([ + 0xc6, 0xfa, 0x7a, 0xf3, 0xbe, 0xdb, 0xad, 0x3a, + 0x3d, 0x65, 0xf3, 0x6a, 0xab, 0xc9, 0x74, 0x31, + 0xb1, 0xbb, 0xe4, 0xc2, 0xd2, 0xf6, 0xe0, 0xe4, + 0x7c, 0xa6, 0x02, 0x03, 0x45, 0x20, 0x23, 0x34, + ]) + decimals = 6 + symbol = "USDC" + + # TransferChecked with decimals matching the attested value. + instr_data = bytes([12]) + struct.pack(' Date: Sat, 18 Jul 2026 13:23:01 -0300 Subject: [PATCH 25/26] test(thorchain): Avalanche router deposit clear-signs; unpinned chain gated MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The router pin is now (chain_id, address): an AVAX depositWithExpiry to the live-verified Avalanche router (00dc61..f1d4) must clear-sign with AdvancedMode OFF — the signature is ECDSA-recovered against the host-built EIP-155 pre-image over chainId 43114, and the native amount screen shows msg.value with the chain's ticker (AVAX). A deposit-shaped tx on a chain with no pinned router (BSC) falls to the blind-sign gate. Report catalog: E23 (AVAX clear-sign, with OLED capture) + E24 (unpinned-chain gate). --- scripts/generate-test-report.py | 17 +++++ tests/test_msg_ethereum_thorchain_deposit.py | 70 ++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/scripts/generate-test-report.py b/scripts/generate-test-report.py index be1d0920..76f5a505 100644 --- a/scripts/generate-test-report.py +++ b/scripts/generate-test-report.py @@ -902,6 +902,23 @@ def _arg_shown(a): 'through to the ordinary blind-sign gate instead of being silently native-decoded — the ' 'fix for the router-spoofing / blind-sign-bypass class of attack.', ['Blind sign disabled (Blocked)']), + ('E23', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_with_expiry_avalanche_router', + 'THORChain deposit on Avalanche clear-signs (per-chain router pin)', + 'THORChain deploys its router at a DIFFERENT address on every EVM chain, so the pin is ' + '(chain_id, address) together. Before the chain scope, only mainnet deposits ever ' + 'matched and an AVAX->ETH swap fell into the blind-sign gate. The Avalanche C-Chain ' + 'router (00dc61..f1d4) is verified live against THORChain /inbound_addresses; the ' + 'native amount screen shows msg.value with the CHAIN\'s ticker (AVAX), and the ' + 'signature is ECDSA-recovered against the host-built pre-image over chainId 43114.', + ['Thorchain router screen', 'AVAX amount', 'Full memo']), + ('E24', 'test_msg_ethereum_thorchain_deposit', + 'test_deposit_unpinned_chain_blind_sign_blocked', + 'Deposit on an unpinned chain is blind-sign gated', + 'The mainnet router ADDRESS on a chain with no pinned router (BSC) must not inherit ' + 'the deposit UX — the same address on another chain may hold unrelated attacker code. ' + 'Falls to the AdvancedMode gate; rejection is pre-UI (no frame).', + []), ]), ('R', 'Ripple (XRP)', '7.0.0', diff --git a/tests/test_msg_ethereum_thorchain_deposit.py b/tests/test_msg_ethereum_thorchain_deposit.py index 177b01a5..083c358c 100644 --- a/tests/test_msg_ethereum_thorchain_deposit.py +++ b/tests/test_msg_ethereum_thorchain_deposit.py @@ -20,6 +20,7 @@ THOR_ROUTER = "d37bbe5744d730a1d98d8dc97c42f0ca46ad7146" # ETH THORChain router +THOR_ROUTER_AVAX = "00dc6100103bc402d490aee3f9a5560cbd91f1d4" # Avalanche C-Chain router ETH_NATIVE = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" # sentinel for native ETH @@ -137,6 +138,75 @@ def test_deposit_with_expiry_non_thor_address_blind_sign_blocked(self): data=data, ) + def test_deposit_with_expiry_avalanche_router(self): + """A THORChain deposit on Avalanche clear-signs — the router pin is + (chain_id, address), not Ethereum-mainnet-only. + + Before the per-chain pin, thor_isThorchainTx only ever matched the + mainnet router, so an AVAX->ETH swap fell into the AdvancedMode + blind-sign gate and the device returned a bare ActionCancelled. The + signature is ECDSA-recovered against the host-built EIP-155 pre-image, + so a wrong digest, chain id, or key fails — not just a shape check. + The native amount screen shows msg.value with the CHAIN's ticker + (AVAX), never the mainnet pseudo-token's ETH label. + """ + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_allallall() + + from keepkeylib.signed_metadata import eth_sighash_legacy, keccak256 + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + + n = parse_path("m/44'/60'/0'/0/0") + nonce, gas_price, gas_limit = 4, 50000000000, 300000 + to = binascii.unhexlify(THOR_ROUTER_AVAX) + value = 500000000000000000 # 0.5 AVAX (native = msg.value) + chain_id = 43114 + + # AdvancedMode intentionally OFF — the deposit must clear-sign. + sig_v, sig_r, sig_s = self.client.ethereum_sign_tx( + n=n, nonce=nonce, gas_price=gas_price, gas_limit=gas_limit, + to=to, value=value, chain_id=chain_id, data=data, + ) + self.assertIn(sig_v, [2 * chain_id + 35, 2 * chain_id + 36]) + digest = eth_sighash_legacy(nonce, gas_price, gas_limit, to, value, + data, chain_id) + from ecdsa import VerifyingKey, SECP256k1, util + rec = sig_v - (35 + 2 * chain_id) + keys = VerifyingKey.from_public_key_recovery_with_digest( + sig_r + sig_s, digest, SECP256k1, hashfunc=None, + sigdecode=util.sigdecode_string, + ) + signer = keccak256(keys[rec].to_string())[-20:] + self.assertEqual(signer, self.client.ethereum_get_address(n)) + + def test_deposit_unpinned_chain_blind_sign_blocked(self): + """A deposit-shaped tx on a chain with NO pinned router must fall to + the blind-sign gate — a router address borrowed onto an unpinned chain + (where it may hold attacker code) cannot inherit the deposit UX.""" + self.requires_fullFeature() + self.requires_firmware("7.15.0") + self.setup_mnemonic_allallall() + + from keepkeylib.client import CallException + + memo = "=:ETH.ETH:0xabcdef1234567890abcdef1234567890abcdef12:0:t:0" + data = _build_deposit_with_expiry_calldata(memo) + + with self.assertRaises((CallException, Exception)): + self.client.ethereum_sign_tx( + n=parse_path("m/44'/60'/0'/0/0"), + nonce=5, + gas_price=50000000000, + gas_limit=300000, + to=binascii.unhexlify(THOR_ROUTER), # real mainnet router addr + value=0, + chain_id=56, # BSC: no pinned router + data=data, + ) + if __name__ == "__main__": unittest.main() From 85866526c33a0af7217b6e085c20bf870803c735 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 18 Jul 2026 13:33:48 -0300 Subject: [PATCH 26/26] chore(submodule): declare device-protocol from keepkey/up-release-protocol MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pin (47e19d8, solana signed-token-def fields) was only reachable from a BitHighlander feature branch while .gitmodules declared BitHighlander:master — 'git submodule update --remote' would silently rewind below the proto fields the solana tests depend on. keepkey/device-protocol:up/release-protocol now fast-forwards through the pin, so declared (url, branch) and pin agree. --- .gitmodules | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitmodules b/.gitmodules index 880097fd..fc3dd91d 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,7 +1,7 @@ [submodule "device-protocol"] path = device-protocol -url = https://github.com/BitHighlander/device-protocol.git -branch = master +url = https://github.com/keepkey/device-protocol.git +branch = up/release-protocol [submodule "keepkeylib/eth/ethereum-lists"] path = keepkeylib/eth/ethereum-lists url = https://github.com/keepkey/ethereum-lists.git