1818import sys
1919import os
2020import 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+ )
2326from 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):
111114RECIPIENT_HRP = "age1onlykey"
112115IDENTITY_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
115124def 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+
183233def 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