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,192 @@
1
+ """``genuine`` — inspect and verify the Cryptnox genuineness applet.
2
+
3
+ Read-only. ``genuine info`` reports what the applet stores; ``genuine verify`` runs
4
+ the two KDF-free genuineness checks — a live ATTEST (proof the card holds the device
5
+ private key) and the certificate chain to a pinned Cryptnox root. It deliberately
6
+ does NOT attempt the ISD / PIV-SSD key-diversification checks: those need the
7
+ per-card HSM-derived secret this tool has no access to.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import secrets
13
+
14
+ import click
15
+ from cryptography import x509
16
+ from cryptography.hazmat.primitives import serialization
17
+ from rich.console import Console
18
+
19
+ from cryptnox_id_cli import trust
20
+ from cryptnox_id_cli.applets.genuine import constants as gc
21
+ from cryptnox_id_cli.applets.genuine.genuine import GenuinenessApplet
22
+ from cryptnox_id_cli.applets.genuine.verify import verify_genuineness
23
+ from cryptnox_id_cli.cli.context import AppContext
24
+ from cryptnox_id_cli.crypto import x509util
25
+ from cryptnox_id_cli.transport.errors import AppletNotFoundError
26
+
27
+ _ISD_SSD_NOTE = (
28
+ "Not checked: ISD / PIV-SSD key genuineness (KDF-derived per-card keys) - this "
29
+ "tool has no HSM/KDF access. Genuineness here is the applet ATTEST + cert chain."
30
+ )
31
+
32
+
33
+ @click.group("genuine")
34
+ def command() -> None:
35
+ """Manage the Cryptnox genuineness / attestation applet (read-only)."""
36
+
37
+
38
+ def _load_dir_anchors(path: str) -> tuple[list[bytes], list[bytes]]:
39
+ """Load extra pinned anchors from a directory of PEM files, classified like the
40
+ bundled store: self-issued CA -> root, other CA -> intermediate."""
41
+ import pathlib
42
+
43
+ roots: list[bytes] = []
44
+ inters: list[bytes] = []
45
+ for entry in sorted(pathlib.Path(path).iterdir()):
46
+ if not entry.is_file() or entry.suffix.lower() not in (".pem", ".crt", ".cer"):
47
+ continue
48
+ raw = entry.read_bytes()
49
+ try:
50
+ certs = x509.load_pem_x509_certificates(raw)
51
+ except ValueError:
52
+ try:
53
+ certs = [x509.load_der_x509_certificate(raw)]
54
+ except ValueError:
55
+ continue
56
+ for cert in certs:
57
+ try:
58
+ is_ca = cert.extensions.get_extension_for_class(x509.BasicConstraints).value.ca
59
+ except x509.ExtensionNotFound:
60
+ is_ca = False
61
+ if not is_ca:
62
+ continue
63
+ der = cert.public_bytes(serialization.Encoding.DER)
64
+ (roots if cert.issuer == cert.subject else inters).append(der)
65
+ return roots, inters
66
+
67
+
68
+ @command.command("info")
69
+ @click.pass_obj
70
+ def info(app: AppContext) -> None:
71
+ """Report the genuineness applet's state (SELECT + GET INFO + GET CERT)."""
72
+ with app.open_session() as session:
73
+ gen = GenuinenessApplet(session)
74
+ try:
75
+ gen.select()
76
+ except AppletNotFoundError:
77
+ app.out.result(
78
+ {"present": False},
79
+ lambda c: c.print(
80
+ "[yellow]Genuineness applet not found[/yellow] "
81
+ "(it is contact-only — absent over a contactless reader)."
82
+ ),
83
+ )
84
+ return
85
+ blob = gen.get_info()
86
+ leaf_der = gen.get_cert(gc.CERT_LEAF)
87
+ issuer_der = gen.get_cert(gc.CERT_INTERMEDIATE)
88
+
89
+ leaf = x509util.describe_certificate(leaf_der) if leaf_der else None
90
+ issuer = x509util.describe_certificate(issuer_der) if issuer_der else None
91
+ payload = {
92
+ "present": True,
93
+ "personalized": leaf is not None,
94
+ "info": blob.hex if blob else None,
95
+ "leaf": leaf,
96
+ "issuer": issuer,
97
+ }
98
+
99
+ def human(c: Console) -> None:
100
+ c.print("[bold]Genuineness applet[/bold]")
101
+ c.print(f" AID: {gc.GENUINE_AID.hex().upper()}")
102
+ if blob:
103
+ c.print(f" Info: {blob.hex}")
104
+ if leaf:
105
+ c.print(f" Device leaf: {leaf['subject']}")
106
+ c.print(f" issuer: {leaf['issuer']}")
107
+ c.print(f" serial: {leaf['serial']}")
108
+ c.print(f" expires: {leaf['not_after']}")
109
+ else:
110
+ c.print(" [yellow]No device leaf certificate (applet not personalized).[/yellow]")
111
+ c.print(f"\n[dim]{_ISD_SSD_NOTE}[/dim]")
112
+ c.print("[dim]Run `genuine verify` to prove the device key and the chain.[/dim]")
113
+
114
+ app.out.result(payload, human)
115
+
116
+
117
+ @command.command("verify")
118
+ @click.option("--nonce", "nonce_hex", help="Challenge as hex (default: 32 random bytes).")
119
+ @click.option(
120
+ "--anchors",
121
+ "anchors_dir",
122
+ type=click.Path(exists=True, dir_okay=True, file_okay=False),
123
+ help="Directory of extra pinned CA PEMs (e.g. a dev genuineness CA) for chain anchoring.",
124
+ )
125
+ @click.pass_obj
126
+ def verify(app: AppContext, nonce_hex: str | None, anchors_dir: str | None) -> None:
127
+ """Prove genuineness: live ATTEST + certificate chain to a pinned Cryptnox root."""
128
+ if nonce_hex:
129
+ try:
130
+ nonce = bytes.fromhex(nonce_hex)
131
+ except ValueError as exc:
132
+ raise click.BadParameter("nonce must be hex", param_hint="--nonce") from exc
133
+ else:
134
+ nonce = secrets.token_bytes(gc.ATTEST_NONCE_LEN)
135
+
136
+ roots, inters = trust.load_anchors()
137
+ if anchors_dir:
138
+ extra_roots, extra_inters = _load_dir_anchors(anchors_dir)
139
+ roots += extra_roots
140
+ inters += extra_inters
141
+
142
+ with app.open_session() as session:
143
+ gen = GenuinenessApplet(session)
144
+ try:
145
+ gen.select()
146
+ except AppletNotFoundError:
147
+ app.out.result(
148
+ {"present": False, "genuine": False},
149
+ lambda c: c.print(
150
+ "[yellow]Genuineness applet not found[/yellow] "
151
+ "(contact-only — use a contact reader)."
152
+ ),
153
+ )
154
+ return
155
+ leaf_der = gen.get_cert(gc.CERT_LEAF)
156
+ issuer_der = gen.get_cert(gc.CERT_INTERMEDIATE)
157
+ signature: bytes | None = None
158
+ if leaf_der is not None:
159
+ try:
160
+ signature = gen.attest(nonce)
161
+ except AppletNotFoundError: # pragma: no cover - defensive
162
+ signature = None
163
+
164
+ result = verify_genuineness(
165
+ leaf_der=leaf_der,
166
+ nonce=nonce,
167
+ signature=signature,
168
+ card_issuer_der=issuer_der,
169
+ roots=roots,
170
+ intermediates=inters,
171
+ )
172
+ payload = {"present": True, **result.to_dict()}
173
+
174
+ def human(c: Console) -> None:
175
+ verdict = "[green]GENUINE[/green]" if result.genuine else "[red]NOT proven[/red]"
176
+ c.print(f"Genuineness: {verdict}")
177
+ pop = "[green]valid[/green]" if result.attested else "[red]invalid[/red]"
178
+ c.print(f" proof of possession (ATTEST): {pop} [dim]{result.attest_detail}[/dim]")
179
+ if result.leaf_subject:
180
+ c.print(f" device leaf: {result.leaf_subject}")
181
+ if result.chain is not None:
182
+ cv = "[green]verified[/green]" if result.chain.verified else "[red]not verified[/red]"
183
+ c.print(f" certificate chain: {cv}")
184
+ if result.chain.chain:
185
+ c.print(f" {' -> '.join(result.chain.chain)}")
186
+ for reason in result.chain.reasons:
187
+ c.print(f" [yellow]-[/yellow] {reason}")
188
+ for note in result.notes:
189
+ c.print(f" [yellow]*[/yellow] {note}")
190
+ c.print(f"\n[dim]{_ISD_SSD_NOTE}[/dim]")
191
+
192
+ app.out.result(payload, human)
@@ -0,0 +1,94 @@
1
+ """``info`` — detect the card and its applets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from cryptnox_id_cli.cli.context import AppContext
11
+ from cryptnox_id_cli.output.render import state_style
12
+ from cryptnox_id_cli.state import StateDetector
13
+ from cryptnox_id_cli.state.model import CardState
14
+ from cryptnox_id_cli.transport.apdu import APDU
15
+ from cryptnox_id_cli.transport.pcsc import CardSession
16
+
17
+
18
+ def _read_cplc(session: CardSession) -> dict[str, str] | None:
19
+ """Best-effort CPLC read (card serial / production data) via the ISD."""
20
+ with contextlib.suppress(Exception):
21
+ sel = session.transmit(
22
+ APDU(0x00, 0xA4, 0x04, 0x00, data=bytes.fromhex("A000000151000000"), le=256)
23
+ )
24
+ if not sel.ok:
25
+ return None
26
+ cplc = session.transmit(APDU(0x80, 0xCA, 0x9F, 0x7F, le=256))
27
+ if not cplc.ok or not cplc.data:
28
+ return None
29
+ raw = cplc.data
30
+ body = raw[3:] if (len(raw) >= 3 and raw[0] == 0x9F and raw[1] == 0x7F) else raw
31
+ serial = body[12:16].hex().upper() if len(body) >= 16 else None
32
+ # Card UID: IC fabrication date || IC serial number || IC batch identifier
33
+ # (CPLC body[10:18], 8 bytes). This is the canonical Cryptnox card UID (cf.
34
+ # CPLCData.kt) — distinct from the bare 4-byte serial above and the value
35
+ # diversified for the per-card PIV-SSD KVN 2 key.
36
+ uid = body[10:18].hex().upper() if len(body) >= 18 else None
37
+ return {"raw": raw.hex().upper(), "ic_serial": serial or "", "uid": uid or ""}
38
+ return None
39
+
40
+
41
+ @click.command("info")
42
+ @click.pass_obj
43
+ def command(app: AppContext) -> None:
44
+ """Detect the inserted card and which applets/functions are present."""
45
+ with app.open_session() as session:
46
+ state: CardState = StateDetector(session).detect()
47
+ cplc = _read_cplc(session)
48
+
49
+ payload = state.to_dict()
50
+ payload["reader"] = app.resolved_reader
51
+ payload["cplc"] = cplc
52
+
53
+ def human(c: Console) -> None:
54
+ c.print(f"[bold]Reader:[/bold] {app.resolved_reader}")
55
+ c.print(f"[bold]ATR:[/bold] {state.atr_hex or '-'}")
56
+ if cplc and cplc.get("ic_serial"):
57
+ c.print(f"[bold]Serial:[/bold] {cplc['ic_serial']} (CPLC)")
58
+ if cplc and cplc.get("uid"):
59
+ c.print(f"[bold]UID:[/bold] {cplc['uid']} (CPLC)")
60
+ c.print("")
61
+
62
+ table = app.out.table("Function", "State", "Details", title="Applets / functions")
63
+ # PIV
64
+ piv_detail = ""
65
+ if state.piv_apt:
66
+ piv_detail = state.piv_apt.label or ""
67
+ if state.piv_pin and state.piv_pin.configured:
68
+ tries = state.piv_pin.retries
69
+ piv_detail += f", PIN {tries} tries" if tries is not None else ", PIN set"
70
+ table.add_row("PIV", state_style(state.piv.label), piv_detail or "-")
71
+ # FIDO
72
+ fido_detail = ", ".join(state.fido_versions) if state.fido_versions else ""
73
+ table.add_row("FIDO2", state_style(state.fido.label), fido_detail or "-")
74
+ # DESFire
75
+ desfire_detail = "-"
76
+ if state.desfire_version:
77
+ dv = state.desfire_version
78
+ desfire_detail = f"EV2 {dv.get('storage_bytes')} B, UID {dv.get('uid')}"
79
+ table.add_row("MIFARE DESFire", state_style(state.desfire.label), desfire_detail)
80
+ # Genuineness / attestation applet
81
+ genuine_detail = state.genuine_leaf_subject or "-"
82
+ table.add_row("Genuineness", state_style(state.genuine.label), genuine_detail)
83
+ c.print(table)
84
+
85
+ if state.piv_objects:
86
+ present = [n for n, v in state.piv_objects.items() if v]
87
+ shown = ", ".join(present) if present else "none"
88
+ c.print(f"\n[dim]PIV objects present:[/dim] {shown}")
89
+ if state.notes:
90
+ c.print("")
91
+ for note in state.notes:
92
+ c.print(f"[yellow]*[/yellow] {note}")
93
+
94
+ app.out.result(payload, human)