Skip to content

Add remote operations through the Cryptnox PIV service - #15

Open
mmlado wants to merge 7 commits into
mainfrom
feat/remote-piv-service
Open

mmlado wants to merge 7 commits into
mainfrom
feat/remote-piv-service

Conversation

@mmlado

@mmlado mmlado commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds the remote command group: operations carried out by the Cryptnox remote PIV service with the card in the local reader.

  • remote authenticate and remote inspect: identify and probe the card through the service, key-free.
  • remote attest: generate a key in a slot through the service and receive its key-attestation certificate, verified locally before success is reported.
  • remote reset and remote dev-reset: wipe and reinstall the PIV function, behind the irreversibility gate the codebase already uses. The service authenticates with the card's own derived key by default; --default-keys selects the publicly known GlobalPlatform key for development cards, matching the flag the rest of the tool uses for that purpose.

The tool is a relay: it transmits each command the service sends and returns the card's answer verbatim, never composing a command of its own and never reassembling a chained response. A fail-closed policy decides, per operation, what reaches the card: which applet may be selected, which instructions may pass in each context, how many authentication attempts each security domain may see, and (where the data is readable) what a DELETE may name. The wallet, FIDO2, genuineness and DESFire functions are unreachable through it. A refusal ends the operation and names the command header.

What the service asserts and what the tool verified locally are shown apart, in the human output and in --json.

Design

docs/adr/0003-relay-for-the-remote-piv-service.md records the decision and the options not taken. The guide (docs/remote/remote-guide.rst) states the policy's limits plainly: inside an encrypted channel it sees instructions, not targets, and on-card key generation is the service's claim.

Changes

  • New package src/cryptnox_id_cli/remote/: frame codec, proof-of-work solver, WebSocket channel (TLS, verifying), relay loop, relay policy, attestation post-verification.
  • New command module cli/commands/remote.py; registered in main.py, classified in dryrun.py.
  • RemoteError family in transport/errors.py, exit codes 12 to 16, documented in docs/exit-codes.rst.
  • AppContext.apdu_trace: the transcript sink for code that transmits outside a CardSession.
  • websockets>=13,<18 as a runtime dependency (synchronous client; the tool stays synchronous).
  • Docs: docs/remote/ (guide, commands), cli-basics, json-output, troubleshooting, README, CHANGELOG, glossary terms in CONTEXT.md.
  • Tests: 9 new files, 240 tests, no socket and no card; scripted service frames against the existing MockConnection, a generated test PKI for the attestation checks, and adversarial policy cases (other card functions, verifier commands, logical channels, DESFire and CTAP framing, DELETE of non-PIV content, second authentication attempt, oversize and malformed frames).

