Skip to content

Commit adfa984

Browse files
committed
Add TKeySigner
This supports a Tillitis TKey hardware signer via a new python library 'keylet' that I just released. The hardware key is somewhat unique in that the key is not stored long term: it's always generated from device id, passphrase and the signer binary (that always uploaded to device). This is why the private key uri encodes the device binary hash: The signing key can only be used with the exact same device binary and passphrase. Signed-off-by: Jussi Kukkonen <jkukkonen@google.com>
1 parent f39ff68 commit adfa984

7 files changed

Lines changed: 758 additions & 7 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ hsm = ["asn1crypto", "cryptography>=48.0.0", "PyKCS11"]
5252
PySPX = ["PySPX>=0.5.0"]
5353
sigstore = ["sigstore>=4,<5"]
5454
vault = ["hvac", "cryptography>=48.0.0"]
55+
tkey = ["keylet", "cryptography>=48.0.0"]
5556

5657
[tool.hatch.version]
5758
path = "securesystemslib/__init__.py"

requirements-pinned.txt

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,22 @@
1-
#
2-
# This file is autogenerated by pip-compile with Python 3.11
3-
# by the following command:
4-
#
5-
# pip-compile --output-file=requirements-pinned.txt requirements.txt
6-
#
1+
# This file was autogenerated by uv via the following command:
2+
# uv pip compile requirements.txt -o requirements-pinned.txt
73
asn1crypto==1.5.1
84
# via -r requirements.txt
95
cffi==2.0.0
106
# via
117
# cryptography
128
# pyspx
139
cryptography==48.0.0
10+
# via
11+
# -r requirements.txt
12+
# keylet
13+
keylet==0.1.0
1414
# via -r requirements.txt
1515
pycparser==3.0
1616
# via cffi
1717
pykcs11==1.5.18
1818
# via -r requirements.txt
19-
pyspx==0.5.0 ; platform_system != "Windows"
19+
pyserial==3.5
20+
# via keylet
21+
pyspx==0.5.0
2022
# via -r requirements.txt

requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,4 @@ cryptography >= 48.0.0
99
PySPX; platform_system != 'Windows'
1010
PyKCS11
1111
asn1crypto
12+
keylet

securesystemslib/signer/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
SpxSigner,
2626
generate_spx_key_pair,
2727
)
28+
from securesystemslib.signer._tkey_signer import TKeySigner
2829
from securesystemslib.signer._vault_signer import VaultSigner
2930

