Skip to content

PKI

How principals (operator, device, server) obtain the keys and Directory-signed tokens they need. This page describes the token-only end-state. A client-certificate PKI (CSR-issued X.509 leaves, client-cert mTLS, bound_cert_serial) is being removed; its residual paths are listed under Transitional.

For per-message verification (envelope, gates, revocation enforcement) see model.md. This page covers key issuance, trust roots, and rotation.

What identity rests on (token-only)

Identity is the Directory's Ed25519 signature over a token — there is no per-app X.509 cert binding. Two token protos, both Directory-signed, both verified against the cached Directory public-key set (common/proto/server/directory.proto):

  • IdentityToken (operator + device) — rides inside every AuthEnvelope. Carries principal_id (UUID), roles (with a kind:operator|kind:device prefix), callsign, max_classification, key_epoch, batch_id, issued_at_ms, expires_at_ms, directory_key_id, visibility_radius_km, and crucially principal_sign_key (the 32-byte Ed25519 public half used to verify each message's device_signature) and device_id (stable per-device anchor). Verified by verify_identity_token.
  • ServerToken (server listeners) — does not ride AuthEnvelope. Pins hostname, max_classification, coverage_cells, roles. Verified by verify_server_token.

Neither token carries bound_cert_serial — that field is reserved/removed (directory.proto:36,89). Classification rides on the token.

Trust roots

Two independent Directory-held trust anchors:

  1. Directory Ed25519 signing key(s) — sign every IdentityToken, ServerToken, and RevocationList. Served at GET /.well-known/directory-key as DirectoryKeyResponse { public_keys[], issued_at_ms }. Exactly one key is live (retired_at IS NULL); rotation marks the current key retired and inserts a fresh one, and the endpoint serves the current key plus every non-compromised prior key within the overlap window so in-flight tokens still verify (directory signing_key_service). Each token names its signer via directory_key_id. Clients refresh on a cadence (Android: DirectoryKeysRefresher, hourly).
  2. Directory Root CA — anchors transport TLS only (server-auth). Pinned cold-start via ProfileBundle.directory_root_ca_der, re-fetched from the Directory's root-CA well-known endpoint; during root rotation it returns new ‖ previous for ~max_cert_TTL + 1d. This validates the router's server certificate, not client identity.

Key material a device holds

  • Device Ed25519 signing key — private half of principal_sign_key. The Directory generates the keypair per token batch, embeds the public half in the token, and returns the private half in the enrollment HTTPS response. It is ephemeral (rotates every login/batch) and is not a long-term device identity. Held in process memory client-side (Android: SecretStore.signWithDeviceKey).
  • Group key — issued by the Directory (GET /api/group-key, Bearer auth; current + previous bundle; key_epoch stamped on the token). Issued to clients only — the Directory denies the key to relay/server principals. The client seals outbound member content (AES-256-GCM) and opens inbound SealedContent with this key. It is not part of envelope authenticity (that is the device signature) — it is the content confidentiality concern. See security/model.md.

Token issuance lifecycle

All issuance flows through the Directory's auth endpoints (directory/start/routes.ts):

Endpoint Auth Issues
POST /api/auth/login FIDO assertion (operators) or single-use registration token (devices) IdentityToken batch
POST /api/auth/extend a still-valid IdentityToken from the current batch refreshed IdentityToken batch
POST /api/auth/device-enroll MDM seed token (scope=enroll-only) device IdentityToken batch
POST /api/auth/revoke admin, or operator self (own only) appends to the revocation snapshot

A server's ServerToken is not issued by a dedicated API endpoint. It's minted in the admin device-creation flow (a server/gateway-platform device with a hostname), carrying the classification ceiling and validity entered on the form, and is delivered to clients embedded in the login response. See Add a server.

Batching + offline. Operators/devices receive a 5 × 7-day IdentityToken batch; servers get a single ServerToken valid for the per-device validity set at creation (1–365 days, default 30). Batching lets token rotation happen offline: each next batch token is sealed and unlocked by a local FIDO presence proof against the cached FIDO public key (operators), so a device survives ~30–35d of Directory unavailability before total credential loss.

Revocation

RevocationList is Directory Ed25519-signed, monotonic by sequence, and carries two levels (directory.proto):

  • revoked_principals — cascades to every token ever issued for a principal_id (operator, device, or server).
  • devices (RevokedDevice) — revokes one device by its Ed25519 device_sign_key.

There is no revoked_certs; identity is token-only.

Distribution: - Bootstrap — signed snapshot over HTTPS at GET /api/revoked-principals, fetched at login and cold-start; cold-start principals also arrive in the login response. - Runtime — a single server router HTTPS-polls the Directory (~30 s) and re-publishes the signed payload verbatim on the Zenoh topic waypoint/global/sec/revocations; native clients subscribe. The relay does not re-sign; verifiers check the Directory signature regardless of transport (RevocationCache.verifySignature / merge).

Enforcement (see model.md): receivers drop on principal or device sign-key after envelope verify; the server force-closes affected sessions on a revoked-list update.

Transitional — being removed

A client-certificate PKI is mid-removal; waypoint_common already dropped it (the cert_enroll module and bound_cert_serial are gone — PR #69 chore/prune-dead-proto) but some services still carry residual cert paths:

  • node still pins an older waypoint_common with the cert_enroll feature and calls build_csr on first-boot login / extend. Migration plan: node/docs/plans/2026-06-02-node-x509-to-token-only.md (not yet executed).
  • directory dropped CSR for server enrollment (ServerEnrollRequest reserves csr_der) but DeviceEnrollRequest still has an active csr_der field.
  • ProfileBundle.cert_chain_der still exists for clients that consume a leaf chain.
  • Server ↔ server / router federation still uses mTLS with peer-cert CN pinned to node_id (server/src/active_peers.rs). Transport TLS itself stays (server-auth via the pinned Directory Root); only client-cert mTLS for app identity and the CSR-issued leaf are going away.

Once node and directory device-enroll complete the migration, this section and the residual proto fields (csr_der, cert_chain_der) should be removed.

Superseded source docs

  • common/PKI.md — replaced by a link to this page.
  • infrastructure/pki/README.md — its "Production model" section links here; its generate.sh staging helper section stays repo-local (demo/dev VM self-signed TLS, not part of the production trust chain).