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
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(common/src/auth_envelope.rssigning_input). - 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). 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 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.
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 implemented in
common, the server, the Android client, the gateway, the web client (web seals/opens
the pub/sub content — chat, drawings, positions, voice — and stamps the signed-cleartext
classification; web channel-def confidentiality is a tracked follow-up because web's
chat/voice CRUD is server-queryable-mediated rather than P2P-gossiped), and the node client
(node seals its positions + command-acks and opens inbound device-commands, stamping its
generation-classification label). The coordinated flag-day wire-breaking cutover that
activates it fleet-wide remains outstanding.
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. All are tracked alongside the edge-capture follow-ups: rotation-on-loss, the storage-capability gate, and the StrongBox/heap-custody tiers in #40; the degraded-key trust surfacing in #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 historical envelopes replayed
byte-identical from a server-side state-sync store.
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).
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.
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.
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.
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.
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.
Per-platform notes¶
| Concern | Android (native) | Web (browser + AdonisJS tier) | Server (waypoint) |
|---|---|---|---|
| Verifier | Kotlin verifyAuthEnvelope (AuthEnvelope.kt), fixture-parity to Rust |
TS re-impl (browser) + Node verify (server tier), fixture-parity |
waypoint_common::auth_envelope directly |
| 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 pub/sub content, fails closed; channel defs deferred |
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, verify_with_policy, signing_input) |
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 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/... |
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).