Skip to content

PETRA common — enforcement-consolidation refactor plan

STATUS: CONTINGENT ON ARCHITECTURE ACCEPTANCE. This entire plan presupposes the pending decision "uniform enforcement + bounded retention": common becomes the single-source enforcement library that every consumer MUST call and MUST NOT reimplement. Nothing here should be actioned until that decision is ratified. This is a read-only inventory plus a phased proposal — no code was changed in producing it.

Repos surveyed (all under /Users/tombeckett/bedrock/): common (Rust + ts/), android (Kotlin + JNI src/), web (TS, server app/ + client inertia/), and the Rust callers node / gateway / server / directory.

Phase order is B → C → A, with D decided per-symbol inside C. Rationale: harden the API first so there is a correct thing to migrate onto (B); migrate the consumers and pick JNI-vs-generated per symbol as each is moved (C); slim the wire last, because proto removal is a coordinated regen best done once enforcement is already centralised (A). The contract-test regime (Section E) is the safety net that runs throughout.


Executive summary (≈15 lines)

  • Biggest dead-proto wins: SignedEnvelope (messages.proto:423) and EncryptedEnvelope (messages.proto:488) have zero live senders/receivers — they survive only as generated bindings — and are superseded by AuthEnvelope + sealed content. README:227 already marks SignedEnvelope "to be removed." Removing both is wire-safe (no traffic uses them) but is a coordinated binding regen + version bump across Rust/Kotlin/TS. The X.509-era fields (bound_cert_serial, csr_der, cert_chain_der, cert_serial) are already reserved — leave the tombstones in place, do not delete them.
  • Top duplicated-enforcement to move into common: (1) Android's Kotlin CMBAC decide, AuthEnvelope device-signature verify, revocation merge/signing-input, key-safety predicate, and capability table are hand-maintained ports kept in sync only by golden vectors — everything except outbox policy is a parallel reimplementation. (2) Web's client verify path and key-expression + isKeySafeSegment are duplicated not by choice but because common's TS is Node-only and its package exports map omits keys.js.
  • JNI vs generated split, one sentence: security-critical decisions (seal/open, signature verify, CMBAC decide, revocation, key-safety, capability resolution) stay/expand as JNI into the single Rust impl; pure data (proto messages, enums, DTOs, key-expression builders) is generated Kotlin via Square Wire, which is already the mechanism for every proto type.
  • Top 3 risks: (1) common's auth_envelope::verify currently skips the revocation gate (passes &[], auth_envelope.rs:404-422) — centralising on it as-is would bless revoked principals; the gate must be wired in first. (2) The flag-day proto/binding regen touches all repos simultaneously — mis-sequencing bricks a client. (3) Android has no runtime CMBAC access gate today (decide is ported but has zero callers), so "consolidation" is partly first implementation, not a like-for-like swap — behaviour change risk.

A — Proto slimming

