Security Model¶
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.
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.
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/** |
DURABLE_CONTENT |
record/plan/**, record/orbat/**, record/requirement/** |
DURABLE_GRANT |
record/report/** |
DURABLE_CONTENT |
| live planes (positions, chat, voice, presence, sensors) | LIVE |
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 record plane (waypoint/global/record/<kind>/<unit_id>[/<record_id>]) splits the
assertion per kind segment: plans, ORBAT units, and report requirements are
authority-bearing (DURABLE_GRANT — receivers additionally gate the verified author's
appointment against the ORBAT projection), while reports are append-only content
(DURABLE_CONTENT — receivers gate only on the verified author's membership of the
reporting unit, dedupe on the (unit_id, report_id) tuple, and never replace a stored
row; a correction is a new report). Catch-up mirrors the split: the grant kinds ride
requiredPurpose = DURABLE_GRANT GETs, reports a separate DURABLE_CONTENT GET. A report
signed DURABLE_GRANT (or a plan signed DURABLE_CONTENT) is dropped before apply — the
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.
This is safe because the relaxed verify is bound to the chat-history key plane, not a
fallback: it is reached only by the get(waypoint/global/chat/**) catch-up reply path (one
caller, one branch — there is no "strict failed → retry allow-expired" anywhere), so a
live position / voice / draw envelope cannot be delivered to it. Directory-sig, device-sig,
nonce replay, and the revocation gate all still apply; only skew + expiry are relaxed. It is
the documented exception to "LIVE = always strict" — a later option is to give chat
history its own DURABLE_CONTENT-style treatment rather than keep the key-scoped relaxation.
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 this is a wire-breaking change with no
dual-read window (like the pending E2E-content flag-day above). Operationally:
- Ship together.
common,server,gateway,web, andandroidmust deploy as one coordinated cut — a pre-purposeverifier and apurpose-signing peer do not interoperate (thedevice_signaturecoverspurpose, so the signing inputs differ). - Durable state re-emits on reconnect. Owners re-publish their durable records (channel defs, invites, drawings) on first reconnect after deploy, so catch-up re-primes under the new signing input.
- Pre-cutover stored records drop until re-emitted. A record stored before the cut has
no
purposein its signing input, so it failsdevice_signatureunder the new verifiers and is dropped from catch-up until its owner re-emits it. In particular, server-side draw catch-up now re-verifies each stored drawing (previously it was served raw), so pre-cutover TCMs vanish from late-joiner catch-up until redrawn — expected, not a regression.
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). The server force-closes active sessions on
a revoked-list update (server/src/audit.rs mtls_session_force_closed).
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. The server's gate_verified
(server/src/envelope.rs) takes the locked VerifiedEnvelope, obtains the snapshot from
its RevocationLedger (or the explicit RevocationSnapshot::assume_none() on the
purge-sweep bypass), and calls authorize. The older replace_revoked_sign_keys
enumeration-mutation pattern is removed.
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 tokens (IdentityToken.max_classification,
ServerToken.max_classification).
- Enforcement is server-side and per-message:
server/src/classification_gate.rsis a publish-side PEP that computes the Bell-LaPadula floormin(sender_token.max_classification, server_token.max_classification)and denies (with audit) any message whose signed-cleartextclassificationenvelope field exceeds the floor. The router reads this field without decoding the (now-encrypted) payload. - Clients render, they don't enforce: the Android classification banner derives a ceiling from verified, non-expired classification sources and is a UI indicator, not an 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, distinct from the server publish-side PEP:
-
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 max_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 mechanism (the signed-cleartext classification envelope field + the
router's Bell-LaPadula PEP above); the per-entity rules describe who sets the ceiling
and how a breach is handled, not a second enforcement path.
-
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: the browser seals/opens content like any client, and additionally runs a trusted server-side recorder that opens sealed positions to maintain a long-term unencrypted archive (track_hits) for UI replay — by design, inside the trusted tier, not the untrusted-router boundary. (Implemented.) -
Server (
waypointrouter) —ServerToken.max_classificationis a host/storage ceiling: the level the router is cleared to store and relay. It is store-not-transit-in-the-clear — under server-blind E2E the router holds opaque ciphertext and never reads content; its only classification actions are the publish-side PEP on the signed-cleartext envelope field and refusing to host content above its own ceiling. A downgraded server caps the floor for everyone via themin. (Implemented.) -
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 link, optional mTLS socket-admission, and a deny-by-default ACL restricting which key-expressions a connection may publish/subscribe, scoped by geohash coverage cell. Zenoh authenticates no content and no principal — the ACL's only view of "who" is a TLS cert Common Name (present only for the privileged web/COP bridge; absent for all field devices, which use a single wildcard ACL subject). All real authorization — identity, roles, clearance, ORBAT, revocation, classification — is app-layer: a Directory-signed Ed25519 IdentityToken + per-message Ed25519 signature + replay window + revocation gate + CMBAC decision, re-verified at every endpoint. Identity is token-only; X.509/mTLS is not the identity mechanism (used only for router-to-router federation and the web bridge). Zenoh ACL is additive defence-in-depth that scopes topics (fog-of-war + membership-graph privacy); it is never the authority on identity or content.
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¶
Everything above is operator / device / server identity — a Directory-signed Ed25519
token, verified through verifyAuthEnvelope, classification-gated, riding inside every
AuthEnvelope. External producers (NiFi, gateway, force-tracking feeds) that POST into
the web tier authenticate on a separate trust root that shares none of that machinery.
| Operator / device / server | 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. Locators use scheme prefixes (quic/, tls/, tcp/, ws/,
wss/); quic/ here is a Zenoh transport locator.
- Client → router: server-cert TLS only. The Android client validates the router
cert against a per-session CA trust bundle composed from the enabled servers'
caCertPemrows, falling back to the system trust store when none is pinned (ZenohEndpointRegistry.writeTrustBundle/composeTrustBundlePem). It 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: full mTLS, peer cert CN pinned to
node_id(server/src/active_peers.rs). This is the one path that presents client certs.
Transport TLS is always defence in depth; a verified AuthEnvelope is the
authoritative authenticity boundary on every hop.
Voice¶
- Native (Android) server-hop fast path: server→client voice datagrams are verified
with
require_device_signature = false(verify_with_policy) — the server re-wraps voice under its own identity, so the original device signature is absent on that hop. The wire envelope is server-signed;sender_principal_idis server-vouched, not end-to-end cryptographically bound. Do not treat the resolved callsign as e2e authenticated. - Web per-frame path: the browser verifies each
VoiceFrame's own envelope through the full five gates plus a key/voiceIdcross-check before the jitter buffer.
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: reuses the same
TrustBadge, resolved from the server-vouchedsender_principal_id→ its node. Shown only when that node isUNVERIFIED/REVOKED. Residual, accepted: becausesender_principal_idis server-vouched (not e2e-bound), a malicious server that forges it as a principal the client does hold a verified token for resolves toVERIFIED→ no badge; that forge-a-known-principal case is not surfaced per-speaker (the alternative — badging every PTT — is banner-blindness and was rejected). A revoked sender's frames are dropped byRevocationStageper frame before dispatch, so a revoked speaker cannot render as verified — it times out and the badge clears. - 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 stays writable outbound for receivers that still read it; gating acceptance on it is a regression. - 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 (AuthEnvelope.kt) — dispatches on the signed purpose to verifyAuthEnvelope (live) / verifyHistoricalEnvelope (durable); fixture-parity to Rust |
TS verify (browser) + Node tier, dispatches on purpose; fixture-parity |
waypoint_common::auth_envelope directly; the server/gateway wrap it in per-plane entrypoints (see below) |
| Transport to router | Zenoh, server-cert TLS, trust bundle, no client cert | WSS, server-cert TLS, no client cert | mTLS for peer/router federation (CN=node_id) |
| Revocation enforce | post-verify, principal + device-sign-key | server-tier choke at login | session force-close on revoked-list update |
| Classification | UI banner (ceiling) | UI banner | per-message PEP on signed-cleartext envelope field (classification_gate.rs) |
| 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 | server-hop fast path (server-vouched sender) | per-frame full verify | re-wraps voice under server identity |
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) |
| Per-plane durable verify split | server/src/envelope.rs (verify_inbound Live, verify_durable_inbound DurableGrant, verify_draw_inbound DurableContent, verify_stored; three separate replay caches), gateway/src/inbound.rs (verify_inbound(expected_purpose) + InboundKind::expected_purpose keyed off the key expression) |
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 classification PEP (reads envelope field) | server/src/classification_gate.rs |
| Server peer mTLS / revocation force-close | server/src/active_peers.rs, server/src/audit.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.
- Treat
TacNetMessage.sequenceas an outbound-only compat field; never 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).