Skip to content

Wire Protocol

How clients and servers talk over Zenoh. Routing is by key expression. Ground truth is the waypoint_common protos (common/proto/server/*) plus the server router (server/); clients (android, web), gateway, and node all speak this.

For per-message verification (gates, signatures, revocation) see security/model.md; for trust roots and tokens see security/pki.md. This page is the wire shape and routing.

Coordinated hard-cut contract

PETRA 1.0 admits only the capability-authorized, opaque-addressed Relay + Storage contract in the normative data-plane specification. Every component must move to the same Common revision before traffic resumes. There is no alternate handshake, address, unsigned publication, retained reader, or compatibility path for an earlier client or connection.

Post-horizon recovery has a distinct purpose, opaque snapshot namespace, deterministic Web cut, and consumer apply path. See the authoritative state snapshot contract. Current realtime snapshot APIs and replay exports are unrelated formats and must not be accepted on this wire path.

Plan receipt is separate from the live command-acknowledgement path. PETRA 1.0 uses the durable, version-bound PlanAcknowledgementV1 family; CommandAck remains only the live response to a correlated pending DeviceCommand.

Direct tactical payload migration

The direct-payload contract defines the authoritative field dispositions, payload fields, semantic bindings and replay rules for Common

257–#259. Chat, Heartbeat, Voice, Drawing, DrawingDelete, Target and TargetDelete

are direct-only in migrated consumers, with no wrapper decoder. Position remains temporarily in TacNetMessage because its Gateway origin witness has no direct payload field under the serialization-only #258 constraint. Android's non-chat migration remains unpublished pending resolution of its behavior-equivalence validator review. There are no legacy clients or live deployments. The eventual candidate after #259 admits no wrapper fallback; the existing immutable candidate has not been regenerated for this migration.

Key namespace

All traffic is rooted at waypoint/. The namespace is cell-first: geocentric topics carry a geohash-5 cell segment right after the root; non-geocentric topics use the literal cell global.

Key expression Purpose
waypoint/<cell>/pos/<opaque-source> Position updates
waypoint/<cell>/heartbeat/<opaque-source> Presence heartbeats
waypoint/<cell>/draw/<opaque-drawing> Drawing shapes (durable)
waypoint/<cell>/draw/del/<opaque-drawing> Drawing-delete tombstones
waypoint/<cell>/target/<opaque-target> Target state (durable)
waypoint/<cell>/target/del/<opaque-target> Target-delete tombstones
waypoint/global/chat/<opaque-channel>/<opaque-message> Chat messages (durable)
waypoint/global/channel/<opaque-channel> Channel definition (durable)
waypoint/global/channel/del/<opaque-channel> Channel-delete tombstone
waypoint/global/channel/members/<opaque-channel> Channel membership projection
waypoint/global/channel/transfer/<opaque-channel> Channel ownership-transfer projection
waypoint/global/invite/<opaque-invitee>/<opaque-channel> Channel invite projection
waypoint/global/voice/<opaque-channel>/<opaque-source>/<opaque-session> Endpoint-signed voice datagrams (best-effort; original LIVE envelope + authorization attachment)
waypoint/global/cmd/<opaque-target>/<opaque-command> Point-to-point device commands
waypoint/global/ack/<opaque-source>/<opaque-command> Command acks (back to issuer)
waypoint/global/router/<opaque-router>/live Router liveliness token
waypoint/global/router/<opaque-router>/token Router ServerToken gossip
waypoint/global/sec/revocations Directory-signed RevocationList feed

<cell> is a 5-char geohash (base32, 0123456789bcdefghjkmnpqrstuvwxyz). A router's ServerToken.coverage_cells bounds ordinary field position/heartbeat publication and its target storage/query scope. The separately registered automatic Web profile may publish drawings and targets and read positions across any canonical cell; that exception remains bound to Web's Directory-issued capability and the closed family/action grammar. Wildcards follow key-expression semantics (** = any suffix).

There is no standalone sensor key alias or LIVE capability. Sensor map objects and removal use draw and draw/del; Node camera-advertisement metadata remains inside its signed position payload.

Storage / durability (observable behaviour)

Prefix Durability
waypoint/<cell>/pos/**, waypoint/<cell>/heartbeat/**, waypoint/global/voice/**, .../router/*/live Transient (none)
waypoint/global/chat/** Durable; CHAT_TTL_MS = 24 h
waypoint/global/record/report/** Durable; RECORD_REPORT_TTL_MS = 48 h
Registered channel, invite, drawing, target, plan, report-requirement, ORBAT, plan-ack, and snapshot families Durable only to the configured offline-resync horizon

All durable retention is bounded by the offline-resync horizon. OFFLINE_RESYNC_HORIZON_MS = 7 d (server/src/store_ttl.rs) is pinned equal to common's outbox_policy::PENDING_TTL_MS, and a CI test asserts every plane's TTL ≤ the horizon. The operating assumption is therefore explicit: a device offline longer than 7 days does a full resync, not a partial catch-up — the server keeps nothing older. The storage engine + sweep cadence are server-internal (see server/ repo).

AuthEnvelope (the wire wrapper)

Every payload on a transport sample is wrapped in one AuthEnvelope proto (common/proto, canonical verify in common/src/auth_envelope.rs):

AuthEnvelope {
  identity_token      // raw Directory-signed IdentityToken bytes
  payload             // SealedContent blob (see Payload confidentiality below)
  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
  device_signature    // 64-byte Ed25519 over the canonical signing input,
                      //   by the token's principal_sign_key
}

There is no nested SignedEnvelope/EncryptedEnvelope. Authenticity and integrity are the Ed25519 device_signature. verify runs, in order: nonce length 12 → issued_at_ms within ±replay window → IdentityToken decodes + Directory Ed25519 sig valid + not expired → device_signature verifies under principal_sign_key → (principal_id, nonce) not replayed. verify returns a VerifiedEnvelope whose identity is private; a caller reaches the identity only through authorize(&RevocationSnapshot) -> AuthorizedIdentity, so the revocation check is a compiler-enforced typestate, not a convention. The server's gate_verified supplies that snapshot, dropping revoked principals, revoked device sign-keys, and tokens with empty device_id, and surfaces max_classification. Full detail in security/model.md.

IdentityToken callsign (PETRA 1.0 flag day)

IdentityToken field 18 is string callsign. Retired numeric field 3 remains reserved and is never a compatibility source; the old reserved name "callsign" is released only so field 18 can use the canonical name. The Directory canonical token signing input includes field 18, and an AuthEnvelope device signature covers the exact serialized IdentityToken bytes it carries, so changing the callsign invalidates both trust layers.

Directory is the only issuer and derives the value at its single token-mint seam:

Subject kind Signed token callsign Display resolution
Human (kind:operator) principal_id.slice(0, 8).toUpperCase() Current verified ORBAT appointment callsign when present, then verified token fallback
Thing/service (kind:device) Trimmed, uppercased Directory devices.callsign Verified token only; ORBAT cannot override

The canonical syntax is 1–32 characters from [A-Z0-9 ._/-]. Directory rejects a thing/service callsign that becomes blank, exceeds the bound, or contains another character. Rust and TypeScript token verification reject an absent, empty, or whitespace-only field as MissingCallsign. Every other noncanonical field-18 value — including lowercase, leading/trailing whitespace, more than 32 characters, or a character outside the allowed set — is InvalidCallsign. Both errors occur before a verified identity is returned. Components whose own runtime identity is an IdentityToken fail boot or activation when that verification fails. ServerToken remains the separate listener identity and is unchanged; a Server's additional service IdentityToken still follows the thing/service rule.

Callsigns from login/session request fields, component configuration, application payloads, and local UI state are untrusted and ignored. Retired TacNetMessage.device_callsign and VoiceDatagram.sender_callsign fields remain reserved; no replacement payload claim is introduced.

This is a coordinated reset, not an additive rollout. Stop runtime traffic; deploy the field-18 schema, Directory issuer, and every verifier/renderer at one exact Common revision; wipe old credentials and retained development/test state; then re-enrol or remint every IdentityToken before resuming. No dual mint, old-token reader, field-3 fallback, local short-ID derivation, or config/session fallback is permitted. The contract is normative now; the dependent Common, Directory, Gateway, Node, Server, Android, and Web changes must all land before the release candidate is runnable.

Conformance vectors and runout

Cross-language hostile vectors must pin field number 18, Directory-signature coverage, the exact human derivation, thing/service trim-and-uppercase canonicalisation, and stable diagnostics. An absent, empty, or whitespace-only value must produce MissingCallsign; lowercase, leading/trailing whitespace, a value longer than 32 characters, and each disallowed-character class must produce InvalidCallsign. Signature tampering remains a separate signature failure. Rust, TypeScript, and generated bindings must agree before the schema is treated as stable.

The coordinated runout covers a human with and without an ORBAT override, a managed device presented with a conflicting ORBAT value, and a configured service boot. It also proves that config, session, payload, and local UI values cannot override the verified result; invalid device-form values are rejected; both callsign diagnostic classes fail closed at every verifier; old credentials fail closed; and all subjects work only after the explicit re-enrol/remint step. No retained pre-cutover token or local short-ID label may survive the reset.

Payload confidentiality — server-blind E2E content encryption

Current model: 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 payload field carries a SealedContent blob: version(1)=0x01 | epoch(u32 BE) | nonce(12) | GCM(ciphertext‖tag) (common/src/crypto/). Heartbeats remain plaintext so liveness/presence survives a missing or stale key.

The router is payload-blind. The classification gate and channel-ownership check read the signed-cleartext classification and owner_principal_id envelope fields — the router never decodes payload. It stores and relays opaque ciphertext. It is group-key-free: the Directory issues the group key to clients only via GET /api/group-key (Bearer auth) and denies it to relay/server principals. The server never receives or holds the group key.

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 revocation + rotation. Edge capture is bounded (one viewpoint) and revocable; tightening it (per-channel keys, rotation-on-loss, forward secrecy) is tracked as follow-up.

Residual metadata. The router can observe timing/tempo, the geohash cell, fixed protocol literals, signed-cleartext classification, message size, and repeated opaque segments. The opaque-addressing contract uses domain-separated salted segments for principal, channel, unit, operation, and object identifiers. The deployment group key gives every member read access to every channel (no per-channel need-to-know). It is not forward-secret beyond rotation.

Implementation status. Live and uniform fleet-wide: common (primitive), server (payload-blind), and the Android, web, node, and gateway clients all seal outbound and open inbound SealedContent, failing closed (drop, never plaintext) when no usable key is held. Only heartbeats travel plaintext. There is no plaintext-content path and no dual-read fallback — the wire-breaking cutover is complete. See security/model.md.

Connection and router identity

Client                                  Router
  ├── session open (TLS or QUIC) ──────►│  configured locator + peer admission
  ├── get(global/router/**/token) ─────►│  endpoint-local control queryable
  │◄── Directory-signed ServerToken ─────┤  router identity + replay public key
  ├── verify + bind token to endpoint ─►│  no application traffic before this
  ├── use Directory capabilities ──────►│  closed action/key gates
  ├── get(exact retained selector) ────►│  fresh QUERY capability + use proof
  │◄── ordered stored AuthEnvelopes ─────┤
  │◄── RetainedReplayReceiptV2 ──────────┤  final successful reply
  └── publish / subscribe ──────────────►│  only within verified capability