Inventory of common/proto/messages.proto, common/proto/records.proto, and common/proto/server/*.proto, cross-checked against Rust src/, the Rust callers, common/ts/, and Android Wire output.

Proto symbol Status Wire-breaking? Evidence
SignedEnvelope DEAD / legacy Removal wire-safe (no live traffic); coordinated binding regen + version bump Defined messages.proto:423; README:227 "Legacy… to be removed"; only refs are generated bindings (common/src/proto/generated/waypoint.rs:540, Android SignedEnvelope.kt) — no hand-written sender/receiver in src/, node/gateway/server, web, or android
EncryptedEnvelope DEAD / legacy Removal wire-safe; same regen messages.proto:488 ("Contains a SignedEnvelope encrypted with group key"); superseded by AuthEnvelope.payload = sealed content (messages.proto:436-438). Only refs are generated bindings (waypoint.rs:604, Android EncryptedEnvelope.kt)
IdentityToken.bound_cert_serial (7) Abandoned — already reserved No (tombstone; deleting the reserved line WOULD be unsafe) directory.proto:36 reserved 7; // was bound_cert_serial
ServerToken.bound_cert_serial (5) Abandoned — already reserved No directory.proto:89
ProfileBundle.cert_chain_der (5) Abandoned — already reserved No directory.proto:116-117
DirectoryLoginResponse.cert_chain_der (6) Abandoned — already reserved No directory.proto:198-199
DeviceEnrollRequest.csr_der (2) Abandoned — already reserved No directory.proto:252-253
RevokeRequest.cert_serial (2) Abandoned — already reserved No directory.proto:271-272
DrawingShape / DrawingDelete KEEP (misread "DEPRECATED") n/a Deprecated only as a top-level draw-plane payload; remain the canonical nested TacNetMessage.drawing{,_delete} type — messages.proto:143-147, 327-330, 385-392
Chat.chat_type (3) / Voice.voice_type (3) Abandoned — already reserved No messages.proto:63-64, 91-92
README auth_envelope section DOC/IMPL DRIFT (not wire) No — docs only README:18, :121-157, :226 describe HMAC-SHA256 keyed by group key with a pack(&group_key,…) / GroupKeys API and a signing-input layout containing key_epoch; the real impl is Ed25519 device_signature over a signing-input carrying classification/owner/purpose and no key_epoch (auth_envelope.rs:1-7, 319-464). Proto comment directory.proto:17 repeats the stale "HMAC over the group key" claim

Actions (Phase A): delete the two dead messages and regenerate all bindings (Rust waypoint.rs, Android Wire, common/ts/generated) in one coordinated bump; rewrite the README auth_envelope section and fix the directory.proto:17 comment to say Ed25519; keep every reserved tombstone (removing them permits field-number reuse and is the only genuinely wire-dangerous edit here). No records.proto changes identified.


B — API clarity (where the current API lets a consumer skip a gate)

Public Rust surface is the lib.rs re-export block (lib.rs:29-87); TS surface is common/ts/src/{cmbac,auth_envelope,authz,identity_token,keys}.ts.

Misuse vectors found:

  1. verify returns an authenticated identity that was never revocation-checked. auth_envelope::verify / verify_with_policy (auth_envelope.rs:319, 369) call verify_identity_token(…, &[]) (lines 413, 419) — the revoked-principal list is hard-coded empty (comment 404-410: "Revocation feed not yet wired into the auth-envelope path"). verify_identity_token does support revocation (identity.rs:237, param revoked_principal_ids), but the envelope entry point bypasses it, and consumers copy the pattern (gateway.rs:255 also passes &[]). A consumer that trusts verify() alone accepts a revoked principal.
  2. Raw open/decrypt yield plaintext with no classification or membership gate. GroupKeyManager::open (group_key.rs:254), decrypt (:170), plus seal/encrypt are public. node/src/zenoh_session.rs:159 does group_keys.open(&payload) → plaintext with no decide() on that path. Nothing in the type system forces cmbac::decide (cmbac.rs:346) or a membership check to run before content is opened.
  3. The gate primitives are free functions returning bare values. decide returns a Decision (correctly fail-closed, cmbac.rs:346-369) and has_capability returns a bool (authz.rs:188) — both are easy to compute and then ignore. The VerifiedEnvelope.purpose field (auth_envelope.rs:116-141) is surfaced for the caller to assert per-plane but common "deliberately does NOT do that matching" — correct as a boundary, but it means every consumer must remember to assert it.

Recommended enforcement-first shape (Phase B):

  • Wire the revocation feed into verify so the envelope path cannot return an un-revocation-checked identity; keep an explicit verify_with_policy escape hatch but make revocation non-optional there too.
  • Introduce a single gate that returns content only after every decision passes, e.g. conceptually open_content(envelope, dir_keys, group_keys, clearance, registry, revoked, plane) -> Result<Content, Denied> running verify → revocation → purpose/plane assertion → decideopen atomically. Demote raw open/decrypt/bare verify/decide into a primitives-namespaced (or pub(crate)) module so a consumer cannot reach a decrypt without going through the gate.
  • Mirror the same gate in the TS surface and make it browser-safe (WebCrypto, no node:crypto/Buffer) and export it — see Section C, the web blocker.

C — Move-list: decision logic that should single-source into common

Line applied: decision logic → single-source into common; when/where to call it + UI + persistence + lifecycle → stays in the consumer. D (JNI vs generated) is decided per Android symbol in the right-hand column.

Android (Kotlin) — duplication by porting; kept in sync only by vectors

Area Lives now (file:line) Kind Common has canonical Rust? Verdict + D decision
CMBAC decide core/classification/Cmbac.kt:52 (full ACDF port; zero prod callers — only CmbacVectorTest.kt:88) decision logic (dead at runtime) Yes — cmbac.rs:346 MOVE-TO-COMMON via new JNI. Note: no runtime CMBAC gate exists today (clearance stored as raw JSON pending "later task") — this is first implementation, not a swap
Envelope device-sig verify + signing-input + replay/skew/purpose core/crypto/AuthEnvelope.kt:61,263 (BouncyCastle Ed25519; only the token Ed25519 crosses JNI) decision logic Yes — auth_envelope.rs MOVE-TO-COMMON via new JNI (expand beyond the token check)
Revocation signing-input + monotonic merge core/directory/RevocationCache.kt:102,138 (Ed25519 primitive already JNI) decision logic Yes — revocation.rs, identity.rs:176 MOVE merge/signing-input into JNI
Key-safety predicate core/records/RecordValidation.kt:31 isKeySafeSegment decision logic Yes — keys::is_key_safe_segment MOVE-TO-COMMON as JNI predicate
Zenoh key builders core/transport/zenoh/ZenohKey.kt (pos/cmd/ack/record/channel keys) pure data (string templates) Yes — keys.rs GENERATE-KOTLIN (or JNI); builders are data, the predicate above is the security part
Capability table + resolver core/authz/Authorization.kt:101,179 (defaultCapabilities/effectiveCapabilities, vector-pinned) decision logic Yes — authz.rs:173,188 MOVE-TO-COMMON via JNI
ORBAT commander / appointment gate core/records/OrbatProjection.kt:99 + RecordGossipSubscriber.kt:428 (awaitAuthority) decision logic Partial (record-access contract) MOVE the decision (commander-ancestry / plan-access predicate); keep ingest wiring in consumer
Outbox policy core/outbox/OutboxPolicy.kt via WayPointCodec.outbox* JNI → outbox_policy.rs decision logic Yes ALREADY single-sourced — the reference pattern. LEAVE
Session wipe on capture KotlinServerEngine.kt:616 wipeAllUserData (from MapScreen.kt:1129) decision logic (edge-local) No Rust counterpart LEAVE-IN-CONSUMER (assume-capture retention is an edge concern)

Web (TS) — duplication forced by common's packaging, not by choice

The web dependency is @waypoint/common-proto-types (github:Bedrock-Defence/common, web/package.json:94). The server bundle (app/) already delegates correctly; the client bundle (inertia/) cannot import common's enforcement mirrors because (a) auth_envelope.ts/identity_token.ts are Node-only (node:crypto, Buffer) and (b) the package exports map omits ./keys.js and ed25519_setup. Fixing packaging (Phase B) collapses two reimplementations for free.

Area Lives now (file:line) Kind Duplicates common TS? Verdict
CMBAC decide server app/domains/report/report_wire_helpers.ts:113 → common decide; client does none server delegates No Already single-sourced. LEAVE
Envelope/identity verify server app/domains/realtime/zenoh_recorder.ts:241 (common); client inertia/features/transport/wire/auth_envelope.ts:358 + identity_token.ts:27-154 (hand-rolled @noble/ed25519) client = dup logic Yes — mirrors common auth_envelope.ts+identity_token.ts MOVE-TO-COMMON via packaging fix: ship browser-safe common TS + export ed25519_setup, then client imports it
Revocation server zenoh_recorder.ts:284 / revoked_principals_cache.ts:458; client channel_gossip_subscriber.ts:183,211 wiring/policy; verify → revocation → apply ordering is correct No common TS module LEAVE (Directory-sourced revoked-set; ordering already correct)
Key exprs + key-safety client inertia/.../infra/key_expressions.ts:21-201 + wire/identity_token.ts:84-108 (isKeySafeSegment); server app/shared/*_keys.ts dup logic Yes — mirrors keys.ts, which is unexported MOVE-TO-COMMON via packaging fix: add ./keys.js to exports, import predicate + builders
Capability / channel gates server app/domains/auth/capabilities_service.ts:1-23 (common authz); client capabilities.ts caps.includes(); channel/moderation VoicePanel.tsx:54 / ChannelManage.tsx:264-268 role booleans (isSystemAdmin\|\|isSystemOperator) server delegates; client = UI role check Partial Capability calc already single-sourced. Consider expressing channel/moderation as common capability tokens (manage_channels/manage_annotations exist in Rust/Kotlin authz, unwired in web client)
Wipe / outbox / retention server quarantine caps zenoh_recorder.ts:73-88; no client outbox by design web-bespoke No — outbox_policy is Rust-only LEAVE (no TS mirror exists; port only if quarantine should adopt outbox semantics)

Rust callers (node / gateway / server)

These consume common directly and already single-source decide (gateway.rs:1309, gateway/inbound.rs:200, server/classification_gate.rs:180) and verify (server/envelope.rs:206, gateway/inbound.rs:148). The one carry-over of the Section B revocation gap is gateway.rs:255 passing &[] — closed by the Phase B fix.


D — JNI vs generated Kotlin (per-symbol)

Kotlin generation path already exists: Square Wire (app/build.gradle.kts:10 plugin, :193 wire {}) fetches common's .proto by git sparse-checkout (:184-189) and generates every proto DTO into app/build/generated/source/wire/. So the "generate pure data" recommendation is already the mechanism for proto types — no JNI marshalling of messages is needed. The JNI boundary (src/lib.rs, crate waypoint_android, contract version 11) is deliberately thin and today marshals raw proto bytes across, with one exception (a hand-built JSON string for verifiedIdentity).

Rule applied: security-critical decision → JNI to the single Rust impl; pure data → generated Kotlin.

Symbol Current mechanism Recommended Why
Ed25519 verify JNI (lib.rs:89MessageSigner::verify) JNI (keep) crypto primitive, single impl
verifyIdentityToken / …AllowingExpired JNI (lib.rs:232,272) JNI (keep + expand) must run the revocation gate inside (close the &[] gap); return proto VerifiedIdentity bytes instead of hand-built JSON (verified_identity_json, lib.rs:184)
AuthEnvelope full verify (device-sig + replay + skew + purpose) Kotlin (AuthEnvelope.kt:61,263) JNI (move) security-critical; a Kotlin reimpl breaks uniformity and is less safe
CMBAC decide Kotlin, dead (Cmbac.kt:52) JNI (move) the label-vs-clearance access decision must be the one Rust impl
Revocation list verify + merge primitive JNI, policy Kotlin (RevocationCache.kt:102,138) JNI (move policy) revocation is security-critical
is_key_safe_segment Kotlin (RecordValidation.kt:31) JNI hostile-input security predicate
effective_capabilities / has_capability Kotlin table (Authorization.kt:101,179) JNI (move) authorization decision
Outbox policy (isQueueEligible, caps, TTL) JNI (lib.rs:334-359) JNI (keep) already exemplary
Proto messages (AuthEnvelope, PositionUpdate, ConfidentialityLabel, Clearance, IdentityToken, records.*) Generated (Wire) Generated (keep) pure data, type-safe, no marshalling
Enums (Role, Capability, NodeCapability, EnvelopePurpose, DrawingKind) Generated (Wire) Generated (keep) pure data
Zenoh key builders (pos/cmd/ack/record) Kotlin (ZenohKey.kt) Generated or JNI string templates are data; the safety predicate (above) is the security part
OutboxMessageKind enum Kotlin mirror (OutboxMessageKind.kt:16) Generated ordinal-for-ordinal data; policy already rides JNI

Flag while here: the Wire output contains ClassificationLabel.kt / ClassificationLevel.kt, but the proto types are ConfidentialityLabel / Clearance — verify this is not a stale generated artifact / naming drift during C.


E — Contract-test regime (the safety net)

Already partly in place: golden vectors in common/testdata/{cmbac_vectors,authorization_vectors,envelope_vectors}.json; Rust conformance tests common/tests/{cmbac_vectors,envelope_vectors,sealed_content_vectors,zenoh_roundtrip}.rs; Android consumes them (CmbacVectorTest.kt, AuthEnvelopeV5VectorTest.kt, vectors copied into test resources); web parity specs (tests/client/transport/auth_envelope_vectors.spec.ts, cmbac_corpus.spec.ts).

Gaps to close so the net actually catches a skipped gate:

  1. Negative enforcement vectors — golden cases that MUST deny, run by every consumer's CI and failing the build if the consumer permits: revoked principal → deny; label rank > clearance → deny; durable envelope on a live plane → reject; open attempted before decide → unreachable by construction (compile-time in the gated API).
  2. Key-safety + key-expression vectors — hostile unit_id/principal_id ("UNIT-1/plan", "*", "a#b") must fail is_key_safe_segment identically in Rust, Android, and web; plus byte-identical key-builder vectors.
  3. Revocation-path vectors for the envelope entry point once B wires the gate in.
  4. Single ownership: vectors live in common/testdata/; every consumer CI (android unit tests, web vitest, Rust callers) pulls the same files at the pinned common rev and runs both the positive and negative suites. A missing consumer = red build.

F — Flag-day cross-repo migration

  • Phase B ships additively (new gated API + browser-safe/exported TS + revocation wired into verify) without removing the old surface — no flag day; consumers can start migrating.
  • Phase C migrates consumers repo-by-repo behind the pinned common rev; Android JNI additions bump the contract version (currently 11) so a stale .so fails fast.
  • Phase A is the one true flag day: deleting SignedEnvelope/EncryptedEnvelope and regenerating bindings must land in common and be re-pinned in android/web (and recompiled in the Rust callers) in a single coordinated bump. Sequence: (1) confirm zero live usage remains post-C, (2) remove + regen in common with a version bump, (3) re-pin all consumers in lockstep, (4) keep the reserved tombstones. The contract-test suite from Section E gates each step.

End of plan. Contingent on acceptance of "uniform enforcement + bounded retention."