keylet 0.2.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.
- keylet-0.2.1.dev0/LICENSE +21 -0
- {keylet-0.2.0 → keylet-0.2.1.dev0}/PKG-INFO +39 -5
- {keylet-0.2.0 → keylet-0.2.1.dev0}/README.md +31 -1
- {keylet-0.2.0 → keylet-0.2.1.dev0}/pyproject.toml +34 -36
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/_serial_hack.py +2 -2
- keylet-0.2.1.dev0/src/keylet/bin/cli.py +145 -0
- keylet-0.2.1.dev0/src/keylet/py.typed +0 -0
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/tkey.py +35 -15
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/tkey_sign.py +10 -6
- keylet-0.2.0/src/keylet/bin/cli.py +0 -158
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/__init__.py +0 -0
- /keylet-0.2.0/src/keylet/py.typed → /keylet-0.2.1.dev0/src/keylet/bin/__init__.py +0 -0
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/resources/__init__.py +0 -0
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/resources/ed25519signer_v3.bin +0 -0
- {keylet-0.2.0 → keylet-0.2.1.dev0}/src/keylet/resources/pqsigner_v3.bin +0 -0
|
@@ -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.
|
|
@@ -1,15 +1,19 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: keylet
|
|
3
|
-
Version: 0.2.
|
|
4
|
-
Summary: Client application for Tillitis TKey hardware ML-DSA signer
|
|
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
|
|
5
6
|
Author: Jussi Kukkonen
|
|
6
7
|
Author-email: Jussi Kukkonen <jkukkonen@google.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
7
10
|
Requires-Dist: cryptography>=48.0.0
|
|
8
11
|
Requires-Dist: pyserial>=3.5
|
|
9
12
|
Requires-Python: >=3.10
|
|
13
|
+
Project-URL: Documentation, https://jku.github.io/keylet/
|
|
14
|
+
Project-URL: Homepage, https://github.com/jku/keylet
|
|
10
15
|
Project-URL: Issues, https://github.com/jku/keylet/issues
|
|
11
16
|
Project-URL: Source, https://github.com/jku/keylet
|
|
12
|
-
Project-URL: Documentation, https://jku.github.io/keylet/
|
|
13
17
|
Description-Content-Type: text/markdown
|
|
14
18
|
|
|
15
19
|
# keylet -- Client library for Tillitis TKey
|
|
@@ -18,6 +22,15 @@ Description-Content-Type: text/markdown
|
|
|
18
22
|
|
|
19
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.
|
|
20
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
|
+
|
|
21
34
|
## Installation
|
|
22
35
|
|
|
23
36
|
```bash
|
|
@@ -26,7 +39,7 @@ pip install keylet
|
|
|
26
39
|
|
|
27
40
|
## CLI Usage
|
|
28
41
|
|
|
29
|
-
The package installs a `keylet` command-line tool
|
|
42
|
+
The package installs a `keylet` command-line tool for signing and verification. This is primarily a test/demo application for the library.
|
|
30
43
|
|
|
31
44
|
```bash
|
|
32
45
|
# Sign without a passphrase, then verify
|
|
@@ -74,3 +87,24 @@ with TKeySign(app=app, secret="hunter2") as signer:
|
|
|
74
87
|
```
|
|
75
88
|
|
|
76
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
|
+
```
|
|
@@ -4,6 +4,15 @@
|
|
|
4
4
|
|
|
5
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
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
|
+
|
|
7
16
|
## Installation
|
|
8
17
|
|
|
9
18
|
```bash
|
|
@@ -12,7 +21,7 @@ pip install keylet
|
|
|
12
21
|
|
|
13
22
|
## CLI Usage
|
|
14
23
|
|
|
15
|
-
The package installs a `keylet` command-line tool
|
|
24
|
+
The package installs a `keylet` command-line tool for signing and verification. This is primarily a test/demo application for the library.
|
|
16
25
|
|
|
17
26
|
```bash
|
|
18
27
|
# Sign without a passphrase, then verify
|
|
@@ -60,3 +69,24 @@ with TKeySign(app=app, secret="hunter2") as signer:
|
|
|
60
69
|
```
|
|
61
70
|
|
|
62
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
|
+
```
|
|
@@ -1,24 +1,28 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "keylet"
|
|
3
|
-
version = "0.2.
|
|
4
|
-
description = "Client application for Tillitis TKey hardware ML-DSA signer"
|
|
3
|
+
version = "0.2.1-dev"
|
|
4
|
+
description = "Client application for Tillitis TKey hardware ML-DSA/Ed25519 signer"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
authors = [
|
|
7
7
|
{ name = "Jussi Kukkonen", email = "jkukkonen@google.com" }
|
|
8
8
|
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
license-files = ["LICENSE"]
|
|
9
11
|
requires-python = ">=3.10"
|
|
10
12
|
dependencies = [
|
|
11
13
|
"cryptography>=48.0.0",
|
|
12
14
|
"pyserial>=3.5",
|
|
13
15
|
]
|
|
16
|
+
keywords = ["ML-DSA", "TKey", "Tillitis", "PQC", "sign", "signer"]
|
|
14
17
|
|
|
15
18
|
[project.scripts]
|
|
16
19
|
keylet = "keylet.bin.cli:main"
|
|
17
20
|
|
|
18
21
|
[project.urls]
|
|
22
|
+
Documentation = "https://jku.github.io/keylet/"
|
|
23
|
+
Homepage = "https://github.com/jku/keylet"
|
|
19
24
|
Issues = "https://github.com/jku/keylet/issues"
|
|
20
25
|
Source = "https://github.com/jku/keylet"
|
|
21
|
-
Documentation = "https://jku.github.io/keylet/"
|
|
22
26
|
|
|
23
27
|
[dependency-groups]
|
|
24
28
|
dev = [
|
|
@@ -28,58 +32,52 @@ dev = [
|
|
|
28
32
|
"zizmor",
|
|
29
33
|
"zensical",
|
|
30
34
|
"mkdocstrings[python]",
|
|
35
|
+
"types-pyserial",
|
|
31
36
|
]
|
|
32
37
|
|
|
38
|
+
# pin uv in setup-uv action
|
|
39
|
+
uv = ["uv"]
|
|
40
|
+
|
|
33
41
|
[build-system]
|
|
34
42
|
requires = ["uv_build>=0.11.23,<0.12.0"]
|
|
35
43
|
build-backend = "uv_build"
|
|
36
44
|
|
|
37
|
-
[tool.ruff]
|
|
38
|
-
line-length = 88
|
|
39
|
-
|
|
40
45
|
[tool.ruff.lint]
|
|
41
|
-
select = [
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
47
|
-
"
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
"S", # flake8-bandit (Security)
|
|
51
|
-
"SIM", # flake8-simplify
|
|
52
|
-
"PT", # flake8-pytest-style
|
|
53
|
-
"PTH", # flake8-use-pathlib
|
|
54
|
-
"TID", # flake8-tidy-imports
|
|
55
|
-
"T20", # flake8-print
|
|
56
|
-
"ARG", # flake8-unused-arguments
|
|
57
|
-
"SIM", # flake8-simplify
|
|
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
|
|
58
55
|
]
|
|
59
|
-
ignore = []
|
|
60
56
|
|
|
61
57
|
[tool.ruff.lint.per-file-ignores]
|
|
62
|
-
"src/keylet/bin
|
|
58
|
+
"src/keylet/bin/**" = [
|
|
59
|
+
"T201" # print is ok in CLI tool
|
|
60
|
+
]
|
|
63
61
|
"tests/**" = [
|
|
64
|
-
"S101", #
|
|
65
|
-
"S105", #
|
|
66
|
-
"PT009", # Use regular assert instead of unittest assertEqual
|
|
67
|
-
"PT027", # Use pytest.raises instead of unittest assertRaises
|
|
62
|
+
"S101", # assert is ok in tests
|
|
63
|
+
"S105", # hardcoded password is ok in tests
|
|
68
64
|
"ARG002", # Unused method argument (common with mocks)
|
|
69
65
|
]
|
|
70
66
|
|
|
71
|
-
|
|
72
|
-
|
|
73
67
|
[tool.mypy]
|
|
74
68
|
python_version = "3.10"
|
|
69
|
+
pretty = true
|
|
75
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
|
|
76
77
|
warn_unreachable = true
|
|
78
|
+
warn_unused_ignores = true
|
|
77
79
|
|
|
78
|
-
[
|
|
79
|
-
module = "serial.*"
|
|
80
|
-
ignore_missing_imports = true
|
|
81
|
-
|
|
82
|
-
[tool.pytest.ini_options]
|
|
80
|
+
[tool.pytest]
|
|
83
81
|
markers = [
|
|
84
82
|
"device: tests that require a physical TKey device",
|
|
85
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
|
|
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
|
-
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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:
|
|
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
|
|
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
|
|
370
|
-
#
|
|
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,16 +10,15 @@ import importlib.resources
|
|
|
10
10
|
import logging
|
|
11
11
|
from dataclasses import dataclass
|
|
12
12
|
|
|
13
|
-
from keylet.tkey import Cmd, LenIdx, Rsp, TKey,
|
|
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 signer binaries
|
|
20
|
+
# Static registry of signer binaries (filename, version)
|
|
21
21
|
# First binary in each list is the default binary.
|
|
22
|
-
# Format: (filename, version)
|
|
23
22
|
_EMBEDDED_MLDSA_BINS: list[tuple[str, int]] = [
|
|
24
23
|
("pqsigner_v3.bin", 3),
|
|
25
24
|
]
|
|
@@ -132,6 +131,9 @@ class SignApp:
|
|
|
132
131
|
TKey key derivation depends on the application binary, so users who want a
|
|
133
132
|
specific key must provide the binary digest.
|
|
134
133
|
|
|
134
|
+
Warning:
|
|
135
|
+
When Ed25519 is used, there is a 4096K size limit to signing payloads.
|
|
136
|
+
|
|
135
137
|
Args:
|
|
136
138
|
version: The version of the signer application to load.
|
|
137
139
|
digest: A BLAKE2s-256 hex digest (or prefix) of the target binary.
|
|
@@ -199,7 +201,7 @@ class TKeySign(TKey):
|
|
|
199
201
|
|
|
200
202
|
Raises:
|
|
201
203
|
TKeyNotFoundError: If the TKey device cannot be found.
|
|
202
|
-
|
|
204
|
+
TKeyUnexpectedAppError: If loading the application fails or the device is
|
|
203
205
|
running a mismatched application.
|
|
204
206
|
TKeyError: For other connection or initialization failures.
|
|
205
207
|
"""
|
|
@@ -220,7 +222,7 @@ class TKeySign(TKey):
|
|
|
220
222
|
if name == app.name and ver == app.version:
|
|
221
223
|
return # Signer application is already loaded
|
|
222
224
|
|
|
223
|
-
raise
|
|
225
|
+
raise TKeyUnexpectedAppError(
|
|
224
226
|
f"TKey is running an unknown application {name, ver}, "
|
|
225
227
|
f"expected {app.name, app.version}"
|
|
226
228
|
)
|
|
@@ -266,7 +268,9 @@ class TKeySign(TKey):
|
|
|
266
268
|
the physical TKey device when it flashes.
|
|
267
269
|
|
|
268
270
|
Args:
|
|
269
|
-
message: The raw bytes of the message/payload to sign.
|
|
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.
|
|
270
274
|
pub_key: The public key bytes (only needed for ML-DSA). If not provided,
|
|
271
275
|
key is retrieved from device.
|
|
272
276
|
|
|
@@ -1,158 +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.ed25519 import Ed25519PublicKey
|
|
10
|
-
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey
|
|
11
|
-
|
|
12
|
-
from keylet.tkey import TKeyNotFoundError
|
|
13
|
-
from keylet.tkey_sign import SignApp, TKeySign
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def cmd_pubkey(args: argparse.Namespace) -> int:
|
|
17
|
-
try:
|
|
18
|
-
if args.type == "ed25519":
|
|
19
|
-
app = SignApp.load_ed25519(digest=args.digest)
|
|
20
|
-
else:
|
|
21
|
-
app = SignApp.load_mldsa(digest=args.digest)
|
|
22
|
-
with TKeySign(app, secret=args.passphrase) as signer:
|
|
23
|
-
pubkey = signer.get_pubkey()
|
|
24
|
-
print(f"Using device app with digest {app.digest[:7]}.")
|
|
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
|
-
if args.type == "ed25519":
|
|
45
|
-
app = SignApp.load_ed25519(digest=args.digest)
|
|
46
|
-
else:
|
|
47
|
-
app = SignApp.load_mldsa(digest=args.digest)
|
|
48
|
-
with TKeySign(app, secret=args.passphrase) as signer:
|
|
49
|
-
print(f"Using device app with digest {app.digest[:7]}.")
|
|
50
|
-
print("Please touch the TKey device when it flashes to sign...")
|
|
51
|
-
signature = signer.sign(data)
|
|
52
|
-
|
|
53
|
-
sig_path = file_path.with_suffix(file_path.suffix + ".signature")
|
|
54
|
-
sig_path.write_bytes(signature)
|
|
55
|
-
print(f"Signature written to {sig_path}")
|
|
56
|
-
return 0
|
|
57
|
-
except TKeyNotFoundError as e:
|
|
58
|
-
print(f"Signing failed: {e}", file=sys.stderr)
|
|
59
|
-
return 1
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def cmd_verify(args: argparse.Namespace) -> int:
|
|
63
|
-
file_path = Path(args.file)
|
|
64
|
-
if not file_path.exists():
|
|
65
|
-
print(f"Error: File {args.file} does not exist", file=sys.stderr)
|
|
66
|
-
return 1
|
|
67
|
-
|
|
68
|
-
sig_path = (
|
|
69
|
-
Path(args.signature)
|
|
70
|
-
if args.signature
|
|
71
|
-
else file_path.with_suffix(file_path.suffix + ".signature")
|
|
72
|
-
)
|
|
73
|
-
if not sig_path.exists():
|
|
74
|
-
print(f"Error: Signature file {sig_path} does not exist", file=sys.stderr)
|
|
75
|
-
return 1
|
|
76
|
-
|
|
77
|
-
try:
|
|
78
|
-
file_bytes = file_path.read_bytes()
|
|
79
|
-
sig_bytes = sig_path.read_bytes()
|
|
80
|
-
|
|
81
|
-
# Get public key either from file or from device
|
|
82
|
-
if args.pubkey:
|
|
83
|
-
pubkey_bytes = Path(args.pubkey).read_bytes()
|
|
84
|
-
else:
|
|
85
|
-
if args.type == "ed25519":
|
|
86
|
-
app = SignApp.load_ed25519(digest=args.digest)
|
|
87
|
-
else:
|
|
88
|
-
app = SignApp.load_mldsa(digest=args.digest)
|
|
89
|
-
with TKeySign(app, secret=args.passphrase) as signer:
|
|
90
|
-
print(f"Using device app with digest {app.digest[:7]}.")
|
|
91
|
-
print("Retrieving public key from device...")
|
|
92
|
-
pubkey_bytes = signer.get_pubkey()
|
|
93
|
-
|
|
94
|
-
# Verify signature using cryptography library
|
|
95
|
-
if args.type == "ed25519":
|
|
96
|
-
ed_pubkey = Ed25519PublicKey.from_public_bytes(pubkey_bytes)
|
|
97
|
-
ed_pubkey.verify(sig_bytes, file_bytes)
|
|
98
|
-
else:
|
|
99
|
-
ml_pubkey = MLDSA44PublicKey.from_public_bytes(pubkey_bytes)
|
|
100
|
-
ml_pubkey.verify(sig_bytes, file_bytes)
|
|
101
|
-
print("Verification successful!")
|
|
102
|
-
return 0
|
|
103
|
-
except InvalidSignature:
|
|
104
|
-
print("Verification failed: Invalid signature", file=sys.stderr)
|
|
105
|
-
return 1
|
|
106
|
-
except TKeyNotFoundError as e:
|
|
107
|
-
print(f"Verification failed: {e}", file=sys.stderr)
|
|
108
|
-
return 1
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def main() -> None:
|
|
112
|
-
parser = argparse.ArgumentParser(
|
|
113
|
-
description="Tillitis TKey Keylet CLI testing tool"
|
|
114
|
-
)
|
|
115
|
-
parser.add_argument("--digest", help="The digest of the device application to use")
|
|
116
|
-
parser.add_argument("--passphrase", help="User Supplied Secret (passphrase)")
|
|
117
|
-
parser.add_argument(
|
|
118
|
-
"-t",
|
|
119
|
-
"--type",
|
|
120
|
-
choices=["ml-dsa", "ed25519"],
|
|
121
|
-
default="ml-dsa",
|
|
122
|
-
help="Signer type (default: %(default)s)",
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
126
|
-
|
|
127
|
-
# pubkey command
|
|
128
|
-
parser_pubkey = subparsers.add_parser("pubkey", help="Get public key from device")
|
|
129
|
-
parser_pubkey.add_argument(
|
|
130
|
-
"-o", "--output", help="Output file to write public key to"
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
# sign command
|
|
134
|
-
parser_sign = subparsers.add_parser("sign", help="Sign a file")
|
|
135
|
-
parser_sign.add_argument("file", help="File to sign")
|
|
136
|
-
|
|
137
|
-
# verify command
|
|
138
|
-
parser_verify = subparsers.add_parser("verify", help="Verify a signature")
|
|
139
|
-
parser_verify.add_argument("file", help="File that was signed")
|
|
140
|
-
parser_verify.add_argument(
|
|
141
|
-
"--signature", help="Signature file (defaults to <FILE>.signature)"
|
|
142
|
-
)
|
|
143
|
-
parser_verify.add_argument(
|
|
144
|
-
"--pubkey", help="Public key file (retrieved from device if not specified)"
|
|
145
|
-
)
|
|
146
|
-
|
|
147
|
-
args = parser.parse_args()
|
|
148
|
-
|
|
149
|
-
if args.command == "pubkey":
|
|
150
|
-
sys.exit(cmd_pubkey(args))
|
|
151
|
-
elif args.command == "sign":
|
|
152
|
-
sys.exit(cmd_sign(args))
|
|
153
|
-
elif args.command == "verify":
|
|
154
|
-
sys.exit(cmd_verify(args))
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
if __name__ == "__main__":
|
|
158
|
-
main()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|