Router selection starts from the endpoint's configured locator set. After opening the secure transport, the client queries waypoint/global/router/**/token; the directly attached router returns only its own token at its exact signed opaque router key, and federation cannot answer that query on another router's behalf. The client verifies the token and binds its signed hostname and port to the selected locator before enabling application traffic. The token also binds the router principal, clearance, coverage cells, opaque router segment, and replay_signing_key to the Directory signature. It never rides an AuthEnvelope and does not grant an endpoint publish or query authority. An endpoint rejects an unknown, expired, revoked, malformed, or wrong-locator router before using it. Group keys come only from the Directory (/api/group-key) and are never held by Relay + Storage.

There is no application auth queryable, AuthResponse, or waypoint/global/auth/** family. The endpoint-local router-token control query returns the raw ServerToken; it authenticates the selected listener and carries no client credential or application authorization.

Group-key rotation and backfill

The Directory owns rotation and issues group keys to clients only — the server is group-key-free and broadcasts nothing about keys over the mesh. An administrator may mint a new epoch from the Directory Key Management page. Revoking a device or a principal explicitly marked lost/captured automatically requests a coalesced rotation; routine offboarding does not. The revoked key holder cannot fetch the new epoch, while already captured ciphertext and previously held epochs remain exposed.

Clients fetch their bundle from GET /api/group-key at login/extend. The bundle is current + previous + a contiguous backfill ring of older epochs (GroupKeyManager; GroupKeyBundle.backfill, up to MAX_BACKFILL_EPOCHS = 64). A client that receives content sealed under an epoch it does not hold refreshes its bundle from the Directory (refresh-on-miss) and drops the triggering frame — the wire frame is never processed into real content. Clients may surface a non-destructive "encrypted · can't display" placeholder for the dropped chat/drawing content (built only from the cleartext key expression and verified sender, replaced if a later refresh + catch-up reopen succeeds) rather than show nothing; see #21 item 3. IdentityToken.key_epoch records the epoch current at token mint.

Offline backfill. The backfill ring is what lets a device that was dark across several rotations catch up: GET /api/group-key serves the contiguous epoch range from a bounded horizon to current, and merge_backfill installs it, failing loud on a gap (NonContiguousBackfill) so a hole can never be silently tolerated. open() resolves an epoch from current → previous → the ring, so any missed-but-in-window epoch decrypts.

Horizon-bounded retirement. The Directory retires an epoch only once its successor predates BACKFILL_HORIZON_MS = 7 d (pinned to the server's offline-resync horizon). A client offline longer than 7 days cannot backfill (the epochs are gone) and fails closed for secure content until it re-syncs / re-auths with the Directory. Heartbeats stay plaintext throughout, so presence survives a missing or stale key.

State catch-up

No separate StateSync protocol — a late-joining client issues a get against the durable prefix and the router replays stored samples:

get("waypoint/global/chat/<opaque-channel>/**") // authorized chat history
get("waypoint/<cell>/draw/**")                  // authorized drawings for a cell
get("waypoint/global/channel/<opaque-channel>") // exact channel definition
get("waypoint/global/invite/<opaque-invitee>/**") // exact invitee scope

The stored AuthEnvelope is re-served verbatim, so the original signer's identity verifies end-to-end on catch-up.

Each retained page ends with exactly one canonical RetainedReplayReceiptV2 as the final successful reply on the exact requested selector, including for an empty page. The receipt binds the canonical selector, exact query parameters, hash of the exact StorageAuthorizationV1 request bytes, reply count, framed digest of every (key, value) in observed order, fresh issue time, exact selected ServerToken, and terminal marker PETRA-RR-END-V2\0. Its Ed25519 signature verifies only against ServerToken.replay_signing_key.

The consumer releases the page only after Common verifies the receipt, current Directory signature and revocation state for the embedded ServerToken, exact selected-token hash, request bindings, ordered reply digest, freshness, signature, and terminal position. A Zenoh error reply, early or duplicate receipt, reply after the receipt, close without a receipt, or reply outside the exact selector fails the whole page. The receipt contains no service IdentityToken or reusable Directory bearer.

The requester queries every applicable provisioned copy without HLC-based Latest consolidation, then verifies and merges by signed semantic version, cut, and tombstone rules. See Delivery and validation.

After the configured horizon, ordinary retained envelopes are not rewritten or re-signed. Class-2 projections recover from a purpose-separated Web snapshot using the snapshot precedence rules; class-1 history remains visibly incomplete.

Opaque addressing

Every semantic key slot uses Common's framed, domain-separated opaque derivation. The Directory distributes the deployment salt only to eligible non-storage endpoints; Relay + Storage receives neither the salt nor a clear semantic identifier. Cleartext and earlier unframed addresses are rejected rather than queried, translated, or dual-read. The authoritative domain registry and derivation are in the opaque-addressing contract.

Records on the durable planes are signed with a AuthEnvelope.purpose that additionally relaxes token expiry — a durable record is a standing fact that outlives the ~5 min signing token and lives for the operation lifetime until an explicit revoke (grants) or delete (content):

  • DURABLE_GRANT — channel definitions (channel/**) and membership invites (invite/<pid>/**).
  • DURABLE_CONTENT — TCMs / drawings / markers (<cell>/draw/**) and, in the PETRA exact-version plan acknowledgements (global/record/plan-ack/**).

Durability is the signer's intent (the field is in the signing input), not a reader-side guess; each subscriber asserts the purpose for its plane. On these planes revocation is the currency check, so every consumer must run the revocation gate after verify and before applying. See Durable-record verification.

Federation

Router-to-router gossip. Peers list each other in transport.connect; every production locator is tls/ or quic/. Each dialer validates the remote server leaf against transport.tls.peer_ca_path and may present its own leaf when deployment-wide mTLS is enabled. The Directory-signed ServerToken, not a certificate CN, carries peer identity and coverage authority. Forwarded IdentityTokens are byte-identical across hops, so the Directory signature stays verifiable end-to-end — routers never re-sign identity claims.

The retained-storage ceiling rides the Directory-signed ServerToken.clearance and is enforced by the generic storage validator. The native Relay does not inspect non-retained LIVE envelopes; publishers preflight and each receiving subscriber applies the authoritative identity/capability/proof/classification gate. Neither decision comes from the TLS chain.

Channel ownership and transfer

Directory is the sole channel authority. Creation, membership changes, ownership transfer, and retirement are actor-signed connected mutations; Relay + Storage only validates and retains the resulting generic committed bytes. It has no owner map, operator override, channel decoder, or per-plane mutation path. See Connected authority mutations.

Replay defence

AuthEnvelope carries a 12-byte nonce + issued_at_ms checked against a sliding window (default 60 s). Outside the window → rejected as stale/future; a repeated (principal_id, nonce) inside the window → rejected. Independent of transport timestamps.

Where to read the code

Concern Location
Envelope + verify (canonical) common/src/auth_envelope.rs, common/proto/server/*
Key namespace, ACL, storage, session identity server/ (router)
Group-key rotation proto common/proto/server/keys.proto
Retained classification gate server/src/storage_validator.rs
Android pack/verify android/.../core/crypto/AuthEnvelope.kt, core/engine/TacNetUtils.kt
Web transport web/inertia/features/transport/