Skip to content

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.md and web/SECURITY.md are 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.rs signing_input). purpose is 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-signed IdentityToken (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_epoch is stamped on the token; clients fetch via GET /api/group-key from the Directory) are the content-encryption concern, separate from envelope authenticity.
  • classification and owner_principal_id are signed-cleartext fields: they are visible to the relay without any key, but are covered by the device_signature so 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:

  • common owns the SealedContent primitive + GroupKeyManager (common/src/crypto/group_key.rs).
  • The server is payload-blind and group-key-free — it never decodes payload and holds no group key.
  • Android seals chat, drawings, positions, and voice through the single sealOutboundPayload chokepoint (core/engine/TacNetUtils.kt), plus its channel-def / membership (GossipChannelPublisher) and command (CommandPublisher) paths; inbound OpenStage drops 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; sealOutbound throws 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:

  1. 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.
  2. 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 opaque invite_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_GRANT confers 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_CONTENT confers 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 a principal_id (operator, device, or server).
  • devices (RevokedDevice) — revokes one device by its 32-byte Ed25519 device_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.clearance ceilings 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_id may only raise the persisted classification, never lower it: the upsert resolves classification = 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 the MAX keeps 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 — mirroring Chat/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 on principal_id + token roles from the verified envelope — never the wire owner_principal_id field, 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-message classification on 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 as track_hits for 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 (waypoint router) — ServerToken.clearance is 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 classification on the envelope (no group key needed) and drops content above its max_classification on 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 (waypoint node) — 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-cleartext classification from its Directory-signed IdentityToken.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 VoiceFrame envelope 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's UndecryptableEnvelopeError preserves the verified identity through a sealed-open failure; subscriber_loop.ts's decodeAndVerify exposes it via an onUndecryptable seam that chat_message_subscriber.ts / drawing_subscriber.ts wire to chatStore / drawingsStore; UndecryptableDrawingsRenderer renders the map marker).

Explicitly NOT enforced

  • Per-sender sequence ordering. TacNetMessage.sequence MUST NOT gate acceptance. A single peer minting sequence = 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 / operationScope group. The HS256 bearer token is the sole credential there; there is no browser session to protect. See Machine / ingest API credentials and protocol/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

  1. Implement the receive-side gates exactly as above; reach an equivalent verifier by calling waypoint_common::auth_envelope or shipping a fixture-parity re-impl.
  2. Do not add a sixth gate (sequence ordering, cross-transport sequence dedup) without landing the same change on every client in the same release.
  3. Keep retired TacNetMessage.sequence reserved; never emit it or gate on it.
  4. 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).