|
| 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()) |
0 commit comments