From d462f40b52d840a57a9a6eb23340737712acfbeb Mon Sep 17 00:00:00 2001 From: Mladen Milankovic Date: Fri, 9 Oct 2026 12:12:12 +0200 Subject: [PATCH] Read card facts without authenticating Add `piv inventory`: the applet's key objects, verifiers, containers and config flags with their access rules, from the vendor GET DATA 2F47xx identifiers. Read the finalized (SECURED) state from the applet's status object and report it beside the personalization ladder in `piv status`, `info`, `report` and `factory piv preperso status`; the finalize gate uses it. 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 removed and `initialize_update_probe` deleted. Co-Authored-By: Claude Fable 5.1 --- CHANGELOG.md | 14 + CONTEXT.md | 11 +- docs/factory/factory-commands.rst | 7 + docs/piv/piv-commands.rst | 15 +- docs/troubleshooting.rst | 4 + src/cryptnox_id_cli/applets/piv/admin.py | 28 +- src/cryptnox_id_cli/applets/piv/inventory.py | 435 +++++++++++++++++++ src/cryptnox_id_cli/applets/piv/objects.py | 9 + src/cryptnox_id_cli/applets/piv/piv.py | 6 + src/cryptnox_id_cli/applets/piv/ssd.py | 148 +++++++ src/cryptnox_id_cli/cli/commands/factory.py | 67 ++- src/cryptnox_id_cli/cli/commands/info.py | 2 + src/cryptnox_id_cli/cli/commands/piv.py | 157 ++++++- src/cryptnox_id_cli/cli/commands/report.py | 1 + src/cryptnox_id_cli/cli/dryrun.py | 1 + src/cryptnox_id_cli/state/detector.py | 14 +- src/cryptnox_id_cli/state/model.py | 5 +- tests/unit/test_piv_inventory.py | 211 +++++++++ tests/unit/test_preperso_status_gate.py | 68 ++- tests/unit/test_scp_label.py | 2 +- tests/unit/test_set_mgmt_key.py | 5 +- tests/unit/test_ssd_facts.py | 145 +++++++ tests/unit/test_state_detector.py | 32 ++ 23 files changed, 1274 insertions(+), 113 deletions(-) create mode 100644 src/cryptnox_id_cli/applets/piv/inventory.py create mode 100644 src/cryptnox_id_cli/applets/piv/ssd.py create mode 100644 tests/unit/test_piv_inventory.py create mode 100644 tests/unit/test_ssd_facts.py 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()