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,64 @@
1
+ """``readers`` — list PC/SC readers (passive, no card session)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import click
6
+ from rich.console import Console
7
+
8
+ from cryptnox_id_cli.cli.context import AppContext
9
+ from cryptnox_id_cli.transport.pcsc import ReaderInfo, reader_states
10
+
11
+
12
+ @click.command("readers")
13
+ @click.pass_obj
14
+ def command(app: AppContext) -> None:
15
+ """List all PC/SC readers, card presence, ATR and the recommended --reader."""
16
+ infos: list[ReaderInfo] = reader_states()
17
+ preferred_with_card = [r for r in infos if r.is_preferred and r.present]
18
+ first_preferred = next((r for r in infos if r.is_preferred), None)
19
+ # Recommend a single reader only when it is unambiguous (one Cryptnox/ACS reader has a card).
20
+ recommended = preferred_with_card[0] if len(preferred_with_card) == 1 else None
21
+ payload = {
22
+ "readers": [
23
+ {
24
+ "index": r.index,
25
+ "name": r.name,
26
+ "card_present": r.present,
27
+ "atr": r.atr_hex or None,
28
+ "preferred": r.is_preferred,
29
+ }
30
+ for r in infos
31
+ ],
32
+ "recommended_reader": recommended.name if recommended else None,
33
+ "preferred_with_card": [r.index for r in preferred_with_card],
34
+ }
35
+
36
+ def human(c: Console) -> None:
37
+ if not infos:
38
+ c.print("[yellow]No PC/SC readers found.[/yellow]")
39
+ return
40
+ table = app.out.table("#", "Reader", "Card", "ATR", title="PC/SC readers")
41
+ for r in infos:
42
+ name = r.name + (" [green](preferred)[/green]" if r.is_preferred else "")
43
+ card = "[green]present[/green]" if r.present else "[dim]empty[/dim]"
44
+ table.add_row(str(r.index), name, card, r.atr_hex or "-")
45
+ c.print(table)
46
+ if recommended is not None:
47
+ c.print(
48
+ f"\nRecommended: [bold]--reader {recommended.index}[/bold] ({recommended.name})"
49
+ )
50
+ elif len(preferred_with_card) > 1:
51
+ picks = " ".join(f"--reader {r.index} ({r.name})" for r in preferred_with_card)
52
+ c.print(
53
+ f"\n[yellow]Multiple Cryptnox/ACS readers have a card[/yellow] - choose one "
54
+ f"(PICC = contactless, ICC = contact):\n {picks}"
55
+ )
56
+ elif first_preferred is not None:
57
+ c.print(
58
+ f"\n[yellow]Cryptnox/ACS reader {first_preferred.index} has no card[/yellow]; "
59
+ "insert one or pass --reader."
60
+ )
61
+ else:
62
+ c.print("\n[yellow]No Cryptnox/ACS reader detected; pass --reader explicitly.[/yellow]")
63
+
64
+ app.out.result(payload, human)
@@ -0,0 +1,217 @@
1
+ """``report`` — machine-readable, secret-safe status reports.
2
+
3
+ Reports contain only non-secret, shareable data: states, versions, object
4
+ presence, public identifiers. No PINs, keys, private material or sensitive
5
+ APDU payloads — by construction (they reuse the read-only inspectors).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import datetime
12
+ import json
13
+ from pathlib import Path
14
+
15
+ import click
16
+ from rich.console import Console
17
+
18
+ from cryptnox_id_cli import CLI_NAME, __version__
19
+ from cryptnox_id_cli.applets.fido.ctap import Ctap2Client, describe_get_info
20
+ from cryptnox_id_cli.applets.mifare.desfire import DesfireNotSelectedError, DesfireTransport
21
+ from cryptnox_id_cli.cli.commands.info import _read_cplc
22
+ from cryptnox_id_cli.cli.context import AppContext
23
+ from cryptnox_id_cli.state import StateDetector
24
+ from cryptnox_id_cli.transport.elevation import FIDO_WINDOWS_MESSAGE
25
+ from cryptnox_id_cli.transport.errors import (
26
+ AppletNotFoundError,
27
+ CardAccessDeniedError,
28
+ CryptnoxError,
29
+ )
30
+
31
+
32
+ @click.group("report")
33
+ def command() -> None:
34
+ """Export readable, secret-safe JSON reports."""
35
+
36
+
37
+ def _envelope(kind: str) -> dict[str, object]:
38
+ return {
39
+ "report": kind,
40
+ "generated_by": f"{CLI_NAME} {__version__}",
41
+ "generated_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
42
+ "safe_to_share": True,
43
+ }
44
+
45
+
46
+ def _card_section(app: AppContext) -> dict[str, object]:
47
+ with app.open_session() as session:
48
+ state = StateDetector(session).detect()
49
+ cplc = _read_cplc(session)
50
+ return {"reader": app.resolved_reader, "state": state.to_dict(), "cplc": cplc}
51
+
52
+
53
+ def _piv_section(app: AppContext) -> dict[str, object]:
54
+ with app.open_session() as session:
55
+ st = StateDetector(
56
+ session, probe_fido=False, probe_desfire=False, probe_genuine=False
57
+ ).detect()
58
+ return {
59
+ "state": st.piv.label,
60
+ "apt": st.piv_apt.to_dict() if st.piv_apt else None,
61
+ "pin": st.piv_pin.to_dict() if st.piv_pin else None,
62
+ "puk": st.piv_puk.to_dict() if st.piv_puk else None,
63
+ "objects_present": st.piv_objects,
64
+ "notes": st.notes,
65
+ }
66
+
67
+
68
+ def _mifare_section(app: AppContext) -> dict[str, object]:
69
+ try:
70
+ with app.open_session() as session:
71
+ dtx = DesfireTransport(session)
72
+ version = dtx.get_version().to_dict()
73
+ free = None
74
+ with contextlib.suppress(CryptnoxError):
75
+ free = dtx.get_free_memory()
76
+ aids = [a.hex().upper() for a in dtx.application_ids()]
77
+ return {
78
+ "state": "DesfireReachable",
79
+ "version": version,
80
+ "free_memory": free,
81
+ "applications": aids,
82
+ }
83
+ except DesfireNotSelectedError:
84
+ return {
85
+ "state": "DesfireNeedsContactlessReader",
86
+ "note": "DESFire (the default MIFARE applet) did not answer on this interface.",
87
+ }
88
+ except CryptnoxError as exc:
89
+ return {"state": "Unknown", "error": str(exc)}
90
+
91
+
92
+ def _genuine_section(app: AppContext) -> dict[str, object]:
93
+ from cryptnox_id_cli.applets.genuine.genuine import GenuinenessApplet
94
+
95
+ try:
96
+ with app.open_session() as session:
97
+ gen = GenuinenessApplet(session)
98
+ if not gen.try_select():
99
+ return {
100
+ "state": "GenuinenessNotPresent",
101
+ "note": "genuineness applet not found (contact-only; absent over contactless).",
102
+ }
103
+ info = gen.get_info()
104
+ leaf_der = gen.get_cert()
105
+ subject = None
106
+ if leaf_der:
107
+ from cryptography import x509
108
+
109
+ subject = x509.load_der_x509_certificate(leaf_der).subject.rfc4514_string()
110
+ return {
111
+ "state": "GenuinenessPersonalized" if leaf_der else "GenuinenessPresent",
112
+ "leaf_subject": subject,
113
+ "info": info.hex if info else None,
114
+ "note": "state only; run `genuine verify` to prove the device key + chain.",
115
+ }
116
+ except CryptnoxError as exc:
117
+ return {"state": "Unknown", "error": str(exc)}
118
+
119
+
120
+ def _fido_section(app: AppContext) -> dict[str, object]:
121
+ try:
122
+ with app.open_session() as session:
123
+ ctap = Ctap2Client(session)
124
+ version = ctap.select()
125
+ info = describe_get_info(ctap.get_info())
126
+ return {"state": "FidoPersonalized", "select_version": version, "get_info": info}
127
+ except CardAccessDeniedError:
128
+ return {"state": "FidoBlockedByOS", "note": FIDO_WINDOWS_MESSAGE}
129
+ except AppletNotFoundError:
130
+ return {"state": "FidoNotPresent"}
131
+ except CryptnoxError as exc:
132
+ return {"state": "Unknown", "error": str(exc)}
133
+
134
+
135
+ def _emit(app: AppContext, payload: dict[str, object], out: str | None) -> None:
136
+ text = json.dumps(payload, indent=2, default=str)
137
+ if out:
138
+ Path(out).write_text(text + "\n", encoding="utf-8")
139
+
140
+ def human(c: Console) -> None:
141
+ if out:
142
+ c.print(f"[green]Report written to {out}[/green] (secret-safe JSON).")
143
+ else:
144
+ c.print(text)
145
+
146
+ app.out.result(payload, human)
147
+
148
+
149
+ def _out_option(fn):
150
+ return click.option(
151
+ "--out", "out_", type=click.Path(dir_okay=False), help="Write the JSON report to FILE."
152
+ )(fn)
153
+
154
+
155
+ @command.command("card")
156
+ @_out_option
157
+ @click.pass_obj
158
+ def card(app: AppContext, out_: str | None) -> None:
159
+ """Full card detection report (all three functions)."""
160
+ payload = _envelope("card")
161
+ payload.update(_card_section(app))
162
+ _emit(app, payload, out_)
163
+
164
+
165
+ @command.command("piv")
166
+ @_out_option
167
+ @click.pass_obj
168
+ def piv(app: AppContext, out_: str | None) -> None:
169
+ """PIV applet report."""
170
+ payload = _envelope("piv")
171
+ payload["piv"] = _piv_section(app)
172
+ _emit(app, payload, out_)
173
+
174
+
175
+ @command.command("mifare")
176
+ @_out_option
177
+ @click.pass_obj
178
+ def mifare(app: AppContext, out_: str | None) -> None:
179
+ """MIFARE DESFire report."""
180
+ payload = _envelope("mifare")
181
+ payload["mifare"] = _mifare_section(app)
182
+ _emit(app, payload, out_)
183
+
184
+
185
+ @command.command("fido")
186
+ @_out_option
187
+ @click.pass_obj
188
+ def fido(app: AppContext, out_: str | None) -> None:
189
+ """FIDO2 authenticator report."""
190
+ payload = _envelope("fido")
191
+ payload["fido"] = _fido_section(app)
192
+ _emit(app, payload, out_)
193
+
194
+
195
+ @command.command("genuine")
196
+ @_out_option
197
+ @click.pass_obj
198
+ def genuine(app: AppContext, out_: str | None) -> None:
199
+ """Genuineness / attestation applet report (state only)."""
200
+ payload = _envelope("genuine")
201
+ payload["genuine"] = _genuine_section(app)
202
+ _emit(app, payload, out_)
203
+
204
+
205
+ @command.command("full")
206
+ @_out_option
207
+ @click.pass_obj
208
+ def full(app: AppContext, out_: str | None) -> None:
209
+ """Everything: card + per-function sections (DESFire probed first)."""
210
+ payload = _envelope("full")
211
+ # DESFire first: it answers only while no JavaCard applet is selected.
212
+ payload["mifare"] = _mifare_section(app)
213
+ payload["card"] = _card_section(app)
214
+ payload["piv"] = _piv_section(app)
215
+ payload["fido"] = _fido_section(app)
216
+ payload["genuine"] = _genuine_section(app)
217
+ _emit(app, payload, out_)
@@ -0,0 +1,130 @@
1
+ """The ``shell`` command — a captive interactive prompt.
2
+
3
+ Launched from a Start-menu shortcut or by hand, it reads a line, runs it as a
4
+ subcommand (type ``piv info`` — no program-name prefix), and loops. Only this
5
+ CLI's own commands run; it is not an OS shell. Global options passed when
6
+ launching the shell (``--reader``, ``--json``, …) carry into every command.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import shlex
13
+ import sys
14
+
15
+ import click
16
+
17
+ from cryptnox_id_cli import CLI_NAME
18
+ from cryptnox_id_cli.cli.context import AppContext
19
+
20
+ _PROMPT = f"{CLI_NAME}> "
21
+ _BANNER = (
22
+ f"{CLI_NAME} interactive shell. Type a command without the "
23
+ f"'{CLI_NAME}' prefix (e.g. 'piv info').\n"
24
+ " help show available commands\n"
25
+ " <command> -h help for a command\n"
26
+ " clear clear the screen\n"
27
+ " exit / quit leave the shell\n"
28
+ )
29
+
30
+
31
+ def _global_args(app: AppContext) -> list[str]:
32
+ """Reconstruct the global options the shell was launched with, so every command
33
+ inherits them (the reader choice above all)."""
34
+ args: list[str] = []
35
+ if app.reader:
36
+ args += ["--reader", app.reader]
37
+ if app.json:
38
+ args.append("--json")
39
+ if app.verbose:
40
+ args.append("--verbose")
41
+ if app.apdu_log_path:
42
+ args += ["--apdu-log", app.apdu_log_path]
43
+ if app.dry_run:
44
+ args.append("--dry-run")
45
+ if app.yes:
46
+ args.append("--yes")
47
+ if app.timeout != 15:
48
+ args += ["--timeout", str(app.timeout)]
49
+ if app.no_color:
50
+ args.append("--no-color")
51
+ return args
52
+
53
+
54
+ @click.command("shell")
55
+ @click.pass_context
56
+ def command(ctx: click.Context) -> None:
57
+ """Start an interactive prompt that runs this CLI's subcommands."""
58
+ root = ctx.find_root().command # the top-level group; avoids importing main (circular)
59
+ app = ctx.obj
60
+ base = _global_args(app)
61
+
62
+ # Line editing / history only when attached to a real terminal. Two reasons: readline
63
+ # is pointless on piped input, and (crucially) an imported readline makes input() read
64
+ # the terminal fd directly, which would bypass a redirected sys.stdin - so a piped or
65
+ # test-driven stdin must go through sys.stdin.readline() instead.
66
+ interactive = sys.stdin.isatty()
67
+ if interactive: # pragma: no cover - terminal-only
68
+ # Windows stdlib has no readline; input() still works, just without history.
69
+ with contextlib.suppress(ImportError):
70
+ import readline # noqa: F401
71
+
72
+ def _next_line() -> str:
73
+ if interactive:
74
+ return input(_PROMPT)
75
+ line = sys.stdin.readline()
76
+ if not line: # EOF on a pipe
77
+ raise EOFError
78
+ return line.rstrip("\n")
79
+
80
+ click.echo(_BANNER)
81
+ while True:
82
+ try:
83
+ line = _next_line().strip()
84
+ except EOFError: # Ctrl-D / end of pipe
85
+ click.echo()
86
+ break
87
+ except KeyboardInterrupt: # Ctrl-C cancels the line, not the shell
88
+ click.echo()
89
+ continue
90
+
91
+ if not line:
92
+ continue
93
+ low = line.lower()
94
+ if low in ("exit", "quit"):
95
+ break
96
+ if low == "help":
97
+ line = "--help"
98
+ elif low == "clear":
99
+ click.clear()
100
+ continue
101
+
102
+ try:
103
+ argv = base + shlex.split(line)
104
+ except ValueError as exc: # unbalanced quotes
105
+ click.echo(f"error: {exc}", err=True)
106
+ continue
107
+
108
+ # Re-enter the root group for this one line. standalone_mode=False keeps Click
109
+ # from calling sys.exit on our behalf; a fresh AppContext per command preserves
110
+ # the per-command SCP03 session semantics the applets require. Errors are already
111
+ # routed through the group's funnel; catch the SystemExit it raises so the loop
112
+ # survives, and catch Click's own usage errors here.
113
+ #
114
+ # sys.argv must mirror the line being run: the Windows FIDO elevation offer
115
+ # rebuilds its UAC relaunch from sys.argv (transport/elevation.relaunch_command),
116
+ # and the shell's own argv says "shell" - relaunching THAT would open a hidden
117
+ # elevated shell waiting on input, hanging both processes. Swap it in, restore
118
+ # after.
119
+ saved_argv = sys.argv
120
+ try:
121
+ sys.argv = [saved_argv[0], *argv]
122
+ root.main(args=argv, prog_name=CLI_NAME, standalone_mode=False)
123
+ except SystemExit:
124
+ pass # error already reported via the group's funnel; keep the shell alive
125
+ except click.ClickException as exc:
126
+ exc.show()
127
+ except (KeyboardInterrupt, EOFError):
128
+ click.echo()
129
+ finally:
130
+ sys.argv = saved_argv
@@ -0,0 +1,71 @@
1
+ """The per-invocation application context shared by all commands."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import TextIO
7
+
8
+ from cryptnox_id_cli import CLI_NAME
9
+ from cryptnox_id_cli.output.render import Output
10
+ from cryptnox_id_cli.secrets.redaction import Redactor
11
+ from cryptnox_id_cli.transport.pcsc import CardSession, RawConnection, connect, pick_reader
12
+
13
+
14
+ @dataclass
15
+ class AppContext:
16
+ reader: str | None = None
17
+ json: bool = False
18
+ verbose: bool = False
19
+ apdu_log_path: str | None = None
20
+ dry_run: bool = False
21
+ yes: bool = False
22
+ timeout: int = 15
23
+ no_color: bool = False
24
+ elevated_result_out: str | None = None
25
+
26
+ resolved_reader: str | None = field(default=None, init=False)
27
+ out: Output = field(init=False)
28
+ redactor: Redactor = field(init=False)
29
+ _log_fh: TextIO | None = field(default=None, init=False)
30
+
31
+ def __post_init__(self) -> None:
32
+ self.out = Output(
33
+ json=self.json,
34
+ no_color=self.no_color,
35
+ verbose=self.verbose,
36
+ result_path=self.elevated_result_out,
37
+ )
38
+ self.redactor = Redactor()
39
+
40
+ def _trace(self, line: str) -> None:
41
+ if self.verbose:
42
+ self.out.err.print(f"[dim]apdu {line}[/dim]")
43
+
44
+ def _ensure_log(self) -> TextIO | None:
45
+ if self.apdu_log_path and self._log_fh is None:
46
+ self._log_fh = open(self.apdu_log_path, "a", encoding="utf-8") # noqa: SIM115
47
+ self._log_fh.write(f"# {CLI_NAME} APDU transcript (secrets redacted)\n")
48
+ return self._log_fh
49
+
50
+ def make_session(self, conn: RawConnection, *, reader_name: str | None = None) -> CardSession:
51
+ """Wrap an already-open connection in a CardSession with our redactor/log."""
52
+ return CardSession(
53
+ conn,
54
+ redactor=self.redactor,
55
+ apdu_log=self._ensure_log(),
56
+ trace=self._trace,
57
+ reader_name=reader_name,
58
+ )
59
+
60
+ def open_session(self) -> CardSession:
61
+ """Connect to the resolved reader (Cryptnox/ACS-first) and return a CardSession."""
62
+ name = pick_reader(self.reader)
63
+ self.resolved_reader = name
64
+ return self.make_session(connect(name), reader_name=name)
65
+
66
+ def close(self) -> None:
67
+ if self._log_fh is not None:
68
+ try:
69
+ self._log_fh.close()
70
+ finally:
71
+ self._log_fh = None
@@ -0,0 +1,181 @@
1
+ """Fail-closed enforcement of the global ``--dry-run`` promise.
2
+
3
+ ``--dry-run`` is documented as "write nothing to the card". Only a few commands can
4
+ actually *plan* their card operations; historically every other command silently ignored
5
+ the flag and executed for real - so ``--dry-run fido reset ...`` wiped the authenticator
6
+ it promised to spare. This module closes that hole centrally:
7
+
8
+ * commands in :data:`PLANS_OWN` implement ``app.dry_run`` themselves (they show a plan
9
+ and send nothing) - left alone;
10
+ * commands in :data:`READ_ONLY` cannot change card state (and consume no secrets or
11
+ retry counters) - left alone;
12
+ * everything else - :data:`NO_DRY_RUN`, and any future command nobody classified - is
13
+ wrapped so that under ``--dry-run`` it REFUSES to run before opening a card session.
14
+
15
+ Refusing a read-only-but-unclassified command costs an error message; executing a
16
+ mutating one costs a card. Fail closed. A unit test asserts every leaf command is
17
+ classified in exactly one of the three sets, so adding a command forces the decision.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import functools
23
+ from collections.abc import Callable
24
+ from typing import Any
25
+
26
+ import click
27
+
28
+ from cryptnox_id_cli.cli.context import AppContext
29
+
30
+ #: Commands that implement ``app.dry_run`` themselves: they detect, plan, print the
31
+ #: intended steps, and send nothing. Verified by reading each implementation.
32
+ PLANS_OWN = frozenset(
33
+ {
34
+ "apdu select",
35
+ "apdu send",
36
+ "apdu transcript",
37
+ "factory piv preperso finalize",
38
+ "factory piv preperso load-config",
39
+ "piv perso import-key",
40
+ "piv perso import-p12",
41
+ "piv quickstart",
42
+ }
43
+ )
44
+
45
+ #: Commands that cannot mutate card state and consume no cardholder secrets or retry
46
+ #: counters, so running them under ``--dry-run`` is harmless. Local-file writers
47
+ #: (``report``, ``generate-chuid``, ``--out`` exports) belong here: the promise is
48
+ #: about the card. ``shell`` is a container - each forwarded line re-enters the root
49
+ #: group and gets its own guard.
50
+ READ_ONLY = frozenset(
51
+ {
52
+ "doctor",
53
+ "info",
54
+ "readers",
55
+ "shell",
56
+ "factory piv preperso export-config",
57
+ "factory piv preperso init-config",
58
+ "factory piv preperso inspect-defaults",
59
+ "factory piv preperso status",
60
+ "fido config show",
61
+ "fido get-info",
62
+ "fido info",
63
+ "fido pin status",
64
+ "fido ping",
65
+ "genuine info",
66
+ "genuine verify",
67
+ "mifare apps list",
68
+ "mifare files list",
69
+ "mifare free-memory",
70
+ "mifare info",
71
+ "mifare read",
72
+ "mifare record read",
73
+ "mifare sdm read",
74
+ "mifare value get",
75
+ "mifare version",
76
+ "piv admin status",
77
+ "piv certs export",
78
+ "piv certs inspect",
79
+ "piv certs list",
80
+ "piv discover",
81
+ "piv export-attestation",
82
+ "piv info",
83
+ "piv objects list",
84
+ "piv objects read",
85
+ "piv perso generate-ccc",
86
+ "piv perso generate-chuid",
87
+ "piv perso generate-discovery",
88
+ "piv pin status",
89
+ "piv preperso",
90
+ "piv slots",
91
+ "piv status",
92
+ "piv validate",
93
+ "piv verify-attestation",
94
+ "report card",
95
+ "report fido",
96
+ "report full",
97
+ "report genuine",
98
+ "report mifare",
99
+ "report piv",
100
+ }
101
+ )
102
+
103
+ #: Commands that mutate the card, or consume secrets / retry counters (a wrong PIN in a
104
+ #: "read" like ``fido credential list`` still burns an attempt), and have no planning
105
+ #: mode. Under ``--dry-run`` these refuse to run. The listing is deliberate: the
106
+ #: partition test forces every new command to be classified here or above.
107
+ NO_DRY_RUN = frozenset(
108
+ {
109
+ "fido config min-pin-length",
110
+ "fido config toggle-always-uv",
111
+ "fido credential assert",
112
+ "fido credential create",
113
+ "fido credential delete",
114
+ "fido credential list",
115
+ "fido credential self-test",
116
+ "fido pin change",
117
+ "fido pin set",
118
+ "fido reset",
119
+ "mifare app create",
120
+ "mifare app delete",
121
+ "mifare files create-standard",
122
+ "mifare format",
123
+ "mifare keys authenticate",
124
+ "mifare keys change",
125
+ "mifare record clear",
126
+ "mifare record create",
127
+ "mifare record write",
128
+ "mifare sdm setup",
129
+ "mifare value create",
130
+ "mifare value credit",
131
+ "mifare value debit",
132
+ "mifare write",
133
+ "piv admin authenticate",
134
+ "piv perso generate-csr",
135
+ "piv perso generate-key",
136
+ "piv perso import-cert",
137
+ "piv perso self-sign-cert",
138
+ "piv perso set-pin",
139
+ "piv perso set-puk",
140
+ "piv perso smoke-test",
141
+ "piv perso write-object",
142
+ "piv perso write-standard-objects",
143
+ "piv pin change",
144
+ "piv pin unblock",
145
+ "piv pin verify",
146
+ "piv puk change",
147
+ }
148
+ )
149
+
150
+
151
+ def _refusing(path: str, fn: Callable[..., Any]) -> Callable[..., Any]:
152
+ @functools.wraps(fn)
153
+ def inner(*args: Any, **kwargs: Any) -> Any:
154
+ ctx = click.get_current_context(silent=True)
155
+ app = ctx.find_object(AppContext) if ctx is not None else None
156
+ if app is not None and app.dry_run:
157
+ raise click.ClickException(
158
+ f"--dry-run: '{path}' cannot preview its card operations, so it refuses to "
159
+ "run (fail-closed; nothing was sent to the card). Run it without --dry-run "
160
+ "to execute."
161
+ )
162
+ return fn(*args, **kwargs)
163
+
164
+ return inner
165
+
166
+
167
+ def install_guard(root: click.Group) -> None:
168
+ """Wrap every leaf command not known to be dry-run-safe. Idempotent."""
169
+ if getattr(root, "_dry_run_guard", False): # pragma: no cover - import-time reentry
170
+ return
171
+ root._dry_run_guard = True # type: ignore[attr-defined]
172
+ _wrap(root, "")
173
+
174
+
175
+ def _wrap(group: click.Group, prefix: str) -> None:
176
+ for name, cmd in group.commands.items():
177
+ path = f"{prefix}{name}"
178
+ if isinstance(cmd, click.Group):
179
+ _wrap(cmd, path + " ")
180
+ elif path not in READ_ONLY and path not in PLANS_OWN and cmd.callback is not None:
181
+ cmd.callback = _refusing(path, cmd.callback)