Skip to content

Add piv admin rotate-keys and delete-factory-keyset - #28

Merged
mmlado merged 1 commit into
mainfrom
feat/piv-admin-rotate-keys
Oct 9, 2026
Merged

mmlado merged 1 commit into
mainfrom
feat/piv-admin-rotate-keys

Conversation

@mmlado

@mmlado mmlado commented Oct 9, 2026

Copy link
Copy Markdown
Collaborator
  • New piv admin rotate-keys: replaces key version 1 of the PIV security domain in place with one PUT KEY (80 D8 01 81). Current keys from --default-keys or PIV_SCP03_ENC/MAC/DEK; new keys from PIV_SCP03_NEW_ENC/MAC/DEK (hex, 16 bytes each, resolve_new_scp03_keys), never the command line. Reads the key table first; refuses when key version 1 is absent, when the new keys are the test keys, or equal the current ones. The channel is opened at the security domain's own AID: the PIV applet does not proxy PUT KEY. Key values travel in the type the session speaks: DES under the session DEK on SCP02, AES under the static DEK on SCP03 (ssd.put_key_apdu). Post-checks: card-echoed key check values equal ours, a fresh authentication with the new keys, key table re-read with the key version 2 entries unchanged; any failure exits non-zero with the recovery hint. --dry-run prints the plan and the new keys' check values. Confirmation prompt or --yes.
  • New piv admin delete-factory-keyset: DELETE KEY by key version (80 E4 00 00 03 D2 01 02), authenticated with key version 1 from PIV_SCP03_* only (no --default-keys; refuses while those equal the test key). No-op with exit 0 when key version 2 is absent. Typed DELETE-FACTORY-KEYSET or --i-understand-this-is-irreversible; --dry-run. Re-reads the table and fails if version 2 is still listed.
  • ssd.py: kcv_des/kcv_aes (GP check values), encrypt_key_des/encrypt_key_aes, put_key_apdu, delete_key_version_apdu, CUSTOMER_KEY_VERSION = 1, CRYPTNOX_KEY_VERSION = 2.
  • cryptography>=43 is now the floor: the SCP02 code imports hazmat.decrepit (3DES), absent before 43; an install on a system with cryptography 41 failed at start-up.
  • Docs: both commands in piv-commands.rst, including that a domain is bound to SCP02 or SCP03 at creation and a keyset of the other type is accepted by the card but can never open a session.

Deviation from the plan: 32-byte keys are refused outright rather than hidden behind a flag.

Verification: the SCP02 path end to end on a D600 development card (dry run, rotation, authentication with the new keys, a throwaway key version 2 added and deleted, rollback, default-key check). The SCP03 path against a loopback security-domain double that runs the real session, static-DEK wrap and key check values (test_admin_rotate_scp03_loopback.py); not on SCP03 silicon, which no card in our hands provides. Known-answer tests pin the check values (8BAF47 for the default key under 3DES). Ruff, 533 pytest, Sphinx -W all green.

🤖 Generated with Claude Code

`rotate-keys` replaces key version 1 of the PIV security domain in place
with one PUT KEY: current keys from --default-keys or PIV_SCP03_*, new keys
from PIV_SCP03_NEW_ENC/MAC/DEK, never the command line. It reads the key
table first, writes only key version 1 in the type the session speaks (DES
under the session DEK on SCP02, AES under the static DEK on SCP03), compares
the key check values the card echoes, authenticates once with the new keys
and re-reads the table so key version 2 is confirmed untouched. A refused
PUT KEY writes nothing; a failed post-check exits non-zero with the recovery
hint. --dry-run shows the plan and check values.

`delete-factory-keyset` deletes key version 2 (DELETE KEY by version),
authenticated with key version 1 from PIV_SCP03_* only, refuses while those
are still the test keys, needs the typed DELETE-FACTORY-KEYSET or the
--i-understand-this-is-irreversible flag, and re-reads the table.

The SCP02 path is verified on hardware; the SCP03 path against a loopback
security-domain double that runs the real session, key wrap and check
values. cryptography>=43 is now required: the SCP02 code imports its
decrepit 3DES module.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

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()
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]
@mmlado
mmlado merged commit 72801fe into main Oct 9, 2026
11 of 13 checks passed
@mmlado
mmlado deleted the feat/piv-admin-rotate-keys branch October 9, 2026 13:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants