Skip to content

Commit 9d1cc9f

Browse files
committed
age plugin PQ fixes + 1.2.11 packaging (onto composite-PQC master)
- age_plugin: user ECC slots 101-116 (not 133/134), key-type byte, OKSETPRIV 0xFF keygen, multi-packet decaps, slot validation + hardware-free tests - client.py: prefer hidraw backend (trustcrypto#89) [surgical — composite PQC work preserved] - setup.py: enable long_description (markdown), bump 1.2.10 -> 1.2.11; MANIFEST.in
1 parent 2c364aa commit 9d1cc9f

8 files changed

Lines changed: 517 additions & 79 deletions

File tree

MANIFEST.in

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
include README.md

onlykey/age_plugin/__init__.py

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,56 @@
11
"""age-plugin-onlykey: age encryption plugin for OnlyKey hardware tokens.
22
33
Supports ML-KEM-768 (FIPS 203) and X-Wing hybrid KEM
4-
(draft-connolly-cfrg-xwing-kem-09) for post-quantum encryption,
5-
compatible with age v1.3.0's mlkem768x25519 recipient type.
4+
(draft-connolly-cfrg-xwing-kem-09) for post-quantum encryption.
5+
6+
The plugin produces native ``mlkem768x25519`` stanzas using OnlyKey-managed
7+
X-Wing keys. This is narrower and more precise than claiming blanket
8+
compatibility with every age recipient or identity flow.
69
"""
710

811
__version__ = "0.1.0"
912
PLUGIN_NAME = "onlykey"
1013

11-
# OnlyKey HID message types (from onlykey.client.Message)
12-
OKGETPUBKEY = 236
13-
OKDECRYPT = 240
14-
OKGENKEY = 230
14+
# OnlyKey HID opcodes (values match onlykey.client.Message)
15+
OKGETPUBKEY = 236 # Message.OKGETPUBKEY
16+
OKDECRYPT = 240 # Message.OKDECRYPT
17+
OKSETPRIV = 239 # Message.OKSETPRIV — also generates an on-device key when
18+
# the key body is all 0xFF (firmware okcore.cpp set_private
19+
# -> okcrypto_generate_random_key)
20+
21+
# ECC key slots that can hold the 32-byte post-quantum seed.
22+
#
23+
# A ML-KEM/X-Wing key is just a 32-byte seed stored in an ordinary ECC key
24+
# slot, and the algorithm is chosen by the key-type byte (buffer[6]) below —
25+
# not by the slot number. The slot is therefore caller-selectable, but only
26+
# across the USER key slots: firmware exposes 101-116 (16 slots) for user keys.
27+
# Slots 117-132 are RESERVED (e.g. 128 web-derivation, 129/130 HMAC, 131
28+
# backup, 132 derivation) and must never be used for PQ keys — writing there
29+
# would clobber internal device keys. Firmware getpubkey/decaps enforce this
30+
# with a `< 117` gate (okcrypto.cpp); the host mirrors it here.
31+
ECC_SLOT_MIN = 101
32+
ECC_SLOT_MAX = 116 # 16 user ECC key slots: 101-116 (117-132 reserved)
33+
DEFAULT_XWING_SLOT = 101
34+
DEFAULT_MLKEM_SLOT = 102
35+
36+
37+
def validate_ecc_slot(slot):
38+
"""Raise ValueError unless slot is a user ECC key slot (101-116).
39+
40+
117-132 are reserved by firmware and are intentionally rejected.
41+
"""
42+
if not (ECC_SLOT_MIN <= int(slot) <= ECC_SLOT_MAX):
43+
raise ValueError(
44+
f"PQ key slot must be a user ECC slot {ECC_SLOT_MIN}-{ECC_SLOT_MAX} "
45+
f"(117-132 are reserved), got {slot}"
46+
)
47+
return int(slot)
48+
49+
# Firmware key-type identifiers (okcore.h: KEYTYPE_MLKEM768 / KEYTYPE_XWING).
50+
# Sent in the low nibble of buffer[6] so the device routes the operation.
51+
KEYTYPE_MLKEM768 = 5
52+
KEYTYPE_XWING = 6
1553

16-
# OnlyKey reserved key slots for post-quantum
17-
SLOT_MLKEM = 133
18-
SLOT_XWING = 134
54+
# An all-0xFF key body sent with OKSETPRIV tells the firmware to generate the
55+
# key on-device (gen_key trigger in okcore.cpp).
56+
GENERATE_ON_DEVICE = b"\xff" * 8

onlykey/age_plugin/cli.py

Lines changed: 136 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,11 @@
1818
import sys
1919
import os
2020
import base64
21+
import hashlib
2122

22-
from onlykey.age_plugin import __version__, PLUGIN_NAME, SLOT_XWING
23+
from onlykey.age_plugin import (
24+
__version__, PLUGIN_NAME, DEFAULT_XWING_SLOT, validate_ecc_slot,
25+
)
2326
from onlykey.age_plugin.protocol import (
2427
Stanza, b64encode_no_pad, b64decode_no_pad,
2528
run_identity_v1, run_recipient_v1,
@@ -111,6 +114,12 @@ def bech32_decode(bech: str):
111114
RECIPIENT_HRP = "age1onlykey"
112115
IDENTITY_HRP = "age-plugin-onlykey-" # uppercase AGE-PLUGIN-ONLYKEY- in file
113116

117+
IDENTITY_VERSION = 1
118+
IDENTITY_FINGERPRINT_LEN = 8
119+
XWING_RECIPIENT_LEN = 1216
120+
XWING_STANZA_ENC_LEN = 1120
121+
FILE_KEY_LEN = 32
122+
114123

115124
def encode_recipient(pubkey: bytes) -> str:
116125
"""Encode X-Wing public key as age recipient string."""
@@ -125,7 +134,12 @@ def decode_recipient(recipient: str) -> bytes:
125134
return data
126135

127136

128-
def encode_identity(slot: int = SLOT_XWING) -> str:
137+
def recipient_fingerprint(pubkey: bytes) -> bytes:
138+
"""Return a short fingerprint for identity binding."""
139+
return hashlib.sha256(pubkey).digest()[:IDENTITY_FINGERPRINT_LEN]
140+
141+
142+
def encode_identity(slot: int = DEFAULT_XWING_SLOT) -> str:
129143
"""Encode an identity string. Contains just the slot number."""
130144
# Identity data: just the slot byte
131145
data = bytes([slot])
@@ -135,26 +149,52 @@ def encode_identity(slot: int = SLOT_XWING) -> str:
135149
)
136150

137151

138-
def decode_identity(identity: str) -> int:
139-
"""Decode identity string, returns slot number."""
152+
def decode_identity(identity: str) -> dict:
153+
"""Decode identity string.
154+
155+
Returns a mapping with ``slot``, ``fingerprint``, and ``legacy`` keys.
156+
Legacy one-byte identities are supported so older files continue to work.
157+
"""
140158
hrp, data = bech32_decode(identity.lower())
141-
if data is None or len(data) < 1:
159+
if hrp != IDENTITY_HRP or data is None or len(data) < 1:
142160
raise ValueError(f"Invalid OnlyKey identity: {identity}")
143-
return data[0]
144161

162+
if len(data) == 1:
163+
return {
164+
"slot": data[0],
165+
"fingerprint": None,
166+
"legacy": True,
167+
}
168+
169+
if len(data) != 2 + IDENTITY_FINGERPRINT_LEN:
170+
raise ValueError(
171+
f"Invalid OnlyKey identity payload length: {len(data)}"
172+
)
173+
174+
version = data[0]
175+
if version != IDENTITY_VERSION:
176+
raise ValueError(f"Unsupported OnlyKey identity version: {version}")
177+
178+
return {
179+
"slot": data[1],
180+
"fingerprint": data[2:],
181+
"legacy": False,
182+
}
145183

146-
def cmd_generate():
184+
185+
186+
def cmd_generate(slot: int = DEFAULT_XWING_SLOT):
147187
"""Generate X-Wing keypair on OnlyKey and print recipient/identity."""
148188
from onlykey.age_plugin.onlykey_hid import OnlyKeyPQ
149189

150-
print("Generating X-Wing keypair on OnlyKey...", file=sys.stderr)
190+
print(f"Generating X-Wing keypair on OnlyKey (ECC slot {slot})...", file=sys.stderr)
151191
dev = OnlyKeyPQ()
152-
pk = dev.xwing_keygen()
192+
pk = dev.xwing_keygen(slot)
153193

154194
recipient = encode_recipient(pk)
155-
identity = encode_identity(SLOT_XWING)
195+
identity = encode_identity(slot)
156196

157-
print(f"# X-Wing public key (age v1.3.0 mlkem768x25519 compatible)", file=sys.stderr)
197+
print("# X-Wing public key (produces native age mlkem768x25519 stanzas)", file=sys.stderr)
158198
print(f"# Recipient: {recipient}", file=sys.stderr)
159199
print(file=sys.stderr)
160200

@@ -164,29 +204,73 @@ def cmd_generate():
164204
print(identity)
165205

166206

167-
def cmd_recipient():
168-
"""Print the current X-Wing public key as an age recipient."""
207+
def cmd_recipient(slot: int = DEFAULT_XWING_SLOT):
208+
"""Print the X-Wing public key in the given ECC slot as an age recipient."""
169209
from onlykey.age_plugin.onlykey_hid import OnlyKeyPQ
170210

171211
dev = OnlyKeyPQ()
172-
pk = dev.xwing_getpubkey()
212+
pk = dev.xwing_getpubkey(slot)
173213
print(encode_recipient(pk))
174214

175215

176-
def cmd_identity():
216+
def cmd_identity(slot: int = DEFAULT_XWING_SLOT):
177217
"""Print an identity file for use with age -i."""
178-
identity = encode_identity(SLOT_XWING)
179-
print(f"# age-plugin-onlykey identity (X-Wing slot {SLOT_XWING})")
218+
identity = encode_identity(slot)
219+
print(f"# age-plugin-onlykey identity (X-Wing ECC slot {slot})")
180220
print(identity)
181221

182222

223+
def _parse_onlykey_identities(identities):
224+
parsed = []
225+
for identity in identities:
226+
try:
227+
parsed.append(decode_identity(identity))
228+
except ValueError:
229+
continue
230+
return parsed
231+
232+
183233
def unwrap_callback(identities, stanzas_per_file):
184234
"""Plugin identity-v1 callback: unwrap file keys using OnlyKey."""
185235
from onlykey.age_plugin.onlykey_hid import OnlyKeyPQ
186236
from onlykey.age_plugin.xwing import open_file_key
187237

188238
results = []
189-
dev = None
239+
parsed_identities = _parse_onlykey_identities(identities)
240+
if not parsed_identities:
241+
print("No valid OnlyKey identity supplied.", file=sys.stderr)
242+
return results
243+
244+
dev = OnlyKeyPQ()
245+
246+
# The identity carries the ECC slot the key lives in; any of the 32 ECC
247+
# slots (101-132) is valid. Query that slot's public key and match it.
248+
matching_identity = None
249+
matching_slot = None
250+
for identity in parsed_identities:
251+
try:
252+
slot = validate_ecc_slot(identity["slot"])
253+
except ValueError:
254+
continue
255+
try:
256+
device_pubkey = dev.xwing_getpubkey(slot)
257+
except Exception as exc:
258+
print(f"Could not read X-Wing key in slot {slot}: {exc}", file=sys.stderr)
259+
continue
260+
if len(device_pubkey) != XWING_RECIPIENT_LEN:
261+
continue
262+
if (identity["fingerprint"] is None
263+
or identity["fingerprint"] == recipient_fingerprint(device_pubkey)):
264+
matching_identity = identity
265+
matching_slot = slot
266+
break
267+
268+
if matching_identity is None:
269+
print(
270+
"OnlyKey identity does not match the connected device's X-Wing public key.",
271+
file=sys.stderr,
272+
)
273+
return results
190274

191275
for file_idx, stanzas in stanzas_per_file.items():
192276
for stanza in stanzas:
@@ -195,27 +279,32 @@ def unwrap_callback(identities, stanzas_per_file):
195279
continue
196280

197281
if len(stanza.args) != 1:
198-
continue
282+
raise ValueError(
283+
f"Malformed mlkem768x25519 stanza: expected 1 arg, got {len(stanza.args)}"
284+
)
199285

200286
# Parse the ciphertext from the stanza argument
201287
try:
202288
enc = b64decode_no_pad(stanza.args[0])
203-
except Exception:
204-
continue
289+
except Exception as exc:
290+
raise ValueError(
291+
"Malformed mlkem768x25519 stanza: invalid base64 ciphertext"
292+
) from exc
205293

206-
if len(enc) != 1120:
207-
continue
294+
if len(enc) != XWING_STANZA_ENC_LEN:
295+
raise ValueError(
296+
f"Malformed mlkem768x25519 stanza: ciphertext must be {XWING_STANZA_ENC_LEN} bytes, got {len(enc)}"
297+
)
208298

209299
# Body must be exactly 32 bytes
210-
if len(stanza.body) != 32:
211-
continue
300+
if len(stanza.body) != FILE_KEY_LEN:
301+
raise ValueError(
302+
f"Malformed mlkem768x25519 stanza body: expected {FILE_KEY_LEN} bytes, got {len(stanza.body)}"
303+
)
212304

213-
# Connect to OnlyKey if not already
214-
if dev is None:
215-
dev = OnlyKeyPQ()
216305

217-
# Send ciphertext to OnlyKey for decapsulation
218-
ss = dev.xwing_decaps(enc)
306+
# Send ciphertext to OnlyKey for decapsulation (in the matched slot)
307+
ss = dev.xwing_decaps(enc, slot=matching_slot)
219308

220309
# Use shared secret to decrypt the file key via HPKE
221310
try:
@@ -281,13 +370,26 @@ def main():
281370
print(f"Unknown state machine: {state_machine}", file=sys.stderr)
282371
sys.exit(1)
283372

373+
# Optional --slot N / --slot=N selects which ECC slot (101-132) holds the key.
374+
slot = DEFAULT_XWING_SLOT
375+
for i, arg in enumerate(args):
376+
if arg == "--slot" and i + 1 < len(args):
377+
slot = args[i + 1]
378+
elif arg.startswith("--slot="):
379+
slot = arg.split("=", 1)[1]
380+
try:
381+
slot = validate_ecc_slot(slot)
382+
except ValueError as exc:
383+
print(f"Error: {exc}", file=sys.stderr)
384+
sys.exit(1)
385+
284386
# Direct invocation modes
285387
if "--generate" in args or "-g" in args:
286-
cmd_generate()
388+
cmd_generate(slot)
287389
elif "--recipient" in args or "-r" in args:
288-
cmd_recipient()
390+
cmd_recipient(slot)
289391
elif "--identity" in args or "-i" in args:
290-
cmd_identity()
392+
cmd_identity(slot)
291393
elif "--version" in args or "-v" in args:
292394
print(f"age-plugin-onlykey {__version__}")
293395
elif "--help" in args or "-h" in args:
@@ -301,6 +403,7 @@ def main():
301403
print(" --generate Generate X-Wing keypair on OnlyKey")
302404
print(" --recipient Print recipient (public key) for encryption")
303405
print(" --identity Print identity file for decryption")
406+
print(" --slot N User ECC slot 101-116 to use (default 101)")
304407
print(" --help Show full help")
305408
print()
306409
print("Quick start:")

0 commit comments

Comments
 (0)