From b0e3b3c5d662e318bfc9284a117c7311497cf931 Mon Sep 17 00:00:00 2001 From: highlander Date: Sat, 19 Sep 2026 03:07:24 -0500 Subject: [PATCH] test(solana): KKSOLSC1 v2 schema reviews asserted by screen text Emulator tests for keepkey-firmware feat/solana-schema-v2 (issue #828), asserting what the OLED says rather than how many screens there are. tests/oled_text.py reads text off a DebugLink frame. DebugLinkState.layout is pixels only, so it renders the expected string with the firmware's own title and body glyphs (tables generated from lib/board/font.c; `python oled_text.py font.c` regenerates them), wraps it as draw_string_walk() does, and requires every line whole and left-aligned on consecutive rows of one screen. On recorded frames it rejects one-character changes, dropped characters and prefixes of every checked string. test_msg_solana_schema_v2.py: - Certified (public Vault 501 certificate + the real Relay v1 delegate signature): a static System Transfer companion is reviewed ("Funding account", then "Send 0.002000000 SOL to ?" under INSTR 2/2); with SetComputeUnitPrice the review shows "Fee payer " and "Max priority fee 0.000200000 SOL" on consecutive FEE screens; a duplicate price or limit is refused with "Invalid priority fee". These skip when the emulator refuses the certificate because it was built without the alpha ClearSign root, and fail on any other refusal. - Runtime (CI signer in slots 2 and 3): a Transfer companion is refused; on the real SoltoshiDICE join signed by slot 2, slot 2's SDICE definition shows "1000.000000 SDICE" with the mint beneath it for all three TOKEN_AMOUNTs, while slot 3's definition renders pixel-identical to none ("1000000000 base units of mint" + mint). - ClearsignAttestor: a v2 TOKEN_AMOUNT arg costs exactly one extra screen, "Arg 1 token mint is / account #3", right after the arg, and the returned signature verifies over sha256(payload). Each of these firmware mutations fails at least one test: certified reviews not binding the priority fee, the attestor mint screen removed, the mint dropped beside a trusted symbol, companion instructions not screened, the fee computed without the explicit compute-unit limit (same screen count, different text), and runtime token definitions accepted from any slot. --- tests/oled_text.py | 233 +++++++++++++++++ tests/test_msg_solana_schema_v2.py | 407 +++++++++++++++++++++++++++++ 2 files changed, 640 insertions(+) create mode 100644 tests/oled_text.py create mode 100644 tests/test_msg_solana_schema_v2.py diff --git a/tests/oled_text.py b/tests/oled_text.py new file mode 100644 index 00000000..b67d0b7f --- /dev/null +++ b/tests/oled_text.py @@ -0,0 +1,233 @@ +"""Find text on a DebugLink OLED frame. + +DebugLinkState.layout is the display framebuffer -- 256x64, one bit per pixel, +each byte eight vertical pixels with the least significant bit on top -- and +there is no text channel. So a test that must know WHAT a screen says renders +the expected string with the firmware's own glyphs and looks for exactly those +pixels: every ink pixel of every glyph lit, every other pixel of its cell dark. + +The glyph tables below are the title and body fonts of keepkey-firmware +lib/board/font.c (printable ASCII). Regenerate them after a font change with + + python oled_text.py path/to/keepkey-firmware/lib/board/font.c + +A body is found the way confirm() draws it: broken into lines as +draw_string_walk() in lib/board/draw.c breaks them (a 44-character Solana +address always wraps), each line whole -- nothing drawn just before or after +it -- and the lines left-aligned on consecutive rows of ONE screen. A body +that pages is not found; assert each page's part instead. +""" + +from __future__ import print_function + +import re +import sys + +GLYPH_HEIGHT = 10 +BODY_WIDTH = 225 # layout.h BODY_WIDTH, the wrap width of confirm() bodies +BODY_LINE_PITCH = GLYPH_HEIGHT + 4 # font height + BODY_FONT_LINE_PADDING + + +def _font(table): + """'w:rows' per printable character from 0x20, rows as fixed-width hex + with bit i = column i.""" + glyphs = {} + for i, entry in enumerate(table.split()): + width, rows = entry.split(':') + n = len(rows) // GLYPH_HEIGHT + glyphs[chr(0x20 + i)] = (int(width), tuple( + int(rows[r * n:(r + 1) * n], 16) for r in range(GLYPH_HEIGHT))) + assert len(glyphs) == 95 + return glyphs + + +# Generated from keepkey-firmware lib/board/font.c by this module's __main__. +TITLE_FONT = _font(""" +5:000000000000000000000000000000 3:000003003003003003000003000000 5:00000f00f000000000000000000000 8:00003607f03603607f036000000000 +7:00c03e00f00f01e03c03c01f00c000 9:0000c606f03601806c0f6063000000 8:00000e01b01b00e07b03307e000000 3:000003003000000000000000000000 +4:006003003003003003003003006000 4:003006006006006006006006003000 7:00c03f01e03f00c000000000000000 7:00000000c00c03f00c00c000000000 +4:000000000000000000007007006003 7:00000000000003f000000000000000 4:000000000000000000007007000000 9:0000c006003001800c006003000000 +7:00001e03303b03f03703301e000000 4:000007006006006006006006000000 7:00001f03003001e00300303f000000 7:00001f03003001e03003001f000000 +7:00001801c01e01b03f018018000000 7:00003f00300301f03003001f000000 7:00001e00300301f03303301e000000 7:00003f03001801800c00c006000000 +7:00001e03303301e03303301e000000 7:00001e03303303e03003001e000000 4:000000000007007000007007000000 4:000000000007007000007007006003 +6:00001800c00600300600c018000000 7:00000000003f00003f000000000000 6:00000300600c01800c006003000000 7:00001e03303001800c00000c000000 +9:07c0c61bb1e31fb1ef0fb00607c000 7:00001e03303303f033033033000000 7:00001f03303301f03303301f000000 7:00001e03300300300303301e000000 +7:00001f03303303303303301f000000 7:00003f00300301f00300303f000000 7:00003f00300301f003003003000000 7:00001e03300303b03303303e000000 +7:00003303303303f033033033000000 5:00000f00600600600600600f000000 7:00003003003003003003301e000000 7:00003301b00f00700f01b033000000 +7:00000300300300300300303f000000 9:0000c30e70ff0db0c30c30c3000000 7:00003303303703f03b033033000000 7:00001e03303303303303301e000000 +7:00001f03303303301f003003000000 7:00001e03303303303303301e030000 7:00001f03303303301f01b033000000 7:00001e03300301e03003301e000000 +7:00003f00c00c00c00c00c00c000000 7:00003303303303303303301e000000 7:00003303303303301e01e00c000000 9:0000db0db0db0db0db0db07e000000 +7:00003303301e00c01e033033000000 7:00003303303301e00c00c00c000000 7:00003f03001800c00600303f000000 5:00000f00300300300300300f000000 +9:00000300600c0180300600c0000000 5:00000f00c00c00c00c00c00f000000 5:00000600f000000000000000000000 7:00000000000000000000003f000000 +4:000003006000000000000000000000 7:00000000001e03003e03303e000000 7:00000300301f03303303301f000000 7:00000000003e00300300303e000000 +7:00003003003e03303303303e000000 7:00000000001e03303f00303e000000 6:00001c00601f006006006006000000 7:00000000003e03303303303e03001e +7:00000300301f033033033033000000 3:000003000003003003003003000000 4:000006000006006006006006006003 6:00000300301b00f00700f01b000000 +3:000003003003003003003003000000 9:00000000007f0db0db0db0db000000 7:00000000001f033033033033000000 7:00000000001e03303303301e000000 +7:00000000001f03303303301f003003 7:00000000003e03303303303e030030 6:00000000001f007003003003000000 7:00000000003e00301e03001f000000 +6:00000600601f00600600601c000000 7:00000000003303303303303e000000 7:00000000003303301e01e00c000000 9:0000000000db0db0db0db07e000000 +7:00000000003301e00c01e033000000 7:00000000003303303303303e03001e 7:00000000003f01800c00603f000000 6:00001c00600600300600601c000000 +3:000003003003003003003003000000 6:00000700c00c01800c00c007000000 7:00003e01f000000000000000000000 +""") + +BODY_FONT = _font(""" +4:00000000000000000000 2:00010101010100010000 4:00050500000000000000 7:00123f12123f12000000 +6:041e05050e14140f0400 8:00422512082452210000 7:000609090629112e0000 2:00010100000000000000 +3:02010101010101010200 3:01020202020202020100 6:04150e15040000000000 6:000004041f0404000000 +3:00000000000003030201 6:000000001f0000000000 3:00000000000003030000 8:00402010080402010000 +6:000e11191513110e0000 3:00030202020202020000 6:000f10100e01011f0000 6:000f10100e10100f0000 +6:00080c0a091f08080000 6:001f01010f10100f0000 6:000e01010f11110e0000 6:001f1008080404020000 +6:000e11110e11110e0000 6:000e11111e10100e0000 3:00000003030003030000 3:00000003030003030201 +5:00080402010204080000 6:0000001f001f00000000 5:00010204080402010000 6:000e1110080400040000 +8:3c4299a1b9a579023c00 6:000e11111f1111110000 6:000f11110f11110f0000 6:000e11010101110e0000 +6:000f11111111110f0000 6:001f01010f01011f0000 6:001f01010f0101010000 6:000e11011d11111e0000 +6:001111111f1111110000 4:00070202020202070000 6:001010101010110e0000 6:00110905030509110000 +6:000101010101011f0000 8:00416355494141410000 6:00111113151911110000 6:000e11111111110e0000 +6:000f1111110f01010000 6:000e11111111110e1000 6:000f1111110f09110000 6:000e11010e10110e0000 +6:001f0404040404040000 6:001111111111110e0000 6:00111111110a0a040000 8:00494949494949360000 +6:0011110a040a11110000 6:001111110a0404040000 6:001f10080402011f0000 4:00070101010101070000 +8:00010204081020400000 4:00070404040404070000 4:00020500000000000000 6:000000000000001f0000 +3:00010200000000000000 6:0000000e101e111e0000 6:0001010f1111110f0000 6:0000001e0101011e0000 +6:0010101e1111111e0000 6:0000000e111f011e0000 5:000c020f020202020000 6:0000001e1111111e100e +6:0001010f111111110000 2:00010001010101010000 3:00020002020202020201 5:00010109050305090000 +2:00010101010101010000 8:0000003f494949490000 6:0000000f111111110000 6:0000000e1111110e0000 +6:0000000f1111110f0101 6:0000001e1111111e1010 5:0000000d030101010000 6:0000001e010e100f0000 +5:0002020f0202020c0000 6:000000111111111e0000 6:00000011110a0a040000 8:00000049494949360000 +6:000000110a040a110000 6:000000111111111e100e 6:0000001f0804021f0000 5:000c02020102020c0000 +2:00010101010101010000 5:00030404080404030000 7:00404020201214080000 +""") + + +def wrap(text, font=BODY_FONT, width=BODY_WIDTH): + """The lines draw_string_walk() breaks `text` into.""" + lines, line, x = [], '', 0 + for i, c in enumerate(text): + if c == '\n': + lines.append(line) + line, x = '', 0 + continue + word = font[c][0] + if c == ' ': + for n in text[i + 1:]: + if n in ' \n': + break + word += font[n][0] + if x + word > width: + lines.append(line) + line, x = '', 0 + if x == 0 and c == ' ': + continue + line += c + x += font[c][0] + lines.append(line) + return lines + + +def _rows(layout): + rows = [0] * 64 + for x in range(256): + for band in range(8): + byte = layout[x + band * 256] + if not isinstance(byte, int): + byte = ord(byte) + for bit in range(8): + if byte >> bit & 1: + rows[band * 8 + bit] |= 1 << x + return rows + + +def _render(line, font): + rows, x = [0] * GLYPH_HEIGHT, 0 + for c in line: + width, glyph = font[c] + for r in range(GLYPH_HEIGHT): + rows[r] |= glyph[r] << x + x += width + return x, rows + + +def _at(rows, want, width, x, y, whole): + """`want` drawn at (x, y); if `whole`, with nothing lit within a glyph's + width on either side of it (a longer line is not this line).""" + mask = (1 << width) - 1 + if any((rows[y + r] >> x) & mask != want[r] for r in range(GLYPH_HEIGHT)): + return False + if not whole: + return True + before = min(x, 9) + edges = ((1 << before) - 1) << (x - before) | 0x1ff << (x + width) + return not any(rows[y + r] & edges for r in range(GLYPH_HEIGHT)) + + +def find_line(layout, line, font=BODY_FONT): + """(x, y) of the topmost rendering of `line` (a prefix of a longer line + counts), or None.""" + width, want = _render(line, font) + rows = _rows(layout) + for y in range(64 - GLYPH_HEIGHT + 1): + for x in range(256 - width + 1): + if _at(rows, want, width, x, y, False): + return x, y + return None + + +def shows(layout, text, font=BODY_FONT, width=BODY_WIDTH): + """Whether this screen shows `text` as confirm() draws a body.""" + lines = [_render(line, font) for line in wrap(text, font, width)] + rows = _rows(layout) + height = (len(lines) - 1) * BODY_LINE_PITCH + GLYPH_HEIGHT + for y in range(64 - height + 1): + for x in range(256 - lines[0][0] + 1): + if all(_at(rows, want, w, x, y + i * BODY_LINE_PITCH, True) + for i, (w, want) in enumerate(lines)): + return True + return False + + +def find_text(screens, text, font=BODY_FONT, width=BODY_WIDTH, start=0): + """Index of the first screen from `start` that shows `text`, or None.""" + for i in range(start, len(screens)): + if shows(screens[i], text, font, width): + return i + return None + + +def _generate(font_c): + src = open(font_c).read() + data = {m.group(1): [int(v, 16) for v in + re.findall(r'0x([0-9a-fA-F]{2})', m.group(2))] + for m in re.finditer( + r'static const uint8_t (image_data_\w+)\[[^\]]*\]\s*=\s*' + r'\{([^}]*)\}', src)} + images = {m.group(1): (m.group(2), int(m.group(3)), int(m.group(4))) + for m in re.finditer( + r'static const CharacterImage (\w+)\s*=\s*\{\s*(\w+),\s*' + r'(\d+),\s*(\d+)\s*\}', src)} + out = {} + for name in ('title_font', 'body_font'): + array = re.search(r'static const Character %s_array\[\]\s*=\s*\{(.*?)' + r'\n\};' % name, src, re.S).group(1) + glyphs = {} + for code, image in re.findall(r'\{\s*(0x[0-9a-fA-F]+)\s*,\s*&(\w+)\s*\}', + array): + if not 0x20 <= int(code, 16) <= 0x7e: + continue + pixels, width, height = images[image] + assert height == GLYPH_HEIGHT, image + ink = data[pixels] + assert len(ink) == width * height, image + glyphs[int(code, 16)] = (width, [ + sum(1 << x for x in range(width) if ink[y * width + x] == 0) + for y in range(height)]) + assert sorted(glyphs) == list(range(0x20, 0x7f)), name + digits = (max(w for w, _ in glyphs.values()) + 3) // 4 + entries = ['%d:%s' % (w, ''.join('%0*x' % (digits, r) for r in rows)) + for w, rows in (glyphs[c] for c in range(0x20, 0x7f))] + out[name] = '\n'.join(' '.join(entries[i:i + 4]) + for i in range(0, len(entries), 4)) + return out + + +if __name__ == '__main__': + tables = _generate(sys.argv[1]) + print('TITLE_FONT = _font("""\n%s\n""")\n' % tables['title_font']) + print('BODY_FONT = _font("""\n%s\n""")' % tables['body_font']) diff --git a/tests/test_msg_solana_schema_v2.py b/tests/test_msg_solana_schema_v2.py new file mode 100644 index 00000000..2154f160 --- /dev/null +++ b/tests/test_msg_solana_schema_v2.py @@ -0,0 +1,407 @@ +# This file is part of the KeepKey project. +# +# Copyright (C) 2026 KeepKey +# +# This library is free software: you can redistribute it and/or modify +# it under the terms of the GNU Lesser General Public License version 3 +# as published by the Free Software Foundation. + +"""KKSOLSC1 schema v2 reviews, asserted by what the screen says. + +Certified tier: the public Vault 501-scope certificate and its delegate's real +signature over the version 1 relayDepositNative schema (the fixture of +test_relay_certified_v0_no_lookup_proof_reaches_signer_check), applied to +messages built around this device's own key so the whole review runs. It needs +the alpha ClearSign root compiled in (KK_CLEARSIGN_ALPHA_ROOT); an emulator +without it refuses the certificate first, and those tests skip. + +Runtime tier: the CI signer, loaded into slots 2 and 3, signs the schema and +the token definitions. + +ClearsignAttestor: the extra screen a version 2 TOKEN_AMOUNT costs the +operator before anything is attested. + +Screens are read through oled_text: DebugLink returns pixels, not text, so the +expected text is rendered with the firmware's glyphs and found in the frame. +""" + +import hashlib +import struct +import unittest + +import common +from ecdsa import SECP256k1, SigningKey, VerifyingKey +from ecdsa.util import sigdecode_string, sigencode_string +from keepkeylib import messages_pb2 as proto +from keepkeylib import messages_solana_pb2 as solana +from keepkeylib.client import CallException +from keepkeylib import signed_metadata +from keepkeylib.tools import b58encode, parse_path +from oled_text import TITLE_FONT, find_line, find_text, shows +from test_msg_display_disclosure import ScreenRecorder + +PATH = parse_path("m/44'/501'/0'/0'") +ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz" + +RELAY_PROGRAM = bytes.fromhex( + "792689378ecd51d80406eb0caa3b62795beb10b6c5dc96bc2e0df03cbfee1abf") +RELAY_DISC = bytes.fromhex("0d9e0ddf5fd51c06") +RELAY_SCHEMA = bytes.fromhex( + "4b4b534f4c53433101792689378ecd51d80406eb0caa3b62795beb10b6c5dc96" + "bc2e0df03cbfee1abf080d9e0ddf5fd51c060c52656c6179204272696467650d" + "6465706f7369744e6174697665020506416d6f756e7404054f72646572010305" + "5661756c74") +RELAY_SCHEMA_SIG = bytes.fromhex( + "801b309d284ae89287a21a6acbd5c63f999515f3ff6bf71d72a256485321b892" + "7b7ebc3a26ace3df5551b85a68df8e9f1ef8eac220d85f4bfaa33d43b5349061") +CERT_501 = bytes.fromhex( + "0101000001f56c68c8804b6565704b6579205661756c74000000000000000000" + "000000000000000000000342f5f9704494b3f9bd72295eecaf29d783d23ea02" + "b2dc9f48abcd2e46d4850cfa2753fac6068a45747a32a4a39f249af72b55370f" + "3491913b7fb9a80207d619b3b4fca6750fc1fdc790da5562b42a351e12cde3c" + "0f084056a24ca8d1bf2c36b5") +SYSTEM = b"\0" * 32 +COMPUTE_BUDGET = bytes.fromhex( + "0306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a40000000") +DESTINATION = b"\x22" * 32 + +# SoltoshiDICE "Blackjack join", the real message and its version 2 schema +# (keepkey-firmware unittests/firmware/solana.cpp kSoltoshiJoinMessageHex and +# kSoltoshiJoinSchemaHex). Instructions: SetComputeUnitLimit, a System +# Transfer to the session key, the 82-byte join. Its three TOKEN_AMOUNTs read +# their mint from join account 3, message key 8: the SDICE mint. +SOLTOSHI_JOIN = bytes.fromhex( + "0100060dec3979a4dc6b401bd045171a189f26856fab9eab75560214f972b2ed" + "c164300f209892e406a5c1bf530d7721f4634090040c3fbe35df3834a2e80d97" + "bee0620e635230d0ec6d2689ed9f0bf1da57e147ac13babc4430c5af1ade2e40" + "c9cf3ee577e4b4511f351e94a764bde876019ec3523510049d3b50b3a8c70fd3" + "8b6007f9847d3c28e2cbbff9e7c4f7cb6d6d71e5bc16d50aa6ed4ffe3aeae6a0" + "d6c6eaa4a11b0a513ec5310074c9de56117aad169ab339ec9a544b3c4cf33a74" + "d0cc37c6fc4da258d76a62aa6a0c641d34cefc33c97f0b9424c1678e26ee25b4" + "c49b7bda00000000000000000000000000000000000000000000000000000000" + "0000000038278241da03c70dd0fc885f7ad2123bad32b33a5402340739af8ae5" + "d84cacef8b673cda2e293e0220ab3e01d2b58ed055c9c1b2762dd515612b10ca" + "cd6e34360306466fe5211732ffecadba72c39be7bc8ce5bbc5f7126b2c439b3a" + "40000000b0e08af4a4fcfad13ef8fcfd9dc70975eb6fc2e04a7c76611540a51c" + "d5db9ed006ddf6e1ee758fde18425dbce46ccddab61afc4d83b90d27febdf928" + "d8a18bfcf9adfb23cba734d5c630dc94ffe9bc6964e347bd3af8c3afb795b849" + "cab5d927030a000502400d0300070200050c0200000080841e00000000000b09" + "0002040803010c060952515600000000000000d4030000000000000100ca9a3b" + "00000000a11b0a513ec5310074c9de56117aad169ab339ec9a544b3c4cf33a74" + "d0cc37c6100e00000000000000ca9a3b0000000000ca9a3b00000000") +SOLTOSHI_SCHEMA = bytes.fromhex( + "4b4b534f4c53433102b0e08af4a4fcfad13ef8fcfd9dc70975eb6fc2e04a7c7661" + "1540a51cd5db9ed001510c536f6c746f736869444943450e426c61636b6a61636b" + "206a6f696e080105526f756e6401085265766973696f6e02045365617406064275" + "792d696e03030b53657373696f6e206b6579070a4578706972657320696e060941" + "6c6c6f77616e63650306094d61782077616765720300") +SDICE_MINT = "4nCmpwne7hCoWTSpAd54uENmCgHJrHTyn4DMPCEMpump" +TOKEN_AMOUNT_LABELS = ("BUY-IN", "ALLOWANCE", "MAX WAGER") + + +def sign(preimage): + """The CI signer (slot 3's key) over sha256(preimage).""" + key = SigningKey.from_string(signed_metadata.TEST_PRIVATE_KEY, + curve=SECP256k1) + return key.sign_digest_deterministic(hashlib.sha256(preimage).digest(), + hashfunc=hashlib.sha256, + sigencode=sigencode_string) + + +def price(micro_lamports): + return (4, [], bytes([3]) + struct.pack("