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