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,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)
|