From fb317a79b544c21f0378e64e9735a236d50b1c97 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 9 Oct 2026 12:41:19 +0200 Subject: [PATCH] Add piv admin keys with a one-shot default-key check List the admin security domain's key table without authenticating. With --check-default, authenticate once to key version 1 with the GlobalPlatform default key: success is a completed authentication and leaves the card's failed-authentication count clear; a wrong key is reported as one counted failure that the next successful `piv admin authenticate` clears. A refused INITIALIZE UPDATE is a new InitializeUpdateRejected error and reported as undetermined. The check is skipped under --dry-run or when the table has no key version 1, and never tries a second key. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 2 + docs/piv/piv-commands.rst | 8 ++ src/cryptnox_id_cli/applets/piv/admin.py | 7 +- src/cryptnox_id_cli/applets/piv/ssd.py | 5 + src/cryptnox_id_cli/cli/commands/piv.py | 143 ++++++++++++++++++++--- src/cryptnox_id_cli/cli/dryrun.py | 1 + tests/unit/test_admin_keys.py | 136 +++++++++++++++++++++ 7 files changed, 284 insertions(+), 18 deletions(-) create mode 100644 tests/unit/test_admin_keys.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b630a3..5130b07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 flags with their access rules, read without authentication. - The finalized (SECURED) state, read from the applet's status object, in `piv status`, `info`, `report` and `factory piv preperso status`. +- `piv admin keys`: the admin security domain's key table; `--check-default` + authenticates once to key version 1 with the GlobalPlatform default key. - `RSA4096` in `perso generate-key`, `perso import-key`, CSR and self-signed-cert signing, and profile key mechanisms. - `perso generate-key --create-key-object`, the dev/eval fallback diff --git a/docs/piv/piv-commands.rst b/docs/piv/piv-commands.rst index 308f227..8677aec 100644 --- a/docs/piv/piv-commands.rst +++ b/docs/piv/piv-commands.rst @@ -74,6 +74,7 @@ Admin channel .. code-block:: text piv admin status the admin security domain: SCP version, key versions (no auth) + piv admin keys its key table; --check-default tests key version 1 (one attempt) piv admin authenticate open the channel (mutual auth) + harmless self-test ``piv admin status`` selects the PIV security domain and reads its key information @@ -81,6 +82,13 @@ template. It never sends INITIALIZE UPDATE: on this chip an INITIALIZE UPDATE th not followed by a successful EXTERNAL AUTHENTICATE counts as a failed authentication, and a status read must not spend one. +``piv admin keys`` lists the same table. With ``--check-default`` it authenticates +once to key version 1 with the publicly known GlobalPlatform test key: a success +means anyone can administer the PIV applet, and leaves the card's +failed-authentication count clear; a wrong key costs one failed authentication, +which the next successful ``piv admin authenticate`` clears. It never tries more +than that one key, and skips the check when the table has no key version 1. + Certificates and objects ------------------------ diff --git a/src/cryptnox_id_cli/applets/piv/admin.py b/src/cryptnox_id_cli/applets/piv/admin.py index 9af267d..558c3fe 100644 --- a/src/cryptnox_id_cli/applets/piv/admin.py +++ b/src/cryptnox_id_cli/applets/piv/admin.py @@ -41,6 +41,11 @@ def scp_label(value: object) -> str: return _SCP_LABELS.get(value, "unknown") if isinstance(value, int) else "unknown" +class InitializeUpdateRejected(Scp03Error): + """The security domain refused INITIALIZE UPDATE itself, before any cryptogram + was exchanged (for example an unknown key version).""" + + def _as_scp02_keys(keys: Scp03Keys | Scp02Keys) -> Scp02Keys: return keys if isinstance(keys, Scp02Keys) else Scp02Keys(keys.enc, keys.mac, keys.dek) @@ -83,7 +88,7 @@ def open( context="INITIALIZE UPDATE", ) if not resp.ok: - raise Scp03Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).") + raise InitializeUpdateRejected(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).") body = resp.data if len(body) < 12: raise Scp03Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes).") diff --git a/src/cryptnox_id_cli/applets/piv/ssd.py b/src/cryptnox_id_cli/applets/piv/ssd.py index 2a1959e..ac26018 100644 --- a/src/cryptnox_id_cli/applets/piv/ssd.py +++ b/src/cryptnox_id_cli/applets/piv/ssd.py @@ -17,6 +17,11 @@ PIV_SSD_AID = bytes.fromhex("A00000015153504101") ISD_AID = bytes.fromhex("A000000151000000") +#: Key version the card ships with on the public GlobalPlatform test key; the one a +#: customer replaces. Version 2 is Cryptnox's own keyset and is left alone. +CUSTOMER_KEY_VERSION = 1 +CRYPTNOX_KEY_VERSION = 2 + TAG_KEY_INFO_TEMPLATE = 0xE0 TAG_KEY_INFO = 0xC0 diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index 6b2e531..4f28685 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -24,7 +24,7 @@ from cryptnox_id_cli.applets.piv import perso as perso_mod from cryptnox_id_cli.applets.piv import preperso as preperso_mod from cryptnox_id_cli.applets.piv import profiles as prof_mod -from cryptnox_id_cli.applets.piv.admin import PivAdmin, scp_label +from cryptnox_id_cli.applets.piv.admin import InitializeUpdateRejected, PivAdmin, scp_label from cryptnox_id_cli.applets.piv.objects import ( PIV_OBJECTS, extract_certificate, @@ -33,7 +33,12 @@ ) from cryptnox_id_cli.applets.piv.piv import PivApplet from cryptnox_id_cli.applets.piv.slots import PIV_SLOTS -from cryptnox_id_cli.applets.piv.ssd import PIV_SSD_AID, describe_security_domain +from cryptnox_id_cli.applets.piv.ssd import ( + CUSTOMER_KEY_VERSION, + PIV_SSD_AID, + SecurityDomainInfo, + describe_security_domain, +) from cryptnox_id_cli.cli.context import AppContext from cryptnox_id_cli.crypto import csr as csr_mod from cryptnox_id_cli.crypto import piv_objects, x509util @@ -46,7 +51,13 @@ resolve_secret, ) from cryptnox_id_cli.state import StateDetector -from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError, describe_sw +from cryptnox_id_cli.transport.errors import ( + CryptnoxError, + Scp02Error, + Scp03Error, + StatusWordError, + describe_sw, +) from cryptnox_id_cli.transport.pcsc import is_contactless_interface from cryptnox_id_cli.util import tlv from cryptnox_id_cli.util.hexutil import to_hex @@ -840,27 +851,36 @@ def admin() -> None: """PIV administrative operations over SCP03 (vendor: OpenFIPS201).""" -@admin.command("status") -@click.pass_obj -def admin_status(app: AppContext) -> None: - """Show the admin security domain's SCP version and key versions (no authentication).""" - with app.open_session() as session: - domain = describe_security_domain(session) +def _require_security_domain(session) -> SecurityDomainInfo: # noqa: ANN001 + domain = describe_security_domain(session) if domain is None: raise CryptnoxError( "No security domain answered: neither the PIV security domain " f"({PIV_SSD_AID.hex().upper()}) nor the ISD is selectable on this interface." ) + return domain + + +def _print_key_table(app: AppContext, c: Console, domain: SecurityDomainInfo) -> None: + c.print(f"PIV admin security domain {domain.aid_hex} ({scp_label(domain.scp_version)}):") + if not domain.keys: + c.print(f" key table withheld (SW={domain.key_table_sw:04X})") + return + table = app.out.table("Key version", "Key ID", "Type", "Length") + for k in domain.keys: + table.add_row(str(k.key_version), str(k.key_id), k.type_name, f"{k.length} B") + c.print(table) + + +@admin.command("status") +@click.pass_obj +def admin_status(app: AppContext) -> None: + """Show the admin security domain's SCP version and key versions (no authentication).""" + with app.open_session() as session: + domain = _require_security_domain(session) def human(c: Console) -> None: - c.print(f"PIV admin security domain {domain.aid_hex} ({scp_label(domain.scp_version)}):") - if not domain.keys: - c.print(f" key table withheld (SW={domain.key_table_sw:04X})") - else: - table = app.out.table("Key version", "Key ID", "Type", "Length") - for k in domain.keys: - table.add_row(str(k.key_version), str(k.key_id), k.type_name, f"{k.length} B") - c.print(table) + _print_key_table(app, c, domain) c.print( " Read without authenticating; nothing counted against the card's " "failed-authentication limit. Run `piv admin authenticate` to open the channel." @@ -869,6 +889,95 @@ def human(c: Console) -> None: app.out.result(domain.to_dict(), human) +def _check_default_key(app: AppContext, session) -> dict[str, object]: # noqa: ANN001 + """One authentication to the customer key version with the GlobalPlatform default + key. Success is a completed authentication, so it leaves the card's + failed-authentication count clear; a wrong key costs one.""" + keys = resolve_scp03_keys(app.redactor, default_keys=True) + adm = PivAdmin(session) + adm.select() + result: dict[str, object] = {"performed": True, "key_version": CUSTOMER_KEY_VERSION} + try: + adm.open(keys, key_version=CUSTOMER_KEY_VERSION) + except InitializeUpdateRejected as exc: + return {**result, "answers_to_default": None, "detail": str(exc)} + except (Scp02Error, Scp03Error) as exc: + return { + **result, + "answers_to_default": False, + "detail": str(exc), + "failed_authentication_counted": True, + } + return { + **result, + "answers_to_default": True, + "scp_version": scp_label(adm.scp_version), + "failed_authentication_counted": False, + } + + +def _default_key_verdict(check: dict[str, object] | None) -> str: + if check is None: + return "not checked (pass --check-default)" + if not check["performed"]: + return f"not checked ({check['reason']})" + answers = check["answers_to_default"] + if answers is True: + return ( + "[red]YES[/red]. Anyone can administer this card's PIV applet; replace the " + "key before deployment." + ) + if answers is False: + return ( + f"no ({check['detail']}). One failed authentication is now counted on the " + "card; a successful `piv admin authenticate` clears it." + ) + return f"undetermined ({check['detail']})" + + +@admin.command("keys") +@click.option( + "--check-default", + is_flag=True, + help=f"Authenticate once to key version {CUSTOMER_KEY_VERSION} with the publicly known " + "GlobalPlatform test key. A wrong key counts as one failed authentication on the card.", +) +@click.pass_obj +def admin_keys(app: AppContext, check_default: bool) -> None: + """List the admin security domain's key table (no authentication). + + With --check-default, also test whether the customer key version still answers + to the GlobalPlatform default key. That is one authentication attempt, never a + search over candidate keys. + """ + with app.open_session() as session: + domain = _require_security_domain(session) + check: dict[str, object] | None = None + if check_default: + if app.dry_run: + check = {"performed": False, "reason": "dry-run"} + elif CUSTOMER_KEY_VERSION not in domain.key_versions: + check = { + "performed": False, + "reason": f"key version {CUSTOMER_KEY_VERSION} is not in the key table", + } + else: + app.out.warn( + "a wrong key counts as one failed authentication on the card; five in a " + "row slow every later authentication down until one succeeds." + ) + check = _check_default_key(app, session) + + def human(c: Console) -> None: + _print_key_table(app, c, domain) + c.print( + f" Key version {CUSTOMER_KEY_VERSION} answers to the GlobalPlatform default key: " + f"{_default_key_verdict(check)}" + ) + + app.out.result({**domain.to_dict(), "default_key_check": check}, human) + + @admin.command("authenticate") @click.option( "--default-keys", diff --git a/src/cryptnox_id_cli/cli/dryrun.py b/src/cryptnox_id_cli/cli/dryrun.py index 0fdf520..9bddaf6 100644 --- a/src/cryptnox_id_cli/cli/dryrun.py +++ b/src/cryptnox_id_cli/cli/dryrun.py @@ -36,6 +36,7 @@ "apdu transcript", "factory piv preperso finalize", "factory piv preperso load-config", + "piv admin keys", "piv perso import-key", "piv perso import-p12", "piv quickstart", diff --git a/tests/unit/test_admin_keys.py b/tests/unit/test_admin_keys.py new file mode 100644 index 0000000..1e3325a --- /dev/null +++ b/tests/unit/test_admin_keys.py @@ -0,0 +1,136 @@ +"""`piv admin keys`: the key table is read without authenticating; `--check-default` +spends exactly one authentication attempt on key version 1 and never searches.""" + +from __future__ import annotations + +import json + +import pytest +from _cardfakes import ResponseAdapter, run, wire_session + +from cryptnox_id_cli.applets.piv.admin import SCP02, InitializeUpdateRejected +from cryptnox_id_cli.cli.commands import piv as piv_cmd +from cryptnox_id_cli.transport.apdu import Response +from cryptnox_id_cli.transport.errors import Scp02Error + +SELECT_SSD = "00A4040009A0000001515350410100" +GET_E0 = "80CA00E000" +FCI_SSD = bytes.fromhex("6F118409A00000015153504101A5049F6501FF") +E0_KVN1_DES = bytes.fromhex("E012C00401018010C00402018010C00403018010") +E0_KVN2_ONLY = bytes.fromhex("E012C00401028810C00402028810C00403028810") +OK = (0x90, 0x00) + + +class _Dead: + def transmit(self, raw: bytes) -> Response: + return Response(b"", 0x6A, 0x82) + + +def _conn(e0: bytes = E0_KVN1_DES) -> ResponseAdapter: + return ResponseAdapter(_Dead(), answers={SELECT_SSD: (FCI_SSD, *OK), GET_E0: (e0, *OK)}) + + +class _FakeAdmin: + """Stands in for the channel: records the key version asked for, then succeeds, + fails the cryptogram check, or has INITIALIZE UPDATE refused.""" + + outcome = "ok" + opened: list[int] = [] + + def __init__(self, session) -> None: + self.card = session + self.scp_version = None + + def select(self) -> None: + pass + + def open(self, keys, *, key_version: int = 0, security_level: int = 0x03) -> None: + _FakeAdmin.opened.append(key_version) + if _FakeAdmin.outcome == "wrong": + raise Scp02Error("Card cryptogram mismatch - the SCP02 keys are wrong for this card.") + if _FakeAdmin.outcome == "rejected": + raise InitializeUpdateRejected("INITIALIZE UPDATE rejected (SW=6A88).") + self.scp_version = SCP02 + + +@pytest.fixture +def fake_admin(monkeypatch): + _FakeAdmin.outcome = "ok" + _FakeAdmin.opened = [] + monkeypatch.setattr(piv_cmd, "PivAdmin", _FakeAdmin) + return _FakeAdmin + + +def _keys(monkeypatch, conn, *args: str) -> dict: + wire_session(monkeypatch, conn) + result = run(["--json", "piv", "admin", "keys", *args]) + assert result.exit_code == 0, result.output + return json.loads(result.stdout) + + +def test_table_alone_never_authenticates(monkeypatch, fake_admin): + conn = _conn() + payload = _keys(monkeypatch, conn) + assert payload["key_versions"][0]["version"] == 1 + assert payload["default_key_check"] is None + assert fake_admin.opened == [] + assert not any(frame.startswith("8050") for frame in conn.log) + + +def test_check_default_yes_is_one_completed_authentication(monkeypatch, fake_admin): + payload = _keys(monkeypatch, _conn(), "--check-default") + check = payload["default_key_check"] + assert check["answers_to_default"] is True + assert check["failed_authentication_counted"] is False + assert check["scp_version"] == "SCP02" + assert fake_admin.opened == [1] + + +def test_check_default_no_reports_the_counted_failure(monkeypatch, fake_admin): + fake_admin.outcome = "wrong" + payload = _keys(monkeypatch, _conn(), "--check-default") + check = payload["default_key_check"] + assert check["answers_to_default"] is False + assert check["failed_authentication_counted"] is True + assert fake_admin.opened == [1] # one attempt, no search + + +def test_check_default_undetermined_when_initialize_update_is_refused(monkeypatch, fake_admin): + fake_admin.outcome = "rejected" + payload = _keys(monkeypatch, _conn(), "--check-default") + check = payload["default_key_check"] + assert check["answers_to_default"] is None + assert "6A88" in check["detail"] + + +def test_check_skipped_when_key_version_1_is_absent(monkeypatch, fake_admin): + payload = _keys(monkeypatch, _conn(E0_KVN2_ONLY), "--check-default") + check = payload["default_key_check"] + assert check["performed"] is False + assert "key version 1" in check["reason"] + assert fake_admin.opened == [] + + +def test_dry_run_lists_the_table_and_skips_the_check(monkeypatch, fake_admin): + wire_session(monkeypatch, _conn()) + result = run(["--dry-run", "--json", "piv", "admin", "keys", "--check-default"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["key_versions"] + assert payload["default_key_check"] == {"performed": False, "reason": "dry-run"} + assert fake_admin.opened == [] + + +def test_human_output_states_the_verdict(monkeypatch, fake_admin): + wire_session(monkeypatch, _conn()) + result = run(["piv", "admin", "keys", "--check-default"]) + assert result.exit_code == 0, result.output + assert "YES" in result.output + assert "replace the key before deployment" in result.output + fake_admin.outcome = "wrong" + wire_session(monkeypatch, _conn()) + result = run(["piv", "admin", "keys", "--check-default"]) + assert "One failed authentication is now counted" in result.output + wire_session(monkeypatch, _conn()) + result = run(["piv", "admin", "keys"]) + assert "not checked (pass --check-default)" in result.output