keylet 0.6.0__tar.gz → 1.0.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: keylet
3
- Version: 0.6.0
3
+ Version: 1.0.0
4
4
  Summary: Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer
5
5
  Keywords: ML-DSA,TKey,Tillitis,PQC,sign,signer
6
6
  Author: Jussi Kukkonen
@@ -46,15 +46,15 @@ The package installs a `keylet` command-line tool for signing and verification.
46
46
  $ keylet sign README.md
47
47
  $ keylet verify README.md
48
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
49
+ # Get public key, sign, and verify using the saved public key
50
+ $ keylet pubkey --output pub.key
51
+ $ keylet sign README.md
52
52
  $ keylet verify --pubkey pub.key README.md
53
53
 
54
54
  # When using keylet long-term, remember to specify device app digest (keylet default
55
55
  # app version may change, but you will need a specific application to keep using the
56
56
  # same key)
57
- $ keylet --passphrase hunter2 --digest 2cd8741 sign README.md
57
+ $ keylet --digest 2cd8741 sign README.md
58
58
 
59
59
  ```
60
60
 
@@ -67,9 +67,9 @@ from keylet import TKeySign, SignApp
67
67
  app = SignApp.load_mldsa()
68
68
  digest = app.digest
69
69
 
70
- # Initialize the signer with a passphrase
70
+ # Initialize the signer with a passphrase, sign a payload
71
71
  with TKeySign(app=app, secret="hunter2") as signer:
72
- # Sign a payload
72
+ pubkey = signer.get_pubkey()
73
73
  signature = signer.sign(b"my payload")
74
74
  ```
75
75
 
@@ -80,13 +80,14 @@ is always used for a specific key:
80
80
  # Load application with a digest stored earlier
81
81
  app = SignApp.load_mldsa(digest=digest)
82
82
 
83
- # Initialize the signer with a passphrase
83
+ # Initialize the signer with a passphrase, sign a payload
84
84
  with TKeySign(app=app, secret="hunter2") as signer:
85
- # Sign a payload
85
+ if signer.get_pubkey() != pubkey:
86
+ exit("Unexpected signing key: maybe incorrect password?")
86
87
  signature = signer.sign(b"my payload")
87
88
  ```
88
89
 
89
- See API Reference in [documentation](https://jku.github.io/keylet/) for more details.
90
+ See API Reference in [documentation](https://jku.github.io/keylet/) for more details.
90
91
 
91
92
  ## Development
92
93
 
@@ -108,3 +109,9 @@ make test
108
109
  # run tests, including on-device tests
109
110
  make test-device
110
111
  ```
112
+
113
+ ### Releasing
114
+
115
+ * If a major or minor version bump is needed run `uv version --bump=[major|minor]`
116
+ * run `make release` to create version bump commit, release tag and dev version bump commit
117
+ * Push the commits and tag to trigger release workflow: `git push --tags origin main`
@@ -28,15 +28,15 @@ The package installs a `keylet` command-line tool for signing and verification.
28
28
  $ keylet sign README.md
29
29
  $ keylet verify README.md
30
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
31
+ # Get public key, sign, and verify using the saved public key
32
+ $ keylet pubkey --output pub.key
33
+ $ keylet sign README.md
34
34
  $ keylet verify --pubkey pub.key README.md
35
35
 
36
36
  # When using keylet long-term, remember to specify device app digest (keylet default
37
37
  # app version may change, but you will need a specific application to keep using the
38
38
  # same key)
39
- $ keylet --passphrase hunter2 --digest 2cd8741 sign README.md
39
+ $ keylet --digest 2cd8741 sign README.md
40
40
 
41
41
  ```
42
42
 
@@ -49,9 +49,9 @@ from keylet import TKeySign, SignApp
49
49
  app = SignApp.load_mldsa()
50
50
  digest = app.digest
51
51
 
52
- # Initialize the signer with a passphrase
52
+ # Initialize the signer with a passphrase, sign a payload
53
53
  with TKeySign(app=app, secret="hunter2") as signer:
54
- # Sign a payload
54
+ pubkey = signer.get_pubkey()
55
55
  signature = signer.sign(b"my payload")
56
56
  ```
57
57
 
@@ -62,13 +62,14 @@ is always used for a specific key:
62
62
  # Load application with a digest stored earlier
63
63
  app = SignApp.load_mldsa(digest=digest)
64
64
 