Verification

  • ruff, ruff format, mypy, pytest (588), sphinx -W: green locally, and the suite also passes on Python 3.10 in a container (CI's other matrix leg).
  • Exercised against the production service on real cards, all five operations:
    • authenticate and inspect succeed; the service's picture of each card matches what the tool reads locally.
    • reset succeeds: all seven steps report success, and the tool's own post-check confirms independently that the PIV applet is present and the security domain came back carrying a second key version where it had only one before.
    • attest succeeds: a key is generated in the slot and the returned certificate passes every local check — chain to the pinned trust anchor, subject serial number against the card's own identity, attested slot against the request, trust model, and the certified key against the certificate read back from the card.
    • dev-reset reaches the service and is refused for want of a valid credential, which exercises its error path.
  • Two policy rules were written from the protocol description and proved too narrow against real traffic; both were corrected in 890ddf1, each refusal having stopped the operation cleanly and named the offending command header, with the card left recoverable. That is the layer working as intended.

What the live runs settled

  • The relay carries a full applet load (116 blocks) and a key-generation flow without the client interpreting either.
  • Several protocol details the written description leaves open are now observed rather than guessed: message framing, how the service asks for the answer-to-reset, the per-operation result fields, and that the service drives response chaining itself.

Not in this change

  • The PyInstaller spec needs a websockets hidden import; the spec is already stale for other reasons and gets its own change.
  • Per-slot attestation containers: the service writes one container, so a second attestation replaces the first. Unchanged here.
  • Certificate pinning for the endpoint: deliberately not applied, see channel.py.

🤖 Generated with Claude Code

mmlado and others added 4 commits September 18, 2026 19:16
The remote PIV service performs lifecycle operations that need keys which
never leave Cryptnox, with the card in the holder's own reader. This adds the
client side for its two key-free operations.

`remote authenticate` identifies the card to the service (ATR and CPLC UID);
`remote inspect` probes whether the PIV applet and its security domain are
present and which key versions they carry. Both open the local reader first,
connect over TLS, answer the service's proof-of-work, and relay the commands
the service sends. The relay is raw: it never sends a command of its own and
never reassembles a chained response, so the service sees the card as a local
reader would.

A fail-closed relay policy decides what reaches the card. It is an allow-list
per operation over the selected applet, class, instruction and SELECT target:
the wallet, FIDO2, genuineness and DESFire functions cannot be selected,
card-holder verifier commands are never relayed, and EXTERNAL AUTHENTICATE is
refused for both operations so no card-management retry can be spent. A
refusal ends the operation and names the command header.

The output keeps what the service asserted apart from what was verified
locally (CPLC UID and ATR read before the connection opens). Relayed commands
are written to the transcript with their data masked unless the instruction
is a known read; `remote --full-transcript` lifts that extra masking.

`websockets` becomes a runtime dependency (synchronous client). New exit codes
12 to 16 cover connection, protocol, policy and service failures.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The remaining three operations of the remote PIV service, on the relay and
policy the read-only operations introduced.

`remote reset` wipes and reinstalls the PIV function: the service
authenticates to the card manager, deletes the PIV applet, its package and
its security domain, loads the applet again, recreates the security domain
and loads the card's keys. `remote dev-reset` does the same for development
cards and leaves the card manager and the security domain on the public
default key; its access credential comes from the environment only. Both
sit behind the irreversibility gate the codebase already uses: a typed
phrase interactively, an explicit flag otherwise, and no `--yes` bypass. What
the card holds is read locally and shown before consent, and read again
afterwards next to the service's result.

The relay policy for these operations admits card content management through
the card manager only, lets a DELETE whose data travels in the clear name
nothing but the PIV instance, package and security domain, caps
INITIALIZE UPDATE, EXTERNAL AUTHENTICATE and LOAD per operation, and stops at
the first failed authentication so no further attempt can spend one of the
card's bounded card-management retries. Inside an encrypted channel the policy
sees instructions, not targets; the docs say so.

`remote attest` generates a key in one slot through the service and receives
its key-attestation certificate. The policy binds key generation to the
requested slot and the management-key handshake to key 9B, so no slot key can
be exercised. The certificate is then verified locally: chain to the pinned
anchors, subject serial number against the card's CPLC UID, attested slot
against the request, trust model, and the certified key against the
certificate read back from the card. An unchecked binding never counts as a
passed one, and the output states that on-card generation is the service's
claim.

`inspect` also learned that the service probes the applet's own admin channel
with INITIALIZE UPDATE, and admits that probe.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both rules were written from the protocol description before the operations
had been run against the service. Running them showed each rule refusing a
command the service legitimately needs, so each refusal is now narrower while
the protection it exists for is unchanged.

The secure-channel authentication caps were counted per operation, but the
service opens a fresh card-manager channel for each step of a reset: delete,
load, install, create the security domain, key it, extradite, load the second
key version, verify. A reset was refused after the security domain had been
keyed, with the applet installed but not yet extradited. The caps are raised
to sixteen for both INITIALIZE UPDATE and EXTERNAL AUTHENTICATE. They were
never the real protection against a spent retry: that is the abort on the
first authentication the card rejects, which is untouched.

The service sets the PIV management key with CHANGE REFERENCE DATA, key
reference 9B, inside the applet's secure channel. The policy refused the whole
instruction as a card-holder verifier, which is true of the PIN and the PUK but
not of the management key: 9B carries no card-holder retry counter, and the
service loads it before generating a key. CHANGE REFERENCE DATA is now allowed
for key reference 9B alone, and only in operations whose rules opt in, which
are reset, dev-reset and attest. The PIN and the PUK stay refused everywhere,
as do VERIFY and RESET RETRY COUNTER whatever they address.

Attest's EXTERNAL AUTHENTICATE cap goes from two to four for the same reason as
the reset caps.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The service authenticates to the card manager with one of two key tiers: the
card's own key, derived per card from its identity, or the publicly known
GlobalPlatform default. Until now the command took `--fused` to select the
derived key and sent the default otherwise, so the safe production case was
the one the caller had to remember to ask for.

The two mistakes are not symmetric. Forgetting the flag on a development card
costs a failed authentication and nothing else; forgetting it on a production
card presents a published key to a card that should only ever answer to its
own. Defaults should fail safe, so the flag is inverted: the derived key is
the default, and `--default-keys` opts into the development path. The name
matches the flag the rest of the tool already uses for the publicly known
GlobalPlatform test keys.

The parameter is sent to the service explicitly either way, so the command's
behaviour does not depend on which default the service applies when it is
absent.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mmlado
mmlado requested a review from embarquech September 25, 2026 14:56
Comment thread tests/unit/test_remote_policy.py Fixed
Comment thread tests/unit/test_remote_channel.py Fixed
Comment thread src/cryptnox_id_cli/cli/commands/remote.py Fixed
Comment thread src/cryptnox_id_cli/remote/channel.py Fixed
Comment thread src/cryptnox_id_cli/remote/channel.py Fixed
Comment thread src/cryptnox_id_cli/remote/channel.py Fixed
Six findings on the pull request, none of them a defect, each made
unambiguous rather than suppressed.

The FrameChannel protocol methods carried `...` bodies, which the analyser
reads as statements without effect; they now carry docstrings, which also
document the contract. The empty `except` in the certificate pre-read has
the comment explaining why an unreadable applet is not an error there.

In the tests, a helper named `delete` built a DELETE APDU and was called
inside an `assert`, which the analyser flags as a possible side effect; it
is `delete_apdu` now. A test that raised inside a `with pytest.raises` block
looked to the analyser as if the assertion after it could never run; the
raise moved into a nested function so the flow is plain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@mmlado mmlado self-assigned this Sep 29, 2026
mmlado and others added 2 commits September 29, 2026 16:56
The guide, the command reference and the sections added to the README and
to the exit-codes, JSON-output and troubleshooting pages leave this branch.
They follow in a separate change, reviewed on its own, based on this one.

The command map in cli-basics keeps a row naming the five commands, which
is what the command-reference drift guard requires of any change that adds
a command; the cross-reference to the command reference page goes with the
page.

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

This branch has not been deployed

No deployments
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