diff --git a/CHANGELOG.md b/CHANGELOG.md index 2eed358..887453b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 floor from `fail_under`, and a tighter floor over the command modules. With branch coverage enabled both floors compare the combined statement-and-branch percentage, which sits below line coverage alone. +- `factory piv preperso set-mgmt-key` - load the PIV management key (9B) + value over the admin channel; `--replace` replaces a value that is set. + `factory piv preperso status` reports whether 9B holds a value. +- `PIV_MGMT_KEY` (hex) for the PIV management key, alongside the existing + `PIV_SCP03_*` admin-channel variables. ### Removed @@ -39,6 +44,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the session is on, so the DESFire diagnosis uses the reader name as well as the ATR. Cards that answer with their own wired-style ATR rather than the PC/SC composed contactless one were the affected case. +- `perso generate-key` and `quickstart` complete on cards that return at most + 256 bytes of an RSA public-key template over the admin channel: the + truncated response is detected and the generation is repeated over plain + APDUs after authenticating the PIV management key (9B). ECC and cards that + return the full template are unaffected. +- A public-key template the CLI cannot parse now ends in a CLI error naming + the response length, instead of an unhandled exception. +- A `PIV_SCP03_*` value that is not valid hex now ends in a CLI error naming + the variable, instead of an unhandled exception that printed the value. ## [1.0.3] - 2026-08-31 diff --git a/CONTEXT.md b/CONTEXT.md index 6e8e7b0..4999b02 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -70,9 +70,21 @@ _Avoid_: self-test (the FIDO2 credential self-test is a different operation) ### Keys and administration **Admin channel**: -The SCP03 secure channel to the PIV applet, through which all PIV writes go. This -card has no Yubico-style management key. -_Avoid_: management key (another vendor's concept; nothing on this card answers to it) +The SCP02/SCP03 secure channel to the PIV applet, through which PIV writes go. The +one exception is reading back a large generated public key, which some cards deliver +only after management-key authentication. +_Avoid_: management key (a different key with a different authentication - see +Management key) + +**Management key (9B)**: +The PIV applet's own AES administration key at key reference 9B. It authenticates over +plain APDUs (GENERAL AUTHENTICATE, mutual) and authorizes writes to the key objects it +administers, which is every key object unless a profile names another. Contact-only. +The CLI uses it for one thing: finishing on-card key generation when the admin channel +cannot return the full public-key template. Its value comes from `--default-keys` or +`PIV_MGMT_KEY`. +_Avoid_: admin key (collides with the admin channel), Yubico-style management key (it +does not replace the admin channel and no PIN/PUK operation uses it) **Default keys**: The publicly documented GlobalPlatform test key value. It authenticates convenience, diff --git a/docs/adr/0002-management-key-fallback-for-on-card-generation.md b/docs/adr/0002-management-key-fallback-for-on-card-generation.md new file mode 100644 index 0000000..ccf1e80 --- /dev/null +++ b/docs/adr/0002-management-key-fallback-for-on-card-generation.md @@ -0,0 +1,67 @@ +# 2. Management-key fallback for on-card key generation + +## Context + +On-card key generation returns the new public key as a `7F49` template in the +GENERATE response. An RSA template is larger than one 256-byte response: 270 bytes +for RSA-2048, and more for RSA-3072 and RSA-4096. A card that chains splits it +across a first response and GET RESPONSE frames, and the CLI's transport reassembles +it — that is the path every command already takes. + +Some cards do not chain on the secured response path. They deliver the first 256 +bytes, end with a plain success status, and drop the remainder. The response looks +complete to the host, so the CLI parsed a partial template and failed with a raw TLV +error. ECC is unaffected: its templates are 70 and 102 bytes. + +The applet is a certified build. Changing it is not on the table for this problem. + +## Options + +1. **Change the applet's response layer** so the secured path chains like the plain + one. Correct at the source, and unavailable: it forks a certified build. +2. **Always generate over plain APDUs**, behind a management-key authentication. + One code path, but it makes a working card depend on a key value that cards in the + field do not carry, and it moves an operation off the admin channel for every card + to fix a problem only some cards have. +3. **Ask for a response-MAC session**, whose framing does chain. Secure-channel-version + specific, and unavailable on one of the two channel versions this CLI speaks. +4. **Detect the truncation and fall back** to the plain path only then. + +## Decision + +Option 4. + +The detector reads the card's own response: an RSA mechanism, a success status, +exactly the requested length, and a `7F49` header declaring a larger object than +arrived. It consults no card identity, version or build, and it cannot fire for ECC +or for a card that returns the full template — those take the admin channel with the +same APDUs as before. + +When it fires, the CLI authenticates the PIV management key (key reference 9B) +mutually over plain APDUs and repeats the GENERATE as a plain APDU; the applet's +plaintext response path chains, and the existing transport loop reassembles the +template. Authentication is mutual rather than external, so the card proves it holds +the key too. The management key's value is never injected automatically: a card whose +9B holds no value is reported with the one command that loads one +(`factory piv preperso set-mgmt-key`). Writing a card's administration key is an +explicit act, not a side effect of generating a key. + +Only the GENERATE read-back moves off the admin channel. Object creation, key import, +certificate writes, PIN and PUK operations and data objects are untouched, as is the +transport. + +## Consequences + +* On a card that needs the fallback, a key pair is generated twice: the applet + generates before it builds the response, so the truncated first attempt already + replaced the slot's key. The key that survives is the one from the plain path, + and its public half is the one reported. The CLI says so before it retries. An + interruption between the two attempts leaves a private key whose public half + nobody knows; re-running the command resolves it. +* The fallback needs 9B to hold a value, and 9B is contact-only. +* "Management key" now names something real on this card, which the glossary + previously told readers to avoid. `CONTEXT.md` carries the new term and the + amended "Admin channel" entry. +* Management-key authentication grants a role that the applet keeps until the card + is deselected or loses power. The CLI does not spend an extra command trying to + drop it early. diff --git a/docs/agents/domain.md b/docs/agents/domain.md index 2f1a0ad..b31ee09 100644 --- a/docs/agents/domain.md +++ b/docs/agents/domain.md @@ -20,9 +20,10 @@ Single-context repo: one `CONTEXT.md` at the root, one `docs/adr/` (numbered When output names a domain concept — in a commit message, an issue title, a test name, a docs sentence — use the term as `CONTEXT.md` defines it and avoid the listed -synonyms. In this repo that means, for example: *admin channel*, never "management -key"; *pre-personalization* vs *personalization*, never the ambiguous "provisioning"; -*genuineness* or *key attestation*, never bare "attestation". +synonyms. In this repo that means, for example: *admin channel* and *management key +(9B)* are two different keys and never stand in for each other; *pre-personalization* +vs *personalization*, never the ambiguous "provisioning"; *genuineness* or *key +attestation*, never bare "attestation". If a concept you need isn't in the glossary, that's a signal: either you're inventing language the project doesn't use (reconsider), or there's a real gap — extend diff --git a/docs/exit-codes.rst b/docs/exit-codes.rst index 8a67096..4588aa0 100644 --- a/docs/exit-codes.rst +++ b/docs/exit-codes.rst @@ -37,8 +37,9 @@ machine token (see :doc:`/json-output`). - ``status_word`` - The card rejected a command (the message carries the ISO status word). * - 7 - - ``scp03_error`` - - SCP03 secure-channel failure (wrong admin keys, cryptogram mismatch). + - ``scp03_error`` / ``mgmt_key`` + - SCP03 secure-channel failure (wrong admin keys, cryptogram mismatch), or + PIV management-key (``9B``) authentication failed. * - 8 - ``profile_error`` - Pre-personalization profile parse/validation failure. diff --git a/docs/factory/factory-commands.rst b/docs/factory/factory-commands.rst index 2ffee0e..669579c 100644 --- a/docs/factory/factory-commands.rst +++ b/docs/factory/factory-commands.rst @@ -15,12 +15,22 @@ manufacturing procedure outside this documentation. factory piv preperso init-config write a built-in profile to editable YAML factory piv preperso export-config read-only snapshot of the card's structure factory piv preperso load-config apply a profile to the card (supports --dry-run) + factory piv preperso set-mgmt-key load the PIV management key (9B) value factory piv preperso finalize IRREVERSIBLY lock the applet structure ``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. +The PIV **management key** (key reference ``9B``) is the applet's own +administration key. Profiles create its key object but never carry a value, +because profiles are shareable files; ``set-mgmt-key`` writes the value over the +admin channel from ``--default-keys`` or ``PIV_MGMT_KEY`` +(``CARD_PIV_MGMT_KEY`` is accepted as an alias). With ``--default-keys`` the +published value is written and the variable is not read. It refuses to create a +missing ``9B`` object and refuses to overwrite a value that is already set +unless ``--replace`` is given; ``status`` shows whether ``9B`` holds a value. + Built-in profiles: ``cryptnox-default`` (the applet's own reference structure), ``developer`` / ``npivp-lab`` (the same structure, labelled for non-production use), ``ssh`` (9A gets SIGN added, nothing else changes — see diff --git a/docs/getting-started.rst b/docs/getting-started.rst index 8bbb823..f4c1dd9 100644 --- a/docs/getting-started.rst +++ b/docs/getting-started.rst @@ -104,6 +104,8 @@ Development keys * **SCP03 / GlobalPlatform ISD** — the published GlobalPlatform test key ``40 41 42 … 4F`` (``--default-keys``); + * **PIV management key (9B)** — the same published GlobalPlatform test + value, doubled for an AES-256 key object (``--default-keys``); * **DESFire** application keys after ``app create`` — all-zero AES (``--zero-key``); * **PIV PIN / PUK** in the examples — ``123456`` / ``12345678``. @@ -112,6 +114,7 @@ Development keys deployment must rotate all of them, which is exactly what the environment-variable key input is for: ``PIV_SCP03_ENC`` / ``PIV_SCP03_MAC`` / ``PIV_SCP03_DEK`` for the admin channel, + ``PIV_MGMT_KEY`` for the PIV management key, ``CRYPTNOX_PIV_PIN`` / ``CRYPTNOX_PIV_NEW_PIN`` / ``CRYPTNOX_PIV_NEW_PUK`` for PINs and PUKs, and the DESFire ``--key-env NAME`` form. diff --git a/docs/piv/piv-commands.rst b/docs/piv/piv-commands.rst index 3516347..f083388 100644 --- a/docs/piv/piv-commands.rst +++ b/docs/piv/piv-commands.rst @@ -8,6 +8,12 @@ admin keys come from ``--default-keys`` (development cards) or the variables. PIN values come from masked prompts or ``CRYPTNOX_PIV_PIN`` / ``CRYPTNOX_PIV_NEW_PIN`` / ``CRYPTNOX_PIV_NEW_PUK`` — never the command line. +On-card generation of a large public key can also use the card's **PIV +management key** (key reference ``9B``), whose value comes from +``--default-keys`` or ``PIV_MGMT_KEY`` — never the command line. +``--default-keys`` covers both the admin channel and the management key: when +it is given, ``PIV_MGMT_KEY`` is not read. + Inspection ---------- @@ -132,6 +138,17 @@ Notes imported there. ``ms-logon`` also creates an RSA-2048 object on 9A, and ``generate-key --slot 9A --algorithm RSA2048`` generates on-card on such a card. Asking for a mechanism the slot has no object for returns ``6A80``. +* Some cards cannot return an RSA public-key template larger than 256 bytes over + the admin channel. ``generate-key`` detects that from the card's own response + and finishes the generation over plain APDUs, authenticating the PIV management + key (``9B``) first. The management key is contact-only and its value comes from + ``--default-keys`` (development cards) or ``PIV_MGMT_KEY``; a card whose ``9B`` + object holds no value is reported with the command that loads one + (``factory piv preperso set-mgmt-key``). ECC is unaffected, and cards that + return the full template take the admin channel as before. +* When that path is taken, the key pair from the first attempt is replaced by the + one generated over the plain path, whose public half is the one reported; RSA + generation therefore runs twice on such a card. * ``piv quickstart`` defaults to ECC P-256. It accepts ``--algorithm RSA*`` only for ``--profile ms-logon`` on slot 9A — the one built-in shape with an RSA key object — and rejects it elsewhere; pass ``--algorithm RSA2048`` diff --git a/docs/piv/piv-personalization.rst b/docs/piv/piv-personalization.rst index 73b008f..ab96adf 100644 --- a/docs/piv/piv-personalization.rst +++ b/docs/piv/piv-personalization.rst @@ -19,6 +19,8 @@ Operator commands live under ``piv``; factory pre-personalization lives under keys — development/evaluation cards use the GlobalPlatform test keys (``--default-keys``); provisioned cards take theirs from the ``PIV_SCP03_ENC`` / ``PIV_SCP03_MAC`` / ``PIV_SCP03_DEK`` environment variables. +``PIV_MGMT_KEY`` carries the PIV management key (``9B``) for cards that need it +(see Step 2). Quickstart (one shot) ---------------------- @@ -105,6 +107,19 @@ on-card just as well — ``ms-logon`` creates an ``RSA2048`` object on 9A, and:: succeeds there, with the private key never leaving the card. Requesting a mechanism the slot has no object for returns ``6A80``. +Over the admin channel some cards deliver at most 256 bytes of a public-key +template, which an RSA template exceeds. ``generate-key`` reads that from the +response and completes the generation with the PIV management key (``9B``) over +plain APDUs; the command line is the same either way. That path is contact-only +and needs the ``9B`` value — ``--default-keys`` on development cards, +``PIV_MGMT_KEY`` otherwise. Profiles create the ``9B`` key object but carry no +value; load one once with: + +.. code-block:: console + + $ cryptnox-id factory piv preperso set-mgmt-key --default-keys + $ cryptnox-id piv perso generate-key --slot 9A --algorithm RSA2048 --out 9a.pub.pem --default-keys + ``piv quickstart`` follows the same rule with training wheels: it defaults to ECC P-256 everywhere, accepts explicit RSA only for ``--profile ms-logon`` on 9A (the one built-in shape with an RSA object, and the one Windows needs — diff --git a/scripts/test-local.sh b/scripts/test-local.sh new file mode 100755 index 0000000..48a7521 --- /dev/null +++ b/scripts/test-local.sh @@ -0,0 +1,119 @@ +#!/usr/bin/env bash +# Local CI mirror. Runs the GitHub Actions jobs on your machine via Docker, using +# python:X-slim images. No git, no runner, no reader: unit tests run against the +# mock transport. +# +# Usage: +# scripts/test-local.sh # everything: all jobs, full matrix +# scripts/test-local.sh 3.10 3.14 # limit the pytest matrix to these +# scripts/test-local.sh --test-only 3.10 # pytest only, no lint/typecheck/build/docs +# +# Jobs mirrored: +# lint once ruff check + format --check, pinned ruff (.github/workflows/ci.yml) +# typecheck once mypy (ci.yml) +# test matrix pytest, mock transport (ci.yml) +# build once python -m build (ci.yml) +# docs once sphinx-build -W, HTML only (.github/workflows/docs.yml) +# +# The default matrix covers every Python version the package declares, which is +# wider than CI's 3.10 + 3.14. The docs job builds HTML only; the PDF step in +# docs.yml needs a full LaTeX install and is not mirrored. +# +# pyscard is a C extension with no Linux wheel, so any job that pip-installs the +# package first installs the build toolchain (build-essential swig libpcsclite-dev). +set -euo pipefail + +# --test-only: run just the pytest matrix, skip lint/typecheck/build/docs. +TEST_ONLY=0 +if [[ "${1:-}" == "--test-only" ]]; then + TEST_ONLY=1 + shift +fi + +# Per-version matrix. Default: every version in pyproject.toml's classifiers. +if [[ $# -gt 0 ]]; then + VERSIONS=("$@") +else + VERSIONS=(3.10 3.11 3.12 3.13 3.14) +fi + +# Version used for the run-once jobs (matches CI). +PRIMARY=3.12 +RUFF_PIN=0.15.16 + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +fail=0 + +# run