diff --git a/CHANGELOG.md b/CHANGELOG.md index 3edf7f0..7b630a3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `piv inventory`: the applet's key objects, verifiers, containers and config + flags with their access rules, read without authentication. +- The finalized (SECURED) state, read from the applet's status object, in + `piv status`, `info`, `report` and `factory piv preperso status`. - `RSA4096` in `perso generate-key`, `perso import-key`, CSR and self-signed-cert signing, and profile key mechanisms. - `perso generate-key --create-key-object`, the dev/eval fallback @@ -21,6 +25,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `factory piv preperso status` reports whether 9B holds a value. - `PIV_MGMT_KEY` (hex) for the PIV management key, next to `PIV_SCP03_*`. +### Changed + +- `piv admin status` and `factory piv preperso status` read the admin security + domain's SCP version and key versions from its key information template + instead of sending INITIALIZE UPDATE, which this chip counts as a failed + authentication. `piv admin status --key-version` is gone. + ### Removed - `transport.pcsc.list_reader_names()`; `reader_states()` returns the same @@ -28,6 +39,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- `factory piv preperso status` reported the SCP version of whatever applet was + selected last, not the PIV security domain's. + - `factory piv preperso load-config` warns that structural operations are permanent (existing elements cannot be changed or removed; only new ones can be added) instead of calling them reversible before finalize. diff --git a/CONTEXT.md b/CONTEXT.md index 4999b02..cc8471a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -35,10 +35,17 @@ data objects. It decides what _does_ exist. _Avoid_: provisioning, enrollment **Personalization state**: -A ladder, never a boolean: pre-personalized, partially personalized, personalized, -secured (locked). Output state labels follow the ladder. +A ladder, never a boolean: pre-personalized, partially personalized, personalized. +Output state labels follow the ladder. Finalized (SECURED) is reported beside the +ladder, not as a rung: finalize locks the structure only, so a secured applet can +sit at any rung. _Avoid_: initialized, blank, done +**Inventory**: +The applet's own listing of its structure - key objects, verifiers, containers and +config flags with their access rules - read without authentication. +_Avoid_: dump, snapshot (export-config is a different, observed-state file) + **Profile**: A named pre-personalization configuration, such as `cryptnox-default` or `ms-logon`. _Avoid_: template, preset diff --git a/docs/factory/factory-commands.rst b/docs/factory/factory-commands.rst index 6a55bfe..e17586c 100644 --- a/docs/factory/factory-commands.rst +++ b/docs/factory/factory-commands.rst @@ -18,6 +18,13 @@ manufacturing procedure outside this documentation. factory piv preperso set-mgmt-key load the PIV management key (9B) value factory piv preperso finalize IRREVERSIBLY lock the applet structure +``status`` never authenticates: the finalize (SECURED) state comes from the +applet's own status object, and the admin security domain's SCP version and key +versions from its key information template. On this chip an INITIALIZE UPDATE +that is not followed by a successful EXTERNAL AUTHENTICATE counts as a failed +authentication, so a read-only status must not send one. ``piv inventory`` lists +the structure itself. + ``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. diff --git a/docs/piv/piv-commands.rst b/docs/piv/piv-commands.rst index d1ec5cb..308f227 100644 --- a/docs/piv/piv-commands.rst +++ b/docs/piv/piv-commands.rst @@ -23,9 +23,17 @@ Inspection piv status lifecycle state, PIN/PUK status, object presence piv discover the Discovery object (PIN usage policy) piv slots key slots and which hold a certificate + piv inventory key objects, verifiers, containers and config, with access rules piv validate consistency check (NOT a NIST/FIPS validation) piv quickstart one-shot personalization (see the PIV quick start) +``piv inventory`` is the applet's own listing of its structure, read without +authentication: every key object with its mechanism, roles, attributes and access +modes, every verifier with its length and retry limits, every container with its +access modes, plus the applet version, its finalize state and its config flags. +An applet whose ``restrict-enumeration`` flag is set withholds the structure +(``6986``) and the command says so; version, state and config still answer. + PIN --- @@ -65,9 +73,14 @@ Admin channel .. code-block:: text - piv admin status probe the SCP03 channel (read-only) + piv admin status the admin security domain: SCP version, key versions (no auth) piv admin authenticate open the channel (mutual auth) + harmless self-test +``piv admin status`` selects the PIV security domain and reads its key information +template. It never sends INITIALIZE UPDATE: on this chip an INITIALIZE UPDATE that is +not followed by a successful EXTERNAL AUTHENTICATE counts as a failed authentication, +and a status read must not spend one. + Certificates and objects ------------------------ diff --git a/docs/troubleshooting.rst b/docs/troubleshooting.rst index ad913e3..bc478eb 100644 --- a/docs/troubleshooting.rst +++ b/docs/troubleshooting.rst @@ -78,6 +78,10 @@ The slot's key has the wrong role. Signing needs a **SIGN-role** slot such as default-profile 9A is AUTHENTICATE-only and refuses ``GENERAL AUTHENTICATE`` signing. See :doc:`/factory/pre-personalization-profiles`. +**``6986`` on ``piv inventory``.** +The applet's ``restrict-enumeration`` config flag is set, so the structure +listing needs the admin role. Version, state and config are still readable. + **``0xAE AUTHENTICATION_ERROR`` on ``mifare app delete``.** Deleting an application requires authentication. Pass ``--zero-key`` (factory default) or ``--key-env NAME``; the CLI then authenticates and sends a MACed diff --git a/src/cryptnox_id_cli/applets/piv/admin.py b/src/cryptnox_id_cli/applets/piv/admin.py index 9f72914..9af267d 100644 --- a/src/cryptnox_id_cli/applets/piv/admin.py +++ b/src/cryptnox_id_cli/applets/piv/admin.py @@ -36,8 +36,8 @@ def scp_label(value: object) -> str: """Human-readable SCP version label. ``value`` is untyped at the call sites - (loosely-typed probe dicts, optional session attributes), so narrow here - instead of duplicating the isinstance check at every caller.""" + (optional session attributes, inferred versions), so narrow here instead of + duplicating the isinstance check at every caller.""" return _SCP_LABELS.get(value, "unknown") if isinstance(value, int) else "unknown" @@ -63,30 +63,6 @@ def select(self) -> None: if not resp.ok: raise Scp03Error(f"SELECT PIV failed (SW={resp.sw_hex()}).") - def initialize_update_probe(self, key_version: int = 0) -> dict[str, object]: - """Read-only probe: INITIALIZE UPDATE only (auth not completed). - - Reports the SCP version from ``keyInfo[1]`` so the caller can tell an SCP02 card - (28-byte body) from an SCP03 one (29/32-byte body).""" - resp = self.card.transmit( - APDU(0x80, 0x50, key_version, 0x00, data=bytes(8), le=256), - context="INITIALIZE UPDATE (probe)", - ) - if not resp.ok: - raise Scp03Error(f"INITIALIZE UPDATE not supported / failed (SW={resp.sw_hex()}).") - b = resp.data - scp_id = b[11] if len(b) > 11 else None - # SCP03 puts the i-param at body[12]; SCP02 body[12:14] is the sequence counter. - scp_i = b[12] if (scp_id == SCP03 and len(b) > 12) else None - return { - "supported": (scp_id == SCP03 and len(b) >= 29) or (scp_id == SCP02 and len(b) >= 28), - "scp_version": scp_id, - "key_version": b[10] if len(b) > 10 else None, - "scp_id": scp_id, - "scp_i": scp_i, - "key_diversification": b[0:10].hex().upper(), - } - def open( self, keys: Scp03Keys | Scp02Keys, diff --git a/src/cryptnox_id_cli/applets/piv/inventory.py b/src/cryptnox_id_cli/applets/piv/inventory.py new file mode 100644 index 0000000..9fbae7f --- /dev/null +++ b/src/cryptnox_id_cli/applets/piv/inventory.py @@ -0,0 +1,435 @@ +"""Keyless inventory of the PIV applet: version, status, config, and the structure +(key objects, verifiers, containers) with their access rules. + +OpenFIPS201 answers GET DATA for the extended identifiers ``2F47xx`` without any +authentication. Only the structure listing can be withheld, by the applet's +``restrict-enumeration`` config flag (``6986``); version, status and config always +answer. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from cryptnox_id_cli.applets.piv import constants as c +from cryptnox_id_cli.applets.piv.objects import PIV_OBJECTS +from cryptnox_id_cli.applets.piv.slots import slot_by_ref +from cryptnox_id_cli.transport.errors import StatusWordError +from cryptnox_id_cli.util import tlv + +if TYPE_CHECKING: + from cryptnox_id_cli.applets.piv.piv import PivApplet + +# Extended data identifiers: the two bytes after 0x2F. +ID_VERSION = 0x4756 +ID_STATUS = 0x4753 +ID_CONFIG = 0x4743 +ID_CONTAINER = 0x4744 +ID_KEY = 0x474B +ID_VERIFIER = 0x4750 + +TAG_DATA = 0x53 + +# GlobalPlatform application life-cycle state as the applet reports it. +STATE_SELECTABLE = 0x07 +STATE_SECURED = 0x0F # applet-specific: SECURE APPLET (finalize) done + +SW_NOT_FOUND = 0x6A82 +SW_NOT_ALLOWED = 0x6986 + +APPLET_TRUE = 0xA5 + +# Access-mode byte: the low five bits are the cardholder condition (0x1F = ALWAYS), +# the high bits are independent qualifiers. +_MODE_ALWAYS = 0x1F +_MODE_BITS = ((0x01, "PIN"), (0x02, "PIN-ALWAYS"), (0x04, "OCC")) +_MODE_SM = 0x40 +_MODE_VCI = 0x60 +_MODE_USER_ADMIN = 0x80 + +_ROLE_BITS = ((0x01, "AUTHENTICATE"), (0x02, "KEY-ESTABLISH"), (0x04, "SIGN")) +_ATTR_BITS = ( + (0x04, "PERMIT-EXTERNAL"), + (0x08, "PERMIT-MUTUAL"), + (0x10, "IMPORTABLE"), + (0x20, "RSA-CRT"), +) + +VERIFIER_NAMES = { + 0x00: "global PIN", + 0x80: "PIN", + 0x81: "PUK", + 0x96: "OCC primary", + 0x97: "OCC secondary", + 0x98: "pairing code", +} + +_CONTAINER_NAMES = {o.oid: o.name for o in PIV_OBJECTS} + +# Every slot with more than one mechanism still fits. +_MAX_INDEX = 128 + + +def describe_state(state: int) -> str: + return {STATE_SELECTABLE: "SELECTABLE", STATE_SECURED: "SECURED"}.get(state, f"{state:#04x}") + + +def describe_mode(mode: int) -> str: + """Words for an access-mode byte, e.g. ``PIN``, ``ALWAYS``, ``NEVER, SM``.""" + base = mode & _MODE_ALWAYS + parts = ( + ["ALWAYS"] + if base == _MODE_ALWAYS + else [name for bit, name in _MODE_BITS if base & bit] or ["NEVER"] + ) + if mode & _MODE_VCI == _MODE_VCI: + parts.append("VCI") + elif mode & _MODE_SM: + parts.append("SM") + if mode & _MODE_USER_ADMIN: + parts.append("USER-ADMIN") + return ", ".join(parts) + + +def role_names(role: int) -> list[str]: + return [name for bit, name in _ROLE_BITS if role & bit] + + +def attribute_names(attributes: int) -> list[str]: + return [name for bit, name in _ATTR_BITS if attributes & bit] + + +def _fields(data: bytes) -> dict[int, bytes]: + """The primitive fields inside the 0x53 wrapper, by tag.""" + nodes = tlv.parse(data, recurse=False) + wrapper = next((n for n in nodes if n.tag == TAG_DATA), None) + inner = tlv.parse(wrapper.value, recurse=False) if wrapper is not None else nodes + return {n.tag: n.value for n in inner} + + +def _byte(fields: dict[int, bytes], tag: int, default: int = 0) -> int: + value = fields.get(tag) + return value[0] if value else default + + +def _flag(fields: dict[int, bytes], tag: int) -> bool: + return _byte(fields, tag) != 0 + + +@dataclass(frozen=True) +class AppletVersion: + label: str + major: int + minor: int + revision: int + debug: bool + + @property + def version(self) -> str: + return f"{self.major}.{self.minor}.{self.revision}" + + def to_dict(self) -> dict[str, object]: + return { + "label": self.label, + "version": self.version, + "debug": self.debug, + } + + +@dataclass(frozen=True) +class AppletStatus: + state: int + operator_roles: int + operator_id: int + immediate: bool + sm_established: bool + vci_established: bool + contactless: bool + fips_mode: bool + + @property + def secured(self) -> bool: + return self.state == STATE_SECURED + + def to_dict(self) -> dict[str, object]: + return { + "state": describe_state(self.state), + "state_raw": f"{self.state:02X}", + "secured": self.secured, + "operator_roles": f"{self.operator_roles:02X}", + "operator_id": f"{self.operator_id:02X}", + "immediate": self.immediate, + "sm_established": self.sm_established, + "vci_established": self.vci_established, + "contactless": self.contactless, + "fips_mode": self.fips_mode, + } + + +@dataclass(frozen=True) +class AppletConfig: + restrict_contactless_global: bool + restrict_contactless_admin: bool + restrict_enumeration: bool + restrict_get_random: bool + vci_compatibility: bool + + def to_dict(self) -> dict[str, object]: + return { + "restrict_contactless_global": self.restrict_contactless_global, + "restrict_contactless_admin": self.restrict_contactless_admin, + "restrict_enumeration": self.restrict_enumeration, + "restrict_get_random": self.restrict_get_random, + "vci_compatibility": self.vci_compatibility, + } + + +@dataclass(frozen=True) +class KeyObject: + ref: int + mode_contact: int + mode_contactless: int + admin_key: int + mechanism: int + role: int + attributes: int + + @property + def slot_name(self) -> str: + slot = slot_by_ref(self.ref) + return slot.name if slot else "-" + + @property + def mechanism_name(self) -> str: + return c.ALGORITHMS.get(self.mechanism, f"{self.mechanism:#04x}") + + def to_dict(self) -> dict[str, object]: + return { + "ref": f"{self.ref:02X}", + "slot": self.slot_name, + "mechanism": self.mechanism_name, + "mechanism_raw": f"{self.mechanism:02X}", + "roles": role_names(self.role), + "attributes": attribute_names(self.attributes), + "mode_contact": describe_mode(self.mode_contact), + "mode_contact_raw": f"{self.mode_contact:02X}", + "mode_contactless": describe_mode(self.mode_contactless), + "mode_contactless_raw": f"{self.mode_contactless:02X}", + "admin_key": f"{self.admin_key:02X}", + } + + +@dataclass(frozen=True) +class VerifierObject: + ref: int + mode_contact: int + mode_contactless: int + min_length: int + max_length: int + retries_contact: int + retries_contactless: int + charset: int + history: int + sequence: int + repeat: int + restrict_update: bool + + @property + def name(self) -> str: + return VERIFIER_NAMES.get(self.ref, "-") + + def to_dict(self) -> dict[str, object]: + return { + "ref": f"{self.ref:02X}", + "name": self.name, + "mode_contact": describe_mode(self.mode_contact), + "mode_contact_raw": f"{self.mode_contact:02X}", + "mode_contactless": describe_mode(self.mode_contactless), + "mode_contactless_raw": f"{self.mode_contactless:02X}", + "min_length": self.min_length, + "max_length": self.max_length, + "retries_contact": self.retries_contact, + "retries_contactless": self.retries_contactless, + "charset": f"{self.charset:02X}", + "history": self.history, + "sequence": self.sequence, + "repeat": self.repeat, + "restrict_update": self.restrict_update, + } + + +@dataclass(frozen=True) +class ContainerObject: + oid: bytes + mode_contact: int + mode_contactless: int + admin_key: int + + @property + def oid_hex(self) -> str: + return self.oid.hex().upper() + + @property + def name(self) -> str: + return _CONTAINER_NAMES.get(self.oid, "-") + + def to_dict(self) -> dict[str, object]: + return { + "oid": self.oid_hex, + "name": self.name, + "mode_contact": describe_mode(self.mode_contact), + "mode_contact_raw": f"{self.mode_contact:02X}", + "mode_contactless": describe_mode(self.mode_contactless), + "mode_contactless_raw": f"{self.mode_contactless:02X}", + "admin_key": f"{self.admin_key:02X}", + } + + +def parse_version(data: bytes) -> AppletVersion: + f = _fields(data) + return AppletVersion( + label=f.get(0x80, b"").decode("ascii", "replace"), + major=_byte(f, 0x81), + minor=_byte(f, 0x82), + revision=_byte(f, 0x83), + debug=_flag(f, 0x84), + ) + + +def parse_status(data: bytes) -> AppletStatus: + f = _fields(data) + return AppletStatus( + state=_byte(f, 0x80), + operator_roles=_byte(f, 0x81), + operator_id=_byte(f, 0x82), + immediate=_flag(f, 0x83), + sm_established=_flag(f, 0x84), + vci_established=_flag(f, 0x85), + contactless=_flag(f, 0x86), + fips_mode=_flag(f, 0x87), + ) + + +def parse_config(data: bytes) -> AppletConfig: + f = _fields(data) + return AppletConfig( + restrict_contactless_global=_flag(f, 0x80), + restrict_contactless_admin=_flag(f, 0x81), + restrict_enumeration=_flag(f, 0x82), + restrict_get_random=_flag(f, 0x83), + vci_compatibility=_flag(f, 0x84), + ) + + +def parse_key(data: bytes) -> KeyObject: + f = _fields(data) + return KeyObject( + ref=_byte(f, 0x8B), + mode_contact=_byte(f, 0x8C), + mode_contactless=_byte(f, 0x8D), + admin_key=_byte(f, 0x91), + mechanism=_byte(f, 0x8E), + role=_byte(f, 0x8F), + attributes=_byte(f, 0x90), + ) + + +def parse_verifier(data: bytes) -> VerifierObject: + f = _fields(data) + return VerifierObject( + ref=_byte(f, 0x8B), + mode_contact=_byte(f, 0x8C), + mode_contactless=_byte(f, 0x8D), + min_length=_byte(f, 0x8E), + max_length=_byte(f, 0x8F), + retries_contact=_byte(f, 0x90), + retries_contactless=_byte(f, 0x91), + charset=_byte(f, 0x92), + history=_byte(f, 0x93), + sequence=_byte(f, 0x94), + repeat=_byte(f, 0x95), + restrict_update=_byte(f, 0x96) == APPLET_TRUE, + ) + + +def parse_container(data: bytes) -> ContainerObject: + f = _fields(data) + return ContainerObject( + oid=bytes(f.get(0x8B, b"")), + mode_contact=_byte(f, 0x8C), + mode_contactless=_byte(f, 0x8D), + admin_key=_byte(f, 0x91), + ) + + +def read_version(piv: PivApplet) -> AppletVersion | None: + resp = piv.get_data_extended(ID_VERSION) + return parse_version(resp.data) if resp.ok else None + + +def read_status(piv: PivApplet) -> AppletStatus | None: + resp = piv.get_data_extended(ID_STATUS) + return parse_status(resp.data) if resp.ok else None + + +def read_config(piv: PivApplet) -> AppletConfig | None: + resp = piv.get_data_extended(ID_CONFIG) + return parse_config(resp.data) if resp.ok else None + + +def _enumerate(piv: PivApplet, ident: int, parse): # noqa: ANN001, ANN202 + """Every entry of one object kind, or ``None`` when the applet withholds them.""" + out = [] + for index in range(_MAX_INDEX): + resp = piv.get_data_extended(ident, index) + if resp.sw == SW_NOT_FOUND: + break + if resp.sw == SW_NOT_ALLOWED: + return None + if not resp.ok: + raise StatusWordError(resp.sw1, resp.sw2, context=f"GET DATA 2F{ident:04X}") + out.append(parse(resp.data)) + return out + + +@dataclass(frozen=True) +class Inventory: + version: AppletVersion | None + status: AppletStatus | None + config: AppletConfig | None + keys: list[KeyObject] | None + verifiers: list[VerifierObject] | None + containers: list[ContainerObject] | None + + @property + def restricted(self) -> bool: + """True when the applet withheld the structure (restrict-enumeration set).""" + return self.keys is None or self.verifiers is None or self.containers is None + + def to_dict(self) -> dict[str, object]: + return { + "version": self.version.to_dict() if self.version else None, + "status": self.status.to_dict() if self.status else None, + "config": self.config.to_dict() if self.config else None, + "restricted": self.restricted, + "keys": [k.to_dict() for k in self.keys] if self.keys is not None else None, + "verifiers": ( + [v.to_dict() for v in self.verifiers] if self.verifiers is not None else None + ), + "containers": ( + [o.to_dict() for o in self.containers] if self.containers is not None else None + ), + } + + +def read_inventory(piv: PivApplet) -> Inventory: + """Everything the selected applet reports about itself without authentication.""" + return Inventory( + version=read_version(piv), + status=read_status(piv), + config=read_config(piv), + keys=_enumerate(piv, ID_KEY, parse_key), + verifiers=_enumerate(piv, ID_VERIFIER, parse_verifier), + containers=_enumerate(piv, ID_CONTAINER, parse_container), + ) diff --git a/src/cryptnox_id_cli/applets/piv/objects.py b/src/cryptnox_id_cli/applets/piv/objects.py index 03fbf2c..394ec05 100644 --- a/src/cryptnox_id_cli/applets/piv/objects.py +++ b/src/cryptnox_id_cli/applets/piv/objects.py @@ -86,6 +86,15 @@ def get_data_apdu(oid: bytes) -> APDU: return APDU(0x00, c.INS_GET_DATA, 0x3F, 0xFF, data=tag_list, le=256) +def get_data_extended_apdu(ident: int, index: int = 0) -> APDU: + """GET DATA for a vendor ``2F47xx`` identifier. ``index`` (P2) picks the entry of + a structure listing; P2 = FF would address a standard object instead.""" + if not 0 <= index < 0xFF: + raise ValueError("index must be 0..254") + tag_list = bytes([0x5C, 0x03, 0x2F, (ident >> 8) & 0xFF, ident & 0xFF]) + return APDU(0x00, c.INS_GET_DATA, 0x3F, index, data=tag_list, le=256) + + def unwrap(oid: bytes, data: bytes) -> bytes: """Strip the outer tag-0x53 wrapper for standard objects; pass discovery through.""" if oid == bytes([TAG_DISCOVERY]): diff --git a/src/cryptnox_id_cli/applets/piv/piv.py b/src/cryptnox_id_cli/applets/piv/piv.py index 5012a1f..22d57d8 100644 --- a/src/cryptnox_id_cli/applets/piv/piv.py +++ b/src/cryptnox_id_cli/applets/piv/piv.py @@ -95,6 +95,12 @@ def apt(self) -> APTInfo | None: def get_data(self, oid: bytes) -> Response: return self.session.transmit(obj.get_data_apdu(oid), context="GET DATA") + def get_data_extended(self, ident: int, index: int = 0) -> Response: + """Vendor GET DATA (``2F47xx``): version, status, config, structure listing.""" + return self.session.transmit( + obj.get_data_extended_apdu(ident, index), context=f"GET DATA 2F{ident:04X}" + ) + def read_object(self, oid: bytes) -> bytes | None: """Return the unwrapped object content, or None if absent (6A82).""" resp = self.get_data(oid) diff --git a/src/cryptnox_id_cli/applets/piv/ssd.py b/src/cryptnox_id_cli/applets/piv/ssd.py new file mode 100644 index 0000000..2a1959e --- /dev/null +++ b/src/cryptnox_id_cli/applets/piv/ssd.py @@ -0,0 +1,148 @@ +"""Keyless facts about the GlobalPlatform security domain that administers the PIV +applet: which SCP it speaks and which key versions it holds. + +Both reads are plain GET DATA on the selected domain. No INITIALIZE UPDATE is sent, +so nothing here counts against the card's failed-authentication limit. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from cryptnox_id_cli.applets.piv.admin import SCP02, SCP03, scp_label +from cryptnox_id_cli.transport.apdu import APDU, Response +from cryptnox_id_cli.transport.pcsc import CardSession +from cryptnox_id_cli.util import tlv + +PIV_SSD_AID = bytes.fromhex("A00000015153504101") +ISD_AID = bytes.fromhex("A000000151000000") + +TAG_KEY_INFO_TEMPLATE = 0xE0 +TAG_KEY_INFO = 0xC0 + +# GlobalPlatform key type coding (GPCS 2.3, table 11-16). +KEY_TYPE_AES = 0x88 +_DES_TYPES = frozenset({0x80, 0x81, 0x82, 0x83, 0x84}) +_KEY_TYPES = { + 0x80: "DES", + 0x81: "3DES", + 0x82: "3DES-CBC", + 0x83: "DES-ECB", + 0x84: "DES-CBC", + 0x85: "TLS-PSK", + 0x88: "AES", + 0x90: "HMAC-SHA1", + 0x91: "HMAC-SHA1-160", + 0xA0: "RSA-e", + 0xA1: "RSA-N", + 0xB0: "ECC-public", + 0xB1: "ECC-private", +} + + +@dataclass(frozen=True) +class KeyEntry: + key_id: int + key_version: int + key_type: int + length: int + + @property + def type_name(self) -> str: + return _KEY_TYPES.get(self.key_type, f"{self.key_type:#04x}") + + def to_dict(self) -> dict[str, object]: + return { + "key_id": self.key_id, + "key_version": self.key_version, + "type": self.type_name, + "type_raw": f"{self.key_type:02X}", + "length": self.length, + } + + +@dataclass(frozen=True) +class SecurityDomainInfo: + aid: bytes + keys: tuple[KeyEntry, ...] + key_table_sw: int + + @property + def aid_hex(self) -> str: + return self.aid.hex().upper() + + @property + def scp_version(self) -> int | None: + """SCP03 for an AES keyset, SCP02 for a DES one; ``None`` when unreadable or mixed.""" + types = {k.key_type for k in self.keys} + if types and all(t == KEY_TYPE_AES for t in types): + return SCP03 + if types and types <= _DES_TYPES: + return SCP02 + return None + + @property + def key_versions(self) -> list[int]: + return sorted({k.key_version for k in self.keys}) + + def keys_for(self, version: int) -> list[KeyEntry]: + return [k for k in self.keys if k.key_version == version] + + def to_dict(self) -> dict[str, object]: + return { + "aid": self.aid_hex, + "scp_version": scp_label(self.scp_version) if self.scp_version else None, + "key_table_sw": f"{self.key_table_sw:04X}", + "key_versions": [ + {"version": v, "keys": [k.to_dict() for k in self.keys_for(v)]} + for v in self.key_versions + ], + } + + +def parse_key_information(data: bytes) -> list[KeyEntry]: + """Entries of a Key Information Template (``E0`` of ``C0`` records).""" + nodes = tlv.parse(data, recurse=False) + template = next((n for n in nodes if n.tag == TAG_KEY_INFO_TEMPLATE), None) + body = template.value if template is not None else bytes(data) + entries: list[KeyEntry] = [] + for node in tlv.parse(body, recurse=False): + v = node.value + if node.tag != TAG_KEY_INFO or len(v) < 4: + continue + if v[2] == 0xFF: # extended format: id, version, FF, type, length(2) + if len(v) >= 6: + entries.append(KeyEntry(v[0], v[1], v[3], int.from_bytes(v[4:6], "big"))) + continue + entries.append(KeyEntry(v[0], v[1], v[2], v[3])) + return entries + + +def select_security_domain(session: CardSession, aid: bytes) -> Response: + label = f"SELECT SD {aid.hex().upper()}" + resp = session.transmit(APDU(0x00, 0xA4, 0x04, 0x00, data=aid, le=256), context=label) + if resp.sw == 0x6700: # same case-4 quirk as the applet SELECTs: retry without Le + resp = session.transmit(APDU(0x00, 0xA4, 0x04, 0x00, data=aid), context=f"{label} (no Le)") + return resp + + +def read_key_information(session: CardSession) -> Response: + return session.transmit( + APDU(0x80, 0xCA, 0x00, TAG_KEY_INFO_TEMPLATE, le=256), context="GET DATA (key info)" + ) + + +def describe_security_domain( + session: CardSession, aids: tuple[bytes, ...] = (PIV_SSD_AID, ISD_AID) +) -> SecurityDomainInfo | None: + """The first selectable domain in ``aids`` with its key table, or ``None``. + + Leaves that domain selected; re-select the PIV applet before using it. + """ + for aid in aids: + if not select_security_domain(session, aid).ok: + continue + resp = read_key_information(session) + keys = tuple(parse_key_information(resp.data)) if resp.ok else () + return SecurityDomainInfo(aid=aid, keys=keys, key_table_sw=resp.sw) + return None diff --git a/src/cryptnox_id_cli/cli/commands/factory.py b/src/cryptnox_id_cli/cli/commands/factory.py index 19c532d..e9984b8 100644 --- a/src/cryptnox_id_cli/cli/commands/factory.py +++ b/src/cryptnox_id_cli/cli/commands/factory.py @@ -19,6 +19,7 @@ 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.applets.piv.ssd import SecurityDomainInfo, describe_security_domain from cryptnox_id_cli.cli.context import AppContext from cryptnox_id_cli.secrets.resolver import resolve_mgmt_key, resolve_scp03_keys from cryptnox_id_cli.state import StateDetector @@ -87,31 +88,46 @@ def _mgmt_key_line(mgmt: dict[str, object] | None) -> str: return f"{name}, {'value set' if mgmt['value_set'] else 'no value'}" +def _domain_line(domain: SecurityDomainInfo | None) -> str: + if domain is None: + return "not determined (no security domain selectable)" + if not domain.keys: + return f"{domain.aid_hex}, key table withheld (SW={domain.key_table_sw:04X})" + versions = ", ".join( + f"{v} ({len(keys)} x {keys[0].type_name} {keys[0].length} B)" + for v in domain.key_versions + for keys in [domain.keys_for(v)] + ) + return f"{scp_label(domain.scp_version)} via {domain.aid_hex}; key version(s) {versions}" + + +def _secured_word(secured: bool | None) -> str: + return "undetermined" if secured is None else ("yes" if secured else "no") + + # --------------------------------------------------------------------------- # @preperso.command("status") @click.pass_obj def status(app: AppContext) -> None: - """Show whether pre-personalization is still possible on this card.""" + """Show whether pre-personalization is still possible on this card (no authentication).""" with app.open_session() as session: st = StateDetector(session, probe_fido=False, probe_desfire=False).detect() - scp_supported = False - scp_version = None try: - probe = PivAdmin(session).initialize_update_probe() - scp_supported = bool(probe.get("supported")) - scp_version = probe.get("scp_version") + domain = describe_security_domain(session) except CryptnoxError: - scp_supported = False + domain = None mgmt = _probe_mgmt_key(session) + selectable = st.piv not in (PivState.NOT_PRESENT, PivState.UNKNOWN) # Same gate as the finalize command: selectable and not yet SECURED. - finalize_allowed = st.piv not in (PivState.NOT_PRESENT, PivState.UNKNOWN, PivState.SECURED) - load_config_allowed = st.piv == PivState.PRE_PERSONALIZED - scp_ver_label = scp_label(scp_version) + finalize_allowed = selectable and not st.piv_secured + load_config_allowed = st.piv == PivState.PRE_PERSONALIZED and not st.piv_secured + scp_version = scp_label(domain.scp_version) if domain and domain.scp_version else None payload = { "state": st.piv.label, - "scp03_available": scp_supported, # kept for back-compat; covers SCP02/SCP03 - "scp_version": scp_ver_label if scp_supported else None, - "secured": st.piv == PivState.SECURED, + "scp03_available": domain is not None, # kept for back-compat; covers SCP02/SCP03 + "scp_version": scp_version, + "security_domain": domain.to_dict() if domain else None, + "secured": st.piv_secured, "finalize_allowed": finalize_allowed, "load_config_allowed": load_config_allowed, "management_key": mgmt, @@ -119,12 +135,15 @@ def status(app: AppContext) -> None: def human(con: Console) -> None: con.print(f"PIV lifecycle state: [bold]{st.piv.label}[/bold]") - 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" Admin secure channel: {_domain_line(domain)}") + con.print(f" Finalized (SECURED): {_secured_word(st.piv_secured)}") con.print(f" Management key (9B): {_mgmt_key_line(mgmt)}") if load_config_allowed: con.print(" Pre-perso load-config: [green]allowed[/green] (no structure yet)") + elif st.piv_secured: + con.print( + " Pre-perso load-config: [yellow]not allowed[/yellow], structure is finalized" + ) else: con.print( " Pre-perso load-config: structure present; it can only add elements, " @@ -132,6 +151,8 @@ def human(con: Console) -> None: ) if finalize_allowed: con.print(" Finalize: [green]allowed[/green]") + elif st.piv_secured: + con.print(" Finalize: [yellow]not allowed[/yellow], already finalized") else: con.print(f" Finalize: [yellow]not allowed[/yellow] in state {st.piv.label}") @@ -428,7 +449,7 @@ def finalize(app: AppContext, understood: bool, default_keys: bool) -> None: raise CryptnoxError( f"Refusing to finalize: PIV applet is not selectable (state {st.piv.label})." ) - if st.piv == PivState.SECURED: + if st.piv_secured: raise CryptnoxError("Applet is already SECURED (finalized) - nothing to do.") if app.dry_run: @@ -462,10 +483,16 @@ def finalize(app: AppContext, understood: bool, default_keys: bool) -> None: # Surface as a non-zero exit so manufacturing automation detects the failure. raise StatusWordError(resp.sw1, resp.sw2, context="SECURE APPLET (finalize)") post = StateDetector(session, probe_fido=False, probe_desfire=False).detect() + confirmed = "confirmed" if post.piv_secured else "not confirmed by the applet" app.out.result( - {"finalized": True, "sw": resp.sw_hex(), "state": post.piv.label}, + { + "finalized": True, + "sw": resp.sw_hex(), + "state": post.piv.label, + "secured": post.piv_secured, + }, lambda con: con.print( - f"[green]Applet finalized[/green] (SW={resp.sw_hex()}); state now {post.piv.label}. " - "Pre-personalization commands are no longer available." + f"[green]Applet finalized[/green] (SW={resp.sw_hex()}); state {post.piv.label}, " + f"SECURED {confirmed}. Pre-personalization commands are no longer available." ), ) diff --git a/src/cryptnox_id_cli/cli/commands/info.py b/src/cryptnox_id_cli/cli/commands/info.py index 98a1224..47fbf55 100644 --- a/src/cryptnox_id_cli/cli/commands/info.py +++ b/src/cryptnox_id_cli/cli/commands/info.py @@ -67,6 +67,8 @@ def human(c: Console) -> None: if state.piv_pin and state.piv_pin.configured: tries = state.piv_pin.retries piv_detail += f", PIN {tries} tries" if tries is not None else ", PIN set" + if state.piv_secured: + piv_detail += ", finalized" table.add_row("PIV", state_style(state.piv.label), piv_detail or "-") # FIDO fido_detail = ", ".join(state.fido_versions) if state.fido_versions else "" diff --git a/src/cryptnox_id_cli/cli/commands/piv.py b/src/cryptnox_id_cli/cli/commands/piv.py index 5112d9f..6b2e531 100644 --- a/src/cryptnox_id_cli/cli/commands/piv.py +++ b/src/cryptnox_id_cli/cli/commands/piv.py @@ -19,6 +19,7 @@ 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 inventory as inventory_mod 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 @@ -32,6 +33,7 @@ ) from cryptnox_id_cli.applets.piv.piv import PivApplet from cryptnox_id_cli.applets.piv.slots import PIV_SLOTS +from cryptnox_id_cli.applets.piv.ssd import PIV_SSD_AID, describe_security_domain from cryptnox_id_cli.cli.context import AppContext from cryptnox_id_cli.crypto import csr as csr_mod from cryptnox_id_cli.crypto import piv_objects, x509util @@ -115,11 +117,18 @@ def status(app: AppContext) -> None: "pin": st.piv_pin.to_dict() if st.piv_pin else None, "puk": st.piv_puk.to_dict() if st.piv_puk else None, "objects_present": st.piv_objects, + "secured": st.piv_secured, "notes": st.notes, } def human(c: Console) -> None: c.print(f"[bold]PIV state:[/bold] {state_style(st.piv.label)}") + if st.piv_secured is None: + c.print(" Structure: finalize state not readable") + elif st.piv_secured: + c.print(" Structure: finalized (SECURED), no further pre-personalization") + else: + c.print(" Structure: not finalized") if st.piv_pin: extra = f", {st.piv_pin.retries} tries left" if st.piv_pin.retries is not None else "" blocked = " [red](blocked)[/red]" if st.piv_pin.blocked else "" @@ -200,6 +209,114 @@ def human(c: Console) -> None: app.out.result({"slots": rows}, human) +def _mode_cell(mode: int) -> str: + return f"{inventory_mod.describe_mode(mode)} ({mode:02X})" + + +@command.command("inventory") +@click.pass_obj +def inventory(app: AppContext) -> None: + """List the applet's structure with its access rules (no authentication). + + Key objects, verifiers and containers as the applet itself reports them, plus + its version, state and config flags. + """ + with app.open_session() as session: + inv = inventory_mod.read_inventory(_select(session)) + + def human(c: Console) -> None: + ver = inv.version + c.print(f"[bold]{ver.label} {ver.version}[/bold]" if ver else "[bold]PIV applet[/bold]") + if inv.status: + s = inv.status + finalized = "finalized (SECURED)" if s.secured else "not finalized" + c.print(f" State: {inventory_mod.describe_state(s.state)}, {finalized}") + interface = "contactless" if s.contactless else "contact" + c.print(f" Interface: {interface}; FIPS mode {'on' if s.fips_mode else 'off'}") + if inv.config: + flags = ", ".join( + f"{name.replace('_', '-')}={'on' if on else 'off'}" + for name, on in inv.config.to_dict().items() + ) + c.print(f" Config: {flags}") + if inv.restricted: + c.print( + "\n[yellow]The applet withholds its structure (restrict-enumeration is " + "set); listing key objects, verifiers and containers needs the admin " + "channel.[/yellow]" + ) + if inv.keys is not None: + table = app.out.table( + "Slot", + "Name", + "Mechanism", + "Roles", + "Attributes", + "Contact", + "Contactless", + "Admin", + title=f"Key objects ({len(inv.keys)})", + ) + for k in inv.keys: + table.add_row( + f"{k.ref:02X}", + k.slot_name, + k.mechanism_name, + "+".join(inventory_mod.role_names(k.role)) or "none", + "+".join(inventory_mod.attribute_names(k.attributes)) or "none", + _mode_cell(k.mode_contact), + _mode_cell(k.mode_contactless), + f"{k.admin_key:02X}", + ) + c.print(table) + if inv.verifiers is not None: + table = app.out.table( + "Ref", + "Name", + "Contact", + "Contactless", + "Length", + "Retries", + "Rules", + title=f"Verifiers ({len(inv.verifiers)})", + ) + for v in inv.verifiers: + rules = ( + f"charset={v.charset:02X} history={v.history} sequence={v.sequence} " + f"repeat={v.repeat} restrict-update={'yes' if v.restrict_update else 'no'}" + ) + table.add_row( + f"{v.ref:02X}", + v.name, + _mode_cell(v.mode_contact), + _mode_cell(v.mode_contactless), + f"{v.min_length}-{v.max_length}", + f"{v.retries_contact}/{v.retries_contactless}", + rules, + ) + c.print(table) + if inv.containers is not None: + table = app.out.table( + "OID", + "Name", + "Contact", + "Contactless", + "Admin", + title=f"Containers ({len(inv.containers)})", + ) + for o in inv.containers: + table.add_row( + o.oid_hex, + o.name, + _mode_cell(o.mode_contact), + _mode_cell(o.mode_contactless), + f"{o.admin_key:02X}", + ) + c.print(table) + + app.out.result(inv.to_dict(), human) + + # ------------------------------------------------------------------ certs --- # @command.group("certs") def certs() -> None: @@ -724,28 +841,32 @@ def admin() -> None: @admin.command("status") -@click.option("--key-version", default=0, type=int, help="SCP03 key version (default 0).") @click.pass_obj -def admin_status(app: AppContext, key_version: int) -> None: - """Probe the SCP03 admin channel (read-only INITIALIZE UPDATE, no auth).""" +def admin_status(app: AppContext) -> None: + """Show the admin security domain's SCP version and key versions (no authentication).""" with app.open_session() as session: - adm = PivAdmin(session) - adm.select() - info = adm.initialize_update_probe(key_version=key_version) + domain = describe_security_domain(session) + if domain is None: + raise CryptnoxError( + "No security domain answered: neither the PIV security domain " + f"({PIV_SSD_AID.hex().upper()}) nor the ISD is selectable on this interface." + ) def human(c: Console) -> None: - scp_id = info.get("scp_id") - label = scp_label(scp_id) - c.print(f"PIV admin secure channel ({label}):") - if isinstance(scp_id, int): - c.print(f" SCP ID: {scp_id:#04x} ({label})") - c.print(f" key version: {info.get('key_version')}") - i_param = info.get("scp_i") - if isinstance(i_param, int): - c.print(f" i-param: {i_param:#04x}") - c.print(" INITIALIZE UPDATE supported - run `piv admin authenticate` to open it.") - - app.out.result(info, human) + c.print(f"PIV admin security domain {domain.aid_hex} ({scp_label(domain.scp_version)}):") + if not domain.keys: + c.print(f" key table withheld (SW={domain.key_table_sw:04X})") + else: + table = app.out.table("Key version", "Key ID", "Type", "Length") + for k in domain.keys: + table.add_row(str(k.key_version), str(k.key_id), k.type_name, f"{k.length} B") + c.print(table) + c.print( + " Read without authenticating; nothing counted against the card's " + "failed-authentication limit. Run `piv admin authenticate` to open the channel." + ) + + app.out.result(domain.to_dict(), human) @admin.command("authenticate") diff --git a/src/cryptnox_id_cli/cli/commands/report.py b/src/cryptnox_id_cli/cli/commands/report.py index 4f08770..dc33294 100644 --- a/src/cryptnox_id_cli/cli/commands/report.py +++ b/src/cryptnox_id_cli/cli/commands/report.py @@ -61,6 +61,7 @@ def _piv_section(app: AppContext) -> dict[str, object]: "pin": st.piv_pin.to_dict() if st.piv_pin else None, "puk": st.piv_puk.to_dict() if st.piv_puk else None, "objects_present": st.piv_objects, + "secured": st.piv_secured, "notes": st.notes, } diff --git a/src/cryptnox_id_cli/cli/dryrun.py b/src/cryptnox_id_cli/cli/dryrun.py index 876dfb7..0fdf520 100644 --- a/src/cryptnox_id_cli/cli/dryrun.py +++ b/src/cryptnox_id_cli/cli/dryrun.py @@ -80,6 +80,7 @@ "piv discover", "piv export-attestation", "piv info", + "piv inventory", "piv objects list", "piv objects read", "piv perso generate-ccc", diff --git a/src/cryptnox_id_cli/state/detector.py b/src/cryptnox_id_cli/state/detector.py index e46ba9b..284afd5 100644 --- a/src/cryptnox_id_cli/state/detector.py +++ b/src/cryptnox_id_cli/state/detector.py @@ -6,6 +6,7 @@ import contextlib from cryptnox_id_cli.applets.genuine.genuine import GenuinenessApplet +from cryptnox_id_cli.applets.piv import inventory from cryptnox_id_cli.applets.piv import objects as piv_obj from cryptnox_id_cli.applets.piv.constants import REF_PIV_PIN, REF_PUK from cryptnox_id_cli.applets.piv.piv import PivApplet @@ -96,6 +97,14 @@ def _detect_piv(self, st: CardState) -> None: st.piv_apt = apt st.piv = PivState.SELECTABLE + status = None + with contextlib.suppress(CryptnoxError): + status = inventory.read_status(piv) + if status is None: + st.notes.append("Finalized (SECURED) state not readable from this applet.") + else: + st.piv_secured = status.secured + with contextlib.suppress(CryptnoxError): st.piv_pin = piv.pin_status(REF_PIV_PIN) st.piv_puk = piv.pin_status(REF_PUK) @@ -123,11 +132,6 @@ def _detect_piv(self, st: CardState) -> None: st.piv = PivState.PERSONALIZED else: st.piv = PivState.PARTIALLY_PERSONALIZED - if st.piv in (PivState.PRE_PERSONALIZED, PivState.PARTIALLY_PERSONALIZED): - st.notes.append( - "PIV SECURED (locked) state cannot be confirmed read-only; " - "it requires opening the admin channel (Phase 3.5)." - ) # ----------------------------------------------------------------- FIDO # def _detect_fido(self, st: CardState) -> None: diff --git a/src/cryptnox_id_cli/state/model.py b/src/cryptnox_id_cli/state/model.py index f16fc74..e5c8a0f 100644 --- a/src/cryptnox_id_cli/state/model.py +++ b/src/cryptnox_id_cli/state/model.py @@ -31,7 +31,6 @@ class PivState(str, Enum): PRE_PERSONALIZED = "pre_personalized" PARTIALLY_PERSONALIZED = "partially_personalized" PERSONALIZED = "personalized" - SECURED = "secured" @property def label(self) -> str: @@ -42,7 +41,6 @@ def label(self) -> str: "pre_personalized": "PivPrePersonalized", "partially_personalized": "PivPartiallyPersonalized", "personalized": "PivPersonalized", - "secured": "PivSecured", }[self.value] @@ -115,6 +113,8 @@ class CardState: piv_pin: PinStatus | None = None piv_puk: PinStatus | None = None piv_objects: dict[str, bool] = field(default_factory=dict) + #: finalized (SECURED) per the applet's own status object; None when not readable. + piv_secured: bool | None = None fido_versions: list[str] | None = None desfire_version: dict[str, object] | None = None genuine_leaf_subject: str | None = None @@ -135,6 +135,7 @@ def to_dict(self) -> dict[str, object]: "pin": self.piv_pin.to_dict() if self.piv_pin else None, "puk": self.piv_puk.to_dict() if self.piv_puk else None, "objects_present": self.piv_objects, + "secured": self.piv_secured, }, "fido": {"state": self.fido.label, "versions": self.fido_versions}, "desfire": {"state": self.desfire.label, "version": self.desfire_version}, diff --git a/tests/unit/test_piv_inventory.py b/tests/unit/test_piv_inventory.py new file mode 100644 index 0000000..459a0a9 --- /dev/null +++ b/tests/unit/test_piv_inventory.py @@ -0,0 +1,211 @@ +"""Keyless inventory of the PIV applet (GET DATA ``2F47xx``). The response bytes +were captured from a D600 card carrying the ``cryptnox-default`` structure.""" + +from __future__ import annotations + +import json + +import pytest +from _cardfakes import run, run_both_modes, wire_session + +from cryptnox_id_cli.applets.piv import inventory as inv +from cryptnox_id_cli.applets.piv.objects import get_data_extended_apdu +from cryptnox_id_cli.applets.piv.piv import PivApplet +from cryptnox_id_cli.transport.pcsc import CardSession + +SELECT_PIV = "00A404000BA00000030800001000010000" +APT = ( + "616F4F0BA000000308000010000100" + "79074F05A000000308" + "500B4F70656E46495053323031" + "5F5049687474703A2F2F6E766C707562732E6E6973742E676F762F6E697374707562732F" + "5370656369616C5075626C69636174696F6E732F4E4953542E53502E3830302D37332D342E706466" +) +VERSION = "532680184F70656E464950533230312D503731443630302D46495053810102820100830100840100" +STATUS = "53188001078101008201008301008401008501008601008701FF" +STATUS_SECURED = "531880010F8101008201008301008401008501008601008701FF" +CONFIG = "530F800100810100820100830100840100" +KEYS = [ + "53158B019B8C013F8D010091019B8E010C8F010190011C", + "53158B019A8C01018D010091019B8E01118F0101900110", + "53158B019C8C01018D010091019B8E01118F0104900110", + "53158B019D8C01018D010091019B8E01118F0102900110", + "53158B019E8C013F8D013F91019B8E01118F0101900110", +] +VERIFIERS = [ + "53248B01808C013F8D01008E01068F010890010691010092010093010094010095010096015A", + "53248B01818C013F8D01008E01088F010890010691010092010093010094010095010096015A", +] +CONTAINERS = [ + "530E8B035FC1028C013F8D013F91019B", + "530E8B035FC1078C013F8D014091019B", + "530E8B035FC1058C013F8D014091019B", + "530E8B035FC1018C013F8D013F91019B", + "530E8B035FC10A8C013F8D014091019B", + "530E8B035FC10B8C013F8D014091019B", + "530E8B035FC1068C013F8D014091019B", + "530E8B035FC1038C01018D014191019B", + "530E8B035FC1088C01018D014191019B", + "530E8B035FC1098C01018D014191019B", +] + + +def _ext(ident: int, index: int = 0) -> str: + return f"00CB3F{index:02X}055C032F{ident:04X}00" + + +def _exchanges(*, secured: bool = False, restricted: bool = False) -> dict[str, str]: + """Unknown commands answer 6A82, which is what ends each listing on the card.""" + ex = { + SELECT_PIV: f"{APT}|9000", + _ext(inv.ID_VERSION): f"{VERSION}|9000", + _ext(inv.ID_STATUS): f"{STATUS_SECURED if secured else STATUS}|9000", + _ext(inv.ID_CONFIG): f"{CONFIG}|9000", + } + listings = ((inv.ID_KEY, KEYS), (inv.ID_VERIFIER, VERIFIERS), (inv.ID_CONTAINER, CONTAINERS)) + for ident, rows in listings: + for index, row in enumerate(rows): + ex[_ext(ident, index)] = "|6986" if restricted else f"{row}|9000" + return ex + + +def test_extended_get_data_apdu_shape(): + assert get_data_extended_apdu(inv.ID_KEY, 2).to_bytes().hex().upper() == _ext(inv.ID_KEY, 2) + with pytest.raises(ValueError): + get_data_extended_apdu(inv.ID_KEY, 0xFF) # P2 = FF addresses a standard object + + +def test_key_header_decodes_slot_mechanism_role_and_modes(): + k = inv.parse_key(bytes.fromhex(KEYS[1])) + assert (k.ref, k.mechanism, k.role, k.attributes, k.admin_key) == (0x9A, 0x11, 0x01, 0x10, 0x9B) + assert (k.slot_name, k.mechanism_name) == ("authentication", "ECC-P256") + assert inv.role_names(k.role) == ["AUTHENTICATE"] + assert inv.attribute_names(k.attributes) == ["IMPORTABLE"] + assert (k.mode_contact, k.mode_contactless) == (0x01, 0x00) + admin = inv.parse_key(bytes.fromhex(KEYS[0])) + assert admin.mechanism_name == "AES-256" + assert inv.attribute_names(admin.attributes) == [ + "PERMIT-EXTERNAL", + "PERMIT-MUTUAL", + "IMPORTABLE", + ] + assert inv.role_names(inv.parse_key(bytes.fromhex(KEYS[2])).role) == ["SIGN"] + assert inv.role_names(inv.parse_key(bytes.fromhex(KEYS[3])).role) == ["KEY-ESTABLISH"] + + +def test_verifier_header_decodes_lengths_retries_and_rules(): + pin = inv.parse_verifier(bytes.fromhex(VERIFIERS[0])) + assert (pin.ref, pin.name) == (0x80, "PIN") + assert (pin.min_length, pin.max_length) == (6, 8) + assert (pin.retries_contact, pin.retries_contactless) == (6, 0) + assert pin.restrict_update is False # the applet's FALSE byte is 0x5A, not 0x00 + puk = inv.parse_verifier(bytes.fromhex(VERIFIERS[1])) + assert (puk.name, puk.min_length) == ("PUK", 8) + + +def test_container_header_decodes_three_byte_oid(): + chuid = inv.parse_container(bytes.fromhex(CONTAINERS[0])) + assert (chuid.oid_hex, chuid.name, chuid.admin_key) == ("5FC102", "chuid", 0x9B) + printed = inv.parse_container(bytes.fromhex(CONTAINERS[9])) + assert (printed.name, printed.mode_contact, printed.mode_contactless) == ("printed", 0x01, 0x41) + + +def test_version_status_and_config_decode(): + ver = inv.parse_version(bytes.fromhex(VERSION)) + assert (ver.label, ver.version, ver.debug) == ("OpenFIPS201-P71D600-FIPS", "2.0.0", False) + st = inv.parse_status(bytes.fromhex(STATUS)) + assert (st.state, st.secured, st.fips_mode, st.contactless) == (0x07, False, True, False) + assert inv.parse_status(bytes.fromhex(STATUS_SECURED)).secured is True + assert inv.describe_state(0x0F) == "SECURED" + assert not any(inv.parse_config(bytes.fromhex(CONFIG)).to_dict().values()) + + +@pytest.mark.parametrize( + ("mode", "words"), + [ + (0x00, "NEVER"), + (0x01, "PIN"), + (0x02, "PIN-ALWAYS"), + (0x03, "PIN, PIN-ALWAYS"), + (0x1F, "ALWAYS"), + (0x3F, "ALWAYS"), # the applet masks the extra bit + (0x40, "NEVER, SM"), + (0x41, "PIN, SM"), + (0x5F, "ALWAYS, SM"), + (0x60, "NEVER, VCI"), + (0x61, "PIN, VCI"), + (0x81, "PIN, USER-ADMIN"), + ], +) +def test_describe_mode(mode, words): + assert inv.describe_mode(mode) == words + + +def test_read_inventory_stops_at_the_first_missing_index(mock_connection): + piv = PivApplet(CardSession(mock_connection("3B00", _exchanges(), []))) + piv.select() + result = inv.read_inventory(piv) + assert [k.ref for k in result.keys] == [0x9B, 0x9A, 0x9C, 0x9D, 0x9E] + assert [v.ref for v in result.verifiers] == [0x80, 0x81] + assert len(result.containers) == 10 + assert [o.name for o in result.containers][:3] == ["chuid", "ccc", "auth-cert"] + assert result.restricted is False + + +def test_read_inventory_reports_withheld_structure(mock_connection): + piv = PivApplet(CardSession(mock_connection("3B00", _exchanges(restricted=True), []))) + piv.select() + result = inv.read_inventory(piv) + assert (result.keys, result.verifiers, result.containers) == (None, None, None) + assert result.restricted is True + assert result.version is not None # version, status and config still answer + + +def test_inventory_command_json_and_human(monkeypatch, mock_connection): + wire_session(monkeypatch, mock_connection("3B00", _exchanges(), [])) + js, human = run_both_modes(["piv", "inventory"]) + assert js.exit_code == 0, js.output + payload = json.loads(js.stdout) + assert payload["version"]["label"] == "OpenFIPS201-P71D600-FIPS" + assert payload["status"]["secured"] is False + assert payload["restricted"] is False + assert [k["ref"] for k in payload["keys"]] == ["9B", "9A", "9C", "9D", "9E"] + assert payload["keys"][2]["roles"] == ["SIGN"] + assert payload["keys"][1]["mode_contact"] == "PIN" + assert payload["verifiers"][0]["name"] == "PIN" + assert payload["containers"][1] == { + "oid": "5FC107", + "name": "ccc", + "mode_contact": "ALWAYS", + "mode_contact_raw": "3F", + "mode_contactless": "NEVER, SM", + "mode_contactless_raw": "40", + "admin_key": "9B", + } + assert human.exit_code == 0, human.output + assert "OpenFIPS201-P71D600-FIPS 2.0.0" in human.output + assert "not finalized" in human.output + assert "Key objects (5)" in human.output + assert "Verifiers (2)" in human.output + assert "Containers (10)" in human.output + + +def test_inventory_command_says_when_the_structure_is_withheld(monkeypatch, mock_connection): + wire_session(monkeypatch, mock_connection("3B00", _exchanges(restricted=True), [])) + js, human = run_both_modes(["piv", "inventory"]) + assert json.loads(js.stdout)["restricted"] is True + assert "restrict-enumeration" in human.output + + +def test_inventory_shows_finalized_state(monkeypatch, mock_connection): + wire_session(monkeypatch, mock_connection("3B00", _exchanges(secured=True), [])) + result = run(["piv", "inventory"]) + assert result.exit_code == 0, result.output + assert "SECURED, finalized" in result.output + + +def test_inventory_is_read_only_under_dry_run(monkeypatch, mock_connection): + wire_session(monkeypatch, mock_connection("3B00", _exchanges(), [])) + result = run(["--dry-run", "--json", "piv", "inventory"]) + assert result.exit_code == 0, result.output + assert json.loads(result.stdout)["keys"] diff --git a/tests/unit/test_preperso_status_gate.py b/tests/unit/test_preperso_status_gate.py index 2ef4178..dadab46 100644 --- a/tests/unit/test_preperso_status_gate.py +++ b/tests/unit/test_preperso_status_gate.py @@ -11,67 +11,65 @@ from cryptnox_id_cli.cli.main import main as root from cryptnox_id_cli.state.model import PivState +SELECTABLE = [PivState.PRE_PERSONALIZED, PivState.PARTIALLY_PERSONALIZED, PivState.PERSONALIZED] -class _NoChannel: - def __init__(self, session): - pass - def initialize_update_probe(self): - return {"supported": False} - - -def _status(monkeypatch, piv_state): +def _status(monkeypatch, piv_state, *, secured=None, json_out=True): @contextlib.contextmanager def fake_session(self): yield object() class _State: piv = piv_state + piv_secured = secured monkeypatch.setattr(AppContext, "open_session", fake_session) - monkeypatch.setattr(factory_cmd, "PivAdmin", _NoChannel) + monkeypatch.setattr(factory_cmd, "describe_security_domain", lambda session: None) monkeypatch.setattr(factory_cmd, "_probe_mgmt_key", lambda session: None) monkeypatch.setattr( factory_cmd, "StateDetector", lambda *a, **kw: type("D", (), {"detect": lambda s: _State})() ) - result = CliRunner().invoke(root, ["--json", "factory", "piv", "preperso", "status"]) + args = ["factory", "piv", "preperso", "status"] + result = CliRunner().invoke(root, (["--json"] if json_out else []) + args) assert result.exit_code == 0, result.output - return json.loads(result.stdout) + return json.loads(result.stdout) if json_out else result.output -@pytest.mark.parametrize( - "state", - [PivState.PRE_PERSONALIZED, PivState.PARTIALLY_PERSONALIZED, PivState.PERSONALIZED], -) +@pytest.mark.parametrize("state", SELECTABLE) def test_finalize_allowed_on_any_selectable_unsecured_card(monkeypatch, state): - payload = _status(monkeypatch, state) + payload = _status(monkeypatch, state, secured=False) assert payload["finalize_allowed"] is True assert payload["load_config_allowed"] is (state == PivState.PRE_PERSONALIZED) -@pytest.mark.parametrize("state", [PivState.SECURED, PivState.NOT_PRESENT, PivState.UNKNOWN]) -def test_finalize_not_allowed_when_finalize_itself_refuses(monkeypatch, state): +@pytest.mark.parametrize("state", SELECTABLE) +def test_unreadable_secured_flag_does_not_block_finalize(monkeypatch, state): + payload = _status(monkeypatch, state) + assert payload["secured"] is None + assert payload["finalize_allowed"] is True + + +@pytest.mark.parametrize("state", [PivState.NOT_PRESENT, PivState.UNKNOWN]) +def test_finalize_not_allowed_when_the_applet_is_not_selectable(monkeypatch, state): payload = _status(monkeypatch, state) assert payload["finalize_allowed"] is False assert payload["load_config_allowed"] is False -def test_human_output_no_longer_says_finalize_is_not_allowed_with_structure(monkeypatch): - @contextlib.contextmanager - def fake_session(self): - yield object() +def test_nothing_allowed_once_secured(monkeypatch): + payload = _status(monkeypatch, PivState.PRE_PERSONALIZED, secured=True) + assert payload["secured"] is True + assert payload["finalize_allowed"] is False + assert payload["load_config_allowed"] is False + out = _status(monkeypatch, PivState.PRE_PERSONALIZED, secured=True, json_out=False) + assert "Finalized (SECURED): yes" in out + assert "already finalized" in out + assert "structure is finalized" in out - class _State: - piv = PivState.PARTIALLY_PERSONALIZED - monkeypatch.setattr(AppContext, "open_session", fake_session) - monkeypatch.setattr(factory_cmd, "PivAdmin", _NoChannel) - monkeypatch.setattr(factory_cmd, "_probe_mgmt_key", lambda session: None) - monkeypatch.setattr( - factory_cmd, "StateDetector", lambda *a, **kw: type("D", (), {"detect": lambda s: _State})() - ) - result = CliRunner().invoke(root, ["factory", "piv", "preperso", "status"]) - assert result.exit_code == 0, result.output - assert "Finalize: allowed" in result.output - assert "finalize is NOT allowed" not in result.output - assert "structure present" in result.output +def test_human_output_no_longer_says_finalize_is_not_allowed_with_structure(monkeypatch): + out = _status(monkeypatch, PivState.PARTIALLY_PERSONALIZED, secured=False, json_out=False) + assert "Finalize: allowed" in out + assert "finalize is NOT allowed" not in out + assert "structure present" in out + assert "Finalized (SECURED): no" in out diff --git a/tests/unit/test_scp_label.py b/tests/unit/test_scp_label.py index 202c7a2..e347bb7 100644 --- a/tests/unit/test_scp_label.py +++ b/tests/unit/test_scp_label.py @@ -1,6 +1,6 @@ """`scp_label` is the single place that turns an SCP version byte into a display name. Its callers hand it loosely-typed values -- `PivAdmin.scp_version` (None until a -channel is open) and entries out of an `initialize_update_probe` payload -- so the +channel is open) and the version a security domain's key table implies -- so the "not a known version byte" paths matter as much as the two happy ones.""" import pytest diff --git a/tests/unit/test_set_mgmt_key.py b/tests/unit/test_set_mgmt_key.py index a5fa6db..953142d 100644 --- a/tests/unit/test_set_mgmt_key.py +++ b/tests/unit/test_set_mgmt_key.py @@ -67,9 +67,6 @@ def __init__(self, session: FakeSession, write_sw: int = 0x9000) -> None: 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") @@ -210,9 +207,11 @@ def fake_session(self): monkeypatch.setattr(AppContext, "open_session", fake_session) monkeypatch.setattr(factory_cmd, "PivAdmin", lambda s: adm) + monkeypatch.setattr(factory_cmd, "describe_security_domain", lambda s: None) class _State: piv = factory_cmd.PivState.PRE_PERSONALIZED + piv_secured = None monkeypatch.setattr( factory_cmd, "StateDetector", lambda *a, **kw: type("D", (), {"detect": lambda s: _State})() diff --git a/tests/unit/test_ssd_facts.py b/tests/unit/test_ssd_facts.py new file mode 100644 index 0000000..b1a9cec --- /dev/null +++ b/tests/unit/test_ssd_facts.py @@ -0,0 +1,145 @@ +"""Keyless facts about the PIV admin security domain: SELECT by AID plus the key +information template, never INITIALIZE UPDATE. On this chip an INITIALIZE UPDATE +that is not followed by a successful EXTERNAL AUTHENTICATE counts as a failed +authentication, so a status read must not send one.""" + +from __future__ import annotations + +import json + +from _cardfakes import ResponseAdapter, run, run_both_modes, wire_session + +from cryptnox_id_cli.applets.piv import ssd +from cryptnox_id_cli.applets.piv.admin import SCP02, SCP03 +from cryptnox_id_cli.transport.apdu import Response +from cryptnox_id_cli.transport.pcsc import CardSession + +SELECT_SSD = "00A4040009A0000001515350410100" +SELECT_ISD = "00A4040008A00000015100000000" +GET_E0 = "80CA00E000" +FCI_SSD = bytes.fromhex("6F118409A00000015153504101A5049F6501FF") +FCI_ISD = bytes.fromhex("6F108408A000000151000000A5049F6501FF") +# Captured from a D600 development card: one DES keyset, key version 1. +E0_DES = bytes.fromhex("E012C00401018010C00402018010C00403018010") +E0_AES_TWO_VERSIONS = bytes.fromhex( + "E024C00401018810C00402018810C00403018810C00401028810C00402028810C00403028810" +) +E0_EXTENDED = bytes.fromhex("E008C0060101FF880020") + +OK = (0x90, 0x00) + + +class _Dead: + """A card that knows nothing; every scripted answer comes from the adapter.""" + + def transmit(self, raw: bytes) -> Response: + return Response(b"", 0x6A, 0x82) + + +def _conn(answers: dict[str, tuple[bytes, int, int]]) -> ResponseAdapter: + return ResponseAdapter(_Dead(), answers=answers) + + +def _no_initialize_update(conn: ResponseAdapter) -> bool: + return not any(frame.startswith("8050") for frame in conn.log) + + +def test_des_key_table_from_the_card_means_scp02(): + keys = ssd.parse_key_information(E0_DES) + assert [(k.key_id, k.key_version, k.type_name, k.length) for k in keys] == [ + (1, 1, "DES", 16), + (2, 1, "DES", 16), + (3, 1, "DES", 16), + ] + info = ssd.SecurityDomainInfo(ssd.PIV_SSD_AID, tuple(keys), 0x9000) + assert (info.scp_version, info.key_versions) == (SCP02, [1]) + + +def test_aes_key_table_with_two_versions_means_scp03(): + keys = tuple(ssd.parse_key_information(E0_AES_TWO_VERSIONS)) + info = ssd.SecurityDomainInfo(ssd.PIV_SSD_AID, keys, 0x9000) + assert (info.scp_version, info.key_versions) == (SCP03, [1, 2]) + assert len(info.keys_for(2)) == 3 + payload = info.to_dict() + assert payload["scp_version"] == "SCP03" + assert [v["version"] for v in payload["key_versions"]] == [1, 2] + assert payload["key_versions"][0]["keys"][0] == { + "key_id": 1, + "key_version": 1, + "type": "AES", + "type_raw": "88", + "length": 16, + } + + +def test_extended_key_information_format(): + (key,) = ssd.parse_key_information(E0_EXTENDED) + assert (key.key_type, key.length) == (0x88, 32) + + +def test_empty_key_table_has_no_scp_version(): + assert ssd.SecurityDomainInfo(ssd.PIV_SSD_AID, (), 0x6982).scp_version is None + + +def test_describe_reads_the_piv_domain_without_initialize_update(): + conn = _conn({SELECT_SSD: (FCI_SSD, *OK), GET_E0: (E0_DES, *OK)}) + info = ssd.describe_security_domain(CardSession(conn)) + assert info is not None + assert (info.aid, info.key_versions, info.key_table_sw) == (ssd.PIV_SSD_AID, [1], 0x9000) + assert _no_initialize_update(conn) + + +def test_describe_falls_back_to_the_isd(): + conn = _conn({SELECT_ISD: (FCI_ISD, *OK), GET_E0: (E0_DES, *OK)}) + info = ssd.describe_security_domain(CardSession(conn)) + assert info is not None and info.aid == ssd.ISD_AID + + +def test_describe_is_none_when_no_domain_selects(): + assert ssd.describe_security_domain(CardSession(_conn({}))) is None + + +def test_withheld_key_table_keeps_its_status_word(): + conn = _conn({SELECT_SSD: (FCI_SSD, *OK), GET_E0: (b"", 0x69, 0x82)}) + info = ssd.describe_security_domain(CardSession(conn)) + assert info is not None + assert (info.keys, info.key_table_sw, info.scp_version) == ((), 0x6982, None) + + +def test_admin_status_lists_key_versions_without_authenticating(monkeypatch): + conn = _conn({SELECT_SSD: (FCI_SSD, *OK), GET_E0: (E0_DES, *OK)}) + wire_session(monkeypatch, conn) + js, human = run_both_modes(["piv", "admin", "status"]) + assert js.exit_code == 0, js.output + payload = json.loads(js.stdout) + assert (payload["aid"], payload["scp_version"]) == ("A00000015153504101", "SCP02") + assert payload["key_versions"][0]["keys"][0]["type"] == "DES" + assert human.exit_code == 0, human.output + assert "SCP02" in human.output and "DES" in human.output + assert _no_initialize_update(conn) + + +def test_admin_status_reports_a_withheld_key_table(monkeypatch): + wire_session(monkeypatch, _conn({SELECT_SSD: (FCI_SSD, *OK), GET_E0: (b"", 0x69, 0x82)})) + result = run(["piv", "admin", "status"]) + assert result.exit_code == 0, result.output + assert "withheld (SW=6982)" in result.output + + +def test_admin_status_fails_clearly_when_nothing_selects(monkeypatch): + wire_session(monkeypatch, _conn({})) + result = run(["piv", "admin", "status"]) + assert result.exit_code != 0 + assert "No security domain answered" in result.output + + +def test_preperso_status_reports_the_domain_and_sends_no_initialize_update(monkeypatch): + conn = _conn({SELECT_SSD: (FCI_SSD, *OK), GET_E0: (E0_DES, *OK)}) + wire_session(monkeypatch, conn) + result = run(["--json", "factory", "piv", "preperso", "status"]) + assert result.exit_code == 0, result.output + payload = json.loads(result.stdout) + assert payload["scp03_available"] is True + assert payload["scp_version"] == "SCP02" + assert payload["security_domain"]["key_versions"][0]["version"] == 1 + assert _no_initialize_update(conn) diff --git a/tests/unit/test_state_detector.py b/tests/unit/test_state_detector.py index 358a74f..7ee52bd 100644 --- a/tests/unit/test_state_detector.py +++ b/tests/unit/test_state_detector.py @@ -9,6 +9,38 @@ ) from cryptnox_id_cli.transport.pcsc import CardSession +SELECT_PIV = "00A404000BA00000030800001000010000" +APT = ( + "616F4F0BA000000308000010000100" + "79074F05A000000308" + "500B4F70656E46495053323031" + "5F5049687474703A2F2F6E766C707562732E6E6973742E676F762F6E697374707562732F" + "5370656369616C5075626C69636174696F6E732F4E4953542E53502E3830302D37332D342E706466" +) +GET_STATUS = "00CB3F00055C032F475300" +STATUS_SECURED = "531880010F8101008201008301008401008501008601008701FF" + + +def test_secured_flag_comes_from_the_applet_status_object(mock_connection): + exchanges = {SELECT_PIV: f"{APT}|9000", GET_STATUS: f"{STATUS_SECURED}|9000"} + conn = mock_connection("3B00", exchanges, []) + st = StateDetector( + CardSession(conn), probe_fido=False, probe_desfire=False, probe_genuine=False + ).detect() + assert st.piv_secured is True + # Finalize locks the structure only; the personalization ladder stays separate. + assert st.piv == PivState.PRE_PERSONALIZED + assert st.to_dict()["piv"]["secured"] is True + assert not any("SECURED" in n for n in st.notes) + + +def test_secured_flag_is_none_when_the_applet_has_no_status_object(acs_session): + st = StateDetector( + acs_session, probe_fido=False, probe_desfire=False, probe_genuine=False + ).detect() + assert st.piv_secured is None + assert any("SECURED" in n for n in st.notes) + def test_detect_real_acs_state(acs_session): st = StateDetector(acs_session).detect()