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("