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,144 @@
|
|
|
1
|
+
"""``doctor`` — environment and per-applet diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
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.state import StateDetector
|
|
12
|
+
from cryptnox_id_cli.state.model import DesfireState, FidoState, GenuinenessState, PivState
|
|
13
|
+
from cryptnox_id_cli.transport.elevation import is_elevated, is_windows
|
|
14
|
+
from cryptnox_id_cli.transport.errors import CryptnoxError, NoCardError
|
|
15
|
+
from cryptnox_id_cli.transport.pcsc import connect, pick_reader, reader_states
|
|
16
|
+
|
|
17
|
+
_STATUS_STYLE = {
|
|
18
|
+
"ok": "[green]ok[/green]",
|
|
19
|
+
"warn": "[yellow]warn[/yellow]",
|
|
20
|
+
"fail": "[red]fail[/red]",
|
|
21
|
+
"info": "[blue]info[/blue]",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@click.command("doctor")
|
|
26
|
+
@click.pass_obj
|
|
27
|
+
def command(app: AppContext) -> None:
|
|
28
|
+
"""Run diagnostics: PC/SC service, reader, card, and per-applet reachability."""
|
|
29
|
+
checks: list[dict[str, str]] = []
|
|
30
|
+
|
|
31
|
+
def add(name: str, status: str, detail: str) -> None:
|
|
32
|
+
checks.append({"check": name, "status": status, "detail": detail})
|
|
33
|
+
|
|
34
|
+
infos = None
|
|
35
|
+
try:
|
|
36
|
+
infos = reader_states()
|
|
37
|
+
add("PC/SC service", "ok", f"{len(infos)} reader(s) detected")
|
|
38
|
+
except CryptnoxError as exc:
|
|
39
|
+
add("PC/SC service", "fail", str(exc))
|
|
40
|
+
|
|
41
|
+
reader_name = None
|
|
42
|
+
if infos is not None:
|
|
43
|
+
try:
|
|
44
|
+
reader_name = pick_reader(app.reader)
|
|
45
|
+
add("Reader (Cryptnox/ACS-first)", "ok", reader_name)
|
|
46
|
+
except CryptnoxError as exc:
|
|
47
|
+
add("Reader (Cryptnox/ACS-first)", "fail", str(exc))
|
|
48
|
+
|
|
49
|
+
if reader_name:
|
|
50
|
+
try:
|
|
51
|
+
session = app.make_session(connect(reader_name))
|
|
52
|
+
except NoCardError as exc:
|
|
53
|
+
add("Card present", "fail", str(exc))
|
|
54
|
+
except CryptnoxError as exc:
|
|
55
|
+
add("Card connection", "fail", str(exc))
|
|
56
|
+
else:
|
|
57
|
+
try:
|
|
58
|
+
add("Card present", "ok", f"ATR {session.atr.hex().upper()}")
|
|
59
|
+
st = StateDetector(session).detect()
|
|
60
|
+
|
|
61
|
+
if st.piv == PivState.NOT_PRESENT:
|
|
62
|
+
add("PIV applet", "warn", "not present")
|
|
63
|
+
elif st.piv == PivState.UNKNOWN:
|
|
64
|
+
add("PIV applet", "warn", "state unknown")
|
|
65
|
+
else:
|
|
66
|
+
add("PIV applet", "ok", f"selectable ({st.piv.label})")
|
|
67
|
+
|
|
68
|
+
if st.fido == FidoState.BLOCKED_BY_OS:
|
|
69
|
+
add(
|
|
70
|
+
"FIDO2 applet",
|
|
71
|
+
"warn",
|
|
72
|
+
"blocked by OS; run from an Administrator terminal to manage FIDO2",
|
|
73
|
+
)
|
|
74
|
+
elif st.fido in (FidoState.SELECTABLE, FidoState.PERSONALIZED):
|
|
75
|
+
add("FIDO2 applet", "ok", st.fido.label)
|
|
76
|
+
elif st.fido == FidoState.NOT_PRESENT:
|
|
77
|
+
add("FIDO2 applet", "warn", "not present on this interface")
|
|
78
|
+
else:
|
|
79
|
+
add("FIDO2 applet", "warn", st.fido.label)
|
|
80
|
+
|
|
81
|
+
if st.desfire == DesfireState.REACHABLE:
|
|
82
|
+
add("DESFire (contactless)", "ok", "reachable")
|
|
83
|
+
elif st.desfire == DesfireState.NEEDS_CONTACTLESS_READER:
|
|
84
|
+
add(
|
|
85
|
+
"DESFire (contactless)",
|
|
86
|
+
"warn",
|
|
87
|
+
"needs a contactless reader (the contact interface cannot reach it)",
|
|
88
|
+
)
|
|
89
|
+
elif st.desfire == DesfireState.NO_ANSWER_CONTACTLESS:
|
|
90
|
+
add(
|
|
91
|
+
"DESFire (contactless)",
|
|
92
|
+
"warn",
|
|
93
|
+
"no answer on this contactless interface - re-present the card; "
|
|
94
|
+
"the reader must pass native DESFire APDUs",
|
|
95
|
+
)
|
|
96
|
+
else:
|
|
97
|
+
add("DESFire (contactless)", "warn", st.desfire.label)
|
|
98
|
+
|
|
99
|
+
if st.genuine == GenuinenessState.PERSONALIZED:
|
|
100
|
+
add("Genuineness applet", "ok", "personalized (run `genuine verify` to prove)")
|
|
101
|
+
elif st.genuine == GenuinenessState.PRESENT:
|
|
102
|
+
add("Genuineness applet", "warn", "present but not personalized (no leaf cert)")
|
|
103
|
+
elif st.genuine == GenuinenessState.NEEDS_CONTACT_READER:
|
|
104
|
+
add("Genuineness applet", "info", "contact-only; unreachable over contactless")
|
|
105
|
+
elif st.genuine == GenuinenessState.NOT_PRESENT:
|
|
106
|
+
add("Genuineness applet", "info", "not present on this card")
|
|
107
|
+
else:
|
|
108
|
+
add("Genuineness applet", "warn", st.genuine.label)
|
|
109
|
+
finally:
|
|
110
|
+
session.disconnect()
|
|
111
|
+
|
|
112
|
+
if is_windows():
|
|
113
|
+
elev = is_elevated()
|
|
114
|
+
if elev is True:
|
|
115
|
+
add("Windows elevation", "ok", "Administrator (FIDO2 reachable)")
|
|
116
|
+
elif elev is False:
|
|
117
|
+
add(
|
|
118
|
+
"Windows elevation",
|
|
119
|
+
"info",
|
|
120
|
+
"not elevated; FIDO2 needs admin (Windows reserves CTAP PC/SC access "
|
|
121
|
+
"for the WebAuthn API). `fido` will offer to relaunch via UAC.",
|
|
122
|
+
)
|
|
123
|
+
else:
|
|
124
|
+
add("Windows elevation", "info", "could not determine elevation")
|
|
125
|
+
|
|
126
|
+
has_fail = any(c["status"] == "fail" for c in checks)
|
|
127
|
+
payload = {"checks": checks, "ok": not has_fail}
|
|
128
|
+
|
|
129
|
+
def human(c: Console) -> None:
|
|
130
|
+
table = app.out.table("Check", "Status", "Detail", title="Diagnostics")
|
|
131
|
+
for ch in checks:
|
|
132
|
+
table.add_row(ch["check"], _STATUS_STYLE.get(ch["status"], ch["status"]), ch["detail"])
|
|
133
|
+
c.print(table)
|
|
134
|
+
if has_fail:
|
|
135
|
+
c.print("\n[red]Some checks failed.[/red]")
|
|
136
|
+
else:
|
|
137
|
+
c.print(
|
|
138
|
+
"\n[green]No blocking issues.[/green] "
|
|
139
|
+
"[dim](FIDO/DESFire warnings are normal on a contact / non-elevated setup)[/dim]"
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
app.out.result(payload, human)
|
|
143
|
+
if has_fail:
|
|
144
|
+
sys.exit(2)
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
"""``factory`` - MANUFACTURING-ONLY operations.
|
|
2
|
+
|
|
3
|
+
Pre-personalization is structural and (at finalize) irreversible. It is isolated
|
|
4
|
+
here, away from the operator-facing ``piv`` commands, on purpose.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import click
|
|
13
|
+
import yaml
|
|
14
|
+
from rich.console import Console
|
|
15
|
+
|
|
16
|
+
from cryptnox_id_cli.applets.piv import constants as c
|
|
17
|
+
from cryptnox_id_cli.applets.piv import preperso as pp
|
|
18
|
+
from cryptnox_id_cli.applets.piv import profiles as prof_mod
|
|
19
|
+
from cryptnox_id_cli.applets.piv.admin import PivAdmin, scp_label
|
|
20
|
+
from cryptnox_id_cli.applets.piv.slots import PIV_SLOTS
|
|
21
|
+
from cryptnox_id_cli.cli.context import AppContext
|
|
22
|
+
from cryptnox_id_cli.secrets.resolver import resolve_scp03_keys
|
|
23
|
+
from cryptnox_id_cli.state import StateDetector
|
|
24
|
+
from cryptnox_id_cli.state.model import PivState
|
|
25
|
+
from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@click.group("factory")
|
|
29
|
+
@click.pass_obj
|
|
30
|
+
def command(app: AppContext) -> None:
|
|
31
|
+
"""MANUFACTURING-ONLY operations (pre-personalization). Use with care."""
|
|
32
|
+
if not app.json:
|
|
33
|
+
app.out.err.print(
|
|
34
|
+
"[bold yellow]*** MANUFACTURING MODE - irreversible operations ***[/bold yellow]"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@command.group("piv")
|
|
39
|
+
def piv_group() -> None:
|
|
40
|
+
"""Factory PIV operations."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@piv_group.group("preperso")
|
|
44
|
+
def preperso() -> None:
|
|
45
|
+
"""PIV pre-personalization (OpenFIPS201 vendor, over SCP03)."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# --------------------------------------------------------------------------- #
|
|
49
|
+
@preperso.command("status")
|
|
50
|
+
@click.pass_obj
|
|
51
|
+
def status(app: AppContext) -> None:
|
|
52
|
+
"""Show whether pre-personalization is still possible on this card."""
|
|
53
|
+
with app.open_session() as session:
|
|
54
|
+
st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
|
|
55
|
+
scp_supported = False
|
|
56
|
+
scp_version = None
|
|
57
|
+
try:
|
|
58
|
+
probe = PivAdmin(session).initialize_update_probe()
|
|
59
|
+
scp_supported = bool(probe.get("supported"))
|
|
60
|
+
scp_version = probe.get("scp_version")
|
|
61
|
+
except CryptnoxError:
|
|
62
|
+
scp_supported = False
|
|
63
|
+
finalize_allowed = st.piv == PivState.PRE_PERSONALIZED
|
|
64
|
+
scp_ver_label = scp_label(scp_version)
|
|
65
|
+
payload = {
|
|
66
|
+
"state": st.piv.label,
|
|
67
|
+
"scp03_available": scp_supported, # kept for back-compat; covers SCP02/SCP03
|
|
68
|
+
"scp_version": scp_ver_label if scp_supported else None,
|
|
69
|
+
"secured": st.piv == PivState.SECURED,
|
|
70
|
+
"finalize_allowed": finalize_allowed,
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
def human(con: Console) -> None:
|
|
74
|
+
con.print(f"PIV lifecycle state: [bold]{st.piv.label}[/bold]")
|
|
75
|
+
avail = f"yes ({scp_ver_label})" if scp_supported else "no"
|
|
76
|
+
con.print(f" Admin secure channel available: {avail}")
|
|
77
|
+
con.print(f" Finalized (SECURED): {'yes' if payload['secured'] else 'no/undetermined'}")
|
|
78
|
+
if finalize_allowed:
|
|
79
|
+
con.print(" Pre-perso load + finalize: [green]allowed[/green]")
|
|
80
|
+
else:
|
|
81
|
+
con.print(
|
|
82
|
+
f" [yellow]finalize is NOT allowed[/yellow] in state {st.piv.label} "
|
|
83
|
+
"(only on a confidently PivPrePersonalized card)."
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
app.out.result(payload, human)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@preperso.command("inspect-defaults")
|
|
90
|
+
@click.pass_obj
|
|
91
|
+
def inspect_defaults(app: AppContext) -> None:
|
|
92
|
+
"""Show safe, non-secret defaults and what this applet supports."""
|
|
93
|
+
supported = [c.ALGORITHMS[a] for a in sorted(c.SUPPORTED_ALGORITHMS)]
|
|
94
|
+
payload = {
|
|
95
|
+
"supported_algorithms": supported,
|
|
96
|
+
"slots": [s.ref_hex for s in PIV_SLOTS],
|
|
97
|
+
"pin_fips": {"min_length": 6, "retry_cap": 10},
|
|
98
|
+
"builtin_profiles": prof_mod.builtin_names(),
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def human(con: Console) -> None:
|
|
102
|
+
con.print("PIV pre-personalization defaults (non-secret):")
|
|
103
|
+
con.print(f" Supported algorithms: {', '.join(supported)}")
|
|
104
|
+
con.print(f" Key slots: {', '.join(s.ref_hex for s in PIV_SLOTS)}")
|
|
105
|
+
con.print(" PIN/PUK limits: min length 6, retry cap 10")
|
|
106
|
+
con.print(f" Built-in profiles: {', '.join(prof_mod.builtin_names())}")
|
|
107
|
+
|
|
108
|
+
app.out.result(payload, human)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@preperso.command("init-config")
|
|
112
|
+
@click.option("--profile", "profile_name", default="cryptnox-default", show_default=True)
|
|
113
|
+
@click.option("--out", "out_", required=True, type=click.Path(dir_okay=False))
|
|
114
|
+
@click.pass_obj
|
|
115
|
+
def init_config(app: AppContext, profile_name: str, out_: str) -> None:
|
|
116
|
+
"""Write a built-in profile to a YAML file you can edit and load."""
|
|
117
|
+
profile = prof_mod.builtin(profile_name)
|
|
118
|
+
Path(out_).write_text(profile.to_yaml(), encoding="utf-8")
|
|
119
|
+
app.out.result(
|
|
120
|
+
{"profile": profile.name, "out": out_},
|
|
121
|
+
lambda con: con.print(f"[green]Wrote profile '{profile.name}' to {out_}[/green]"),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@preperso.command("export-config")
|
|
126
|
+
@click.option("--out", "out_", required=True, type=click.Path(dir_okay=False))
|
|
127
|
+
@click.pass_obj
|
|
128
|
+
def export_config(app: AppContext, out_: str) -> None:
|
|
129
|
+
"""Export an observed (read-only) snapshot of the card state. Not a loadable profile."""
|
|
130
|
+
with app.open_session() as session:
|
|
131
|
+
st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
|
|
132
|
+
snapshot = {
|
|
133
|
+
"_note": "Observed read-only snapshot - NOT a loadable profile (secrets excluded).",
|
|
134
|
+
"state": st.piv.label,
|
|
135
|
+
"objects_present": st.piv_objects,
|
|
136
|
+
"pin": st.piv_pin.to_dict() if st.piv_pin else None,
|
|
137
|
+
"puk": st.piv_puk.to_dict() if st.piv_puk else None,
|
|
138
|
+
}
|
|
139
|
+
Path(out_).write_text(yaml.safe_dump(snapshot, sort_keys=False), encoding="utf-8")
|
|
140
|
+
app.out.result(
|
|
141
|
+
{"out": out_, **snapshot},
|
|
142
|
+
lambda con: con.print(f"[green]Wrote observed state snapshot to {out_}[/green]"),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _load_profile(file_: str | None, profile_name: str | None) -> prof_mod.PivProfile:
|
|
147
|
+
if bool(file_) == bool(profile_name):
|
|
148
|
+
raise click.UsageError("specify exactly one of --file or --profile")
|
|
149
|
+
profile = (
|
|
150
|
+
prof_mod.from_yaml(Path(file_).read_text(encoding="utf-8"))
|
|
151
|
+
if file_
|
|
152
|
+
else prof_mod.builtin(profile_name or "")
|
|
153
|
+
)
|
|
154
|
+
profile.ensure_valid()
|
|
155
|
+
return profile
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@preperso.command("load-config")
|
|
159
|
+
@click.option("--file", "file_", type=click.Path(exists=True, dir_okay=False))
|
|
160
|
+
@click.option("--profile", "profile_name", help="A built-in profile name instead of --file.")
|
|
161
|
+
@click.option("--dry-run", is_flag=True, help="Validate and show the payload; do not write.")
|
|
162
|
+
@click.option(
|
|
163
|
+
"--default-keys",
|
|
164
|
+
is_flag=True,
|
|
165
|
+
help="Use the default GlobalPlatform TEST keys "
|
|
166
|
+
"(publicly known - fine for dev/eval, never for deployment).",
|
|
167
|
+
)
|
|
168
|
+
@click.pass_obj
|
|
169
|
+
def load_config(
|
|
170
|
+
app: AppContext, file_: str | None, profile_name: str | None, dry_run: bool, default_keys: bool
|
|
171
|
+
) -> None:
|
|
172
|
+
"""Apply a pre-personalization profile (structure) to the card over SCP03."""
|
|
173
|
+
profile = _load_profile(file_, profile_name)
|
|
174
|
+
ops = profile.build_ops()
|
|
175
|
+
payload_bytes = profile.build_payload()
|
|
176
|
+
|
|
177
|
+
if dry_run or app.dry_run:
|
|
178
|
+
result = {
|
|
179
|
+
"dry_run": True,
|
|
180
|
+
"profile": profile.name,
|
|
181
|
+
"operations": [label for label, _ in ops],
|
|
182
|
+
"bulk_bytes": len(payload_bytes),
|
|
183
|
+
"bulk_hex": payload_bytes.hex().upper(),
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
def human(con: Console) -> None:
|
|
187
|
+
con.print(f"[bold]DRY RUN[/bold] - profile '{profile.name}' ({profile.mode})")
|
|
188
|
+
con.print(f" {len(ops)} operations, each sent as a short PUT DATA ADMIN command:")
|
|
189
|
+
for label, op in ops:
|
|
190
|
+
con.print(f" [{op[0]:#04x}] {label}")
|
|
191
|
+
con.print("\n [dim]Nothing was sent to the card.[/dim]")
|
|
192
|
+
|
|
193
|
+
app.out.result(result, human)
|
|
194
|
+
return
|
|
195
|
+
|
|
196
|
+
# Real write.
|
|
197
|
+
app.out.warn(
|
|
198
|
+
f"This writes {len(ops)} structural operations to the card's data model "
|
|
199
|
+
"(reversible only before finalize)."
|
|
200
|
+
)
|
|
201
|
+
if not app.yes and not click.confirm("Proceed with writing to the card?", default=False):
|
|
202
|
+
raise click.Abort()
|
|
203
|
+
|
|
204
|
+
keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
|
|
205
|
+
# Send one op per PUT DATA ADMIN (each a single-op BULK) to keep every wrapped
|
|
206
|
+
# command a short APDU. Stop at the first rejection and report what was applied.
|
|
207
|
+
sent: list[dict[str, object]] = []
|
|
208
|
+
failed: tuple[str, str] | None = None
|
|
209
|
+
with app.open_session() as session:
|
|
210
|
+
adm = PivAdmin(session)
|
|
211
|
+
for label, op in ops:
|
|
212
|
+
# JCOP 4.5 allows only ONE application APDU per applet-directed SCP03
|
|
213
|
+
# session, so open a fresh secure channel for each PUT DATA ADMIN.
|
|
214
|
+
adm.select()
|
|
215
|
+
adm.open(keys)
|
|
216
|
+
resp = adm.send(
|
|
217
|
+
pp.put_data_admin_apdu(pp.build_bulk([op])), context=f"PUT DATA ADMIN [{label}]"
|
|
218
|
+
)
|
|
219
|
+
sent.append({"op": label, "sw": resp.sw_hex(), "ok": resp.ok})
|
|
220
|
+
if not resp.ok:
|
|
221
|
+
failed = (label, resp.sw_hex())
|
|
222
|
+
break
|
|
223
|
+
applied_ok = failed is None
|
|
224
|
+
|
|
225
|
+
def report(con: Console) -> None:
|
|
226
|
+
for s in sent:
|
|
227
|
+
mark = "[green]ok[/green]" if s["ok"] else f"[red]{s['sw']}[/red]"
|
|
228
|
+
con.print(f" {mark} {s['op']}")
|
|
229
|
+
if applied_ok:
|
|
230
|
+
con.print(
|
|
231
|
+
f"\n[green]Applied profile '{profile.name}'[/green] ({len(sent)} operations)."
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
if failed is None: # unreachable: this branch exists because an op failed
|
|
235
|
+
raise RuntimeError("stopped without a failing operation")
|
|
236
|
+
con.print(f"\n[red]Stopped at[/red] {failed[0]} (SW={failed[1]}).")
|
|
237
|
+
con.print(
|
|
238
|
+
" Operations before it were applied. An existing object/verifier/key is "
|
|
239
|
+
"rejected; reinstall the PIV applet for a clean load, or edit the profile."
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
app.out.result({"profile": profile.name, "applied": applied_ok, "operations": sent}, report)
|
|
243
|
+
if not applied_ok:
|
|
244
|
+
# Partial/failed structural load must not look like success to a caller's exit check.
|
|
245
|
+
sys.exit(6)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@preperso.command("finalize")
|
|
249
|
+
@click.option(
|
|
250
|
+
"--i-understand-this-is-irreversible",
|
|
251
|
+
"understood",
|
|
252
|
+
is_flag=True,
|
|
253
|
+
help="Required for non-interactive use.",
|
|
254
|
+
)
|
|
255
|
+
@click.option(
|
|
256
|
+
"--default-keys",
|
|
257
|
+
is_flag=True,
|
|
258
|
+
help="Use the default GlobalPlatform TEST keys "
|
|
259
|
+
"(publicly known - fine for dev/eval, never for deployment).",
|
|
260
|
+
)
|
|
261
|
+
@click.pass_obj
|
|
262
|
+
def finalize(app: AppContext, understood: bool, default_keys: bool) -> None:
|
|
263
|
+
"""IRREVERSIBLY lock the applet (SECURE APPLET). Pre-perso commands stop working."""
|
|
264
|
+
with app.open_session() as session:
|
|
265
|
+
st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
|
|
266
|
+
# Finalize gates on the lifecycle (must be selectable and not already SECURED),
|
|
267
|
+
# not on how much structure has been loaded - finalize runs AFTER pre-perso load.
|
|
268
|
+
if st.piv in (PivState.NOT_PRESENT, PivState.UNKNOWN):
|
|
269
|
+
raise CryptnoxError(
|
|
270
|
+
f"Refusing to finalize: PIV applet is not selectable (state {st.piv.label})."
|
|
271
|
+
)
|
|
272
|
+
if st.piv == PivState.SECURED:
|
|
273
|
+
raise CryptnoxError("Applet is already SECURED (finalized) - nothing to do.")
|
|
274
|
+
|
|
275
|
+
if app.dry_run:
|
|
276
|
+
app.out.result(
|
|
277
|
+
{"dry_run": True, "would_finalize": True, "state": st.piv.label},
|
|
278
|
+
lambda con: con.print(
|
|
279
|
+
"[yellow]dry-run:[/yellow] would finalize (SECURE APPLET) - no command sent. "
|
|
280
|
+
f"Current PIV state: {st.piv.label}."
|
|
281
|
+
),
|
|
282
|
+
)
|
|
283
|
+
return
|
|
284
|
+
|
|
285
|
+
if not understood:
|
|
286
|
+
if app.json or not sys.stdin.isatty():
|
|
287
|
+
raise CryptnoxError(
|
|
288
|
+
"finalize requires --i-understand-this-is-irreversible in non-interactive mode."
|
|
289
|
+
)
|
|
290
|
+
app.out.warn("This is IRREVERSIBLE: pre-personalization commands will stop working.")
|
|
291
|
+
if click.prompt("Type FINALIZE-PIV to continue") != "FINALIZE-PIV":
|
|
292
|
+
raise click.Abort()
|
|
293
|
+
|
|
294
|
+
keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
|
|
295
|
+
with app.open_session() as session:
|
|
296
|
+
adm = PivAdmin(session)
|
|
297
|
+
adm.select()
|
|
298
|
+
adm.open(keys)
|
|
299
|
+
resp = adm.send(
|
|
300
|
+
pp.put_data_admin_apdu(pp.build_bulk([pp.secure_applet()])), context="SECURE APPLET"
|
|
301
|
+
)
|
|
302
|
+
if not resp.ok:
|
|
303
|
+
# Surface as a non-zero exit so manufacturing automation detects the failure.
|
|
304
|
+
raise StatusWordError(resp.sw1, resp.sw2, context="SECURE APPLET (finalize)")
|
|
305
|
+
post = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
|
|
306
|
+
app.out.result(
|
|
307
|
+
{"finalized": True, "sw": resp.sw_hex(), "state": post.piv.label},
|
|
308
|
+
lambda con: con.print(
|
|
309
|
+
f"[green]Applet finalized[/green] (SW={resp.sw_hex()}); state now {post.piv.label}. "
|
|
310
|
+
"Pre-personalization commands are no longer available."
|
|
311
|
+
),
|
|
312
|
+
)
|