65
- # Initialize the signer with a passphrase
65
+ # Initialize the signer with a passphrase, sign a payload
66
66
  with TKeySign(app=app, secret="hunter2") as signer:
67
- # Sign a payload
67
+ if signer.get_pubkey() != pubkey:
68
+ exit("Unexpected signing key: maybe incorrect password?")
68
69
  signature = signer.sign(b"my payload")
69
70
  ```
70
71
 
71
- See API Reference in [documentation](https://jku.github.io/keylet/) for more details.
72
+ See API Reference in [documentation](https://jku.github.io/keylet/) for more details.
72
73
 
73
74
  ## Development
74
75
 
@@ -90,3 +91,9 @@ make test
90
91
  # run tests, including on-device tests
91
92
  make test-device
92
93
  ```
94
+
95
+ ### Releasing
96
+
97
+ * If a major or minor version bump is needed run `uv version --bump=[major|minor]`
98
+ * run `make release` to create version bump commit, release tag and dev version bump commit
99
+ * Push the commits and tag to trigger release workflow: `git push --tags origin main`
@@ -0,0 +1,87 @@
1
+ [project]
2
+ name = "keylet"
3
+ version = "1.0.0"
4
+ description = "Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer"
5
+ readme = "README.md"
6
+ license = "MIT"
7
+ license-files = ["LICENSE"]
8
+ requires-python = ">=3.10"
9
+ dependencies = [
10
+ "cryptography>=48.0.0",
11
+ "pyserial>=3.5",
12
+ ]
13
+ keywords = [
14
+ "ML-DSA",
15
+ "TKey",
16
+ "Tillitis",
17
+ "PQC",
18
+ "sign",
19
+ "signer",
20
+ ]
21
+
22
+ [[project.authors]]
23
+ name = "Jussi Kukkonen"
24
+ email = "jkukkonen@google.com"
25
+
26
+ [project.scripts]
27
+ keylet = "keylet.bin.cli:main"
28
+
29
+ [project.urls]
30
+ Documentation = "https://jku.github.io/keylet/"
31
+ Homepage = "https://github.com/jku/keylet"
32
+ Issues = "https://github.com/jku/keylet/issues"
33
+ Source = "https://github.com/jku/keylet"
34
+
35
+ [dependency-groups]
36
+ dev = [
37
+ "mypy",
38
+ "pytest",
39
+ "ruff",
40
+ "zizmor",
41
+ "zensical",
42
+ "mkdocstrings[python]",
43
+ "types-pyserial",
44
+ ]
45
+ uv = ["uv"]
46
+
47
+ [build-system]
48
+ requires = ["uv_build>=0.12.0,<0.13.0"]
49
+ build-backend = "uv_build"
50
+
51
+ [tool.ruff.lint]
52
+ select = ["ALL"]
53
+ ignore = [
54
+ "BLE001",
55
+ "PLR2004",
56
+ "COM812",
57
+ "D",
58
+ "EM",
59
+ "TRY003",
60
+ "PYI019",
61
+ ]
62
+
63
+ [tool.ruff.lint.per-file-ignores]
64
+ "src/keylet/bin/**" = ["T201"]
65
+ "tests/**" = [
66
+ "S101",
67
+ "S105",
68
+ "S106",
69
+ "SLF001",
70
+ "ARG002",
71
+ ]
72
+
73
+ [tool.mypy]
74
+ python_version = "3.10"
75
+ pretty = true
76
+ strict = true
77
+ strict_equality = true
78
+ disallow_any_unimported = true
79
+ disallow_untyped_calls = true
80
+ disallow_untyped_defs = true
81
+ warn_redundant_casts = true
82
+ warn_return_any = true
83
+ warn_unreachable = true
84
+ warn_unused_ignores = true
85
+
86
+ [tool.pytest]
87
+ markers = ["device: tests that require a physical TKey device"]
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "keylet"
3
- version = "0.6.0"
3
+ version = "1.0.0"
4
4
  description = "Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer"
5
5
  readme = "README.md"
