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
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down
18 changes: 15 additions & 3 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
67 changes: 67 additions & 0 deletions docs/adr/0002-management-key-fallback-for-on-card-generation.md
Original file line number Diff line number Diff line change
@@ -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.
7 changes: 4 additions & 3 deletions docs/agents/domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions docs/exit-codes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/factory/factory-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/getting-started.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``.
Expand All @@ -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.

Expand Down
17 changes: 17 additions & 0 deletions docs/piv/piv-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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
----------

Expand Down Expand Up @@ -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``
Expand Down
15 changes: 15 additions & 0 deletions docs/piv/piv-personalization.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
----------------------
Expand Down Expand Up @@ -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 —
Expand Down
119 changes: 119 additions & 0 deletions scripts/test-local.sh
Original file line number Diff line number Diff line change
@@ -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 <label> <fail-on-error 0|1> <python-ver> <bash-script>
run() {
local label=$1 hard=$2 ver=$3 script=$4
echo "==================== ${label} (py${ver}) ===================="
# ':ro,z' - z relabels for SELinux, else the container can't read the mount.
# That label is the one thing this script changes on your checkout: the repo is
# copied into the container rather than used in place, so no job writes a file
# there. .git and everything .gitignore covers stays behind: .git mutates
# whenever git runs on the host (index.lock), and local venvs, caches and build
# output would only slow the copy and could mask a clean build.
if docker run --rm -v "${REPO_ROOT}:/src:ro,z" -w /work "python:${ver}-slim" \
bash -c "set -e; tar -cf - -C /src \
--exclude='./.git' --exclude='./.venv' --exclude='./venv' --exclude='./env' \
--exclude='./build' --exclude='./dist' --exclude='./docs/_build' \
--exclude='*.egg-info' --exclude='__pycache__' \
--exclude='.mypy_cache' --exclude='.pytest_cache' --exclude='.ruff_cache' \
. | tar -xf - -C /work; ${script}"; then
echo "PASS ${label}"
else
echo "FAIL ${label}"
if [[ "${hard}" == 1 ]]; then fail=1; else echo " (informational - not failing the run)"; fi
fi
}

# Snippet installing the pyscard build toolchain.
PCSC_INSTALL='apt-get update -qq && apt-get install -y --no-install-recommends build-essential swig libpcsclite-dev >/dev/null'

# --- lint (once) ---
if [[ "${TEST_ONLY}" == 0 ]]; then
run "lint" 1 "${PRIMARY}" "
pip install -q ruff==${RUFF_PIN}
ruff check src tests
ruff format --check src tests
"
fi

# --- test (matrix) ---
for ver in "${VERSIONS[@]}"; do
run "test" 1 "${ver}" "
${PCSC_INSTALL}
python --version
pip install -q -e '.[dev]'
pytest -q -m 'not real_card'
"
done

if [[ "${TEST_ONLY}" == 0 ]]; then

# --- typecheck (once; a failure fails CI, so it fails here too) ---
run "typecheck" 1 "${PRIMARY}" "
${PCSC_INSTALL}
pip install -q -e . mypy
mypy
"

# --- build (once) ---
run "build" 1 "${PRIMARY}" "
pip install -q build
python -m build
"

# --- docs (once) ---
run "docs" 1 "${PRIMARY}" "
pip install -q -r docs/requirements.txt
sphinx-build -W --keep-going -b html docs docs/_build/html
"

fi # end TEST_ONLY guard

echo "========================================================"
if [[ "${fail}" == 0 ]]; then echo "ALL PASS"; else echo "FAILURES above"; fi
exit "${fail}"
11 changes: 11 additions & 0 deletions src/cryptnox_id_cli/applets/piv/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,17 @@ def open(
"expected 0x02 or 0x03)."
)

def forget_channel(self) -> None:
"""Drop the host's secure-channel state.

A re-SELECT of the applet resets the card's channel unconditionally, so after
one the host's session keys describe a channel that no longer exists. Clearing
them here keeps the two sides in step: the next :meth:`send` says "secure
channel not open" instead of wrapping into a void.
"""
self.scp = None
self.scp_version = None

def send(self, apdu: APDU, *, context: str | None = None) -> Response:
if self.scp is None:
raise Scp03Error("secure channel not open")
Expand Down
Loading
Loading