Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
mechanisms.
- `perso generate-key --create-key-object` - the same dev/eval fallback
`import-key` already had.
- `perso set-mgmt-key` - set the PIV management key (9B, AES) via CHANGE
REFERENCE DATA ADMIN over SCP03, the same shape as `set-pin`/`set-puk`.
- `piv status` now reports whether the management key (9B) is set, alongside
PIN/PUK status.

## [1.0.3] - 2026-08-31

Expand Down
14 changes: 13 additions & 1 deletion docs/piv/piv-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ Inspection
.. code-block:: text

piv info applet AID/label, supported algorithms, key slots
piv status lifecycle state, PIN/PUK status, object presence
piv status lifecycle state, PIN/PUK/management-key status, object presence
piv discover the Discovery object (PIN usage policy)
piv slots key slots and which hold a certificate
piv validate consistency check (NOT a NIST/FIPS validation)
Expand Down Expand Up @@ -108,6 +108,7 @@ Personalization (``piv perso``)

piv perso set-pin set the initial PIN (over SCP03)
piv perso set-puk set the initial PUK (over SCP03)
piv perso set-mgmt-key set the management key (9B, AES; over SCP03)
piv perso generate-key on-card key generation (per the slot's key objects)
piv perso import-key inject an externally generated private key
piv perso import-p12 one-command PKCS#12 (.p12/.pfx) key + certificate import
Expand Down Expand Up @@ -143,3 +144,14 @@ Notes
starts with a CLEAR, so re-runs are safe.
* Large objects (certificates) are written with ISO command chaining
automatically.
* ``piv perso set-mgmt-key`` sets the AES value of key reference 9B, sent as
CHANGE REFERENCE DATA ADMIN elements over SCP03 (CLEAR, then the raw key —
same shape as ``import-key``; this admin command is SCP03-only, with no 9B
alternative. The applet does genuinely support 9B as the PIV standard's
management key (SP 800-73's Administration Key) for *standard* PIV writes
(``PUT DATA``, ``GENERATE ASYMMETRIC KEY PAIR``): those accept either an
open SCP03 admin session or a completed GENERAL AUTHENTICATE (INS 0x87)
challenge-response with 9B — the genuinely standard part. |cli| only ever
takes the SCP03 path itself. ``--algorithm`` must match the mechanism the
pre-personalization profile gave the admin key object (``cryptnox-default``:
AES256); a mismatch returns ``6A88``.
20 changes: 20 additions & 0 deletions docs/piv/piv-personalization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ Step 1 — set the PIN and PUK
$ cryptnox-id piv perso set-pin --default-keys # prompts (masked) for the new PIN
$ cryptnox-id piv pin status # configured? retries?

Step 1b — set the management key (optional)
---------------------------------------------

Key reference 9B holds an AES key (``cryptnox-default``: AES-256). The applet
genuinely supports the PIV standard's management-key model (SP 800-73's
Administration Key, key reference 9B): a standard PIV write (``PUT DATA`` on
a container, ``GENERATE ASYMMETRIC KEY PAIR``) is authorized by *either* an
open SCP03 admin session *or* a completed 9B challenge-response (every
container/key in ``cryptnox-default`` is bound to 9B as its admin key). |cli|
only ever takes the SCP03 path and never authenticates with 9B itself — a
spec-compliant client that speaks GENERAL AUTHENTICATE with AES-256 (P1
``0x0C``) could use the other path once 9B is set.

.. code-block:: console

$ cryptnox-id piv perso set-mgmt-key --default-keys # prompts (masked) for the new key, as hex

The key value is raw AES key material (hex, sized to ``--algorithm``: 16/24/32 bytes).
``--algorithm`` must match whatever pre-perso gave the admin key object.

Step 2 — generate keys on the card
-----------------------------------

Expand Down
54 changes: 43 additions & 11 deletions src/cryptnox_id_cli/applets/piv/keyimport.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,22 @@
"""PIV asymmetric private-key injection (CHANGE REFERENCE DATA ADMIN over SCP03).
"""PIV private-key injection (CHANGE REFERENCE DATA ADMIN over SCP03).

OpenFIPS201 accepts externally generated key material with ``00 24 <P1=mechanism>
<P2=key reference>`` carrying exactly ONE key-element TLV per command, so an import
is a short sequence of admin commands (one fresh SCP03 session each on JCOP 4.5).
The target key object must already exist with the same (slot, mechanism) pair and
the IMPORTABLE attribute; RSA objects additionally fix CRT vs plain form at
creation. A key only becomes usable once both public and private parts are loaded,
and re-loading an initialised key requires a CLEAR first — every plan therefore
starts with CLEAR, which makes re-imports idempotent.

Element tags and exact-length rules (mirrors the applet's PIVKeyECC/PIVKeyRSA):
CLEAR ``9F`` (empty); ECC public point ``86`` (X9.62 uncompressed, 65/97 bytes) and
private scalar ``87`` (big-endian right-aligned, 32/48 bytes); RSA modulus ``81``
(k bytes), public exponent ``82`` (exactly 3 bytes), private exponent ``83``
(k bytes, plain form only), CRT components ``90``/``91``/``92``/``93``/``94``
(P/Q/dP/dQ/qInv, each k/2 bytes).
creation. A key only becomes usable once its parts are loaded, and re-loading an
initialised key requires a CLEAR first — every plan therefore starts with CLEAR,
which makes re-imports idempotent.

Element tags and exact-length rules (mirrors the applet's PIVKeyECC/PIVKeyRSA/
PIVKeySYM): CLEAR ``9F`` (empty); ECC public point ``86`` (X9.62 uncompressed,
65/97 bytes) and private scalar ``87`` (big-endian right-aligned, 32/48 bytes);
RSA modulus ``81`` (k bytes), public exponent ``82`` (exactly 3 bytes), private
exponent ``83`` (k bytes, plain form only), CRT components
``90``/``91``/``92``/``93``/``94`` (P/Q/dP/dQ/qInv, each k/2 bytes); symmetric
(AES) key value ``80`` (exactly the key length: 16/24/32 bytes) — the admin key
(9B) is the only symmetric object this applet exposes.
"""

from __future__ import annotations
Expand Down Expand Up @@ -43,6 +45,7 @@
ELEMENT_RSA_DP = 0x92
ELEMENT_RSA_DQ = 0x93
ELEMENT_RSA_PQ = 0x94
ELEMENT_SYM_KEY = 0x80 # PIVKeySYM's sole element: the raw secret key value

# Largest TLV body sent as a single (SCP03-wrapped) short APDU; larger bodies go
# through ISO command chaining. Matches PivAdmin.send_chained's plaintext budget.
Expand All @@ -53,6 +56,9 @@
_EC_SCALAR_LEN = {0x11: 32, 0x14: 48}
_RSA_MODULUS_LEN = {0x07: 256, 0x05: 384, 0x16: 512}

# AES mechanism -> raw key length in bytes (this applet's admin key, 9B, is AES-only).
_AES_KEY_LEN = {0x08: 16, 0x0A: 24, 0x0C: 32}

# Host-side slot policy (SP 800-78 per-key-reference algorithm table): PKI slots
# and the retired key history accept the four asymmetric mechanisms; 9B is
# AES-only and 04 is secure-messaging-only. The deployed pristine v2.0.0-fips
Expand Down Expand Up @@ -156,6 +162,32 @@ def validate_slot_mechanism(ref: int, mechanism: int) -> None:
raise KeyImportError(f"mechanism {mechanism:#04x} is not an asymmetric mechanism.")


def aes_key_len(mechanism: int) -> int | None:
"""Raw key length in bytes for an AES mechanism id, or ``None`` if not AES."""
return _AES_KEY_LEN.get(mechanism)


def sym_key_plan(mechanism: int, key_value: bytes) -> list[KeyElement]:
"""CLEAR, then the raw AES key value — the two commands PIVKeySYM accepts.

PIVKeySYM has exactly one element (``80``, the raw secret) and refuses it
outright (SW 6985) once the key is initialised, so — like the asymmetric
plans — this always starts with CLEAR to make re-running idempotent.
"""
expected = aes_key_len(mechanism)
if expected is None:
raise KeyImportError(f"mechanism {mechanism:#04x} is not a supported AES mechanism.")
if len(key_value) != expected:
raise KeyImportError(
f"AES key value must be exactly {expected} bytes for this mechanism, "
f"got {len(key_value)}."
)
return [
KeyElement(ELEMENT_CLEAR, b"", "CLEAR (9F)", False),
KeyElement(ELEMENT_SYM_KEY, key_value, "key value (80)", True),
]


def encode_exponent(e: int) -> bytes:
"""The applet takes the RSA public exponent as exactly 3 bytes."""
if e <= 0 or e >= 1 << 24:
Expand Down
61 changes: 61 additions & 0 deletions src/cryptnox_id_cli/applets/piv/piv.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from dataclasses import dataclass

from cryptnox_id_cli.applets.piv import constants as c
from cryptnox_id_cli.applets.piv import keyimport
from cryptnox_id_cli.applets.piv import objects as obj
from cryptnox_id_cli.applets.piv.apt import APTInfo, parse_apt
from cryptnox_id_cli.transport.apdu import APDU, Response
Expand Down Expand Up @@ -45,6 +46,50 @@ def _pin_status_from_sw(ref: int, resp: Response) -> PinStatus:
return PinStatus(ref, configured=False, blocked=False, verified=False, retries=None)


@dataclass(frozen=True)
class MgmtKeyStatus:
ref: int
mechanism: int | None # AES mechanism id the key object was found at, or None
configured: bool | None # None: exists, but access rules blocked telling (rare)

def to_dict(self) -> dict[str, object]:
return {
"ref": f"{self.ref:02X}",
"mechanism": c.ALGORITHMS.get(self.mechanism) if self.mechanism is not None else None,
"configured": self.configured,
}


# AES-128/192/256 - the only mechanisms the admin key (9B) can hold on this applet.
_ADMIN_KEY_MECHANISMS = (0x08, 0x0A, 0x0C)


def _mgmt_key_status_from_sw(ref: int, mechanism: int, resp: Response) -> MgmtKeyStatus | None:
"""One mechanism's GENERAL AUTHENTICATE probe result, or None ("no key object
at this mechanism - try the next one").

The probe body (``keyimport.probe_apdu``) shapes a CHALLENGE-with-data plus an
empty RESPONSE, i.e. GENERAL AUTHENTICATE's "Internal Authenticate" case. For a
ROLE_AUTHENTICATE symmetric key - what this applet always makes the admin key -
that shape is invalid once the existence/access/initialised preconditions are
behind it, so it lands deterministically on SW 6985. That gives a clean four-way
read: 6A86/6A88 no object, 6983 not initialised, 6982 access rules blocked the
check itself, 6985 initialised. Anything else is left unclassified rather than
assumed initialised.
"""
if resp.sw in (0x6A86, 0x6A88): # no key object at (ref, mechanism)
return None
if resp.sw == 0x6983: # exists, value not initialised
return MgmtKeyStatus(ref, mechanism, configured=False)
if resp.sw == 0x6982: # exists, but access rules blocked the check itself
return MgmtKeyStatus(ref, mechanism, configured=None)
if resp.sw == 0x6985: # existence + access + initialised all passed
return MgmtKeyStatus(ref, mechanism, configured=True)
# Key object exists (we're past the 6A86/6A88 branch) but this SW isn't one of
# the classified outcomes - report the mechanism without guessing further.
return MgmtKeyStatus(ref, mechanism, configured=None)


def _pad_pin(pin: bytes) -> bytes:
"""PIV PINs are 8 bytes, padded with 0xFF."""
if len(pin) > 8:
Expand Down Expand Up @@ -169,3 +214,19 @@ def unblock_pin(self, puk: bytes, new_pin: bytes, ref: int = c.REF_PIV_PIN) -> R
APDU(0x00, c.INS_RESET_RETRY, 0x00, ref, data=body),
context="RESET RETRY COUNTER",
)

# -- management key (9B) ------------------------------------------------- #
def mgmt_key_status(self, ref: int = c.KEYREF_ADMIN) -> MgmtKeyStatus:
"""Non-destructive status of the admin key (GENERAL AUTHENTICATE probe,
empty challenge - same technique ``keyimport.probe_apdu`` uses to check
an asymmetric slot before import; never touches a retry counter, since 9B
has none). Tries each AES mechanism this applet supports until one
resolves to an existing key object at this reference."""
for mechanism in _ADMIN_KEY_MECHANISMS:
resp = self.session.transmit(
keyimport.probe_apdu(ref, mechanism), context="GENERAL AUTHENTICATE (status)"
)
status = _mgmt_key_status_from_sw(ref, mechanism, resp)
if status is not None:
return status
return MgmtKeyStatus(ref, mechanism=None, configured=None)
119 changes: 117 additions & 2 deletions src/cryptnox_id_cli/cli/commands/piv.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@
from cryptnox_id_cli.state import StateDetector
from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError, describe_sw
from cryptnox_id_cli.util import tlv
from cryptnox_id_cli.util.hexutil import to_hex
from cryptnox_id_cli.util.hexutil import from_hex, to_hex

# Slots that carry a certificate container, and the object that holds it.
SLOT_CERT_OBJECT = {
Expand Down Expand Up @@ -98,15 +98,20 @@ def human(c: Console) -> None:
@command.command("status")
@click.pass_obj
def status(app: AppContext) -> None:
"""Show PIV lifecycle state, PIN/PUK status and which objects are present."""
"""Show PIV lifecycle state, PIN/PUK/management-key status and objects present."""
with app.open_session() as session:
st = StateDetector(session, probe_fido=False, probe_desfire=False).detect()
mgmt_key = None
if st.piv_apt is not None:
with contextlib.suppress(CryptnoxError):
mgmt_key = _select(session).mgmt_key_status()
payload: dict[str, object] = {
"state": st.piv.label,
"reader": app.resolved_reader,
"apt": st.piv_apt.to_dict() if st.piv_apt else None,
"pin": st.piv_pin.to_dict() if st.piv_pin else None,
"puk": st.piv_puk.to_dict() if st.piv_puk else None,
"mgmt_key": mgmt_key.to_dict() if mgmt_key else None,
"objects_present": st.piv_objects,
"notes": st.notes,
}
Expand All @@ -121,6 +126,22 @@ def human(c: Console) -> None:
extra = f", {st.piv_puk.retries} tries left" if st.piv_puk.retries is not None else ""
blocked = " [red](blocked)[/red]" if st.piv_puk.blocked else ""
c.print(f" PUK (81): {'set' if st.piv_puk.configured else 'not set'}{extra}{blocked}")
if mgmt_key:
mech_name = (
pivc.ALGORITHMS.get(mgmt_key.mechanism, "unknown mechanism")
if mgmt_key.mechanism is not None
else "unknown mechanism"
)
if mgmt_key.configured is True:
c.print(f" Management key (9B): [green]set[/green] ({mech_name})")
elif mgmt_key.configured is False:
c.print(f" Management key (9B): [yellow]not set[/yellow] ({mech_name})")
else:
c.print(
" Management key (9B): [dim]unknown[/dim] (could not tell from this probe)"
)
elif st.piv_apt is not None:
c.print(" Management key (9B): [dim]no admin key object found on this card[/dim]")
if st.piv_objects:
table = app.out.table("Object", "Present")
for name, present in st.piv_objects.items():
Expand Down Expand Up @@ -1626,6 +1647,100 @@ def perso_set_puk(app: AppContext, pin_value: str | None, default_keys: bool) ->
)


# Friendly diagnoses for CHANGE REFERENCE DATA ADMIN rejections while setting 9B.
_MGMT_KEY_SW_HINTS = {
0x6A88: (
"no 9B key object at this mechanism - pass --algorithm matching whatever "
"pre-perso gave the admin key object (cryptnox-default: AES256)"
),
0x6982: "the key object is not IMPORTABLE (or the admin channel is not accepted)",
}


def _execute_sym_key_set(
adm: PivAdmin, keys, ref: int, mech: int, plan: list[keyimport.KeyElement], label: str
) -> list[dict[str, object]]:
"""Send a symmetric-key element plan, one fresh SCP03 session per element
(JCOP 4.5 allows one application APDU per applet-directed secure-channel
session) — same shape as ``_execute_key_import``, minus the probe/create
step: unlike an asymmetric slot, 9B's key object is expected to already
exist from pre-perso, exactly like the PIN/PUK verifiers set-pin/set-puk
assume."""
results: list[dict[str, object]] = []
for index, el in enumerate(plan, 1):
adm.select()
adm.open(keys)
label_ctx = f"SET {label} [{el.label}] [{index}/{len(plan)}]"
resp = adm.send(keyimport.import_apdu(ref, mech, el), context=label_ctx)
results.append({"element": el.label, "sw": resp.sw_hex(), "ok": resp.ok})
if not resp.ok:
hint = _MGMT_KEY_SW_HINTS.get(resp.sw)
ctx = f"SET {label} [{el.label}]" + (f" - {hint}" if hint else "")
raise StatusWordError(resp.sw1, resp.sw2, context=ctx)
return results


@perso.command("set-mgmt-key")
@click.option(
"--algorithm",
default="AES256",
show_default=True,
type=click.Choice(["AES128", "AES192", "AES256"], case_sensitive=False),
help="Management key (9B) mechanism; must match what pre-perso gave the admin key object.",
)
@click.option(
"--key",
"key_value",
help="(discouraged) management key as hex on the CLI; "
"prefer env CRYPTNOX_PIV_MGMT_KEY or the prompt.",
)
@click.option(
"--default-keys",
is_flag=True,
help="Use the default GlobalPlatform TEST keys "
"(publicly known - fine for dev/eval, never for deployment).",
)
@click.pass_obj
def perso_set_mgmt_key(
app: AppContext, algorithm: str, key_value: str | None, default_keys: bool
) -> None:
"""Set the PIV management key (9B). Set it for standards-compliance/
interoperability with tools that authenticate using 9B management key.
"""
app.out.warn(
"replaces the current management key without needing its value (admin channel write)."
)
mech = prof_mod.MECHANISMS[algorithm.upper()]
expected_len = keyimport.aes_key_len(mech)
secret = resolve_secret(
redactor=app.redactor,
env_var="CRYPTNOX_PIV_MGMT_KEY",
prompt_label=f"New management key ({algorithm.upper()}, hex, {expected_len} bytes)",
provided=key_value,
)
try:
key_bytes = from_hex(secret.decode("ascii"))
except (ValueError, UnicodeDecodeError) as exc:
raise click.BadParameter(f"management key must be hex: {exc}") from exc
app.redactor.register(key_bytes)
plan = keyimport.sym_key_plan(mech, key_bytes)
keys = resolve_scp03_keys(app.redactor, default_keys=default_keys)
with app.open_session() as session:
adm = PivAdmin(session)
results = _execute_sym_key_set(adm, keys, pivc.KEYREF_ADMIN, mech, plan, "management key")
payload = {
"ref": f"{pivc.KEYREF_ADMIN:02X}",
"algorithm": algorithm.upper(),
"set": True,
"elements": results,
}

def human(c: Console) -> None:
c.print(f"[green]Management key set.[/green] ({algorithm.upper()}, ref 9B)")

app.out.result(payload, human)


def _resolve_asym_mech(algorithm: str) -> int:
mech = perso_mod.ALGORITHMS.get(algorithm.upper())
if mech is None or mech not in pivc.SUPPORTED_ALGORITHMS:
Expand Down
1 change: 1 addition & 0 deletions src/cryptnox_id_cli/cli/dryrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@
"piv perso generate-key",
"piv perso import-cert",
"piv perso self-sign-cert",
"piv perso set-mgmt-key",
"piv perso set-pin",
"piv perso set-puk",
"piv perso smoke-test",
Expand Down
Loading
Loading