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 +15 -0
- keylet-0.1.0/README.md +4 -0
- keylet-0.1.0/pyproject.toml +64 -0
- keylet-0.1.0/src/keylet/__init__.py +5 -0
- keylet-0.1.0/src/keylet/_serial_hack.py +147 -0
- keylet-0.1.0/src/keylet/bin/cli.py +139 -0
- keylet-0.1.0/src/keylet/py.typed +0 -0
- keylet-0.1.0/src/keylet/resources/__init__.py +1 -0
- keylet-0.1.0/src/keylet/resources/pqsigner_v3.bin +0 -0
- keylet-0.1.0/src/keylet/tkey.py +421 -0
- keylet-0.1.0/src/keylet/tkey_sign.py +271 -0
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,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,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
|
|
Binary file
|
|
@@ -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)
|