keylet 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
keylet-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,15 @@
1
+ Metadata-Version: 2.3
2
+ Name: keylet
3
+ Version: 0.1.0
4
+ Summary: Client application Tillitis TKey ML-DSA signer
5
+ Author: Jussi Kukkonen
6
+ Author-email: Jussi Kukkonen <jkukkonen@google.com>
7
+ Requires-Dist: cryptography>=48.0.0
8
+ Requires-Dist: pyserial>=3.5
9
+ Requires-Python: >=3.10
10
+ Description-Content-Type: text/markdown
11
+
12
+ ## Keylet -- Client library for Tillitis TKey
13
+
14
+ This library provides a client implementation for the Tillitis TKey, and specifically
15
+ the [tkey-pq-device-signer](https://github.com/tillitis/tkey-pq-device-signer) application.
keylet-0.1.0/README.md ADDED
@@ -0,0 +1,4 @@
1
+ ## Keylet -- Client library for Tillitis TKey
2
+
3
+ This library provides a client implementation for the Tillitis TKey, and specifically
4
+ the [tkey-pq-device-signer](https://github.com/tillitis/tkey-pq-device-signer) application.
@@ -0,0 +1,64 @@
1
+ [project]
2
+ name = "keylet"
3
+ version = "0.1.0"
4
+ description = "Client application Tillitis TKey ML-DSA signer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jussi Kukkonen", email = "jkukkonen@google.com" }
8
+ ]
9
+ requires-python = ">=3.10"
10
+ dependencies = [
11
+ "cryptography>=48.0.0",
12
+ "pyserial>=3.5",
13
+ ]
14
+
15
+ [project.scripts]
16
+ keylet = "keylet.bin.cli:main"
17
+
18
+
19
+ [dependency-groups]
20
+ dev = [
21
+ "mypy>=1.10.0",
22
+ "pytest>=8.0.0",
23
+ "pytest-cov>=4.1.0",
24
+ "ruff>=0.3.0",
25
+ "types-pyserial>=3.5.0.20240310",
26
+ "zizmor>=0.8.0",
27
+ "zensical>=0.0.46",
28
+ "mkdocstrings[python]>=1.0.4",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.23,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.ruff]
36
+ line-length = 88
37
+ target-version = "py310"
38
+
39
+ [tool.ruff.lint]
40
+ select = [
41
+ "E", # pycodestyle errors
42
+ "W", # pycodestyle warnings
43
+ "F", # pyflakes
44
+ "I", # isort
45
+ "B", # flake8-bugbear
46
+ "C4", # flake8-comprehensions
47
+ "UP", # pyupgrade
48
+ "RUF", # Ruff-specific rules
49
+ ]
50
+ ignore = []
51
+
52
+ [tool.mypy]
53
+ python_version = "3.10"
54
+ strict = true
55
+ warn_unreachable = true
56
+
57
+ [[tool.mypy.overrides]]
58
+ module = "serial.*"
59
+ ignore_missing_imports = true
60
+
61
+ [tool.pytest.ini_options]
62
+ markers = [
63
+ "device: tests requiring a physical TKey device running a no-touch binary",
64
+ ]
@@ -0,0 +1,5 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ # ruff: noqa: F401
5
+ from keylet.tkey_sign import SignApp, TKeySign
@@ -0,0 +1,147 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ import os
5
+ import select
6
+ import sys
7
+
8
+ if sys.platform == "linux":
9
+ import array
10
+ import fcntl
11
+ import termios
12
+
13
+ from typing import Protocol
14
+
15
+
16
+ class SerialConnection(Protocol):
17
+ timeout: float
18
+
19
+ def read(self, n: int) -> bytes: ...
20
+ def write(self, data: bytes) -> int: ...
21
+ def close(self) -> None: ...
22
+
23
+ @property
24
+ def in_waiting(self) -> int: ...
25
+
26
+
27
+ class RawSerialConnection:
28
+ """A Linux specific raw Python serial connection.
29
+
30
+ This helper exists because pyserial does not work with glibc >=2.42:
31
+ https://github.com/pyserial/pyserial/commit/70d18864 is the missing bug fix.
32
+ """
33
+
34
+ def __init__(self, port: str, baudrate: int, timeout: float) -> None:
35
+ self.timeout = timeout
36
+ self._fd: int | None = self._open(port, baudrate)
37
+
38
+ def _open(self, port: str, baudrate: int) -> int:
39
+ fd = os.open(port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK)
40
+ try:
41
+ # 1. Use termios to configure raw 8N1 mode
42
+ attrs = termios.tcgetattr(fd)
43
+
44
+ # Clear input processing
45
+ attrs[0] &= ~(
46
+ termios.IGNBRK
47
+ | termios.BRKINT
48
+ | termios.PARMRK
49
+ | termios.ISTRIP
50
+ | termios.INLCR
51
+ | termios.IGNCR
52
+ | termios.ICRNL
53
+ | termios.IXON
54
+ | termios.IXOFF
55
+ | termios.IXANY
56
+ | termios.INPCK
57
+ )
58
+ # Clear output processing (raw output)
59
+ attrs[1] &= ~termios.OPOST
60
+ # Clear local modes (no echo, no signals, no canonical input)
61
+ attrs[3] &= ~(
62
+ termios.ECHO
63
+ | termios.ECHONL
64
+ | termios.ICANON
65
+ | termios.ISIG
66
+ | termios.IEXTEN
67
+ )
68
+ # Clear control modes (no size, parity, stop bits, flow control)
69
+ attrs[2] &= ~(
70
+ termios.CSIZE | termios.PARENB | termios.CSTOPB | termios.CRTSCTS
71
+ )
72
+ attrs[2] |= termios.CS8 | termios.CREAD | termios.CLOCAL
73
+
74
+ # Set speed using standard constants (this is changed below)
75
+ attrs[4] = termios.B9600
76
+ attrs[5] = termios.B9600
77
+
78
+ termios.tcsetattr(fd, termios.TCSANOW, attrs)
79
+
80
+ # 2. Use termios2 to set the custom 62500 baud rate
81
+ tcgets2 = 0x802C542A
82
+ tcsets2 = 0x402C542B
83
+ bother = 0o010000
84
+
85
+ buf = array.array("i", [0] * 64)
86
+ fcntl.ioctl(fd, tcgets2, buf)
87
+
88
+ buf[2] &= ~0x100F # Clear CBAUD/CBAUDEX speed flags
89
+ buf[2] |= bother # Flag for custom speed (BOTHER)
90
+ buf[9] = buf[10] = baudrate # Set custom speed
91
+
92
+ fcntl.ioctl(fd, tcsets2, buf)
93
+
94
+ # 3. Restore blocking mode
95
+ flags = fcntl.fcntl(fd, fcntl.F_GETFL)
96
+ fcntl.fcntl(fd, fcntl.F_SETFL, flags & ~os.O_NONBLOCK)
97
+
98
+ # 4. Acquire exclusive access
99
+ tiocexcl = 0x540C
100
+ fcntl.ioctl(fd, tiocexcl, 0)
101
+
102
+ return fd
103
+ except Exception:
104
+ os.close(fd)
105
+ raise
106
+
107
+ def write(self, data: bytes) -> int:
108
+ if self._fd is None:
109
+ raise ValueError("Port is closed")
110
+ return os.write(self._fd, data)
111
+
112
+ def read(self, n: int) -> bytes:
113
+ """Read exactly n bytes blockingly, respecting the configured timeout."""
114
+ if self._fd is None:
115
+ raise ValueError("Port is closed")
116
+ data = bytearray()
117
+ while len(data) < n:
118
+ r, _, _ = select.select([self._fd], [], [], self.timeout)
119
+ if not r:
120
+ break # Timeout
121
+ chunk = os.read(self._fd, n - len(data))
122
+ if len(chunk) == 0:
123
+ break # EOF/Disconnect
124
+ data.extend(chunk)
125
+ return bytes(data)
126
+
127
+ def reset_input_buffer(self) -> None:
128
+ pass
129
+
130
+ def reset_output_buffer(self) -> None:
131
+ pass
132
+
133
+ @property
134
+ def in_waiting(self) -> int:
135
+ if self._fd is None:
136
+ return 0
137
+ buf = array.array("i", [0])
138
+ try:
139
+ fcntl.ioctl(self._fd, termios.FIONREAD, buf)
140
+ return buf[0]
141
+ except Exception:
142
+ return 0
143
+
144
+ def close(self) -> None:
145
+ if self._fd is not None:
146
+ os.close(self._fd)
147
+ self._fd = None
@@ -0,0 +1,139 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ import argparse
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from cryptography.exceptions import InvalidSignature
9
+ from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey
10
+
11
+ from keylet.tkey import TKeyNotFoundError
12
+ from keylet.tkey_sign import SignApp, TKeySign
13
+
14
+
15
+ def get_signer(device: str | None, passphrase: str | None) -> TKeySign:
16
+ """Helper to initialize TKeySign with the default device signer binary."""
17
+ app = SignApp.load_mldsa()
18
+ return TKeySign(app, device=device, secret=passphrase)
19
+
20
+
21
+ def cmd_pubkey(args: argparse.Namespace) -> int:
22
+ try:
23
+ with get_signer(args.device, args.passphrase) as signer:
24
+ pubkey = signer.get_pubkey()
25
+ if args.output:
26
+ Path(args.output).write_bytes(pubkey)
27
+ print(f"Public key written to {args.output}")
28
+ else:
29
+ print(pubkey.hex())
30
+ return 0
31
+ except TKeyNotFoundError as e:
32
+ print(f"Error: {e}", file=sys.stderr)
33
+ return 1
34
+
35
+
36
+ def cmd_sign(args: argparse.Namespace) -> int:
37
+ file_path = Path(args.file)
38
+ if not file_path.exists():
39
+ print(f"Error: File {args.file} does not exist", file=sys.stderr)
40
+ return 1
41
+
42
+ try:
43
+ data = file_path.read_bytes()
44
+ with get_signer(args.device, args.passphrase) as signer:
45
+ print("Please touch the TKey device when it flashes to sign...")
46
+ signature = signer.sign(data)
47
+
48
+ sig_path = file_path.with_suffix(file_path.suffix + ".signature")
49
+ sig_path.write_bytes(signature)
50
+ print(f"Signature written to {sig_path}")
51
+ return 0
52
+ except TKeyNotFoundError as e:
53
+ print(f"Signing failed: {e}", file=sys.stderr)
54
+ return 1
55
+
56
+
57
+ def cmd_verify(args: argparse.Namespace) -> int:
58
+ file_path = Path(args.file)
59
+ if not file_path.exists():
60
+ print(f"Error: File {args.file} does not exist", file=sys.stderr)
61
+ return 1
62
+
63
+ sig_path = (
64
+ Path(args.signature)
65
+ if args.signature
66
+ else file_path.with_suffix(file_path.suffix + ".signature")
67
+ )
68
+ if not sig_path.exists():
69
+ print(f"Error: Signature file {sig_path} does not exist", file=sys.stderr)
70
+ return 1
71
+
72
+ try:
73
+ file_bytes = file_path.read_bytes()
74
+ sig_bytes = sig_path.read_bytes()
75
+
76
+ # Get public key either from file or from device
77
+ if args.pubkey:
78
+ pubkey_bytes = Path(args.pubkey).read_bytes()
79
+ else:
80
+ print("Retrieving public key from device...")
81
+ with get_signer(args.device, args.passphrase) as signer:
82
+ pubkey_bytes = signer.get_pubkey()
83
+
84
+ # Verify signature using cryptography library
85
+ pubkey = MLDSA44PublicKey.from_public_bytes(pubkey_bytes)
86
+ pubkey.verify(sig_bytes, file_bytes)
87
+ print("Verification successful!")
88
+ return 0
89
+ except InvalidSignature:
90
+ print("Verification failed: Invalid signature", file=sys.stderr)
91
+ return 1
92
+ except TKeyNotFoundError as e:
93
+ print(f"Verification failed: {e}", file=sys.stderr)
94
+ return 1
95
+
96
+
97
+ def main() -> None:
98
+ parser = argparse.ArgumentParser(
99
+ description="Tillitis TKey Keylet CLI testing tool"
100
+ )
101
+ parser.add_argument(
102
+ "--device", help="Serial port of the TKey device (e.g. /dev/ttyACM0)"
103
+ )
104
+ parser.add_argument("--passphrase", help="User Supplied Secret (passphrase)")
105
+
106
+ subparsers = parser.add_subparsers(dest="command", required=True)
107
+
108
+ # pubkey command
109
+ parser_pubkey = subparsers.add_parser("pubkey", help="Get public key from device")
110
+ parser_pubkey.add_argument(
111
+ "-o", "--output", help="Output file to write public key to"
112
+ )
113
+
114
+ # sign command
115
+ parser_sign = subparsers.add_parser("sign", help="Sign a file")
116
+ parser_sign.add_argument("file", help="File to sign")
117
+
118
+ # verify command
119
+ parser_verify = subparsers.add_parser("verify", help="Verify a signature")
120
+ parser_verify.add_argument("file", help="File that was signed")
121
+ parser_verify.add_argument(
122
+ "--signature", help="Signature file (defaults to <FILE>.signature)"
123
+ )
124
+ parser_verify.add_argument(
125
+ "--pubkey", help="Public key file (retrieved from device if not specified)"
126
+ )
127
+
128
+ args = parser.parse_args()
129
+
130
+ if args.command == "pubkey":
131
+ sys.exit(cmd_pubkey(args))
132
+ elif args.command == "sign":
133
+ sys.exit(cmd_sign(args))
134
+ elif args.command == "verify":
135
+ sys.exit(cmd_verify(args))
136
+
137
+
138
+ if __name__ == "__main__":
139
+ main()
File without changes
@@ -0,0 +1 @@
1
+ # Package marker for resource files
@@ -0,0 +1,421 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import logging
8
+ import sys
9
+ from dataclasses import dataclass
10
+ from types import TracebackType
11
+ from typing import TypeVar
12
+
13
+ import serial
14
+ from serial.tools import list_ports
15
+
16
+ from keylet._serial_hack import (
17
+ RawSerialConnection,
18
+ SerialConnection,
19
+ )
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # USB Vendor & Product ID for TKey
24
+ TKEY_USB_VID = 0x1207
25
+ TKEY_USB_PID = 0x8887
26
+
27
+ # Maximum size for applications to load onto TKey (100 KiB)
28
+ APP_MAXSIZE = 100 * 1024
29
+
30
+
31
+ # Data lengths corresponding to header length bits (0, 1, 2, 3)
32
+ PROTO_DATA_LENGTH = [1, 4, 32, 128]
33
+
34
+
35
+ # Length indices mapping to PROTO_DATA_LENGTH
36
+ class LenIdx:
37
+ I1 = 0
38
+ """1-byte payload length index."""
39
+ I4 = 1
40
+ """4-byte payload length index."""
41
+ I32 = 2
42
+ """32-byte payload length index."""
43
+ I128 = 3
44
+ """128-byte payload length index."""
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Rsp:
49
+ """Protocol response definition.
50
+
51
+ Attributes:
52
+ id: The response identifier byte.
53
+ len_idx: The length index indicating the expected data size.
54
+ """
55
+
56
+ id: int
57
+ len_idx: int
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Cmd:
62
+ """Protocol command definition.
63
+
64
+ Attributes:
65
+ id: The command identifier byte.
66
+ endpoint: The target endpoint on the device.
67
+ len_idx: The length index indicating the payload data size.
68
+ valid_responses: A tuple of acceptable responses for this command.
69
+ """
70
+
71
+ id: int
72
+ endpoint: int
73
+ len_idx: int
74
+ valid_responses: tuple[Rsp, ...]
75
+
76
+
77
+ class FwRsp:
78
+ """Firmware responses"""
79
+
80
+ NAME_VERSION = Rsp(0x02, LenIdx.I32)
81
+ """Response containing firmware name and version."""
82
+ LOAD_APP = Rsp(0x04, LenIdx.I4)
83
+ """Response indicating the application loading status."""
84
+ LOAD_APP_DATA = Rsp(0x06, LenIdx.I4)
85
+ """Response indicating the application data chunk status."""
86
+ LOAD_APP_DATA_READY = Rsp(0x07, LenIdx.I128)
87
+ """Response indicating all application data has been received and verified."""
88
+
89
+
90
+ class FwCmd:
91
+ """Firmware commands"""
92
+
93
+ NAME_VERSION = Cmd(0x01, 2, LenIdx.I1, (FwRsp.NAME_VERSION,))
94
+ """Command to query the firmware name and version."""
95
+ LOAD_APP = Cmd(0x03, 2, LenIdx.I128, (FwRsp.LOAD_APP,))
96
+ """Command to initiate application loading with size and optional secret."""
97
+ LOAD_APP_DATA = Cmd(
98
+ 0x05, 2, LenIdx.I128, (FwRsp.LOAD_APP_DATA, FwRsp.LOAD_APP_DATA_READY)
99
+ )
100
+ """Command to send a chunk of application binary data."""
101
+
102
+
103
+ _TKey = TypeVar("_TKey", bound="TKey")
104
+
105
+
106
+ class TKeyError(Exception):
107
+ """Base class for TKey errors."""
108
+
109
+
110
+ class TKeyNotFoundError(TKeyError):
111
+ """A TKey device was not found"""
112
+
113
+
114
+ class TKeyAppError(TKeyError):
115
+ """Raised when loading the application fails."""
116
+
117
+
118
+ class TKeyIOError(TKeyError):
119
+ """Raised when read/write fails."""
120
+
121
+
122
+ class TKeyProtocolError(TKeyError):
123
+ """Raised upon protocol errors in command or response."""
124
+
125
+
126
+ class TKey:
127
+ """Base TKey Client
128
+
129
+ TKey is used to build host (client) applications for a Tillitis TKey.
130
+ It implements the Firmware protocol and provides serial IO as well
131
+ as some helpers for the actual application implementation.
132
+
133
+ Links:
134
+
135
+ * [Framing protocol](https://dev.tillitis.se/protocol/#framing-protocol)
136
+ """
137
+
138
+ def __init__(
139
+ self,
140
+ device: str | None,
141
+ ) -> None:
142
+ """Initialize serial connection to TKey device.
143
+
144
+ Args:
145
+ device: Optional serial port path (e.g., `/dev/ttyACM0`). If None,
146
+ the port is auto-detected.
147
+
148
+ Raises:
149
+ TKeyNotFoundError: If no TKey device is found.
150
+ TKeyError: If the serial connection fails to open.
151
+ """
152
+
153
+ self._conn: SerialConnection | None = None
154
+ self._fid = 0
155
+
156
+ port = self._find_device(device)
157
+ self._conn = self._get_connection(port, baudrate=62500, timeout=5.0)
158
+
159
+ @staticmethod
160
+ def _find_device(device_path: str | None) -> str:
161
+ """Discover TKey device serial port using pyserial."""
162
+
163
+ ports = list_ports.comports()
164
+ devices = sorted(
165
+ p.device for p in ports if p.vid == TKEY_USB_VID and p.pid == TKEY_USB_PID
166
+ )
167
+
168
+ if device_path is None:
169
+ if not devices:
170
+ raise TKeyNotFoundError("No TKey devices found")
171
+ device_path = devices[0]
172
+ elif device_path not in devices:
173
+ raise TKeyNotFoundError(f"TKey device {device_path} not found")
174
+ return device_path
175
+
176
+ def _get_connection(
177
+ self, port: str, baudrate: int, timeout: float
178
+ ) -> SerialConnection:
179
+ if sys.platform == "linux":
180
+ return RawSerialConnection(port, baudrate, timeout)
181
+ else:
182
+ try:
183
+ return serial.Serial(port, baudrate=baudrate, timeout=timeout)
184
+ except Exception as e:
185
+ raise TKeyError(f"Failed to open serial port {port}: {e}") from e
186
+
187
+ def disconnect(self) -> None:
188
+ if self._conn is not None:
189
+ try:
190
+ self._conn.close()
191
+ except Exception as e:
192
+ logger.debug("Failed to close TKey connection: %s", e)
193
+ self._conn = None
194
+
195
+ def __del__(self) -> None:
196
+ self.disconnect()
197
+
198
+ def __enter__(self: _TKey) -> _TKey:
199
+ return self
200
+
201
+ def __exit__(
202
+ self,
203
+ exc_type: type[BaseException] | None,
204
+ exc_val: BaseException | None,
205
+ exc_tb: TracebackType | None,
206
+ ) -> None:
207
+ self.disconnect()
208
+
209
+ def _next_fid(self) -> int:
210
+ """Returns a frame id (rotating sequence [0-3])"""
211
+ self._fid = (self._fid + 1) % 4
212
+ return self._fid
213
+
214
+ def send(self, cmd: Cmd, data: bytes = b"", timeout: int = -1) -> bytes:
215
+ """Frame and send a command, then read the first response frame.
216
+
217
+ Caller is expected to call recv_response() if the response is longer than one
218
+ frame.
219
+
220
+ Args:
221
+ cmd: The command to send.
222
+ data: Optional payload data for the command.
223
+ timeout: Optional serial timeout in seconds. If -1, the default
224
+ connection timeout is used.
225
+
226
+ Returns:
227
+ The first response frame as bytes.
228
+
229
+ Raises:
230
+ TKeyError: If the TKey is not connected.
231
+ TKeyProtocolError: If the data exceeds the command's maximum length.
232
+ TKeyIOError: If writing the frame fails.
233
+ """
234
+ if self._conn is None:
235
+ raise TKeyError("TKey is not connected")
236
+
237
+ old_timeout = self._conn.timeout
238
+ if timeout >= 0:
239
+ self._conn.timeout = timeout
240
+ try:
241
+ return self._send(cmd, data)
242
+ finally:
243
+ if timeout >= 0:
244
+ self._conn.timeout = old_timeout
245
+
246
+ def _send(self, cmd: Cmd, data: bytes = b"") -> bytes:
247
+ if self._conn is None:
248
+ raise TKeyError("TKey is not connected")
249
+
250
+ fid = self._next_fid()
251
+
252
+ expected_len = PROTO_DATA_LENGTH[cmd.len_idx]
253
+ if len(data) > expected_len - 1:
254
+ raise TKeyProtocolError("Data exceeds command data length in header")
255
+
256
+ header = (fid << 5) | (cmd.endpoint << 3) | cmd.len_idx
257
+ frame = bytearray(1 + expected_len)
258
+ frame[0] = header
259
+ frame[1] = cmd.id
260
+ if data:
261
+ frame[2 : 2 + len(data)] = data
262
+
263
+ try:
264
+ self._conn.write(bytes(frame))
265
+ except Exception as e:
266
+ raise TKeyIOError(f"Failed to write frame: {e}") from e
267
+
268
+ return self.recv_response(cmd)
269
+
270
+ def recv_response(self, cmd: Cmd) -> bytes:
271
+ """Receive and return a response frame.
272
+
273
+ `recv_response()` can only be called if there are unread frames from a previous
274
+ `send()` call for the given command.
275
+
276
+ Args:
277
+ cmd: The command that this response is for.
278
+
279
+ Returns:
280
+ The response frame as bytes.
281
+
282
+ Raises:
283
+ TKeyError: If the TKey is not connected.
284
+ TKeyIOError: If reading the response from the serial port fails
285
+ or returns no data.
286
+ TKeyProtocolError: If the response status is not OK, the response
287
+ length is invalid, the frame ID/endpoint mismatch, or the
288
+ response ID is unexpected.
289
+ """
290
+ if self._conn is None:
291
+ raise TKeyError("TKey is not connected")
292
+
293
+ try:
294
+ resp_header_byte = self._conn.read(1)
295
+ except Exception as e:
296
+ raise TKeyIOError(f"Failed to read response header: {e}") from e
297
+
298
+ if not resp_header_byte:
299
+ raise TKeyIOError("No response data")
300
+
301
+ header_val = resp_header_byte[0]
302
+ resp_fid = (header_val >> 5) & 3
303
+ resp_eid = (header_val >> 3) & 3
304
+ resp_status = (header_val >> 2) & 1
305
+ resp_len_idx = header_val & 3
306
+ resp_len = PROTO_DATA_LENGTH[resp_len_idx]
307
+
308
+ if resp_status == 1:
309
+ try:
310
+ self._conn.read(resp_len)
311
+ except Exception as e:
312
+ logger.debug("Failed to read remaining bytes after NOK status: %s", e)
313
+ raise TKeyProtocolError("Response status code not OK (1)")
314
+
315
+ try:
316
+ resp_data = self._conn.read(resp_len)
317
+ except Exception as e:
318
+ raise TKeyIOError(f"Failed to read response data: {e}") from e
319
+
320
+ if len(resp_data) != resp_len:
321
+ raise TKeyProtocolError("Unexpected response data length")
322
+
323
+ # Validate frame ID and endpoint
324
+ if resp_fid != self._fid or resp_eid != cmd.endpoint:
325
+ raise TKeyProtocolError(
326
+ f"Response mismatch: expected Frame ID {self._fid} and Endpoint "
327
+ f"{cmd.endpoint}, got Frame ID {resp_fid} and Endpoint {resp_eid}"
328
+ )
329
+
330
+ rsp = Rsp(resp_data[0], resp_len_idx)
331
+ if rsp not in cmd.valid_responses:
332
+ raise TKeyProtocolError(
333
+ f"Unexpected protocol response for cmd {cmd.id:#x} on endpoint "
334
+ f"{cmd.endpoint}: response={rsp.id:#x}, len_index={rsp.len_idx}"
335
+ )
336
+
337
+ response = bytearray(1 + resp_len)
338
+ response[0] = header_val
339
+ response[1:] = resp_data
340
+ return bytes(response)
341
+
342
+ def load_app(self, app_binary: bytes, secret: str | None = None) -> bool:
343
+ """Load an application binary into the TKey device.
344
+
345
+ Args:
346
+ app_binary: The raw bytes of the application binary.
347
+ secret: Optional User Supplied Secret (passphrase) to configure
348
+ the app with.
349
+
350
+ Returns:
351
+ True if the application was successfully loaded, False if the
352
+ device was not in firmware mode (i.e. already running an application).
353
+
354
+ Raises:
355
+ TKeyAppError: If the binary is too large, the device is not ready,
356
+ or the loaded app's digest does not match the local digest.
357
+ TKeyError: If the device is running an unknown firmware or if loading
358
+ data fails.
359
+ """
360
+ file_size = len(app_binary)
361
+ if file_size > APP_MAXSIZE:
362
+ raise TKeyAppError(
363
+ f"Application binary is too large ({file_size} > {APP_MAXSIZE})"
364
+ )
365
+
366
+ try:
367
+ # Query firmware name
368
+ rx = self.send(FwCmd.NAME_VERSION)
369
+ except TKeyError:
370
+ # Not in firmware mode
371
+ # TODO would be nice to only do this on NOK response, not other errors
372
+ return False
373
+
374
+ # we are in firmware mode. Load the app
375
+ fw_name0 = rx[2:6].decode("ascii").rstrip()
376
+ fw_name1 = rx[6:10].decode("ascii").rstrip()
377
+ if fw_name0 != "tk1" or fw_name1 != "mkdf":
378
+ raise TKeyError(f"TKey is running an unknown firmware {fw_name0, fw_name1}")
379
+
380
+ file_digest = hashlib.blake2s(app_binary, digest_size=32).digest()
381
+
382
+ data = bytearray(127)
383
+ data[0:4] = file_size.to_bytes(4, byteorder="little")
384
+ if secret is not None:
385
+ data[4] = 1
386
+ uss = hashlib.blake2s(secret.encode("utf-8"), digest_size=32)
387
+ data[5 : 5 + 32] = uss.digest()
388
+
389
+ response = self.send(FwCmd.LOAD_APP, bytes(data))
390
+ if response[2] == 1:
391
+ raise TKeyAppError("Device not ready (STATUS_BAD)")
392
+
393
+ result_digest = self._load_app_data(app_binary)
394
+ if file_digest != result_digest:
395
+ raise TKeyAppError(
396
+ "App digest does not match "
397
+ f"({file_digest.hex()} != {result_digest.hex()})"
398
+ )
399
+
400
+ if self._conn and self._conn.in_waiting:
401
+ self._conn.read(self._conn.in_waiting)
402
+
403
+ return True
404
+
405
+ def _load_app_data(self, file_data: bytes) -> bytes:
406
+ digest = b""
407
+ offset = 0
408
+ while offset < len(file_data):
409
+ chunk = file_data[offset : offset + 127]
410
+ response = self.send(FwCmd.LOAD_APP_DATA, chunk)
411
+ response_id = response[1]
412
+ status = response[2]
413
+ if status == 1:
414
+ raise TKeyError("Bad status when writing app data")
415
+
416
+ if response_id == FwRsp.LOAD_APP_DATA_READY.id:
417
+ digest = response[3:35]
418
+
419
+ offset += 127
420
+
421
+ return digest
@@ -0,0 +1,271 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ """Keylet TKey signer implementation"""
5
+
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import importlib.resources
10
+ import logging
11
+ from dataclasses import dataclass
12
+
13
+ from keylet.tkey import Cmd, LenIdx, Rsp, TKey, TKeyAppError, TKeyError
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ MU_SIZE = (64).to_bytes(4, byteorder="little")
18
+
19
+
20
+ # Static registry of all signer binaries packaged in keylet.resources.
21
+ # First binary in the list is the default binary.
22
+ # Format: (filename, version, name)
23
+ _EMBEDDED_MLDSA_BINS: list[tuple[str, int, tuple[str, str]]] = [
24
+ ("pqsigner_v3.bin", 3, ("tk1", "pqsn")),
25
+ ]
26
+
27
+
28
+ @dataclass
29
+ class SignApp:
30
+ """Configuration and binary data for the TKey device signer application.
31
+
32
+ Attributes:
33
+ binary: The raw bytes of the device application binary.
34
+ version: The version number of the device application.
35
+ name: A tuple representing the expected firmware name and app name
36
+ on the device (defaults to `("tk1", "pqsn")`).
37
+ sig_size: The size of the generated signature in bytes (defaults to 2420).
38
+ key_size: The size of the public key in bytes (defaults to 1312).
39
+ """
40
+
41
+ binary: bytes
42
+ version: int
43
+ name: tuple[str, str] = ("tk1", "pqsn")
44
+ sig_size: int = 2420
45
+ key_size: int = 1312
46
+
47
+ @property
48
+ def digest(self) -> str:
49
+ """Return the BLAKE2s-256 hex digest of the application binary."""
50
+ return hashlib.blake2s(self.binary, digest_size=32).hexdigest()
51
+
52
+ @classmethod
53
+ def load_mldsa(
54
+ cls, version: int | None = None, digest: str | None = None
55
+ ) -> SignApp:
56
+ """Load a ML-DSA signer application from package resources.
57
+
58
+ If a digest (or prefix) is provided, it returns the binary matching the
59
+ digest. If a version is provided, it filters by version. If neither is
60
+ provided, current default binary is loaded.
61
+
62
+ TKey key derivation depends on the application binary, so users who want a
63
+ specific key must provide the binary digest.
64
+
65
+ Args:
66
+ version: The version of the signer application to load.
67
+ digest: A BLAKE2s-256 hex digest (or prefix) of the target binary.
68
+
69
+ Returns:
70
+ An instance of SignApp configured with the loaded binary.
71
+
72
+ Raises:
73
+ ValueError: If no binary matches the criteria, or if the search
74
+ is ambiguous (matches multiple binaries).
75
+ """
76
+ resources_dir = importlib.resources.files("keylet.resources")
77
+ matches = []
78
+
79
+ # Scan registered binaries
80
+ for filename, file_ver, name in _EMBEDDED_MLDSA_BINS:
81
+ # Filter by version if requested
82
+ if version is not None and file_ver != version:
83
+ continue
84
+
85
+ binary = resources_dir.joinpath(filename).read_bytes()
86
+ file_digest = hashlib.blake2s(binary, digest_size=32).hexdigest()
87
+
88
+ # Filter by digest if requested
89
+ if digest is not None and not file_digest.startswith(digest.lower()):
90
+ continue
91
+
92
+ matches.append((binary, file_ver, name, filename))
93
+
94
+ if digest is None and version is None:
95
+ # First binary is the default one
96
+ break
97
+
98
+ if not matches:
99
+ raise ValueError(
100
+ f"No device binary found matching: version={version}, digest={digest}"
101
+ )
102
+
103
+ if len(matches) > 1:
104
+ raise ValueError(
105
+ f"Multiple device binaries found matching: version={version}, "
106
+ f"digest={digest}."
107
+ )
108
+
109
+ matched_binary, matched_version, matched_name, _ = matches[0]
110
+ return cls(matched_binary, matched_version, matched_name)
111
+
112
+
113
+ class SignRsp:
114
+ """Application responses"""
115
+
116
+ GET_PUBKEY = Rsp(0x02, LenIdx.I128)
117
+ SET_SIZE = Rsp(0x04, LenIdx.I4)
118
+ LOAD_DATA = Rsp(0x06, LenIdx.I4)
119
+ GET_SIG = Rsp(0x08, LenIdx.I128)
120
+ GET_NAMEVERSION = Rsp(0x0A, LenIdx.I32)
121
+ GET_FIRMWARE_HASH = Rsp(0x0C, LenIdx.I128)
122
+
123
+
124
+ class SignCmd:
125
+ """Application commands"""
126
+
127
+ GET_PUBKEY = Cmd(0x01, 3, LenIdx.I1, (SignRsp.GET_PUBKEY,))
128
+ SET_SIZE = Cmd(0x03, 3, LenIdx.I32, (SignRsp.SET_SIZE,))
129
+ LOAD_DATA = Cmd(0x05, 3, LenIdx.I128, (SignRsp.LOAD_DATA,))
130
+ GET_SIG = Cmd(0x07, 3, LenIdx.I1, (SignRsp.GET_SIG,))
131
+ GET_NAMEVERSION = Cmd(0x09, 3, LenIdx.I1, (SignRsp.GET_NAMEVERSION,))
132
+ GET_FIRMWARE_HASH = Cmd(0x0B, 3, LenIdx.I32, (SignRsp.GET_FIRMWARE_HASH,))
133
+
134
+
135
+ class TKeySign(TKey):
136
+ """Client for communicating with the TKey signer application.
137
+
138
+ This class extends the base TKey client to implement public key retrieval and
139
+ signing as defined in the
140
+ [tkey-pq-device-signer protocol](https://github.com/tillitis/tkey-pq-device-signer).
141
+ """
142
+
143
+ def __init__(
144
+ self,
145
+ app: SignApp,
146
+ device: str | None = None,
147
+ secret: str | None = None,
148
+ ) -> None:
149
+ """Initialize the TKey signing client.
150
+
151
+ If the TKey device is in firmware mode, this will automatically load the
152
+ application binary. If the device is already running an application, it
153
+ verifies that the running application matches the expected name and version.
154
+
155
+ Args:
156
+ app: The SignApp configuration containing the binary and metadata.
157
+ device: Optional serial port path (e.g., `/dev/ttyACM0`). If None,
158
+ the port is auto-detected.
159
+ secret: Optional User Supplied Secret (passphrase) used as a seed
160
+ for key derivation.
161
+
162
+ Raises:
163
+ TKeyNotFoundError: If the TKey device cannot be found.
164
+ TKeyAppError: If loading the application fails or the device is
165
+ running a mismatched application.
166
+ TKeyError: For other connection or initialization failures.
167
+ """
168
+ super().__init__(device)
169
+ self.key_size = app.key_size
170
+ self.sig_size = app.sig_size
171
+
172
+ try:
173
+ if not self.load_app(app.binary, secret):
174
+ # TKey is not in firmware mode: Query application name and version
175
+ rx = self.send(SignCmd.GET_NAMEVERSION)
176
+ name = (
177
+ rx[2:6].decode("ascii").rstrip(),
178
+ rx[6:10].decode("ascii").rstrip(),
179
+ )
180
+ ver = int.from_bytes(rx[10:14], byteorder="little")
181
+ if name == app.name and ver == app.version:
182
+ return # Signer application is already loaded
183
+
184
+ raise TKeyAppError(
185
+ f"TKey is running an unknown application {name, ver}, "
186
+ f"expected {app.name, app.version}"
187
+ )
188
+ except TKeyError:
189
+ self.disconnect()
190
+ raise
191
+
192
+ def get_pubkey(self) -> bytes:
193
+ """Retrieve the public key bytes from the TKey device.
194
+
195
+ Returns:
196
+ The raw public key bytes.
197
+
198
+ Raises:
199
+ TKeyIOError: If reading from the serial port fails.
200
+ TKeyProtocolError: If there is a framing or protocol mismatch.
201
+ """
202
+ pubkey = bytearray(self.key_size)
203
+
204
+ # Issue command, read first frame
205
+ rx = self.send(SignCmd.GET_PUBKEY)
206
+ offset = 0
207
+
208
+ while offset < self.key_size:
209
+ chunk_size = min(self.key_size - offset, 127)
210
+ pubkey[offset : offset + chunk_size] = rx[2 : 2 + chunk_size]
211
+ offset += chunk_size
212
+ if offset < self.key_size:
213
+ rx = self.recv_response(SignCmd.GET_PUBKEY)
214
+
215
+ return bytes(pubkey)
216
+
217
+ def sign(self, message: bytes, pub_key: bytes | None = None) -> bytes:
218
+ """Sign a message using ML-DSA.
219
+
220
+ Computes the FIPS 204 external mu using the message and public key,
221
+ sends it to the device, and retrieves the signature.
222
+
223
+ Note:
224
+ This method blocks and waits (up to 60 seconds) for the user to touch
225
+ the physical TKey device when it flashes.
226
+
227
+ Args:
228
+ message: The raw bytes of the message/payload to sign.
229
+ pub_key: The public key bytes used to compute the external mu.
230
+ If None, the public key is retrieved from the device.
231
+
232
+ Returns:
233
+ The generated signature as raw bytes.
234
+
235
+ Raises:
236
+ TKeyError: If the device returns a bad status during signing.
237
+ TKeyIOError: If writing or reading from the serial port fails.
238
+ TKeyProtocolError: If there is a framing or protocol mismatch.
239
+ """
240
+ if pub_key is None:
241
+ pub_key = self.get_pubkey()
242
+
243
+ # Compute FIPS 204 external mu
244
+ tr = hashlib.shake_256(pub_key).digest(64)
245
+ mu = hashlib.shake_256(tr + b"\x00\x00" + message).digest(64)
246
+
247
+ # Set size: in our case mu is always 64 bytes
248
+ self.send(SignCmd.SET_SIZE, MU_SIZE)
249
+
250
+ # Load data: mu fits in single frame
251
+ self.send(SignCmd.LOAD_DATA, mu)
252
+
253
+ # Trigger signing (blocks waiting for touch) and read first frame
254
+ rx = self.send(SignCmd.GET_SIG, timeout=60)
255
+
256
+ # Read remaining frames
257
+ signature = bytearray(self.sig_size)
258
+ offset = 0
259
+
260
+ while offset < self.sig_size:
261
+ if rx[2] != 0:
262
+ raise TKeyError(f"GetSig chunk NOK status: {rx[2]}")
263
+
264
+ chunk_size = min(self.sig_size - offset, 126)
265
+ signature[offset : offset + chunk_size] = rx[3 : 3 + chunk_size]
266
+ offset += chunk_size
267
+
268
+ if offset < self.sig_size:
269
+ rx = self.recv_response(SignCmd.GET_SIG)
270
+
271
+ return bytes(signature)