kkeyroost

Learn · PIV

PIV — smart-card certificates

A smart-card standard for holding X.509 certificates and their keys — the backbone of certificate-based SSH, VPN, workstation login, and document signing.

keyroost screenshot — The PIV tab: card status, PIN/PUK/management-key, and per-slot key and certificate management.
The PIV tab: card status, PIN/PUK/management-key, and per-slot key and certificate management.

What it is

PIV (Personal Identity Verification, NIST SP 800-73-4) began as a US-government employee-credential standard and is now widely supported on general-purpose security keys. It stores certificates and private keys in numbered slots, each with an intended purpose, and performs signing/auth with them on-device — gated by a PIN, with the slots' use governed by a management key.

The common key slots

PIV also defines 20 retired key-management slots, 82–95. They hold decryption keys rotated out of 9D so old mail and archives stay readable, and keyroost treats them as first-class slots: every slot-taking command accepts --slot 82 … --slot 95, the desktop app lists them under the PIV pane's retired keys (82–95) tab, which reads each slot's contents when you open it, and piv info lists the retired slots that hold a key or a certificate after the standard four (also in --json's slots).

PIN & PUK

PIV has a user PIN plus a PUK that can reset a blocked PIN. Exhaust both retry counters and only a full applet reset can recover the card, erasing its keys and certs. Note that PIV's reset instruction is a vendor extension — SP 800-73-4 defines none — so a standards-only card that never implemented it cannot be recovered by keyroost at all once both counters are spent. keyroost's whole-device factory reset detects that case up front and leaves PIV out of the sweep, rather than blocking the PIN and PUK on a card it could not then wipe. See resetting →

The management key

Separate from the PIN, a third credential — the management key — gates administrative changes: generating keys, importing certificates, and changing retry counts. Most cards ship with a well-known factory default (010203…0708, repeated to fill the key), so anyone can administer a fresh card. If you rely on PIV day-to-day, change it — keyroost can change it to a new key you supply (generate a random one and keep it somewhere safe, such as a password manager). Lose a changed key and no admin changes are possible until a full applet reset. On the command line, --mgmt-key default uses the card's factory default when keyroost knows it for that card.

What keyroost does with PIV

k

Full management. Status (which PIV applet the card runs, its version, serial, PIN retries, slot contents) needs no PIN. Beyond that, keyroost generates keys on the card (RSA 1024–4096, ECC P-256/P-384/P-521, Ed25519, X25519 — whichever the card accepts), creates a self-signed certificate in the slot or a certificate request (CSR) for a certificate authority — signed by the card itself, the private key never leaves it — imports and exports certificates, imports a private key with its certificate from a .p12/.pfx file, changes the PIN, PUK, retry counts and management key, moves a key between slots, clears a slot's certificate or key, tests that a slot's key still works, writes a fresh CHUID, and resets the applet. The same controls are in the desktop app's PIV pane.

Importing a key from a file. piv key import --slot 9a --in FILE (in the app, Import key from file…) writes a private key and its certificate from a .p12/.pfx file. keyroost opens the file with its password and checks that this key can take it before it asks for the management key; then it checks the PIN, writes the key and the certificate, and tests the slot as piv test does. Legacy-encrypted files open too. A file with several keys is listed so you can pick one with --entry N; the authority certificates in the file are not written to the card, and --chain-out FILE saves them. On a Nitrokey 3, keys go into slot 9A only. An imported key can't be attested as generated on the key.

keyroostctl piv info
keyroostctl piv key generate --slot 9a --algorithm eccp256    # asks for the management key
keyroostctl piv cert generate --slot 9a --subject "CN=Alice" --days 365   # asks for the PIN and the management key
keyroostctl piv cert request --slot 9a --subject "CN=Alice,O=Example" --out request.csr
keyroostctl piv cert export --slot 9a                  # prints the certificate as PEM
keyroostctl piv cert export --slot 9a --format der --out cert.der
keyroostctl piv key generate --slot 9a --mgmt-key default    # a fresh card's factory-default key
keyroostctl piv pin change                             # asks for the current PIN, then the new one

Typed by hand, each secret is asked for at a hidden prompt. In a script, give it as --pin env:NAME or --pin stdin (and the same for --mgmt-key, --puk and the --new-… flags). When two flags read stdin on one command, keyroost reads them as consecutive lines, and each flag's --help says which line it reads — for cert generate that is the PIN first, then the management key; for a change, the current secret first, then the new one. -s is short for --slot, -o for --out and -i for --in.

Both cert commands take --generate-key to generate the slot's key in the same step (with the same --algorithm, --pin-policy and --touch-policy options as key generate), which spares cards without GET METADATA the --pubkey-out / --pubkey-in detour.

Slot states. In piv info and the app, a slot without a certificate reads empty when the card says it holds no key, key present, no certificate when the card confirms a key, and no certificate (a key may be present) when keyroost can't tell. keyroost can't tell on cards where it doesn't use GET METADATA's key type, and on cards whose GET METADATA answer neither names a key nor says there is none. A slot that holds a certificate but, as the card confirms, no key reads …, no key in piv info and certificate present, no key in the app.

Certificates. keyroost reads certificates stored gzip-compressed (the PIV standard's CertInfo 0x01 form) and verifies their checksum. A damaged one, or one that would decompress past 64 KiB, shows as present but unreadable: in piv info's text, as cert_unreadable in --json, and as “unreadable cert” in the app. A certificate the card refuses as too large, even with the compression choice below, fails with a clear “too large” message, or “no room left” when the card is full, and leaves the slot's existing certificate unchanged. keyroost also refuses a certificate whose public key doesn't match the key already in the slot, when it can read that key. A certificate can also go into a slot with no key yet; keyroost then notes that it won't work until a matching key is put in the slot. cert export writes the certificate as PEM, to stdout or to --out FILE; add --format der for the raw DER bytes. cert import reads the certificate from --in FILE. When an output file already exists (--out on cert export, cert request, cert generate or key generate, or --pubkey-out), a terminal asks "overwrite? [y/N]"; a script must add --overwrite. A directory as the output is refused before the card is touched.

Clearing a slot. cert delete removes only the slot's X.509 certificate object and leaves the private key in place — standard PIV, so it works on every card. key delete permanently erases the slot's private key (a Yubico extension: YubiKey firmware 5.7 or newer, or another card that implements it; where it isn't available, overwrite the slot with a fresh key generate instead). Both need the management key and are destructive, so they name the key and ask y/N first. A script has no terminal to ask on and adds --yes; so does a command whose management key is piped in with --mgmt-key stdin. key generate, cert import, cert generate and --generate-key ask the same way when the slot already holds a key or certificate, and retries set always asks: it also resets the PIN and PUK to their factory defaults (a Yubico extension).

keyroostctl piv cert delete --slot 9a                 # asks y/N, then for the management key
keyroostctl piv key delete  --slot 9a                 # Yubico extension
keyroostctl piv key delete  --slot 9a --mgmt-key env:PIV_MGMT --yes   # in a script

Moving a key between slots. key move relocates a slot's private key without exporting it — the Yubico MOVE KEY instruction: YubiKey firmware 5.7 or newer, or another card that implements it. It is non-destructive, so it doesn't ask first: keyroost refuses when the card reports that the destination slot already holds a key (delete that slot's key first, or pick an empty slot). When keyroost can't tell whether the destination holds a key, it says so and sends the move, and the card decides. It also refuses a same-slot move, and the source slot's X.509 certificate object stays where it is (move the certificate separately with cert export + cert import if you want it to follow). Both the standard and the retired slots are valid on either end, which makes archiving a superseded 9D decryption key into a retired slot a single command. The desktop app offers the same thing as Move key….

keyroostctl piv key move --from 9d --to 82   # archive a rotated key (asks for the management key)
keyroostctl piv key move --from 82 --to 9d   # bring it back

Testing a slot's key. test proves a slot's private key still works end to end, without changing anything on the card. keyroost reads the slot's certificate, takes its public key, and builds a fixed challenge from it; the card then runs every private-key operation the key's algorithm supports and keyroost verifies each result against that same public key. The operations depend on the key type — decrypt for RSA, key-agree (ECDH) for P-256 / P-384 / P-521 / X25519, and sign for RSA / ECDSA / Ed25519 — and an operation the key can't do is reported as skipped, not failed. It needs the PIN unless the slot's PIN policy is never (typically 9E), in which case leave the PIN flags off; a PIN-per-use slot such as 9C is re-verified before each operation. The challenge is deterministic — sliced from one built-in constant — so a run is fully reproducible and pulls in no randomness. The desktop app offers the same thing as Test… in the slot pane, just below Delete; the button is live when the slot holds a certificate, or a key the card reports, of a type keyroost can test.

A slot with a key but no certificate (an X25519 key never has a self-signed one, and a freshly generated key has none yet) is tested against the public key the card reports for it, where the card reports one (GET METADATA: a YubiKey 5.3 or later, for instance, and other cards keyroost lists as supporting it). The key is proven to work, but no certificate was part of the test: the result says so, and --json sets cert_tested to false.

keyroostctl piv test --slot 9a --pin env:PIV_PIN  # never asks: --pin names the PIN source
keyroostctl piv test --slot 9e                 # PIN policy "never": no PIN needed
keyroostctl --json piv test --slot 9c --pin env:PIV_PIN

The command prints one line per operation — Decrypt: passed, Sign: FAILED — …, Key Agree: skipped — not supported for … — and exits non-zero if any operation failed. With --json it emits { slot, slot_name, algorithm, ok, operations: [ { operation, result, detail } … ] } instead: slot is the --slot value ("9a"), slot_name its label, and detail is the reason when the result is failed, otherwise null. A slot with no certificate, or a key type keyroost can't verify, is an error — nothing is tested.

Writing a new CHUID. chuid generate writes a fresh Card Holder Unique Identifier with a randomly-generated GUID (or one you supply with --guid). Windows' PIV minidriver caches a card's contents keyed by the CHUID's GUID, so after reprovisioning a card Windows can keep showing stale data until the GUID changes — a new CHUID forces it to re-read the card. It needs the management key. The desktop app offers the same thing as New CHUID… in the card settings.

keyroostctl piv chuid generate                 # asks for the management key

Compressed certificates. The PIV standard lets a slot's certificate be stored gzip-compressed: the certificate object's CertInfo byte says whether it is (NIST SP 800-73-4, Part 1, Appendix A). keyroost reads compressed certificates, and piv info marks them stored compressed. When writing one with cert import or cert generate, keyroost stores the certificate uncompressed by default and compresses it only if the card refuses it as too large, then says it did and why. --compress always stores it compressed; --no-compress never does, so a certificate the card refuses fails with a message saying compression may make it fit. keyroost keeps no table of card sizes — the card's own refusal decides — and every compressed write is read back and compared with the original certificate. The desktop app's Import certificate and Self-signed certificate dialogs offer the same choice as a Compression dropdown: Automatic (only if too large), Always, or Never.

keyroostctl piv cert import --slot 9d --in cert.pem       # compress only if needed
keyroostctl piv cert import --slot 9d --in cert.pem --compress
keyroostctl piv cert generate --slot 9a --subject "CN=Alice" --no-compress
Which software reads compressed certificates

Readers confirmed to handle them include ykman, yubico-piv-tool / libykpiv / ykcs11 (when built with compression support), OpenSC (when built with zlib), the YubiKey Minidriver 5.0 and later, and the Yubico .NET SDK 1.13.1 and later. Windows' built-in PIV smart-card driver read one in a community test on Windows 11 (#152), and so did macOS's built-in PIV support in another community test. A certificate too large to fit uncompressed has not been tried on macOS yet. A certificate that fits uncompressed is stored uncompressed unless you ask otherwise.

In that macOS test, macOS recognized the key as a smart card only once it had a CHUID and certificates in both slot 9A and slot 9D, and sc_auth identities listed only the 9A identity.

What each key supports. Many management commands — extra key algorithms, PIN and touch policies, move and delete key, retry counts, changing the management key, reset — are vendor extensions rather than standard PIV. keyroost identifies which PIV applet a card runs (shown in piv info and at the start of the app's PIV status line) and checks each of these against a per-applet list of what each supports. A feature listed as unsupported for a key is dimmed in the app, with the reason on hover; one with no entry yet stays usable, marked with a warning.

The list can be out of date for newer firmware. When it dims something, the bottom of the PIV pane reads “N PIV features are marked unsupported for this key.” with an Enable Anyway link. It makes those controls usable, with the warning, for that key until you choose Undo or close the app, and also sends the GET METADATA and ATTEST reads the list would skip. If the key can't do something, it just refuses. Reset isn't included: on the command line, piv reset --force sends a single reset without blocking the PIN or PUK. On the command line, key generate, mgmt-key change, key move, key delete and reset refuse such a feature unless you add --force, which also sends those reads. Each read skipped or sent anyway leaves a line in the --debug output and the app's APDU trace.

keyroostctl piv key move --from 9d --to 82 --force

Bulk / scripted provisioning — use the CLI

The desktop app collects the management key (and PIN) per operation and wipes the secret from memory as soon as that operation finishes — a deliberate security choice. For a single slot that's ideal. For provisioning several slots, or many keys, re-entering the management key on every step gets tedious fast.

For batch work the CLI is the intended path. The management key and PIN come from environment variables or stdin once, so a shell loop can provision every slot without re-entry. Keep the secrets out of argv — give them as --mgmt-key env:NAME / --mgmt-key stdin and --pin env:NAME / --pin stdin (keyroost refuses the secret itself as a value), so they never appear in your shell history or the process list.

# management key + PIN supplied once via env; the loop provisions each slot.
# (set these in your own shell; don't commit or echo them)
export PIV_MGMT=010203040506070801020304050607080102030405060708   # AES-192 / 3DES key, hex
export PIV_PIN=123456

for slot in 9a 9c 9d 9e; do
  keyroostctl piv key generate --slot "$slot" --algorithm eccp256 \
      --mgmt-key env:PIV_MGMT --device my-yubikey
  keyroostctl piv cert generate --slot "$slot" --subject "CN=$USER" --days 365 \
      --mgmt-key env:PIV_MGMT --pin env:PIV_PIN --device my-yubikey
done

unset PIV_MGMT PIV_PIN   # clear the secrets when you're done

If a certificate authority issues your certificates, replace the cert generate step with cert request — note it takes no --days, and no management key unless you add --generate-key — the CSR signature itself needs only the PIN; the management key is for the key-generation step:

  keyroostctl piv cert request --slot "$slot" --subject "CN=$USER" \
      --pin env:PIV_PIN --out "slot-$slot.csr" --device my-yubikey

Import each issued certificate later with piv cert import --slot "$slot" --in … --mgmt-key env:PIV_MGMT. Rerunning the loop over CSR files from an earlier run asks before replacing each one; add --overwrite to run it unattended. --device keeps the loop on one key when several are plugged in: it takes a friendly name, a serial, or the number keyroostctl list shows (--reader <substr> works too). Run in a terminal, each step that would replace a slot in use asks first; add --yes to let the loop overwrite without asking.

One slot? The GUI is fine.

None of this replaces the desktop app — provisioning a slot or two there is perfectly comfortable. The CLI just earns its keep when you're scripting many slots or many keys and don't want to re-enter the management key each time.

Errors and diagnostics

When the card rejects a command, keyroost appends the ISO 7816 status word's plain-language meaning to the error, e.g. device rejected piv delete key: SW=6D00 (instruction not supported or invalid) — no lookup table needed to make sense of a failure.

On a contact or NFC reader that negotiates the older T=0 protocol, certificate import and on-card signing used to fail with a PC/SC "transaction failed" error, because T=0 can't carry the large commands those operations need. keyroost now reads the negotiated protocol and chains large commands from the start on T=0 links, so this no longer happens.

The desktop app's activity log (opened from the top bar, and detachable into its own window) lists every change and every error, in every tab, distinguishing what you triggered from background reads. Turn on its APDU trace checkbox before an operation to have it also record the exact commands and responses exchanged with the key — the same detail --debug prints on the CLI — and, while it's on, to give every read and refresh an entry of its own. Clear empties the log and Copy takes a trace elsewhere, for example into a bug report.

Authoritative resources

From the command line: see Certificates and smart-card keys (PIV) on the Command line page.