6
6
  authors = [
@@ -39,7 +39,7 @@ dev = [
39
39
  uv = ["uv"]
40
40
 
41
41
  [build-system]
42
- requires = ["uv_build>=0.11.23,<0.12.0"]
42
+ requires = ["uv_build>=0.12.0,<0.13.0"]
43
43
  build-backend = "uv_build"
44
44
 
45
45
  [tool.ruff.lint]
@@ -61,6 +61,7 @@ ignore = [
61
61
  "tests/**" = [
62
62
  "S101", # assert is ok in tests
63
63
  "S105", # hardcoded password is ok in tests
64
+ "S106", # hardcoded password is ok in tests
64
65
  "SLF001", # Private member access is ok in tests
65
66
  "ARG002", # Unused method argument (common with mocks)
66
67
  ]
@@ -0,0 +1,29 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
3
+
4
+ from keylet.tkey import (
5
+ TKeyAppError,
6
+ TKeyDeviceBusyError,
7
+ TKeyError,
8
+ TKeyIOError,
9
+ TKeyNOKError,
10
+ TKeyNotFoundError,
11
+ TKeyNotInFirmwareModeError,
12
+ TKeyProtocolError,
13
+ TKeyUnexpectedAppError,
14
+ )
15
+ from keylet.tkey_sign import SignApp, TKeySign
16
+
17
+ __all__ = [
18
+ "SignApp",
19
+ "TKeyAppError",
20
+ "TKeyDeviceBusyError",
21
+ "TKeyError",
22
+ "TKeyIOError",
23
+ "TKeyNOKError",
24
+ "TKeyNotFoundError",
25
+ "TKeyNotInFirmwareModeError",
26
+ "TKeyProtocolError",
27
+ "TKeySign",
28
+ "TKeyUnexpectedAppError",
29
+ ]
@@ -0,0 +1,2 @@
1
+ # SPDX-License-Identifier: MIT
2
+ # Copyright (c) 2026 keylet authors
@@ -2,6 +2,7 @@
2
2
  # Copyright (c) 2026 keylet authors
3
3
 
4
4
  import argparse
5
+ import getpass
5
6
  import sys
6
7
  from collections.abc import Generator
7
8
  from contextlib import contextmanager
@@ -11,18 +12,18 @@ from cryptography.exceptions import InvalidSignature
11
12
  from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
12
13
  from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey
13
14
 
14
- from keylet.tkey import TKeyNotFoundError, TKeyUnexpectedAppError
15
- from keylet.tkey_sign import SignApp, TKeySign
15
+ from keylet import SignApp, TKeyNotFoundError, TKeySign, TKeyUnexpectedAppError
16
16
 
17
17
 
18
18
  @contextmanager
19
19
  def _app_signer(args: argparse.Namespace) -> Generator[TKeySign, None, None]:
20
20
  try:
21
+ secret = getpass.getpass("Enter passphrase (press Enter for none): ")
21
22
  if args.type == "ed25519":
22
23
  app = SignApp.load_ed25519(digest=args.digest)
23
24
  else:
24
25
  app = SignApp.load_mldsa(digest=args.digest)
25
- with TKeySign(app, secret=args.passphrase) as signer:
26
+ with TKeySign(app, secret=secret or None) as signer:
26
27
  print(f"Using {args.type} device app with digest {app.digest[:7]}")
27
28
  yield signer
28
29
  except TKeyNotFoundError as e:
@@ -46,10 +47,19 @@ def cmd_sign(args: argparse.Namespace) -> None:
46
47
  if not file_path.exists():
47
48
  sys.exit(f"Error: File {args.file} does not exist")
48
49
 
49
- data = file_path.read_bytes()
50
+ pubkey = None
51
+ if args.pubkey is not None:
52
+ key_path = Path(args.pubkey)
53
+ if not key_path.exists():
54
+ sys.exit(f"Error: Public key {args.pubkey} does not exist")
55
+ pubkey = key_path.read_bytes()
56
+ # Note that we do not compare the given pubkey to the pubkey from the device
57
+ # A real application would want to do that to check for passphrase typos etc.
58
+
50
59
  with _app_signer(args) as signer:
51
60
  print("Please touch the TKey device when it flashes to sign...")
52
- signature = signer.sign(data)
61
+ with file_path.open("rb") as f:
62
+ signature = signer.sign(f, pubkey)
53
63
 
54
64
  sig_path = file_path.with_suffix(file_path.suffix + ".signature")
55
65
  sig_path.write_bytes(signature)
@@ -76,8 +86,8 @@ def cmd_verify(args: argparse.Namespace) -> None:
76
86
  if args.pubkey:
77
87
  pubkey_bytes = Path(args.pubkey).read_bytes()
78
88
  else:
89
+ print("Retrieving public key from device...")
79
90
  with _app_signer(args) as signer:
80
- print("Retrieving public key from device...")
81
91
  pubkey_bytes = signer.get_pubkey()
82
92
 
83
93
  try:
@@ -101,7 +111,6 @@ def main() -> None:
101
111
  parser.add_argument(
102
112
  "--digest", help="Optional digest of the device application to use"
103
113
  )
104
- parser.add_argument("--passphrase")
105
114
  parser.add_argument(
106
115
  "-t",
107
116
  "--type",
@@ -119,6 +128,10 @@ def main() -> None:
119
128
  # sign command
120
129
  parser_sign = subparsers.add_parser("sign", help="Sign a file")
121
130
  parser_sign.add_argument("file", help="File to sign")
131
+ parser_sign.add_argument(
132
+ "--pubkey",
133
+ help="Optional public key file (retrieved from device if not specified)",
134
+ )
122
135
 
123
136
  # verify command
124
137
  parser_verify = subparsers.add_parser("verify", help="Verify a signature")
@@ -1,5 +1,4 @@
1
1
  # SPDX-License-Identifier: MIT
2
2
  # Copyright (c) 2026 keylet authors
3
3
 
4
- # ruff: noqa: F401
5
- from keylet.tkey_sign import SignApp, TKeySign
4
+ # Package marker for resource files
@@ -138,6 +138,10 @@ class TKeyUnexpectedAppError(TKeyAppError):
138
138
  """Raised when TKey is already running a different application."""
139
139
 
140
140
 
141
+ class TKeyNotInFirmwareModeError(TKeyAppError):
142
+ """Raised when the TKey is not in firmware mode and firmware mode was required."""
143
+
144
+
141
145
  class TKey:
142
146
  """Base TKey Client
143
147
 
@@ -8,14 +8,56 @@ from __future__ import annotations
8
8
  import hashlib
9
9
  import importlib.resources
10
10
  import logging
11
+ from collections.abc import Iterable, Iterator
11
12
  from dataclasses import dataclass
12
-
13
- from keylet.tkey import Cmd, LenIdx, Rsp, TKey, TKeyError, TKeyUnexpectedAppError
13
+ from typing import Protocol, TypeAlias, runtime_checkable
14
+
15
+ from keylet.tkey import (
16
+ Cmd,
17
+ LenIdx,
18
+ Rsp,
19
+ TKey,
20
+ TKeyError,
21
+ TKeyNotInFirmwareModeError,
22
+ TKeyUnexpectedAppError,
23
+ )
14
24
 
15
25
  logger = logging.getLogger(__name__)
16
26
 
17
- MU_SIZE = (64).to_bytes(4, byteorder="little")
18
27
  MAX_PAYLOAD_SIZE = 4096
28
+ _STREAM_CHUNK_SIZE = 64 * 1024 # 64 KiB buffer for streaming
29
+
30
+
31
+ @runtime_checkable
32
+ class BinaryReader(Protocol):
33
+ """Protocol for binary streams supporting chunked reads."""
34
+
35
+ def read(self, size: int = -1, /) -> bytes: ...
36
+
37
+
38
+ SignableMessage: TypeAlias = bytes | BinaryReader | Iterable[bytes]
39
+
40
+
41
+ def _iter_chunks(message: SignableMessage) -> Iterator[bytes]:
42
+ """Yield chunks of bytes from bytes, a binary reader, or an iterable of bytes."""
43
+ if isinstance(message, bytes):
44
+ yield message
45
+ elif isinstance(message, BinaryReader):
46
+ while chunk := message.read(_STREAM_CHUNK_SIZE):
47
+ yield chunk
48
+ else: # iterable
49
+ yield from message
50
+
51
+
52
+ def _read_bounded(message: SignableMessage, max_size: int) -> bytes:
53
+ """Read stream into memory up to max_size, failing fast on overflow."""
54
+ buf = bytearray()
55
+ for chunk in _iter_chunks(message):
56
+ buf.extend(chunk)
57
+ if len(buf) > max_size:
58
+ raise ValueError(f"Payload size exceeds maximum {max_size} bytes")
59
+ return bytes(buf)
60
+
19
61
 
20
62
  # Static registry of signer binaries (filename, version)
21
63
  # First binary in each list is the default binary.
@@ -186,12 +228,15 @@ class TKeySign(TKey):
186
228
  app: SignApp,
187
229
  device: str | None = None,
188
230
  secret: str | None = None,
231
+ *,
232
+ require_firmware_mode: bool = False,
189
233
  ) -> None:
190
234
  """Initialize the TKey signing client.
191
235
 
192
236
  If the TKey device is in firmware mode, this will automatically load the
193
- application binary. If the device is already running an application, it
194
- verifies that the running application matches the expected name and version.
237
+ application binary. If the device is already running an application (and
238
+ require_firmware_mode is not set), verifies that the running application
239
+ matches the expected name and version.
195
240
 
196
241
  Args:
197
242
  app: The SignApp configuration containing the binary and metadata.
@@ -199,9 +244,13 @@ class TKeySign(TKey):
199
244
  the port is auto-detected.
200
245
  secret: Optional User Supplied Secret (passphrase) used as a seed
201
246
  for key derivation.
247
+ require_firmware_mode: If True, fail if the device is not in firmware
248
+ mode instead of attempting to use an already running application.
202
249
 
203
250
  Raises:
204
251
  TKeyNotFoundError: If the TKey device cannot be found.
252
+ TKeyNotInFirmwareModeError: when require_firmware_mode is set and the
253
+ device is not in firmware mode.
205
254
  TKeyUnexpectedAppError: If loading the application fails or the device is
206
255
  running a mismatched application.
207
256
  TKeyError: For other connection or initialization failures.
@@ -213,6 +262,11 @@ class TKeySign(TKey):
213
262
 
214
263
  try:
215
264
  if not self.load_app(app.binary, secret):
265
+ if require_firmware_mode:
266
+ raise TKeyNotInFirmwareModeError(
267
+ "TKey is not in firmware mode but require_firmware_mode was set"
268
+ )
269
+
216
270
  # TKey is not in firmware mode: Query application name and version
217
271
  rx = self.send(SignCmd.GET_NAMEVERSION)
218
272
  name = (
@@ -256,22 +310,25 @@ class TKeySign(TKey):
256
310
 
257
311
  return bytes(pubkey)
258
312
 
259
- def sign(self, message: bytes, pub_key: bytes | None = None) -> bytes:
313
+ def sign(self, message: SignableMessage, pub_key: bytes | None = None) -> bytes:
260
314
  """Sign a payload.
261
315
 
262
316
  Sends payload to device and retrieves the signature.
263
317
 
264
318
  For ML-DSA, the FIPS 204 external mu is computed using the message
265
319
  and public key: the mu is sent to device instead of payload.
320
+ Streaming messages of arbitrary size are supported for ML-DSA.
321
+ For Ed25519, the message (or stream) must not exceed 4096 bytes.
266
322
 
267
323
  Note:
268
324
  This method blocks and waits (up to 60 seconds) for the user to touch
269
325
  the physical TKey device when it flashes.
270
326
 
271
327
  Args:
272
- message: The raw bytes of the message/payload to sign. When Ed25519 keys
273
- are used, there is a max message size of 4096B. This limitation does
274
- not apply to ML-DSA as FIPS 204 external mu is used.
328
+ message: The raw bytes, binary reader (file-like object), or chunk iterable
329
+ to sign. For Ed25519, the total message size cannot exceed 4096B.
330
+ This limitation does not apply to ML-DSA as FIPS 204 external mu
331
+ is used.
275
332
  pub_key: The public key bytes (only needed for ML-DSA). If not provided,
276
333
  key is retrieved from device.
277
334
 
@@ -288,13 +345,14 @@ class TKeySign(TKey):
288
345
  if pub_key is None:
289
346
  pub_key = self.get_pubkey()
290
347
  tr = hashlib.shake_256(pub_key).digest(64)
291
- payload = hashlib.shake_256(tr + b"\x00\x00" + message).digest(64)
348
+ shake = hashlib.shake_256(tr + b"\x00\x00")
349
+ for chunk in _iter_chunks(message):
350
+ shake.update(chunk)
351
+ payload = shake.digest(64)
292
352
  else:
293
- payload = message
353
+ payload = _read_bounded(message, MAX_PAYLOAD_SIZE)
294
354
 
295
355
  # Set size
296
- if len(payload) > MAX_PAYLOAD_SIZE:
297
- raise ValueError(f"Payload too large {len(payload)} > {MAX_PAYLOAD_SIZE}]")
298
356
  self.send(SignCmd.SET_SIZE, len(payload).to_bytes(4, byteorder="little"))
299
357
 
300
358
  # Load data in chunks
File without changes
@@ -1 +0,0 @@
1
- # Package marker for resource files
File without changes
File without changes