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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
flags with their access rules, read without authentication.
- The finalized (SECURED) state, read from the applet's status object, in
`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.
- `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
8 changes: 8 additions & 0 deletions docs/piv/piv-commands.rst
Original file line number Diff line number Diff line change
Expand Up @@ -74,13 +74,21 @@ Admin channel
.. code-block:: text

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 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
not followed by a successful EXTERNAL AUTHENTICATE counts as a failed authentication,
and a status read must not spend one.

``piv admin keys`` lists the same table. With ``--check-default`` it authenticates
once to key version 1 with the publicly known GlobalPlatform test key: a success
means anyone can administer the PIV applet, and leaves the card's
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.

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

Expand Down
7 changes: 6 additions & 1 deletion src/cryptnox_id_cli/applets/piv/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ def scp_label(value: object) -> str:
return _SCP_LABELS.get(value, "unknown") if isinstance(value, int) else "unknown"


class InitializeUpdateRejected(Scp03Error):
"""The security domain refused INITIALIZE UPDATE itself, before any cryptogram
was exchanged (for example an unknown key version)."""


def _as_scp02_keys(keys: Scp03Keys | Scp02Keys) -> Scp02Keys:
return keys if isinstance(keys, Scp02Keys) else Scp02Keys(keys.enc, keys.mac, keys.dek)

Expand Down Expand Up @@ -83,7 +88,7 @@ def open(
context="INITIALIZE UPDATE",
)
if not resp.ok:
raise Scp03Error(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
raise InitializeUpdateRejected(f"INITIALIZE UPDATE rejected (SW={resp.sw_hex()}).")
body = resp.data
if len(body) < 12:
raise Scp03Error(f"INITIALIZE UPDATE response too short ({len(body)} bytes).")
Expand Down
5 changes: 5 additions & 0 deletions src/cryptnox_id_cli/applets/piv/ssd.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
PIV_SSD_AID = bytes.fromhex("A00000015153504101")
ISD_AID = bytes.fromhex("A000000151000000")

#: Key version the card ships with on the public GlobalPlatform test key; the one a
#: customer replaces. Version 2 is Cryptnox's own keyset and is left alone.
CUSTOMER_KEY_VERSION = 1
CRYPTNOX_KEY_VERSION = 2

TAG_KEY_INFO_TEMPLATE = 0xE0
TAG_KEY_INFO = 0xC0

Expand Down
143 changes: 126 additions & 17 deletions src/cryptnox_id_cli/cli/commands/piv.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from cryptnox_id_cli.applets.piv import perso as perso_mod
from cryptnox_id_cli.applets.piv import preperso as preperso_mod
from cryptnox_id_cli.applets.piv import profiles as prof_mod
from cryptnox_id_cli.applets.piv.admin import PivAdmin, scp_label
from cryptnox_id_cli.applets.piv.admin import InitializeUpdateRejected, PivAdmin, scp_label
from cryptnox_id_cli.applets.piv.objects import (
PIV_OBJECTS,
extract_certificate,
Expand All @@ -33,7 +33,12 @@
)
from cryptnox_id_cli.applets.piv.piv import PivApplet
from cryptnox_id_cli.applets.piv.slots import PIV_SLOTS
from cryptnox_id_cli.applets.piv.ssd import PIV_SSD_AID, describe_security_domain
from cryptnox_id_cli.applets.piv.ssd import (
CUSTOMER_KEY_VERSION,
PIV_SSD_AID,
SecurityDomainInfo,
describe_security_domain,
)
from cryptnox_id_cli.cli.context import AppContext
from cryptnox_id_cli.crypto import csr as csr_mod
from cryptnox_id_cli.crypto import piv_objects, x509util
Expand All @@ -46,7 +51,13 @@
resolve_secret,
)
from cryptnox_id_cli.state import StateDetector
from cryptnox_id_cli.transport.errors import CryptnoxError, StatusWordError, describe_sw
from cryptnox_id_cli.transport.errors import (
CryptnoxError,
Scp02Error,
Scp03Error,
StatusWordError,
describe_sw,
)
from cryptnox_id_cli.transport.pcsc import is_contactless_interface
from cryptnox_id_cli.util import tlv
from cryptnox_id_cli.util.hexutil import to_hex
Expand Down Expand Up @@ -840,27 +851,36 @@ def admin() -> None:
"""PIV administrative operations over SCP03 (vendor: OpenFIPS201)."""


@admin.command("status")
@click.pass_obj
def admin_status(app: AppContext) -> None:
"""Show the admin security domain's SCP version and key versions (no authentication)."""
with app.open_session() as session:
domain = describe_security_domain(session)
def _require_security_domain(session) -> SecurityDomainInfo: # noqa: ANN001
domain = describe_security_domain(session)
if domain is None:
raise CryptnoxError(
"No security domain answered: neither the PIV security domain "
f"({PIV_SSD_AID.hex().upper()}) nor the ISD is selectable on this interface."
)
return domain


