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,729 @@
1
+ """``fido`` — FIDO2 / CTAP 2.1 inspection and management.
2
+
3
+ ``ping``, ``info``/``get-info``, ``pin status`` and ``config show`` only read;
4
+ ``pin set``/``change``, the ``credential`` commands and ``config`` policy changes
5
+ write to the card, and ``reset`` irreversibly erases every credential and the PIN.
6
+
7
+ Windows reserves direct PC/SC access to the FIDO2/CTAP AID for the WebAuthn
8
+ platform API and blocks non-elevated processes (SCARD_E_NO_ACCESS). Every command
9
+ states that requirement (and the reason) up front; when not elevated it offers to
10
+ relaunch itself through a UAC prompt and surfaces the elevated result back here.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import contextlib
16
+ import json
17
+ import os
18
+ import sys
19
+ import tempfile
20
+ from collections.abc import Callable
21
+ from pathlib import Path
22
+ from typing import TypeVar
23
+
24
+ import click
25
+ from rich.console import Console
26
+
27
+ from cryptnox_id_cli.applets.fido import authdata
28
+ from cryptnox_id_cli.applets.fido import constants as fido_c
29
+ from cryptnox_id_cli.applets.fido.ctap import Ctap2Client, describe_get_info
30
+ from cryptnox_id_cli.cli.context import AppContext
31
+ from cryptnox_id_cli.transport.elevation import (
32
+ FIDO_WINDOWS_MESSAGE,
33
+ fido_elevation_status,
34
+ is_windows,
35
+ relaunch_elevated,
36
+ )
37
+ from cryptnox_id_cli.transport.errors import CardAccessDeniedError, CryptnoxError
38
+ from cryptnox_id_cli.util.hexutil import from_hex
39
+
40
+ T = TypeVar("T")
41
+
42
+
43
+ @click.group("fido")
44
+ def command() -> None:
45
+ """Manage the FIDO2 applet (needs an Administrator terminal on Windows).
46
+
47
+ Inspection commands (`ping`, `info`, `pin status`, `config show`) only read;
48
+ `pin`, `credential` and `config` commands write to the card, and `reset`
49
+ irreversibly erases every credential and the PIN.
50
+ """
51
+
52
+
53
+ def _with_fido(app: AppContext, action: Callable[[Ctap2Client], T]) -> T:
54
+ """Open a session, run ``action`` with a CTAP client, translating the Windows
55
+ OS block into the documented friendly message."""
56
+ try:
57
+ with app.open_session() as session:
58
+ return action(Ctap2Client(session))
59
+ except CardAccessDeniedError as exc:
60
+ raise CardAccessDeniedError(FIDO_WINDOWS_MESSAGE, hresult=exc.hresult) from exc
61
+
62
+
63
+ def _render_elevated_result(app: AppContext, payload: dict) -> None:
64
+ if app.json:
65
+ sys.stdout.write(json.dumps(payload, indent=2, default=str) + "\n")
66
+ return
67
+ app.out.console.print("[dim]result (ran as Administrator):[/dim]")
68
+ app.out.console.print_json(data=payload)
69
+
70
+
71
+ def _run_elevated(app: AppContext) -> None:
72
+ """Relaunch this command via a UAC prompt, then surface the result here. Exits."""
73
+ fd, tmp = tempfile.mkstemp(prefix="cnx-fido-", suffix=".json")
74
+ os.close(fd)
75
+ try:
76
+ app.out.note("requesting elevation - approve the Windows UAC prompt...")
77
+ code = relaunch_elevated(["--json", "--elevated-result-out", tmp])
78
+ if code is None:
79
+ # UAC declined or could not start: fall back to the documented guidance.
80
+ raise CardAccessDeniedError(FIDO_WINDOWS_MESSAGE)
81
+ text = Path(tmp).read_text(encoding="utf-8") if Path(tmp).exists() else ""
82
+ if text.strip():
83
+ _render_elevated_result(app, json.loads(text))
84
+ elif code != 0:
85
+ raise CryptnoxError(f"elevated FIDO2 command failed (exit {code}).")
86
+ sys.exit(code)
87
+ finally:
88
+ with contextlib.suppress(OSError):
89
+ os.remove(tmp)
90
+
91
+
92
+ def _ensure_fido_access(app: AppContext) -> None:
93
+ """Surface the FIDO admin requirement (with the reason). If not elevated on
94
+ Windows, offer to relaunch via a UAC prompt; on acceptance this does not return."""
95
+ severity, message = fido_elevation_status()
96
+ if severity != "warn":
97
+ if message:
98
+ app.out.note(message)
99
+ return
100
+ app.out.warn(message)
101
+ if not is_windows():
102
+ return
103
+ # Offer the one-click UAC relaunch when we can interact (or --yes was given).
104
+ offer = app.yes or (sys.stdin.isatty() and not app.json)
105
+ if offer and (
106
+ app.yes or click.confirm("Re-run this command as Administrator now?", default=True)
107
+ ):
108
+ _run_elevated(app)
109
+
110
+
111
+ @command.command("ping")
112
+ @click.pass_obj
113
+ def ping(app: AppContext) -> None:
114
+ """Basic connectivity test: SELECT the FIDO2 applet and report its version string."""
115
+ _ensure_fido_access(app)
116
+ version = _with_fido(app, lambda ctap: ctap.select())
117
+ app.out.result(
118
+ {"selected": True, "version": version},
119
+ lambda c: c.print(f"[green]FIDO2 applet selected.[/green] Version string: {version}"),
120
+ )
121
+
122
+
123
+ def _get_info_payload(app: AppContext) -> dict[str, object]:
124
+ def action(ctap: Ctap2Client) -> dict[str, object]:
125
+ version = ctap.select()
126
+ info = describe_get_info(ctap.get_info())
127
+ info["select_version"] = version
128
+ return info
129
+
130
+ return _with_fido(app, action)
131
+
132
+
133
+ def _joined(value: object) -> str:
134
+ if isinstance(value, list) and value:
135
+ return ", ".join(str(v) for v in value)
136
+ return "-"
137
+
138
+
139
+ def _render_info(app: AppContext, info: dict[str, object]) -> None:
140
+ def human(c: Console) -> None:
141
+ c.print("[bold]FIDO2 authenticator (authenticatorGetInfo)[/bold]")
142
+ cryptnox = info.get("cryptnox_aaguid")
143
+ rows = [
144
+ ("Versions", _joined(info.get("versions"))),
145
+ ("Extensions", _joined(info.get("extensions"))),
146
+ ("AAGUID", str(info.get("aaguid"))),
147
+ ("Cryptnox AAGUID", "yes" if cryptnox else ("no" if cryptnox is False else "-")),
148
+ ("Max message size", str(info.get("max_msg_size"))),
149
+ ("PIN/UV protocols", _joined(info.get("pin_uv_auth_protocols"))),
150
+ ("Transports", _joined(info.get("transports"))),
151
+ ("Min PIN length", str(info.get("min_pin_length") or "-")),
152
+ ("Firmware", str(info.get("firmware_version") or "-")),
153
+ ]
154
+ table = app.out.table("Property", "Value")
155
+ for name, value in rows:
156
+ table.add_row(name, value)
157
+ options = info.get("options")
158
+ if isinstance(options, dict):
159
+ for opt, val in sorted(options.items()):
160
+ table.add_row(f"option {opt}", str(val))
161
+ algorithms = info.get("algorithms")
162
+ if isinstance(algorithms, list):
163
+ algs = ", ".join(f"{a.get('alg')} ({a.get('type')})" for a in algorithms)
164
+ table.add_row("Algorithms", algs)
165
+ c.print(table)
166
+
167
+ app.out.result(info, human)
168
+
169
+
170
+ @command.command("info")
171
+ @click.pass_obj
172
+ def info(app: AppContext) -> None:
173
+ """Show the authenticator's capabilities (CTAP authenticatorGetInfo)."""
174
+ _ensure_fido_access(app)
175
+ _render_info(app, _get_info_payload(app))
176
+
177
+
178
+ @command.command("get-info")
179
+ @click.pass_obj
180
+ def get_info(app: AppContext) -> None:
181
+ """Alias of ``fido info`` (raw CTAP authenticatorGetInfo)."""
182
+ _ensure_fido_access(app)
183
+ _render_info(app, _get_info_payload(app))
184
+
185
+
186
+ def _resolve_fido_pin(app: AppContext, env_var: str | None, label: str, *, confirm: bool) -> str:
187
+ """Get a FIDO PIN from an env var or a masked prompt; register it for redaction."""
188
+ value = os.environ.get(env_var) if env_var else None
189
+ if value is None:
190
+ if app.json or not sys.stdin.isatty():
191
+ raise CryptnoxError(
192
+ f"{label} required: set ${env_var or 'a PIN env var'} or run interactively."
193
+ )
194
+ value = click.prompt(label, hide_input=True, confirmation_prompt=confirm)
195
+ app.redactor.register(value.encode("utf-8"))
196
+ return value
197
+
198
+
199
+ @command.group("pin")
200
+ def pin() -> None:
201
+ """FIDO PIN: status, set (initial), change."""
202
+
203
+
204
+ @pin.command("status")
205
+ @click.pass_obj
206
+ def pin_status(app: AppContext) -> None:
207
+ """Show whether a PIN is set and how many retries remain (non-destructive)."""
208
+ _ensure_fido_access(app)
209
+
210
+ def action(ctap: Ctap2Client) -> dict[str, object]:
211
+ ctap.select()
212
+ options = describe_get_info(ctap.get_info()).get("options")
213
+ client_pin = options.get("clientPin") if isinstance(options, dict) else None
214
+ retries: int | None = None
215
+ power_cycle: bool | None = None
216
+ if client_pin is not None:
217
+ retries, power_cycle = ctap.pin_retries()
218
+ return {
219
+ "pin_supported": client_pin is not None,
220
+ "pin_set": client_pin,
221
+ "retries_remaining": retries,
222
+ "power_cycle_required": power_cycle,
223
+ }
224
+
225
+ payload = _with_fido(app, action)
226
+
227
+ def human(c: Console) -> None:
228
+ if not payload["pin_supported"]:
229
+ c.print("clientPin: [yellow]not supported by this authenticator[/yellow]")
230
+ return
231
+ state = "[green]set[/green]" if payload["pin_set"] else "[yellow]not set[/yellow]"
232
+ c.print(f"FIDO PIN: {state}")
233
+ if payload["retries_remaining"] is not None:
234
+ c.print(f" Retries remaining: {payload['retries_remaining']}")
235
+ if payload["power_cycle_required"]:
236
+ c.print(
237
+ " [yellow]PIN auth blocked for this power cycle - re-present the card.[/yellow]"
238
+ )
239
+
240
+ app.out.result(payload, human)
241
+
242
+
243
+ @pin.command("set")
244
+ @click.option("--pin-env", help="Env var holding the new PIN (else masked prompt).")
245
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
246
+ @click.pass_obj
247
+ def pin_set(app: AppContext, pin_env: str | None, protocol: str) -> None:
248
+ """Set the INITIAL FIDO PIN (only when none is set yet). Min 4 characters."""
249
+ _ensure_fido_access(app)
250
+ new_pin = _resolve_fido_pin(app, pin_env, "New FIDO PIN", confirm=True)
251
+
252
+ def action(ctap: Ctap2Client) -> dict[str, object]:
253
+ ctap.select()
254
+ ctap.set_pin(new_pin, protocol=int(protocol))
255
+ return {"pin_set": True, "protocol": int(protocol)}
256
+
257
+ app.out.result(
258
+ _with_fido(app, action),
259
+ lambda c: c.print(
260
+ "[green]FIDO PIN set.[/green] Keep it safe - it cannot be removed "
261
+ "without an authenticator reset (which wipes all credentials)."
262
+ ),
263
+ )
264
+
265
+
266
+ @pin.command("change")
267
+ @click.option("--current-pin-env", help="Env var holding the current PIN (else prompt).")
268
+ @click.option("--new-pin-env", help="Env var holding the new PIN (else prompt).")
269
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
270
+ @click.pass_obj
271
+ def pin_change(
272
+ app: AppContext, current_pin_env: str | None, new_pin_env: str | None, protocol: str
273
+ ) -> None:
274
+ """Change the FIDO PIN. A wrong current PIN decrements the retry counter."""
275
+ _ensure_fido_access(app)
276
+ current_pin = _resolve_fido_pin(app, current_pin_env, "Current FIDO PIN", confirm=False)
277
+ new_pin = _resolve_fido_pin(app, new_pin_env, "New FIDO PIN", confirm=True)
278
+
279
+ def action(ctap: Ctap2Client) -> dict[str, object]:
280
+ ctap.select()
281
+ ctap.change_pin(current_pin, new_pin, protocol=int(protocol))
282
+ return {"pin_changed": True, "protocol": int(protocol)}
283
+
284
+ app.out.result(
285
+ _with_fido(app, action),
286
+ lambda c: c.print("[green]FIDO PIN changed.[/green]"),
287
+ )
288
+
289
+
290
+ def _pin_token(
291
+ app: AppContext,
292
+ ctap: Ctap2Client,
293
+ pin_env: str | None,
294
+ permissions: int,
295
+ rp_id: str | None,
296
+ proto: int,
297
+ ) -> bytes | None:
298
+ """Obtain a pinUvAuthToken from a PIN env var, or None (let the card decide UV)."""
299
+ if not pin_env:
300
+ return None
301
+ pin = os.environ.get(pin_env)
302
+ if not pin:
303
+ raise CryptnoxError(f"environment variable {pin_env} is not set.")
304
+ app.redactor.register(pin.encode("utf-8"))
305
+ return ctap.get_pin_token(pin, protocol=proto, permissions=permissions, rp_id=rp_id)
306
+
307
+
308
+ @command.group("credential")
309
+ def credential() -> None:
310
+ """FIDO credentials: register, authenticate, end-to-end self-test.
311
+
312
+ These are WRITE operations and require user presence (a tap). If the card has a
313
+ PIN set, pass --pin-env NAME (an env var holding the PIN).
314
+ """
315
+
316
+
317
+ @credential.command("self-test")
318
+ @click.option("--rp-id", default="cryptnox.local", show_default=True)
319
+ @click.option("--pin-env", help="Env var with the FIDO PIN (if the card requires UV).")
320
+ @click.option("--rk", is_flag=True, help="Use a resident (discoverable) credential.")
321
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
322
+ @click.pass_obj
323
+ def credential_self_test(
324
+ app: AppContext, rp_id: str, pin_env: str | None, rk: bool, protocol: str
325
+ ) -> None:
326
+ """Register a credential, get an assertion, and verify the signature - a full FIDO2
327
+ round-trip proving the authenticator works. Requires a tap (user presence)."""
328
+ _ensure_fido_access(app)
329
+ proto = int(protocol)
330
+ app.out.note("touch/tap the authenticator when it asks for user presence.")
331
+
332
+ def action(ctap: Ctap2Client) -> dict[str, object]:
333
+ ctap.select()
334
+ perms = fido_c.PERM_MAKE_CREDENTIAL | fido_c.PERM_GET_ASSERTION
335
+ token = _pin_token(app, ctap, pin_env, perms, rp_id, proto)
336
+ cdh_reg = os.urandom(32)
337
+ cred = ctap.make_credential(
338
+ client_data_hash=cdh_reg,
339
+ rp_id=rp_id,
340
+ user_id=os.urandom(16),
341
+ resident_key=rk,
342
+ pin_uv_token=token,
343
+ protocol=proto,
344
+ )
345
+ cred_id = cred["credential_id"]
346
+ cdh_auth = os.urandom(32)
347
+ token2 = _pin_token(app, ctap, pin_env, fido_c.PERM_GET_ASSERTION, rp_id, proto)
348
+ assertion = ctap.get_assertion(
349
+ rp_id=rp_id,
350
+ client_data_hash=cdh_auth,
351
+ allow_credential_ids=[cred_id] if isinstance(cred_id, bytes) else None,
352
+ pin_uv_token=token2,
353
+ protocol=proto,
354
+ )
355
+ verified = False
356
+ pubkey = cred["credential_public_key"]
357
+ auth_data = assertion["auth_data"]
358
+ signature = assertion["signature"]
359
+ if (
360
+ isinstance(pubkey, dict)
361
+ and isinstance(auth_data, bytes)
362
+ and isinstance(signature, bytes)
363
+ ):
364
+ verified = authdata.verify_es256_assertion(pubkey, auth_data, cdh_auth, signature)
365
+ return {
366
+ "registered": True,
367
+ "resident": rk,
368
+ "credential_id": cred_id.hex().upper() if isinstance(cred_id, bytes) else None,
369
+ "assertion_verified": verified,
370
+ }
371
+
372
+ payload = _with_fido(app, action)
373
+
374
+ def human(c: Console) -> None:
375
+ ok = payload["assertion_verified"]
376
+ mark = "[green]PASS[/green]" if ok else "[red]FAIL[/red]"
377
+ c.print(f"FIDO2 register -> assert -> verify: {mark}")
378
+ c.print(f" credential id: {payload['credential_id']}")
379
+ c.print(f" signature verified against the registered key: {ok}")
380
+
381
+ app.out.result(payload, human)
382
+
383
+
384
+ @credential.command("create")
385
+ @click.option("--rp-id", default="cryptnox.local", show_default=True)
386
+ @click.option("--user-name", default="cryptnox", show_default=True)
387
+ @click.option("--rk", is_flag=True, help="Resident (discoverable) credential.")
388
+ @click.option("--pin-env", help="Env var with the FIDO PIN (if required).")
389
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
390
+ @click.pass_obj
391
+ def credential_create(
392
+ app: AppContext, rp_id: str, user_name: str, rk: bool, pin_env: str | None, protocol: str
393
+ ) -> None:
394
+ """Register a credential (authenticatorMakeCredential). Requires a tap."""
395
+ _ensure_fido_access(app)
396
+ proto = int(protocol)
397
+ app.out.note("touch/tap the authenticator when it asks for user presence.")
398
+
399
+ def action(ctap: Ctap2Client) -> dict[str, object]:
400
+ ctap.select()
401
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_MAKE_CREDENTIAL, rp_id, proto)
402
+ cred = ctap.make_credential(
403
+ client_data_hash=os.urandom(32),
404
+ rp_id=rp_id,
405
+ user_id=os.urandom(16),
406
+ user_name=user_name,
407
+ resident_key=rk,
408
+ pin_uv_token=token,
409
+ protocol=proto,
410
+ )
411
+ cid = cred["credential_id"]
412
+ return {
413
+ "registered": True,
414
+ "rp_id": rp_id,
415
+ "credential_id": cid.hex().upper() if isinstance(cid, bytes) else None,
416
+ "fmt": cred["fmt"],
417
+ }
418
+
419
+ payload = _with_fido(app, action)
420
+ app.out.result(
421
+ payload,
422
+ lambda c: c.print(
423
+ f"[green]Registered credential[/green] on {rp_id}.\n id: {payload['credential_id']}"
424
+ ),
425
+ )
426
+
427
+
428
+ @credential.command("assert")
429
+ @click.option("--rp-id", default="cryptnox.local", show_default=True)
430
+ @click.option("--credential-id", help="Credential id (hex) for a non-resident credential.")
431
+ @click.option("--pin-env", help="Env var with the FIDO PIN (if required).")
432
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
433
+ @click.pass_obj
434
+ def credential_assert(
435
+ app: AppContext, rp_id: str, credential_id: str | None, pin_env: str | None, protocol: str
436
+ ) -> None:
437
+ """Get an assertion (authenticatorGetAssertion). Requires a tap."""
438
+ _ensure_fido_access(app)
439
+ proto = int(protocol)
440
+ allow = [from_hex(credential_id)] if credential_id else None
441
+ app.out.note("touch/tap the authenticator when it asks for user presence.")
442
+
443
+ def action(ctap: Ctap2Client) -> dict[str, object]:
444
+ ctap.select()
445
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_GET_ASSERTION, rp_id, proto)
446
+ assertion = ctap.get_assertion(
447
+ rp_id=rp_id,
448
+ client_data_hash=os.urandom(32),
449
+ allow_credential_ids=allow,
450
+ pin_uv_token=token,
451
+ protocol=proto,
452
+ )
453
+ cid = assertion["credential_id"]
454
+ signature = assertion["signature"]
455
+ return {
456
+ "asserted": True,
457
+ "credential_id": cid.hex().upper() if isinstance(cid, bytes) else None,
458
+ "signature_len": len(signature) if isinstance(signature, bytes) else 0,
459
+ "number_of_credentials": assertion["number_of_credentials"],
460
+ }
461
+
462
+ payload = _with_fido(app, action)
463
+ app.out.result(
464
+ payload,
465
+ lambda c: c.print(
466
+ f"[green]Assertion OK[/green] (credential {payload['credential_id']}, "
467
+ f"{payload['signature_len']}-byte signature)."
468
+ ),
469
+ )
470
+
471
+
472
+ def _user_label(user: object) -> str:
473
+ if isinstance(user, dict):
474
+ return str(user.get("name") or user.get("displayName") or "?")
475
+ return "?"
476
+
477
+
478
+ @credential.command("list")
479
+ @click.option("--pin-env", required=True, help="Env var with the FIDO PIN (credMgmt needs UV).")
480
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
481
+ @click.pass_obj
482
+ def credential_list(app: AppContext, pin_env: str, protocol: str) -> None:
483
+ """List resident credentials (authenticatorCredentialManagement)."""
484
+ _ensure_fido_access(app)
485
+ proto = int(protocol)
486
+
487
+ def action(ctap: Ctap2Client) -> dict[str, object]:
488
+ ctap.select()
489
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_CREDENTIAL_MGMT, None, proto)
490
+ if token is None:
491
+ raise CryptnoxError("credential management requires a PIN (set the --pin-env var).")
492
+ existing, remaining = ctap.get_creds_metadata(token, protocol=proto)
493
+ creds = ctap.enumerate_credentials(token, protocol=proto)
494
+ return {
495
+ "existing": existing,
496
+ "max_remaining": remaining,
497
+ "credentials": [
498
+ {
499
+ "rp_id": x["rp_id"],
500
+ "user": _user_label(x["user"]),
501
+ "credential_id": x["credential_id"].hex().upper()
502
+ if isinstance(x["credential_id"], bytes)
503
+ else None,
504
+ }
505
+ for x in creds
506
+ ],
507
+ }
508
+
509
+ payload = _with_fido(app, action)
510
+
511
+ def human(c: Console) -> None:
512
+ c.print(
513
+ f"Resident credentials: [bold]{payload['existing']}[/bold] "
514
+ f"(room for ~{payload['max_remaining']} more)"
515
+ )
516
+ for cr in payload["credentials"]: # type: ignore[attr-defined]
517
+ c.print(f" - rp={cr['rp_id']} user={cr['user']} id={cr['credential_id']}")
518
+
519
+ app.out.result(payload, human)
520
+
521
+
522
+ @credential.command("delete")
523
+ @click.option("--credential-id", required=True, help="Credential id (hex) to delete.")
524
+ @click.option("--pin-env", required=True, help="Env var with the FIDO PIN.")
525
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
526
+ @click.pass_obj
527
+ def credential_delete(app: AppContext, credential_id: str, pin_env: str, protocol: str) -> None:
528
+ """Delete a resident credential by id (authenticatorCredentialManagement)."""
529
+ _ensure_fido_access(app)
530
+ proto = int(protocol)
531
+ cid = from_hex(credential_id)
532
+ if not app.yes and not click.confirm(
533
+ f"Delete credential {credential_id[:16]}...?", default=False
534
+ ):
535
+ raise click.Abort()
536
+
537
+ def action(ctap: Ctap2Client) -> dict[str, object]:
538
+ ctap.select()
539
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_CREDENTIAL_MGMT, None, proto)
540
+ if token is None:
541
+ raise CryptnoxError("credential management requires a PIN (set the --pin-env var).")
542
+ ctap.delete_credential(cid, token, protocol=proto)
543
+ return {"deleted": True, "credential_id": credential_id.upper()}
544
+
545
+ app.out.result(
546
+ _with_fido(app, action),
547
+ lambda c: c.print("[green]Deleted credential.[/green]"),
548
+ )
549
+
550
+
551
+ @command.group("config")
552
+ def config() -> None:
553
+ """authenticatorConfig: device-wide policy (alwaysUv, minimum PIN length).
554
+
555
+ Changing policy needs the authenticatorConfiguration permission, so pass
556
+ --pin-env NAME when a clientPIN is set. Bio enrollment, large-blob and
557
+ enterprise attestation are not implemented by this applet (see `config show`).
558
+ """
559
+
560
+
561
+ def _fido_options(ctap: Ctap2Client) -> dict:
562
+ opts = describe_get_info(ctap.get_info()).get("options")
563
+ return opts if isinstance(opts, dict) else {}
564
+
565
+
566
+ @config.command("show")
567
+ @click.pass_obj
568
+ def config_show(app: AppContext) -> None:
569
+ """Show the configuration policy and which config operations the applet supports."""
570
+ _ensure_fido_access(app)
571
+
572
+ def action(ctap: Ctap2Client) -> dict[str, object]:
573
+ version = ctap.select()
574
+ info = describe_get_info(ctap.get_info())
575
+ opts = info.get("options")
576
+ opts = opts if isinstance(opts, dict) else {}
577
+ return {
578
+ "select_version": version,
579
+ "authenticator_config_supported": bool(opts.get("authnrCfg")),
580
+ "set_min_pin_length_supported": bool(opts.get("setMinPINLength")),
581
+ "always_uv": opts.get("alwaysUv"),
582
+ "min_pin_length": info.get("min_pin_length"),
583
+ "make_cred_uv_not_required": opts.get("makeCredUvNotRqd"),
584
+ "enterprise_attestation": opts.get("ep"),
585
+ "client_pin_set": opts.get("clientPin"),
586
+ }
587
+
588
+ payload = _with_fido(app, action)
589
+
590
+ def human(c: Console) -> None:
591
+ c.print("[bold]authenticatorConfig policy[/bold]")
592
+ ep = payload["enterprise_attestation"]
593
+ rows = [
594
+ ("authenticatorConfig supported", payload["authenticator_config_supported"]),
595
+ ("alwaysUv", payload["always_uv"]),
596
+ ("min PIN length", payload["min_pin_length"]),
597
+ ("setMinPINLength supported", payload["set_min_pin_length_supported"]),
598
+ ("makeCredUvNotRqd", payload["make_cred_uv_not_required"]),
599
+ ("enterprise attestation", "not supported" if ep is None else ep),
600
+ ("clientPIN set", payload["client_pin_set"]),
601
+ ]
602
+ table = app.out.table("Property", "Value")
603
+ for name, value in rows:
604
+ table.add_row(name, str(value))
605
+ c.print(table)
606
+ c.print("[dim]bio enrollment and large-blob are not implemented by this applet.[/dim]")
607
+
608
+ app.out.result(payload, human)
609
+
610
+
611
+ @config.command("toggle-always-uv")
612
+ @click.option("--pin-env", help="Env var with the FIDO PIN (required when a clientPIN is set).")
613
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
614
+ @click.pass_obj
615
+ def config_toggle_always_uv(app: AppContext, pin_env: str | None, protocol: str) -> None:
616
+ """Toggle alwaysUv: when on, every makeCredential/getAssertion requires user verification."""
617
+ _ensure_fido_access(app)
618
+ app.out.warn(
619
+ "This flips a DEVICE-WIDE authenticator policy: alwaysUv applies to every "
620
+ "credential and every relying party using this authenticator."
621
+ )
622
+ if not app.yes and not click.confirm("Toggle alwaysUv now?", default=False):
623
+ raise click.Abort()
624
+ proto = int(protocol)
625
+
626
+ def action(ctap: Ctap2Client) -> dict[str, object]:
627
+ ctap.select()
628
+ before = _fido_options(ctap).get("alwaysUv")
629
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_AUTHENTICATOR_CONFIG, None, proto)
630
+ ctap.toggle_always_uv(pin_uv_token=token, protocol=proto)
631
+ after = _fido_options(ctap).get("alwaysUv")
632
+ return {"always_uv_before": before, "always_uv_after": after}
633
+
634
+ payload = _with_fido(app, action)
635
+ app.out.result(
636
+ payload,
637
+ lambda c: c.print(
638
+ f"alwaysUv: {payload['always_uv_before']} -> [bold]{payload['always_uv_after']}[/bold]"
639
+ ),
640
+ )
641
+
642
+
643
+ @config.command("min-pin-length")
644
+ @click.option("--length", type=int, required=True, help="New minimum PIN length (>= 4).")
645
+ @click.option("--force-change-pin", is_flag=True, help="Force a PIN change on next use.")
646
+ @click.option("--pin-env", help="Env var with the FIDO PIN (required when a clientPIN is set).")
647
+ @click.option("--protocol", type=click.Choice(["1", "2"]), default="1", show_default=True)
648
+ @click.pass_obj
649
+ def config_min_pin_length(
650
+ app: AppContext, length: int, force_change_pin: bool, pin_env: str | None, protocol: str
651
+ ) -> None:
652
+ """Raise the minimum FIDO PIN length. ONE-WAY: it can only increase; lowering it
653
+ again requires `fido reset` (which wipes all credentials)."""
654
+ _ensure_fido_access(app)
655
+ if length < 4:
656
+ raise CryptnoxError("minimum PIN length must be at least 4.")
657
+ proto = int(protocol)
658
+ app.out.warn(
659
+ "setMinPINLength only INCREASES the minimum; it cannot be lowered except by "
660
+ "'fido reset' (which erases all credentials)."
661
+ )
662
+ if not app.yes:
663
+ if app.json or not sys.stdin.isatty():
664
+ raise CryptnoxError(
665
+ "fido config min-pin-length is one-way and needs confirmation: "
666
+ "pass --yes to proceed with --json or piped stdin."
667
+ )
668
+ if not click.confirm(f"Set minimum PIN length to {length}?", default=False):
669
+ raise click.Abort()
670
+
671
+ def action(ctap: Ctap2Client) -> dict[str, object]:
672
+ ctap.select()
673
+ before = describe_get_info(ctap.get_info()).get("min_pin_length")
674
+ token = _pin_token(app, ctap, pin_env, fido_c.PERM_AUTHENTICATOR_CONFIG, None, proto)
675
+ ctap.set_min_pin_length(
676
+ length, force_change_pin=force_change_pin or None, pin_uv_token=token, protocol=proto
677
+ )
678
+ after = describe_get_info(ctap.get_info()).get("min_pin_length")
679
+ return {
680
+ "min_pin_length_before": before,
681
+ "min_pin_length_after": after,
682
+ "force_change_pin": force_change_pin,
683
+ }
684
+
685
+ payload = _with_fido(app, action)
686
+ app.out.result(
687
+ payload,
688
+ lambda c: c.print(
689
+ f"min PIN length: {payload['min_pin_length_before']} -> "
690
+ f"[bold]{payload['min_pin_length_after']}[/bold]"
691
+ ),
692
+ )
693
+
694
+
695
+ @command.command("reset")
696
+ @click.option(
697
+ "--i-understand-this-wipes-all-credentials",
698
+ "understood",
699
+ is_flag=True,
700
+ help="Required for non-interactive use.",
701
+ )
702
+ @click.pass_obj
703
+ def reset(app: AppContext, understood: bool) -> None:
704
+ """IRREVERSIBLY reset the FIDO2 authenticator: erases ALL credentials and the PIN."""
705
+ _ensure_fido_access(app)
706
+ app.out.warn("This ERASES every FIDO2 credential and the PIN on this authenticator.")
707
+ if not understood:
708
+ if app.json or not sys.stdin.isatty():
709
+ raise CryptnoxError(
710
+ "fido reset needs --i-understand-this-wipes-all-credentials non-interactively."
711
+ )
712
+ if click.prompt("Type RESET-FIDO to continue") != "RESET-FIDO":
713
+ raise click.Abort()
714
+ app.out.note(
715
+ "authenticatorReset usually must be issued shortly after the card is presented "
716
+ "and needs a tap (user presence); re-present the card if it is refused."
717
+ )
718
+
719
+ def action(ctap: Ctap2Client) -> dict[str, object]:
720
+ ctap.select()
721
+ ctap.reset()
722
+ return {"reset": True}
723
+
724
+ app.out.result(
725
+ _with_fido(app, action),
726
+ lambda c: c.print(
727
+ "[green]FIDO2 authenticator reset.[/green] All credentials + PIN erased."
728
+ ),
729
+ )