Skip to content
Merged
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`piv status`, `info`, `report` and `factory piv preperso status`.
- `piv admin keys`: the admin security domain's key table; `--check-default`
authenticates once to key version 1 with the GlobalPlatform default key.
- `piv admin rotate-keys`: replace key version 1 of the PIV security domain in
place; new keys from `PIV_SCP03_NEW_ENC` / `_MAC` / `_DEK`, key version 2
never addressed, key check values and a fresh authentication verified after.
- `piv admin delete-factory-keyset`: delete key version 2 after a typed
confirmation; refuses while key version 1 is still the test key.
- `RSA4096` in `perso generate-key`, `perso import-key`, CSR and
self-signed-cert signing, and profile key mechanisms.
- `perso generate-key --create-key-object`, the dev/eval fallback
Expand Down Expand Up @@ -43,6 +48,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- `factory piv preperso status` reported the SCP version of whatever applet was
selected last, not the PIV security domain's.
- `cryptography>=43` is now required: the SCP02 code imports its `decrepit`
3DES module, which older releases do not have, so an install on a system
with cryptography 41 failed at start-up.

- `factory piv preperso load-config` warns that structural operations are
permanent (existing elements cannot be changed or removed; only new ones can
Expand Down
26 changes: 26 additions & 0 deletions docs/piv/piv-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,9 @@ Admin channel
piv admin status the admin security domain: SCP version, key versions (no auth)
piv admin keys its key table; --check-default tests key version 1 (one attempt)
piv admin authenticate open the channel (mutual auth) + harmless self-test
piv admin rotate-keys replace key version 1 in place (new keys from PIV_SCP03_NEW_*)
piv admin delete-factory-keyset
IRREVERSIBLY delete key version 2, Cryptnox's keyset

``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
Expand All @@ -89,6 +92,29 @@ failed-authentication count clear; a wrong key costs one failed authentication,
which the next successful ``piv admin authenticate`` clears. It never tries more
than that one key, and skips the check when the table has no key version 1.

``piv admin rotate-keys`` replaces key version 1 of the PIV security domain in
place: one PUT KEY carrying ENC, MAC and DEK, encrypted under the current DEK,
with the key version byte left at 1. The current keys come from ``--default-keys``
or ``PIV_SCP03_ENC`` / ``PIV_SCP03_MAC`` / ``PIV_SCP03_DEK``; the new ones from
``PIV_SCP03_NEW_ENC`` / ``PIV_SCP03_NEW_MAC`` / ``PIV_SCP03_NEW_DEK`` (hex, 16
bytes each), never the command line. The command reads the key table first,
refuses to write anything but key version 1, compares the key check values the
card echoes with its own, authenticates once with the new keys, and re-reads the
table to confirm key version 2 is as it was. Key version 2 is Cryptnox's keyset
for remote reset and attestation; the command never addresses it. ``--dry-run``
prints the plan with the new keys' check values and sends nothing. A refused PUT
KEY writes nothing, so the current keys still work. The new keys take the type the
domain speaks, DES on SCP02 and AES on SCP03: a domain is bound to one of the two
when it is created, and a keyset of the other type is accepted by the card yet can
never open a session afterwards.

``piv admin delete-factory-keyset`` deletes key version 2 (DELETE KEY by key
version), authenticated with key version 1 from the ``PIV_SCP03_*`` variables.
It refuses while those are the publicly known test keys, and needs the typed
confirmation ``DELETE-FACTORY-KEYSET`` or ``--i-understand-this-is-irreversible``.
Afterwards Cryptnox's remote reset and attestation cannot reach the card; key
version 1 is the only way left to administer the PIV applet.

Certificates and objects
------------------------

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ dependencies = [
"pyscard>=2.0",
"click>=8.1",
"rich>=13.0",
"cryptography>=41.0",
"cryptography>=43.0",
# Cap below 6: cbor2 6.x is a Rust build with no Intel-macOS wheel, so a plain
# `pip install` fails there without a Rust toolchain. Our CBOR use is trivial
# (CTAP dumps/loads) and 5.x ships wheels everywhere.
Expand Down
75 changes: 71 additions & 4 deletions src/cryptnox_id_cli/applets/piv/ssd.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
"""Keyless facts about the GlobalPlatform security domain that administers the PIV
applet: which SCP it speaks and which key versions it holds.
"""The GlobalPlatform security domain that administers the PIV applet: keyless facts
(which SCP it speaks, which key versions it holds) and the key-management commands
(PUT KEY, DELETE KEY) that a holder of its keys sends it.

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.
The facts are plain GET DATA on the selected domain. No INITIALIZE UPDATE is sent for
them, so nothing there counts against the card's failed-authentication limit.
"""

from __future__ import annotations

from dataclasses import dataclass

from cryptography.hazmat.decrepit.ciphers.algorithms import TripleDES
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes

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.transport.scp03 import Scp03Keys
from cryptnox_id_cli.util import tlv

PIV_SSD_AID = bytes.fromhex("A00000015153504101")
Expand All @@ -26,6 +31,7 @@
TAG_KEY_INFO = 0xC0

# GlobalPlatform key type coding (GPCS 2.3, table 11-16).
KEY_TYPE_DES = 0x80
KEY_TYPE_AES = 0x88
_DES_TYPES = frozenset({0x80, 0x81, 0x82, 0x83, 0x84})
_KEY_TYPES = {
Expand Down Expand Up @@ -137,6 +143,67 @@
)


def kcv_des(key: bytes) -> bytes:
"""GP key check value for a 2-key 3DES key: 3DES-ECB of eight zero bytes, first 3 bytes."""
enc = Cipher(TripleDES(key + key[:8]), modes.ECB()).encryptor() # noqa: S304 - KCV
return (enc.update(bytes(8)) + enc.finalize())[:3]


def kcv_aes(key: bytes) -> bytes:
"""GP key check value for an AES key: AES-ECB of sixteen 0x01 bytes, first 3 bytes."""
enc = Cipher(algorithms.AES(key), modes.ECB()).encryptor() # noqa: S305 - KCV
return (enc.update(b"\x01" * 16) + enc.finalize())[:3]


def kcv(scp_version: int, key: bytes) -> bytes:
return kcv_aes(key) if scp_version == SCP03 else kcv_des(key)


def encrypt_key_des(session_dek: bytes, key: bytes) -> bytes:
"""SCP02 key data: 3DES-ECB under the session DEK."""
enc = Cipher(TripleDES(session_dek + session_dek[:8]), modes.ECB()).encryptor() # noqa: S304
return enc.update(key) + enc.finalize()


def encrypt_key_aes(dek: bytes, key: bytes) -> bytes:
"""SCP03 key data: AES-CBC with a zero ICV under the static DEK."""
enc = Cipher(algorithms.AES(dek), modes.CBC(bytes(16))).encryptor()
return enc.update(key) + enc.finalize()


def put_key_apdu(
scp_version: int,
dek: bytes,
new_keys: Scp03Keys,
*,
replace_version: int,
new_version: int,
) -> tuple[APDU, list[bytes]]:
"""PUT KEY writing ENC, MAC and DEK (key IDs 1-3) as ``new_version``.

``replace_version`` is the version being replaced; 0 adds a new one. ``dek`` is the
key the new values travel under: the session DEK on SCP02, the static DEK on SCP03.
Returns the APDU and the three key check values the card echoes on success.
"""
blocks = b""
kcvs: list[bytes] = []
for key in (new_keys.enc, new_keys.mac, new_keys.dek):
check = kcv(scp_version, key)
if scp_version == SCP03:
block = bytes([KEY_TYPE_AES, 0x11, 0x10]) + encrypt_key_aes(dek, key)
else:
block = bytes([KEY_TYPE_DES, 0x10]) + encrypt_key_des(dek, key)
blocks += block + bytes([0x03]) + check
kcvs.append(check)
# P2: bit 8 set = several keys follow, low bits = first key ID.
return APDU(0x80, 0xD8, replace_version, 0x81, data=bytes([new_version]) + blocks), kcvs


def delete_key_version_apdu(version: int) -> APDU:
"""DELETE every key of one key version (tag D2)."""
return APDU(0x80, 0xE4, 0x00, 0x00, data=bytes([0xD2, 0x01, version]))


def describe_security_domain(
session: CardSession, aids: tuple[bytes, ...] = (PIV_SSD_AID, ISD_AID)
) -> SecurityDomainInfo | None:
Expand Down
Loading
Loading