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