cryptnox-id-cli 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. cryptnox_id_cli/__init__.py +22 -0
  2. cryptnox_id_cli/__main__.py +6 -0
  3. cryptnox_id_cli/applets/__init__.py +2 -0
  4. cryptnox_id_cli/applets/fido/__init__.py +7 -0
  5. cryptnox_id_cli/applets/fido/authdata.py +81 -0
  6. cryptnox_id_cli/applets/fido/constants.py +155 -0
  7. cryptnox_id_cli/applets/fido/ctap.py +430 -0
  8. cryptnox_id_cli/applets/fido/errors.py +90 -0
  9. cryptnox_id_cli/applets/fido/pinproto.py +166 -0
  10. cryptnox_id_cli/applets/genuine/__init__.py +12 -0
  11. cryptnox_id_cli/applets/genuine/constants.py +32 -0
  12. cryptnox_id_cli/applets/genuine/genuine.py +85 -0
  13. cryptnox_id_cli/applets/genuine/verify.py +150 -0
  14. cryptnox_id_cli/applets/mifare/__init__.py +10 -0
  15. cryptnox_id_cli/applets/mifare/desfire.py +353 -0
  16. cryptnox_id_cli/applets/mifare/ev2.py +421 -0
  17. cryptnox_id_cli/applets/piv/__init__.py +9 -0
  18. cryptnox_id_cli/applets/piv/admin.py +182 -0
  19. cryptnox_id_cli/applets/piv/apt.py +58 -0
  20. cryptnox_id_cli/applets/piv/constants.py +66 -0
  21. cryptnox_id_cli/applets/piv/keyimport.py +244 -0
  22. cryptnox_id_cli/applets/piv/objects.py +126 -0
  23. cryptnox_id_cli/applets/piv/perso.py +138 -0
  24. cryptnox_id_cli/applets/piv/piv.py +171 -0
  25. cryptnox_id_cli/applets/piv/preperso.py +174 -0
  26. cryptnox_id_cli/applets/piv/profiles.py +486 -0
  27. cryptnox_id_cli/applets/piv/slots.py +44 -0
  28. cryptnox_id_cli/cli/__init__.py +1 -0
  29. cryptnox_id_cli/cli/commands/__init__.py +1 -0
  30. cryptnox_id_cli/cli/commands/apdu.py +123 -0
  31. cryptnox_id_cli/cli/commands/doctor.py +144 -0
  32. cryptnox_id_cli/cli/commands/factory.py +312 -0
  33. cryptnox_id_cli/cli/commands/fido.py +729 -0
  34. cryptnox_id_cli/cli/commands/genuine.py +192 -0
  35. cryptnox_id_cli/cli/commands/info.py +94 -0
  36. cryptnox_id_cli/cli/commands/mifare.py +998 -0
  37. cryptnox_id_cli/cli/commands/piv.py +2558 -0
  38. cryptnox_id_cli/cli/commands/readers.py +64 -0
  39. cryptnox_id_cli/cli/commands/report.py +217 -0
  40. cryptnox_id_cli/cli/commands/shell.py +130 -0
  41. cryptnox_id_cli/cli/context.py +71 -0
  42. cryptnox_id_cli/cli/dryrun.py +181 -0
  43. cryptnox_id_cli/cli/main.py +117 -0
  44. cryptnox_id_cli/crypto/__init__.py +1 -0
  45. cryptnox_id_cli/crypto/attestation.py +169 -0
  46. cryptnox_id_cli/crypto/csr.py +135 -0
  47. cryptnox_id_cli/crypto/piv_objects.py +79 -0
  48. cryptnox_id_cli/crypto/x509util.py +37 -0
  49. cryptnox_id_cli/output/__init__.py +5 -0
  50. cryptnox_id_cli/output/render.py +106 -0
  51. cryptnox_id_cli/secrets/__init__.py +5 -0
  52. cryptnox_id_cli/secrets/redaction.py +141 -0
  53. cryptnox_id_cli/secrets/resolver.py +93 -0
  54. cryptnox_id_cli/state/__init__.py +19 -0
  55. cryptnox_id_cli/state/detector.py +273 -0
  56. cryptnox_id_cli/state/model.py +147 -0
  57. cryptnox_id_cli/transport/__init__.py +28 -0
  58. cryptnox_id_cli/transport/apdu.py +72 -0
  59. cryptnox_id_cli/transport/elevation.py +165 -0
  60. cryptnox_id_cli/transport/errors.py +185 -0
  61. cryptnox_id_cli/transport/pcsc.py +351 -0
  62. cryptnox_id_cli/transport/scp02.py +247 -0
  63. cryptnox_id_cli/transport/scp03.py +213 -0
  64. cryptnox_id_cli/trust/__init__.py +90 -0
  65. cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
  66. cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
  67. cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
  68. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
  69. cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
  70. cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
  71. cryptnox_id_cli/util/__init__.py +1 -0
  72. cryptnox_id_cli/util/hexutil.py +36 -0
  73. cryptnox_id_cli/util/tlv.py +144 -0
  74. cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
  75. cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
  76. cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
  77. cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
  78. cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
  79. cryptnox_id_cli-1.0.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,430 @@
