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 everyAuthEnvelope. Carriesprincipal_id(UUID),roles(with akind:operator|kind:deviceprefix),callsign,max_classification,key_epoch,batch_id,issued_at_ms,expires_at_ms,directory_key_id,visibility_radius_km, and cruciallyprincipal_sign_key(the 32-byte Ed25519 public half used to verify each message'sdevice_signature) anddevice_id(stable per-device anchor). Verified byverify_identity_token.ServerToken(server listeners) — does not rideAuthEnvelope. Pinshostname,max_classification,coverage_cells,roles. Verified byverify_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:
- Directory Ed25519 signing key(s) — sign every
IdentityToken,ServerToken, andRevocationList. Served atGET /.well-known/directory-keyasDirectoryKeyResponse { 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 (directorysigning_key_service). Each token names its signer viadirectory_key_id. Clients refresh on a cadence (Android:DirectoryKeysRefresher, hourly). - 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_epochstamped 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 inboundSealedContentwith this key. It is not part of envelope authenticity (that is the device signature) — it is the content confidentiality concern. Seesecurity/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 aprincipal_id(operator, device, or server).devices(RevokedDevice) — revokes one device by its Ed25519device_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:
nodestill pins an olderwaypoint_commonwith thecert_enrollfeature and callsbuild_csron first-boot login / extend. Migration plan:node/docs/plans/2026-06-02-node-x509-to-token-only.md(not yet executed).directorydropped CSR for server enrollment (ServerEnrollRequestreservescsr_der) butDeviceEnrollRequeststill has an activecsr_derfield.ProfileBundle.cert_chain_derstill 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; itsgenerate.shstaging helper section stays repo-local (demo/dev VM self-signed TLS, not part of the production trust chain).