From fadff3e3a3a25d525985dd9a1d8aa98546273b40 Mon Sep 17 00:00:00 2001 From: Philipp Serr Date: Sat, 5 Sep 2026 22:52:15 +0200 Subject: [PATCH 1/2] Add piv perso set-mgmt-key and management-key status New `piv perso set-mgmt-key` sets the PIV management key (9B, AES) the same way set-pin/set-puk work: CHANGE REFERENCE DATA ADMIN over SCP03. Unlike PIN/PUK, 9B is a key object, not a verifier, so the value is raw AES key material (hex, 16/24/32 bytes per --algorithm) sent as a CLEAR element followed by the key-value element (tag 0x80) - the same CLEAR-first, one-element-per-SCP03-session shape import-key already uses. keyimport.py gains sym_key_plan()/aes_key_len() alongside the existing asymmetric element_plan(), verified against the OpenFIPS201 v2 applet source (PIVKeySYM.java) rather than guessed. `piv status` now also reports whether 9B is set, via a non-destructive GENERAL AUTHENTICATE probe (PivApplet.mgmt_key_status()) tried across AES-128/192/256. The SW-to-state mapping (6A86/6A88 no object, 6983 not initialised, 6985 initialised, everything else left unknown rather than guessed) is likewise verified against the OpenFIPS201 v2 applet source, scoped to this command only - not wired into the shared StateDetector used by info/doctor/report/quickstart. Docs cover both the SCP03-only admin commands (set-pin/set-puk/ import-key/set-mgmt-key, which have no 9B alternative) and the genuine PIV-standard management-key model 9B enables for ordinary writes (PUT DATA, GENERATE ASYMMETRIC KEY PAIR via GENERAL AUTHENTICATE). Co-Authored-By: Claude --- CHANGELOG.md | 4 + docs/piv/piv-commands.rst | 14 ++- docs/piv/piv-personalization.rst | 20 +++ src/cryptnox_id_cli/applets/piv/keyimport.py | 54 ++++++-- src/cryptnox_id_cli/applets/piv/piv.py | 61 +++++++++ src/cryptnox_id_cli/cli/commands/piv.py | 111 +++++++++++++++- src/cryptnox_id_cli/cli/dryrun.py | 1 + tests/unit/test_keyimport.py | 24 ++++ tests/unit/test_piv_mgmt_key_status.py | 125 +++++++++++++++++++ 9 files changed, 400 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_piv_mgmt_key_status.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d35f653..b1291f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 mechanisms. - `perso generate-key --create-key-object` - the same dev/eval fallback `import-key` already had. +- `perso set-mgmt-key` - set the PIV management key (9B, AES) via CHANGE + REFERENCE DATA ADMIN over SCP03, the same shape as `set-pin`/`set-puk`. +- `piv status` now reports whether the management key (9B) is set, alongside + PIN/PUK status. ## [1.0.3] - 2026-08-31 diff --git a/docs/piv/piv-commands.rst b/docs/piv/piv-commands.rst index 3516347..561de0b 100644 --- a/docs/piv/piv-commands.rst +++ b/docs/piv/piv-commands.rst @@ -14,7 +14,7 @@ Inspection .. code-block:: text piv info applet AID/label, supported algorithms, key slots - piv status lifecycle state, PIN/PUK status, object presence + piv status lifecycle state, PIN/PUK/management-key status, object presence piv discover the Discovery object (PIN usage policy) piv slots key slots and which hold a certificate piv validate consistency check (NOT a NIST/FIPS validation) @@ -108,6 +108,7 @@ Personalization (``piv perso``) piv perso set-pin set the initial PIN (over SCP03) piv perso set-puk set the initial PUK (over SCP03) + piv perso set-mgmt-key set the management key (9B, AES; over SCP03) piv perso generate-key on-card key generation (per the slot's key objects) piv perso import-key inject an externally generated private key piv perso import-p12 one-command PKCS#12 (.p12/.pfx) key + certificate import @@ -143,3 +144,14 @@ Notes starts with a CLEAR, so re-runs are safe. * Large objects (certificates) are written with ISO command chaining automatically. +* ``piv perso set-mgmt-key`` sets the AES value of key reference 9B, sent as + CHANGE REFERENCE DATA ADMIN elements over SCP03 (CLEAR, then the raw key — + same shape as ``import-key``; this admin command is SCP03-only, with no 9B + alternative. The applet does genuinely support 9B as the PIV standard's + management key (SP 800-73's Administration Key) for *standard* PIV writes + (``PUT DATA``, ``GENERATE ASYMMETRIC KEY PAIR``): those accept either an + open SCP03 admin session or a completed GENERAL AUTHENTICATE (INS 0x87) + challenge-response with 9B — the genuinely standard part. |cli| only ever + takes the SCP03 path itself. ``--algorithm`` must match the mechanism the + pre-personalization profile gave the admin key object (``cryptnox-default``: + AES256); a mismatch returns ``6A88``. diff --git a/docs/piv/piv-personalization.rst b/docs/piv/piv-personalization.rst index 73b008f..39cb038 100644 --- a/docs/piv/piv-personalization.rst +++ b/docs/piv/piv-personalization.rst @@ -82,6 +82,26 @@ Step 1 — set the PIN and PUK $ cryptnox-id piv perso set-pin --default-keys # prompts (masked) for the new PIN $ cryptnox-id piv pin status # configured? retries? +Step 1b — set the management key (optional) +--------------------------------------------- + +Key reference 9B holds an AES key (``cryptnox-default``: AES-256). The applet +genuinely supports the PIV standard's management-key model (SP 800-73's +Administration Key, key reference 9B): a standard PIV write (``PUT DATA`` on +a container, ``GENERATE ASYMMETRIC KEY PAIR``) is authorized by *either* an +open SCP03 admin session *or* a completed 9B challenge-response (every +container/key in ``cryptnox-default`` is bound to 9B as its admin key). |cli| +only ever takes the SCP03 path and never authenticates with 9B itself — a +spec-compliant client that speaks GENERAL AUTHENTICATE with AES-256 (P1 +``0x0C``) could use the other path once 9B is set. + +.. code-block:: console + + $ cryptnox-id piv perso set-mgmt-key --default-keys # prompts (masked) for the new key, as hex + +The key value is raw AES key material (hex, sized to ``--algorithm``: 16/24/32 bytes). +``--algorithm`` must match whatever pre-perso gave the admin key object. + Step 2 — generate keys on the card ----------------------------------- diff --git a/src/cryptnox_id_cli/applets/piv/keyimport.py b/src/cryptnox_id_cli/applets/piv/keyimport.py index 64e7828..cd6d390 100644 --- a/src/cryptnox_id_cli/applets/piv/keyimport.py +++ b/src/cryptnox_id_cli/applets/piv/keyimport.py @@ -1,20 +1,22 @@ -"""PIV asymmetric private-key injection (CHANGE REFERENCE DATA ADMIN over SCP03). +"""PIV private-key injection (CHANGE REFERENCE DATA ADMIN over SCP03). OpenFIPS201 accepts externally generated key material with ``00 24 `` carrying exactly ONE key-element TLV per command, so an import is a short sequence of admin commands (one fresh SCP03 session each on JCOP 4.5). The target key object must already exist with the same (slot, mechanism) pair and the IMPORTABLE attribute; RSA objects additionally fix CRT vs plain form at -creation. A key only becomes usable once both public and private parts are loaded, -and re-loading an initialised key requires a CLEAR first — every plan therefore -starts with CLEAR, which makes re-imports idempotent. - -Element tags and exact-length rules (mirrors the applet's PIVKeyECC/PIVKeyRSA): -CLEAR ``9F`` (empty); ECC public point ``86`` (X9.62 uncompressed, 65/97 bytes) and -private scalar ``87`` (big-endian right-aligned, 32/48 bytes); RSA modulus ``81`` -(k bytes), public exponent ``82`` (exactly 3 bytes), private exponent ``83`` -(k bytes, plain form only), CRT components ``90``/``91``/``92``/``93``/``94`` -(P/Q/dP/dQ/qInv, each k/2 bytes). +creation. A key only becomes usable once its parts are loaded, and re-loading an +initialised key requires a CLEAR first — every plan therefore starts with CLEAR, +which makes re-imports idempotent. + +Element tags and exact-length rules (mirrors the applet's PIVKeyECC/PIVKeyRSA/ +PIVKeySYM): CLEAR ``9F`` (empty); ECC public point ``86`` (X9.62 uncompressed, +65/97 bytes) and private scalar ``87`` (big-endian right-aligned, 32/48 bytes); +RSA modulus ``81`` (k bytes), public exponent ``82`` (exactly 3 bytes), private +exponent ``83`` (k bytes, plain form only), CRT components +``90``/``91``/``92``/``93``/``94`` (P/Q/dP/dQ/qInv, each k/2 bytes); symmetric +(AES) key value ``80`` (exactly the key length: 16/24/32 bytes) — the admin key +(9B) is the only symmetric object this applet exposes. """ from __future__ import annotations @@ -43,6 +45,7 @@ ELEMENT_RSA_DP = 0x92 ELEMENT_RSA_DQ = 0x93 ELEMENT_RSA_PQ = 0x94 +ELEMENT_SYM_KEY = 0x80 # PIVKeySYM's sole element: the raw secret key value # Largest TLV body sent as a single (SCP03-wrapped) short APDU; larger bodies go # through ISO command chaining. Matches PivAdmin.send_chained's plaintext budget. @@ -53,6 +56,9 @@ _EC_SCALAR_LEN = {0x11: 32, 0x14: 48} _RSA_MODULUS_LEN = {0x07: 256, 0x05: 384, 0x16: 512} +# AES mechanism -> raw key length in bytes (this applet's admin key, 9B, is AES-only). +_AES_KEY_LEN = {0x08: 16, 0x0A: 24, 0x0C: 32} + # Host-side slot policy (SP 800-78 per-key-reference algorithm table): PKI slots # and the retired key history accept the four asymmetric mechanisms; 9B is # AES-only and 04 is secure-messaging-only. The deployed pristine v2.0.0-fips @@ -156,6 +162,32 @@ def validate_slot_mechanism(ref: int, mechanism: int) -> None: raise KeyImportError(f"mechanism {mechanism:#04x} is not an asymmetric mechanism.") +def aes_key_len(mechanism: int) -> int | None: + """Raw key length in bytes for an AES mechanism id, or ``None`` if not AES.""" + return _AES_KEY_LEN.get(mechanism) + + +def sym_key_plan(mechanism: int, key_value: bytes) -> list[KeyElement]: + """CLEAR, then the raw AES key value — the two commands PIVKeySYM accepts. + + PIVKeySYM has exactly one element (``80``, the raw secret) and refuses it + outright (SW 6985) once the key is initialised, so — like the asymmetric + plans — this always starts with CLEAR to make re-running idempotent. + """ + expected = aes_key_len(mechanism) + if expected is None: + raise KeyImportError(f"mechanism {mechanism:#04x} is not a supported AES mechanism.") + if len(key_value) != expected: + raise KeyImportError( + f"AES key value must be exactly {expected} bytes for this mechanism, " + f"got {len(key_value)}." + ) + return [ + KeyElement(ELEMENT_CLEAR, b"", "CLEAR (9F)", False), + KeyElement(ELEMENT_SYM_KEY, key_value, "key value (80)", True), + ] + + def encode_exponent(e: int) -> bytes: """The applet takes the RSA public exponent as exactly 3 bytes.""" if e <= 0 or e >= 1 << 24: diff --git a/src/cryptnox_id_cli/applets/piv/piv.py b/src/cryptnox_id_cli/applets/piv/piv.py index 5012a1f..c4d0150 100644 --- a/src/cryptnox_id_cli/applets/piv/piv.py +++ b/src/cryptnox_id_cli/applets/piv/piv.py @@ -5,6 +5,7 @@ from dataclasses import dataclass from cryptnox_id_cli.applets.piv import constants as c +from cryptnox_id_cli.applets.piv import keyimport from cryptnox_id_cli.applets.piv import objects as obj from cryptnox_id_cli.applets.piv.apt import APTInfo, parse_apt from cryptnox_id_cli.transport.apdu import APDU, Response @@ -45,6 +46,50 @@ def _pin_status_from_sw(ref: int, resp: Response) -> PinStatus: return PinStatus(ref, configured=False, blocked=False, verified=False, retries=None) +@dataclass(frozen=True) +class MgmtKeyStatus: + ref: int + mechanism: int | None # AES mechanism id the key object was found at, or None + configured: bool | None # None: exists, but access rules blocked telling (rare) + + def to_dict(self) -> dict[str, object]: + return { + "ref": f"{self.ref:02X}", + "mechanism": c.ALGORITHMS.get(self.mechanism) if self.mechanism is not None else None, + "configured": self.configured, + } + + +# AES-128/192/256 - the only mechanisms the admin key (9B) can hold on this applet. +_ADMIN_KEY_MECHANISMS = (0x08, 0x0A, 0x0C) + + +def _mgmt_key_status_from_sw(ref: int, mechanism: int, resp: Response) -> MgmtKeyStatus | None: + """One mechanism's GENERAL AUTHENTICATE probe result, or None ("no key object + at this mechanism - try the next one"). + + The probe body (``keyimport.probe_apdu``) shapes a CHALLENGE-with-data plus an + empty RESPONSE, i.e. GENERAL AUTHENTICATE's "Internal Authenticate" case. For a + ROLE_AUTHENTICATE symmetric key - what this applet always makes the admin key - + that shape is invalid once the existence/access/initialised preconditions are + behind it, so it lands deterministically on SW 6985. That gives a clean four-way + read: 6A86/6A88 no object, 6983 not initialised, 6982 access rules blocked the + check itself, 6985 initialised. Anything else is left unclassified rather than + assumed initialised. + """ + if resp.sw in (0x6A86, 0x6A88): # no key object at (ref, mechanism) + return None + if resp.sw == 0x6983: # exists, value not initialised + return MgmtKeyStatus(ref, mechanism, configured=False) + if resp.sw == 0x6982: # exists, but access rules blocked the check itself + return MgmtKeyStatus(ref, mechanism, configured=None) + if resp.sw == 0x6985: # existence + access + initialised all passed + return MgmtKeyStatus(ref, mechanism, configured=True) + # Key object exists (we're past the 6A86/6A88 branch) but this SW isn't one of + # the classified outcomes - report the mechanism without guessing further. + return MgmtKeyStatus(ref, mechanism, configured=None) + + def _pad_pin(pin: bytes) -> bytes: """PIV PINs are 8 bytes, padded with 0xFF.""" if len(pin) > 8: @@ -169,3 +214,19 @@ def unblock_pin(self, puk: bytes, new_pin: bytes, ref: int = c.REF_PIV_PIN) -> R APDU(0x00, c.INS_RESET_RETRY, 0x00, ref, data=body), context="RESET RETRY COUNTER", ) + + # -- management key (9B) ------------------------------------------------- # + def mgmt_key_status(self, ref: int = c.KEYREF_ADMIN) -> MgmtKeyStatus: + """Non-destructive status of the admin key (GENERAL AUTHENTICATE probe, + empty challenge - same technique ``keyimport.probe_apdu`` uses to check + an asymmetric slot before import; never touches a retry counter, since 9B + has none). Tries each AES mechanism this applet supports until one + resolves to an existing key object at this reference.""" + for mechanism in _ADMIN_KEY_MECHANISMS: + resp = self.session.transmit( + keyimport.probe_apdu(ref, mechanism), context="GENERAL AUTHENTICATE (status)" + ) + status = _mgmt_key_status_from_sw(ref, mechanism, resp) + if status is not None: + return status + return MgmtKeyStatus(ref, mechanism=None, configured=None) diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index 6d6d073..f89faca 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -40,7 +40,7 @@ from cryptnox_id_cli.state import StateDetector from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError, describe_sw from cryptnox_id_cli.util import tlv -from cryptnox_id_cli.util.hexutil import to_hex +from cryptnox_id_cli.util.hexutil import from_hex, to_hex # Slots that carry a certificate container, and the object that holds it. SLOT_CERT_OBJECT = { @@ -98,15 +98,20 @@ def human(c: Console) -> None: @command.command("status") @click.pass_obj def status(app: AppContext) -> None: - """Show PIV lifecycle state, PIN/PUK status and which objects are present.""" + """Show PIV lifecycle state, PIN/PUK/management-key status and objects present.""" with app.open_session() as session: st = StateDetector(session, probe_fido=False, probe_desfire=False).detect() + mgmt_key = None + if st.piv_apt is not None: + with contextlib.suppress(CryptnoxError): + mgmt_key = _select(session).mgmt_key_status() payload: dict[str, object] = { "state": st.piv.label, "reader": app.resolved_reader, "apt": st.piv_apt.to_dict() if st.piv_apt else None, "pin": st.piv_pin.to_dict() if st.piv_pin else None, "puk": st.piv_puk.to_dict() if st.piv_puk else None, + "mgmt_key": mgmt_key.to_dict() if mgmt_key else None, "objects_present": st.piv_objects, "notes": st.notes, } @@ -121,6 +126,16 @@ def human(c: Console) -> None: extra = f", {st.piv_puk.retries} tries left" if st.piv_puk.retries is not None else "" blocked = " [red](blocked)[/red]" if st.piv_puk.blocked else "" c.print(f" PUK (81): {'set' if st.piv_puk.configured else 'not set'}{extra}{blocked}") + if mgmt_key: + mech_name = pivc.ALGORITHMS.get(mgmt_key.mechanism, "unknown mechanism") + if mgmt_key.configured is True: + c.print(f" Management key (9B): [green]set[/green] ({mech_name})") + elif mgmt_key.configured is False: + c.print(f" Management key (9B): [yellow]not set[/yellow] ({mech_name})") + else: + c.print(" Management key (9B): [dim]unknown[/dim] (could not tell from this probe)") + elif st.piv_apt is not None: + c.print(" Management key (9B): [dim]no admin key object found on this card[/dim]") if st.piv_objects: table = app.out.table("Object", "Present") for name, present in st.piv_objects.items(): @@ -1626,6 +1641,98 @@ def perso_set_puk(app: AppContext, pin_value: str | None, default_keys: bool) -> ) +# Friendly diagnoses for CHANGE REFERENCE DATA ADMIN rejections while setting 9B. +_MGMT_KEY_SW_HINTS = { + 0x6A88: ( + "no 9B key object at this mechanism - pass --algorithm matching whatever " + "pre-perso gave the admin key object (cryptnox-default: AES256)" + ), + 0x6982: "the key object is not IMPORTABLE (or the admin channel is not accepted)", +} + + +def _execute_sym_key_set( + adm: PivAdmin, keys, ref: int, mech: int, plan: list[keyimport.KeyElement], label: str +) -> list[dict[str, object]]: + """Send a symmetric-key element plan, one fresh SCP03 session per element + (JCOP 4.5 allows one application APDU per applet-directed secure-channel + session) — same shape as ``_execute_key_import``, minus the probe/create + step: unlike an asymmetric slot, 9B's key object is expected to already + exist from pre-perso, exactly like the PIN/PUK verifiers set-pin/set-puk + assume.""" + results: list[dict[str, object]] = [] + for index, el in enumerate(plan, 1): + adm.select() + adm.open(keys) + label_ctx = f"SET {label} [{el.label}] [{index}/{len(plan)}]" + resp = adm.send(keyimport.import_apdu(ref, mech, el), context=label_ctx) + results.append({"element": el.label, "sw": resp.sw_hex(), "ok": resp.ok}) + if not resp.ok: + hint = _MGMT_KEY_SW_HINTS.get(resp.sw) + ctx = f"SET {label} [{el.label}]" + (f" - {hint}" if hint else "") + raise StatusWordError(resp.sw1, resp.sw2, context=ctx) + return results + + +@perso.command("set-mgmt-key") +@click.option( + "--algorithm", + default="AES256", + show_default=True, + type=click.Choice(["AES128", "AES192", "AES256"], case_sensitive=False), + help="Management key (9B) mechanism; must match what pre-perso gave the admin key object.", +) +@click.option( + "--key", + "key_value", + help="(discouraged) management key as hex on the CLI; " + "prefer env CRYPTNOX_PIV_MGMT_KEY or the prompt.", +) +@click.option( + "--default-keys", + is_flag=True, + help="Use the default GlobalPlatform TEST keys " + "(publicly known - fine for dev/eval, never for deployment).", +) +@click.pass_obj +def perso_set_mgmt_key( + app: AppContext, algorithm: str, key_value: str | None, default_keys: bool +) -> None: + """Set the PIV management key (9B). Set it for standards-compliance/ + interoperability with tools that authenticate using 9B management key. + """ + app.out.warn("replaces the current management key without needing its value (admin channel write).") + mech = prof_mod.MECHANISMS[algorithm.upper()] + expected_len = keyimport.aes_key_len(mech) + secret = resolve_secret( + redactor=app.redactor, + env_var="CRYPTNOX_PIV_MGMT_KEY", + prompt_label=f"New management key ({algorithm.upper()}, hex, {expected_len} bytes)", + provided=key_value, + ) + try: + key_bytes = from_hex(secret.decode("ascii")) + except (ValueError, UnicodeDecodeError) as exc: + raise click.BadParameter(f"management key must be hex: {exc}") from exc + app.redactor.register(key_bytes) + plan = keyimport.sym_key_plan(mech, key_bytes) + keys = resolve_scp03_keys(app.redactor, default_keys=default_keys) + with app.open_session() as session: + adm = PivAdmin(session) + results = _execute_sym_key_set(adm, keys, pivc.KEYREF_ADMIN, mech, plan, "management key") + payload = { + "ref": f"{pivc.KEYREF_ADMIN:02X}", + "algorithm": algorithm.upper(), + "set": True, + "elements": results, + } + + def human(c: Console) -> None: + c.print(f"[green]Management key set.[/green] ({algorithm.upper()}, ref 9B)") + + app.out.result(payload, human) + + def _resolve_asym_mech(algorithm: str) -> int: mech = perso_mod.ALGORITHMS.get(algorithm.upper()) if mech is None or mech not in pivc.SUPPORTED_ALGORITHMS: diff --git a/src/cryptnox_id_cli/cli/dryrun.py b/src/cryptnox_id_cli/cli/dryrun.py index 9e6628d..b90ad64 100644 --- a/src/cryptnox_id_cli/cli/dryrun.py +++ b/src/cryptnox_id_cli/cli/dryrun.py @@ -135,6 +135,7 @@ "piv perso generate-key", "piv perso import-cert", "piv perso self-sign-cert", + "piv perso set-mgmt-key", "piv perso set-pin", "piv perso set-puk", "piv perso smoke-test", diff --git a/tests/unit/test_keyimport.py b/tests/unit/test_keyimport.py index 31b8f25..2b66a0d 100644 --- a/tests/unit/test_keyimport.py +++ b/tests/unit/test_keyimport.py @@ -105,6 +105,24 @@ def test_chaining_threshold(rsa2048_key, p256_key): assert all(len(el.body()) <= ki.SINGLE_APDU_MAX for el in ecc_plan) +def test_sym_key_plan_lengths(): + assert ki.aes_key_len(0x08) == 16 + assert ki.aes_key_len(0x0A) == 24 + assert ki.aes_key_len(0x0C) == 32 + assert ki.aes_key_len(0x11) is None # not AES + + plan = ki.sym_key_plan(0x0C, b"\x11" * 32) + assert [(el.tag, len(el.value)) for el in plan] == [(0x9F, 0), (0x80, 32)] + assert [el.secret for el in plan] == [False, True] + + +def test_sym_key_plan_rejects_wrong_length_or_mechanism(): + with pytest.raises(ki.KeyImportError): + ki.sym_key_plan(0x0C, b"\x11" * 16) # AES-256 wants 32 bytes + with pytest.raises(ki.KeyImportError): + ki.sym_key_plan(0x11, b"\x11" * 32) # ECCP256 is not AES + + # ------------------------------------------------------------------ APDUs --- # def test_clear_apdu_golden(p256_key): clear = ki.element_plan(p256_key)[0] @@ -112,6 +130,12 @@ def test_clear_apdu_golden(p256_key): assert apdu.to_bytes().hex().upper() == "0024119C029F00" +def test_sym_key_apdu_golden(): + key_element = ki.sym_key_plan(0x0C, b"\x11" * 32)[1] + apdu = ki.import_apdu(0x9B, 0x0C, key_element) + assert apdu.to_bytes().hex().upper() == "00240C9B228020" + "11" * 32 + + def test_probe_apdu_golden(): # 00 87 11 9C Lc=07 7C{82(empty) 81{00}} Le=00 assert ki.probe_apdu(0x9C, 0x11).to_bytes().hex().upper() == "0087119C077C0582008101" + "0000" diff --git a/tests/unit/test_piv_mgmt_key_status.py b/tests/unit/test_piv_mgmt_key_status.py new file mode 100644 index 0000000..89811c2 --- /dev/null +++ b/tests/unit/test_piv_mgmt_key_status.py @@ -0,0 +1,125 @@ +"""Admin key (9B) status: a non-destructive GENERAL AUTHENTICATE probe per AES +mechanism, same technique as ``keyimport.probe_apdu``'s pre-import existence +check. No retry counter is ever touched (9B has none). + +The probe body shapes GENERAL AUTHENTICATE's "Internal Authenticate" case +(CHALLENGE with data, empty RESPONSE). For this applet's admin key - always a +ROLE_AUTHENTICATE symmetric key - that shape is invalid once existence/access/ +initialised are behind it, so an initialised key answers 6985 deterministically +(verified against the applet source, PIV.java's generalAuthenticate/CASE 1). +""" + +from __future__ import annotations + +from cryptnox_id_cli.applets.piv import constants as c +from cryptnox_id_cli.applets.piv import keyimport as ki +from cryptnox_id_cli.applets.piv.piv import PivApplet +from cryptnox_id_cli.transport.pcsc import CardSession + +AES128, AES192, AES256 = 0x08, 0x0A, 0x0C + + +class _DictConn: + """Minimal RawConnection: maps command-APDU hex -> '|'.""" + + def __init__(self, exchanges: dict[str, str]) -> None: + self._exchanges = {k.upper(): v for k, v in exchanges.items()} + self.sent: list[str] = [] + + def transmit(self, apdu: list[int]) -> tuple[list[int], int, int]: + key = bytes(apdu).hex().upper() + self.sent.append(key) + spec = self._exchanges.get(key) + if spec is None: + return [], 0x6A, 0x82 + data_hex, sw_hex = spec.split("|") + data = list(bytes.fromhex(data_hex)) if data_hex else [] + sw = bytes.fromhex(sw_hex) + return data, sw[0], sw[1] + + def get_atr(self) -> bytes: + return bytes.fromhex("3B8580018073C821100E") + + def disconnect(self) -> None: # pragma: no cover - nothing to release + pass + + +def _probe_hex(mechanism: int) -> str: + return ki.probe_apdu(c.KEYREF_ADMIN, mechanism).to_bytes().hex().upper() + + +def test_configured_on_first_mechanism_tried(): + conn = _DictConn({_probe_hex(AES128): "|6985"}) # existence+access+initialised all pass + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.ref == c.KEYREF_ADMIN + assert status.mechanism == AES128 + assert status.configured is True + assert conn.sent == [_probe_hex(AES128)] # stops at the first match, no fallthrough + + +def test_not_configured(): + conn = _DictConn({_probe_hex(AES128): "|6983"}) # exists, value not initialised + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.mechanism == AES128 + assert status.configured is False + + +def test_falls_through_mechanisms_until_the_key_object_is_found(): + conn = _DictConn( + { + _probe_hex(AES128): "|6A86", # no such key object at AES-128 + _probe_hex(AES192): "|6A86", # nor AES-192 + _probe_hex(AES256): "|6985", # this applet's admin key is AES-256, initialised + } + ) + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.mechanism == AES256 + assert status.configured is True + assert conn.sent == [_probe_hex(AES128), _probe_hex(AES192), _probe_hex(AES256)] + + +def test_no_admin_key_object_at_any_mechanism(): + conn = _DictConn({_probe_hex(m): "|6A86" for m in (AES128, AES192, AES256)}) + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.mechanism is None + assert status.configured is None + + +def test_access_rules_blocked_reports_unknown_not_a_guess(): + conn = _DictConn( + { + _probe_hex(AES128): "|6A86", + _probe_hex(AES192): "|6A86", + _probe_hex(AES256): "|6982", # exists, access rules not satisfied + } + ) + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.mechanism == AES256 + assert status.configured is None + + +def test_unclassified_sw_is_left_unknown_rather_than_assumed_configured(): + # A generic/unexpected SW (e.g. an unmatched command in a mock, or a status this + # probe hasn't been mapped for) must not be silently read as "configured". + conn = _DictConn( + { + _probe_hex(AES128): "|6A86", + _probe_hex(AES192): "|6A86", + _probe_hex(AES256): "|6A80", + } + ) + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.mechanism == AES256 + assert status.configured is None + + +def test_to_dict_names_the_mechanism(): + conn = _DictConn( + { + _probe_hex(AES128): "|6A86", + _probe_hex(AES192): "|6A86", + _probe_hex(AES256): "|6985", + } + ) + status = PivApplet(CardSession(conn)).mgmt_key_status() + assert status.to_dict() == {"ref": "9B", "mechanism": "AES-256", "configured": True} From cdd9bfac289da873d31dfc1d29afc71cefc1d1b6 Mon Sep 17 00:00:00 2001 From: Philipp Serr Date: Wed, 16 Sep 2026 18:00:19 +0000 Subject: [PATCH 2/2] Fix lint and type errors in piv status/set-mgmt-key Wrap two lines exceeding ruff's 100-char limit, and guard against mgmt_key.mechanism being None before the ALGORITHMS dict lookup to satisfy mypy. Co-Authored-By: Claude --- src/cryptnox_id_cli/cli/commands/piv.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index f89faca..0c0962d 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -127,13 +127,19 @@ def human(c: Console) -> None: blocked = " [red](blocked)[/red]" if st.piv_puk.blocked else "" c.print(f" PUK (81): {'set' if st.piv_puk.configured else 'not set'}{extra}{blocked}") if mgmt_key: - mech_name = pivc.ALGORITHMS.get(mgmt_key.mechanism, "unknown mechanism") + mech_name = ( + pivc.ALGORITHMS.get(mgmt_key.mechanism, "unknown mechanism") + if mgmt_key.mechanism is not None + else "unknown mechanism" + ) if mgmt_key.configured is True: c.print(f" Management key (9B): [green]set[/green] ({mech_name})") elif mgmt_key.configured is False: c.print(f" Management key (9B): [yellow]not set[/yellow] ({mech_name})") else: - c.print(" Management key (9B): [dim]unknown[/dim] (could not tell from this probe)") + c.print( + " Management key (9B): [dim]unknown[/dim] (could not tell from this probe)" + ) elif st.piv_apt is not None: c.print(" Management key (9B): [dim]no admin key object found on this card[/dim]") if st.piv_objects: @@ -1701,7 +1707,9 @@ def perso_set_mgmt_key( """Set the PIV management key (9B). Set it for standards-compliance/ interoperability with tools that authenticate using 9B management key. """ - app.out.warn("replaces the current management key without needing its value (admin channel write).") + app.out.warn( + "replaces the current management key without needing its value (admin channel write)." + ) mech = prof_mod.MECHANISMS[algorithm.upper()] expected_len = keyimport.aes_key_len(mech) secret = resolve_secret(