PKI¶
How principals (operator, device, server) obtain the keys and Directory-signed tokens they need. Identity is token-only — a Directory-signed Ed25519 token, with no per-app X.509 client certificate. Certificates appear in exactly one place: transport TLS (server-auth, with optional deployment-wide mTLS) — see Trust roots.
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):
The field-18 callsign below is the PETRA 1.0 contract. Issuers and verifiers use this one shape; credentials without it are invalid and have no compatibility verifier.
IdentityToken(operator + device) — rides inside everyAuthEnvelope. Carriesprincipal_id(UUID),roles(with akind:operator|kind:deviceprefix), the canonical non-blankcallsignat field 18,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. Pinsprincipal_id,hostname,clearance,coverage_cells,roles, and the Directory-derivedopaque_principal_segmentused for the router-token key. It also pins the 32-byte Ed25519replay_signing_keyused only to authenticate retained-page completion receipts. Itsdirectory_key_idis the full 32-byte SHA-256 of the raw Directory Ed25519 public key. Verified byverify_server_token.
The callsign is part of the Directory-signed identity, not a self-asserted
attribute. For a human, Directory derives it as
principal_id.slice(0, 8).toUpperCase(); a verified ORBAT appointment may
override it only for display. For a thing or service, Directory trims and
uppercases the device record's callsign and the token value is authoritative.
Canonical values contain 1–32 characters from [A-Z0-9 ._/-]. Directory
rejects a device value that becomes blank or exceeds the bound, and token
verification rejects an absent, empty, or whitespace-only field as
MissingCallsign. Any other noncanonical field-18 value — including lowercase,
leading/trailing whitespace, more than 32 characters, or a character outside
the allowed set — fails as InvalidCallsign before a verified identity is
returned. A Server listener still uses ServerToken without a callsign; if that
process also holds a service IdentityToken for Directory/API activity, the
service token follows the same mandatory thing/service rule.
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:IdentityTokenretains its 8-byte identifier, whileServerTokenuses the full 32-byte SHA-256 fingerprint and verifies only against the exact matching trusted key. 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. The same CA also anchors server↔server / router federation: each dialing router verifies the peer's server leaf and can present its own leaf. Strict inbound mTLS is an optional deployment-wide admission posture, not router identity. Peer identity is the Directory-signedServerToken— app identity stays token-only.
Zenoh transport admission is separate from PETRA application identity. Field clients use one registered wildcard ACL subject whose permissions remain limited to the closed action and key registry; stock Zenoh does not dynamically update or revoke a subject per device. A Web bridge may use mTLS as additional transport admission defense where deployed, but its certificate does not establish PETRA identity or authority.
Machine ingest credentials are a separate symmetric trust root
The machine ingest API (/api/feed/*) does not use the Directory Ed25519 key.
Its credentials are HS256 JWTs signed with the deployment secret
WAYPOINT_API_JWT_SECRET — a symmetric trust root unrelated to either anchor above.
Their revocation is independent too: a DB live-check on api_credentials.revoked_at,
not the Directory RevocationList. This does not violate token-only identity —
it is machine-API auth (authorizing a producer to write feed data), not principal
identity, so it carries no clearance and rides no AuthEnvelope. Full contract:
protocol/ingest-api.md.
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; the same router principal also receives its current ServerToken and matching private signing key |
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 |
| WebAuth code exchange | current FIDO assertion retained through the one-time code | one human browser IdentityToken + ephemeral session signing key, expiring within one hour in production |
POST /api/auth/logout |
exact browser IdentityToken bearer; empty protobuf body |
idempotently revokes that browser session and signing key |
A server's ServerToken is not issued by a dedicated API endpoint. It is minted in the
admin device-creation flow for a server/gateway device with a hostname. A router's authenticated
machine-token extension returns only that same principal's current ServerToken. Directory reuses a
current token only when it matches the persisted Device fields and the current replay public key.
Human, Web, and field-device login never receives an unrelated router credential. A router token
missing the signed opaque segment, full key id, or replay public key is invalid and cannot enable
traffic. See Add a server.
The opaque segment is derived with the deployment salt under domain router-principal. Directory
stamps only the derived 32-lowercase-hex segment; the content-holding salt is never disclosed to a
Server. A router publishes its ServerToken only below that signed segment. Verification requires
canonical protobuf, issued_at_ms <= now < expires_at_ms, a positive lifetime no longer than 365
days, the exact trusted-key fingerprint and signature, safe hostname/principal/coverage fields,
valid clearance, a 32-byte replay public key, and current signed principal revocation state.
Server credential generation¶
Directory co-issues the three values for one server credential generation:
| File | Purpose |
|---|---|
service-token.bin |
bearer IdentityToken for the server's own authenticated Directory HTTPS calls |
server-token.bin |
peer-facing Directory-signed ServerToken, including the replay public key |
signing-private-key.bin |
32-byte Ed25519 seed used only to sign RetainedReplayReceiptV2 |
The private key's public half must equal both the service token's principal_sign_key and
ServerToken.replay_signing_key; all three credentials must identify the same unrevoked
principal. Initial bootstrap stages all three under one versioned generation and atomically
switches the current pointer. Normal startup then loads that one generation and verifies
every signature, identity, and key binding before opening authority or transport surfaces.
A partial or mismatched generation never runs.
Automatic renewal uses the current service bearer only for Directory challenge/extend, then verifies, stages, and atomically activates all three returned values before restart. Receipt verification never exposes or accepts that bearer: the receipt embeds only the peer-facing ServerToken and verifies against its Directory-bound replay public key.
FIDO-bound browser capability session¶
The Web authorization-code flow may produce a direct browser data-plane identity only
after Directory verifies a current FIDO credential and carries that exact credential
anchor through exchange. Directory creates a fresh ephemeral session signing key, binds
the canonical human IdentityToken and capability eligibility to that key and credential,
and caps the token, private browser-session record, and every issued capability at one hour
in production. The shared BROWSER_CAPABILITY_TTL_MINUTES setting permits a longer
non-production duration only with explicit DEV_LOGIN_ENABLED=true in Directory and Web;
Infrastructure local defaults to 480 minutes, matching its Web session age. Both services
reject an unsafe override at startup, and Web rejects a mismatched signed lifetime at
callback. See the development exception
for the exact configuration and deployment contract.
Principal-id-anchored WebAuth bearer tokens remain capability-ineligible.
The browser key is held in the encrypted, HttpOnly Adonis session and current JavaScript
memory, and is re-exposed only through authenticated, no-store, encrypted-history Inertia
documents. It therefore survives a hard refresh and supports the same login across tabs
without Local Storage; the complete capability set remains memory-only. Logout clears the
shared Web session and a non-authority-bearing cross-tab notification immediately wipes
every open tab. Session expiry, credential removal, rotation, revocation, or rejection also
invalidate the key. This deliberately makes the ordinary Web session sufficient to recover
its exact original signing authority. Web may proxy canonical capability
challenge/request/response bytes but receives no delegation. Exact grant and
heartbeat-versus-position rules are in
FIDO-bound browser endpoint capabilities.
Batching + offline. Native/tactical operators and provisioned 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). The FIDO-bound browser
code-exchange class is explicitly excluded: it receives one token and session key expiring
within one hour in production (or the explicitly configured development duration) and
has no tactical offline batch. Native/device 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.
PETRA 1.0 coordinated callsign reset
Field 18 is a flag-day contract, not a compatibility extension. Common's Rust and
TypeScript verifiers, Directory's single mint seam, and every consumer must move to
one exact callsign-capable Common revision together. Stop runtime traffic, wipe old
credentials and retained development/test state, deploy the issuer and all
verifiers, then re-enrol or remint every IdentityToken. There is no dual mint,
legacy reader, config fallback, or acceptance of the retired field 3. A component
whose runtime service IdentityToken lacks the field fails boot or activation.
Detailed wire rules are in
wire-protocol.md.
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; Relay + Storage rejects and purges revoked retained
values. A token-only native Relay cannot map an application principal to a transport
session and does not claim to force-close it. Automatic group-key rotation on loss/capture
prevents the revoked holder from opening newly sealed epochs.
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).