From acd1fe8f3ac64de1f8310a596f156adf1a819efb Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Wed, 16 Sep 2026 18:42:50 +0200 Subject: [PATCH 1/3] Fall back to the PIV management key when the admin channel truncates a generated key On-card RSA generation returns a 7F49 template larger than one 256-byte response. Cards that do not chain on the secured response path deliver the first 256 bytes with a plain success status and drop the rest, so the CLI parsed a partial template and failed with a raw TLV error. Detect that response and repeat the generation over plain APDUs behind a mutual PIV management key (9B) authentication, where the card does chain. Detection requires an RSA mechanism, SW 9000, exactly 256 bytes, and a leading 7F49 header declaring more, so it cannot fire for ECC or for a card that chains. Cards that return the full template are untouched. Add factory piv preperso set-mgmt-key to load the 9B value, since the profiles create the key object but never gave it one, and report 9B in factory piv preperso status. The value comes from --default-keys or PIV_MGMT_KEY, never the command line. A public-key template the CLI still cannot parse now ends in a CLI error naming the response length instead of an unhandled exception. Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 15 + CONTEXT.md | 18 +- ...ent-key-fallback-for-on-card-generation.md | 67 +++ docs/agents/domain.md | 7 +- docs/exit-codes.rst | 5 +- docs/factory/factory-commands.rst | 9 + docs/getting-started.rst | 3 + docs/piv/piv-commands.rst | 15 + docs/piv/piv-personalization.rst | 15 + src/cryptnox_id_cli/applets/piv/admin.py | 11 + src/cryptnox_id_cli/applets/piv/mgmt_auth.py | 384 ++++++++++++++++++ src/cryptnox_id_cli/applets/piv/perso.py | 43 +- src/cryptnox_id_cli/cli/commands/factory.py | 151 ++++++- src/cryptnox_id_cli/cli/commands/piv.py | 191 ++++++++- src/cryptnox_id_cli/cli/dryrun.py | 1 + src/cryptnox_id_cli/secrets/resolver.py | 58 ++- src/cryptnox_id_cli/util/tlv.py | 17 + tests/unit/test_cap_truncation.py | 124 ++++++ tests/unit/test_generate_key_fallback.py | 335 +++++++++++++++ tests/unit/test_generate_key_gate.py | 5 +- tests/unit/test_generate_truncation.py | 92 +++++ tests/unit/test_mgmt_auth.py | 161 ++++++++ tests/unit/test_mgmt_auth_flow.py | 218 ++++++++++ tests/unit/test_mgmt_key_resolver.py | 87 ++++ tests/unit/test_redaction.py | 33 ++ tests/unit/test_set_mgmt_key.py | 227 +++++++++++ 26 files changed, 2265 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0002-management-key-fallback-for-on-card-generation.md create mode 100644 src/cryptnox_id_cli/applets/piv/mgmt_auth.py create mode 100644 tests/unit/test_cap_truncation.py create mode 100644 tests/unit/test_generate_key_fallback.py create mode 100644 tests/unit/test_generate_truncation.py create mode 100644 tests/unit/test_mgmt_auth.py create mode 100644 tests/unit/test_mgmt_auth_flow.py create mode 100644 tests/unit/test_mgmt_key_resolver.py create mode 100644 tests/unit/test_set_mgmt_key.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d35f653..c88b70b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,21 @@ 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. +- `factory piv preperso set-mgmt-key` - load the PIV management key (9B) + value over the admin channel; `--replace` replaces a value that is set. + `factory piv preperso status` reports whether 9B holds a value. +- `PIV_MGMT_KEY` (hex) for the PIV management key, alongside the existing + `PIV_SCP03_*` admin-channel variables. + +### Fixed + +- `perso generate-key` and `quickstart` complete on cards that return at most + 256 bytes of an RSA public-key template over the admin channel: the + truncated response is detected and the generation is repeated over plain + APDUs after authenticating the PIV management key (9B). ECC and cards that + return the full template are unaffected. +- A public-key template the CLI cannot parse now ends in a CLI error naming + the response length, instead of an unhandled exception. ## [1.0.3] - 2026-08-31 diff --git a/CONTEXT.md b/CONTEXT.md index 6e8e7b0..4999b02 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,9 +70,21 @@ _Avoid_: self-test (the FIDO2 credential self-test is a different operation) ### Keys and administration **Admin channel**: -The SCP03 secure channel to the PIV applet, through which all PIV writes go. This -card has no Yubico-style management key. -_Avoid_: management key (another vendor's concept; nothing on this card answers to it) +The SCP02/SCP03 secure channel to the PIV applet, through which PIV writes go. The +one exception is reading back a large generated public key, which some cards deliver +only after management-key authentication. +_Avoid_: management key (a different key with a different authentication - see +Management key) + +**Management key (9B)**: +The PIV applet's own AES administration key at key reference 9B. It authenticates over +plain APDUs (GENERAL AUTHENTICATE, mutual) and authorizes writes to the key objects it +administers, which is every key object unless a profile names another. Contact-only. +The CLI uses it for one thing: finishing on-card key generation when the admin channel +cannot return the full public-key template. Its value comes from `--default-keys` or +`PIV_MGMT_KEY`. +_Avoid_: admin key (collides with the admin channel), Yubico-style management key (it +does not replace the admin channel and no PIN/PUK operation uses it) **Default keys**: The publicly documented GlobalPlatform test key value. It authenticates convenience, diff --git a/docs/adr/0002-management-key-fallback-for-on-card-generation.md b/docs/adr/0002-management-key-fallback-for-on-card-generation.md new file mode 100644 index 0000000..ccf1e80 --- /dev/null +++ b/docs/adr/0002-management-key-fallback-for-on-card-generation.md @@ -0,0 +1,67 @@ +# 2. Management-key fallback for on-card key generation + +## Context + +On-card key generation returns the new public key as a `7F49` template in the +GENERATE response. An RSA template is larger than one 256-byte response: 270 bytes +for RSA-2048, and more for RSA-3072 and RSA-4096. A card that chains splits it +across a first response and GET RESPONSE frames, and the CLI's transport reassembles +it — that is the path every command already takes. + +Some cards do not chain on the secured response path. They deliver the first 256 +bytes, end with a plain success status, and drop the remainder. The response looks +complete to the host, so the CLI parsed a partial template and failed with a raw TLV +error. ECC is unaffected: its templates are 70 and 102 bytes. + +The applet is a certified build. Changing it is not on the table for this problem. + +## Options + +1. **Change the applet's response layer** so the secured path chains like the plain + one. Correct at the source, and unavailable: it forks a certified build. +2. **Always generate over plain APDUs**, behind a management-key authentication. + One code path, but it makes a working card depend on a key value that cards in the + field do not carry, and it moves an operation off the admin channel for every card + to fix a problem only some cards have. +3. **Ask for a response-MAC session**, whose framing does chain. Secure-channel-version + specific, and unavailable on one of the two channel versions this CLI speaks. +4. **Detect the truncation and fall back** to the plain path only then. + +## Decision + +Option 4. + +The detector reads the card's own response: an RSA mechanism, a success status, +exactly the requested length, and a `7F49` header declaring a larger object than +arrived. It consults no card identity, version or build, and it cannot fire for ECC +or for a card that returns the full template — those take the admin channel with the +same APDUs as before. + +When it fires, the CLI authenticates the PIV management key (key reference 9B) +mutually over plain APDUs and repeats the GENERATE as a plain APDU; the applet's +plaintext response path chains, and the existing transport loop reassembles the +template. Authentication is mutual rather than external, so the card proves it holds +the key too. The management key's value is never injected automatically: a card whose +9B holds no value is reported with the one command that loads one +(`factory piv preperso set-mgmt-key`). Writing a card's administration key is an +explicit act, not a side effect of generating a key. + +Only the GENERATE read-back moves off the admin channel. Object creation, key import, +certificate writes, PIN and PUK operations and data objects are untouched, as is the +transport. + +## Consequences + +* On a card that needs the fallback, a key pair is generated twice: the applet + generates before it builds the response, so the truncated first attempt already + replaced the slot's key. The key that survives is the one from the plain path, + and its public half is the one reported. The CLI says so before it retries. An + interruption between the two attempts leaves a private key whose public half + nobody knows; re-running the command resolves it. +* The fallback needs 9B to hold a value, and 9B is contact-only. +* "Management key" now names something real on this card, which the glossary + previously told readers to avoid. `CONTEXT.md` carries the new term and the + amended "Admin channel" entry. +* Management-key authentication grants a role that the applet keeps until the card + is deselected or loses power. The CLI does not spend an extra command trying to + drop it early. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index 2f1a0ad..b31ee09 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -20,9 +20,10 @@ Single-context repo: one `CONTEXT.md` at the root, one `docs/adr/` (numbered When output names a domain concept — in a commit message, an issue title, a test name, a docs sentence — use the term as `CONTEXT.md` defines it and avoid the listed -synonyms. In this repo that means, for example: *admin channel*, never "management -key"; *pre-personalization* vs *personalization*, never the ambiguous "provisioning"; -*genuineness* or *key attestation*, never bare "attestation". +synonyms. In this repo that means, for example: *admin channel* and *management key +(9B)* are two different keys and never stand in for each other; *pre-personalization* +vs *personalization*, never the ambiguous "provisioning"; *genuineness* or *key +attestation*, never bare "attestation". If a concept you need isn't in the glossary, that's a signal: either you're inventing language the project doesn't use (reconsider), or there's a real gap — extend diff --git a/docs/exit-codes.rst b/docs/exit-codes.rst index 8a67096..4588aa0 100644 --- a/docs/exit-codes.rst +++ b/docs/exit-codes.rst @@ -37,8 +37,9 @@ machine token (see :doc:`/json-output`). - ``status_word`` - The card rejected a command (the message carries the ISO status word). * - 7 - - ``scp03_error`` - - SCP03 secure-channel failure (wrong admin keys, cryptogram mismatch). + - ``scp03_error`` / ``mgmt_key`` + - SCP03 secure-channel failure (wrong admin keys, cryptogram mismatch), or + PIV management-key (``9B``) authentication failed. * - 8 - ``profile_error`` - Pre-personalization profile parse/validation failure. diff --git a/docs/factory/factory-commands.rst b/docs/factory/factory-commands.rst index 2ffee0e..3631560 100644 --- a/docs/factory/factory-commands.rst +++ b/docs/factory/factory-commands.rst @@ -15,12 +15,21 @@ manufacturing procedure outside this documentation. factory piv preperso init-config write a built-in profile to editable YAML factory piv preperso export-config read-only snapshot of the card's structure factory piv preperso load-config apply a profile to the card (supports --dry-run) + factory piv preperso set-mgmt-key load the PIV management key (9B) value factory piv preperso finalize IRREVERSIBLY lock the applet structure ``load-config`` sends one structural operation per SCP03 session (a platform requirement of this card), so a profile load is a sequence of short commands; it stops at the first rejection and reports exactly what was applied. +The PIV **management key** (key reference ``9B``) is the applet's own +administration key. Profiles create its key object but never carry a value, +because profiles are shareable files; ``set-mgmt-key`` writes the value over the +admin channel from ``--default-keys`` or ``PIV_MGMT_KEY`` +(``CARD_PIV_MGMT_KEY`` is accepted as an alias). It refuses to create a missing +``9B`` object and refuses to overwrite a value that is already set unless +``--replace`` is given; ``status`` shows whether ``9B`` holds a value. + Built-in profiles: ``cryptnox-default`` (the applet's own reference structure), ``developer`` / ``npivp-lab`` (the same structure, labelled for non-production use), ``ssh`` (9A gets SIGN added, nothing else changes — see diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 8bbb823..f4c1dd9 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -104,6 +104,8 @@ Development keys * **SCP03 / GlobalPlatform ISD** — the published GlobalPlatform test key ``40 41 42 … 4F`` (``--default-keys``); + * **PIV management key (9B)** — the same published GlobalPlatform test + value, doubled for an AES-256 key object (``--default-keys``); * **DESFire** application keys after ``app create`` — all-zero AES (``--zero-key``); * **PIV PIN / PUK** in the examples — ``123456`` / ``12345678``. @@ -112,6 +114,7 @@ Development keys deployment must rotate all of them, which is exactly what the environment-variable key input is for: ``PIV_SCP03_ENC`` / ``PIV_SCP03_MAC`` / ``PIV_SCP03_DEK`` for the admin channel, + ``PIV_MGMT_KEY`` for the PIV management key, ``CRYPTNOX_PIV_PIN`` / ``CRYPTNOX_PIV_NEW_PIN`` / ``CRYPTNOX_PIV_NEW_PUK`` for PINs and PUKs, and the DESFire ``--key-env NAME`` form. diff --git a/docs/piv/piv-commands.rst b/docs/piv/piv-commands.rst index 3516347..367bb4d 100644 --- a/docs/piv/piv-commands.rst +++ b/docs/piv/piv-commands.rst @@ -8,6 +8,10 @@ admin keys come from ``--default-keys`` (development cards) or the variables. PIN values come from masked prompts or ``CRYPTNOX_PIV_PIN`` / ``CRYPTNOX_PIV_NEW_PIN`` / ``CRYPTNOX_PIV_NEW_PUK`` — never the command line. +On-card generation of a large public key can also use the card's **PIV +management key** (key reference ``9B``), whose value comes from +``--default-keys`` or ``PIV_MGMT_KEY`` — never the command line. + Inspection ---------- @@ -132,6 +136,17 @@ Notes imported there. ``ms-logon`` also creates an RSA-2048 object on 9A, and ``generate-key --slot 9A --algorithm RSA2048`` generates on-card on such a card. Asking for a mechanism the slot has no object for returns ``6A80``. +* Some cards cannot return an RSA public-key template larger than 256 bytes over + the admin channel. ``generate-key`` detects that from the card's own response + and finishes the generation over plain APDUs, authenticating the PIV management + key (``9B``) first. The management key is contact-only and its value comes from + ``--default-keys`` (development cards) or ``PIV_MGMT_KEY``; a card whose ``9B`` + object holds no value is reported with the command that loads one + (``factory piv preperso set-mgmt-key``). ECC is unaffected, and cards that + return the full template take the admin channel as before. +* When that path is taken, the key pair from the first attempt is replaced by the + one generated over the plain path, whose public half is the one reported; RSA + generation therefore runs twice on such a card. * ``piv quickstart`` defaults to ECC P-256. It accepts ``--algorithm RSA*`` only for ``--profile ms-logon`` on slot 9A — the one built-in shape with an RSA key object — and rejects it elsewhere; pass ``--algorithm RSA2048`` diff --git a/docs/piv/piv-personalization.rst b/docs/piv/piv-personalization.rst index 73b008f..ab96adf 100644 --- a/docs/piv/piv-personalization.rst +++ b/docs/piv/piv-personalization.rst @@ -19,6 +19,8 @@ Operator commands live under ``piv``; factory pre-personalization lives under keys — development/evaluation cards use the GlobalPlatform test keys (``--default-keys``); provisioned cards take theirs from the ``PIV_SCP03_ENC`` / ``PIV_SCP03_MAC`` / ``PIV_SCP03_DEK`` environment variables. +``PIV_MGMT_KEY`` carries the PIV management key (``9B``) for cards that need it +(see Step 2). Quickstart (one shot) ---------------------- @@ -105,6 +107,19 @@ on-card just as well — ``ms-logon`` creates an ``RSA2048`` object on 9A, and:: succeeds there, with the private key never leaving the card. Requesting a mechanism the slot has no object for returns ``6A80``. +Over the admin channel some cards deliver at most 256 bytes of a public-key +template, which an RSA template exceeds. ``generate-key`` reads that from the +response and completes the generation with the PIV management key (``9B``) over +plain APDUs; the command line is the same either way. That path is contact-only +and needs the ``9B`` value — ``--default-keys`` on development cards, +``PIV_MGMT_KEY`` otherwise. Profiles create the ``9B`` key object but carry no +value; load one once with: + +.. code-block:: console + + $ cryptnox-id factory piv preperso set-mgmt-key --default-keys + $ cryptnox-id piv perso generate-key --slot 9A --algorithm RSA2048 --out 9a.pub.pem --default-keys + ``piv quickstart`` follows the same rule with training wheels: it defaults to ECC P-256 everywhere, accepts explicit RSA only for ``--profile ms-logon`` on 9A (the one built-in shape with an RSA object, and the one Windows needs — diff --git a/src/cryptnox_id_cli/applets/piv/admin.py b/src/cryptnox_id_cli/applets/piv/admin.py index bdadb71..9f72914 100644 --- a/src/cryptnox_id_cli/applets/piv/admin.py +++ b/src/cryptnox_id_cli/applets/piv/admin.py @@ -138,6 +138,17 @@ def open( "expected 0x02 or 0x03)." ) + def forget_channel(self) -> None: + """Drop the host's secure-channel state. + + A re-SELECT of the applet resets the card's channel unconditionally, so after + one the host's session keys describe a channel that no longer exists. Clearing + them here keeps the two sides in step: the next :meth:`send` says "secure + channel not open" instead of wrapping into a void. + """ + self.scp = None + self.scp_version = None + def send(self, apdu: APDU, *, context: str | None = None) -> Response: if self.scp is None: raise Scp03Error("secure channel not open") diff --git a/src/cryptnox_id_cli/applets/piv/mgmt_auth.py b/src/cryptnox_id_cli/applets/piv/mgmt_auth.py new file mode 100644 index 0000000..373cc66 --- /dev/null +++ b/src/cryptnox_id_cli/applets/piv/mgmt_auth.py @@ -0,0 +1,384 @@ +"""PIV management key (key reference 9B): authentication and value management. + +The applet administers each key object either through the GlobalPlatform secure +channel (the admin role on a wrapped APDU) or through the PIV management key named in +the object's admin-key element, which defaults to 9B. Authenticating 9B over plain +APDUs grants the key-holder role for 9B, which authorizes GENERATE on every key +object 9B administers, and the applet answers on its plaintext response path, which +chains long responses with 61xx. The CLI uses this for one thing: finishing an on-card +key generation when the admin channel cannot return the full public-key template. 9B +is contact-only on the structure this CLI lays down (the key object's contactless +access mode is NEVER). + +Authentication is MUTUAL (GENERAL AUTHENTICATE cases 4 and 5): the card returns an +enciphered witness, the host returns it deciphered together with its own challenge, +and the card's answer to that challenge proves the card holds the key too. + +Wire grammar only; the caller owns the card session and the SELECT that precedes the +exchange. See docs/adr/0002-management-key-fallback-for-on-card-generation.md. +""" + +from __future__ import annotations + +import hmac +import os +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass + +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes + +from cryptnox_id_cli.applets.piv import constants as c +from cryptnox_id_cli.transport.apdu import APDU, Response +from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError +from cryptnox_id_cli.util import tlv + +INS_GENERAL_AUTHENTICATE = 0x87 +INS_CHANGE_REFERENCE_DATA = 0x24 + +TAG_DYNAMIC_AUTH = 0x7C +TAG_WITNESS = 0x80 +TAG_CHALLENGE = 0x81 +TAG_RESPONSE = 0x82 + +ELEMENT_KEY = 0x80 # symmetric key value element +ELEMENT_CLEAR = 0x9F # "drop the current value" element + +#: AES block length. Witness, challenge and response are one block each. +BLOCK_LEN = 16 + +#: AES mechanism id -> key length in bytes. +AES_MECHANISMS: dict[int, int] = {0x08: 16, 0x0A: 24, 0x0C: 32} + +#: Probe order when nothing narrows it: the built-in profiles create 9B as AES-256. +MECHANISM_PROBE_ORDER: tuple[int, ...] = (0x0C, 0x08, 0x0A) + +#: Shaped like ``CardSession.transmit`` - the caller passes the bound method. +Transmit = Callable[..., Response] + +_PREFIX = "PIV management key authentication failed: " + + +def mechanism_name(mechanism: int) -> str: + """Human-readable mechanism label, e.g. ``AES-256``.""" + return c.ALGORITHMS.get(mechanism, f"{mechanism:#04x}") + + +def mechanism_for_key_length(length: int) -> int | None: + """The AES mechanism a key value of ``length`` bytes belongs to, or ``None``.""" + for mechanism, key_len in AES_MECHANISMS.items(): + if key_len == length: + return mechanism + return None + + +class MgmtKeyError(CryptnoxError): + """Management-key (9B) authentication or provisioning failure.""" + + code = "mgmt_key" + exit_code = 7 + + def __init__(self, message: str, *, stage: str, sw: int | None = None) -> None: + super().__init__(message) + #: Which part of the exchange failed: ``missing``, ``empty``, ``access``, + #: ``unusable``, ``key_length``, ``key_mismatch``, ``card_verify``, + #: ``protocol`` or ``set_value``. + self.stage = stage + self.sw = sw + + def to_dict(self) -> dict[str, object]: + return { + "error": self.code, + "message": str(self), + "stage": self.stage, + "sw": f"{self.sw:04X}" if self.sw is not None else None, + } + + +@dataclass(frozen=True) +class MgmtKeyMaterial: + """Where the 9B value came from, and the value it can serve per mechanism.""" + + #: ``--default-keys``, ``$PIV_MGMT_KEY`` or ``$CARD_PIV_MGMT_KEY``. Named in + #: messages; the value itself never is. + source: str + #: mechanism id -> key bytes, already length-checked against AES_MECHANISMS. + keys: Mapping[int, bytes] + + def mechanisms(self) -> tuple[int, ...]: + """Probe order: the mechanisms this material can serve first, then the rest. + + Probing the others too costs one short APDU each and turns "authentication + failed" into "this card's 9B is AES-128 and you supplied 32 bytes". + """ + served = [m for m in MECHANISM_PROBE_ORDER if m in self.keys] + rest = [m for m in MECHANISM_PROBE_ORDER if m not in self.keys] + return tuple(served + rest) + + def key_for(self, mechanism: int) -> bytes: + """The value for a mechanism the card reported, or a ``key_length`` failure.""" + value = self.keys.get(mechanism) + if value is not None: + return bytes(value) + name = mechanism_name(mechanism) + need = AES_MECHANISMS.get(mechanism, 0) + if self.source == "--default-keys": + raise MgmtKeyError( + f"--default-keys has no published value for an {name} management key; " + f"set $PIV_MGMT_KEY ({need * 2} hex characters).", + stage="key_length", + ) + supplied = next(iter(self.keys.values()), b"") + raise MgmtKeyError( + f"{_PREFIX}the card's management key is {name} and needs a {need}-byte " + f"value; {self.source} supplied {len(supplied)} bytes.", + stage="key_length", + ) + + +@dataclass(frozen=True) +class MgmtKeyProbe: + """Outcome of the read-only witness-request probe.""" + + #: The mechanism whose key object answered, or ``None`` when every candidate + #: reported "no such (key reference, mechanism) pair". + mechanism: int | None + #: Status word of the deciding response (6A86 when no candidate answered). + sw: int + #: The enciphered witness, when the card returned one. + witness: bytes | None + + +@dataclass(frozen=True) +class MgmtAuth: + """A completed mutual authentication against 9B.""" + + mechanism: int + source: str + + +# --------------------------------------------------------------------------- # +# APDU builders # +# --------------------------------------------------------------------------- # +def witness_request_apdu(mechanism: int) -> APDU: + """GENERAL AUTHENTICATE case 4: an empty witness tag asks the card for one.""" + body = tlv.build_constructed(TAG_DYNAMIC_AUTH, tlv.build(TAG_WITNESS, b"")) + return APDU(0x00, INS_GENERAL_AUTHENTICATE, mechanism, c.KEYREF_ADMIN, data=body, le=256) + + +def mutual_response_apdu(mechanism: int, witness: bytes, challenge: bytes) -> APDU: + """GENERAL AUTHENTICATE case 5: the deciphered witness, then the host challenge. + + Tag order inside the template is witness (80) before challenge (81); the applet + dispatches on exactly that combination. + """ + body = tlv.build_constructed( + TAG_DYNAMIC_AUTH, + tlv.build(TAG_WITNESS, witness) + tlv.build(TAG_CHALLENGE, challenge), + ) + return APDU(0x00, INS_GENERAL_AUTHENTICATE, mechanism, c.KEYREF_ADMIN, data=body, le=256) + + +def clear_value_apdu(mechanism: int) -> APDU: + """CHANGE REFERENCE DATA ADMIN dropping 9B's current value (admin channel only).""" + return APDU( + 0x00, + INS_CHANGE_REFERENCE_DATA, + mechanism, + c.KEYREF_ADMIN, + data=tlv.build(ELEMENT_CLEAR, b""), + ) + + +def set_value_apdu(mechanism: int, value: bytes) -> APDU: + """CHANGE REFERENCE DATA ADMIN loading a 9B value (admin channel only).""" + expected = AES_MECHANISMS.get(mechanism) + if expected is None: + raise MgmtKeyError( + f"{mechanism:#04x} is not an AES mechanism, so it cannot hold a management key.", + stage="key_length", + ) + if len(value) != expected: + raise MgmtKeyError( + f"an {mechanism_name(mechanism)} management key needs a {expected}-byte value; " + f"{len(value)} bytes were supplied.", + stage="key_length", + ) + return APDU( + 0x00, + INS_CHANGE_REFERENCE_DATA, + mechanism, + c.KEYREF_ADMIN, + data=tlv.build(ELEMENT_KEY, bytes(value)), + ) + + +# --------------------------------------------------------------------------- # +# Crypto and parsing # +# --------------------------------------------------------------------------- # +def aes_ecb_encrypt(key: bytes, block: bytes) -> bytes: + """Encrypt exactly one AES block, no padding (the 9B authentication primitive).""" + enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() # noqa: S305 - single block + return enc.update(_one_block(block)) + enc.finalize() + + +def aes_ecb_decrypt(key: bytes, block: bytes) -> bytes: + """Decrypt exactly one AES block, no padding.""" + dec = Cipher(algorithms.AES(key), modes.ECB()).decryptor() # noqa: S305 - single block + return dec.update(_one_block(block)) + dec.finalize() + + +def _one_block(block: bytes) -> bytes: + if len(block) != BLOCK_LEN: + raise MgmtKeyError( + f"{_PREFIX}expected a {BLOCK_LEN}-byte block, got {len(block)}.", stage="protocol" + ) + return bytes(block) + + +def auth_field(data: bytes, tag: int) -> bytes: + """The one-block value of ``tag`` inside the 7C dynamic-authentication template.""" + try: + nodes = tlv.parse(data) + except ValueError as exc: + raise MgmtKeyError( + f"{_PREFIX}the card's response did not match the expected 7C template ({exc}).", + stage="protocol", + ) from exc + template = tlv.find(nodes, TAG_DYNAMIC_AUTH) + if template is None: + raise MgmtKeyError( + f"{_PREFIX}the card's response carries no 7C template.", stage="protocol" + ) + node = tlv.find(template.children, tag) + if node is None: + raise MgmtKeyError( + f"{_PREFIX}the card's 7C template carries no tag {tag:02X}.", stage="protocol" + ) + if len(node.value) != BLOCK_LEN: + raise MgmtKeyError( + f"{_PREFIX}tag {tag:02X} is {len(node.value)} bytes; expected {BLOCK_LEN}.", + stage="protocol", + ) + return node.value + + +# --------------------------------------------------------------------------- # +# Drivers # +# --------------------------------------------------------------------------- # +def probe(transmit: Transmit, mechanisms: Sequence[int]) -> MgmtKeyProbe: + """Ask 9B for a witness, one candidate mechanism at a time. + + This is the only way to learn anything about a key object's value: the applet has + no read-only "is it set" query, and its precondition ordering makes the status word + of this one command say which of "absent", "no value", "not accessible here", + "not usable for this flow" and "ready" holds. Nothing is written and no retry + counter exists on 9B. + + A successful probe leaves a two-step exchange open on the card; the applet resets + it on the next witness request, so abandoning one is harmless. It does clear any + key-holder role already held, so never probe between an authentication and the + operation that depends on it. + """ + for mechanism in mechanisms: + resp = transmit(witness_request_apdu(mechanism), context="9B witness request") + if resp.sw == 0x6A86: # no key object for this (reference, mechanism) pair + continue + witness = auth_field(resp.data, TAG_WITNESS) if resp.ok else None + return MgmtKeyProbe(mechanism=mechanism, sw=resp.sw, witness=witness) + return MgmtKeyProbe(mechanism=None, sw=0x6A86, witness=None) + + +def authenticate( + transmit: Transmit, + material: MgmtKeyMaterial, + *, + challenge: bytes | None = None, + contactless: bool = False, +) -> MgmtAuth: + """Authenticate 9B mutually over plain APDUs, granting the key-holder role. + + ``challenge`` is injectable so tests are deterministic; production passes nothing + and gets a fresh random block. ``contactless`` only shapes the refusal message. + + The two commands are sent back to back: the applet holds a single pending + authentication state, so anything in between invalidates the exchange. + """ + candidates = material.mechanisms() + found = probe(transmit, candidates) + + if found.mechanism is None: + names = ", ".join(mechanism_name(m) for m in candidates) + raise MgmtKeyError( + f"{_PREFIX}this card has no AES key object in slot 9B (SW=6A86 for {names}). " + "Slot 9B is laid down by pre-personalization; check the card with " + "'cryptnox-id factory piv preperso status'.", + stage="missing", + sw=found.sw, + ) + name = mechanism_name(found.mechanism) + if found.sw == 0x6983: + raise MgmtKeyError( + f"{_PREFIX}slot 9B exists ({name}) but holds no key value (SW=6983), so this " + "card cannot complete the generation over the plain path. Load a value once " + "with 'cryptnox-id factory piv preperso set-mgmt-key' (needs the admin " + "channel), then re-run this command.", + stage="empty", + sw=found.sw, + ) + if found.sw == 0x6982: + where = ( + ", and this session looks like a contactless (PICC) interface - re-run on the " + "contact reader." + if contactless + else "; use the contact reader." + ) + raise MgmtKeyError( + f"{_PREFIX}slot 9B refused authentication on this interface (SW=6982). The " + f"management key is contact-only on this card{where}", + stage="access", + sw=found.sw, + ) + if found.sw == 0x6985: + raise MgmtKeyError( + f"{_PREFIX}slot 9B does not permit mutual authentication (SW=6985). Its key " + "object needs the AUTHENTICATE role and the PERMIT_MUTUAL attribute, which " + "are fixed at pre-personalization.", + stage="unusable", + sw=found.sw, + ) + if found.witness is None: + raise StatusWordError(found.sw >> 8, found.sw & 0xFF, context="9B witness request") + + key = material.key_for(found.mechanism) + witness = aes_ecb_decrypt(key, found.witness) + nonce = challenge if challenge is not None else os.urandom(BLOCK_LEN) + + resp = transmit( + mutual_response_apdu(found.mechanism, witness, nonce), context="9B mutual response" + ) + if resp.sw == 0x6982: + raise MgmtKeyError( + f"{_PREFIX}the card rejected the response to its witness (SW=6982), so the " + f"value from {material.source} is not this card's 9B key.", + stage="key_mismatch", + sw=resp.sw, + ) + if resp.sw in (0x6A80, 0x6700): + raise MgmtKeyError( + f"{_PREFIX}the card's response did not match the expected 7C template " + f"(SW={resp.sw_hex()}).", + stage="protocol", + sw=resp.sw, + ) + if not resp.ok: + raise StatusWordError(resp.sw1, resp.sw2, context="9B mutual response") + + answer = auth_field(resp.data, TAG_RESPONSE) + if not hmac.compare_digest(aes_ecb_decrypt(key, answer), nonce): + raise MgmtKeyError( + f"{_PREFIX}the card's response does not match the challenge, so the card does " + f"not hold the key from {material.source}.", + stage="card_verify", + sw=resp.sw, + ) + return MgmtAuth(mechanism=found.mechanism, source=material.source) diff --git a/src/cryptnox_id_cli/applets/piv/perso.py b/src/cryptnox_id_cli/applets/piv/perso.py index e01280f..0d037d3 100644 --- a/src/cryptnox_id_cli/applets/piv/perso.py +++ b/src/cryptnox_id_cli/applets/piv/perso.py @@ -12,7 +12,7 @@ from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives.asymmetric import ec, rsa -from cryptnox_id_cli.transport.apdu import APDU +from cryptnox_id_cli.transport.apdu import APDU, Response from cryptnox_id_cli.util import tlv INS_CHANGE_REFERENCE_DATA = 0x24 @@ -38,6 +38,14 @@ # Asymmetric algorithm name -> mechanism id (those this applet supports). ALGORITHMS = {"ECCP256": 0x11, "ECCP384": 0x14, "RSA2048": 0x07, "RSA3072": 0x05, "RSA4096": 0x16} +#: What ``generate_keypair_apdu`` asks for (Le = 0x00 on the wire, i.e. "up to 256"). +#: A card whose response layer cannot chain cuts the template at exactly this length. +GENERATE_LE = 256 + +#: Mechanisms whose 7F49 template exceeds one 256-byte response. RSA only: the ECC +#: templates are 70 (P-256) and 102 (P-384) bytes and can never be cut. +RSA_MECHANISMS = frozenset({0x07, 0x05, 0x16}) + def pad_pin(value: bytes, length: int = 8) -> bytes: """PIV PIN/PUK values are padded to 8 bytes with 0xFF.""" @@ -59,7 +67,38 @@ def generate_keypair_apdu(slot: int, mechanism: int) -> APDU: request = tlv.build_constructed( TAG_GENERATE_REQUEST, tlv.build(TAG_MECHANISM, bytes([mechanism])) ) - return APDU(0x00, INS_GENERATE_ASYMMETRIC, 0x00, slot, data=request, le=256) + return APDU(0x00, INS_GENERATE_ASYMMETRIC, 0x00, slot, data=request, le=GENERATE_LE) + + +def declared_template_bytes(data: bytes) -> int | None: + """Total size (header + value) the leading 7F49 header declares. + + ``None`` when ``data`` does not start with a readable public-key template header. + Read from the header alone, so it works on a response that was cut short. + """ + header = tlv.peek(data) + if header is None: + return None + tag, length, header_len = header + return header_len + length if tag == TAG_PUBKEY_TEMPLATE else None + + +def generate_response_truncated(mechanism: int, resp: Response) -> bool: + """True when a GENERATE response was cut short by the card's response layer. + + Some cards deliver at most ``GENERATE_LE`` bytes of a secured response and end it + with a plain success status instead of chaining the remainder, so the caller gets a + complete-looking answer holding a partial template. The signature is exact: an RSA + mechanism (ECC templates are far below the cut), success, exactly the requested + length, and a 7F49 header declaring more than arrived. No card identity, version or + build is consulted - only the card's own response. + """ + if mechanism not in RSA_MECHANISMS: + return False + if resp.sw != 0x9000 or len(resp.data) != GENERATE_LE: + return False + declared = declared_template_bytes(resp.data) + return declared is not None and declared > len(resp.data) def parse_public_key(mechanism: int, response: bytes): diff --git a/src/cryptnox_id_cli/cli/commands/factory.py b/src/cryptnox_id_cli/cli/commands/factory.py index 6b471ab..715ce43 100644 --- a/src/cryptnox_id_cli/cli/commands/factory.py +++ b/src/cryptnox_id_cli/cli/commands/factory.py @@ -14,12 +14,13 @@ from rich.console import Console from cryptnox_id_cli.applets.piv import constants as c +from cryptnox_id_cli.applets.piv import mgmt_auth from cryptnox_id_cli.applets.piv import preperso as pp 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.slots import PIV_SLOTS from cryptnox_id_cli.cli.context import AppContext -from cryptnox_id_cli.secrets.resolver import resolve_scp03_keys +from cryptnox_id_cli.secrets.resolver import resolve_mgmt_key, resolve_scp03_keys from cryptnox_id_cli.state import StateDetector from cryptnox_id_cli.state.model import PivState from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError @@ -45,6 +46,47 @@ def preperso() -> None: """PIV pre-personalization (OpenFIPS201 vendor, over SCP03).""" +def _probe_mgmt_key(session: object) -> dict[str, object] | None: + """Read-only look at slot 9B: does the key object exist, does it hold a value? + + At most three short GENERAL AUTHENTICATE requests, none of which writes anything + or consumes a retry counter (9B has none). ``None`` when the PIV applet is not + selectable at all - that is already visible in the lifecycle state. + """ + try: + adm = PivAdmin(session) # type: ignore[arg-type] + adm.select() + found = mgmt_auth.probe(adm.card.transmit, mgmt_auth.MECHANISM_PROBE_ORDER) + except CryptnoxError: + return None + if found.mechanism is None: + return {"present": False, "mechanism": None, "value_set": None, "sw": f"{found.sw:04X}"} + # 6983 is "no value"; 6982 is "not usable on this interface", which says nothing + # about the value. Everything else means the object answered, so a value is there. + value_set: bool | None = None + if found.sw == 0x6983: + value_set = False + elif found.sw != 0x6982: + value_set = True + return { + "present": True, + "mechanism": mgmt_auth.mechanism_name(found.mechanism), + "value_set": value_set, + "sw": f"{found.sw:04X}", + } + + +def _mgmt_key_line(mgmt: dict[str, object] | None) -> str: + if mgmt is None: + return "not determined" + if not mgmt["present"]: + return "absent" + name = mgmt["mechanism"] + if mgmt["value_set"] is None: + return f"{name}, not accessible on this interface" + return f"{name}, {'value set' if mgmt['value_set'] else 'no value'}" + + # --------------------------------------------------------------------------- # @preperso.command("status") @click.pass_obj @@ -60,6 +102,7 @@ def status(app: AppContext) -> None: scp_version = probe.get("scp_version") except CryptnoxError: scp_supported = False + mgmt = _probe_mgmt_key(session) finalize_allowed = st.piv == PivState.PRE_PERSONALIZED scp_ver_label = scp_label(scp_version) payload = { @@ -68,6 +111,7 @@ def status(app: AppContext) -> None: "scp_version": scp_ver_label if scp_supported else None, "secured": st.piv == PivState.SECURED, "finalize_allowed": finalize_allowed, + "management_key": mgmt, } def human(con: Console) -> None: @@ -75,6 +119,7 @@ def human(con: Console) -> None: avail = f"yes ({scp_ver_label})" if scp_supported else "no" con.print(f" Admin secure channel available: {avail}") con.print(f" Finalized (SECURED): {'yes' if payload['secured'] else 'no/undetermined'}") + con.print(f" Management key (9B): {_mgmt_key_line(mgmt)}") if finalize_allowed: con.print(" Pre-perso load + finalize: [green]allowed[/green]") else: @@ -245,6 +290,110 @@ def report(con: Console) -> None: sys.exit(6) +#: Why the card refused the management-key write, in its own terms. +_SET_MGMT_KEY_SW_HINTS = { + 0x6985: "slot 9B already holds a value (SW=6985); re-run with --replace.", + 0x6A88: "there is no 9B key object for this mechanism (SW=6A88).", + 0x6700: "the card rejected the value's length (SW=6700).", + 0x6982: "the admin channel is not authorized to write the management key (SW=6982).", +} + + +@preperso.command("set-mgmt-key") +@click.option( + "--default-keys", + is_flag=True, + help="Use the publicly known GlobalPlatform TEST keys for the admin channel, and " + "the same published value for the PIV management key (9B) itself " + "(fine for dev/eval, never for deployment).", +) +@click.option( + "--replace", + is_flag=True, + help="Replace a value that is already set (CLEAR then SET). Irreversible for the " + "current value.", +) +@click.pass_obj +def set_mgmt_key(app: AppContext, default_keys: bool, replace: bool) -> None: + """Load the PIV management key (slot 9B) value over the admin channel. + + The 9B key object itself comes from the pre-personalization profile; this command + refuses to create one. A value that is already set is replaced only with + ``--replace`` (CLEAR, then SET); if that is interrupted after the CLEAR, re-run + without ``--replace``. + """ + material = resolve_mgmt_key(app.redactor, default_keys=default_keys) + keys = resolve_scp03_keys(app.redactor, default_keys=default_keys) + with app.open_session() as session: + adm = PivAdmin(session) + adm.select() + found = mgmt_auth.probe(adm.card.transmit, material.mechanisms()) + if found.mechanism is None: + names = ", ".join(mgmt_auth.mechanism_name(m) for m in material.mechanisms()) + raise mgmt_auth.MgmtKeyError( + f"this card has no AES key object in slot 9B (SW=6A86 for {names}). The " + "key object is laid down by pre-personalization; load a profile first.", + stage="missing", + sw=found.sw, + ) + if found.sw == 0x6982: + raise mgmt_auth.MgmtKeyError( + "slot 9B is not accessible on this interface (SW=6982); the management key " + "is contact-only on this card.", + stage="access", + sw=found.sw, + ) + name = mgmt_auth.mechanism_name(found.mechanism) + # The applet checks "has a value" before it checks the key's attributes, so + # anything but 6983 means a value is already there - 6985 included. + has_value = found.sw != 0x6983 + if has_value and not replace: + raise mgmt_auth.MgmtKeyError( + f"slot 9B ({name}) already holds a value (SW={found.sw:04X}); pass " + "--replace to replace it.", + stage="set_value", + sw=found.sw, + ) + value = material.key_for(found.mechanism) + if has_value: + app.out.warn( + "slot 9B already holds a PIV management key; replacing it invalidates " + "every credential that knows the current value." + ) + adm.select() + adm.open(keys) + cleared = adm.send( + mgmt_auth.clear_value_apdu(found.mechanism), context="CLEAR management key" + ) + if not cleared.ok: + raise StatusWordError(cleared.sw1, cleared.sw2, context="CLEAR management key") + adm.select() + adm.open(keys) + resp = adm.send( + mgmt_auth.set_value_apdu(found.mechanism, value), context="SET management key" + ) + if not resp.ok: + hint = _SET_MGMT_KEY_SW_HINTS.get(resp.sw) + if hint is None: + raise StatusWordError(resp.sw1, resp.sw2, context="SET management key") + raise mgmt_auth.MgmtKeyError(hint, stage="set_value", sw=resp.sw) + action = "replaced" if has_value else "set" + payload = { + "slot": "9B", + "mechanism": name, + "source": material.source, + "action": action, + "sw": resp.sw_hex(), + } + app.out.result( + payload, + lambda con: con.print( + f"[green]PIV management key {action}[/green] in slot 9B ({name}, " + f"value from {material.source})." + ), + ) + + @preperso.command("finalize") @click.option( "--i-understand-this-is-irreversible", diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index 6d6d073..5b81803 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -12,13 +12,14 @@ from collections.abc import Callable from dataclasses import dataclass from pathlib import Path +from typing import Any, NamedTuple import click from rich.console import Console from cryptnox_id_cli import CLI_NAME, trust from cryptnox_id_cli.applets.piv import constants as pivc -from cryptnox_id_cli.applets.piv import keyimport +from cryptnox_id_cli.applets.piv import keyimport, mgmt_auth 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 @@ -36,9 +37,15 @@ from cryptnox_id_cli.crypto import piv_objects, x509util from cryptnox_id_cli.crypto.attestation import verify_attestation_chain from cryptnox_id_cli.output.render import state_style -from cryptnox_id_cli.secrets.resolver import resolve_scp03_keys, resolve_secret +from cryptnox_id_cli.secrets.resolver import ( + MGMT_KEY_ENV, + resolve_mgmt_key, + resolve_scp03_keys, + 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.pcsc import is_contactless_interface from cryptnox_id_cli.util import tlv from cryptnox_id_cli.util.hexutil import to_hex @@ -813,8 +820,9 @@ def perso() -> None: @click.option( "--default-keys", is_flag=True, - help="Use the default GlobalPlatform TEST keys " - "(publicly known - fine for dev/eval, never for deployment).", + help="Use the publicly known GlobalPlatform TEST keys for the admin channel, and " + "the same published value for the PIV management key (9B) if the card needs it " + "(fine for dev/eval, never for deployment).", ) @click.option( "--out", "out_", type=click.Path(dir_okay=False), help="Write public-key PEM to FILE." @@ -871,7 +879,10 @@ def perso_generate_key( probe = session.transmit(keyimport.probe_apdu(ref, mech), context=f"probe {slot}") if probe.sw == 0x6A86: _create_key_object(app, adm, keys, ref, mech, False, slot) - public_key = _generate_key_on_card(adm, keys, ref, mech, label=slot) + generated = _generate_key_on_card( + app, adm, keys, ref, mech, label=slot, default_keys=default_keys + ) + public_key = generated.public_key pem = perso_mod.public_key_pem(public_key) der = public_key.public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo @@ -885,10 +896,13 @@ def perso_generate_key( "public_key_sha256": fingerprint, "out": out_, "public_key_pem": None if out_ else pem.decode(), + "generate_path": generated.path, + "management_key": generated.management_key, } + how = "on-card, via the PIV management key" if generated.management_key else "on-card" def human(c: Console) -> None: - c.print(f"[green]Generated {alg} key in slot {slot}[/green] (on-card).") + c.print(f"[green]Generated {alg} key in slot {slot}[/green] ({how}).") c.print(f" Public key SHA-256: {fingerprint}") if out_: c.print(f" Public key PEM -> {out_}") @@ -907,7 +921,49 @@ def _set_verifier(adm: PivAdmin, keys, ref: int, padded: bytes, *, label: str): return adm.send(perso_mod.set_verifier_value_apdu(ref, padded), context=f"SET {label}") -def _generate_key_on_card(adm: PivAdmin, keys, ref: int, mech: int, *, label: str): +class GeneratedKey(NamedTuple): + """An on-card generated public key and the path that produced it.""" + + #: An RSA or EC public key from ``cryptography``; untyped like every other + #: public-key value in this module (the two classes share no protocol). + public_key: Any + #: ``admin-channel`` or ``management-key``. + path: str + #: ``{"mechanism": "AES-256", "source": "$PIV_MGMT_KEY"}`` on the management-key + #: path, ``None`` on the admin channel. + management_key: dict[str, object] | None + + +def _parse_generated(mech: int, data: bytes, *, label: str, path: str): + """Parse a GENERATE response, turning a malformed template into a CLI error.""" + try: + return perso_mod.parse_public_key(mech, data) + except ValueError as exc: + raise CryptnoxError( + f"GENERATE key {label}: the card returned a public-key template this CLI " + f"cannot parse ({len(data)} bytes over the {path} path, SW=9000): {exc}. " + "Re-run with --verbose to capture the exchange." + ) from exc + + +def _generate_key_on_card( + app: AppContext, + adm: PivAdmin, + keys, + ref: int, + mech: int, + *, + label: str, + default_keys: bool = False, +) -> GeneratedKey: + """Generate a key pair on the card and return its public half. + + The admin channel is tried first and is the whole story on a card that can return + the full public-key template through it. A card that cannot says so in its own + response - success, but only as many bytes as were asked for, with the template's + own header declaring more - and only then does the generation repeat over plain + APDUs behind a PIV management key authentication. + """ adm.select() adm.open(keys) resp = adm.send(perso_mod.generate_keypair_apdu(ref, mech), context=f"GENERATE {label}") @@ -915,7 +971,109 @@ def _generate_key_on_card(adm: PivAdmin, keys, ref: int, mech: int, *, label: st raise CryptnoxError(f"GENERATE key {label} failed (SW=6F00): {_IMPORT_SW_HINTS[0x6F00]}.") if not resp.ok: raise StatusWordError(resp.sw1, resp.sw2, context=f"GENERATE key {label}") - return perso_mod.parse_public_key(mech, resp.data) + if perso_mod.generate_response_truncated(mech, resp): + return _generate_via_mgmt_key( + app, + adm, + ref, + mech, + label=label, + default_keys=default_keys, + received=len(resp.data), + declared=perso_mod.declared_template_bytes(resp.data), + ) + return GeneratedKey( + _parse_generated(mech, resp.data, label=label, path="admin-channel"), + "admin-channel", + None, + ) + + +def _atr_or_none(session) -> bytes | None: + """The session's ATR when the transport can supply one (diagnostic use only).""" + try: + return session.atr + except Exception: # noqa: BLE001 - a missing ATR only costs a less precise message + return None + + +def _generate_via_mgmt_key( + app: AppContext, + adm: PivAdmin, + ref: int, + mech: int, + *, + label: str, + default_keys: bool, + received: int, + declared: int | None, +) -> GeneratedKey: + """Repeat the generation over plain APDUs, authenticating the management key first. + + The card generated and stored a key pair on the truncated attempt already - the + applet builds the response only after generating - so that first key is gone + whatever happens here, and RSA generation runs twice on such a card. + """ + declared_text = f"{declared}" if declared is not None else "more" + app.out.warn( + f"the admin channel returned {received} of {declared_text} bytes of the public-key " + "template; completing the generation with the PIV management key (9B) over plain " + f"APDUs. The first attempt already replaced the key in slot {label}; the key that " + "remains is the one generated now." + ) + material = resolve_mgmt_key( + app.redactor, + default_keys=default_keys, + missing_message=( + f"The card returned {received} of {declared_text} bytes of the public-key " + "template over the admin channel, so this command must authenticate the PIV " + "management key (slot 9B) and repeat the generation over plain APDUs. Set " + f"${MGMT_KEY_ENV} (hex: 32, 48 or 64 characters for AES-128/192/256), or pass " + "--default-keys if this card still holds the published development value." + ), + ) + # A re-SELECT resets the card's secure channel, which is exactly the state the plain + # phase wants; the key-holder role granted afterwards survives every later command. + adm.select() + adm.forget_channel() + contactless = is_contactless_interface(adm.card.reader_name, _atr_or_none(adm.card)) + auth = mgmt_auth.authenticate(adm.card.transmit, material, contactless=contactless) + app.out.detail( + f"PIV management key 9B: {mgmt_auth.mechanism_name(auth.mechanism)}, mutual " + f"authentication, card verified (value from {auth.source})." + ) + + context = f"GENERATE {label} (management-key path)" + resp = adm.card.transmit(perso_mod.generate_keypair_apdu(ref, mech), context=context) + if resp.sw == 0x6F00 and keyimport.rsa_modulus_len(mech): + raise CryptnoxError(f"GENERATE key {label} failed (SW=6F00): {_IMPORT_SW_HINTS[0x6F00]}.") + if resp.sw == 0x6982: + raise StatusWordError( + resp.sw1, + resp.sw2, + context=( + f"GENERATE key {label} (management-key path; 9B authenticated, so slot " + f"{label}'s key object is bound to a different admin key than 9B)" + ), + ) + if not resp.ok: + raise StatusWordError( + resp.sw1, resp.sw2, context=f"GENERATE key {label} (management-key path)" + ) + if perso_mod.generate_response_truncated(mech, resp): + raise CryptnoxError( + f"GENERATE key {label}: the card truncated the response on the plain path as " + f"well ({len(resp.data)} bytes); this CLI has no further fallback." + ) + info: dict[str, object] = { + "mechanism": mgmt_auth.mechanism_name(auth.mechanism), + "source": auth.source, + } + return GeneratedKey( + _parse_generated(mech, resp.data, label=label, path="management-key"), + "management-key", + info, + ) def _import_cert_der(adm: PivAdmin, keys, ref: int, cert_der: bytes, *, label: str): @@ -2201,8 +2359,9 @@ def _collect_quickstart_facts(session, ref: int, mech: int) -> tuple[CardFacts, @click.option( "--default-keys", is_flag=True, - help="Use the default GlobalPlatform TEST keys " - "(publicly known - fine for dev/eval, never for deployment).", + help="Use the publicly known GlobalPlatform TEST keys for the admin channel, and " + "the same published value for the PIV management key (9B) if the card needs it " + "(fine for dev/eval, never for deployment).", ) @click.option( "--dry-run", "dry_run", is_flag=True, help="Detect, plan and show the steps; write nothing." @@ -2416,7 +2575,10 @@ def record(step: str, status: str, **extra: object) -> None: raise StatusWordError(resp.sw1, resp.sw2, context="SET PUK") record(planned_step.step, "ok", sw=resp.sw_hex()) elif planned_step.step == "generate-key": - public_key = _generate_key_on_card(adm, keys, ref, mech, label=slot) + generated = _generate_key_on_card( + app, adm, keys, ref, mech, label=slot, default_keys=default_keys + ) + public_key = generated.public_key spki = public_key.public_bytes( serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo ) @@ -2427,7 +2589,12 @@ def record(step: str, status: str, **extra: object) -> None: record( planned_step.step, "ok", - detail={"algorithm": alg, "public_key_sha256": fingerprint}, + detail={ + "algorithm": alg, + "public_key_sha256": fingerprint, + "generate_path": generated.path, + "management_key": generated.management_key, + }, ) elif planned_step.step == "certificate": if spki is None: # unreachable: generate-key runs whenever certificate runs diff --git a/src/cryptnox_id_cli/cli/dryrun.py b/src/cryptnox_id_cli/cli/dryrun.py index 9e6628d..b90fc84 100644 --- a/src/cryptnox_id_cli/cli/dryrun.py +++ b/src/cryptnox_id_cli/cli/dryrun.py @@ -115,6 +115,7 @@ "fido credential self-test", "fido pin change", "fido pin set", + "factory piv preperso set-mgmt-key", "fido reset", "mifare app create", "mifare app delete", diff --git a/src/cryptnox_id_cli/secrets/resolver.py b/src/cryptnox_id_cli/secrets/resolver.py index b95c526..b4ee779 100644 --- a/src/cryptnox_id_cli/secrets/resolver.py +++ b/src/cryptnox_id_cli/secrets/resolver.py @@ -7,8 +7,10 @@ prompt. Prefer the environment variable or the prompt for exactly that reason. SCP03 admin channel keys never come from the command line: ``--default-keys`` -(the publicly known GlobalPlatform test keys) or the three env vars only. Every -resolved secret is registered with the redactor before use. +(the publicly known GlobalPlatform test keys) or the three env vars only. The PIV +management key (9B) follows the same rule: ``--default-keys`` or ``$PIV_MGMT_KEY``, +never the command line. Every resolved secret is registered with the redactor +before use. """ from __future__ import annotations @@ -17,6 +19,7 @@ import os import sys +from cryptnox_id_cli.applets.piv.mgmt_auth import MgmtKeyMaterial, mechanism_for_key_length from cryptnox_id_cli.secrets.redaction import Redactor from cryptnox_id_cli.transport.errors import CryptnoxError from cryptnox_id_cli.transport.scp03 import Scp03Keys @@ -24,6 +27,11 @@ DEFAULT_GP_KEY = bytes.fromhex("404142434445464748494A4B4C4D4E4F") +#: Environment variable carrying the PIV management key (9B) value, as hex. +MGMT_KEY_ENV = "PIV_MGMT_KEY" +#: Accepted when the primary variable is unset (the name used by factory tooling). +MGMT_KEY_ENV_ALIAS = "CARD_PIV_MGMT_KEY" + class SecretInputError(CryptnoxError): """Raised when a required secret cannot be obtained safely.""" @@ -91,3 +99,49 @@ def resolve_scp03_keys( f"evaluation cards), or set ${enc_env}/${mac_env}/${dek_env} (hex) with the " "card's real keys." ) + + +def resolve_mgmt_key( + redactor: Redactor, + *, + default_keys: bool = False, + env_var: str = MGMT_KEY_ENV, + env_alias: str = MGMT_KEY_ENV_ALIAS, + missing_message: str | None = None, +) -> MgmtKeyMaterial: + """Resolve the PIV management key (9B) value, never from the command line. + + Order: ``--default-keys`` (the publicly known GlobalPlatform test value: as-is for + an AES-128 key object, doubled for AES-256; there is no published AES-192 value) + -> ``$PIV_MGMT_KEY`` (hex) -> ``$CARD_PIV_MGMT_KEY`` (hex) -> ``SecretInputError``. + A hex value must be 16, 24 or 32 bytes; its length selects the mechanism. Every + resolved value is registered for redaction before it is returned. + + ``missing_message`` replaces the "nothing supplied" text, so the caller can say why + the value is needed at the moment it turns out to be. + """ + if default_keys: + doubled = DEFAULT_GP_KEY + DEFAULT_GP_KEY + redactor.register(DEFAULT_GP_KEY) + redactor.register(doubled) + return MgmtKeyMaterial("--default-keys", {0x08: DEFAULT_GP_KEY, 0x0C: doubled}) + raw, source = os.environ.get(env_var), f"${env_var}" + if raw is None: + raw, source = os.environ.get(env_alias), f"${env_alias}" + if raw is not None: + value = from_hex(raw) + mechanism = mechanism_for_key_length(len(value)) + if mechanism is None: + raise SecretInputError( + f"{source} is {len(value)} bytes; the PIV management key must be 16, 24 " + "or 32 bytes (AES-128/192/256)." + ) + redactor.register(value) + return MgmtKeyMaterial(source, {mechanism: value}) + raise SecretInputError( + missing_message + or ( + f"PIV management key value required: pass --default-keys (development cards) " + f"or set ${env_var} (hex)." + ) + ) diff --git a/src/cryptnox_id_cli/util/tlv.py b/src/cryptnox_id_cli/util/tlv.py index 4f03293..252fc70 100644 --- a/src/cryptnox_id_cli/util/tlv.py +++ b/src/cryptnox_id_cli/util/tlv.py @@ -88,6 +88,23 @@ def parse(data: bytes | bytearray, *, recurse: bool = True) -> list[TLV]: return out +def peek(data: bytes | bytearray) -> tuple[int, int, int] | None: + """``(tag, declared length, header length)`` of the first TLV in ``data``. + + Returns ``None`` when the header itself is incomplete or uses a length form this + parser does not support. Never raises, and never checks that the value actually + fits the buffer - which is the point: it reads what a *truncated* buffer claims + its first object's size to be. + """ + buf = bytes(data) + try: + tag, off = _read_tag(buf, 0) + length, off = _read_len(buf, off) + except ValueError: + return None + return tag, length, off + + def find(tlvs: list[TLV], tag: int) -> TLV | None: """Depth-first search for the first TLV with ``tag``.""" for t in tlvs: diff --git a/tests/unit/test_cap_truncation.py b/tests/unit/test_cap_truncation.py new file mode 100644 index 0000000..bf8379b --- /dev/null +++ b/tests/unit/test_cap_truncation.py @@ -0,0 +1,124 @@ +"""The two response-layer behaviours, exercised through the real transport. + +test_generate_truncation.py pins the rule against hand-built responses. This one puts +a model of the card's response layer behind a real ``CardSession`` and sends the real +``generate_keypair_apdu``, so the 61xx reassembly loop, the shipped APDU builder and +the detector are all in the loop together. + +Limits, stated so nobody reads more into a pass: there is no secure-channel crypto +here, no key-object authorization, no GENERAL AUTHENTICATE, and therefore no evidence +that the plain path is *permitted* on any card - only that response framing behaves as +modelled. Response-MAC framing is deliberately not modelled; the CLI never asks for it. +""" + +from __future__ import annotations + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from cryptnox_id_cli.applets.piv import perso as perso_mod +from cryptnox_id_cli.transport.pcsc import CardSession +from cryptnox_id_cli.util import tlv + +RSA2048 = 0x07 +ECCP256 = 0x11 +MAX_RAPDU = 256 + + +class VirtualApplet: + """A RawConnection that answers GENERATE with a fixed template. + + ``chaining`` is the plain response path (and any card whose secured path chains): + it hands out at most 256 bytes at a time and says 61xx while bytes remain. + ``truncating`` is the secured path of a card that does not chain: it sends the + first 256 bytes, reports success, and drops the rest. + """ + + def __init__(self, template: bytes, *, chaining: bool) -> None: + self.template = template + self.chaining = chaining + self.pending = b"" + self.transcript: list[str] = [] + + def transmit(self, apdu: list[int]) -> tuple[list[int], int, int]: + raw = bytes(apdu) + self.transcript.append(raw.hex().upper()) + if raw[1] == 0xC0: # GET RESPONSE + return self._serve(self.pending) + if raw[1] == perso_mod.INS_GENERATE_ASYMMETRIC: + if not self.chaining: + return list(self.template[:MAX_RAPDU]), 0x90, 0x00 + return self._serve(self.template) + return [], 0x6A, 0x82 + + def _serve(self, remaining: bytes) -> tuple[list[int], int, int]: + chunk, self.pending = remaining[:MAX_RAPDU], remaining[MAX_RAPDU:] + if self.pending: + return list(chunk), 0x61, len(self.pending) & 0xFF + return list(chunk), 0x90, 0x00 + + def get_atr(self) -> bytes: + return bytes.fromhex("3BFA1300008131FE454A434F5033") + + def disconnect(self) -> None: + pass + + +@pytest.fixture(scope="module") +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def rsa_template(rsa_key) -> bytes: + numbers = rsa_key.public_key().public_numbers() + body = tlv.build(perso_mod.TAG_RSA_MODULUS, numbers.n.to_bytes(256, "big")) + tlv.build( + perso_mod.TAG_RSA_EXPONENT, numbers.e.to_bytes(3, "big") + ) + return tlv.build(perso_mod.TAG_PUBKEY_TEMPLATE, body) + + +@pytest.fixture(scope="module") +def ecc_template() -> bytes: + point = ( + ec.generate_private_key(ec.SECP256R1()) + .public_key() + .public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + ) + return tlv.build(perso_mod.TAG_PUBKEY_TEMPLATE, tlv.build(perso_mod.TAG_ECC_POINT, point)) + + +def _generate(template: bytes, *, chaining: bool, slot: int, mech: int): + applet = VirtualApplet(template, chaining=chaining) + session = CardSession(applet) + return applet, session.transmit(perso_mod.generate_keypair_apdu(slot, mech)) + + +def test_a_chaining_card_delivers_the_whole_template(rsa_template, rsa_key): + applet, resp = _generate(rsa_template, chaining=True, slot=0x9A, mech=RSA2048) + assert len(resp.data) == 270 and resp.ok + assert applet.transcript.count("00C000000E") == 1 + assert perso_mod.generate_response_truncated(RSA2048, resp) is False + parsed = perso_mod.parse_public_key(RSA2048, resp.data) + assert parsed.public_numbers() == rsa_key.public_key().public_numbers() + + +def test_a_truncating_card_looks_successful_and_is_not(rsa_template): + applet, resp = _generate(rsa_template, chaining=False, slot=0x9A, mech=RSA2048) + assert (len(resp.data), resp.sw) == (256, 0x9000) + assert not any(cmd.startswith("00C000") for cmd in applet.transcript), ( + "no 61xx was reported, so the transport had nothing to drain - the whole problem" + ) + assert perso_mod.generate_response_truncated(RSA2048, resp) is True + # This is the failure the fallback exists to prevent. + with pytest.raises(ValueError, match="extends beyond buffer"): + perso_mod.parse_public_key(RSA2048, resp.data) + + +@pytest.mark.parametrize("chaining", [True, False]) +def test_ecc_is_unaffected_on_either_response_path(ecc_template, chaining): + _, resp = _generate(ecc_template, chaining=chaining, slot=0x9A, mech=ECCP256) + assert resp.ok and len(resp.data) == 70 + assert perso_mod.generate_response_truncated(ECCP256, resp) is False + assert perso_mod.parse_public_key(ECCP256, resp.data).curve.name == "secp256r1" diff --git a/tests/unit/test_generate_key_fallback.py b/tests/unit/test_generate_key_fallback.py new file mode 100644 index 0000000..2864035 --- /dev/null +++ b/tests/unit/test_generate_key_fallback.py @@ -0,0 +1,335 @@ +"""`_generate_key_on_card`: which path it takes, and what it sends on each. + +The seam is shared by `piv perso generate-key` and `piv quickstart`, so both inherit +whatever this helper does. Two properties matter most and are asserted directly: a +card that returns the full template must behave exactly as before (no extra APDU, no +environment read, no console line), and a card that truncates must SELECT, authenticate +the management key, and repeat the generation - in that order, with nothing in between +the two halves of the authentication. +""" + +from __future__ import annotations + +import contextlib + +import pytest +from click.testing import CliRunner +from cryptography.hazmat.primitives.asymmetric import rsa + +from cryptnox_id_cli.applets.piv import mgmt_auth as ma +from cryptnox_id_cli.applets.piv import perso as perso_mod +from cryptnox_id_cli.cli.commands import piv as piv_cmd +from cryptnox_id_cli.cli.context import AppContext +from cryptnox_id_cli.cli.main import main as root +from cryptnox_id_cli.transport.apdu import Response +from cryptnox_id_cli.util import tlv + +RSA2048 = 0x07 +KEY256 = bytes(range(32)) +WITNESS = bytes.fromhex("A7E57B882467107902739D50387B3651") + + +@pytest.fixture(scope="module") +def rsa_key(): + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +@pytest.fixture(scope="module") +def template(rsa_key) -> bytes: + numbers = rsa_key.public_key().public_numbers() + body = tlv.build(perso_mod.TAG_RSA_MODULUS, numbers.n.to_bytes(256, "big")) + tlv.build( + perso_mod.TAG_RSA_EXPONENT, numbers.e.to_bytes(3, "big") + ) + return tlv.build(perso_mod.TAG_PUBKEY_TEMPLATE, body) + + +class FakeSession: + """The plain side of the card: SELECT, the 9B exchange, and plain GENERATE.""" + + reader_name = "Fake Contact Reader 00 00" + + def __init__(self, template: bytes, *, key: bytes = KEY256, mechanism: int = 0x0C) -> None: + self.template = template + self.key = key + self.mechanism = mechanism + self.sent: list[str] = [] + #: overrides, keyed by INS, applied before the default behaviour + self.generate_response: Response | None = None + self.witness_sw: int | None = None + self.mutual_sw: int | None = None + + @property + def atr(self) -> bytes: + return bytes.fromhex("3BFA1300008131FE454A434F5033") + + def transmit(self, apdu, *, context: str | None = None) -> Response: + self.sent.append(apdu.to_bytes().hex().upper()) + if apdu.ins == 0xA4: + return Response(b"", 0x90, 0x00) + if apdu.ins == 0x87: + return self._general_authenticate(apdu) + if apdu.ins == perso_mod.INS_GENERATE_ASYMMETRIC: + if self.generate_response is not None: + return self.generate_response + return Response(self.template, 0x90, 0x00) + return Response(b"", 0x6A, 0x82) + + def _general_authenticate(self, apdu) -> Response: + if apdu.p1 != self.mechanism: + return Response(b"", 0x6A, 0x86) + fields = {c.tag: c.value for c in tlv.parse(apdu.data)[0].children} + if set(fields) == {ma.TAG_WITNESS}: + if self.witness_sw is not None: + return Response(b"", self.witness_sw >> 8, self.witness_sw & 0xFF) + body = tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_WITNESS, ma.aes_ecb_encrypt(self.key, WITNESS)), + ) + return Response(body, 0x90, 0x00) + if self.mutual_sw is not None: + return Response(b"", self.mutual_sw >> 8, self.mutual_sw & 0xFF) + body = tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_RESPONSE, ma.aes_ecb_encrypt(self.key, fields[ma.TAG_CHALLENGE])), + ) + return Response(body, 0x90, 0x00) + + +class FakeAdmin: + """The admin-channel side: records the session lifecycle, answers GENERATE.""" + + def __init__(self, session: FakeSession, secured_response: Response) -> None: + self.card = session + self.secured_response = secured_response + self.events: list[str] = [] + + def select(self) -> None: + self.events.append("select") + self.card.transmit(_select_apdu()) + + def open(self, keys) -> None: + self.events.append("open") + + def forget_channel(self) -> None: + self.events.append("forget_channel") + + def send(self, apdu, *, context: str | None = None) -> Response: + self.events.append(f"send:{apdu.ins:02X}") + return self.secured_response + + +def _select_apdu(): + from cryptnox_id_cli.applets.piv import constants as pivc + from cryptnox_id_cli.transport.apdu import APDU + + return APDU(0x00, pivc.INS_SELECT, 0x04, 0x00, data=pivc.PIV_AID, le=256) + + +def _app(**kw) -> AppContext: + return AppContext(**kw) + + +def _generate(adm, app, **kw): + return piv_cmd._generate_key_on_card(app, adm, object(), 0x9A, RSA2048, label="9A", **kw) + + +@pytest.fixture(autouse=True) +def _no_inherited_env(monkeypatch): + monkeypatch.delenv("PIV_MGMT_KEY", raising=False) + monkeypatch.delenv("CARD_PIV_MGMT_KEY", raising=False) + + +# ------------------------------------------------------- field-card behaviour -- +def test_a_card_that_returns_the_full_template_is_untouched(template, rsa_key, capsys): + session = FakeSession(template) + adm = FakeAdmin(session, Response(template, 0x90, 0x00)) + generated = _generate(adm, _app()) + + assert generated.path == "admin-channel" + assert generated.management_key is None + assert generated.public_key.public_numbers() == rsa_key.public_key().public_numbers() + assert adm.events == ["select", "open", "send:47"] + assert [cmd[:8] for cmd in session.sent] == ["00A40400"], "only the admin SELECT was sent" + assert "management key" not in capsys.readouterr().out + + +def test_ecc_never_takes_the_fallback(template): + # A 256-byte success on an ECC mechanism cannot be truncation; it is a parse error. + session = FakeSession(template) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(piv_cmd.CryptnoxError, match="cannot parse"): + piv_cmd._generate_key_on_card(_app(), adm, object(), 0x9A, 0x11, label="9A") + assert adm.events == ["select", "open", "send:47"] + + +# ------------------------------------------------------------- happy fallback -- +def test_the_fallback_selects_authenticates_and_regenerates(template, rsa_key, monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + generated = _generate(adm, _app()) + + assert generated.path == "management-key" + assert generated.management_key == {"mechanism": "AES-256", "source": "$PIV_MGMT_KEY"} + assert generated.public_key.public_numbers() == rsa_key.public_key().public_numbers() + assert adm.events == ["select", "open", "send:47", "select", "forget_channel"] + # SELECT, witness request, mutual response, plain GENERATE - in that order, with + # nothing between the two halves of the authentication. + assert [cmd[:8] for cmd in session.sent] == [ + "00A40400", # the admin channel's own SELECT + "00A40400", # the fallback's SELECT, which resets the card's channel + "00870C9B", + "00870C9B", + "0047009A", + ] + + +def test_the_operator_is_told_the_first_key_was_already_replaced(template, monkeypatch, capsys): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + adm = FakeAdmin(FakeSession(template), Response(template[:256], 0x90, 0x00)) + _generate(adm, _app()) + warning = capsys.readouterr().out + assert "256 of 270" in warning + assert "already replaced the key in slot 9A" in warning + + +def test_default_keys_supplies_the_published_development_value(template): + from cryptnox_id_cli.secrets.resolver import DEFAULT_GP_KEY + + session = FakeSession(template, key=DEFAULT_GP_KEY * 2) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + generated = _generate(adm, _app(), default_keys=True) + assert generated.management_key == {"mechanism": "AES-256", "source": "--default-keys"} + + +# ----------------------------------------------------------------- refusals --- +def test_no_key_material_says_how_to_supply_it(template): + from cryptnox_id_cli.secrets.resolver import SecretInputError + + session = FakeSession(template) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(SecretInputError) as excinfo: + _generate(adm, _app()) + assert "PIV_MGMT_KEY" in str(excinfo.value) + assert "--default-keys" in str(excinfo.value) + assert not any(cmd.startswith("0087") for cmd in session.sent) + + +def test_an_empty_9b_names_the_command_that_loads_a_value(template, monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + session.witness_sw = 0x6983 + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _generate(adm, _app()) + assert excinfo.value.stage == "empty" + assert excinfo.value.exit_code == 7 + assert "set-mgmt-key" in str(excinfo.value) + assert not any(cmd.startswith("0047") for cmd in session.sent) + + +def test_a_wrong_management_key_is_reported_as_such(template, monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + session.mutual_sw = 0x6982 + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _generate(adm, _app()) + assert excinfo.value.stage == "key_mismatch" + + +def test_a_refused_plain_generate_surfaces_the_status_word(template, monkeypatch): + from cryptnox_id_cli.transport.errors import StatusWordError + + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + session.generate_response = Response(b"", 0x6A, 0x80) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(StatusWordError, match="management-key path"): + _generate(adm, _app()) + + +def test_6982_on_the_plain_generate_names_the_admin_key_binding(template, monkeypatch): + from cryptnox_id_cli.transport.errors import StatusWordError + + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + session.generate_response = Response(b"", 0x69, 0x82) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(StatusWordError, match="different admin key"): + _generate(adm, _app()) + + +def test_a_still_truncated_plain_response_stops_instead_of_looping(template, monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + session.generate_response = Response(template[:256], 0x90, 0x00) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + with pytest.raises(piv_cmd.CryptnoxError, match="no further fallback"): + _generate(adm, _app()) + + +def test_an_unparseable_template_is_a_cli_error_not_a_traceback(): + adm = FakeAdmin(FakeSession(b""), Response(bytes.fromhex("7F4903810100"), 0x90, 0x00)) + with pytest.raises(piv_cmd.CryptnoxError) as excinfo: + _generate(adm, _app()) + assert excinfo.value.to_dict()["error"] == "error" + assert "6 bytes over the admin-channel path" in str(excinfo.value) + + +# ------------------------------------------------------------ command level --- +def test_the_json_payload_reports_the_path_and_the_management_key(template, monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + session = FakeSession(template) + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + + @contextlib.contextmanager + def fake_session(self): + yield session + + monkeypatch.setattr(AppContext, "open_session", fake_session) + monkeypatch.setattr(piv_cmd, "_select", lambda s: object()) + monkeypatch.setattr(piv_cmd, "_read_cert_der", lambda piv, name: None) + monkeypatch.setattr(piv_cmd, "PivAdmin", lambda s: adm) + for var in ("PIV_SCP03_ENC", "PIV_SCP03_MAC", "PIV_SCP03_DEK"): + monkeypatch.setenv(var, "40" * 16) # the admin channel is stubbed out anyway + + result = CliRunner().invoke( + root, + ["--json", "piv", "perso", "generate-key", "--slot", "9A", "--algorithm", "RSA2048"], + ) + assert result.exit_code == 0, result.output + import json + + payload = json.loads(result.stdout) + assert payload["generate_path"] == "management-key" + assert payload["management_key"] == {"mechanism": "AES-256", "source": "$PIV_MGMT_KEY"} + + +def test_the_exit_code_for_a_management_key_failure_is_seven(template, monkeypatch): + session = FakeSession(template) + session.witness_sw = 0x6983 + adm = FakeAdmin(session, Response(template[:256], 0x90, 0x00)) + + @contextlib.contextmanager + def fake_session(self): + yield session + + monkeypatch.setenv("PIV_MGMT_KEY", KEY256.hex()) + monkeypatch.setattr(AppContext, "open_session", fake_session) + monkeypatch.setattr(piv_cmd, "_select", lambda s: object()) + monkeypatch.setattr(piv_cmd, "_read_cert_der", lambda piv, name: None) + monkeypatch.setattr(piv_cmd, "PivAdmin", lambda s: adm) + for var in ("PIV_SCP03_ENC", "PIV_SCP03_MAC", "PIV_SCP03_DEK"): + monkeypatch.setenv(var, "40" * 16) # the admin channel is stubbed out anyway + + result = CliRunner().invoke( + root, + ["--json", "piv", "perso", "generate-key", "--slot", "9A", "--algorithm", "RSA2048"], + ) + assert result.exit_code == 7 + import json + + error = json.loads(result.stdout or result.output) + assert error["error"] == "mgmt_key" + assert error["stage"] == "empty" diff --git a/tests/unit/test_generate_key_gate.py b/tests/unit/test_generate_key_gate.py index fb73797..a844fbb 100644 --- a/tests/unit/test_generate_key_gate.py +++ b/tests/unit/test_generate_key_gate.py @@ -32,9 +32,10 @@ def fake_session(self): piv_cmd, "_read_cert_der", lambda piv, name: b"\x30\x03" if cert_present else None ) - def fake_generate(adm, keys, ref, mech, *, label): + def fake_generate(app, adm, keys, ref, mech, *, label, default_keys=False): calls["generated"] = True - return ec.generate_private_key(ec.SECP256R1()).public_key() + public_key = ec.generate_private_key(ec.SECP256R1()).public_key() + return piv_cmd.GeneratedKey(public_key, "admin-channel", None) monkeypatch.setattr(piv_cmd, "_generate_key_on_card", fake_generate) return calls diff --git a/tests/unit/test_generate_truncation.py b/tests/unit/test_generate_truncation.py new file mode 100644 index 0000000..6636191 --- /dev/null +++ b/tests/unit/test_generate_truncation.py @@ -0,0 +1,92 @@ +"""The rule that decides a GENERATE response was cut short. + +Getting this wrong in either direction is expensive: a false positive sends a working +card down a fallback that needs a key value it may not have, and a false negative is +the original failure (a partial template parsed as if complete). The rule therefore +reads only the card's own response - mechanism, status word, length, and the total the +template's own header declares - and every clause is pinned here. +""" + +from __future__ import annotations + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric import ec, rsa + +from cryptnox_id_cli.applets.piv import perso as perso_mod +from cryptnox_id_cli.transport.apdu import Response +from cryptnox_id_cli.util import tlv + +RSA2048 = 0x07 +ECCP256 = 0x11 + + +@pytest.fixture(scope="module") +def rsa_template() -> bytes: + """A real 270-byte 7F49 template, as the applet builds it for RSA-2048.""" + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + numbers = key.public_key().public_numbers() + body = tlv.build(perso_mod.TAG_RSA_MODULUS, numbers.n.to_bytes(256, "big")) + tlv.build( + perso_mod.TAG_RSA_EXPONENT, numbers.e.to_bytes(3, "big") + ) + template = tlv.build(perso_mod.TAG_PUBKEY_TEMPLATE, body) + assert len(template) == 270 + return template + + +@pytest.fixture(scope="module") +def ecc_template() -> bytes: + point = ( + ec.generate_private_key(ec.SECP256R1()) + .public_key() + .public_bytes(serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint) + ) + template = tlv.build(perso_mod.TAG_PUBKEY_TEMPLATE, tlv.build(perso_mod.TAG_ECC_POINT, point)) + assert len(template) == 70 + return template + + +def _resp(data: bytes, sw: int = 0x9000) -> Response: + return Response(data, sw >> 8, sw & 0xFF) + + +def test_a_complete_template_is_not_truncated(rsa_template): + assert perso_mod.generate_response_truncated(RSA2048, _resp(rsa_template)) is False + assert perso_mod.parse_public_key(RSA2048, rsa_template).key_size == 2048 + + +def test_a_256_byte_prefix_of_a_270_byte_template_is_truncated(rsa_template): + cut = rsa_template[:256] + assert perso_mod.generate_response_truncated(RSA2048, _resp(cut)) is True + assert perso_mod.declared_template_bytes(cut) == 270 + + +@pytest.mark.parametrize("sw", [0x6A80, 0x6F00, 0x6982]) +def test_a_failed_response_is_never_truncation(rsa_template, sw): + assert perso_mod.generate_response_truncated(RSA2048, _resp(rsa_template[:256], sw)) is False + + +def test_ecc_never_triggers_the_rule(ecc_template): + # Even padded to exactly the cut length - the mechanism clause settles it, and an + # ECC template is far too small to reach 256 bytes on any card. + padded = ecc_template + bytes(256 - len(ecc_template)) + assert perso_mod.generate_response_truncated(ECCP256, _resp(padded)) is False + assert perso_mod.generate_response_truncated(ECCP256, _resp(ecc_template)) is False + + +def test_a_response_that_is_not_a_public_key_template_is_not_truncation(): + assert perso_mod.generate_response_truncated(RSA2048, _resp(bytes(256))) is False + assert perso_mod.declared_template_bytes(bytes(256)) is None + + +def test_a_header_declaring_exactly_the_received_length_is_not_truncation(): + # 7F49 82 00 FB + 251 bytes = 256 total: complete, and the comparison is strict. + body = bytes.fromhex("7F498200FB") + bytes(251) + assert len(body) == 256 + assert perso_mod.declared_template_bytes(body) == 256 + assert perso_mod.generate_response_truncated(RSA2048, _resp(body)) is False + + +@pytest.mark.parametrize("length", [255, 257]) +def test_only_a_cut_at_the_requested_length_counts(rsa_template, length): + assert perso_mod.generate_response_truncated(RSA2048, _resp(rsa_template[:length])) is False diff --git a/tests/unit/test_mgmt_auth.py b/tests/unit/test_mgmt_auth.py new file mode 100644 index 0000000..1c5b440 --- /dev/null +++ b/tests/unit/test_mgmt_auth.py @@ -0,0 +1,161 @@ +"""Wire grammar and crypto for PIV management key (9B) authentication. + +Every byte of the 9B exchange is fixed by the applet's case dispatch: it selects +GENERAL AUTHENTICATE case 4 or case 5 purely from which tags are present and whether +they carry data, so a stray tag or an off-by-one length silently becomes a different +command. These vectors pin the bytes the way test_perso.py pins GENERATE. +""" + +from __future__ import annotations + +import pytest + +from cryptnox_id_cli.applets.piv import keyimport +from cryptnox_id_cli.applets.piv import mgmt_auth as ma +from cryptnox_id_cli.applets.piv import perso as perso_mod +from cryptnox_id_cli.util import tlv + + +def _hex(apdu) -> str: + return apdu.to_bytes().hex().upper() + + +# --------------------------------------------------------------------- APDUs -- +def test_witness_request_bytes(): + assert _hex(ma.witness_request_apdu(0x0C)) == "00870C9B047C02800000" + assert _hex(ma.witness_request_apdu(0x08)) == "0087089B047C02800000" + + +def test_mutual_response_bytes(): + witness = bytes(range(16)) + challenge = bytes(range(16, 32)) + assert _hex(ma.mutual_response_apdu(0x0C, witness, challenge)) == ( + "00870C9B267C248010000102030405060708090A0B0C0D0E0F8110101112131415161718191A1B1C1D1E1F00" + ) + + +def test_clear_value_bytes(): + assert _hex(ma.clear_value_apdu(0x0C)) == "00240C9B029F00" + + +def test_set_value_bytes_per_mechanism(): + assert _hex(ma.set_value_apdu(0x0C, bytes(32))) == "00240C9B228020" + "00" * 32 + assert _hex(ma.set_value_apdu(0x08, bytes(16))) == "0024089B128010" + "00" * 16 + assert _hex(ma.set_value_apdu(0x0A, bytes(24))) == "00240A9B1A8018" + "00" * 24 + + +def test_set_value_refuses_a_length_the_mechanism_cannot_hold(): + with pytest.raises(ma.MgmtKeyError, match="32-byte value"): + ma.set_value_apdu(0x0C, bytes(16)) + + +def test_set_value_refuses_a_non_aes_mechanism(): + with pytest.raises(ma.MgmtKeyError, match="not an AES mechanism"): + ma.set_value_apdu(0x07, bytes(32)) + + +# --------------------------------------------------------------------- crypto -- +# FIPS-197 Appendix C vectors: the authentication is a single raw AES block. +_BLOCK = bytes.fromhex("00112233445566778899AABBCCDDEEFF") + + +def test_aes_ecb_single_block_matches_fips_197(): + key128 = bytes.fromhex("000102030405060708090A0B0C0D0E0F") + key256 = bytes(range(32)) + assert ma.aes_ecb_encrypt(key128, _BLOCK).hex().upper() == "69C4E0D86A7B0430D8CDB78070B4C55A" + assert ma.aes_ecb_encrypt(key256, _BLOCK).hex().upper() == "8EA2B7CA516745BFEAFC49904B496089" + for key in (key128, key256): + assert ma.aes_ecb_decrypt(key, ma.aes_ecb_encrypt(key, _BLOCK)) == _BLOCK + + +def test_aes_ecb_refuses_anything_but_one_block(): + with pytest.raises(ma.MgmtKeyError, match="16-byte block"): + ma.aes_ecb_encrypt(bytes(16), bytes(15)) + + +# -------------------------------------------------------------------- parsing -- +def test_auth_field_reads_a_block_out_of_the_template(): + assert ma.auth_field(bytes.fromhex("7C128010" + "00" * 16), ma.TAG_WITNESS) == bytes(16) + + +@pytest.mark.parametrize( + "response", + [ + "8010" + "00" * 16, # no 7C template at all + "7C12" + "8110" + "00" * 16, # 7C present, but the block is under tag 81 + "7C11" + "800F" + "00" * 15, # 7C/80 present, 15 bytes where a block is due + ], +) +def test_auth_field_rejects_every_other_shape(response): + with pytest.raises(ma.MgmtKeyError) as excinfo: + ma.auth_field(bytes.fromhex(response), ma.TAG_WITNESS) + assert excinfo.value.stage == "protocol" + + +# ------------------------------------------------------------------- material -- +def test_mechanism_for_key_length(): + assert ma.mechanism_for_key_length(16) == 0x08 + assert ma.mechanism_for_key_length(24) == 0x0A + assert ma.mechanism_for_key_length(32) == 0x0C + assert ma.mechanism_for_key_length(15) is None + + +def test_probe_order_starts_with_what_the_material_can_serve(): + explicit_128 = ma.MgmtKeyMaterial("$PIV_MGMT_KEY", {0x08: bytes(16)}) + assert explicit_128.mechanisms() == (0x08, 0x0C, 0x0A) + default = ma.MgmtKeyMaterial("--default-keys", {0x08: bytes(16), 0x0C: bytes(32)}) + assert default.mechanisms() == (0x0C, 0x08, 0x0A) + + +def test_key_for_a_mechanism_the_material_cannot_serve_points_at_the_env_var(): + default = ma.MgmtKeyMaterial("--default-keys", {0x08: bytes(16), 0x0C: bytes(32)}) + with pytest.raises(ma.MgmtKeyError) as excinfo: + default.key_for(0x0A) + assert excinfo.value.stage == "key_length" + assert "$PIV_MGMT_KEY" in str(excinfo.value) + assert "48 hex characters" in str(excinfo.value) + + +def test_key_for_names_both_lengths_when_an_explicit_value_does_not_fit(): + material = ma.MgmtKeyMaterial("$PIV_MGMT_KEY", {0x0C: bytes(32)}) + with pytest.raises(ma.MgmtKeyError) as excinfo: + material.key_for(0x08) + assert excinfo.value.stage == "key_length" + assert "16-byte" in str(excinfo.value) and "32 bytes" in str(excinfo.value) + + +def test_error_serialises_stage_and_status_word(): + err = ma.MgmtKeyError("nope", stage="empty", sw=0x6983) + assert err.to_dict() == { + "error": "mgmt_key", + "message": "nope", + "stage": "empty", + "sw": "6983", + } + assert ma.MgmtKeyError("nope", stage="protocol").to_dict()["sw"] is None + + +# ---------------------------------------------------------------- agreements -- +def test_mechanism_tables_agree_with_the_rest_of_the_applet_code(): + # The detector gates on RSA; _RSA_MODULUS_LEN is keyimport's private table of the + # same mechanisms. Reading it deliberately: two copies that drift are the bug. + assert set(keyimport._RSA_MODULUS_LEN) == perso_mod.RSA_MECHANISMS + assert set(ma.AES_MECHANISMS) == {0x08, 0x0A, 0x0C} + assert set(ma.MECHANISM_PROBE_ORDER) == set(ma.AES_MECHANISMS) + + +def test_mechanism_name_uses_the_applet_algorithm_table(): + assert ma.mechanism_name(0x0C) == "AES-256" + assert ma.mechanism_name(0xFE) == "0xfe" + + +# -------------------------------------------------------------------- tlv.peek -- +def test_peek_reads_a_declared_length_from_a_header_alone(): + assert tlv.peek(bytes.fromhex("7F49820109") + bytes(251)) == (0x7F49, 265, 5) + assert tlv.peek(bytes.fromhex("7F4981FF") + bytes(255)) == (0x7F49, 255, 4) + assert tlv.peek(bytes.fromhex("8010") + bytes(16)) == (0x80, 16, 2) + + +def test_peek_returns_none_on_an_unreadable_header(): + assert tlv.peek(bytes.fromhex("7F498201")) is None # cut inside the length field + assert tlv.peek(b"") is None diff --git a/tests/unit/test_mgmt_auth_flow.py b/tests/unit/test_mgmt_auth_flow.py new file mode 100644 index 0000000..bc79b78 --- /dev/null +++ b/tests/unit/test_mgmt_auth_flow.py @@ -0,0 +1,218 @@ +"""The 9B mutual-authentication driver against a loop-back card. + +The applet decides in a fixed order - does the (key reference, mechanism) pair exist, +is it accessible on this interface, does it hold a value, does its role and attribute +bitmap permit this flow - and each answer is a different status word. The CLI's whole +diagnostic value on this path is turning those into a sentence naming the fix, so the +fake card below reproduces that ordering exactly and every branch is exercised. + +Mirrors the loop-back style of test_scp02.py: an independent inline model of the card +side, driven through the real driver with an injected host challenge. +""" + +from __future__ import annotations + +import pytest + +from cryptnox_id_cli.applets.piv import mgmt_auth as ma +from cryptnox_id_cli.transport.apdu import APDU, Response +from cryptnox_id_cli.transport.errors import StatusWordError +from cryptnox_id_cli.util import tlv + +ATTR_PERMIT_EXTERNAL = 0x04 +ATTR_PERMIT_MUTUAL = 0x08 +ATTR_IMPORTABLE = 0x10 + +WITNESS = bytes.fromhex("A7E57B882467107902739D50387B3651") +CHALLENGE = bytes(range(16, 32)) +KEY256 = bytes(range(32)) +KEY128 = bytes(range(16)) + + +class FakeMgmtCard: + """A 9B key object with the applet's precondition ordering, and nothing else.""" + + def __init__( + self, + *, + key: bytes = KEY256, + mechanism: int = 0x0C, + attributes: int = ATTR_IMPORTABLE | ATTR_PERMIT_MUTUAL, + initialised: bool = True, + accessible: bool = True, + card_key: bytes | None = None, + ) -> None: + self.key = key + #: what the card actually enciphers with; differs from ``key`` to model a card + #: that answers 9000 but does not hold the host's value. + self.card_key = card_key if card_key is not None else key + self.mechanism = mechanism + self.attributes = attributes + self.initialised = initialised + self.accessible = accessible + self.commands: list[str] = [] + + def transmit(self, apdu: APDU, *, context: str | None = None) -> Response: + self.commands.append(apdu.to_bytes().hex().upper()) + if apdu.p2 != 0x9B or apdu.p1 != self.mechanism: + return Response(b"", 0x6A, 0x86) + if not self.accessible: + return Response(b"", 0x69, 0x82) + if not self.initialised: + return Response(b"", 0x69, 0x83) + nodes = tlv.parse(apdu.data) + template = tlv.find(nodes, ma.TAG_DYNAMIC_AUTH) + assert template is not None + fields = {child.tag: child.value for child in template.children} + if set(fields) == {ma.TAG_WITNESS} and not fields[ma.TAG_WITNESS]: + return self._case4() + if set(fields) == {ma.TAG_WITNESS, ma.TAG_CHALLENGE}: + return self._case5(fields[ma.TAG_WITNESS], fields[ma.TAG_CHALLENGE]) + return Response(b"", 0x6A, 0x80) + + def _case4(self) -> Response: + if not self.attributes & ATTR_PERMIT_MUTUAL: + return Response(b"", 0x69, 0x85) + body = tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_WITNESS, ma.aes_ecb_encrypt(self.card_key, WITNESS)), + ) + return Response(body, 0x90, 0x00) + + def _case5(self, witness: bytes, challenge: bytes) -> Response: + if witness != WITNESS: + return Response(b"", 0x69, 0x82) + body = tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_RESPONSE, ma.aes_ecb_encrypt(self.card_key, challenge)), + ) + return Response(body, 0x90, 0x00) + + +def _material(source: str = "$PIV_MGMT_KEY", **keys: bytes) -> ma.MgmtKeyMaterial: + return ma.MgmtKeyMaterial(source, {int(k[1:], 16): v for k, v in keys.items()}) + + +DEFAULT_MATERIAL = ma.MgmtKeyMaterial("--default-keys", {0x08: KEY128, 0x0C: KEY256}) + + +def _auth(card: FakeMgmtCard, material: ma.MgmtKeyMaterial, **kw): + return ma.authenticate(card.transmit, material, challenge=CHALLENGE, **kw) + + +# --------------------------------------------------------------------- happy -- +def test_mutual_authentication_sends_exactly_two_frames_back_to_back(): + card = FakeMgmtCard() + auth = _auth(card, _material(m0C=KEY256)) + assert auth == ma.MgmtAuth(mechanism=0x0C, source="$PIV_MGMT_KEY") + assert len(card.commands) == 2, "an extra command between the two halves resets the card" + assert card.commands[0] == "00870C9B047C02800000" + # The second frame must carry the deciphered witness the card is waiting for. + assert WITNESS.hex().upper() in card.commands[1] + assert CHALLENGE.hex().upper() in card.commands[1] + + +def test_a_card_whose_9b_is_aes_128_is_found_by_probing_past_6a86(): + card = FakeMgmtCard(key=KEY128, mechanism=0x08) + auth = _auth(card, DEFAULT_MATERIAL) + assert auth.mechanism == 0x08 + # AES-256 first (the built-in profiles' shape), rejected, then AES-128. + assert len(card.commands) == 3 + assert card.commands[0].startswith("00870C9B") + assert card.commands[1].startswith("0087089B") + + +# ------------------------------------------------------------ card-state rows -- +def test_no_key_object_at_all_names_every_mechanism_probed(): + card = FakeMgmtCard(mechanism=0x99) # matches no candidate + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, DEFAULT_MATERIAL) + assert excinfo.value.stage == "missing" + assert excinfo.value.to_dict()["sw"] == "6A86" + for name in ("AES-256", "AES-128", "AES-192"): + assert name in str(excinfo.value) + assert len(card.commands) == 3 + + +def test_an_object_without_a_value_points_at_the_command_that_loads_one(): + card = FakeMgmtCard(initialised=False) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=KEY256)) + assert excinfo.value.stage == "empty" + assert excinfo.value.to_dict()["sw"] == "6983" + assert "set-mgmt-key" in str(excinfo.value) + assert len(card.commands) == 1, "no case 5 may follow a refused witness request" + + +def test_an_object_permitting_only_external_authentication_is_reported_as_unusable(): + card = FakeMgmtCard(attributes=ATTR_IMPORTABLE | ATTR_PERMIT_EXTERNAL) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=KEY256)) + assert excinfo.value.stage == "unusable" + assert "PERMIT_MUTUAL" in str(excinfo.value) + + +@pytest.mark.parametrize( + ("contactless", "fragment"), + [(True, "contactless (PICC) interface"), (False, "use the contact reader")], +) +def test_access_refusal_shapes_its_advice_around_the_interface(contactless, fragment): + card = FakeMgmtCard(accessible=False) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=KEY256), contactless=contactless) + assert excinfo.value.stage == "access" + assert fragment in str(excinfo.value) + + +def test_an_unexpected_status_word_falls_through_to_the_status_word_error(): + card = FakeMgmtCard() + card.transmit = lambda apdu, **kw: Response(b"", 0x6F, 0x00) # type: ignore[method-assign] + with pytest.raises(StatusWordError, match="9B witness request"): + _auth(card, _material(m0C=KEY256)) + + +# --------------------------------------------------------------- key mismatch -- +def test_the_wrong_host_key_is_reported_as_a_key_mismatch(): + card = FakeMgmtCard(key=KEY256) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=bytes(32))) + assert excinfo.value.stage == "key_mismatch" + assert "$PIV_MGMT_KEY" in str(excinfo.value) + + +def test_a_card_that_accepts_the_witness_but_answers_wrongly_fails_verification(): + # The card takes the host's witness (so it knows the key the host used) but + # enciphers the challenge under a different one: only the host's check catches it. + card = FakeMgmtCard(key=KEY256) + card._case5 = lambda witness, challenge: Response( # type: ignore[method-assign] + tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_RESPONSE, ma.aes_ecb_encrypt(bytes(32), bytes(16))), + ), + 0x90, + 0x00, + ) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=KEY256)) + assert excinfo.value.stage == "card_verify" + + +def test_a_value_of_the_wrong_length_is_caught_before_any_crypto_runs(): + card = FakeMgmtCard(key=KEY128, mechanism=0x08) + with pytest.raises(ma.MgmtKeyError) as excinfo: + _auth(card, _material(m0C=KEY256)) + assert excinfo.value.stage == "key_length" + assert "16-byte" in str(excinfo.value) and "32 bytes" in str(excinfo.value) + assert len(card.commands) == 2, "probe found AES-128 at the second try; no case 5 followed" + + +# --------------------------------------------------------------------- probe -- +def test_probe_reports_the_card_state_without_completing_the_exchange(): + assert ma.probe(FakeMgmtCard(mechanism=0x99).transmit, ma.MECHANISM_PROBE_ORDER) == ( + ma.MgmtKeyProbe(mechanism=None, sw=0x6A86, witness=None) + ) + empty = ma.probe(FakeMgmtCard(initialised=False).transmit, ma.MECHANISM_PROBE_ORDER) + assert (empty.mechanism, empty.sw, empty.witness) == (0x0C, 0x6983, None) + ready = ma.probe(FakeMgmtCard().transmit, ma.MECHANISM_PROBE_ORDER) + assert (ready.mechanism, ready.sw) == (0x0C, 0x9000) + assert ready.witness == ma.aes_ecb_encrypt(KEY256, WITNESS) diff --git a/tests/unit/test_mgmt_key_resolver.py b/tests/unit/test_mgmt_key_resolver.py new file mode 100644 index 0000000..053bc67 --- /dev/null +++ b/tests/unit/test_mgmt_key_resolver.py @@ -0,0 +1,87 @@ +"""Where the PIV management key (9B) value may come from, and where it may not. + +Same rule as the admin-channel keys: a flag for the published development value, an +environment variable for a real one, never the command line and never a prompt (the +fallback that needs it has to work non-interactively). The value's length is what +picks the mechanism, so a wrong length must be refused here rather than turning into +a confusing card refusal later. +""" + +from __future__ import annotations + +import pytest + +from cryptnox_id_cli.secrets.redaction import Redactor +from cryptnox_id_cli.secrets.resolver import DEFAULT_GP_KEY, SecretInputError, resolve_mgmt_key + + +@pytest.fixture(autouse=True) +def _no_inherited_env(monkeypatch): + monkeypatch.delenv("PIV_MGMT_KEY", raising=False) + monkeypatch.delenv("CARD_PIV_MGMT_KEY", raising=False) + + +def test_default_keys_serves_both_published_shapes(): + redactor = Redactor() + material = resolve_mgmt_key(redactor, default_keys=True) + assert material.source == "--default-keys" + assert material.keys == {0x08: DEFAULT_GP_KEY, 0x0C: DEFAULT_GP_KEY * 2} + for value in material.keys.values(): + assert redactor.mask(value.hex().upper()) != value.hex().upper() + + +@pytest.mark.parametrize(("size", "mechanism"), [(16, 0x08), (24, 0x0A), (32, 0x0C)]) +def test_the_value_length_picks_the_mechanism(monkeypatch, size, mechanism): + value = bytes(range(size)) + monkeypatch.setenv("PIV_MGMT_KEY", value.hex()) + material = resolve_mgmt_key(Redactor()) + assert material.source == "$PIV_MGMT_KEY" + assert material.keys == {mechanism: value} + + +def test_the_value_is_registered_for_redaction(monkeypatch): + value = bytes(range(32)) + monkeypatch.setenv("PIV_MGMT_KEY", value.hex()) + redactor = Redactor() + resolve_mgmt_key(redactor) + assert value.hex().upper() not in redactor.mask(value.hex().upper()) + + +@pytest.mark.parametrize( + "text", ["40:41:42:43:44:45:46:47:48:49:4A:4B:4C:4D:4E:4F", "0x" + "41" * 16] +) +def test_hex_input_tolerates_the_usual_separators(monkeypatch, text): + monkeypatch.setenv("PIV_MGMT_KEY", text) + assert len(resolve_mgmt_key(Redactor()).keys[0x08]) == 16 + + +def test_a_value_of_an_impossible_length_is_refused(monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", "00" * 15) + with pytest.raises(SecretInputError, match="15 bytes; the PIV management key must be"): + resolve_mgmt_key(Redactor()) + + +def test_the_alias_is_honoured_only_when_the_primary_is_unset(monkeypatch): + monkeypatch.setenv("CARD_PIV_MGMT_KEY", "AA" * 32) + assert resolve_mgmt_key(Redactor()).source == "$CARD_PIV_MGMT_KEY" + monkeypatch.setenv("PIV_MGMT_KEY", "BB" * 32) + material = resolve_mgmt_key(Redactor()) + assert material.source == "$PIV_MGMT_KEY" + assert material.keys[0x0C] == b"\xbb" * 32 + + +def test_default_keys_wins_over_the_environment(monkeypatch): + monkeypatch.setenv("PIV_MGMT_KEY", "AA" * 32) + assert resolve_mgmt_key(Redactor(), default_keys=True).source == "--default-keys" + + +def test_nothing_supplied_names_both_ways_to_supply_it(): + with pytest.raises(SecretInputError) as excinfo: + resolve_mgmt_key(Redactor()) + assert "$PIV_MGMT_KEY" in str(excinfo.value) + assert "--default-keys" in str(excinfo.value) + + +def test_the_caller_may_say_why_the_value_is_needed(): + with pytest.raises(SecretInputError, match="because the card truncated the template"): + resolve_mgmt_key(Redactor(), missing_message="because the card truncated the template") diff --git a/tests/unit/test_redaction.py b/tests/unit/test_redaction.py index ab55752..f139369 100644 --- a/tests/unit/test_redaction.py +++ b/tests/unit/test_redaction.py @@ -105,3 +105,36 @@ def test_chained_change_reference_data_masked(): out = Redactor().redact_command(apdu) assert component not in out assert "REDACTED" in out + + +def test_management_key_value_never_reaches_a_transcript(): + # Two independent defences on the 9B value: registration masks it wherever it + # appears, and CHANGE REFERENCE DATA (INS 24) masks its data field regardless. + from cryptnox_id_cli.applets.piv.mgmt_auth import set_value_apdu + + value = bytes(range(32)) + registered = Redactor() + registered.register(value) + assert value.hex().upper() not in registered.mask(value.hex().upper()) + + unregistered = Redactor() + out = unregistered.redact_command(set_value_apdu(0x0C, value).to_bytes()) + assert value.hex().upper() not in out + assert out == "00240C9B22" + + +def test_management_key_witness_exchange_is_masked_by_ins(): + # The witness and the challenge travel under GENERAL AUTHENTICATE, so neither + # direction of the handshake can be read out of a log. + from cryptnox_id_cli.applets.piv.mgmt_auth import mutual_response_apdu, witness_request_apdu + + r = Redactor() + witness = bytes.fromhex("A7E57B882467107902739D50387B3651") + response = bytes.fromhex("7C1280" + "10" + witness.hex()) + assert r.redact_response(response, 0x90, 0x00, ins=0x87) == "9000" + assert witness.hex().upper() not in r.redact_command( + mutual_response_apdu(0x0C, witness, bytes(16)).to_bytes() + ) + # Even the witness request, which carries nothing secret, is masked by INS: the + # handshake cannot be followed in a transcript, so diagnosis goes in the messages. + assert r.redact_command(witness_request_apdu(0x0C).to_bytes()) == "00870C9B0400" diff --git a/tests/unit/test_set_mgmt_key.py b/tests/unit/test_set_mgmt_key.py new file mode 100644 index 0000000..a5fa6db --- /dev/null +++ b/tests/unit/test_set_mgmt_key.py @@ -0,0 +1,227 @@ +"""`factory piv preperso set-mgmt-key`: what it writes, and what it refuses to write. + +Loading a card's administration key changes who can administer the card, so the +command's value is mostly in its refusals: it never creates a missing key object, and +it never overwrites a value that is already there without ``--replace``. The applet +does not overwrite silently either (it answers 6985), but the CLI must not rely on +that to decide whether an operator meant it. +""" + +from __future__ import annotations + +import contextlib +import json + +import pytest +from click.testing import CliRunner + +from cryptnox_id_cli.applets.piv import mgmt_auth as ma +from cryptnox_id_cli.cli.commands import factory as factory_cmd +from cryptnox_id_cli.cli.context import AppContext +from cryptnox_id_cli.cli.main import main as root +from cryptnox_id_cli.transport.apdu import Response +from cryptnox_id_cli.util import tlv + +KEY256 = bytes(range(32)) +WITNESS = bytes.fromhex("A7E57B882467107902739D50387B3651") + + +class FakeSession: + """Answers SELECT and the 9B witness request; records everything sent.""" + + reader_name = "Fake Contact Reader 00 00" + + def __init__(self, *, witness_sw: int, mechanism: int = 0x0C) -> None: + self.witness_sw = witness_sw + self.mechanism = mechanism + self.sent: list[str] = [] + + @property + def atr(self) -> bytes: + return bytes.fromhex("3BFA1300008131FE454A434F5033") + + def transmit(self, apdu, *, context: str | None = None) -> Response: + self.sent.append(apdu.to_bytes().hex().upper()) + if apdu.ins == 0xA4: + return Response(b"", 0x90, 0x00) + if apdu.ins == 0x87: + if apdu.p1 != self.mechanism: + return Response(b"", 0x6A, 0x86) + if self.witness_sw != 0x9000: + return Response(b"", self.witness_sw >> 8, self.witness_sw & 0xFF) + body = tlv.build_constructed( + ma.TAG_DYNAMIC_AUTH, + tlv.build(ma.TAG_WITNESS, ma.aes_ecb_encrypt(KEY256, WITNESS)), + ) + return Response(body, 0x90, 0x00) + return Response(b"", 0x6A, 0x82) + + +class FakeAdmin: + def __init__(self, session: FakeSession, write_sw: int = 0x9000) -> None: + self.card = session + self.write_sw = write_sw + self.events: list[str] = [] + self.writes: list[str] = [] + + def select(self) -> None: + self.events.append("select") + + def initialize_update_probe(self, key_version: int = 0) -> dict[str, object]: + return {"supported": True, "scp_version": 0x03} + + def open(self, keys) -> None: + self.events.append("open") + + def send(self, apdu, *, context: str | None = None) -> Response: + self.writes.append(apdu.to_bytes().hex().upper()) + self.events.append(f"send:{apdu.ins:02X}") + return Response(b"", self.write_sw >> 8, self.write_sw & 0xFF) + + +def _run(monkeypatch, session, adm, args, *, env_key: str | None = KEY256.hex()): + @contextlib.contextmanager + def fake_session(self): + yield session + + monkeypatch.setattr(AppContext, "open_session", fake_session) + monkeypatch.setattr(factory_cmd, "PivAdmin", lambda s: adm) + for var in ("PIV_SCP03_ENC", "PIV_SCP03_MAC", "PIV_SCP03_DEK"): + monkeypatch.setenv(var, "40" * 16) + monkeypatch.delenv("CARD_PIV_MGMT_KEY", raising=False) + if env_key is None: + monkeypatch.delenv("PIV_MGMT_KEY", raising=False) + else: + monkeypatch.setenv("PIV_MGMT_KEY", env_key) + return CliRunner().invoke(root, args) + + +BASE = ["--json", "factory", "piv", "preperso", "set-mgmt-key"] + + +def test_an_empty_object_is_loaded_with_one_write(monkeypatch): + session = FakeSession(witness_sw=0x6983) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE) + assert result.exit_code == 0, result.output + assert adm.writes == ["00240C9B228020" + KEY256.hex().upper()] + payload = json.loads(result.stdout) + assert payload["action"] == "set" + assert payload["mechanism"] == "AES-256" + assert payload["source"] == "$PIV_MGMT_KEY" + + +@pytest.mark.parametrize("witness_sw", [0x9000, 0x6985]) +def test_a_value_already_set_is_never_replaced_without_the_flag(monkeypatch, witness_sw): + # 6985 too: the applet checks "has a value" before it checks the attributes, so + # anything but 6983 proves a value is there. + session = FakeSession(witness_sw=witness_sw) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE) + assert result.exit_code == 7 + assert json.loads(result.stdout)["stage"] == "set_value" + assert adm.writes == [], "nothing may be written before the operator asks for it" + + +def test_replace_clears_first_then_sets_each_in_its_own_session(monkeypatch): + session = FakeSession(witness_sw=0x9000) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, [*BASE, "--replace"]) + assert result.exit_code == 0, result.output + assert adm.writes == [ + "00240C9B029F00", + "00240C9B228020" + KEY256.hex().upper(), + ] + # One probe SELECT, then a fresh channel for the CLEAR and another for the SET. + assert adm.events == [ + "select", + "select", + "open", + "send:24", + "select", + "open", + "send:24", + ] + assert json.loads(result.stdout)["action"] == "replaced" + assert "invalidates every credential" in result.stderr + + +def test_a_missing_key_object_is_not_created(monkeypatch): + session = FakeSession(witness_sw=0x9000, mechanism=0x99) # matches no candidate + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE) + assert result.exit_code == 7 + assert json.loads(result.stdout)["stage"] == "missing" + assert adm.writes == [] + + +def test_a_value_of_the_wrong_length_for_the_card_is_refused_before_writing(monkeypatch): + session = FakeSession(witness_sw=0x6983, mechanism=0x08) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE) # 32-byte value, AES-128 object + assert result.exit_code == 7 + assert json.loads(result.stdout)["stage"] == "key_length" + assert adm.writes == [] + + +def test_an_inaccessible_interface_is_named_as_such(monkeypatch): + session = FakeSession(witness_sw=0x6982) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE) + assert result.exit_code == 7 + assert json.loads(result.stdout)["stage"] == "access" + assert adm.writes == [] + + +def test_a_refused_write_reports_the_cards_own_reason(monkeypatch): + session = FakeSession(witness_sw=0x6983) + adm = FakeAdmin(session, write_sw=0x6A88) + result = _run(monkeypatch, session, adm, BASE) + assert result.exit_code == 7 + assert "no 9B key object" in json.loads(result.stdout)["message"] + + +def test_no_value_supplied_is_an_input_error_not_a_card_error(monkeypatch): + session = FakeSession(witness_sw=0x6983) + adm = FakeAdmin(session) + result = _run(monkeypatch, session, adm, BASE, env_key=None) + assert result.exit_code == 3 + assert "--default-keys" in json.loads(result.stdout)["message"] + + +def test_dry_run_refuses_before_a_session_opens(monkeypatch): + def no_session(self): + raise AssertionError("--dry-run opened a card session") + + monkeypatch.setattr(AppContext, "open_session", no_session) + result = CliRunner().invoke(root, ["--dry-run", *BASE[1:], "--default-keys"]) + assert result.exit_code != 0 + assert "cannot preview its card operations" in result.output + + +# ------------------------------------------------------------------- status --- +def test_status_reports_whether_9b_holds_a_value(monkeypatch): + session = FakeSession(witness_sw=0x6983) + adm = FakeAdmin(session) + + @contextlib.contextmanager + def fake_session(self): + yield session + + monkeypatch.setattr(AppContext, "open_session", fake_session) + monkeypatch.setattr(factory_cmd, "PivAdmin", lambda s: adm) + + class _State: + piv = factory_cmd.PivState.PRE_PERSONALIZED + + monkeypatch.setattr( + factory_cmd, "StateDetector", lambda *a, **kw: type("D", (), {"detect": lambda s: _State})() + ) + result = CliRunner().invoke(root, ["--json", "factory", "piv", "preperso", "status"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["management_key"] == { + "present": True, + "mechanism": "AES-256", + "value_set": False, + "sw": "6983", + } From 04c5f0d239ce1e6404bf81b5d79ea2ba8bd9c802 Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Wed, 16 Sep 2026 18:43:31 +0200 Subject: [PATCH 2/3] Add a Docker mirror of the CI jobs for local runs scripts/test-local.sh runs lint, typecheck, the pytest matrix, the package build and the HTML docs build in python:X-slim containers, matching .github/workflows/ci.yml and docs.yml. The default pytest matrix covers every Python version the package declares, 3.10 through 3.14, which is wider than CI. Each job works on a copy of the repository that leaves .git and ignored paths behind, so nothing is written to the checkout. Co-Authored-By: Claude Opus 5 (1M context) --- scripts/test-local.sh | 118 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100755 scripts/test-local.sh diff --git a/scripts/test-local.sh b/scripts/test-local.sh new file mode 100755 index 0000000..7f122f8 --- /dev/null +++ b/scripts/test-local.sh @@ -0,0 +1,118 @@ +#!/usr/bin/env bash +# Local CI mirror. Runs the GitHub Actions jobs on your machine via Docker, using +# python:X-slim images. No git, no runner, no reader: unit tests run against the +# mock transport. +# +# Usage: +# scripts/test-local.sh # everything: all jobs, full matrix +# scripts/test-local.sh 3.10 3.14 # limit the pytest matrix to these +# scripts/test-local.sh --test-only 3.10 # pytest only, no lint/typecheck/build/docs +# +# Jobs mirrored: +# lint once ruff check + format --check, pinned ruff (.github/workflows/ci.yml) +# typecheck once mypy (ci.yml) +# test matrix pytest, mock transport (ci.yml) +# build once python -m build (ci.yml) +# docs once sphinx-build -W, HTML only (.github/workflows/docs.yml) +# +# The default matrix covers every Python version the package declares, which is +# wider than CI's 3.10 + 3.14. The docs job builds HTML only; the PDF step in +# docs.yml needs a full LaTeX install and is not mirrored. +# +# pyscard is a C extension with no Linux wheel, so any job that pip-installs the +# package first installs the build toolchain (build-essential swig libpcsclite-dev). +set -euo pipefail + +# --test-only: run just the pytest matrix, skip lint/typecheck/build/docs. +TEST_ONLY=0 +if [[ "${1:-}" == "--test-only" ]]; then + TEST_ONLY=1 + shift +fi + +# Per-version matrix. Default: every version in pyproject.toml's classifiers. +if [[ $# -gt 0 ]]; then + VERSIONS=("$@") +else + VERSIONS=(3.10 3.11 3.12 3.13 3.14) +fi + +# Version used for the run-once jobs (matches CI). +PRIMARY=3.12 +RUFF_PIN=0.15.16 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail=0 + +# run