def _print_key_table(app: AppContext, c: Console, domain: SecurityDomainInfo) -> None:
c.print(f"PIV admin security domain {domain.aid_hex} ({scp_label(domain.scp_version)}):")
if not domain.keys:
c.print(f" key table withheld (SW={domain.key_table_sw:04X})")
return
table = app.out.table("Key version", "Key ID", "Type", "Length")
for k in domain.keys:
table.add_row(str(k.key_version), str(k.key_id), k.type_name, f"{k.length} B")
c.print(table)


@admin.command("status")
@click.pass_obj
def admin_status(app: AppContext) -> None:
"""Show the admin security domain's SCP version and key versions (no authentication)."""
with app.open_session() as session:
domain = _require_security_domain(session)

def human(c: Console) -> None:
c.print(f"PIV admin security domain {domain.aid_hex} ({scp_label(domain.scp_version)}):")
if not domain.keys:
c.print(f" key table withheld (SW={domain.key_table_sw:04X})")
else:
table = app.out.table("Key version", "Key ID", "Type", "Length")
for k in domain.keys:
table.add_row(str(k.key_version), str(k.key_id), k.type_name, f"{k.length} B")
c.print(table)
_print_key_table(app, c, domain)
c.print(
" Read without authenticating; nothing counted against the card's "
"failed-authentication limit. Run `piv admin authenticate` to open the channel."
Expand All @@ -869,6 +889,95 @@ def human(c: Console) -> None:
app.out.result(domain.to_dict(), human)


def _check_default_key(app: AppContext, session) -> dict[str, object]: # noqa: ANN001
"""One authentication to the customer key version with the GlobalPlatform default
key. Success is a completed authentication, so it leaves the card's
failed-authentication count clear; a wrong key costs one."""
keys = resolve_scp03_keys(app.redactor, default_keys=True)
adm = PivAdmin(session)
adm.select()
result: dict[str, object] = {"performed": True, "key_version": CUSTOMER_KEY_VERSION}
try:
adm.open(keys, key_version=CUSTOMER_KEY_VERSION)
except InitializeUpdateRejected as exc:
return {**result, "answers_to_default": None, "detail": str(exc)}
except (Scp02Error, Scp03Error) as exc:
return {
**result,
"answers_to_default": False,
"detail": str(exc),
"failed_authentication_counted": True,
}
return {
**result,
"answers_to_default": True,
"scp_version": scp_label(adm.scp_version),
"failed_authentication_counted": False,
}


def _default_key_verdict(check: dict[str, object] | None) -> str:
if check is None:
return "not checked (pass --check-default)"
if not check["performed"]:
return f"not checked ({check['reason']})"
answers = check["answers_to_default"]
if answers is True:
return (
"[red]YES[/red]. Anyone can administer this card's PIV applet; replace the "
"key before deployment."
)
if answers is False:
return (
f"no ({check['detail']}). One failed authentication is now counted on the "
"card; a successful `piv admin authenticate` clears it."
)
return f"undetermined ({check['detail']})"


@admin.command("keys")
@click.option(
"--check-default",
is_flag=True,
help=f"Authenticate once to key version {CUSTOMER_KEY_VERSION} with the publicly known "
"GlobalPlatform test key. A wrong key counts as one failed authentication on the card.",
)
@click.pass_obj
def admin_keys(app: AppContext, check_default: bool) -> None:
"""List the admin security domain's key table (no authentication).

With --check-default, also test whether the customer key version still answers
to the GlobalPlatform default key. That is one authentication attempt, never a
search over candidate keys.
"""
with app.open_session() as session:
domain = _require_security_domain(session)
check: dict[str, object] | None = None
if check_default:
if app.dry_run:
check = {"performed": False, "reason": "dry-run"}
elif CUSTOMER_KEY_VERSION not in domain.key_versions:
check = {
"performed": False,
"reason": f"key version {CUSTOMER_KEY_VERSION} is not in the key table",
}
else:
app.out.warn(
"a wrong key counts as one failed authentication on the card; five in a "
"row slow every later authentication down until one succeeds."
)
check = _check_default_key(app, session)

def human(c: Console) -> None:
_print_key_table(app, c, domain)
c.print(
f" Key version {CUSTOMER_KEY_VERSION} answers to the GlobalPlatform default key: "
f"{_default_key_verdict(check)}"
)

app.out.result({**domain.to_dict(), "default_key_check": check}, human)


@admin.command("authenticate")
@click.option(
"--default-keys",
Expand Down
1 change: 1 addition & 0 deletions src/cryptnox_id_cli/cli/dryrun.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"apdu transcript",
"factory piv preperso finalize",
"factory piv preperso load-config",
"piv admin keys",
"piv perso import-key",
"piv perso import-p12",
"piv quickstart",
Expand Down
136 changes: 136 additions & 0 deletions tests/unit/test_admin_keys.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"""`piv admin keys`: the key table is read without authenticating; `--check-default`
spends exactly one authentication attempt on key version 1 and never searches."""

from __future__ import annotations

import json

import pytest
from _cardfakes import ResponseAdapter, run, wire_session

from cryptnox_id_cli.applets.piv.admin import SCP02, InitializeUpdateRejected
from cryptnox_id_cli.cli.commands import piv as piv_cmd
from cryptnox_id_cli.transport.apdu import Response
from cryptnox_id_cli.transport.errors import Scp02Error

SELECT_SSD = "00A4040009A0000001515350410100"
GET_E0 = "80CA00E000"
FCI_SSD = bytes.fromhex("6F118409A00000015153504101A5049F6501FF")
E0_KVN1_DES = bytes.fromhex("E012C00401018010C00402018010C00403018010")
E0_KVN2_ONLY = bytes.fromhex("E012C00401028810C00402028810C00403028810")
OK = (0x90, 0x00)


class _Dead:
def transmit(self, raw: bytes) -> Response:
return Response(b"", 0x6A, 0x82)


def _conn(e0: bytes = E0_KVN1_DES) -> ResponseAdapter:
return ResponseAdapter(_Dead(), answers={SELECT_SSD: (FCI_SSD, *OK), GET_E0: (e0, *OK)})


class _FakeAdmin:
"""Stands in for the channel: records the key version asked for, then succeeds,
fails the cryptogram check, or has INITIALIZE UPDATE refused."""

outcome = "ok"
opened: list[int] = []

def __init__(self, session) -> None:
self.card = session
self.scp_version = None

def select(self) -> None:
pass

def open(self, keys, *, key_version: int = 0, security_level: int = 0x03) -> None:
_FakeAdmin.opened.append(key_version)
if _FakeAdmin.outcome == "wrong":
raise Scp02Error("Card cryptogram mismatch - the SCP02 keys are wrong for this card.")
if _FakeAdmin.outcome == "rejected":
raise InitializeUpdateRejected("INITIALIZE UPDATE rejected (SW=6A88).")
self.scp_version = SCP02


@pytest.fixture
def fake_admin(monkeypatch):
_FakeAdmin.outcome = "ok"
_FakeAdmin.opened = []
monkeypatch.setattr(piv_cmd, "PivAdmin", _FakeAdmin)
return _FakeAdmin


def _keys(monkeypatch, conn, *args: str) -> dict:
wire_session(monkeypatch, conn)
result = run(["--json", "piv", "admin", "keys", *args])
assert result.exit_code == 0, result.output
return json.loads(result.stdout)


def test_table_alone_never_authenticates(monkeypatch, fake_admin):
conn = _conn()
payload = _keys(monkeypatch, conn)
assert payload["key_versions"][0]["version"] == 1
assert payload["default_key_check"] is None
assert fake_admin.opened == []
assert not any(frame.startswith("8050") for frame in conn.log)


def test_check_default_yes_is_one_completed_authentication(monkeypatch, fake_admin):
payload = _keys(monkeypatch, _conn(), "--check-default")
check = payload["default_key_check"]
assert check["answers_to_default"] is True
assert check["failed_authentication_counted"] is False
assert check["scp_version"] == "SCP02"
assert fake_admin.opened == [1]


def test_check_default_no_reports_the_counted_failure(monkeypatch, fake_admin):
fake_admin.outcome = "wrong"
payload = _keys(monkeypatch, _conn(), "--check-default")
check = payload["default_key_check"]
assert check["answers_to_default"] is False
assert check["failed_authentication_counted"] is True
assert fake_admin.opened == [1] # one attempt, no search


def test_check_default_undetermined_when_initialize_update_is_refused(monkeypatch, fake_admin):
fake_admin.outcome = "rejected"
payload = _keys(monkeypatch, _conn(), "--check-default")
check = payload["default_key_check"]
assert check["answers_to_default"] is None
assert "6A88" in check["detail"]


def test_check_skipped_when_key_version_1_is_absent(monkeypatch, fake_admin):
payload = _keys(monkeypatch, _conn(E0_KVN2_ONLY), "--check-default")
check = payload["default_key_check"]
assert check["performed"] is False
assert "key version 1" in check["reason"]
assert fake_admin.opened == []


def test_dry_run_lists_the_table_and_skips_the_check(monkeypatch, fake_admin):
wire_session(monkeypatch, _conn())
result = run(["--dry-run", "--json", "piv", "admin", "keys", "--check-default"])
assert result.exit_code == 0, result.output
payload = json.loads(result.stdout)
assert payload["key_versions"]
assert payload["default_key_check"] == {"performed": False, "reason": "dry-run"}
assert fake_admin.opened == []


def test_human_output_states_the_verdict(monkeypatch, fake_admin):
wire_session(monkeypatch, _conn())
result = run(["piv", "admin", "keys", "--check-default"])
assert result.exit_code == 0, result.output
assert "YES" in result.output
assert "replace the key before deployment" in result.output
fake_admin.outcome = "wrong"
wire_session(monkeypatch, _conn())
result = run(["piv", "admin", "keys", "--check-default"])
assert "One failed authentication is now counted" in result.output
wire_session(monkeypatch, _conn())
result = run(["piv", "admin", "keys"])
assert "not checked (pass --check-default)" in result.output
Loading