keylet 0.1.0__tar.gz → 0.2.1.dev0__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.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 keylet authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,110 @@
1
+ Metadata-Version: 2.4
2
+ Name: keylet
3
+ Version: 0.2.1.dev0
4
+ Summary: Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer
5
+ Keywords: ML-DSA,TKey,Tillitis,PQC,sign,signer
6
+ Author: Jussi Kukkonen
7
+ Author-email: Jussi Kukkonen <jkukkonen@google.com>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Requires-Dist: cryptography>=48.0.0
11
+ Requires-Dist: pyserial>=3.5
12
+ Requires-Python: >=3.10
13
+ Project-URL: Documentation, https://jku.github.io/keylet/
14
+ Project-URL: Homepage, https://github.com/jku/keylet
15
+ Project-URL: Issues, https://github.com/jku/keylet/issues
16
+ Project-URL: Source, https://github.com/jku/keylet
17
+ Description-Content-Type: text/markdown
18
+
19
+ # keylet -- Client library for Tillitis TKey
20
+
21
+ [![keylet on GitHub](https://img.shields.io/badge/GitHub-jku%2Fkeylet-blue?logo=github)](https://github.com/jku/keylet) [![keylet on PyPI](https://img.shields.io/pypi/v/keylet.svg)](https://pypi.org/project/keylet/) [![keylet documentation](https://img.shields.io/badge/Documentation-blue)](https://jku.github.io/keylet/)
22
+
23
+ Keylet is a Python client library and CLI tool for the [Tillitis TKey](https://www.tillitis.se/products/tkey/) security token, and implements a ML-DSA / Ed25519 signer application for TKey.
24
+
25
+ TKeys unique feature is that it has no long-term memory: signing keys are _always_ generated from a seed at runtime. This seed is built by combining a Unique Device Secret, a Device Application hash and an optional User Supplied Secret. Both the Device Application and User Supplied Secret are provided at runtime by `keylet`.
26
+
27
+ The unique design leads to some API peculiarities:
28
+
29
+ * User Supplied Secret (passphrase) is not directly validated by keylet: a "wrong" passphrase will just lead to using a different signing key. In practice the calling application should look at `TKeySign.get_pubkey()`: if the key is unexpected, then potentially the wrong passphrase was used.
30
+ * In long-term use (where the same signing key is expected to be used over a period of time) the calling application is responsible for always selecting the same Device Application: `keylet` provides a mechanism for this, see examples.
31
+ * The only way to change the device application or passphrase after initialization is to unplug the device and start over.
32
+ * Signer initialization has an optimization where the initialization succeeds if the TKey has already been initialized with matching device application name and version. Unfortunately `keylet` cannot confirm that the exact device binary is the expected one or that the passphrase is still the same one (but again, the calling application can compare `TKeySign.get_pubkey()` to the expected key)
33
+
34
+ ## Installation
35
+
36
+ ```bash
37
+ pip install keylet
38
+ ```
39
+
40
+ ## CLI Usage
41
+
42
+ The package installs a `keylet` command-line tool for signing and verification. This is primarily a test/demo application for the library.
43
+
44
+ ```bash
45
+ # Sign without a passphrase, then verify
46
+ $ keylet sign README.md
47
+ $ keylet verify README.md
48
+
49
+ # Get public key, sign with a passphrase, and verify using the saved public key
50
+ $ keylet --passphrase hunter2 pubkey --output pub.key
51
+ $ keylet --passphrase hunter2 sign README.md
52
+ $ keylet verify --pubkey pub.key README.md
53
+
54
+ # When using keylet long-term, remember to specify device app digest (keylet default
55
+ # app version may change, but you will need a specific application to keep using the
56
+ # same key)
57
+ $ keylet --passphrase hunter2 --digest 186bcf6 sign README.md
58
+
59
+ ```
60
+
61
+ ## Library Usage
62
+
63
+ ```python
64
+ from keylet import TKeySign, SignApp
65
+
66
+ # Load the default embedded ML-DSA signer
67
+ app = SignApp.load_mldsa()
68
+ digest = app.digest
69
+
70
+ # Initialize the signer with a passphrase
71
+ with TKeySign(app=app, secret="hunter2") as signer:
72
+ # Sign a payload
73
+ signature = signer.sign(b"my payload")
74
+ ```
75
+
76
+ In long-term use, the device app digest should be used to ensure the same application
77
+ is always used for a specific key:
78
+
79
+ ```python
80
+ # Load application with a digest stored earlier
81
+ app = SignApp.load_mldsa(digest=digest)
82
+
83
+ # Initialize the signer with a passphrase
84
+ with TKeySign(app=app, secret="hunter2") as signer:
85
+ # Sign a payload
86
+ signature = signer.sign(b"my payload")
87
+ ```
88
+
89
+ See the [API Reference](https://jku.github.io/keylet/api/) for more details.
90
+
91
+ ## Development
92
+
93
+ [uv](https://docs.astral.sh/uv/) is a required development tool.
94
+
95
+ ```bash
96
+ # Run keylet CLI from source
97
+ uv run keylet sign README.md
98
+
99
+ # run linters and type checker
100
+ make lint
101
+
102
+ # Fix formatting and lint issues
103
+ make fix
104
+
105
+ # run tests
106
+ make test
107
+
108
+ # run tests, including on-device tests
109
+ make test-device
110
+ ```
@@ -0,0 +1,92 @@
1
+ # keylet -- Client library for Tillitis TKey
2
+
3
+ [![keylet on GitHub](https://img.shields.io/badge/GitHub-jku%2Fkeylet-blue?logo=github)](https://github.com/jku/keylet) [![keylet on PyPI](https://img.shields.io/pypi/v/keylet.svg)](https://pypi.org/project/keylet/) [![keylet documentation](https://img.shields.io/badge/Documentation-blue)](https://jku.github.io/keylet/)
4
+
5
+ Keylet is a Python client library and CLI tool for the [Tillitis TKey](https://www.tillitis.se/products/tkey/) security token, and implements a ML-DSA / Ed25519 signer application for TKey.
6
+
7
+ TKeys unique feature is that it has no long-term memory: signing keys are _always_ generated from a seed at runtime. This seed is built by combining a Unique Device Secret, a Device Application hash and an optional User Supplied Secret. Both the Device Application and User Supplied Secret are provided at runtime by `keylet`.
8
+
9
+ The unique design leads to some API peculiarities:
10
+
11
+ * User Supplied Secret (passphrase) is not directly validated by keylet: a "wrong" passphrase will just lead to using a different signing key. In practice the calling application should look at `TKeySign.get_pubkey()`: if the key is unexpected, then potentially the wrong passphrase was used.
12
+ * In long-term use (where the same signing key is expected to be used over a period of time) the calling application is responsible for always selecting the same Device Application: `keylet` provides a mechanism for this, see examples.
13
+ * The only way to change the device application or passphrase after initialization is to unplug the device and start over.
14
+ * Signer initialization has an optimization where the initialization succeeds if the TKey has already been initialized with matching device application name and version. Unfortunately `keylet` cannot confirm that the exact device binary is the expected one or that the passphrase is still the same one (but again, the calling application can compare `TKeySign.get_pubkey()` to the expected key)
15
+
16
+ ## Installation
17
+
18
+ ```bash
19
+ pip install keylet
20
+ ```
21
+
22
+ ## CLI Usage
23
+
24
+ The package installs a `keylet` command-line tool for signing and verification. This is primarily a test/demo application for the library.
25
+
26
+ ```bash
27
+ # Sign without a passphrase, then verify
28
+ $ keylet sign README.md
29
+ $ keylet verify README.md
30
+
31
+ # Get public key, sign with a passphrase, and verify using the saved public key
32
+ $ keylet --passphrase hunter2 pubkey --output pub.key
33
+ $ keylet --passphrase hunter2 sign README.md
34
+ $ keylet verify --pubkey pub.key README.md
35
+
36
+ # When using keylet long-term, remember to specify device app digest (keylet default
37
+ # app version may change, but you will need a specific application to keep using the
38
+ # same key)
39
+ $ keylet --passphrase hunter2 --digest 186bcf6 sign README.md
40
+
41
+ ```
42
+
43
+ ## Library Usage
44
+
45
+ ```python
46
+ from keylet import TKeySign, SignApp
47
+
48
+ # Load the default embedded ML-DSA signer
49
+ app = SignApp.load_mldsa()
50
+ digest = app.digest
51
+
52
+ # Initialize the signer with a passphrase
53
+ with TKeySign(app=app, secret="hunter2") as signer:
54
+ # Sign a payload
55
+ signature = signer.sign(b"my payload")
56
+ ```
57
+
58
+ In long-term use, the device app digest should be used to ensure the same application
59
+ is always used for a specific key:
60
+
61
+ ```python
62
+ # Load application with a digest stored earlier
63
+ app = SignApp.load_mldsa(digest=digest)
64
+
65
+ # Initialize the signer with a passphrase
66
+ with TKeySign(app=app, secret="hunter2") as signer:
67
+ # Sign a payload
68
+ signature = signer.sign(b"my payload")
69
+ ```
70
+
71
+ See the [API Reference](https://jku.github.io/keylet/api/) for more details.
72
+
73
+ ## Development
74
+
75
+ [uv](https://docs.astral.sh/uv/) is a required development tool.
76
+
77
+ ```bash
78
+ # Run keylet CLI from source
79
+ uv run keylet sign README.md
80
+
81
+ # run linters and type checker
82
+ make lint
83
+
84
+ # Fix formatting and lint issues
85
+ make fix
86
+
87
+ # run tests
88
+ make test
89
+
90
+ # run tests, including on-device tests
91
+ make test-device
92
+ ```
@@ -0,0 +1,83 @@
1
+ [project]
2
+ name = "keylet"
3
+ version = "0.2.1-dev"
4
+ description = "Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jussi Kukkonen", email = "jkukkonen@google.com" }
8
+ ]
9
+ license = "MIT"
10
+ license-files = ["LICENSE"]
11
+ requires-python = ">=3.10"
12
+ dependencies = [
13
+ "cryptography>=48.0.0",
14
+ "pyserial>=3.5",
15
+ ]
16
+ keywords = ["ML-DSA", "TKey", "Tillitis", "PQC", "sign", "signer"]
17
+
18
+ [project.scripts]
19
+ keylet = "keylet.bin.cli:main"
20
+
21
+ [project.urls]
22
+ Documentation = "https://jku.github.io/keylet/"
23
+ Homepage = "https://github.com/jku/keylet"
24
+ Issues = "https://github.com/jku/keylet/issues"
25
+ Source = "https://github.com/jku/keylet"
26
+
27
+ [dependency-groups]
28
+ dev = [
29
+ "mypy",
30
+ "pytest",
31
+ "ruff",
32
+ "zizmor",
33
+ "zensical",
34
+ "mkdocstrings[python]",
35
+ "types-pyserial",
36
+ ]
37
+
38
+ # pin uv in setup-uv action
39
+ uv = ["uv"]
40
+
41
+ [build-system]
42
+ requires = ["uv_build>=0.11.23,<0.12.0"]
43
+ build-backend = "uv_build"
44
+
45
+ [tool.ruff.lint]
46
+ select = ["ALL"]
47
+ ignore = [
48
+ "BLE001", # blind exception
49
+ "PLR2004", # magic value
50
+ "COM812", # trailing comma
51
+ "D", # pydocstyle
52
+ "EM", # flake8-errmsg
53
+ "TRY003", # long message in exception argument
54
+ "PYI019" # typing.Self is only available in >= 3.13
55
+ ]
56
+
57
+ [tool.ruff.lint.per-file-ignores]
58
+ "src/keylet/bin/**" = [
59
+ "T201" # print is ok in CLI tool
60
+ ]
61
+ "tests/**" = [
62
+ "S101", # assert is ok in tests
63
+ "S105", # hardcoded password is ok in tests
64
+ "ARG002", # Unused method argument (common with mocks)
65
+ ]
66
+
67
+ [tool.mypy]
68
+ python_version = "3.10"
69
+ pretty = true
70
+ strict = true
71
+ strict_equality = true
72
+ disallow_any_unimported = true
73
+ disallow_untyped_calls = true
74
+ disallow_untyped_defs = true
75
+ warn_redundant_casts = true
76
+ warn_return_any = true
77
+ warn_unreachable = true
78
+ warn_unused_ignores = true
79
+
80
+ [tool.pytest]
81
+ markers = [
82
+ "device: tests that require a physical TKey device",
83
+ ]
@@ -98,11 +98,11 @@ class RawSerialConnection:
98
98
  # 4. Acquire exclusive access
99
99
  tiocexcl = 0x540C
100
100
  fcntl.ioctl(fd, tiocexcl, 0)
101
-
102
- return fd
103
101
  except Exception:
104
102
  os.close(fd)
105
103
  raise
104
+ else:
105
+ return fd
106
106
 
107
107
  def write(self, data: bytes) -> int:
108
108
  if self._fd is None:
@@ -0,0 +1,145 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ import argparse
5
+ import sys
6
+ from collections.abc import Generator
7
+ from contextlib import contextmanager
8
+ from pathlib import Path
9
+
10
+ from cryptography.exceptions import InvalidSignature
11
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
12
+ from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey
13
+
14
+ from keylet.tkey import TKeyNotFoundError, TKeyUnexpectedAppError
15
+ from keylet.tkey_sign import SignApp, TKeySign
16
+
17
+
18
+ @contextmanager
19
+ def _app_signer(args: argparse.Namespace) -> Generator[TKeySign, None, None]:
20
+ try:
21
+ if args.type == "ed25519":
22
+ app = SignApp.load_ed25519(digest=args.digest)
23
+ else:
24
+ app = SignApp.load_mldsa(digest=args.digest)
25
+ with TKeySign(app, secret=args.passphrase) as signer:
26
+ print(f"Using {args.type} device app with digest {app.digest[:7]}")
27
+ yield signer
28
+ except TKeyNotFoundError as e:
29
+ sys.exit(f"Error: {e}")
30
+ except TKeyUnexpectedAppError as e:
31
+ sys.exit(f"Error: {e}")
32
+
33
+
34
+ def cmd_pubkey(args: argparse.Namespace) -> None:
35
+ with _app_signer(args) as signer:
36
+ pubkey = signer.get_pubkey()
37
+ if args.output:
38
+ Path(args.output).write_bytes(pubkey)
39
+ print(f"Public key written to {args.output}")
40
+ else:
41
+ print(pubkey.hex())
42
+
43
+
44
+ def cmd_sign(args: argparse.Namespace) -> None:
45
+ file_path = Path(args.file)
46
+ if not file_path.exists():
47
+ sys.exit(f"Error: File {args.file} does not exist")
48
+
49
+ data = file_path.read_bytes()
50
+ with _app_signer(args) as signer:
51
+ print("Please touch the TKey device when it flashes to sign...")
52
+ signature = signer.sign(data)
53
+
54
+ sig_path = file_path.with_suffix(file_path.suffix + ".signature")
55
+ sig_path.write_bytes(signature)
56
+ print(f"Signature written to {sig_path}")
57
+
58
+
59
+ def cmd_verify(args: argparse.Namespace) -> None:
60
+ file_path = Path(args.file)
61
+ if not file_path.exists():
62
+ sys.exit(f"Error: File {args.file} does not exist")
63
+
64
+ sig_path = (
65
+ Path(args.signature)
66
+ if args.signature
67
+ else file_path.with_suffix(file_path.suffix + ".signature")
68
+ )
69
+ if not sig_path.exists():
70
+ sys.exit(f"Error: Signature file {sig_path} does not exist")
71
+
72
+ file_bytes = file_path.read_bytes()
73
+ sig_bytes = sig_path.read_bytes()
74
+
75
+ # Get public key either from file or from device
76
+ if args.pubkey:
77
+ pubkey_bytes = Path(args.pubkey).read_bytes()
78
+ else:
79
+ with _app_signer(args) as signer:
80
+ print("Retrieving public key from device...")
81
+ pubkey_bytes = signer.get_pubkey()
82
+
83
+ try:
84
+ # Verify signature using cryptography
85
+ if args.type == "ed25519":
86
+ ed_pubkey = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
87
+ ed_pubkey.verify(sig_bytes, file_bytes)
88
+ else:
89
+ ml_pubkey = MLDSA44PublicKey.from_public_bytes(pubkey_bytes)
90
+ ml_pubkey.verify(sig_bytes, file_bytes)
91
+ except InvalidSignature:
92
+ sys.exit("Verification failed: Invalid signature")
93
+
94
+ print("Verification successful!")
95
+
96
+
97
+ def main() -> None:
98
+ parser = argparse.ArgumentParser(
99
+ description="keylet -- Signing tool for Tillitis TKey"
100
+ )
101
+ parser.add_argument(
102
+ "--digest", help="Optional digest of the device application to use"
103
+ )
104
+ parser.add_argument("--passphrase")
105
+ parser.add_argument(
106
+ "-t",
107
+ "--type",
108
+ choices=["ml-dsa", "ed25519"],
109
+ default="ml-dsa",
110
+ help="key type (default: %(default)s)",
111
+ )
112
+
113
+ subparsers = parser.add_subparsers(dest="command", required=True)
114
+
115
+ # pubkey command
116
+ parser_pubkey = subparsers.add_parser("pubkey", help="Get public key from device")
117
+ parser_pubkey.add_argument("-o", "--output", help="File to write public key to")
118
+
119
+ # sign command
120
+ parser_sign = subparsers.add_parser("sign", help="Sign a file")
121
+ parser_sign.add_argument("file", help="File to sign")
122
+
123
+ # verify command
124
+ parser_verify = subparsers.add_parser("verify", help="Verify a signature")
125
+ parser_verify.add_argument("file", help="File to verify")
126
+ parser_verify.add_argument(
127
+ "--signature", help="Signature file (defaults to <FILE>.signature)"
128
+ )
129
+ parser_verify.add_argument(
130
+ "--pubkey",
131
+ help="Optional public key file (retrieved from device if not specified)",
132
+ )
133
+
134
+ args = parser.parse_args()
135
+
136
+ if args.command == "pubkey":
137
+ cmd_pubkey(args)
138
+ elif args.command == "sign":
139
+ cmd_sign(args)
140
+ elif args.command == "verify":
141
+ cmd_verify(args)
142
+
143
+
144
+ if __name__ == "__main__":
145
+ main()
File without changes
@@ -3,12 +3,12 @@
3
3
 
4
4
  from __future__ import annotations
5
5
 
6
+ import errno
6
7
  import hashlib
7
8
  import logging
8
9
  import sys
9
10
  from dataclasses import dataclass
10
- from types import TracebackType
11
- from typing import TypeVar
11
+ from typing import TYPE_CHECKING, TypeVar
12
12
 
13
13
  import serial
14
14
  from serial.tools import list_ports
@@ -18,6 +18,9 @@ from keylet._serial_hack import (
18
18
  SerialConnection,
19
19
  )
20
20
 
21
+ if TYPE_CHECKING:
22
+ from types import TracebackType
23
+
21
24
  logger = logging.getLogger(__name__)
22
25
 
23
26
  # USB Vendor & Product ID for TKey
@@ -100,7 +103,7 @@ class FwCmd:
100
103
  """Command to send a chunk of application binary data."""
101
104
 
102
105
 
103
- _TKey = TypeVar("_TKey", bound="TKey")
106
+ Self = TypeVar("Self", bound="TKey")
104
107
 
105
108
 
106
109
  class TKeyError(Exception):
@@ -111,6 +114,10 @@ class TKeyNotFoundError(TKeyError):
111
114
  """A TKey device was not found"""
112
115
 
113
116
 
117
+ class TKeyDeviceBusyError(TKeyError):
118
+ """Raised when the TKey device is already in use."""
119
+
120
+
114
121
  class TKeyAppError(TKeyError):
115
122
  """Raised when loading the application fails."""
116
123
 
@@ -123,6 +130,14 @@ class TKeyProtocolError(TKeyError):
123
130
  """Raised upon protocol errors in command or response."""
124
131
 
125
132
 
133
+ class TKeyNOKError(TKeyProtocolError):
134
+ """Raised when the TKey device returns a NOK (Not OK) status."""
135
+
136
+
137
+ class TKeyUnexpectedAppError(TKeyAppError):
138
+ """Raised when TKey is already running a different application."""
139
+
140
+
126
141
  class TKey:
127
142
  """Base TKey Client
128
143
 
@@ -176,13 +191,19 @@ class TKey:
176
191
  def _get_connection(
177
192
  self, port: str, baudrate: int, timeout: float
178
193
  ) -> 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
194
+ try:
195
+ if sys.platform == "linux":
196
+ conn = RawSerialConnection(port, baudrate, timeout)
197
+ else:
198
+ conn = serial.Serial(port, baudrate=baudrate, timeout=timeout)
199
+ except OSError as e:
200
+ if e.errno in (errno.EBUSY, errno.EACCES) or "Access is denied" in str(e):
201
+ raise TKeyDeviceBusyError(f"TKey device {port} is busy") from e
202
+ raise TKeyError(f"Failed to open serial port {port}") from e
203
+ except Exception as e:
204
+ raise TKeyError(f"Failed to open serial port {port}") from e
205
+
206
+ return conn
186
207
 
187
208
  def disconnect(self) -> None:
188
209
  if self._conn is not None:
@@ -195,7 +216,7 @@ class TKey:
195
216
  def __del__(self) -> None:
196
217
  self.disconnect()
197
218
 
198
- def __enter__(self: _TKey) -> _TKey:
219
+ def __enter__(self: Self) -> Self:
199
220
  return self
200
221
 
201
222
  def __exit__(
@@ -310,7 +331,7 @@ class TKey:
310
331
  self._conn.read(resp_len)
311
332
  except Exception as e:
312
333
  logger.debug("Failed to read remaining bytes after NOK status: %s", e)
313
- raise TKeyProtocolError("Response status code not OK (1)")
334
+ raise TKeyNOKError("Response status code not OK (1)")
314
335
 
315
336
  try:
316
337
  resp_data = self._conn.read(resp_len)
@@ -366,9 +387,8 @@ class TKey:
366
387
  try:
367
388
  # Query firmware name
368
389
  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
390
+ except TKeyNOKError:
391
+ # Device returned NOK: we are running an application already
372
392
  return False
373
393
 
374
394
  # we are in firmware mode. Load the app
@@ -10,18 +10,21 @@ import importlib.resources
10
10
  import logging
11
11
  from dataclasses import dataclass
12
12
 
13
- from keylet.tkey import Cmd, LenIdx, Rsp, TKey, TKeyAppError, TKeyError
13
+ from keylet.tkey import Cmd, LenIdx, Rsp, TKey, TKeyError, TKeyUnexpectedAppError
14
14
 
15
15
  logger = logging.getLogger(__name__)
16
16
 
17
17
  MU_SIZE = (64).to_bytes(4, byteorder="little")
18
18
 
19
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")),
20
+ # Static registry of signer binaries (filename, version)
21
+ # First binary in each list is the default binary.
22
+ _EMBEDDED_MLDSA_BINS: list[tuple[str, int]] = [
23
+ ("pqsigner_v3.bin", 3),
24
+ ]
25
+
26
+ _EMBEDDED_ED25519_BINS: list[tuple[str, int]] = [
27
+ ("ed25519signer_v3.bin", 3),
25
28
  ]
26
29
 
27
30
 
@@ -32,17 +35,16 @@ class SignApp:
32
35
  Attributes:
33
36
  binary: The raw bytes of the device application binary.
34
37
  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).
38
+ name: Device application name tuple.
39
+ sig_size: The size of the generated signature in bytes.
40
+ key_size: The size of the public key in bytes.
39
41
  """
40
42
 
41
43
  binary: bytes
42
44
  version: int
43
- name: tuple[str, str] = ("tk1", "pqsn")
44
- sig_size: int = 2420
45
- key_size: int = 1312
45
+ name: tuple[str, str]
46
+ sig_size: int
47
+ key_size: int
46
48
 
47
49
  @property
48
50
  def digest(self) -> str:
@@ -50,34 +52,14 @@ class SignApp:
50
52
  return hashlib.blake2s(self.binary, digest_size=32).hexdigest()
51
53
 
52
54
  @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
- """
55
+ def _find_binary(
56
+ cls, version: int | None, digest: str | None, bins: list[tuple[str, int]]
57
+ ) -> tuple[bytes, int]:
76
58
  resources_dir = importlib.resources.files("keylet.resources")
77
59
  matches = []
78
60
 
79
61
  # Scan registered binaries
80
- for filename, file_ver, name in _EMBEDDED_MLDSA_BINS:
62
+ for filename, file_ver in bins:
81
63
  # Filter by version if requested
82
64
  if version is not None and file_ver != version:
83
65
  continue
@@ -89,7 +71,7 @@ class SignApp:
89
71
  if digest is not None and not file_digest.startswith(digest.lower()):
90
72
  continue
91
73
 
92
- matches.append((binary, file_ver, name, filename))
74
+ matches.append((binary, file_ver))
93
75
 
94
76
  if digest is None and version is None:
95
77
  # First binary is the default one
@@ -106,8 +88,65 @@ class SignApp:
106
88
  f"digest={digest}."
107
89
  )
108
90
 
109
- matched_binary, matched_version, matched_name, _ = matches[0]
110
- return cls(matched_binary, matched_version, matched_name)
91
+ return matches[0]
92
+
93
+ @classmethod
94
+ def load_mldsa(
95
+ cls, version: int | None = None, digest: str | None = None
96
+ ) -> SignApp:
97
+ """Load a ML-DSA signer application from package resources.
98
+
99
+ If a digest (or prefix) is provided, it returns the binary matching the
100
+ digest. If a version is provided, it filters by version. If neither is
101
+ provided, current default binary is loaded.
102
+
103
+ TKey key derivation depends on the application binary, so users who want a
104
+ specific key must provide the binary digest.
105
+
106
+ Args:
107
+ version: The version of the signer application to load.
108
+ digest: A BLAKE2s-256 hex digest (or prefix) of the target binary.
109
+
110
+ Returns:
111
+ An instance of SignApp configured with the loaded binary.
112
+
113
+ Raises:
114
+ ValueError: If no binary matches the criteria, or if the search
115
+ is ambiguous (matches multiple binaries).
116
+ """
117
+
118
+ binary, version = cls._find_binary(version, digest, _EMBEDDED_MLDSA_BINS)
119
+ return cls(binary, version, ("tk1", "pqsn"), 2420, 1312)
120
+
121
+ @classmethod
122
+ def load_ed25519(
123
+ cls, version: int | None = None, digest: str | None = None
124
+ ) -> SignApp:
125
+ """Load a Ed25519 signer application from package resources.
126
+
127
+ If a digest (or prefix) is provided, it returns the binary matching the
128
+ digest. If a version is provided, it filters by version. If neither is
129
+ provided, current default binary is loaded.
130
+
131
+ TKey key derivation depends on the application binary, so users who want a
132
+ specific key must provide the binary digest.
133
+
134
+ Warning:
135
+ When Ed25519 is used, there is a 4096K size limit to signing payloads.
136
+
137
+ Args:
138
+ version: The version of the signer application to load.
139
+ digest: A BLAKE2s-256 hex digest (or prefix) of the target binary.
140
+
141
+ Returns:
142
+ An instance of SignApp configured with the loaded binary.
143
+
144
+ Raises:
145
+ ValueError: If no binary matches the criteria, or if the search
146
+ is ambiguous (matches multiple binaries).
147
+ """
148
+ binary, version = cls._find_binary(version, digest, _EMBEDDED_ED25519_BINS)
149
+ return cls(binary, version, ("tk1", "sign"), 64, 32)
111
150
 
112
151
 
113
152
  class SignRsp:
@@ -135,9 +174,10 @@ class SignCmd:
135
174
  class TKeySign(TKey):
136
175
  """Client for communicating with the TKey signer application.
137
176
 
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).
177
+ This class implements public key retrieval and signing as defined in the
178
+ [tkey-pq-device-signer protocol](https://github.com/tillitis/tkey-pq-device-signer)
179
+ but is also compatible with the
180
+ [tkey-device-signer protocol](https://github.com/tillitis/tkey-device-signer).
141
181
  """
142
182
 
143
183
  def __init__(
@@ -161,13 +201,14 @@ class TKeySign(TKey):
161
201
 
162
202
  Raises:
163
203
  TKeyNotFoundError: If the TKey device cannot be found.
164
- TKeyAppError: If loading the application fails or the device is
204
+ TKeyUnexpectedAppError: If loading the application fails or the device is
165
205
  running a mismatched application.
166
206
  TKeyError: For other connection or initialization failures.
167
207
  """
168
208
  super().__init__(device)
169
209
  self.key_size = app.key_size
170
210
  self.sig_size = app.sig_size
211
+ self.name = app.name
171
212
 
172
213
  try:
173
214
  if not self.load_app(app.binary, secret):
@@ -181,7 +222,7 @@ class TKeySign(TKey):
181
222
  if name == app.name and ver == app.version:
182
223
  return # Signer application is already loaded
183
224
 
184
- raise TKeyAppError(
225
+ raise TKeyUnexpectedAppError(
185
226
  f"TKey is running an unknown application {name, ver}, "
186
227
  f"expected {app.name, app.version}"
187
228
  )
@@ -215,19 +256,23 @@ class TKeySign(TKey):
215
256
  return bytes(pubkey)
216
257
 
217
258
  def sign(self, message: bytes, pub_key: bytes | None = None) -> bytes:
218
- """Sign a message using ML-DSA.
259
+ """Sign a payload.
219
260
 
220
- Computes the FIPS 204 external mu using the message and public key,
221
- sends it to the device, and retrieves the signature.
261
+ Sends payload to device and retrieves the signature.
262
+
263
+ For ML-DSA, the FIPS 204 external mu is computed using the message
264
+ and public key: the mu is sent to device instead of payload.
222
265
 
223
266
  Note:
224
267
  This method blocks and waits (up to 60 seconds) for the user to touch
225
268
  the physical TKey device when it flashes.
226
269
 
227
270
  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.
271
+ message: The raw bytes of the message/payload to sign. When Ed25519 keys
272
+ are used, there is a max message size of 4096K. This limitation does
273
+ not apply to ML-DSA as FIPS 204 external mu is used.
274
+ pub_key: The public key bytes (only needed for ML-DSA). If not provided,
275
+ key is retrieved from device.
231
276
 
232
277
  Returns:
233
278
  The generated signature as raw bytes.
@@ -237,18 +282,30 @@ class TKeySign(TKey):
237
282
  TKeyIOError: If writing or reading from the serial port fails.
238
283
  TKeyProtocolError: If there is a framing or protocol mismatch.
239
284
  """
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)
285
+ # pqsn = ML-DSA signer, pqnt = no-touch ML-DSA test signer
286
+ if self.name in [("tk1", "pqsn"), ("tk1", "pqnt")]:
287
+ # Compute FIPS 204 external mu
288
+ if pub_key is None:
289
+ pub_key = self.get_pubkey()
290
+ tr = hashlib.shake_256(pub_key).digest(64)
291
+ payload = hashlib.shake_256(tr + b"\x00\x00" + message).digest(64)
292
+ else:
293
+ payload = message
294
+
295
+ # Set size
296
+ if len(payload) > 4096:
297
+ raise ValueError(f"Payload too large {len(payload)} > 4096]")
298
+ self.send(SignCmd.SET_SIZE, len(payload).to_bytes(4, byteorder="little"))
299
+
300
+ # Load data in chunks
301
+ chunk_size = 127
302
+ offset = 0
303
+ while offset < len(payload):
304
+ chunk = payload[offset : offset + chunk_size]
305
+ rx = self.send(SignCmd.LOAD_DATA, chunk)
306
+ if rx[2] != 0:
307
+ raise TKeyError(f"LoadData chunk NOK status: {rx[2]}")
308
+ offset += chunk_size
252
309
 
253
310
  # Trigger signing (blocks waiting for touch) and read first frame
254
311
  rx = self.send(SignCmd.GET_SIG, timeout=60)
keylet-0.1.0/PKG-INFO DELETED
@@ -1,15 +0,0 @@
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 DELETED
@@ -1,4 +0,0 @@
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.
@@ -1,64 +0,0 @@
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
- ]
@@ -1,139 +0,0 @@
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()