1
+ """CTAP2-over-APDU client (read-only commands for Phase 7A).
2
+
3
+ Framing (verified against the applet source and the Android client):
4
+ ``CLA 0x80, INS 0x10, P1 0x00, P2 0x00``, data = ``[CTAP cmd byte][CBOR params]``.
5
+ The response is ``[CTAP status byte][CBOR payload]``; ``61xx``/GET RESPONSE
6
+ chaining is handled by the transport layer.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import uuid
12
+ from typing import Any
13
+
14
+ import cbor2
15
+
16
+ from cryptnox_id_cli.applets.fido import constants as c
17
+ from cryptnox_id_cli.applets.fido import pinproto
18
+ from cryptnox_id_cli.applets.fido.authdata import parse_authenticator_data
19
+ from cryptnox_id_cli.applets.fido.errors import CtapStatusError
20
+ from cryptnox_id_cli.transport.apdu import APDU
21
+ from cryptnox_id_cli.transport.errors import AppletNotFoundError, StatusWordError
22
+ from cryptnox_id_cli.transport.pcsc import CardSession
23
+
24
+
25
+ class Ctap2Client:
26
+ def __init__(self, session: CardSession) -> None:
27
+ self.s = session
28
+
29
+ # -- selection ----------------------------------------------------------- #
30
+ def select(self) -> str:
31
+ """SELECT the FIDO applet; returns the version string from the response."""
32
+ resp = self.s.transmit(
33
+ APDU(0x00, 0xA4, 0x04, 0x00, data=c.FIDO_AID, le=256), context="SELECT FIDO"
34
+ )
35
+ if resp.sw == 0x6A82:
36
+ raise AppletNotFoundError("FIDO2 applet not found on this card.")
37
+ if not resp.ok:
38
+ raise StatusWordError(resp.sw1, resp.sw2, context="SELECT FIDO")
39
+ return resp.data.decode("ascii", "replace") or "(no version string)"
40
+
41
+ # -- CTAP message -------------------------------------------------------- #
42
+ def command(self, cmd: int, params: dict | None = None, *, context: str | None = None) -> Any:
43
+ # CTAP2 mandates canonical CBOR (map keys sorted by encoded form). Without it,
44
+ # a strict authenticator misparses out-of-order maps -> CTAP2_ERR_MISSING_PARAMETER.
45
+ payload = bytes([cmd]) + (
46
+ cbor2.dumps(params, canonical=True) if params is not None else b""
47
+ )
48
+ resp = self.s.transmit(
49
+ APDU(0x80, c.INS_CTAP_MSG, 0x00, 0x00, data=payload, le=256),
50
+ context=context or f"CTAP 0x{cmd:02X}",
51
+ )
52
+ if not resp.ok:
53
+ raise StatusWordError(resp.sw1, resp.sw2, context=context or "CTAP")
54
+ if not resp.data:
55
+ raise CtapStatusError(0x7F, context=context)
56
+ status = resp.data[0]
57
+ if status != 0x00:
58
+ raise CtapStatusError(status, context=context)
59
+ return cbor2.loads(resp.data[1:]) if len(resp.data) > 1 else None
60
+
61
+ def get_info(self) -> dict:
62
+ info = self.command(c.CTAP_GET_INFO, context="authenticatorGetInfo")
63
+ if not isinstance(info, dict):
64
+ raise CtapStatusError(0x12, context="authenticatorGetInfo")
65
+ return info
66
+
67
+ def pin_retries(self) -> tuple[int | None, bool | None]:
68
+ """clientPIN getPinRetries → (retries, powerCycleState). Consumes nothing."""
69
+ resp = self.command(
70
+ c.CTAP_CLIENT_PIN,
71
+ {0x01: 1, 0x02: c.PIN_GET_RETRIES},
72
+ context="clientPIN getPinRetries",
73
+ )
74
+ if not isinstance(resp, dict):
75
+ return None, None
76
+ retries = resp.get(0x03)
77
+ power_cycle = resp.get(0x04)
78
+ return (
79
+ int(retries) if isinstance(retries, int) else None,
80
+ bool(power_cycle) if power_cycle is not None else None,
81
+ )
82
+
83
+ # -- PIN/UV auth protocol (write ops) ----------------------------------- #
84
+ def get_key_agreement(self, protocol: int = 1) -> dict:
85
+ """clientPIN getKeyAgreement -> the authenticator's COSE public key."""
86
+ resp = self.command(
87
+ c.CTAP_CLIENT_PIN,
88
+ {c.CP_PROTOCOL: protocol, c.CP_SUBCOMMAND: c.PIN_GET_KEY_AGREEMENT},
89
+ context="clientPIN getKeyAgreement",
90
+ )
91
+ key = resp.get(c.CPR_KEY_AGREEMENT) if isinstance(resp, dict) else None
92
+ if not isinstance(key, dict):
93
+ raise CtapStatusError(0x12, context="getKeyAgreement")
94
+ return key
95
+
96
+ def set_pin(self, new_pin: str, protocol: int = 1) -> None:
97
+ """Set the initial PIN on an authenticator that has none."""
98
+ platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
99
+ new_pin_enc = proto.encrypt(pinproto.pad_pin(new_pin))
100
+ self.command(
101
+ c.CTAP_CLIENT_PIN,
102
+ {
103
+ c.CP_PROTOCOL: protocol,
104
+ c.CP_SUBCOMMAND: c.PIN_SET_PIN,
105
+ c.CP_KEY_AGREEMENT: platform_key,
106
+ c.CP_NEW_PIN_ENC: new_pin_enc,
107
+ c.CP_PIN_UV_AUTH_PARAM: proto.authenticate(new_pin_enc),
108
+ },
109
+ context="clientPIN setPIN",
110
+ )
111
+
112
+ def change_pin(self, current_pin: str, new_pin: str, protocol: int = 1) -> None:
113
+ """Change an existing PIN (decrements the retry counter on a wrong current PIN)."""
114
+ platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
115
+ pin_hash_enc = proto.encrypt(pinproto.pin_hash_left16(current_pin))
116
+ new_pin_enc = proto.encrypt(pinproto.pad_pin(new_pin))
117
+ self.command(
118
+ c.CTAP_CLIENT_PIN,
119
+ {
120
+ c.CP_PROTOCOL: protocol,
121
+ c.CP_SUBCOMMAND: c.PIN_CHANGE_PIN,
122
+ c.CP_KEY_AGREEMENT: platform_key,
123
+ c.CP_PIN_HASH_ENC: pin_hash_enc,
124
+ c.CP_NEW_PIN_ENC: new_pin_enc,
125
+ c.CP_PIN_UV_AUTH_PARAM: proto.authenticate(new_pin_enc + pin_hash_enc),
126
+ },
127
+ context="clientPIN changePIN",
128
+ )
129
+
130
+ def get_pin_token(
131
+ self,
132
+ pin: str,
133
+ *,
134
+ protocol: int = 1,
135
+ permissions: int | None = None,
136
+ rp_id: str | None = None,
137
+ ) -> bytes:
138
+ """Obtain a pinUvAuthToken (decrypted). Uses the permissions-scoped subcommand
139
+ (0x09) when ``permissions`` is given, else the legacy getPinToken (0x05)."""
140
+ platform_key, proto = pinproto.encapsulate(self.get_key_agreement(protocol), protocol)
141
+ pin_hash_enc = proto.encrypt(pinproto.pin_hash_left16(pin))
142
+ params = {
143
+ c.CP_PROTOCOL: protocol,
144
+ c.CP_KEY_AGREEMENT: platform_key,
145
+ c.CP_PIN_HASH_ENC: pin_hash_enc,
146
+ }
147
+ if permissions is not None:
148
+ params[c.CP_SUBCOMMAND] = c.PIN_GET_TOKEN_USING_PIN
149
+ params[c.CP_PERMISSIONS] = permissions
150
+ if rp_id is not None:
151
+ params[c.CP_RP_ID] = rp_id
152
+ ctx = "clientPIN getPinUvAuthTokenUsingPinWithPermissions"
153
+ else:
154
+ params[c.CP_SUBCOMMAND] = c.PIN_GET_PIN_TOKEN
155
+ ctx = "clientPIN getPinToken"
156
+ resp = self.command(c.CTAP_CLIENT_PIN, params, context=ctx)
157
+ enc = resp.get(c.CPR_PIN_UV_AUTH_TOKEN) if isinstance(resp, dict) else None
158
+ if not isinstance(enc, (bytes, bytearray)):
159
+ raise CtapStatusError(0x12, context=ctx)
160
+ return proto.decrypt(bytes(enc))
161
+
162
+ # -- credentials -------------------------------------------------------- #
163
+ def make_credential(
164
+ self,
165
+ *,
166
+ client_data_hash: bytes,
167
+ rp_id: str,
168
+ user_id: bytes,
169
+ user_name: str = "cryptnox",
170
+ resident_key: bool = False,
171
+ pin_uv_token: bytes | None = None,
172
+ protocol: int = 1,
173
+ algorithms: tuple[int, ...] = (c.COSE_ALG_ES256,),
174
+ ) -> dict[str, object]:
175
+ """authenticatorMakeCredential (register). Returns the parsed credential id +
176
+ public key. Requires user presence (a tap) on the authenticator."""
177
+ params: dict[int, object] = {
178
+ c.MC_CLIENT_DATA_HASH: client_data_hash,
179
+ c.MC_RP: {"id": rp_id, "name": rp_id},
180
+ c.MC_USER: {"id": user_id, "name": user_name, "displayName": user_name},
181
+ c.MC_PUB_KEY_CRED_PARAMS: [{"alg": a, "type": "public-key"} for a in algorithms],
182
+ }
183
+ if resident_key:
184
+ params[c.MC_OPTIONS] = {"rk": True}
185
+ if pin_uv_token is not None:
186
+ params[c.MC_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
187
+ protocol, pin_uv_token, client_data_hash
188
+ )
189
+ params[c.MC_PIN_UV_AUTH_PROTOCOL] = protocol
190
+ resp = self.command(c.CTAP_MAKE_CREDENTIAL, params, context="authenticatorMakeCredential")
191
+ auth_data = resp.get(c.MCR_AUTH_DATA) if isinstance(resp, dict) else None
192
+ if not isinstance(auth_data, (bytes, bytearray)):
193
+ raise CtapStatusError(0x12, context="makeCredential")
194
+ parsed = parse_authenticator_data(bytes(auth_data))
195
+ return {
196
+ "fmt": resp.get(c.MCR_FMT),
197
+ "auth_data": bytes(auth_data),
198
+ "att_stmt": resp.get(c.MCR_ATT_STMT),
199
+ "credential_id": parsed.credential_id,
200
+ "credential_public_key": parsed.credential_public_key,
201
+ "sign_count": parsed.sign_count,
202
+ }
203
+
204
+ def get_assertion(
205
+ self,
206
+ *,
207
+ rp_id: str,
208
+ client_data_hash: bytes,
209
+ allow_credential_ids: list[bytes] | None = None,
210
+ pin_uv_token: bytes | None = None,
211
+ protocol: int = 1,
212
+ user_presence: bool = True,
213
+ ) -> dict[str, object]:
214
+ """authenticatorGetAssertion (authenticate). Returns authData + signature."""
215
+ params: dict[int, object] = {
216
+ c.GA_RP_ID: rp_id,
217
+ c.GA_CLIENT_DATA_HASH: client_data_hash,
218
+ c.GA_OPTIONS: {"up": user_presence},
219
+ }
220
+ if allow_credential_ids:
221
+ params[c.GA_ALLOW_LIST] = [
222
+ {"id": cid, "type": "public-key"} for cid in allow_credential_ids
223
+ ]
224
+ if pin_uv_token is not None:
225
+ params[c.GA_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
226
+ protocol, pin_uv_token, client_data_hash
227
+ )
228
+ params[c.GA_PIN_UV_AUTH_PROTOCOL] = protocol
229
+ resp = self.command(c.CTAP_GET_ASSERTION, params, context="authenticatorGetAssertion")
230
+ auth_data = resp.get(c.GAR_AUTH_DATA) if isinstance(resp, dict) else None
231
+ signature = resp.get(c.GAR_SIGNATURE) if isinstance(resp, dict) else None
232
+ credential = resp.get(c.GAR_CREDENTIAL) if isinstance(resp, dict) else None
233
+ if not isinstance(auth_data, (bytes, bytearray)) or not isinstance(
234
+ signature, (bytes, bytearray)
235
+ ):
236
+ raise CtapStatusError(0x12, context="getAssertion")
237
+ return {
238
+ "auth_data": bytes(auth_data),
239
+ "signature": bytes(signature),
240
+ "credential_id": credential.get("id") if isinstance(credential, dict) else None,
241
+ "number_of_credentials": resp.get(c.GAR_NUMBER_OF_CREDENTIALS),
242
+ }
243
+
244
+ def reset(self) -> None:
245
+ """authenticatorReset - IRREVERSIBLY wipes all credentials and the PIN. Many
246
+ authenticators only honor it shortly after power-up and require user presence."""
247
+ self.command(c.CTAP_RESET, context="authenticatorReset")
248
+
249
+ # -- credential management (0x0A) --------------------------------------- #
250
+ def credential_management(
251
+ self,
252
+ subcommand: int,
253
+ *,
254
+ sub_params: dict | None = None,
255
+ pin_uv_token: bytes | None = None,
256
+ protocol: int = 1,
257
+ ) -> Any:
258
+ """authenticatorCredentialManagement. pinUvAuthParam is over
259
+ ``subCommand || subCommandParams`` (omitted for the GetNext sub-commands)."""
260
+ params: dict[int, object] = {c.CM_SUBCOMMAND: subcommand}
261
+ if sub_params is not None:
262
+ params[c.CM_SUBCOMMAND_PARAMS] = sub_params
263
+ if pin_uv_token is not None:
264
+ msg = bytes([subcommand]) + (
265
+ cbor2.dumps(sub_params, canonical=True) if sub_params is not None else b""
266
+ )
267
+ params[c.CM_PIN_UV_AUTH_PROTOCOL] = protocol
268
+ params[c.CM_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
269
+ protocol, pin_uv_token, msg
270
+ )
271
+ return self.command(
272
+ c.CTAP_CREDENTIAL_MANAGEMENT, params, context="authenticatorCredentialManagement"
273
+ )
274
+
275
+ def get_creds_metadata(self, pin_uv_token: bytes, protocol: int = 1) -> tuple[int, int]:
276
+ """(existing resident credentials, max remaining)."""
277
+ resp = self.credential_management(
278
+ c.CM_GET_CREDS_METADATA, pin_uv_token=pin_uv_token, protocol=protocol
279
+ )
280
+ if not isinstance(resp, dict):
281
+ return 0, 0
282
+ return int(resp.get(c.CMR_EXISTING_RESIDENT_COUNT, 0)), int(
283
+ resp.get(c.CMR_MAX_REMAINING, 0)
284
+ )
285
+
286
+ def enumerate_credentials(self, pin_uv_token: bytes, protocol: int = 1) -> list[dict]:
287
+ """Walk every resident credential -> list of {rp_id, user, credential_id, public_key}."""
288
+ out: list[dict] = []
289
+ try:
290
+ first_rp = self.credential_management(
291
+ c.CM_ENUMERATE_RPS_BEGIN, pin_uv_token=pin_uv_token, protocol=protocol
292
+ )
293
+ except CtapStatusError as exc:
294
+ if exc.status == 0x2E: # CTAP2_ERR_NO_CREDENTIALS - none stored
295
+ return out
296
+ raise
297
+ if not isinstance(first_rp, dict):
298
+ return out
299
+ total_rps = int(first_rp.get(c.CMR_TOTAL_RPS, 0))
300
+ rps = [first_rp] if total_rps else []
301
+ for _ in range(max(0, total_rps - 1)):
302
+ rps.append(self.credential_management(c.CM_ENUMERATE_RPS_NEXT))
303
+ for rp in rps:
304
+ rp_info = rp.get(c.CMR_RP) if isinstance(rp, dict) else None
305
+ rp_id = rp_info.get("id") if isinstance(rp_info, dict) else None
306
+ rp_id_hash = rp.get(c.CMR_RP_ID_HASH) if isinstance(rp, dict) else None
307
+ first = self.credential_management(
308
+ c.CM_ENUMERATE_CREDS_BEGIN,
309
+ sub_params={c.CMP_RP_ID_HASH: rp_id_hash},
310
+ pin_uv_token=pin_uv_token,
311
+ protocol=protocol,
312
+ )
313
+ if not isinstance(first, dict):
314
+ continue
315
+ total = int(first.get(c.CMR_TOTAL_CREDENTIALS, 0))
316
+ creds = [first]
317
+ for _ in range(max(0, total - 1)):
318
+ creds.append(self.credential_management(c.CM_ENUMERATE_CREDS_NEXT))
319
+ for cred in creds:
320
+ if not isinstance(cred, dict):
321
+ continue
322
+ cid = cred.get(c.CMR_CREDENTIAL_ID)
323
+ out.append(
324
+ {
325
+ "rp_id": rp_id,
326
+ "user": cred.get(c.CMR_USER),
327
+ "credential_id": cid.get("id") if isinstance(cid, dict) else None,
328
+ "public_key": cred.get(c.CMR_PUBLIC_KEY),
329
+ }
330
+ )
331
+ return out
332
+
333
+ def delete_credential(
334
+ self, credential_id: bytes, pin_uv_token: bytes, protocol: int = 1
335
+ ) -> None:
336
+ self.credential_management(
337
+ c.CM_DELETE_CREDENTIAL,
338
+ sub_params={c.CMP_CREDENTIAL_ID: {"id": credential_id, "type": "public-key"}},
339
+ pin_uv_token=pin_uv_token,
340
+ protocol=protocol,
341
+ )
342
+
343
+ # -- authenticator configuration (0x0D) --------------------------------- #
344
+ def authenticator_config(
345
+ self,
346
+ subcommand: int,
347
+ *,
348
+ sub_params: dict | None = None,
349
+ pin_uv_token: bytes | None = None,
350
+ protocol: int = 1,
351
+ ) -> Any:
352
+ """authenticatorConfig. When a pinUvAuthToken is supplied the pinUvAuthParam is
353
+ a MAC over ``32×0xFF || 0x0D || subCommand || subCommandParams`` (the 0xFF prefix
354
+ is mandated by the CTAP 2.1 spec, not a card quirk). The token needs the
355
+ authenticatorConfiguration (acfg) permission. On an unprotected authenticator
356
+ (no clientPIN, alwaysUv false) the card may accept the call with no token."""
357
+ params: dict[int, object] = {c.AC_SUBCOMMAND: subcommand}
358
+ if sub_params is not None:
359
+ params[c.AC_SUBCOMMAND_PARAMS] = sub_params
360
+ if pin_uv_token is not None:
361
+ msg = (
362
+ b"\xff" * 32
363
+ + bytes([c.CTAP_AUTHENTICATOR_CONFIG, subcommand])
364
+ + (cbor2.dumps(sub_params, canonical=True) if sub_params is not None else b"")
365
+ )
366
+ params[c.AC_PIN_UV_AUTH_PROTOCOL] = protocol
367
+ params[c.AC_PIN_UV_AUTH_PARAM] = pinproto.pin_uv_authenticate(
368
+ protocol, pin_uv_token, msg
369
+ )
370
+ return self.command(c.CTAP_AUTHENTICATOR_CONFIG, params, context="authenticatorConfig")
371
+
372
+ def toggle_always_uv(self, *, pin_uv_token: bytes | None = None, protocol: int = 1) -> None:
373
+ """Flip the alwaysUv flag (require user verification for every operation)."""
374
+ self.authenticator_config(
375
+ c.AC_TOGGLE_ALWAYS_UV, pin_uv_token=pin_uv_token, protocol=protocol
376
+ )
377
+
378
+ def set_min_pin_length(
379
+ self,
380
+ length: int,
381
+ *,
382
+ rp_ids: list[str] | None = None,
383
+ force_change_pin: bool | None = None,
384
+ pin_uv_token: bytes | None = None,
385
+ protocol: int = 1,
386
+ ) -> None:
387
+ """Raise the minimum PIN length. The value can only INCREASE; lowering it again
388
+ requires authenticatorReset."""
389
+ sub: dict[int, object] = {c.ACP_NEW_MIN_PIN_LENGTH: length}
390
+ if rp_ids:
391
+ sub[c.ACP_MIN_PIN_LENGTH_RP_IDS] = rp_ids
392
+ if force_change_pin is not None:
393
+ sub[c.ACP_FORCE_CHANGE_PIN] = force_change_pin
394
+ self.authenticator_config(
395
+ c.AC_SET_MIN_PIN_LENGTH,
396
+ sub_params=sub,
397
+ pin_uv_token=pin_uv_token,
398
+ protocol=protocol,
399
+ )
400
+
401
+
402
+ def describe_get_info(info: dict) -> dict[str, object]:
403
+ """Turn the int-keyed getInfo map into a readable, JSON-able structure."""
404
+ aaguid = info.get(c.INFO_AAGUID)
405
+ aaguid_str = None
406
+ cryptnox = None
407
+ if isinstance(aaguid, bytes) and len(aaguid) == 16:
408
+ aaguid_str = str(uuid.UUID(bytes=aaguid))
409
+ cryptnox = aaguid.hex() in c.CRYPTNOX_AAGUIDS
410
+ algorithms = info.get(c.INFO_ALGORITHMS)
411
+ alg_list = None
412
+ if isinstance(algorithms, list):
413
+ alg_list = [
414
+ {"alg": a.get("alg"), "type": a.get("type")} for a in algorithms if isinstance(a, dict)
415
+ ]
416
+ return {
417
+ "versions": info.get(c.INFO_VERSIONS),
418
+ "extensions": info.get(c.INFO_EXTENSIONS),
419
+ "aaguid": aaguid_str,
420
+ "cryptnox_aaguid": cryptnox,
421
+ "options": info.get(c.INFO_OPTIONS),
422
+ "max_msg_size": info.get(c.INFO_MAX_MSG_SIZE),
423
+ "pin_uv_auth_protocols": info.get(c.INFO_PIN_UV_AUTH_PROTOCOLS),
424
+ "max_creds_in_list": info.get(c.INFO_MAX_CREDS_IN_LIST),
425
+ "max_cred_id_length": info.get(c.INFO_MAX_CRED_ID_LENGTH),
426
+ "transports": info.get(c.INFO_TRANSPORTS),
427
+ "algorithms": alg_list,
428
+ "min_pin_length": info.get(c.INFO_MIN_PIN_LENGTH),
429
+ "firmware_version": info.get(c.INFO_FIRMWARE_VERSION),
430
+ }
@@ -0,0 +1,90 @@
1
+ """CTAP2 status-code decoding (CTAP 2.1; matches the Android client's table)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from cryptnox_id_cli.transport.errors import CryptnoxError
6
+
7
+ _CTAP_ERRORS: dict[int, tuple[str, str]] = {
8
+ 0x01: ("CTAP1_ERR_INVALID_COMMAND", "The command is not valid or not supported."),
9
+ 0x02: ("CTAP1_ERR_INVALID_PARAMETER", "Invalid parameter."),
10
+ 0x03: ("CTAP1_ERR_INVALID_LENGTH", "Invalid message length."),
11
+ 0x04: ("CTAP1_ERR_INVALID_SEQ", "Invalid message sequencing."),
12
+ 0x05: ("CTAP1_ERR_TIMEOUT", "Message timed out."),
13
+ 0x06: ("CTAP1_ERR_CHANNEL_BUSY", "Channel busy."),
14
+ 0x11: ("CTAP2_ERR_CBOR_UNEXPECTED_TYPE", "Invalid/unexpected CBOR type."),
15
+ 0x12: ("CTAP2_ERR_INVALID_CBOR", "Error when parsing CBOR."),
16
+ 0x14: ("CTAP2_ERR_MISSING_PARAMETER", "Missing non-optional parameter."),
17
+ 0x15: ("CTAP2_ERR_LIMIT_EXCEEDED", "Limit for number of items exceeded."),
18
+ 0x17: ("CTAP2_ERR_LARGE_BLOB_STORAGE_FULL", "Large blob storage is full."),
19
+ 0x19: ("CTAP2_ERR_CREDENTIAL_EXCLUDED", "A credential in the exclude list already exists."),
20
+ 0x21: ("CTAP2_ERR_PROCESSING", "Processing (lengthy operation underway)."),
21
+ 0x22: ("CTAP2_ERR_INVALID_CREDENTIAL", "Credential not valid for the authenticator."),
22
+ 0x23: ("CTAP2_ERR_USER_ACTION_PENDING", "Authentication is waiting for user interaction."),
23
+ 0x24: ("CTAP2_ERR_OPERATION_PENDING", "Another processing operation is pending."),
24
+ 0x25: ("CTAP2_ERR_NO_OPERATIONS", "No outstanding operations."),
25
+ 0x26: (
26
+ "CTAP2_ERR_UNSUPPORTED_ALGORITHM",
27
+ "Authenticator does not support the requested algorithm.",
28
+ ),
29
+ 0x27: ("CTAP2_ERR_OPERATION_DENIED", "The operation was denied (user presence / policy)."),
30
+ 0x28: ("CTAP2_ERR_KEY_STORE_FULL", "Internal key storage is full."),
31
+ 0x2B: ("CTAP2_ERR_UNSUPPORTED_OPTION", "Unsupported option."),
32
+ 0x2C: ("CTAP2_ERR_INVALID_OPTION", "Not a valid option for the current operation."),
33
+ 0x2D: ("CTAP2_ERR_KEEPALIVE_CANCEL", "The operation was cancelled."),
34
+ 0x2E: ("CTAP2_ERR_NO_CREDENTIALS", "No valid credentials provided / found."),
35
+ 0x2F: ("CTAP2_ERR_USER_ACTION_TIMEOUT", "Timeout waiting for user interaction."),
36
+ 0x30: ("CTAP2_ERR_NOT_ALLOWED", "Command not allowed on this CID/transport."),
37
+ 0x31: ("CTAP2_ERR_PIN_INVALID", "The PIN is invalid (wrong PIN entered)."),
38
+ 0x32: (
39
+ "CTAP2_ERR_PIN_BLOCKED",
40
+ "The PIN is blocked. Reset is required to use the authenticator again.",
41
+ ),
42
+ 0x33: (
43
+ "CTAP2_ERR_PIN_AUTH_INVALID",
44
+ "PIN authentication (pinUvAuthParam) verification failed.",
45
+ ),
46
+ 0x34: (
47
+ "CTAP2_ERR_PIN_AUTH_BLOCKED",
48
+ "PIN authentication is blocked for this power cycle - remove and re-present the card.",
49
+ ),
50
+ 0x35: ("CTAP2_ERR_PIN_NOT_SET", "No PIN is set on this authenticator."),
51
+ 0x36: ("CTAP2_ERR_PUAT_REQUIRED", "A PIN/UV auth token is required for this operation."),
52
+ 0x37: (
53
+ "CTAP2_ERR_PIN_POLICY_VIOLATION",
54
+ "The new PIN violates the PIN policy (e.g. too short).",
55
+ ),
56
+ 0x39: ("CTAP2_ERR_REQUEST_TOO_LARGE", "The request is larger than the authenticator supports."),
57
+ 0x3A: ("CTAP2_ERR_ACTION_TIMEOUT", "The action timed out."),
58
+ 0x3B: ("CTAP2_ERR_UP_REQUIRED", "User presence is required."),
59
+ 0x3C: ("CTAP2_ERR_UV_BLOCKED", "Built-in user verification is blocked."),
60
+ 0x3E: ("CTAP2_ERR_UV_INVALID", "User verification failed."),
61
+ 0x7F: ("CTAP1_ERR_OTHER", "Other unspecified error."),
62
+ }
63
+
64
+
65
+ def describe_ctap(status: int) -> tuple[str, str]:
66
+ return _CTAP_ERRORS.get(
67
+ status, (f"CTAP_ERR_0x{status:02X}", f"Unknown CTAP error 0x{status:02X}.")
68
+ )
69
+
70
+
71
+ class CtapStatusError(CryptnoxError):
72
+ """A CTAP command returned a non-success status byte."""
73
+
74
+ code = "ctap_error"
75
+ exit_code = 10
76
+
77
+ def __init__(self, status: int, *, context: str | None = None) -> None:
78
+ self.status = status
79
+ name, message = describe_ctap(status)
80
+ prefix = f"{context}: " if context else ""
81
+ super().__init__(f"{prefix}{message} ({name}, 0x{status:02X})")
82
+
83
+ def to_dict(self) -> dict[str, object]:
84
+ name, _ = describe_ctap(self.status)
85
+ return {
86
+ "error": self.code,
87
+ "message": str(self),
88
+ "ctap_status": f"0x{self.status:02X}",
89
+ "ctap_name": name,
90
+ }
@@ -0,0 +1,166 @@
1
+ """CTAP2 PIN/UV Auth Protocol (one and two) - the crypto under FIDO PIN + token ops.
2
+
3
+ Both protocols agree a shared secret over ECDH (P-256), then provide ``encrypt`` /
4
+ ``decrypt`` / ``authenticate``:
5
+
6
+ - **Protocol one:** sharedSecret = SHA-256(Z); AES-256-CBC with a zero IV; HMAC-SHA-256
7
+ truncated to 16 bytes.
8
+ - **Protocol two:** sharedSecret = HKDF(Z) -> 32-byte HMAC key || 32-byte AES key;
9
+ AES-256-CBC with a random IV prepended to the ciphertext; full HMAC-SHA-256.
10
+
11
+ ``Z`` is the 32-byte X coordinate of the ECDH point.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+
18
+ from cryptography.hazmat.primitives import hashes, hmac
19
+ from cryptography.hazmat.primitives.asymmetric import ec
20
+ from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
21
+ from cryptography.hazmat.primitives.kdf.hkdf import HKDF
22
+
23
+ from cryptnox_id_cli.applets.fido import constants as c
24
+
25
+ _P256 = ec.SECP256R1()
26
+
27
+
28
+ def _sha256(data: bytes) -> bytes:
29
+ h = hashes.Hash(hashes.SHA256())
30
+ h.update(data)
31
+ return h.finalize()
32
+
33
+
34
+ def _hmac256(key: bytes, msg: bytes) -> bytes:
35
+ h = hmac.HMAC(key, hashes.SHA256())
36
+ h.update(msg)
37
+ return h.finalize()
38
+
39
+
40
+ def _aes_cbc(key: bytes, iv: bytes, data: bytes, *, encrypt: bool) -> bytes:
41
+ cipher = Cipher(algorithms.AES(key), modes.CBC(iv))
42
+ op = cipher.encryptor() if encrypt else cipher.decryptor()
43
+ return op.update(data) + op.finalize()
44
+
45
+
46
+ def cose_to_public_key(cose: dict) -> ec.EllipticCurvePublicKey:
47
+ x = int.from_bytes(cose[c.COSE_X], "big")
48
+ y = int.from_bytes(cose[c.COSE_Y], "big")
49
+ return ec.EllipticCurvePublicNumbers(x, y, _P256).public_key()
50
+
51
+
52
+ def public_key_to_cose(pub: ec.EllipticCurvePublicKey) -> dict:
53
+ nums = pub.public_numbers()
54
+ return {
55
+ c.COSE_KTY: c.COSE_KTY_EC2,
56
+ c.COSE_ALG: c.COSE_ALG_ECDH_ES_HKDF256,
57
+ c.COSE_CRV: c.COSE_CRV_P256,
58
+ c.COSE_X: nums.x.to_bytes(32, "big"),
59
+ c.COSE_Y: nums.y.to_bytes(32, "big"),
60
+ }
61
+
62
+
63
+ class PinUvAuthProtocol:
64
+ version: int
65
+
66
+ def __init__(self, shared_secret: bytes) -> None:
67
+ self.shared_secret = shared_secret
68
+
69
+ @staticmethod
70
+ def kdf(z: bytes) -> bytes: # pragma: no cover - overridden
71
+ raise NotImplementedError
72
+
73
+ def encrypt(self, plaintext: bytes) -> bytes: # pragma: no cover - overridden
74
+ raise NotImplementedError
75
+
76
+ def decrypt(self, ciphertext: bytes) -> bytes: # pragma: no cover - overridden
77
+ raise NotImplementedError
78
+
79
+ def authenticate(self, message: bytes) -> bytes: # pragma: no cover - overridden
80
+ raise NotImplementedError
81
+
82
+
83
+ class ProtocolOne(PinUvAuthProtocol):
84
+ version = 1
85
+
86
+ @staticmethod
87
+ def kdf(z: bytes) -> bytes:
88
+ return _sha256(z)
89
+
90
+ def encrypt(self, plaintext: bytes) -> bytes:
91
+ return _aes_cbc(self.shared_secret, b"\x00" * 16, plaintext, encrypt=True)
92
+
93
+ def decrypt(self, ciphertext: bytes) -> bytes:
94
+ return _aes_cbc(self.shared_secret, b"\x00" * 16, ciphertext, encrypt=False)
95
+
96
+ def authenticate(self, message: bytes) -> bytes:
97
+ return _hmac256(self.shared_secret, message)[:16]
98
+
99
+
100
+ class ProtocolTwo(PinUvAuthProtocol):
101
+ version = 2
102
+
103
+ @staticmethod
104
+ def kdf(z: bytes) -> bytes:
105
+ salt = b"\x00" * 32
106
+ hmac_key = HKDF(hashes.SHA256(), 32, salt, b"CTAP2 HMAC key").derive(z)
107
+ aes_key = HKDF(hashes.SHA256(), 32, salt, b"CTAP2 AES key").derive(z)
108
+ return hmac_key + aes_key
109
+
110
+ @property
111
+ def _hmac_key(self) -> bytes:
112
+ return self.shared_secret[:32]
113
+
114
+ @property
115
+ def _aes_key(self) -> bytes:
116
+ return self.shared_secret[32:]
117
+
118
+ def encrypt(self, plaintext: bytes) -> bytes:
119
+ iv = os.urandom(16)
120
+ return iv + _aes_cbc(self._aes_key, iv, plaintext, encrypt=True)
121
+
122
+ def decrypt(self, ciphertext: bytes) -> bytes:
123
+ return _aes_cbc(self._aes_key, ciphertext[:16], ciphertext[16:], encrypt=False)
124
+
125
+ def authenticate(self, message: bytes) -> bytes:
126
+ return _hmac256(self._hmac_key, message)
127
+
128
+
129
+ _PROTOCOLS: dict[int, type[PinUvAuthProtocol]] = {1: ProtocolOne, 2: ProtocolTwo}
130
+
131
+
132
+ def encapsulate(peer_cose: dict, version: int) -> tuple[dict, PinUvAuthProtocol]:
133
+ """ECDH against the authenticator's public key; returns the platform COSE key to
134
+ send and the initialized protocol (holding the shared secret)."""
135
+ cls = _PROTOCOLS.get(version)
136
+ if cls is None:
137
+ raise ValueError(f"unsupported PIN/UV auth protocol {version}")
138
+ ephemeral = ec.generate_private_key(_P256)
139
+ z = ephemeral.exchange(ec.ECDH(), cose_to_public_key(peer_cose))
140
+ return public_key_to_cose(ephemeral.public_key()), cls(cls.kdf(z))
141
+
142
+
143
+ # -- PIN value helpers ------------------------------------------------------- #
144
+ _PIN_PAD_LEN = 64
145
+
146
+
147
+ def pad_pin(pin: str) -> bytes:
148
+ """UTF-8 PIN padded with 0x00 to 64 bytes (CTAP minimum). 4..63 bytes."""
149
+ raw = pin.encode("utf-8")
150
+ if len(raw) < 4:
151
+ raise ValueError("FIDO PIN must be at least 4 characters.")
152
+ if len(raw) > _PIN_PAD_LEN - 1:
153
+ raise ValueError("FIDO PIN must be at most 63 bytes.")
154
+ return raw + b"\x00" * (_PIN_PAD_LEN - len(raw))
155
+
156
+
157
+ def pin_hash_left16(pin: str) -> bytes:
158
+ """LEFT(SHA-256(PIN), 16) - the pinHash the authenticator checks."""
159
+ return _sha256(pin.encode("utf-8"))[:16]
160
+
161
+
162
+ def pin_uv_authenticate(version: int, key: bytes, message: bytes) -> bytes:
163
+ """The pinUvAuthParam MAC over ``message`` keyed by a pinUvAuthToken: protocol 1
164
+ truncates the HMAC to 16 bytes, protocol 2 uses the full 32."""
165
+ mac = _hmac256(key, message)
166
+ return mac[:16] if version == 1 else mac