Security Model¶
The PETRA 1.0 Zenoh and DDIL data-plane contract is the normative threat model for Directory-issued publish/query capabilities, opaque addressing, malformed selectors, capability theft/replay, revoked endpoints, traffic analysis, and compromised disposable Relay + Storage. This page continues to describe the currently deployed envelope, identity, revocation, classification, and transport controls; the status roadmap separates those live controls from the 1.0 target.
The authoritative snapshot contract separately defines Web-only snapshot authority, current-audience encryption, cold-client rollback protection, endpoint-signature confusion gates, and the rule that applying recovered state never proves an endpoint authored the snapshot or was online.
Capability-refresh trust boundary¶
PETRA 1.0 reconnect uses the authenticated full-set protocol in the
DDIL data-plane contract.
The endpoint proves possession of the signing key in its exact canonical, unexpired
Directory IdentityToken over a single-use challenge under
petra-capability-refresh-request-v1. Directory derives authority only from current
Directory state; caller-declared membership, geographic cells, selectors, or cached
grants are never entitlement evidence.
The signed response binds the exact token digest, subject and key, echoed challenge, validity interval, strictly monotonic per-principal generation, and complete canonical entry set. A valid signed empty set is an authoritative result and removes every cached grant. No failure is interpreted as empty: timeout, transport, oversize, decode, canonicalization, signature, binding, generation, or entry failure preserves the prior cache byte-for-byte but leaves the endpoint degraded. Shared publish/query and outbox drain stay blocked until one complete newer response verifies and replaces the cache atomically. This prevents a network attacker from forging removal by truncating or corrupting a response and prevents a stale cache from being treated as reconnected authority.
Directory's subject entitlement index is transactional materialization, not an authority source. Channel membership changes update one complete once-sealed membership projection and every affected index row in the same transaction. A removal is a higher-version full projection plus index removal; omission from the next successfully verified response then removes cached authority before shared operation resumes. While Directory is unreachable, an unknown remote removal remains the accepted bounded residual until the cached signed grant expires; a locally known principal/key revocation still fails immediately.
The cross-platform, receive-side security contract for Bedrock. Android, web, server
(waypoint node), gateway, and any future client commit to the same envelope shape.
Ground truth is waypoint_common::auth_envelope (Rust). Every client reaches an
equivalent verifier — Rust peers call it directly; the Kotlin (Android) and TypeScript
(web) clients ship native re-implementations held to byte-for-byte parity by shared
envelope fixture vectors. When this document and a client diverge, the code in
waypoint_common wins and the client is the bug.
This page is the single source for the receive-side security contract. The per-repo
android/SECURITY.mdandweb/SECURITY.mdare superseded — each is replaced by a link here. The mechanism below is reconciled against current source.
Per-message authenticity: Ed25519 device signature¶
Each runtime message travels inside an AuthEnvelope:
AuthEnvelope {
identity_token // raw Directory-signed IdentityToken bytes
payload // SealedContent or plaintext proto (see Payload confidentiality)
classification // signed-cleartext classification level (relay-readable, unforgeable)
owner_principal_id // signed-cleartext channel owner (set on channel control messages)
nonce // 12-byte random nonce
issued_at_ms // sender wall-clock at pack time
purpose // signed verification intent (LIVE / DURABLE_GRANT / DURABLE_CONTENT)
device_signature // 64-byte Ed25519 over the canonical signing input,
// by the token's principal_sign_key
}
Authenticity and integrity are an Ed25519 device_signature over the canonical
signing input. (common/src/auth_envelope.rs)
- The signing input is length-prefixed and unambiguous:
len(identity_token)‖identity_token ‖ len(payload)‖payload ‖ len(nonce)‖nonce ‖ issued_at_ms(BE u64) ‖ classification ‖ len(owner_principal_id)‖owner_principal_id ‖ purpose(BE u32)(common/src/auth_envelope.rssigning_input).purposeis appended last — see Durable-record verification. - The signer is the sender's per-principal signing key. Its public half,
principal_sign_key, is embedded in the Directory-signedIdentityToken(field 15); the private half is delivered to the device in the enrollment HTTPS response. It is per-batch and ephemeral — re-enrollment mints a fresh keypair — and is not a device identity. (common/proto/server/directory.proto:47-54) - There is no group key in the envelope. Group keys (
key_epochis stamped on the token; clients fetch viaGET /api/group-keyfrom the Directory) are the content-encryption concern, separate from envelope authenticity. classificationandowner_principal_idare signed-cleartext fields: they are visible to the relay without any key, but are covered by thedevice_signatureso they cannot be forged or downgraded by the relay.
Callsign trust boundary (PETRA 1.0)¶
Every verified IdentityToken carries a non-blank canonical callsign at field 18,
covered by the Directory signature. A human display may use a callsign from a current,
verified ORBAT appointment and otherwise falls back to the verified token. A thing or
service always uses the verified token and ignores ORBAT callsign overrides. The label
is attribution metadata, not authorization: roles, clearance, signed authority grants,
and ORBAT appointment state continue to decide what the principal may do.
No verifier or renderer accepts a callsign from component configuration, a login or
session request, the application payload, or local UI state. An absent, empty, or
whitespace-only token callsign fails as MissingCallsign; every other noncanonical
field-18 value fails as InvalidCallsign. Both fail before a verified identity is
returned, so a component whose own runtime IdentityToken cannot pass that gate fails
boot or activation. The coordinated field-18 cutover and credential reset are defined in
the wire contract.
Payload confidentiality — server-blind E2E content encryption¶
Model (current): member content — chat, drawings, channel definitions, positions, and
voice — is encrypted with AES-256-GCM under the deployment group key before it enters
the envelope. The wire payload field carries a SealedContent blob:
version(1)=0x01 | epoch(u32 BE) | nonce(12) | GCM(ciphertext‖tag). The group key is
managed by GroupKeyManager (current + previous epoch, plus a bounded contiguous
backfill ring for offline catch-up — see
wire-protocol → Group-key rotation and backfill).
Heartbeats remain plaintext so liveness/presence survives a missing or stale key.
The router is payload-blind. It reads the signed-cleartext classification and
owner_principal_id envelope fields for its classification gate and channel-ownership
check, and never decodes payload. It stores and relays opaque ciphertext. It is also
group-key-free: the Directory issues the group key to clients only via
GET /api/group-key (authenticated, Bearer token) and denies it to server/relay
principals (platformType:'server'). The server never receives or holds the group key.
Android client holds group-key material. The Android client pulls the group key from
the Directory at login/extend, holds it in-heap (persisted encrypted), seals outbound
content, and opens inbound SealedContent. It fails closed: when no usable key is
present, messages are queued but never sent as plaintext.
Scope of protection. This blinds the router/host — the central aggregator that relays and stores everyone's content and history. It does not protect against edge-device capture: a captured member leaks its own content and its copy of the deployment-wide group key until the key is rotated. That window is bounded automatically — revoking a key-holder triggers a coalesced group-key rotation (rotation-on-loss, see Revocation), so the captured key stops opening newly-sealed content without an operator having to remember to rotate. Edge capture is bounded (one viewpoint) and revocable; further tightening (per-channel keys, forward secrecy) is tracked as follow-up.
Accepted residuals. Metadata remains visible to the router: principal identities,
message timing and tempo, key-expression cell / chat_id / msg_id, the cleartext
classification level, and message sizes. The deployment group key gives every member
read access to every channel (no per-channel need-to-know). Not forward-secret beyond
rotation. Heartbeats and position routing keys remain cleartext. Transport TLS is
defence in depth, never the authoritative confidentiality gate.
Implementation status. Server-blind E2E content confidentiality is live and uniform
across the fleet. Every client seals outbound content under the deployment group key and
opens inbound SealedContent, failing closed (drop, never plaintext) when no usable key is
held:
commonowns theSealedContentprimitive +GroupKeyManager(common/src/crypto/group_key.rs).- The
serveris payload-blind and group-key-free — it never decodespayloadand holds no group key. - Android seals chat, drawings, positions, and voice through the single
sealOutboundPayloadchokepoint (core/engine/TacNetUtils.kt), plus its channel-def / membership (GossipChannelPublisher) and command (CommandPublisher) paths; inboundOpenStagedrops on missing key / unknown epoch / decrypt failure. - Web seals every pub/sub content publisher — chat, drawings, positions, voice, channel
defs, membership, records, and commands — and stamps the signed-cleartext classification;
sealOutboundthrows rather than emit plaintext. - Node seals its positions + command-acks and opens inbound device-commands, stamping its generation-classification label.
- Gateway seals content it injects from external feeds and opens inbound content before conversion, both fail-closed.
Only heartbeats travel plaintext (presence must survive key loss). There is no plaintext-content path and no dual-read fallback anywhere — the coordinated wire-breaking cutover is complete, so the historical divergence (Android sending plaintext protos while web sealed) is closed.
Key custody at rest (target principle — not yet enforced)¶
Server-blind E2E removes the central-aggregator blast radius but does nothing for edge capture: a captured key-holder leaks its copy of the deployment group key, which decrypts every member's content until rotation. How exposed a given device class is depends entirely on how it holds the key when not running. The principle we are moving toward:
A key-holder may persist the group key only if it can store it under hardware-backed secure storage (TEE / StrongBox / TPM / HSM). If it cannot, it MUST NOT write the key to disk at all — it pulls the key from the Directory on every boot and holds it in memory only.
So each device class lands on one of two approaches:
- Encryption at rest — persist the key wrapped by a hardware-bound key that never leaves secure hardware. Survives reboots without a Directory round-trip; only acceptable when the hardware backing is actually present.
- Pull at boot, in-memory only — never write the key to disk; fetch it from the Directory at startup (boot fails closed without it) and keep it in RAM. A powered-off captured device then has no key at rest to extract.
The decision between (1) and (2) is per device, gated on its actual secure-storage capability — never a static per-platform assumption. A device that would use approach (1) but finds no hardware-backed keystore at runtime must fall back to approach (2), not silently persist under a software key.
This principle is a target, not the current state. Where each entity stands today:
| Entity | Today | Matches principle? |
|---|---|---|
| Node | In-memory only; key never written to disk (fetched-bundle bytes zeroized after install); boot-required, fail-closed. | Yes (approach 2). |
| Gateway | In-memory only; boot pull, fail-closed. | Yes (approach 2). |
| Android | Persisted via EncryptedSharedPreferences under an AndroidKeyStore MasterKey — hardware-backed (TEE), and being StrongBox-pinned where available (docs#40 Tier 1, android#136). |
Partial (approach 1): the at-rest wrapper is hardening, but EncryptedSharedPreferences silently falls back to a software master key on a device with no secure keystore — which the principle forbids — and the wrapper does nothing for the heap-plaintext runtime exposure. |
Note the android entry hardens only the at-rest wrapper: the group key is stored as an
encrypted blob, not as a non-extractable Keystore key object, so the key bytes are
still plaintext in heap at runtime (and during the Directory fetch). At-rest wrapping
stops a key lifted off stopped storage; it does not stop in-place use or heap extraction on
a powered/unlocked device. Closing the heap-exposure gap means holding the key as a
hardware-bound Keystore key (crypto via Cipher, key never in heap) or wrapped-key import —
materially larger work, tiered in #40.
Gaps to close (tracked): android is pinning StrongBox where available (android#136); the remaining principle gap is that it must refuse to persist under a software key — degrading to approach (2) (pull-at-boot, in-memory) on a device without a hardware-backed keystore, rather than writing a key it cannot protect. The capture-window-bounding companion (rotation when a holder is lost) and the heap-custody tiers are separate levers. The group-key rotation mechanism (operator-triggered rotation + a contiguous offline-backfill ring — see wire-protocol → Group-key rotation and backfill) auto-fires on loss: the Directory rotates the group key when it revokes a key-holder (a non-server device, or a principal an operator flags lost/captured), so a captured holder is cut off from newly-sealed content without a manual step — see Revocation → rotation-on-loss. What remains under #40 is the at-rest storage-capability gate and the StrongBox/heap-custody tiers. The degraded-key trust surfacing is #21.
Direct tactical payload boundary¶
The direct-payload contract assigns former wrapper metadata to its authoritative source and requires semantic conflict checks after exact envelope/publication verification and opening, before dispatch. Payload event time does not replace envelope freshness; Gateway origin is sealed correlation, never identity. This additive contract does not change current consumer decoders or relax any gate below.
Receive-side gates¶
Every inbound AuthEnvelope MUST be rejected unless all of these hold, in this
order (waypoint_common::auth_envelope::verify_with_policy):
| # | Gate | Detail |
|---|---|---|
| 1 | Nonce length | exactly NONCE_LEN = 12 bytes. |
| 2 | Clock skew | issued_at_ms within ±DEFAULT_REPLAY_WINDOW_MS = 60_000 of now_ms (fresh frames; SkewPolicy::FreshOnly). |
| 3 | Identity | identity_token decodes, its Directory Ed25519 signature verifies against the cached Directory key set, and expires_at_ms > now_ms. |
| 4 | Device signature | the 64-byte device_signature verifies over the signing input under the token's principal_sign_key. Checked before the replay cache mutates, so a forged envelope cannot burn a nonce slot. |
| 5 | Replay | (principal_id, nonce) not seen within the 60 s per-subscriber replay window. |
Token expiry (gate 3) and replay eviction (gate 5) always use wall-clock now_ms;
SkewPolicy::AllowStale relaxes only gate 2, for envelopes replayed byte-identical
from a server-side state-sync store. Durable authorization records carry a signed
purpose that additionally relaxes expiry — see the next section.
Durable-record verification (signed envelope purpose)¶
Some records are not real-time messages — they are standing facts that must outlive
the ~5-minute IdentityToken that signed them, and are read back from catch-up/retained
storage arbitrarily later. Two kinds:
- Durable grants — authorization facts: channel membership invites
(
waypoint/global/invite/<invitee_segment>/<channel_id>— the invitee slot is the opaqueinvite_segment(salt, principal_id), never a cleartext principal id; see the wire-protocol page's "Opaque invite addressing"), channel definitions (waypoint/global/channel/**), and the authority-bearing record-plane kinds — plans, ORBAT units, report requirements (waypoint/global/record/{plan,orbat,requirement}/**). - Durable content — tactical/data records: TCMs, drawings, tactical markers on
waypoint/<cell>/draw/**, and append-only reports (waypoint/global/record/report/**).
Both live for the operation lifetime — a grant until an explicit revoke
(ChannelMemberUpdate.joined = false, superseded by higher updated_at_ms); content until
an explicit delete/tombstone. Neither lapses just because the signer's identity token has
expired or the signer went offline. Tying their currency to the 5-minute token TTL is the
root cause of the "late joiner silently misses a chat/voice channel" bug and the
"web-drawn TCM stops reaching new joiners while existing clients keep it" divergence — both
seen in the wild.
Durability is declared by the signer, in the signed envelope¶
The AuthEnvelope carries a purpose field:
EnvelopePurpose |
Meaning |
|---|---|
LIVE (default, 0) |
Real-time frame. Strict freshness. |
DURABLE_GRANT (1) |
Standing authorization fact (invite / channel def / plan / ORBAT unit / report requirement). |
DURABLE_CONTENT (2) |
Standing data record (TCM / drawing / marker / report). |
purpose is part of the signing input — it is covered by device_signature (gate 4),
so it cannot be flipped on a captured envelope without breaking the signature, and cannot
be set without the principal's signing key. Durability is therefore the signer's
declared intent, travelling with the record, not a reader-side guess about a key
expression. The IdentityToken is untouched — it keeps its normal ~5-minute expiry, so
the person's identity stays mortal and the revocation list stays GC-able; only the record
is durable.
Verification¶
purpose is plaintext (integrity-protected at gate 4), so the verifier reads it up front
and selects the policy. Both durable variants get the same relaxation:
| Gate | LIVE |
DURABLE_GRANT / DURABLE_CONTENT |
|---|---|---|
| 1 Nonce length | enforced | enforced |
2 Clock skew (issued_at_ms) |
enforced (FreshOnly) |
relaxed (AllowStale) |
| 3a Token Directory signature | enforced | enforced |
3b Token expiry (expires_at_ms) |
enforced | relaxed (AllowExpired) |
4 Device signature (covers purpose) |
enforced | enforced |
5 Replay (principal, nonce) |
enforced | enforced (separate cache) |
| Revocation (caller-side gate) | enforced | enforced |
Cross-plane misuse is rejected belt-and-suspenders — each subscriber requires the specific purpose for its plane, so a record can't be smuggled across types:
| Plane | Required purpose |
|---|---|
invite/**, channel/** |
DURABLE_GRANT |
draw/**, target/** and their tombstones |
DURABLE_CONTENT |
record/orbat/** |
DURABLE_GRANT |
record/plan/** |
DURABLE_GRANT; Web-authored plan versions publish under exact REGISTERED_CONTENT |
record/report/** |
DURABLE_CONTENT |
record/requirement/** |
DURABLE_GRANT |
record/plan-ack/** |
DURABLE_CONTENT under the exact-version PlanAcknowledgementV1 contract |
| retained chat | LIVE under REUSABLE_CONTENT |
non-retained position, heartbeat, voice, command, live CommandAck |
LIVE under LIVE_PUBLICATION |
| native/channel-member/router liveliness | Specialized transient control; no LIVE_PUBLICATION family |
A ChannelMemberUpdate arriving as DURABLE_CONTENT, a drawing as DURABLE_GRANT, or any
durable record as LIVE (and vice-versa) is dropped before apply. The two durable values
are kept distinct precisely so this assertion is a real boundary, not just a label — a
single merged DURABLE would let an authz grant ride the content plane and vice-versa.
The standalone SensorInfo/SensorRemoved plane is absent. Sensor map objects and removal
use drawing values and tombstones, while Node camera-advertisement metadata remains inside
the signed position payload.
The record plane (waypoint/global/record/<kind>/<unit_id>[/<record_id>]) splits the
assertion per kind segment. Complete ORBAT units are Directory authority projections
and use DURABLE_GRANT with exact committed-mutation capabilities. Plans and report
requirements use DURABLE_GRANT; reports use DURABLE_CONTENT. Their semantic
publication gates still come from the current entitlement matrix rather than from grant
mode. In particular, receivers require the verified report author's current
command appointment on the reporting unit itself, including the committed
provisional-team lead case, dedupe on the (unit_id, report_id) tuple, and never replace a
stored row; a correction is a new report. Catch-up queries use the same per-family purpose.
An ORBAT, plan, or requirement record signed DURABLE_CONTENT, or a report signed
DURABLE_GRANT, is dropped before apply — purpose is inside the signing input, so a relay
cannot re-plane a record.
The expiry knob is verify_identity_token_opts(.., allow_expired = true)
(common/src/crypto/identity.rs), reached on both durable paths. The docstring already
states the contract: "currency for historical data is enforced out-of-band by
purge-at-revoke, not by this check."
Security posture¶
Relaxing expiry removes the Directory's periodic re-attestation as a freshness signal on
the durable planes, so revocation becomes the currency check. Every durable consumer
MUST gate the verified principal_id against the revoked-principals snapshot after verify
and before applying (the same &[] deferral the envelope verifier makes for live frames —
see Revocation). A consumer that reads a durable plane without the revocation
gate is a security hole. Replay reads use a separate replay cache from the live path so a
retained sample cannot burn a live nonce slot.
The two durable variants carry different stakes:
DURABLE_GRANTconfers access (channel membership), so revocation here is load-bearing — an expired-but-not-revoked grant still admits the principal until the revoked snapshot catches it.DURABLE_CONTENTconfers no access — worst case a stale TCM from a departed author lingers on the map. Revocation still applies (don't surface a revoked principal's content), but signature validity is not the display lifetime: content display is governed by its own app-level validity window and explicit delete/tombstone, exactly as PLAN sections hide on out-of-band start/end times.
Chat history — the one LIVE-purpose exception¶
Chat messages are published LIVE (strict), but their history is read back from
storage long after the signing token has expired. So chat-history catch-up is the single
place a LIVE-purpose envelope is verified allow-expired — it does not assert a
durable purpose, because the records were legitimately signed LIVE and are being
replayed with old tokens.
The requester presents a QUERY capability and use proof scoped to one exact opaque chat
channel. Every reply remains inside that selector and capability, independently passes its
original envelope/key/publication checks, and is held provisional until the final
RetainedReplayReceiptV2 verifies. There is no "strict failed → retry allow-expired"
fallback, and a live position, voice, or drawing envelope cannot enter the chat-history
branch. Directory signature, device signature, nonce replay, and revocation remain
enforced; only the original chat envelope's skew/token-expiry checks are relaxed. This is
the retained LIVE exception under REUSABLE_CONTENT, not LIVE_PUBLICATION.
Rejected alternatives¶
- Null
IdentityToken.expires_at_ms(immortal identity). Puts "never expires" on the principal's identity rather than the grant — immortal on every plane (voice, chat, positions), not just durable reads. Inverts the expiry gate to fail-open (absent value = most-privileged), forces the revocation list to retain the principal forever (no expiry after which the token is dead anyway), and needs the Directory to mint non-expiring tokens — a leaked one never dies. Right idea (durable record), wrong object (identity, not grant). - Reader-side / key-plane allow-expired (no signed marker). Reader decides to ignore
expiry based on the key expression; the signer never declares intent, so a reader could
apply the durable policy to a record meant ephemeral, and the policy lives in scattered
subscriber code instead of the bytes. Less robust than a signed
purpose. - Long-lived / service-principal grant-signing key. Sign durable grants with a Directory-managed long-lived service credential instead of the granter's token. Viable long-term and removes the expired-token awkwardness, but a larger Directory-side change and still revocation-dependent. Deferred.
Deployment — coordinated cutover¶
purpose is part of the signing input, so the production boundary has no dual-read window.
common, server, gateway, web, android, and node deploy from one release lock
while traffic is stopped. Operators wipe pre-cutover credentials and retained
development/test state, then re-enrol subjects before traffic resumes. A value without the
current signed purpose is rejected; no component re-emits, upgrades, or translates it.
Application-layer dedup (separate from gate 5)¶
Cryptographic replay (gate 5) is distinct from logical duplicates. Application layers
dedup by canonical message identifier — chat by message_id, drawings by shape_id,
positions last-write-wins — never by (principal, sequence).
Identity binds to data through principal_id, not the wire key¶
principal_id is a stable Directory-assigned UUID, constant across devices and key
rotations (directory.proto:30). It is the authoritative identity extracted from the
verified token. Any sender_public_key-style field on the wire is attacker-controlled
and ignored. device_id (field 16) is the stable per-device anchor —
SHA-256(FIDO credential id) for operators, SHA-256(device id) for devices — so
receivers key one node row per physical device and re-login updates it in place.
Revocation¶
RevocationList is Directory-signed (Ed25519), monotonic by sequence, and carries
two levels (directory.proto:122-148):
revoked_principals— cascades to every token/key ever issued for aprincipal_id(operator, device, or server).devices(RevokedDevice) — revokes one device by its 32-byte Ed25519device_sign_key, when a device key is compromised but the operator stays active.
There is no revoked_certs list; identity is token-only. The Directory serves the
signed snapshot at /api/revoked-principals; clients
cold-start from LoginResponse and refresh on a live feed.
Enforcement is per platform (see notes below). On Android the receive pipeline checks
both levels after verify, before dispatch (MessageProcessor RevocationStage:
getRevokedPrincipals + isSignKeyRevoked). Server records the
mtls_session_force_closed audit event for a revoked federated peer, but stock Zenoh
exposes no close hook: that event is not proof that the socket or a field-client
subscription was remotely terminated. Current revocation instead rejects each newly
verified envelope or storage use before application use.
The "verify, then check revocation before use" rule is compiler-enforced as a
typestate. verify returns a VerifiedEnvelope whose identity is private; the only way
to reach the underlying identity is
VerifiedEnvelope::authorize(&RevocationSnapshot) -> AuthorizedIdentity
(common/src/auth_envelope.rs). A caller therefore cannot act on a verified envelope
without first passing it through a revocation snapshot — the gate is enforced by the type
system, not by convention or a remembered call. Common's storage verifier applies the
current signed RevocationSnapshot and returns authority only through the typed
AuthorizedIdentity. Server uses that path through storage_validator.rs for ingest,
restart validation, and retained replies.
Rotation-on-loss¶
Revoking a key-holder additionally rotates the deployment group key, bounding the post-capture confidentiality window (the Directory is both the revocation authority and the rotation authority, so the two events meet there). The trigger is deliberately narrow:
- A non-server device revocation (node/gateway/web — all hold the key) always rotates.
A server is a content-blind relay that is denied the key at
/api/group-key, so revoking one rotates nothing. - A human principal revocation rotates only when the operator marks it lost/captured — a captured Android holds the same single group key, but a routine offboarding must not re-key the fleet. The bound for a captured handset is this rotation, not fog-of-war or clearance: neither limits decryption of already-held-epoch ciphertext under one key.
The revoked holder is excluded from the new epoch by construction: its token is on the
revoked snapshot, so /api/group-key returns 403 and it never receives the new key. It
keeps whatever it already cached under epochs it already held; it gets nothing sealed under
the new epoch. Honest members catch the new epoch via the backfill ring.
Rotations are coalesced Directory-side — a burst of revocations (a whole unit's
devices) collapses to one rotation via a short debounce — but the two triggers differ in
urgency. A lost/captured flag rotates within the debounce window (~seconds),
bypassing the inter-rotation rate cap so an urgent capture response is never made to
wait behind a routine rotation's budget; it still coalesces, so a capture inside a wider
burst yields one rotation, not many. Bulk device auto-rotations (a device_revoked
burst) are additionally rate-capped to a minimum inter-rotation interval — derived
from the offline-resync horizon and the backfill-ring capacity — which keeps them within
the contiguous-backfill budget, so an honest device offline within the horizon resyncs
incrementally rather than being forced to a full resync. Either way the residual is the
same and bounded: content already sealed under the current, not-yet-rotated epoch
stays readable to a still-holding key-holder until the rotation fires — rotation cuts the
captured key off from everything sealed under the new epoch onward, not retroactively.
Classification¶
Classification rides on Directory-signed identity, capability, and Server tokens. Enforcement follows the data path:
- A publisher preflights the signed-cleartext envelope classification against its current identity and exact publication-capability ceilings.
- Relay + Storage validates every retained ingest and reply against the subject,
publication/query capability, and
ServerToken.clearanceceilings before accepting or releasing the opaque value. - The stock Relay does not inspect non-retained LIVE envelopes. Every receiving subscriber verifies the identity, capability, use proof, signed-cleartext classification, and its own policy before payload decode. A modified publisher cannot make an over-ceiling value authoritative by bypassing its local preflight.
Classification banners derive from the same verified sources but remain UI indicators; the verifier, not the rendered banner, is the enforcement boundary.
Channel-definition persistence integrity (client)¶
Chat/voice channel definitions propagate P2P over gossip (waypoint/global/channel/**)
and are persisted into the client's local DB. Each definition is an AuthEnvelope and
clears all receive-side gates (1–5 above) before any DB write. Two persistence invariants
then guard a channel's stored definition against after-the-fact tampering — they are
client-side integrity rules applied after the receive authority gate:
-
Stored classification is monotonic (raise-only). A re-received definition for an existing
chat_id/voice_idmay only raise the persisted classification, never lower it: the upsert resolvesclassification = MAX(stored, incoming)(ChatDao.upsert/VoiceDao.upsert). Channel-definition update payloads (rename, recolour) carry no classification field at all, so an edit decodes to level 0 and theMAXkeeps the established level — a name/colour edit can never silently zero a channel's classification. Net effect: once a channel's classification is established, no participant — owner, global admin, or attacker — can downgrade it on any client by re-broadcasting a definition. -
Mutation is owner- or global-admin-gated, on the verified identity. After verify, a channel upsert or delete is accepted only when the verified sender is the channel's established owner or carries a global-admin role (
admin/operator) in its Directory-signed token — mirroringChat/Voice.hasOwnerPowers(ChannelGossipSubscriber.upsertAuthorized/deleteAuthorized). A new channel (no stored owner) is established by its first definition; a delete of an unknown channel is never accepted. The gate keys onprincipal_id+ tokenrolesfrom the verified envelope — never the wireowner_principal_idfield, which is attacker-controllable.
The ceiling attaches to a different subject per entity¶
Every participant has a classification ceiling, but the subject it binds to —
and how a breach is handled — differs by entity. All of these resolve to the same
on-the-wire signed-cleartext classification envelope field; the per-entity rules describe
who sets the ceiling and how each verifier handles it, not a second label format.
-
Device / web client (a logged-in principal) — the ceiling is per user, carried as
IdentityToken.max_classification, not a property of the hardware. The same device cleared higher for one operator must drop to a lower ceiling when a lower-cleared operator logs in. The client stamps a per-messageclassificationon each envelope; a device generates content on behalf of its user, so the user's clearance is the bound. The web tier is itself a key-holding member. A FIDO-bound browser seals/opens only under its short-lived human grants. The Web machine's trusted server-side recorder is separate: Directory's automatic relationship gives it the registered all-cell position selector and five closed aggregate retained-query selectors. Manual relationships add only exact chat/voice channels, position cells, and explicitly related plan-version acknowledgement reads. These grants remain bounded by machine clearance, operation scope, and applicable relationship/content ceilings. Web may open verified samples to maintain long-term application archives such astrack_hitsfor UI replay, inside the trusted tier and outside the untrusted-router boundary. It receives no unregistered family, cross-operation scope, invite, membership, heartbeat, sensor, command, live-command-acknowledgement, or arbitrary snapshot visibility. -
Server (
waypointrouter) —ServerToken.clearanceis a host/storage ceiling. The generic retained validator rejects ingest and query replies above it while leaving application payloads sealed. The stock Relay does not inspect or classification- gate non-retained LIVE forwarding; publishers preflight and receiving subscribers remain authoritative for that traffic. (Implemented for retained storage.) -
Gateway — a key-holding member that seals and opens member content E2E like any member: it seals the content it injects from external feeds before publishing to the mesh, and opens inbound sealed content after envelope verify, before converting it. Like a server it also carries a receive/transmit ceiling, but because it bridges to external systems (interop egress/ingress) the ceiling is an active drop-gate: it reads the signed-cleartext
classificationon the envelope (no group key needed) and drops content above itsmax_classificationon receive — before any open — and again before anything it would convert or emit leaves for a foreign system. The gateway must never up-level or leak content past its clearance. It pulls the group key from the Directory, so it requires Directory connectivity at boot: with no initial key it can neither seal nor open, and startup aborts (fail-closed — it never falls back to plaintext). (Implemented.) -
Node (
waypointnode) — a node has no human principal, so there is no user ceiling to inherit. The relevant classification is instead the level the node generates — the marking it stamps on the content it emits (e.g. the classification of its own position). A node is a content source, so it carries a generation label rather than a clearance. As a key-holding member it seals the content it emits (position updates + command-acks) under the group key before the envelope and opens inbound device-commands after envelope verify + the revocation gate, before decode; it stamps the signed-cleartextclassificationfrom its Directory-signedIdentityToken.max_classification(so the level cannot be locally up-stamped) with an empty owner. The generation label is a labelling action, not a drop-gate — unlike the gateway the node has no receive/transmit ceiling and consumes what it is sent. Heartbeats stay plaintext (presence must survive key loss). It pulls the group key from the Directory and holds it in memory only, so it requires Directory connectivity at boot — with no initial key it can neither seal nor open and startup aborts (fail-closed; no usable key ⇒ drop, never plaintext). (Implemented.)
The common thread: a clearance bounds consumption and relay (devices, servers, gateways), while a node — having no user — is bound by what it produces.
The auth boundary: transport enforces reachability, the app enforces identity¶
The split between what Zenoh (transport) enforces and what the app enforces is the single most-confused point in this model. The rule:
Zenoh enforces reachability; the app enforces identity. Zenoh provides an encrypted TLS/QUIC link and a default-deny peer/action/key ACL. Field clients use one registered wildcard transport subject because stock Zenoh cannot dynamically update or revoke per-device subjects; its permissions remain limited to the exact closed action/key registry. This subject is coarse reachability and topic admission, not PETRA principal identity. Application authorization — identity, roles, clearance, ORBAT, revocation, classification — remains a Directory-signed token, per-message signature, replay/proof gate, and CMBAC decision re-verified at each authoritative boundary. Identity is token-only; mTLS for a Web bridge or deployment-wide federation is transport defense where configured and never a substitute for the token. Zenoh ACL never becomes the authority on PETRA identity, content, or live audience membership.
For retained reads, this split has an authoritative Relay boundary: Relay + Storage
validates the QUERY capability, fresh exact-selector proof, current revocation, and
reply containment before releasing bytes. Stock Zenoh supplies no equivalent
capability-aware admission hook for a live subscriber declaration. First-party clients
therefore declare only their current relationship-derived exact opaque scopes and verify
every original publication before decode, but that local rule cannot constrain modified
client code.
An endpoint that remains transport-reachable and holds the fleet-wide deployment group-key epoch can widen its own live subscription and decrypt observed traffic, including after app-layer revocation but before rotation. It still cannot forge a publication that passes an honest receiver's subject signature, capability, proof, containment, revocation, and policy gates. Revocation together with group-key rotation prevents the holder from opening newly sealed epochs; neither action erases ciphertext or key epochs already obtained. PETRA 1.0 makes no immediate live-subscription-withdrawal claim and adds no subscription-lease wire, Relay plugin, or Zenoh fork. The full accepted residual and exact first-party selector rules are normative in the Zenoh/DDIL contract.
The two sections below are the mechanism detail behind this statement: token-only identity and transport TLS as defence in depth.
Identity is token-only (no X.509 leaf binding)¶
Neither IdentityToken nor ServerToken binds an X.509 cert serial —
bound_cert_serial is reserved/removed (directory.proto:36,89). Identity is the
Directory's Ed25519 signature over the token, full stop. Transport TLS to the router is
a separate concern (next section) and does not establish principal identity.
Machine / ingest API credentials¶
Endpoint identity above is a Directory-signed IdentityToken, verified through the
AuthEnvelope pipeline. Server listener identity is the separate Directory-signed
ServerToken and never rides an envelope. External producers (NiFi and force-tracking
feeds) that POST into the Web tier authenticate on a separate trust root that shares
none of that machinery.
| Operator / endpoint device | Machine ingest | |
|---|---|---|
| Credential | Directory-signed Ed25519 token | HS256 JWT |
| Verified by | verifyAuthEnvelope (the five gates) |
signature + DB live-lookup |
| Trust root | Directory Ed25519 signing key | deployment secret WAYPOINT_API_JWT_SECRET |
| Revocation | Directory-signed RevocationList |
api_credentials.revoked_at |
| Classification-gated | Yes | No |
Rides AuthEnvelope |
Yes | No |
The JWT is HS256 (algorithm pinned — no none downgrade), signed with
WAYPOINT_API_JWT_SECRET. Its header carries a kid; claims are { kid, scopes, ops,
iat, exp? }. The signature is the credential — no secret is stored at rest.
Authorization runs on two axes: scopes (detections:write etc.) enforced in
middleware, and ops (an operation-id allowlist; ["*"] = account-wide, empty =
deny-all) enforced per-row because the operation id rides the request body.
Revocation is its own registry, unrelated to the Directory RevocationList. The
api_credentials table holds metadata only (no secret column). Verify checks the
signature, then requires the kid to be live — present, not revoked, not expired
(findByKidLive). Revoking stamps revoked_at and is effective from the next request:
the signature still verifies, the live-lookup fails. There is no un-revoke; rotating
WAYPOINT_API_JWT_SECRET kills every outstanding token at once.
These credentials are machine-API auth, not principal identity — they carry no
clearance and are never classification-gated. The full HTTP contract (endpoints, batch
semantics, status codes, payload shape) is in
protocol/ingest-api.md.
Transport TLS (defence in depth, not the identity gate)¶
Transport is Zenoh. Production native locators are quic/ or tls/; browser access is
fronted as HTTPS/WSS. quic/ here is a Zenoh transport locator.
- Client → router: server-cert TLS only. Android uses the device-managed bootstrap root or CA as its router trust anchor. If no custom CA is configured, Zenoh uses the system trust store. If a custom CA is configured, any failure to persist or write its trust bundle is terminal; the client must never downgrade to system trust after that failure. Android does not present a client cert. Browsers likewise cannot present a client cert on the WSS upgrade. App-layer envelope verify is therefore the authoritative gate on this hop.
- Server ↔ server / router federation: each router pins the outbound peer's
server certificate to the deployment CA and presents its own server leaf when
dialing. Strict inbound mTLS is optional and must be enabled consistently for
every node that connects. Peer identity remains the Directory-signed
ServerToken, not the certificate CN.
Transport TLS is always defence in depth. The PETRA 1.0 boundary requires the original
subject-signed AuthEnvelope, LIVE capability, and proof to reach and be verified by every
receiving subscriber. Voice has no server-vouched or signature-bypass exception.
The only plaintext production listener is the bounded health endpoint. It carries no application traffic, credentials, authority, content, or mutation surface. Plain TCP, UDP, and WS data-plane listeners are invalid production configuration.
Voice¶
- Native path: Server forwards the original subject-signed voice envelope,
capability, and use proof without interception or republication. Before opening,
Android verifies the original principal/signing key, capability containment, key digest,
purpose, classification, proof/replay, and known revocation. After opening the raw
VoiceFrame, it re-derives the exact channel/principal/session key and checks current channel membership before audio dispatch. Replay entries are committed only after the complete authority composition succeeds, so an invalid attachment or moved key cannot burn the valid frame. - Web per-frame path: the browser likewise verifies each original
VoiceFrameenvelope and LIVE authority before the jitter buffer. - Addressing and subscription: both clients derive
waypoint/global/voice/<opaque-channel>/<opaque-principal>/<opaque-session>from the deployment salt. First-party subscribers declare only exact capability-derived channel selectors; the clear channel, principal, and stream identifiers remain inside the endpoint-signed sealed frame and are re-derived after open.
Trust-surfacing UX (badges)¶
Clients graceful-degrade and label rather than silently drop. The badges are a visibility aid, not an enforcement boundary — enforcement is the envelope verify + revocation chain above. A message that passes envelope verify is cryptographically authentic regardless of which (even untrusted) relay carried it, and shows as trusted (no badge). The badge marks per-item verification state, never the relay path.
- Chat / node
TrustBadge(VERIFIED→ no chip /UNVERIFIED/REVOKED/SELF_PENDING): the live trust of the sender's node, joined at read time from its token/revocation state — never persisted on the message, so revocation surfaces retroactively. Shown only on an issue; verified senders are uncluttered. - Voice speaker badge: the badge and callsign resolve only from the original verified subject identity after full LIVE authorization. Server identity is never accepted as a substitute endpoint signer.
- Undecryptable placeholder (
UNVERIFIED-built, gated on revocation): content sealed under an epoch the client doesn't hold renders an "encrypted · can't display" placeholder (chat bubble / map lock marker) instead of vanishing — built only from cleartext + verified identity, never overwriting a real row, and replaced when a later key refresh + catch-up reopens it. Built on android (OpenStage→handleUndecryptable;UndecryptableDrawingsLayer) and on web (session_lifecycle.ts'sUndecryptableEnvelopeErrorpreserves the verified identity through a sealed-open failure;subscriber_loop.ts'sdecodeAndVerifyexposes it via anonUndecryptableseam thatchat_message_subscriber.ts/drawing_subscriber.tswire tochatStore/drawingsStore;UndecryptableDrawingsRendererrenders the map marker).
Explicitly NOT enforced¶
- Per-sender sequence ordering.
TacNetMessage.sequenceMUST NOT gate acceptance. A single peer mintingsequence = wall_clock_ms()locks out a legitimate principal across every receiver that gates on monotonicity, and a benign device wipe resets the counter below the high-water mark. Replay (gate 5) and the device signature (gate 4) already cover the threat. The field is reserved/removed in the current schema; no compatibility writer or acceptance gate may restore it. - Cross-transport
(principal, sequence)dedup. The same message legitimately arrives over multiple transports (live + state-sync replay) carrying the same nonce; gate 5 plus application-layer canonical-id dedup handle it. - Session / CSRF on the machine ingest boundary.
/api/feed/*is the one cookie-less, CSRF-exempt write boundary — it sits outside the session /operationScopegroup. The HS256 bearer token is the sole credential there; there is no browser session to protect. See Machine / ingest API credentials andprotocol/ingest-api.md.
Per-platform notes¶
| Concern | Android (native) | Web (browser + AdonisJS tier) | Server (waypoint) |
|---|---|---|---|
| Verifier | Kotlin verifyEnvelopeByPurpose for ordinary planes; voice uses native Common verifyLivePublication to compose endpoint envelope + current revocation + capability + proof + exact key before open/decode |
TS verify plus LIVE publication authority composition; fixture-parity |
waypoint_common::auth_envelope for retained application boundaries; native Relay leaves voice bytes untouched |
| Transport to router | Zenoh TLS/QUIC, server trust bundle, no client cert by default | WSS to the remote-api bridge | private-CA TLS/QUIC federation; optional deployment-wide inbound mTLS |
| Revocation enforce | Current principal + device-sign-key snapshot per voice frame after signature verification; ordinary planes also gate post-verify | Current principal + device-sign-key snapshot per frame; server tier also chokes revoked sessions at login | current signed snapshot in retained validation; own ServerToken revocation/expiry fails health and startup |
| Classification | LIVE capability ceiling enforced per frame before open/decode; UI also surfaces the ceiling | LIVE capability ceiling enforced per frame before open/decode; UI also surfaces the ceiling | retained storage ceiling in storage_validator.rs; native LIVE Relay does not inspect envelopes |
| Content sealing | GroupKeyManager (AES-256-GCM); seals outbound, opens inbound; fails closed without key |
GroupKeyManager (browser WebCrypto + Node node:crypto); seals/opens all pub/sub content incl. channel defs + membership, fails closed |
payload-blind relay; no group-key held |
| Voice | Per-frame original-subject/capability/proof verification on exact opaque selectors | Per-frame original-subject/capability/proof verification on exact opaque selectors | Byte-transparent native Relay; no re-wrap, decode, retention, or signer substitution |
Where to read the code¶
| Concern | Location |
|---|---|
| Canonical envelope verify | common/src/auth_envelope.rs (verify → VerifiedEnvelope; authorize(&RevocationSnapshot) → AuthorizedIdentity typestate; verify_with_policy, signing_input, dispatch on EnvelopePurpose), common/src/revocation.rs (RevocationSnapshot, RevocationLedger) |
| Capability and retained-value verification | common/src/storage.rs; server/src/storage_validator.rs and server/src/storage_handler.rs; gateway/src/inbound.rs for the gateway receive boundary |
SealedContent wire format + GroupKeyManager |
common/src/crypto/ |
| IdentityToken / ServerToken / RevocationList | common/proto/server/directory.proto |
| Android receive pipeline | android/.../core/engine/MessageProcessor.kt (verify → revocation → dispatch) |
| Android channel-def persistence gate (owner/admin + raise-only classification) | android/.../core/transport/zenoh/ChannelGossipSubscriber.kt (upsertAuthorized, deleteAuthorized), android/.../core/db/dao/{ChatDao,VoiceDao}.kt (MAX(classification)) |
| Android envelope + device-key signing | android/.../core/crypto/AuthEnvelope.kt, SecretStore.kt |
| Android group-key fetch + content sealing | android/.../core/directory/DirectoryClient.kt, GroupKeyManager |
| Web content sealing + key refresh + trusted archive | web/inertia/features/transport/wire/{sealed_content,group_key,auth_envelope}.ts, web/app/domains/auth/group_key_controller.ts, web/app/domains/tracks/position_zenoh_recorder.ts |
| Android router trust bundle | android/.../core/transport/zenoh/ZenohEndpointRegistry.kt |
| Server retained classification gate | server/src/storage_validator.rs |
| Server peer trust and identity | server/src/router.rs, server/src/server_token_gossip.rs |
| Directory group-key issuance (clients only) | directory/app/... (/api/group-key) |
| Directory revocation + keys | directory/app/... (/api/revoked-principals, /.well-known/directory-key) |
| Web envelope verify | web/inertia/features/transport/ + web/app/domains/... |
| Machine ingest credentials (HS256 + registry) | web/app/domains/api_credentials/, web/app/middleware/api_auth_middleware.ts, web/app/domains/api/ |
Cross-platform commitment¶
- Implement the receive-side gates exactly as above; reach an equivalent verifier by
calling
waypoint_common::auth_envelopeor shipping a fixture-parity re-impl. - Do not add a sixth gate (sequence ordering, cross-transport sequence dedup) without landing the same change on every client in the same release.
- Keep retired
TacNetMessage.sequencereserved; never emit it or gate on it. - Keep transport TLS as defence in depth; never let it substitute for envelope verify.
Reporting¶
Security-sensitive issues: do not file public tickets. Email
security@bedrock-defence.com (or your deployment's equivalent).