3031
# Register supported private key uri schemes and the Signers implementing them
@@ -37,6 +38,7 @@
3738
AzureSigner.SCHEME: AzureSigner,
3839
AWSSigner.SCHEME: AWSSigner,
3940
VaultSigner.SCHEME: VaultSigner,
41+
TKeySigner.SCHEME: TKeySigner,
4042
}
4143
)
4244

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,182 @@
1+
"""ML-DSA-44 Signer for Tillitis TKey"""
2+
3+
from __future__ import annotations
4+
5+
import hashlib
6+
import logging
7+
from urllib import parse
8+
9+
from securesystemslib.exceptions import UnsupportedLibraryError
10+
from securesystemslib.signer._key import Key, SSlibKey
11+
from securesystemslib.signer._signature import Signature
12+
from securesystemslib.signer._signer import SecretsHandler, Signer
13+
14+
TKEY_IMPORT_ERROR = None
15+
try:
16+
from cryptography.hazmat.primitives import serialization
17+
from cryptography.hazmat.primitives.asymmetric.mldsa import MLDSA44PublicKey
18+
from keylet import SignApp, TKeySign
19+
except ImportError as e:
20+
TKEY_IMPORT_ERROR = f"TKeySigner: {e}"
21+
22+
23+
logger = logging.getLogger(__name__)
24+
25+
26+
class TKeySigner(Signer):
27+
"""Tillitis TKey Signer.
28+
29+
Supports signing scheme "ml-dsa-44/1".
30+
31+
The private key URI is
32+
tkey:[device_path]?digest=<hex_prefix>&[passphrase=true]
33+
34+
digest is required in the URI: The device binary (identified by its
35+
digest hash prefix) is part of the private key seed. A key can only
36+
be used with the same exact binary.
37+
38+
device_path is not required and is not typically useful as the device association
39+
may be dynamic.
40+
41+
Examples:
42+
tkey:?digest=7c75714
43+
tkey:?digest=7c75714&passphrase=true
44+
tkey:/dev/ttyACM0?digest=7c75714&passphrase=true
45+
"""
46+
47+
# TODO the privkey uri possibly should include binary hash as it is critical that
48+
# the binary does not change: Unsure uf we can rely on it otherwise
49+
50+
SCHEME = "tkey"
51+
52+
def __init__(
53+
self,
54+
device_path: str | None,
55+
public_key: SSlibKey,
56+
secrets_handler: SecretsHandler | None = None,
57+
digest: str | None = None,
58+
) -> None:
59+
if TKEY_IMPORT_ERROR:
60+
raise UnsupportedLibraryError(TKEY_IMPORT_ERROR)
61+
62+
self._public_key = public_key
63+
64+
passphrase = secrets_handler("Passphrase") if secrets_handler else None
65+
app = SignApp.load_mldsa(digest=digest)
66+
self._tkey = TKeySign(app, device_path, passphrase)
67+
68+
# key derivation depends on passphrase: compare keys to make sure
69+
raw_pubkey = self._tkey.get_pubkey()
70+
if public_key.scheme == "ml-dsa-44/1":
71+
key = SSlibKey.from_crypto(MLDSA44PublicKey.from_public_bytes(raw_pubkey))
72+
else:
73+
raise ValueError(f"unsupported scheme {public_key.scheme}")
74+
75+
if key.keyval != self.public_key.keyval:
76+
raise RuntimeError(
77+
"TKey public key does not match: This could mean incorrect Passphrase."
78+
)
79+
80+
@property
81+
def public_key(self) -> SSlibKey:
82+
return self._public_key
83+
84+
@classmethod
85+
def from_priv_key_uri(
86+
cls,
87+
priv_key_uri: str,
88+
public_key: Key,
89+
secrets_handler: SecretsHandler | None = None,
90+
) -> TKeySigner:
91+
if not isinstance(public_key, SSlibKey):
92+
raise ValueError(f"expected SSlibKey for {priv_key_uri}")
93+
94+
uri = parse.urlparse(priv_key_uri)
95+
if uri.scheme != cls.SCHEME:
96+
raise ValueError(f"TKeySigner does not support {priv_key_uri}")
97+
98+
# Extract device path (empty or "/" triggers auto-detect)
99+
device_path = uri.path if uri.path not in ("", "/") else None
100+
101+
# Extract query parameters
102+
query_params = parse.parse_qs(uri.query)
103+
104+
digest = None
105+
if "digest" in query_params:
106+
digest = query_params["digest"][0]
107+
108+
if digest is None:
109+
raise ValueError("TKey URI must include 'digest'")
110+
111+
pass_str = query_params.get("passphrase", ["false"])[0]
112+
if pass_str.lower() != "true":
113+
secrets_handler = None
114+
elif secrets_handler is None:
115+
raise ValueError(
116+
"TKey URI has 'passphrase' but no secrets_handler was given"
117+
)
118+
119+
return cls(
120+
device_path,
121+
public_key=public_key,
122+
secrets_handler=secrets_handler,
123+
digest=digest,
124+
)
125+
126+
@classmethod
127+
def import_(
128+
cls,
129+
digest: str | None = None,
130+
device_path: str | None = None,
131+
passphrase: str | None = None,
132+
) -> tuple[str, SSlibKey]:
133+
"""Import public key and signer details from a TKey device.
134+
135+
Args:
136+
digest: Optional digest or digest prefix of device binary.
137+
device_path: Optional COM port path. Typically not useful as the port may
138+
be dynamic
139+
passphrase: Optional "User Supplied Secret". Will be used as part of the
140+
seed for the private key
141+
"""
142+
if TKEY_IMPORT_ERROR:
143+
raise UnsupportedLibraryError(TKEY_IMPORT_ERROR)
144+
145+
app = SignApp.load_mldsa(digest=digest)
146+
with TKeySign(app, device_path, passphrase) as tk:
147+
raw_pubkey = tk.get_pubkey()
148+
149+
# Build URI with digest prefix and optional passphrase boolean
150+
query = {"digest": app.digest[:7]}
151+
152+
if passphrase is not None:
153+
query["passphrase"] = "true" # noqa: S105
154+
155+
key = SSlibKey.from_crypto(MLDSA44PublicKey.from_public_bytes(raw_pubkey))
156+
157+
# Only encode path if it was explicitly passed as argument
158+
path = device_path if device_path is not None else ""
159+
uri = f"{cls.SCHEME}:{path}?{parse.urlencode(query)}"
160+
161+
return uri, key
162+
163+
def sign(self, payload: bytes) -> Signature:
164+
"""Signs payload with Tillitis TKey."""
165+
166+
if self.public_key.keytype != "ml-dsa":
167+
raise ValueError(f"unsupported keytype {self.public_key.keytype}")
168+
169+
# Use TUF-specific message prefix and digest as payload
170+
digest = hashlib.sha512(payload).digest()
171+
msg = b"tuf" + bytes([1]) + digest
172+
173+
# Provide the pub key bytes for mu calculation
174+
pk_pem = self.public_key.keyval["public"].encode("utf-8")
175+
public_key = serialization.load_pem_public_key(pk_pem)
176+
key_bytes = public_key.public_bytes(
177+
encoding=serialization.Encoding.Raw,
178+
format=serialization.PublicFormat.Raw,
179+
)
180+
181+
sig_bytes = self._tkey.sign(msg, key_bytes)
182+
return Signature(self.public_key.keyid, sig_bytes.hex())

tests/check_tkey_signer.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import unittest
2+
3+
from securesystemslib.signer import Signer, TKeySigner
4+
5+
6+
class TestTKeySigner(unittest.TestCase):
7+
def test_tkey_signer_import_and_sign(self) -> None:
8+
"""This test requires
9+
* a physical Tillitis TKey to be connected
10+
* a touch on the key when it blinks green
11+
"""
12+
13+
def passphrase(_: str) -> str:
14+
return "hunter2"
15+
16+
uri, pub_key = TKeySigner.import_(passphrase="hunter2")
17+
18+
self.assertEqual(pub_key.keytype, "ml-dsa")
19+
self.assertEqual(pub_key.scheme, "ml-dsa-44/1")
20+
self.assertTrue(uri.startswith("tkey:"))
21+
self.assertTrue("digest=" in uri)
22+
self.assertTrue("passphrase=true" in uri)
23+
24+
# Get a signer from standard Signer factory, sign
25+
signer = Signer.from_priv_key_uri(uri, pub_key, passphrase)
26+
signature = signer.sign(b"PQC!")
27+
28+
pub_key.verify_signature(signature, b"PQC!")
29+
30+
31+
if __name__ == "__main__":
32+
unittest.main()

0 commit comments

Comments
 (0)