PETRA common — enforcement-consolidation refactor plan¶
STATUS: CONTINGENT ON ARCHITECTURE ACCEPTANCE. This entire plan presupposes the pending decision "uniform enforcement + bounded retention":
commonbecomes 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) andEncryptedEnvelope(messages.proto:488) have zero live senders/receivers — they survive only as generated bindings — and are superseded byAuthEnvelope+ sealed content. README:227 already marksSignedEnvelope"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 alreadyreserved— 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 +isKeySafeSegmentare duplicated not by choice but because common's TS is Node-only and its packageexportsmap omitskeys.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::verifycurrently 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 (decideis 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:
verifyreturns an authenticated identity that was never revocation-checked.auth_envelope::verify/verify_with_policy(auth_envelope.rs:319, 369) callverify_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_tokendoes support revocation (identity.rs:237, paramrevoked_principal_ids), but the envelope entry point bypasses it, and consumers copy the pattern (gateway.rs:255 also passes&[]). A consumer that trustsverify()alone accepts a revoked principal.- Raw
open/decryptyield plaintext with no classification or membership gate.GroupKeyManager::open(group_key.rs:254),decrypt(:170), plusseal/encryptare public.node/src/zenoh_session.rs:159doesgroup_keys.open(&payload)→ plaintext with nodecide()on that path. Nothing in the type system forcescmbac::decide(cmbac.rs:346) or a membership check to run before content is opened. - The gate primitives are free functions returning bare values.
decidereturns aDecision(correctly fail-closed, cmbac.rs:346-369) andhas_capabilityreturns abool(authz.rs:188) — both are easy to compute and then ignore. TheVerifiedEnvelope.purposefield (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
verifyso the envelope path cannot return an un-revocation-checked identity; keep an explicitverify_with_policyescape 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 →decide→openatomically. Demote rawopen/decrypt/bareverify/decideinto aprimitives-namespaced (orpub(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:89 → MessageSigner::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 areConfidentialityLabel/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:
- 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;openattempted beforedecide→ unreachable by construction (compile-time in the gated API). - Key-safety + key-expression vectors — hostile
unit_id/principal_id("UNIT-1/plan","*","a#b") must failis_key_safe_segmentidentically in Rust, Android, and web; plus byte-identical key-builder vectors. - Revocation-path vectors for the envelope entry point once B wires the gate in.
- Single ownership: vectors live in
common/testdata/; every consumer CI (android unit tests, web vitest, Rust callers) pulls the same files at the pinnedcommonrev 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
commonrev; Android JNI additions bump the contract version (currently 11) so a stale.sofails fast. - Phase A is the one true flag day: deleting
SignedEnvelope/EncryptedEnvelopeand regenerating bindings must land incommonand 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 incommonwith a version bump, (3) re-pin all consumers in lockstep, (4) keep thereservedtombstones. The contract-test suite from Section E gates each step.
End of plan. Contingent on acceptance of "uniform enforcement + bounded retention."