Content-key architecture — replacing the single deployment group key¶
Status: ACCEPTED and now IMPLEMENTED — §4 (single key + uniform enforcement + bounded retention), decided 2026-07-19, delivered across the fleet by 2026-07-20. Operational (operator-triggered) rotation + a contiguous offline-backfill ring closes FM1; FM2 is accepted-by-policy; the sealing cutover is live fleet-wide. Per-scope keying (§5) is not being pursued; it is retained here as the alternative to revisit only if classification/plan-scoping must become a cryptographic boundary against a valid in-date member (the software-enforcement ceiling described in §4). Sections 1–2 below describe the state that motivated the decision; the "current state" facts are annotated inline where the shipped code has since moved past them.
Scope: the content-encryption / at-rest-and-in-transit protection layer (the
SealedContent blob inside AuthEnvelope.payload, plus what each device is allowed to
store and view). Envelope authenticity (Ed25519 device_signature,
Directory-signed IdentityToken, replay/skew gates) is unchanged and out of scope.
Repos: common (contract + enforcement decision logic), directory (issuance +
rotation + role radius), server (content-blind buffering), android / web / node
/ gateway (consumers that call the shared gates).
TL;DR — recommendation. "One key" couples two independent problems: a temporal / capture-continuation problem (a captured device keeps participating and holds a live snapshot) and a confidentiality-scoping problem (every valid member can decrypt every classification / channel / plan). The recommended architecture is "uniform enforcement + bounded retention" (single key, §4): make
commonthe single source of the enforcement decisions every device runs, bound what a device holds in time and space (short windows + credential expiry + fog-of-war ±X km), treat forward secrecy as explicitly out of scope for perishable data while keeping the classification gate for the non-perishable tail, and make rotation an operational command action (FM1). Per-scope cryptographic keying (§5) is the documented alternative for the branch where classification/plan-scoping must be a cryptographic boundary rather than uniform software enforcement. Both branches keep FM1 and FM2 as hard constraints.
1. Current state (read against the code)¶
1.1 The one key and its manager¶
Content — chat, drawings, positions, voice, channel definitions — is AES-256-GCM
sealed under a single deployment-wide group key before it enters the envelope.
Manager: GroupKeyManager
(common/src/crypto/group_key.rs):
- Holds
current+ optionalpreviousplus a bounded contiguousbackfillring (MAX_BACKFILL_EPOCHS= 64) — the ring of older epochs called for by §4.4 has since shipped; the "exactly two epochs, no ring" state described here is the pre-fix baseline. seal()encrypts undercurrent; self-describing blobversion(1)=0x01 ‖ epoch(u32 BE) ‖ nonce(12) ‖ GCM(ct‖tag).open()parses the epoch and selectscurrent→previous→ thebackfillring, elseGroupKeyError::UnknownEpoch.update_keys()overwritescurrent, sets at most oneprevious;merge_backfill()installs the served range and fails loud on a gap (NonContiguousBackfill).
Mirrored byte-for-byte in web
sealed_content.ts and
Android core/crypto/SealedContent.kt.
1.2 Issuance / distribution (Directory issues; the server is denied it)¶
GroupKeyService(directory/app/domains/shared/group_key_service.ts) now serves all live rows within the backfill horizon (getBundlereturns current + previous + the contiguous ring) and, onrotate(), insertsMAX+1and retires an epoch only once its successor predatesBACKFILL_HORIZON_MS= 7 d — the §4.4 contiguous-range fix, shipped. (The pre-fix behaviour was cur+prev-only, retiring everything older thanprevious.)- Devices fetch from
GET /api/group-key(routedirectory/start/routes.ts:165-172;api_controller.ts:212-221) — not in the loginProfileBundle(directory.proto:120-121); the token carries only the epoch number (key_epoch,directory.proto:38). - The server is denied the key (the content-blind enforcement point):
api_service.ts:650-662returns 403 forplatformType === 'server'; the Rust server never fetches or holds it (server/src/main.rs:246-249) and routes/stores the sealedpayloadopaque.
1.3 Custody, warm-start, rotation¶
| Entity | Custody | Cite |
|---|---|---|
| Android | EncryptedSharedPreferences (TEE/StrongBox, software fallback), cur+prev |
ProfileStore.kt:270-294; IdentityCoordinator.kt:93-144 |
| Node | in-memory only, single Arc<RwLock<Option<GroupKeyManager>>> |
node/src/group_key.rs:32-51; config.rs:66 (3600 s) |
| Web | in-heap manager | sealed_content.ts |
| Server | never holds it | server/src/main.rs:246-249 |
Rotation is an admin UI action (POST /admin/keys/group/rotate), republished as an
opaque notice the server relays but does not interpret; clients re-pull
GET /api/group-key. Retirement is now horizon-bounded: content stays readable while
its epoch is within the 7-day backfill horizon and a reconnecting device backfills the
contiguous range; content whose epoch has aged past the horizon is permanently unreadable
by anyone, and a device dark longer than the horizon does a full re-sync.
Load-bearing property: the group key gives every member read access to every
channel, classification, and plan — no cryptographic need-to-know. This is a
documented accepted residual (docs/security/model.md:74-86) and the tightening
direction ("per-channel keys, rotation-on-loss, forward secrecy") is named there
(model.md:77-79).
Enforcement decision logic already lives in common but consumers do not all call
it: the CMBAC engine is common/src/cmbac.rs (decide() :346, Decision :287),
mirrored to Kotlin core/classification/Cmbac.kt and TS. Shared retention constants
live in common/src/outbox_policy.rs and are pulled into Kotlin via JNI. The gap (§4.1)
is that common frames itself as "only authenticates and reports"
(auth_envelope.rs:122-123) and leaves the gate call to each consumer — and on
Android's content path that call is simply absent (§4.1). So enforcement lives partly
in prose, not shared code.
2. The two known production failure modes (confirmed in code)¶
FM1 — offline rotation (#118)¶
A device offline across ≥1 rotation reconnects holding a stale epoch and cannot
decrypt content sealed under any epoch it missed, because retention is cur+prev only
end-to-end: the manager holds two epochs (GroupKeyManager.kt:20-21;
group_key.rs:87-90) and the Directory endpoint itself only ever returns cur+prev
(group_key_service.ts:76-77 — .orderBy('epoch','desc').limit(2); keys.proto:8-21)
— so even a post-reconnect fetch cannot recover an epoch older than previous. Two
rotations while offline → UnknownEpoch → frame dropped, not queued
(OpenStage.kt; node mirror group_key.rs). This is now fixed: the contiguous
backfill ring (§4.4 — GroupKeyBundle.backfill, MAX_BACKFILL_EPOCHS = 64,
merge_backfill) ships in common / directory / android / web, so a device that missed
epochs within the 7-day horizon backfills them all on reconnect. FM1 now survives only
past the horizon, where a full re-sync is required by design (node deliberately does not
carry the ring — it is a live-plane consumer).
FM2 — cross-device own-history (#119)¶
The same user on a new device / after re-enrolment cannot read their own prior
chat: catch-up returns the original ciphertext under its original epoch
(server/src/chat_handler.rs:8-39 stores every verified chat message as sealed envelope
bytes on a 24 h TTL and re-serves the original ciphertext; local Room ChatEntity is
wiped on reinstall), but the new device fetches only cur+prev, and the Directory has
retired the sealing epoch. principal_id is stable
(directory.proto) so authorisation is intact — the key is gone. Accepted by
policy (§4.3): with one rarely-rotated key a re-authed principal reads its own history
under the same still-live key, so FM2 dissolves for the common case; the sensitivity tail
stays behind the classification gate.
FM1 and FM2 are the same missing capability twice: no principal can obtain a key
epoch older than previous. FM1 is its live face; FM2 its historical face. Any chosen
architecture must fix both.
3. The decision criterion (decide this first)¶
┌─────────────────────────────────────────────┐
│ Must classification / plan-scoping be a │
│ CRYPTOGRAPHIC boundary against a currently- │
│ VALID in-date member? │
└───────────────┬─────────────────┬────────────┘
NO │ │ YES
(software + audit ok) │ │ (member must be
▼ ▼ cryptographically unable)
§4 RECOMMENDED: uniform §5 ALTERNATIVE: per-scope
enforcement + bounded key hierarchy + per-object
retention (single key) CEK wrapping (Candidate A)
- The only cryptographic line in §4 is "a valid in-date key is needed to decrypt at all" — and every in-date member has it. Classification, channel, and plan scoping are enforced by shared software gates + bounded retention + audit, which hold against a lost device with an intact OS (MDM / disk-crypto / keystore) but not against a rooted/patched adversary. State this honestly to whoever owns the risk.
- If that residual is unacceptable for classification/plan-scoping — i.e. a valid SECRET-cleared member must be cryptographically unable to read TOP SECRET, not just gated in software — take §5 for those scopes.
- Recommendation for PETRA: §4 as the baseline for the whole fleet, with §5 held in reserve for the specific compartments (clearance ceiling, REL-TO, higher-echelon future-op plans) if and when a requirement demands a cryptographic boundary. Do not build §5 fleet-wide by default — it is materially more machinery than the threat model currently justifies.
4. RECOMMENDED — uniform enforcement + bounded retention (single key)¶
Keep one deployment content key. Make the guarantee "a device can't store or view what it shouldn't" come from shared enforcement code + bounded retention, not from per-consumer reimplementation or from cryptographic scoping. Four components.
4.1 common as the single enforcement point¶
Today common owns the enforcement decision logic — CMBAC decide()
(cmbac.rs:346), outbox eligibility/TTL (outbox_policy.rs), identity verification —
but deliberately does not invoke the gates: "common … only authenticates and
reports" (auth_envelope.rs:122-123), leaving each consumer to call (or forget) the
gate. That is exactly why #211 exists: on Android the inbound decrypt path's only
gate is holding the key (OpenStage.kt:24-27 → SealedContent.open → keyForEpoch
SealedContent.kt:45), and Cmbac.decide (fully implemented, Cmbac.kt:52-90) has
no caller anywhere in app/src/main — its sole caller is CmbacVectorTest. The
replay/catch-up path likewise decrypts then dispatches to display behind only a
revocation check (MessageProcessor.kt:283-327). Enforcement lived in prose.
This design partially reverses common's "only reports" framing, with a precise
nuance:
commonowns the enforcement DECISION as the single source of truth — the CMBAC read decision, the wipe/retention decision (what may persist, for how long, within what radius), the classification-vs-clearance decision. One implementation, ported by vector, never re-derived.- Consumers MUST call it at their gates and MUST NOT skip or reimplement it. A
consumer decrypts/stores/renders an object only after
common's decision says so. No local role/clearance computation (Android's CLAUDE.md already forbids this: "roles live inside the token; read them, never derive", "Do NOT reimplement crypto / verify / classification … One verification path only"). - Policy runs in-process, per device —
commonis a library, not a service. No network round-trip to enforce; the decision logic is compiled/linked into every consumer (Rust directly; Kotlin/TS via the shared ports + JNI, asoutbox_policy/cmbacalready are). This preserves offline operation.
Concretely: promote the content-view path to call Cmbac.decide(label, clearance)
before rendering a decrypted object — not merely at channel-def acceptance
(ChannelGossipSubscriber) — and route the wipe/retention triggers through a shared
common policy module (§4.2). This closes #211 (classification enforced in common,
called at every gate) and is the substrate for #210 (uniform wipe).
4.2 Bounded retention — time AND space¶
Blast radius on capture = {local AO} × {recent window}. Bound both axes:
Time.
- Short storage windows for perishable content (positions POSITION_TTL_MS; chat store
TTL 24 h server-side chat_handler.rs/store_ttl.rs; outbox 7 d
outbox_policy.rs:63).
- Credential expiry: short IdentityToken batches (5×7 d) + forced YubiKey / WebAuthn
re-auth every X days. An expired principal cannot send (sends stop on token
expiry) and, once key custody is denied on re-auth failure, cannot pull fresh keys.
- Android already wipes all local user data on session end — including non-explicit
ends (token expiry, process kill, user reassignment), not just logout —
KotlinServerEngine.wipeAllUserData (KotlinServerEngine.kt:616; android/CLAUDE.md
"hold ONLY the current user's active-session data"). #210 is to make this a shared
contract, not an Android-only behaviour (node is already in-memory-only so it dies
with the process; web is privileged-core and deliberately keeps the full picture).
Space — fog-of-war.
- A device stores only content within ±X km of its position. Already
implemented on Android and role-scoped: MessageProcessor computes a
FogOfWarDecision from distance vs radiusKm (MessageProcessor.kt:836-847), and a
DB prune runs on GPS-5 geohash-cell change, dropping position and drawing rows
whose anchor sits outside the radius (TacNetEngine.kt:913-938,
applyFogOfWarPruneIfCellChanged). The radius is Directory-stamped per role on the
token — visibility_radius_km FIELD=10 / LEAD=15 / COMMAND=25 / STRATEGIC=50
(directory.proto:43-46). Edge troops hold a small AO; HQ/Web holds the full picture.
- Active eviction is essential, not just an ingest filter: a mobile device must
prune what it already stored as it moves out of ±X km (the geohash-cell-change
prune above), or capture yields the whole route's history. This limits storage, not
relay — a content-blind router still forwards beyond the radius; fog bounds what an
edge endpoint keeps.
- #210/#55 substrate: promote the fog decision + prune into a shared common policy
the same way CMBAC is shared, so node/web/gateway apply an identical, role-scoped,
contract-tested rule instead of Android owning it alone.
4.3 Forward secrecy explicitly OUT OF SCOPE (scoped)¶
Accept old-data recoverability. Keep one key, keep old data readable. Rationale: on capture, assume the enemy already knows the old, local picture — perishable tactical data (positions, past chat) has little residual value once it is stale and in-AO. This is a deliberate posture, not an oversight.
This dissolves FM2 for the common case: with one rarely-rotated key (§4.4), a new device re-authenticates and reads its own history under the same still-live key — cross-device own-history "just works" with no re-wrap escrow, no key-history endpoint.
But scope it — the sensitivity tail still needs the classification gate:
- Perishable / low-sensitivity (positions, old chat): recoverability accepted; fog + time bound the exposure. ✅
- Non-perishable / sensitive (future-op plans, ORBAT, identities): still gated by classification (§4.1) and retention. An old TOP SECRET future-op plan must not sit on a SECRET edge device — fog + time do not cover this, because a future plan is neither stale nor necessarily in-AO. The §4.1 CMBAC gate (called at store and at view) is what keeps the sensitivity tail off under-cleared devices; §4.2 keeps the bulk small.
So: fog + time handle the bulk; the classification gate handles the sensitivity tail; forward secrecy is given up only for the perishable bulk.
4.4 Operational key rotation (the FM1 fix)¶
Rotation becomes a command action on connectivity, not an automatic timer:
- The commander rotates when the force is known to be online ("troops back online,
rotating now") — the Directory admin action already exists (
keys_controller.ts:25). - The previous key is accepted for a bounded grace window (~1 day) so stragglers
catch up; the client already holds current+previous (
group_key.rs:87-90), so this needs no new key-holding — it needs the rotation cadence to be operator-driven and the grace window to be explicit. - A device dark longer than the window must re-sync / re-auth (fresh token + fresh key) — an operator-visible managed fallback, not silent breakage.
- Minimum change to close FM1 fully even in this single-key world: extend
GET /api/group-keyto serve the contiguous epoch range within the grace window (not just cur+prev — todaygroup_key_service.ts:76-77hard-limits to two rows), so a device that missed the middle of two fast rotations can still backfill. Small, additive, and reusable by §5. Without it, "rotate on connectivity" + cur+prev is adequate for the intended operational pattern but still breaks a device that misses two rotations inside one window.
FM1 is thus a managed procedure (rotate when connected, bounded grace, re-auth past the window), not an automatic timer that silently deafens offline devices.
4.5 Caveats the reader must not miss¶
- Software enforcement, stated honestly. The guarantee holds against a lost
device with an intact OS (relies on MDM, disk-crypto, keystore, and the app build
being intact). It does not hold against a rooted/patched adversary who can
run modified code that ignores the
commongates — that adversary holds a valid in-date key and can decrypt anything the key covers. The single cryptographic line is "a valid in-date key is needed to decrypt at all." Everything else is defence-in-depth software policy. If the threat model includes a sophisticated patch-the-binary adversary for classification separation, you are in §5 territory. - "Uniform" is meaningless without a contract-test regime — make it a first-class
deliverable. The reason #211 exists is that enforcement lived in prose while
Cmbac.decidesat test-only with no production caller. So: - Golden enforcement vectors in
common— canonical(label, clearance) → Decision,(position, radius) → keep/evict,(sessionEvent) → wipecases, in the same style as the existingsealed_content_vectors.json/envelope_vectors.json. - Every consumer's CI runs them (Rust, Kotlin, TS/web, node, gateway).
- Negative tests fail if a consumer skips a gate — e.g. a decrypted over-classified object is not rendered, an out-of-radius row is evicted, a session-end does wipe. A consumer that decrypts-then-forgets-to-check must go red in CI. This is what turns "uniform" from an aspiration into an invariant.
- Fog-of-war is storage-scoped, role-scoped, and needs active eviction. It limits what an edge endpoint retains, not what routers relay; it is driven by the Directory-stamped per-role radius; and it must prune already-stored rows on movement, not merely filter ingest.
4.6 Findings closed by §4 (all shipped 2026-07)¶
| Finding | Closed by | How |
|---|---|---|
| #211 classification not endpoint-enforceable | §4.1 | CMBAC decide owned by common, called at every store/view gate (today it has no production caller — OpenStage.kt:24-27 gates only on the key), contract-tested |
| #210 uniform wipe | §4.1 + §4.2-time | Wipe/retention promoted to a shared common policy every consumer calls; Android's wipeAllUserData (KotlinServerEngine.kt:616) becomes the contract, not a one-off |
| #55 capture blast radius (node) | §4.2 (space×time) | Capture yields only {±X km AO} × {recent window}, role-scoped — not the whole deployment picture; bounded further by credential expiry + rotation |
| #118 / FM1 offline rotation | §4.4 | Operational rotation on connectivity + bounded grace + contiguous-epoch backfill within the window; re-auth past it |
| #119 / FM2 cross-device own-history | §4.3 | Accepted by policy — one still-live key means own-history reads on a new device; sensitivity tail held by §4.1 gate |
5. ALTERNATIVE — per-scope key hierarchy with per-object CEK wrapping (Candidate A)¶
Take this branch only when classification / plan-scoping must be a cryptographic boundary against a currently-valid member (the YES branch of §3) — e.g. a patch-the-binary adversary is in scope for classification separation. It can be applied selectively to just the compartments that need it, on top of §4.
5.1 Scheme¶
Each object is encrypted under a fresh content-encryption key (CEK); the CEK is wrapped once per authorised scope using Directory-issued scope keys. A recipient decrypts iff it holds a scope key the CEK was wrapped for — a member lacking the clearance/plan scope gets no CEK, so "decrypt-then-check" collapses into "cannot decrypt". That is what makes it cryptographic where §4 is software.
Scope kinds (deliberately few):
| Scope | Value | Maps to | Closes cryptographically |
|---|---|---|---|
clr clearance rung |
policy/classification |
IdentityToken.clearance.max_classification (read-down: hold your rung + below) |
#211 |
cat compartment |
policy/tag/value |
clearance.category_auths (messages.proto:361-376) |
#211 (REL-TO) |
orbat command scope |
unit_id |
OrbatRecord subtree+up-chain (the visibleUnitIds rule, made cryptographic) |
#421 |
chan channel (deferred) |
chat_id/voice_id |
ChatMembership |
per-channel need-to-know (only if required) |
Scope keys derive from Directory-held roots so rotation is a counter bump and issuance
rides the token:
scope_key(kind,value,epoch) = HKDF(root[kind,value], kind‖value‖epoch). Entitlement is
a pure function of the already-signed token (clearance + roles + ORBAT), computed by
the same common CMBAC engine so crypto and policy cannot diverge.
5.2 Seal / open¶
- Seal: fresh CEK → GCM the plaintext (re-tag
SealedContentversion = 0x02,epoch→CEK id) → for each required scope,AES-KW(scope_key, CEK)→ emit aWrappedKeyEnvelope { scope_kind, scope_value, scope_epoch, wrapped_cek }[]alongside the v2 blob. Signed-cleartextclassification/owner_principal_id(messages.proto:451-452) unchanged, so the content-blind server still routes/gates. - Open: for each wrapped entry the device holds the scope key for, AES-KW-unwrap the CEK, GCM-open. No match → fail closed.
5.3 Distribution & catch-up — FM1 (hard constraint)¶
- Directory:
GET /api/scope-keysreturns every scope-key epoch from a bounded horizon to current, wrapped to the device — a contiguous range, the capability FM1 needs (vs today's cur+prev-only). - Content-blind server buffer: scope-key rotations published as sealed
AuthEnvelopes onwaypoint/scopekey/<kind>/<value>/<epoch>, each new epoch wrapped for the previous epoch's holders (hash-chain). The server buffers these exactly like chat (full opaque bytes,chat_handler.rs:8-39) — it holds wrapped keys it cannot unwrap, preserving content-blindness. - Bounded to the offline-resync horizon (reuse the 7-day
outbox_policy.rs:63precedent); a device dark longer re-enrols.
5.4 History recovery — FM2 + the FS/recoverability call (hard constraint)¶
Per-object CEKs give strong forward secrecy; own-history across devices needs the
opposite. Recommended mechanism if in this branch: the Directory escrows scope-key
epoch history and, on fresh auth, re-wraps only the scopes the principal is entitled to
now to the new device (GET /api/scope-keys?history=1).
State the tradeoff exactly: a re-authenticated principal recovers history for scopes it currently holds; it does not get scopes it never had or has since lost (declassification/re-parent is retroactive on new devices); a captured, re-authed device yields only its currently-entitled scopes' history — never other compartments, units, or post-revocation epochs (strictly less than the group-key world's "everything"). Forward secrecy is then bounded by Directory custody, not device custody — objects needing absolute FS use a never-escrowed CEK (unreadable on a new device by design). Note this is a genuine cost vs §4, which gets FM2 for free precisely because it keeps one recoverable key.
5.5 Rotation & rekey-on-membership-change¶
Per-scope-line rotation (independent epochs). Add = lazy (issue current epoch on next
login). Remove = eager (rotate the scope epoch so the removed principal's held epoch
can't read new content), paired with the existing pull-based RevocationList cascade
(directory.proto:127-151) — crypto denial + envelope rejection, defence in depth.
Offline removed members keep only pre-rotation content.
5.6 Findings closed cryptographically by §5¶
211 (clr/cat + fail-closed open), #421 (orbat wrap at publish — not the browser¶
visibleUnitIds filter, which today governs only attachments and never plan records —
see §7), #55 (captured node holds only role-entitled scopes), FM1/#118 (§5.3 contiguous
range), FM2/#119 (§5.4 escrow re-wrap).
6. Migration¶
§4 needs almost no wire change — it is a common-ownership + Directory-policy +
contract-test effort: promote CMBAC/wipe/fog into shared gates, add the golden vectors +
CI, add operator-driven rotation cadence + grace window, optionally the contiguous-epoch
GET /api/group-key range for FM1. No flag day.
§5, if adopted, is wire-breaking with no dual-read (same class as the purpose and
E2E-content cutovers, model.md:308-324): (1) ship common opening both
SealedContent v1 (group key) + v2 (CEK+wrapped scopes); (2) Directory serves both
/api/group-key and /api/scope-keys; (3) flag-day switch to sealing v2, durable owners
re-emit on reconnect; (4) privileged-core (Web) backfill re-wraps existing
tombstoned content (generate CEK, re-seal v2, wrap for the scopes its existing label +
ORBAT imply) — content whose scope is indeterminate stays group-key-sealed; (5) retire
the group key after the history horizon.
7. Rejected alternatives¶
- Status quo (single key, prose-only enforcement). Rejected. This is exactly what
#211/#210/#421/#55 flag — enforcement in comments,
Cmbac.decidewith no production caller. #421 is in fact weaker than "UI-only": plan records are sealed under the shared group key (web/app/domains/orders/plan_shape_distributor.ts:323,sealNode), broadcast on a wildcard every client subscribes to (web/app/shared/record_keys.ts:31,35,PLAN_RECORD_SUB = waypoint/global/record/plan/*/*), and served by the API filtered only byoperation_idwith no echelon predicate (orders_repository.ts:28-41,orders_controller.ts:166-190) — the ORBAT subtree/chain rulevisibleUnitIds(orbat_visibility_helpers.ts) is applied to attachments (artifact_service.ts:173-187), never to plan records. So "higher-echelon plans commanders-only" is presentation-layer only. §4 is the minimal fix that keeps the single key but makes enforcement real and retention bounded. - Per-channel need-to-know keying for everything. Rejected as default. The stated
residual explicitly accepts intra-channel sharing; a key per chat channel is machinery
the threat model doesn't justify. Available as the
chanscope in §5 if a concrete requirement appears. - MLS / TreeKEM (RFC 9420). Rejected for the tactical edge. Its forward-secrecy / post-compromise guarantees depend on interactive, ordered key-agreement rounds among members; the operating assumption is intermittent, partition-prone connectivity with content-blind relays and store-and-forward. MLS epoch progression stalls/forks under partition and wants member-driven agreement, not a central minter — the opposite of the Directory-issued + offline-pull shape everything else relies on. Revisit only if the topology ever gains reliable connectivity.
8. Risks, open questions, phased plan¶
Risks¶
- §4 is only as strong as its weakest consumer. A single consumer that decrypts and forgets the gate reopens #211/#210 — the contract-test regime (§4.5.2) is the control, and it must be a blocking CI gate, not advisory.
- Software-enforcement ceiling (§4.5.1). Honest scope: intact-OS lost device, not rooted adversary. If leadership needs cryptographic classification separation against a capable adversary, that is the §5 trigger — surface it explicitly for sign-off.
- Fog eviction correctness. A prune that runs too eagerly deletes in-AO data; too
lazily leaves a capture trail. The geohash-cell-change trigger
(
TacNetEngine.kt:913-938) is the right seam but needs the shared-contract + negative tests before node/web rely on it. - §5 (if taken): fail-closed availability + escrow concentration. A mis-issued entitlement makes content undecryptable, and Directory escrow concentrates confidentiality — both called out in §5.4.
Open questions (prototype these)¶
- Rotation cadence vs grace window vs offline tolerance — pick the operating point and validate the contiguous-epoch backfill (§4.4) against a realistic offline profile.
- The
commonenforcement-gate API — the exact signature/porting of a shareddecide-to-store/decide-to-view/decide-to-evict/decide-to-wipesurface across Rust/Kotlin/TS, and how negative tests assert "gate was called". - Fog radius policy — whether ±X km per role is sufficient, or plans/ORBAT need a separate non-geographic retention rule (they are not position-anchored).
- §5 entitlement ↔ CMBAC-decision equivalence — the correctness crux if that branch is taken.
Phased plan¶
- Phase 0 — enforcement + retention (§4), no wire change. Promote CMBAC/wipe/fog to
shared
commongates; add golden enforcement vectors + blocking CI in every consumer (closes #211/#210 substrate). Add operator-driven rotation cadence + ~1-day grace + contiguous-epoch backfill (closes FM1/#118). Credential-expiry / forced re-auth policy in Directory. FM2/#119 accepted-by-policy, documented. - Phase 1 — fog-of-war hardening (§4.2 space). Shared, role-scoped fog decision + active eviction across node/web/gateway with negative tests (tightens #55).
- Phase 2 — per-scope keying (§5), only if the §3 decision says classification/plan
must be cryptographic.
commonv1/v2 dual-open; Directoryscope_keys+/api/scope-keys; startclr(#211) thenorbat(#421); escrow re-wrap for FM2; flag-day cutover + backfill. Selective per compartment, not fleet-wide by default.
Prototype before Phase 2: seal/catch-up cost on edge targets; the entitlement ↔ CMBAC-decision equivalence; the content-blind server buffering wrapped scope-key rotations end-to-end.