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.
- cryptnox_id_cli/__init__.py +22 -0
- cryptnox_id_cli/__main__.py +6 -0
- cryptnox_id_cli/applets/__init__.py +2 -0
- cryptnox_id_cli/applets/fido/__init__.py +7 -0
- cryptnox_id_cli/applets/fido/authdata.py +81 -0
- cryptnox_id_cli/applets/fido/constants.py +155 -0
- cryptnox_id_cli/applets/fido/ctap.py +430 -0
- cryptnox_id_cli/applets/fido/errors.py +90 -0
- cryptnox_id_cli/applets/fido/pinproto.py +166 -0
- cryptnox_id_cli/applets/genuine/__init__.py +12 -0
- cryptnox_id_cli/applets/genuine/constants.py +32 -0
- cryptnox_id_cli/applets/genuine/genuine.py +85 -0
- cryptnox_id_cli/applets/genuine/verify.py +150 -0
- cryptnox_id_cli/applets/mifare/__init__.py +10 -0
- cryptnox_id_cli/applets/mifare/desfire.py +353 -0
- cryptnox_id_cli/applets/mifare/ev2.py +421 -0
- cryptnox_id_cli/applets/piv/__init__.py +9 -0
- cryptnox_id_cli/applets/piv/admin.py +182 -0
- cryptnox_id_cli/applets/piv/apt.py +58 -0
- cryptnox_id_cli/applets/piv/constants.py +66 -0
- cryptnox_id_cli/applets/piv/keyimport.py +244 -0
- cryptnox_id_cli/applets/piv/objects.py +126 -0
- cryptnox_id_cli/applets/piv/perso.py +138 -0
- cryptnox_id_cli/applets/piv/piv.py +171 -0
- cryptnox_id_cli/applets/piv/preperso.py +174 -0
- cryptnox_id_cli/applets/piv/profiles.py +486 -0
- cryptnox_id_cli/applets/piv/slots.py +44 -0
- cryptnox_id_cli/cli/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/__init__.py +1 -0
- cryptnox_id_cli/cli/commands/apdu.py +123 -0
- cryptnox_id_cli/cli/commands/doctor.py +144 -0
- cryptnox_id_cli/cli/commands/factory.py +312 -0
- cryptnox_id_cli/cli/commands/fido.py +729 -0
- cryptnox_id_cli/cli/commands/genuine.py +192 -0
- cryptnox_id_cli/cli/commands/info.py +94 -0
- cryptnox_id_cli/cli/commands/mifare.py +998 -0
- cryptnox_id_cli/cli/commands/piv.py +2558 -0
- cryptnox_id_cli/cli/commands/readers.py +64 -0
- cryptnox_id_cli/cli/commands/report.py +217 -0
- cryptnox_id_cli/cli/commands/shell.py +130 -0
- cryptnox_id_cli/cli/context.py +71 -0
- cryptnox_id_cli/cli/dryrun.py +181 -0
- cryptnox_id_cli/cli/main.py +117 -0
- cryptnox_id_cli/crypto/__init__.py +1 -0
- cryptnox_id_cli/crypto/attestation.py +169 -0
- cryptnox_id_cli/crypto/csr.py +135 -0
- cryptnox_id_cli/crypto/piv_objects.py +79 -0
- cryptnox_id_cli/crypto/x509util.py +37 -0
- cryptnox_id_cli/output/__init__.py +5 -0
- cryptnox_id_cli/output/render.py +106 -0
- cryptnox_id_cli/secrets/__init__.py +5 -0
- cryptnox_id_cli/secrets/redaction.py +141 -0
- cryptnox_id_cli/secrets/resolver.py +93 -0
- cryptnox_id_cli/state/__init__.py +19 -0
- cryptnox_id_cli/state/detector.py +273 -0
- cryptnox_id_cli/state/model.py +147 -0
- cryptnox_id_cli/transport/__init__.py +28 -0
- cryptnox_id_cli/transport/apdu.py +72 -0
- cryptnox_id_cli/transport/elevation.py +165 -0
- cryptnox_id_cli/transport/errors.py +185 -0
- cryptnox_id_cli/transport/pcsc.py +351 -0
- cryptnox_id_cli/transport/scp02.py +247 -0
- cryptnox_id_cli/transport/scp03.py +213 -0
- cryptnox_id_cli/trust/__init__.py +90 -0
- cryptnox_id_cli/trust/genuine/cryptnox-attestation-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-dlt-cards-ca.pem +17 -0
- cryptnox_id_cli/trust/genuine/cryptnox-genuineness-ca.pem +18 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca-2.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-intermediate-ca.pem +19 -0
- cryptnox_id_cli/trust/genuine/cryptnox-root-ca.pem +19 -0
- cryptnox_id_cli/util/__init__.py +1 -0
- cryptnox_id_cli/util/hexutil.py +36 -0
- cryptnox_id_cli/util/tlv.py +144 -0
- cryptnox_id_cli-1.0.2.dist-info/METADATA +227 -0
- cryptnox_id_cli-1.0.2.dist-info/RECORD +79 -0
- cryptnox_id_cli-1.0.2.dist-info/WHEEL +5 -0
- cryptnox_id_cli-1.0.2.dist-info/entry_points.txt +4 -0
- cryptnox_id_cli-1.0.2.dist-info/licenses/LICENSE +165 -0
- 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
|
+
)
|