feat(rooms): data rooms end to end — storage, dispatch, verification, MLS, and a host - #1237
Conversation
A data room is a shared space whose access is governed by credentials the room itself issues. This lands the storage and its invariants; the Trust-Task dispatch that authorizes operations follows once rooms/* is published in the registry (trustoverip/dtgwg-trust-tasks-tf#346) - the dispatcher refuses a URI the published registry has no schema for, and growing the unspecced allowlist is the wrong fix. Written first so that the dispatch layer is a thin wrapper over settled behaviour rather than a place where storage decisions get made under time pressure. The row deliberately carries an owner, a visibility, an epoch and a retention period, and NO member list. Not omitted for now - there must not be one. The moment this service keeps a roster and consults it, three things stop being true at once: the room can no longer move to another host without reissuing credentials, this service becomes part of the room's membership definition, and a room whose contents we cannot read acquires a member list we can. Invariants enforced in the store rather than trusted to callers, each with a test: - An open room refuses ciphertext and a sealed room refuses cleartext, so a tier promise cannot be broken by a caller passing the wrong shape. - A private room refuses a recorded author: on that tier authorship belongs inside the sealed body where only members can read it. - A record sealed under a stale epoch is refused, because a reader holding the current key could not open it. - An epoch advances by exactly one. A gap would leave records sealed under an epoch nobody holds a key for; a repeat would let a removed member's key open material written after their removal, which is the whole point of advancing. - Versions are monotonic per room, not per record - one comparable number is what a sinceVersion watermark needs. A conflict carries the current version so a caller need not re-read, because between a bare rejection and the re-read the record can change again. - A listing returns tombstones to a watermark caller. Without that a puller learns of every create and update and never of a delete, so retracted records resurrect on its next full rebuild. - Retract and purge are separate verbs: a tombstone keeps the key, version and epoch so sync converges and the audit chain holds, and erasure is a distinct, higher-trust act. Keyspaces registered in ALL and BACKED_UP - the two must partition ALL exactly - with the census count moved to 27 and the matching AppState fields opened, so the documented ALL-matches-AppState invariant stays true rather than merely passing a length check. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
🛡️ AI Agentic Security Code Review3 AI-confirmed issues, 2 findings need a human to review/validate. Mandatory to check: 🔒 Security Code Review Report Details🛡️ Security Code Review Report — PR #1237
🗺️ Scan CoverageModules scanned: 1 · with findings: 1 · files: 6 · findings: 7
⚖️ Cross-Finding Reconciliation1 root-cause cluster(s) received divergent verdicts across findings that share the same file + weakness. These are surfaced (not auto-resolved) — a reviewer should confirm the verdicts are intentionally different, not an artifact of findings being judged in isolation:
Executive Summary
🔒 Security IssuesConfirmed Vulnerabilities (3)🟡 Unauthenticated/unverified hard erasure via purge_record (function body truncated, no visible authorization or audit gate)
🧠 AI Triage:
Summary: purge_record performs irreversible, tombstone-including deletion of a room record, but its implementation is truncated in the supplied diff and no authorization or audit-logging call is visible before the cutoff. Given every other function in this module (create_room, put_record, get_record, advance_epoch) lacks caller-identity verification, this destructive operation is suspected to share the same gap, which would let any reachable caller permanently destroy forensic/sync-critical tombstone data. 📝 Description: If unauthorized, an attacker (or buggy internal caller) can permanently destroy tombstones that incremental sync and audit reconstruction depend on, causing deleted records to silently resurrect for peers who never saw the retraction, and removing forensic evidence relied upon by the AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT signed-checkpoint chain described elsewhere in the codebase. 🧪 Proof of Concept: The doc comment confirms this is the 'erasure path' distinct from retraction, i.e., truly destructive and irreversible. No visible authorization parameter is passed into the function signature (unlike what a credential-gated design would require), and the body is cut off before any check can be confirmed, consistent with the module-wide pattern of deferring authorization entirely to an external dispatch layer. Vulnerable lines: 560, 580 🔎 Evidence: 💥 Impact: If unauthorized, an attacker (or buggy internal caller) can permanently destroy tombstones that incremental sync and audit reconstruction depend on, causing deleted records to silently resurrect for peers who never saw the retraction, and removing forensic evidence relied upon by the AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT signed-checkpoint chain described elsewhere in the codebase. 🧭 Reachability:
⚖️ Triage Factors:
Attack scenario: A caller with access to purge_record can potentially permanently destroy tombstone records with no visible credential check or audit log, based on the pattern established by every other function in this module. 🔧 Remediation:
Require an elevated, explicitly-verified credential parameter (owner or compliance role) before any hard-delete executes, and emit a mandatory append-only audit entry (ideally into the Ed25519-signed AUDIT_CHECKPOINT chain) prior to or atomically with the delete, so the destructive act itself is provable even though its target content is gone. Vulnerable code: Secure code:
🟡 Non-atomic read-modify-write on room/record version counter allows race condition
🧠 AI Triage:
📝 Description: put_record and retract_record read the Room, compute next_version, then write the record and the room back in two separate, non-atomic operations. Concurrent writers to the same room can race on this read-modify-write. 🌱 Root Cause: The documented invariant only guarantees monotonicity but the version-assignment sequence (read room -> read record for precondition check -> write record -> write room) is not transactional, allowing a TOCTOU window between the version-conflict check and the final write under concurrent access. 🔎 Evidence: 🎯 Attack Scenario: Two concurrent writers to the same key/room can both read the same 'expected_version', both pass the precondition check, and both write, with the second write silently overwriting the first despite the optimistic-concurrency check appearing to succeed for both.
|
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | vtc-service/src/rooms/storage.rs:1 |
| Finding ID | github_pr-aa5160fc4f6b |
| CWE | CWE-862 |
| OWASP | A01:2021 - Broken Access Control |
| Detection Source | threat_model |
🧠 AI Triage:
- Severity reassessed: CRITICAL → MEDIUM — The code evidence confirms the absence of authorization logic in the storage function signature, which is a legitimate design flaw. However, the scanner explicitly frames this as contingent on a not-yet-wired dispatcher and reachability is 'no-info' with only 60% scanner confidence — there is no evidence a public/authenticated route currently calls this function without the missing check. Per severity gates, Critical requires confirmed reachability (≥7) and either exploit≥7 or public exploit≥8; neither is met here. This caps the finding at High: a serious, must-fix-before-shipping authorization gap in production-bound infrastructure code, but not a confirmed actively-exploitable critical vulnerability today.
- Composite score: 4
- Environment: unknown
📝 Description:
The storage layer functions (create_room, put_record, retract_record, purge_record, advance_epoch) perform no credential or membership verification themselves; the module doc states authorization dispatch 'lands separately'. Until that dispatcher is wired in, any caller with access to these functions can perform room operations without presenting a membership credential.
🌱 Root Cause: Authorization is deferred to a not-yet-implemented dispatch layer, while the storage API is already fully functional and exported.
🔎 Evidence: vtc-service/src/rooms/storage.rs:1
pub async fn put_record(
rooms: &KeyspaceHandle,
records: &KeyspaceHandle,
room_id: &str,
mut record: Record,
expected_version: Option<u64>,
now: u64,
) -> Result<Record, AppError> {
🎯 Attack Scenario:
If any route or internal caller invokes these storage functions before the Trust-Task dispatcher is wired in, an unauthenticated or unauthorized caller could create rooms, write, retract, or purge records for rooms they do not own or belong to.
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 60%
- AI Validation Evidence: EVIDENCE FOUND: storage.rs functions (create_room, put_record, retract_record, purge_record, advance_epoch) contain no credential/membership checks — e.g.
pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> { if rooms.get_raw(room_key(&room.room_id)).await?.is_some() {...} rooms.insert(...).await }has zero identity checks. The module doc explicitly states 'The Trust-Task dispatch that authorizes those operations lands separately'. EVIDENCE NOT FOUND: server.r- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
🟡 Missing authorization/credential verification in rooms::storage public functions (create_room, get_room, advance_epoch, put_record, get_record, list_records, retract_record)
| Field | Detail |
|---|---|
| Severity | MEDIUM |
| Location | vtc-service/src/rooms/storage.rs:40 |
| Finding ID | github_pr-aeab5ecc3df2 |
| CWE | CWE-862, CWE-284 |
| OWASP | A01:2021 - Broken Access Control |
| MITRE ATT&CK | T1078 - Valid Accounts (analogous: missing credential check), T1565 - Data Manipulation |
| CAPEC | CAPEC-1, CAPEC-122 |
| CVSS 4.0 | 9.1 (CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N) |
| DREAD | 8 |
| Reachability | 🔴 Reachable |
| Exploit Maturity | conceptual |
| Detection Source | skill_scan |
🧠 AI Triage:
- Severity reassessed: CRITICAL → MEDIUM — The vulnerability class (CWE-862 Missing Authorization) and code evidence are severe and reachable per the scanner (is_reachable=true, no auth gate), justifying high severity. However, critical requires confirmed production exposure (Env≥8) and either strong exploit evidence or exploitability≥7 — here environment is 'unknown' (no deployment manifest/route wiring confirmed), exploit maturity is only 'conceptual', and the scanner itself rates exploitability as 'medium' pending confirmation that an external route is wired to these functions. This falls short of the critical gate criteria and is capped at high.
- Composite score: 4.3
- Environment: unknown
Summary: The rooms::storage module exposes create_room, get_room, put_record, advance_epoch, and related functions with no authentication or authorization check whatsoever — enforcement is deferred to a Trust-Task dispatch layer that, per the module's own documentation and the diff, has not yet been merged. Any caller that can reach these public functions (an internal service, a future route wired before dispatch lands, or a test/admin tool) can create rooms, tamper with records, and read cleartext content belonging to any room.
📝 Description:
An attacker with reach to these functions (e.g. via an internal service call, a future HTTP route wired before dispatch integration is complete, or a misconfigured deployment) can create rooms impersonating any owner_did, write arbitrary sealed/cleartext records into any room, read Open-tier cleartext content belonging to other tenants, and advance a room's epoch — corrupting the key-rotation invariant that member credentials depend on.
🧪 Proof of Concept:
Neither create_room nor get_room (nor put_record, advance_epoch, retract_record) validates any caller identity, credential, or membership proof — they operate purely on room_id string equality against storage. Authorization is documented as deferred to an as-yet-unmerged dispatch layer, meaning any code path with access to these public functions today has unrestricted read/write.
pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> {
if rooms.get_raw(room_key(&room.room_id)).await?.is_some() {
return Err(AppError::Conflict(format!(
"room `{}` is already registered here",
room.room_id
)));
}
rooms.insert(room_key(&room.room_id), room).await
}
/// Fetch a room, or [`AppError::NotFound`].
pub async fn get_room(rooms: &KeyspaceHandle, room_id: &str) -> Result<Room, AppError> {
let raw = rooms
.get_raw(room_key(room_id))
.await?
.ok_or_else(|| AppError::NotFound(format!("room `{room_id}` not found")))?;
serde_json::from_slice(&raw)
.map_err(|e| AppError::Internal(format!("decode room `{room_id}`: {e}")))
}
Vulnerable lines: 40, 58
🔎 Evidence: vtc-service/src/rooms/storage.rs:40
pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> {
if rooms.get_raw(room_key(&room.room_id)).await?.is_some() { ... }
rooms.insert(room_key(&room.room_id), room).await
}
pub async fn put_record(rooms: &KeyspaceHandle, records: &KeyspaceHandle, room_id: &str, mut record: Record, expected_version: Option<u64>, now: u64) -> Result<Record, AppError> { ... }
💥 Impact:
An attacker with reach to these functions (e.g. via an internal service call, a future HTTP route wired before dispatch integration is complete, or a misconfigured deployment) can create rooms impersonating any owner_did, write arbitrary sealed/cleartext records into any room, read Open-tier cleartext content belonging to other tenants, and advance a room's epoch — corrupting the key-rotation invariant that member credentials depend on.
🧭 Reachability:
- Network exposure: internal
- Auth barrier: none
- Attack path: EP-001/EP-002/EP-003/EP-004/EP-005 (direct function calls) → rooms::storage::* → keyspace store, no auth gate in between
⚖️ Triage Factors:
| Factor | Value |
|---|---|
| Fixable | ✅ Yes |
| Exploitability | medium |
| Business impact | critical |
| Public exploit | None known |
| Environment | unknown |
Attack scenario: Any caller able to reach rooms::storage functions directly (before the referenced dispatch layer lands) can create rooms, and read/write record content for any room_id with zero credential verification.
🔧 Remediation:
⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.
Until the Trust-Task dispatch layer lands, mark these functions pub(crate) so they cannot be invoked from outside the crate/dispatch boundary, and require an explicit verified-credential parameter at every mutating/reading entry point as defense-in-depth. This prevents any accidental or premature wiring of a route directly to storage without going through authorization.
Vulnerable code:
pub async fn put_record(rooms: &KeyspaceHandle, records: &KeyspaceHandle, room_id: &str, mut record: Record, expected_version: Option<u64>, now: u64) -> Result<Record, AppError> {
let mut room = get_room(rooms, room_id).await?;
// no caller identity / credential check here
...
}
Secure code:
pub async fn put_record(rooms: &KeyspaceHandle, records: &KeyspaceHandle, room_id: &str, mut record: Record, expected_version: Option<u64>, now: u64, presented_credential: &VerifiedMembership) -> Result<Record, AppError> {
let room = get_room(rooms, room_id).await?;
presented_credential.verify_against(&room.room_id)?; // enforce membership proof before any state mutation
...
}
🔍 Validation Log
- Verdict:
⚠️ Must-Review-By-Human- Confidence: 90%
- AI Validation Evidence: EVIDENCE FOUND: Same as above — create_room, get_room, advance_epoch, put_record, get_record, list_records, retract_record all lack any identity/credential verification in the quoted code. EVIDENCE NOT FOUND: No route/handler code in server.rs is shown wiring these functions to a reachable HTTP endpoint; the module doc states dispatch 'lands separately' (feat(rooms): add the rooms/* task family trustoverip/dtgwg-trust-tasks-tf#346), implying it's not yet wired. CHANGED VS PRE-EXISTING: CHANGED — rooms/storage.rs is new in this MR (full
- Validation Effort: This finding was validated up to a point, but the available evidence was insufficient for a conclusive automated verdict. A human (developer / security team) must manually review the code and decide. Not dismissed — treat as an open item pending human review.
Generated by Agentic Sec — AI Security Validation Agent
This report includes full scan data + AI validation evidence. Feed to engineering copilots for automated fix deployment.
Details
🛡️ Threat Model & Affect Analysis — PR #1237
| Field | Value |
|---|---|
| Repository | OpenVTC/verifiable-trust-infrastructure |
| Branch | feat/rooms-open-tier → main |
| Generated | 2026-09-05 |
ℹ️ This report contains theoretical threats and impact analysis for the MR.
Unlike the Security Code Review Report (which contains confirmed, materialised issues),
these are potential risks that may or may not be exploitable. Use this for defence-in-depth planning.
📋 Affect Analysis
Change Summary
Introduces a new 'data rooms' storage layer (rooms::mod + rooms::storage) implementing a zero-membership-list, credential-based data-sharing primitive with three confidentiality tiers (Open/Attributed/Private), plus wiring it into the crate module tree and the central keyspace registry. The authorization/dispatch layer that will gate access to these functions is explicitly deferred to a separate, not-yet-landed change (tracked as trustoverip/dtgwg-trust-tasks-tf#346).
Diff: +792 / -4 lines
Types: feature, storage-layer, config
🧩 Affected Components
| Component | Impact | Change | What Changed |
|---|---|---|---|
| rooms::storage (new persistence module) | critical | new | A complete new CRUD API (create_room, get_room, advance_epoch, put_record, get_record, list_records, retract_record, purge_record) over two |
📁 File Classifications
vtc-service/src/lib.rs
- Type: config
vtc-service/src/rooms/mod.rs
- Type: security
vtc-service/src/rooms/storage.rs
- Type: security
vtc-service/src/store/keyspaces.rs
- Type: config
🛡️ STRIDE Threat Model
Identified Threats (12)
🔴 STRIDE-1: Missing Authorization Enforcement in rooms::storage Function Calls
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Information Disclosure, Elevation of Privilege |
| Severity | Critical |
| Likelihood | Very Likely |
| CVSS | 9.1 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Critical |
| CWE | CWE-862,CWE-284 |
| CAPEC | CAPEC-1,CAPEC-122 |
| OWASP | A01:2021 - Broken Access Control |
Description: rooms::storage functions (create_room, get_room, advance_epoch, put_record, retract_record, purge_record) in vtc-service/src/rooms/storage.rs allow unauthorized data access or modification due to no membership/credential verification implemented at the storage layer, resulting in unauthorized room creation, record tampering, and data exposure until the referenced Trust-Task dispatch layer (trustoverip/dtgwg-trust-tasks-tf#346) lands.
Evidence: vtc-service/src/rooms/storage.rs:1-60
pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> {
if rooms.get_raw(room_key(&room.room_id)).await?.is_some() {
return Err(AppError::Conflict(...));
}
rooms.insert(room_key(&room.room_id), room).await
}
Attack Scenario:
- Attacker identifies that vtc-service/src/rooms/mod.rs and storage.rs contain no membership list or credential verification logic — the module doc explicitly states 'There is no member list' and defers authorization to an unlanded dispatch layer.
- Attacker (or any caller with function-level access, e.g., via an internal API route not shown in the truncated server.rs) invokes
create_room(rooms, room)directly, supplying an arbitraryowner_didandroom_idwithout presenting any membership credential. - Because
create_roomin vtc-service/src/rooms/storage.rs only checks for room_id collision (rooms.get_raw(room_key(...))), the call succeeds and a room is registered under attacker control. - Attacker calls
put_record(rooms, records, room_id, record, expected_version, now)to write arbitrary sealed/cleartext content into any room whose room_id they can guess or enumerate, since no caller identity or credential validation occurs before the version/visibility checks. - Attacker calls
get_record/list_recordsto read Open-tier cleartext content of rooms they do not own, since visibility gating governs content shape, not caller identity. - If the currently-unimplemented dispatch layer is delayed, misconfigured, or bypassed (e.g., an internal service call path added later that calls storage functions directly), unauthorized reads/writes persist in production.
🔎 Threat Clue: Derived from COMP-003 via EP-001, EP-002, EP-003, EP-004, EP-005, EP-006, EP-007, EP-008
- Data Flows: rooms -> room_records
Preconditions: Caller has network or process-level access to any code path that invokes rooms::storage functions directly (e.g., an internal RPC, test harness, or future route wired before dispatch enforcement is complete)., The Trust-Task dispatch layer (issue #346) is not yet merged or is bypassed.
Existing Controls: Room-exact key prefixing (room_records:<roomId>:) prevents cross-room key collisions at the storage layer. • Design documentation explicitly defers authorization to a separate dispatch layer.
Recommended Mitigations: Do not expose rooms::storage functions on any network-reachable route until the Trust-Task dispatch layer is merged and enforced. • Add a defense-in-depth capability check (e.g., require a verified credential token parameter) at the storage API boundary even though authorization is designed to live upstream. • Add integration tests that assert storage functions are unreachable without going through dispatch. • Document and enforce (e.g., via pub(crate) visibility) that these functions cannot be called from outside the crate until dispatch wraps them.
🔴 STRIDE-2: Unauthenticated Hard Erasure via purge_record
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation, Denial of Service, Elevation of Privilege |
| Severity | Critical |
| Likelihood | Likely |
| CVSS | 8.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N |
| Residual Severity | High |
| CWE | CWE-862,CWE-778 |
| CAPEC | CAPEC-17,CAPEC-268 |
| OWASP | A01:2021 - Broken Access Control, A09:2021 - Security Logging and Monitoring Failures |
Description: purge_record in vtc-service/src/rooms/storage.rs allows unauthorized irreversible record deletion due to a truncated/unverifiable authorization check and no audit logging visible in the supplied diff, resulting in permanent data loss and destruction of forensic evidence.
Evidence: vtc-service/src/rooms/storage.rs:~560-580 (truncated)
pub async fn purge_record(
records: &KeyspaceHandle,
room_id: &str,
key: &str,
) -> Result<(), AppError> {
let k = re
... [diff truncated]
Attack Scenario:
- Attacker gains function-level access to
purge_record(records, room_id, key)in vtc-service/src/rooms/storage.rs, whose implementation is truncated in the diff (let k = re...), meaning no visible authorization gate exists in the reviewed code. - Unlike
retract_record, which merely tombstones data,purge_recordis described as 'Permanently remove a record, tombstone included' — a genuinely destructive operation. - Attacker calls purge_record with an arbitrary room_id/key pair for a room they do not own, deleting the tombstone entirely.
- Because tombstones are relied upon by list_records/incremental sync logic ('without a tombstone a puller learns of every create and update and never of a delete'), the purge also breaks sync consistency for legitimate peers, causing deleted records to silently vanish without any client-visible signal.
- No audit log call is visible in the truncated function body, so the deletion event may be unattributed and unrecoverable, defeating any downstream audit/compliance review that depends on the AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT keyspaces referenced in keyspaces.rs.
🔎 Threat Clue: Derived from COMP-003 via EP-008
- Data Flows: room_records deletion
Preconditions: Caller can invoke purge_record directly (function-level or via a future unguarded route)., No authorization or audit-log call exists inside the truncated function body.
Existing Controls: Separate verb (purge_record vs retract_record) intentionally isolates the high-trust operation from routine retraction, per module design comments. • AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT keyspaces exist elsewhere in the system for signed checkpoints.
Recommended Mitigations: Require an explicit elevated-privilege credential (e.g., owner-only or compliance-role scoped) before invoking purge_record. • Emit a mandatory audit log entry (append-only, ideally to the Ed25519-signed AUDIT_CHECKPOINT chain) before or atomically with the delete. • Add a confirmation/second-factor step (e.g., two-phase delete with a cooldown) for GDPR/right-to-erasure requests specifically, distinguishing legitimate erasure from malicious purge. • Restrict purge_record visibility to pub(crate) and gate it strictly behind the dispatch layer once merged.
🟠 STRIDE-3: Non-Atomic Read-Modify-Write TOCTOU in put_record Version Assignment
| Field | Detail |
|---|---|
| Category | Tampering |
| Severity | High |
| Likelihood | Likely |
| CVSS | 7.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:H/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-362,CWE-367 |
| CAPEC | CAPEC-26 |
| OWASP | A04:2021 - Insecure Design |
Description: put_record in vtc-service/src/rooms/storage.rs allows race-condition-based version-counter corruption due to the room-and-record read-modify-write not being atomic across the rooms and room_records keyspaces, resulting in optimistic-concurrency-control bypass and lost/overwritten updates under concurrent access.
Evidence: vtc-service/src/rooms/storage.rs:~140-220
pub async fn put_record(
rooms: &KeyspaceHandle,
records: &KeyspaceHandle,
room_id: &str,
mut record: Record,
expected_version: Option<u64>,
now: u64,
) -> Result<Record, AppError> {
let mut room = get_room(rooms, room_id).await?;
let existing = get_record(records, ro
Attack Scenario:
- Two concurrent callers (or one attacker racing a legitimate writer) invoke
put_recordon the same room_id at nearly the same time. - Both calls execute
let mut room = get_room(rooms, room_id).await?;and independently read the samenext_versionvalue before either write completes, since there is no lock or transaction spanningroomsandroom_recordskeyspaces (module doc explicitly acknowledges: 'That read-modify-write is not atomic across the two keyspaces'). - Attacker's write and the legitimate write both pass the
expected_versionprecondition check against the same stale room state (e.g., both believe current version is N). - Both writes proceed:
record.version = room.next_version;assigns the same version number to two different records intended to be distinguishable, or one overwrites the room'snext_versioncounter after the other, resulting in a duplicate/collided version instead of the documented 'skipped version' failure mode. - Downstream consumers relying on strict per-room version monotonicity for
sinceVersionwatermarking receive ambiguous or colliding version numbers, causing sync divergence or attacker-controlled record injection that silently overwrites a legitimate concurrent write, defeating the optimistic concurrency control (expected_version) the API advertises as its integrity guarantee.
🔎 Threat Clue: Derived from COMP-003 via EP-004
- Data Flows: rooms <-> room_records read-modify-write
Preconditions: Attacker can issue concurrent/rapid requests against the same room_id., No transactional guarantee spans the rooms and room_records keyspace writes.
Existing Controls: Design comment acknowledges the gap and argues failure mode is a skipped (not duplicated) version — but this argument holds only under sequential contention, not true concurrent races on both keyspaces. • expected_version precondition provides some protection against blind overwrites for sequential callers.
Recommended Mitigations: Wrap the room-read, version-assignment, and both keyspace writes in a single atomic transaction or use a compare-and-swap primitive on the room's next_version field. • Use a per-room mutex/lock or optimistic retry loop that re-validates next_version immediately before the final write. • Add a uniqueness constraint on (room_id, version) enforced by the storage layer to detect and reject collisions at write time.
🟠 STRIDE-4: Cleartext Storage of Sensitive Content in Open Visibility Rooms
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | High |
| Likelihood | Likely |
| CVSS | 6.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-312,CWE-359 |
| CAPEC | CAPEC-116 |
| OWASP | A02:2021 - Cryptographic Failures |
Description: Record.cleartext field in vtc-service/src/rooms/mod.rs in Open-visibility rooms allows information disclosure due to the storage layer holding unencrypted content directly readable by the service operator, resulting in exposure of potentially sensitive data to any party with storage or backup access.
Evidence: vtc-service/src/rooms/mod.rs:~90-135
pub fn stores_cleartext(&self) -> bool {
matches!(self, Visibility::Open)
}
...
pub cleartext: Option<serde_json::Value>,
Attack Scenario:
- A room is created with
Visibility::Open, which per design 'stores_cleartext()' returns true, meaningput_recordrequiresrecord.cleartextto be populated and rejectssealedcontent. - An attacker who compromises the storage backend (e.g., via a separate vulnerability, insider access, backup exfiltration, or misconfigured keyspace access controls) can read
room_records:<roomId>:<key>rows directly and obtain full cleartext JSON content without needing any room credential. - Because visibility is 'Immutable for the life of a room' per the module doc, a room mistakenly created as Open cannot be retroactively protected — all historical content remains permanently exposed to anyone with storage access.
- The owner_did and author fields are also visible in this tier, allowing an attacker to correlate exposed content with specific identities, amplifying privacy impact (linkage attack).
🔎 Threat Clue: Derived from COMP-003 via EP-004, EP-005, EP-006
- Data Flows: room_records cleartext storage
Preconditions: A room is configured with Visibility::Open., Attacker gains read access to the underlying keyspace store (compromise, insider threat, backup leak, misconfigured access control).
Existing Controls: Visibility tiers (Attributed/Private) exist as sealed alternatives for sensitive content. • Design forces an explicit choice at room-creation time rather than a default; immutability prevents accidental silent downgrade later.
Recommended Mitigations: Encrypt storage backend at rest independent of application-level visibility tiers, so operator/backup compromise does not yield plaintext. • Add server-side warnings/guardrails discouraging Open-tier usage for anything resembling PII or regulated data. • Implement strict access controls and audit logging on the underlying keyspace store itself, not just at the application layer. • Consider periodic content-classification scanning to flag potentially sensitive data mistakenly placed in Open rooms.
🟡 STRIDE-5: Room ID Enumeration via NotFound/Conflict Error Oracle
| Field | Detail |
|---|---|
| Category | Information Disclosure |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-203 |
| CAPEC | CAPEC-116,CAPEC-127 |
| OWASP | A01:2021 - Broken Access Control |
Description: get_room and create_room in vtc-service/src/rooms/storage.rs allow room existence enumeration due to distinguishable AppError::NotFound and AppError::Conflict error responses leaking existence state, resulting in unauthorized discovery of valid room identifiers for follow-on targeted attacks.
Evidence: vtc-service/src/rooms/storage.rs:~40-58
pub async fn get_room(rooms: &KeyspaceHandle, room_id: &str) -> Result<Room, AppError> {
let raw = rooms.get_raw(room_key(room_id)).await?
.ok_or_else(|| AppError::NotFound(format!("room `{room_id}` not found")))?;
...
Attack Scenario:
- Attacker without any valid credential repeatedly calls
get_room(rooms, room_id)orcreate_roomwith guessed/brute-forced room_id values (room_id is owner-minted and could follow predictable patterns such as DID-derived slugs). get_roomreturnsAppError::NotFoundfor non-existent rooms and successfully returns Room data (or a distinguishable Conflict on create) for existing ones, creating a binary oracle for room existence.- Attacker builds a list of valid room_ids across the deployment without needing any authorization, since existence-checking is not gated by credential verification at this layer.
- Attacker uses harvested room_ids to focus subsequent authorization-bypass attempts (STRIDE-1) or targeted denial-of-service / resource exhaustion against known-active rooms.
🔎 Threat Clue: Derived from COMP-003 via EP-001, EP-002
- Data Flows: rooms lookup
Preconditions: Attacker has repeated function/API-level access to get_room or create_room., No rate limiting or generic error response is enforced at this layer (rate limiting is out of scope for the storage module and unverified in provided code).
Existing Controls: Errors are structured (AppError::NotFound/Conflict) which is good for legitimate debugging but bad for enumeration resistance.
Recommended Mitigations: Apply rate limiting and anomaly detection on repeated NotFound responses per caller at the API/dispatch layer. • Consider returning uniform generic errors for unauthenticated existence checks once the dispatch layer is in place. • Ensure room_id is not derived from guessable/sequential values; require sufficiently high-entropy identifiers.
🟡 STRIDE-6: Unbounded prefix_iter_raw Scan Resource Exhaustion in list_records
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.5 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-400,CWE-770 |
| CAPEC | CAPEC-125,CAPEC-130 |
| OWASP | A04:2021 - Insecure Design |
Description: list_records in vtc-service/src/rooms/storage.rs allows resource-exhaustion denial of service due to the unbounded prefix_iter_raw scan and full decode-and-sort of every record in a room without pagination or limits, resulting in service unavailability for large rooms.
Evidence: vtc-service/src/rooms/storage.rs:~330-355
let pairs = records.prefix_iter_raw(scan_prefix).await?;
let mut out = Vec::with_capacity(pairs.len());
for (_k, v) in pairs {
let record: Record = serde_json::from_slice(&v)...;
...
out.push(record);
}
out.sort_by_key(|r| r.version);
Attack Scenario:
- Attacker (or any caller, absent upstream rate limiting) creates or targets a room and writes an extremely large number of records via repeated
put_recordcalls (each call is individually cheap and passes validation). - Attacker then calls
list_records(records, room_id, key_prefix, since_version)on that room. - The function performs
records.prefix_iter_raw(scan_prefix).await?which fetches and buffers all matching key-value pairs into memory (let pairs = ...; let mut out = Vec::with_capacity(pairs.len());) with no page size, limit, or streaming cutoff. - Every record is deserialized with
serde_json::from_sliceand pushed intoout, then the entire vector is sorted by version (out.sort_by_key(...)), causing O(n log n) CPU plus O(n) memory proportional to attacker-controlled room size. - Repeated calls against a large room by multiple concurrent attacker sessions exhaust service memory/CPU, degrading or crashing the process and impacting all tenants sharing the deployment (noisy-neighbor DoS).
🔎 Threat Clue: Derived from COMP-003 via EP-006
- Data Flows: room_records prefix scan
Preconditions: Attacker can write many records to a room (bounded only by any quota enforcement upstream, which is not visible in provided code)., Attacker can invoke list_records repeatedly or on a very large room.
Existing Controls: Room-exact prefix scanning limits scope to a single room, preventing cross-room amplification. • None visible for pagination, size limits, or quota enforcement at this layer.
Recommended Mitigations: Add mandatory pagination (limit/offset or cursor-based) to list_records and reject unbounded requests. • Enforce a maximum record count per room and reject writes beyond quota at put_record time. • Add per-caller rate limiting and request timeouts at the dispatch/API layer. • Stream results instead of buffering the full result set in memory before sorting.
🟡 STRIDE-7: Missing Rate Limiting Enables Room and Record Creation Flooding
| Field | Detail |
|---|---|
| Category | Denial of Service |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.9 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-770,CWE-400 |
| CAPEC | CAPEC-125 |
| OWASP | A04:2021 - Insecure Design |
Description: create_room and put_record in vtc-service/src/rooms/storage.rs allow storage exhaustion denial of service due to absence of quota, rate limiting, or resource caps at the storage layer, resulting in unbounded keyspace growth and increased storage/backup costs.
Evidence: vtc-service/src/rooms/storage.rs:~28-36
pub async fn create_room(rooms: &KeyspaceHandle, room: &Room) -> Result<(), AppError> {
if rooms.get_raw(room_key(&room.room_id)).await?.is_some() {
return Err(AppError::Conflict(...));
}
rooms.insert(room_key(&room.room_id), room).await
}
Attack Scenario:
- Attacker with any function/API access repeatedly calls
create_roomwith distinct room_id values, each succeeding as long as the id is unused (no global rate limit or per-caller quota visible in this module). - Each successful create_room call permanently adds a
rooms:<roomId>entry that persists until retention_days lapses, and per module design, rooms are backed up (referenced ALL/backup census tests in keyspaces.rs) — meaning every junk room also consumes backup capacity. - Attacker further amplifies impact by writing many
put_recordcalls into each spam room, since put_record has no per-room record-count cap. - Storage and backup costs grow unbounded, degrading system performance and increasing operational costs, and potentially triggering downstream retention/lifecycle jobs to process large volumes of garbage data (amplifying the DoS to lifecycle/reclamation subsystems).
🔎 Threat Clue: Derived from COMP-003 via EP-001, EP-004
- Data Flows: rooms creation flood
Preconditions: No authentication/authorization control gates create_room or put_record at this layer., No quota system is visible in the reviewed code.
Existing Controls: retention_days field allows eventual reclamation of unused rooms, bounding long-term (but not short-term) growth.
Recommended Mitigations: Enforce per-owner-DID room creation quotas and rate limiting at the dispatch layer. • Require proof-of-work or CAPTCHA-equivalent friction for anonymous or low-trust room creation paths if publicly reachable. • Add global and per-tenant storage caps with monitoring/alerting on abnormal growth rates.
🟡 STRIDE-8: Author Field Spoofing in put_record Enables Attribution Forgery
| Field | Detail |
|---|---|
| Category | Spoofing, Tampering, Repudiation |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 6.3 CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-345,CWE-290 |
| CAPEC | CAPEC-194 |
| OWASP | A07:2021 - Identification and Authentication Failures |
Description: Record.author field accepted as caller-supplied input in put_record in vtc-service/src/rooms/storage.rs allows attribution forgery due to no verification that the caller invoking the write is actually the identity named in the author field, resulting in falsified audit trails on Open/Attributed tier rooms.
Evidence: vtc-service/src/rooms/storage.rs:~195-210
if matches!(room.visibility, super::Visibility::Private) && record.author.is_some() {
return Err(AppError::Validation(...));
}
// no check binding record.author to caller identity for Open/Attributed
Attack Scenario:
- Attacker with write access to an Open or Attributed room (see STRIDE-1 for how that access could be obtained without proper authorization) constructs a
Recordstruct with an arbitraryauthorfield value impersonating a legitimate member's identifier. - Attacker calls
put_record(rooms, records, room_id, record, expected_version, now); the function only validates content-shape rules relative to visibility (cleartext vs sealed) and the private-room author exclusion — it never cross-checksrecord.authoragainst any verified caller identity/credential. - The forged author value is persisted and later surfaced via
Record::metadata()("author": self.author) to all callers listing the room, and is also 'visible' per the Visibility table for Open/Attributed tiers. - Downstream consumers relying on the author field for accountability, per-member access logs, or dispute resolution (explicitly cited as the purpose of the Attributed tier: 'The tier for anyone under an obligation to produce per-member access logs') receive falsified attribution, and the legitimately named member can later repudiate wrongly-attributed content, or an attacker can frame a legitimate member for content they did not write.
🔎 Threat Clue: Derived from COMP-003 via EP-004
- Data Flows: put_record author field
Preconditions: Attacker has write access to a room (via STRIDE-1 bypass or a legitimately compromised low-privilege credential)., Room visibility is Open or Attributed (author is disclosed/stored)., No cross-check exists between record.author and a verified caller credential in the reviewed code.
Existing Controls: Private-tier rooms structurally reject an author field, limiting this threat's scope to Open/Attributed tiers only. • Design intends author verification to occur upstream via credential-based dispatch (not yet landed).
Recommended Mitigations: Once the Trust-Task dispatch layer lands, cryptographically bind the author field to the verified credential presented for the write, rejecting any mismatch. • Reject client-supplied author values entirely at the storage layer and instead derive author strictly from the verified caller identity passed in by the dispatch layer. • Add integrity signing over (key, version, author) tuples to make post-hoc forgery detectable even if bypassed once.
🟡 STRIDE-9: Insufficient Audit Logging of Sensitive Room Lifecycle Operations
| Field | Detail |
|---|---|
| Category | Repudiation |
| Severity | Medium |
| Likelihood | Likely |
| CVSS | 5.4 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:N/SC:N/SI:L/SA:N |
| Residual Severity | Medium |
| CWE | CWE-778,CWE-223 |
| CAPEC | CAPEC-268 |
| OWASP | A09:2021 - Security Logging and Monitoring Failures |
Description: advance_epoch, retract_record, and purge_record in vtc-service/src/rooms/storage.rs allow undetectable sensitive state changes due to no visible calls to the AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT keyspaces from within the rooms module, resulting in repudiation of key-epoch rotations and destructive record operations.
Evidence: vtc-service/src/rooms/storage.rs:~90-120
pub async fn advance_epoch(...) -> Result<Room, AppError> {
let mut room = get_room(rooms, room_id).await?;
...
rooms.insert(room_key(room_id), &room).await?;
Ok(room)
}
Attack Scenario:
- Attacker who has obtained illegitimate write access (via STRIDE-1) calls
advance_epoch(rooms, room_id, new_epoch, now)to force an unauthorized key-rotation event, which changes which ciphertext future records will be validated against. - No code in the reviewed rooms/storage.rs slice writes to the AUDIT, AUDIT_KEY, or AUDIT_CHECKPOINT keyspaces defined in vtc-service/src/store/keyspaces.rs, so this state change may go unrecorded in the tamper-evident audit chain described elsewhere in the system ('Signed audit checkpoints (VTC audit: signed checkpoints (tamper-evidence against a store-level adversary) #708) — periodic Ed25519-signed commitments').
- Similarly,
retract_recordtombstones content andpurge_recorderases it entirely, both without a visible audit-log call in this module. - Because the signed audit checkpoint mechanism exists elsewhere in the codebase but is not invoked from this module, an attacker's actions here are not necessarily bound into the tamper-evident chain, allowing the attacker (or a compromised operator) to later deny having performed the epoch rotation or deletion, undermining the non-repudiation goal the audit-checkpoint feature was built for.
🔎 Threat Clue: Derived from COMP-003 via EP-003, EP-007, EP-008
- Data Flows: rooms lifecycle -> audit (missing)
Preconditions: Attacker has write access to advance_epoch/retract_record/purge_record., No integration point between this module and the AUDIT keyspaces is present in the reviewed code (may exist in unincluded server.rs, which is flagged as missing data).
Existing Controls: AUDIT/AUDIT_KEY/AUDIT_CHECKPOINT keyspaces and Ed25519-signed checkpoint mechanism exist at the system level, suggesting an integration point may exist elsewhere (unverifiable from provided files).
Recommended Mitigations: Explicitly wire advance_epoch, retract_record, and purge_record to emit signed audit-checkpoint entries as part of the same logical transaction. • Add integration tests asserting that every destructive/security-relevant room operation produces a corresponding audit record. • Document and verify (once server.rs is available) that dispatch-layer calls into rooms::storage are always paired with an audit write.
🔵 STRIDE-10: Embedded Prompt-Injection-Style Markers in Source Diff Attempting Reviewer/AI Manipulation
| Field | Detail |
|---|---|
| Category | Tampering, Repudiation |
| Severity | Low |
| Likelihood | Possible |
| CVSS | 3.1 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:P/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-1039 |
| CAPEC | CAPEC-660 |
| OWASP | A03:2021 - Injection |
Description: The keyspaces.rs diff hunk in vtc-service/src/store/keyspaces.rs allows reviewer/tooling confusion due to embedded [REMOVED]-style comment markers that mimic redaction/guard banners around a changed assertion count, resulting in potential concealment of a functional test-value change from automated or human review.
Evidence: vtc-service/src/store/keyspaces.rs:~30-40
/* [REMOVED] /// `server::run` opens 21 keyspaces into 21 `*_ks` fields — if a */
/// `server::run` opens every one of them into a `*_ks` field — if a
/// keyspace is added to one without the other, this trips.
#[test]
fn all_matches_app_state_keyspace_count() {
/* [REMOVED] assert_eq!(A
Attack Scenario:
- The diff/source for vtc-service/src/store/keyspaces.rs contains inline comment blocks formatted as
/* [REMOVED] /// ... */immediately preceding the actual active line, mimicking a security-tool redaction banner rather than a normal code comment. - This pattern surrounds a semantically meaningful change: the expected keyspace count assertion changes from
25to27(assert_eq!(ALL.len(), 27, ...)), which is exactly the kind of test-value change a reviewer should scrutinize since it validates that server::run wiring matches AppState's keyspace fields. - If an automated review tool (including an LLM-based reviewer) is not carefully scoped to treat all diff content as inert data, such markers could be crafted to suppress or alter reporting of the underlying change, effectively hiding a functional/security-relevant modification inside what looks like tooling noise.
- In this instance the content was correctly treated as inert data per the security directive, but the pattern itself represents a viable technique for future supply-chain-style manipulation of automated review pipelines, and the underlying count change (25->27, presumably to account for the new ROOMS/ROOM_RECORDS keyspaces) should still be manually verified against server.rs (not provided) to ensure the keyspace-count invariant genuinely holds and was not the actual injection target.
🔎 Threat Clue: Derived from COMP-003 via N/A
- Data Flows: CI/code-review pipeline
Preconditions: An automated or human reviewer processes the raw diff without treating embedded pseudo-instruction text strictly as inert data., The change surrounded by the markers is itself unverified (server.rs was not provided, so the true keyspace count cannot be confirmed).
Existing Controls: This analysis pipeline enforces a security directive treating all such embedded text as data, not instructions, neutralizing the immediate manipulation attempt. • The change is inside a test assertion, limiting blast radius to CI failure detection rather than production behavior.
Recommended Mitigations: Flag and quarantine any diff content containing comment patterns resembling tool/guard banners (e.g., [REMOVED]) for mandatory manual review before merge. • Add CI linting to detect anomalous comment patterns that do not match the project's standard doc-comment style. • Independently verify the ALL.len() keyspace count against the actual AppState struct in server.rs before merging, since that file was not available for review here.
🟠 STRIDE-11: Epoch Advancement Race Condition Enabling Post-Removal Decryption Window
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | High |
| Likelihood | Possible |
| CVSS | 6.8 CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Medium |
| CWE | CWE-362,CWE-613 |
| CAPEC | CAPEC-26 |
| OWASP | A04:2021 - Insecure Design |
Description: advance_epoch and put_record in vtc-service/src/rooms/storage.rs allow a removed-member decryption window due to the non-atomic sequencing between epoch advancement and in-flight record writes sealed under the prior epoch, resulting in confidentiality loss where a just-removed member's key can still be valid for records the room owner believed were already protected by the new epoch.
Evidence: vtc-service/src/rooms/storage.rs:~95-115, ~148
pub async fn advance_epoch(...) -> Result<Room, AppError> {
let mut room = get_room(rooms, room_id).await?;
if new_epoch != room.epoch + 1 { ... }
room.epoch = new_epoch;
...
}
// put_record independently re-reads room and checks record.epoch != Some(room.epoch)
Attack Scenario:
- Room owner removes a member and calls
advance_epoch(rooms, room_id, new_epoch, now)intending that no further records can be sealed under a key the removed member possesses. - A concurrent legitimate (or attacker-controlled, if member removal was itself reactive to compromise)
put_recordcall that was already in flight — having read the room's old epoch viaget_roommoments before the epoch advance committed — proceeds to write a record withrecord.epoch != Some(room.epoch)validated against the stale in-memory room snapshot rather than the just-updated value. - Because
put_recordre-fetchesroominternally (let mut room = get_room(rooms, room_id).await?;) at the start of its own execution rather than sharing a single consistent snapshot with the epoch-advance caller, the exact ordering of these two operations relative to each other is not enforced by any lock, so a record can be validated against and stamped with the epoch value that was current a few milliseconds before removal, which the removed member may still be able to decrypt if they cached the room's pre-advance epoch key. - The window is narrow but real given the explicit non-atomicity acknowledged in the module's own documentation for the read-modify-write pattern, and its consequence directly undermines the stated security purpose of advance_epoch ('a repeat would let a removed member's key open material written after their removal — which is the whole point of advancing').
🔎 Threat Clue: Derived from COMP-003 via EP-003, EP-004
- Data Flows: advance_epoch <-> put_record race
Preconditions: A member removal / epoch advance is racing a concurrent put_record call on the same room., The removed member (or an attacker who obtained their key material) can observe or predict the timing window.
Existing Controls: put_record does validate that record.epoch == Some(room.epoch) at the time it executes, narrowing the window strictly to genuine in-flight concurrent requests rather than allowing arbitrary post-removal writes. • Module documentation explicitly acknowledges the non-atomicity, indicating design awareness (though not yet full mitigation).
Recommended Mitigations: Serialize epoch-advance and put_record operations per room using a lock or single-writer queue so no write can straddle an epoch boundary. • Have advance_epoch return a barrier/fencing token that in-flight writes must re-validate against immediately before commit. • Reduce the acceptable race window with short-lived optimistic locks (e.g., compare-and-swap on room version) covering both epoch and next_version fields atomically.
🟡 STRIDE-12: Missing room_id and key Input Validation Enables Keyspace Delimiter Injection
| Field | Detail |
|---|---|
| Category | Tampering, Information Disclosure |
| Severity | Medium |
| Likelihood | Possible |
| CVSS | 5.7 CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N |
| Residual Severity | Low |
| CWE | CWE-20,CWE-668 |
| CAPEC | CAPEC-267 |
| OWASP | A03:2021 - Injection |
Description: room_key/record_key construction in vtc-service/src/rooms/storage.rs allows keyspace boundary confusion due to no validation that room_id or key are free of the : delimiter used in the storage key format, resulting in potential cross-room record access or unintended key collisions.
Evidence: vtc-service/src/rooms/storage.rs:~18-27
fn room_key(room_id: &str) -> String {
format!("{ROOMS_PREFIX}{room_id}")
}
fn record_key(room_id: &str, key: &str) -> String {
format!("{RECORDS_PREFIX}{room_id}:{key}")
}
Attack Scenario:
- Attacker (with access to create_room or put_record, e.g., via STRIDE-1) supplies a
room_idvalue such asvictim-room:extraor akeyvalue containing a colon, e.g.,../other-room:secret. record_key(room_id, key)builds the storage key via simple string formatting:format!("{RECORDS_PREFIX}{room_id}:{key}"), with no validation rejecting colons or other structurally significant characters in either room_id or key.- Depending on the underlying KeyspaceHandle implementation's prefix-matching semantics (not shown in provided files), a crafted room_id/key combination could produce a storage key that collides with or shadows a different legitimate room's key namespace — the module's own comment concedes the trailing colon is 'what makes this room-exact' but does not describe input sanitization preventing a malicious room_id from itself containing the delimiter and forging a boundary.
- If the underlying store's prefix_iter_raw uses simple byte-prefix scanning (as list_records suggests), an attacker-crafted room_id containing a colon could cause list_records for a legitimate room to inadvertently match keys the attacker placed in an adjacent forged namespace, or vice versa — leaking or corrupting data across the intended room boundary.
🔎 Threat Clue: Derived from COMP-003 via EP-001, EP-004, EP-006
- Data Flows: room_key/record_key construction
Preconditions: Attacker can supply arbitrary room_id or key strings (via create_room/put_record, gated by STRIDE-1's broader authorization gap)., The underlying KeyspaceHandle store does not itself enforce delimiter-safety on keys (unverifiable from provided files; store implementation not included).
Existing Controls: Trailing colon on record_prefix is explicitly designed to make room-exact scans work correctly for well-formed room_ids. • Room_id is described as owner-minted, implying some upstream identifier format expectations may exist (unverified in this module).
Recommended Mitigations: Validate room_id and key against an allow-list character set (e.g., alphanumeric, hyphen, underscore) that excludes the : delimiter before constructing storage keys. • Use a structured/length-prefixed key encoding instead of naive colon-delimited string concatenation to make delimiter injection structurally impossible. • Add unit tests specifically asserting that a room_id or key containing a colon cannot cause cross-room key collisions.
🍝 PASTA Threat Model
Application Purpose
The vtc-service Data Rooms module provides a credential-authorized, portable, multi-tier-confidentiality shared storage layer for the OpenVTC Verifiable Trust Infrastructure, enabling members to exchange cleartext, attributed, or zero-knowledge-sealed records without the hosting service itself holding a membership roster.
Inherent Risks
- The module intentionally defers all membership authorization to an external, not-yet-landed Trust-Task dispatch layer, creating a window where storage functions have no enforced access control if exposed prematurely.
- Irreversible hard-delete (purge_record) functionality exists with an authorization path that cannot be verified from the available source.
- Non-atomic multi-keyspace writes (rooms + room_records) create inherent race-condition exposure under concurrent load.
- The zero-membership-list design is a deliberate architectural tradeoff that shifts all authorization risk onto the correctness of an external, unbuilt component.
Objectives
Risk: Accept the temporary absence of storage-layer authorization only if network exposure of these functions is strictly prevented until dispatch lands.; Treat any bypass of the future dispatch layer as a critical severity finding requiring immediate remediation.
Business: Enable portable, credential-governed data rooms that support enterprise and consortium data-sharing use cases without vendor lock-in.
Security: Ensure no room or record can be created, read, modified, or deleted without a verified membership credential once the dispatch layer lands.; Ensure sealed-tier content is never persisted in cleartext and vice versa.; Ensure destructive operations are fully auditable and non-repudiable.
Financial: Avoid storage and backup cost overruns from unbounded or abusive room/record creation.
Compliance: Support right-to-erasure/GDPR-style hard-delete requirements via purge_record with defensible audit evidence.; Maintain tamper-evident audit trails for room lifecycle and record deletion events consistent with the existing Ed25519-signed audit checkpoint mechanism.
Functional: Support three confidentiality tiers (Open, Attributed, Private) with correct content-shape enforcement per tier.; Provide monotonic per-room versioning for reliable incremental sync via sinceVersion watermarks.
Operational: Ensure room and record CRUD operations remain available and performant at scale.; Ensure destructive operations (retract, purge) behave predictably and are recoverable where intended (retraction) or clearly irreversible (purge).
Business Impact Analysis (3)
BIA-1: Data Room Authorization and Access Control (Critical)
The end-to-end process by which callers create, read, and write room records is gated (once completed) by the Trust-Task dispatch layer verifying membership credentials before any storage operation executes.
MTD: 00 days 04:00 hours | RTO: 00 days 02:00 hours | RPO: 00 days 00:15 hours
- Stakeholders: Compliance Officers / Data Room Members / Data Room Owners / Platform Operators / Trust-Task Dispatch Maintainers
- Dependencies: KeyspaceHandle Store Backend / Trust-Task Dispatch Layer (unlanded) / Verifiable Credential Verification Subsystem / vti_common Error/Store Abstractions
- Disruptions: Dispatch layer bypass or premature route exposure allowing direct storage-function calls / Malicious or accidental removal of the authorization gate during refactoring
- Impacts: Unauthorized data exposure across all confidentiality tiers / Regulatory exposure for unauthorized processing of member data / Loss of customer trust in the zero-roster security model
BIA-2: Record Lifecycle and Hard Erasure (High)
The process by which records are retracted (soft delete/tombstone) or purged (hard delete) to satisfy retention policy or erasure obligations while preserving sync and audit integrity.
MTD: 01 days 00:00 hours | RTO: 00 days 08:00 hours | RPO: 00 days 01:00 hours
- Stakeholders: Compliance Officers / Data Room Members / Data Room Owners / Legal/Regulatory Teams
- Dependencies: Audit Checkpoint Subsystem (Ed25519-signed) / KeyspaceHandle Store Backend / rooms::storage Module
- Disruptions: Unauthorized purge_record execution destroying evidence or legitimate data / Missing audit trail for destructive operations preventing compliance proof
- Impacts: Irrecoverable data loss / Failure to demonstrate compliant erasure to regulators / Broken incremental sync due to premature tombstone removal
BIA-3: Room and Record Storage Capacity Management (Medium)
The operational process ensuring room and record creation remains bounded by quota/rate limiting to prevent storage exhaustion and cost overrun.
MTD: 02 days 00:00 hours | RTO: 00 days 12:00 hours | RPO: 00 days 04:00 hours
- Stakeholders: Finance/Cost Management / Platform Operators / SRE/On-call Engineers
- Dependencies: KeyspaceHandle Store Backend / Retention/Lifecycle Reclamation Jobs
- Disruptions: Flooding attacks creating excessive rooms/records / Unbounded list_records scans causing memory exhaustion
- Impacts: Increased storage/backup costs / Service degradation or outage for all tenants
Technical Scope
Roles (4): RO-1 Room Owner · RO-2 Room Member · RO-3 Platform Operator · RO-4 Unauthenticated Caller
Actors (4): AC-1 Room Owner Client · AC-2 Room Member Client · AC-3 Trust-Task Dispatcher · AC-4 vtc-service Process
Entry Points (2): EP-1 create_room · EP-2 put_record / retract_record / purge_record
Threat Actors (4): TA-1 Unauthorized External Caller · TA-2 Malicious or Compromised Room Member · TA-3 Insider/Operator with Storage Access · TA-4 Resource-Exhaustion Attacker
Infrastructure (1): IF-1 vtc-service Deployment
Trust Boundaries (3): TB-1 External Caller Boundary · TB-2 Dispatch-to-Storage Boundary · TB-3 Application-to-Store Boundary
External Entities (2): EE-1 Room Owner (DID-controlled) · EE-2 Room Member Client
System Components (4): SC-1 rooms::storage Module · SC-2 KeyspaceHandle Store Backend · SC-3 Trust-Task Dispatch Layer (Unlanded) · SC-4 Audit Checkpoint Subsystem
Resources And Assets (3): RA-1 Room Metadata · RA-2 Room Record Content · RA-3 Signed Audit Checkpoints
Technologies And Dependencies (3): TD-1 serde / serde_json · TD-2 vti_common::error::AppError · TD-3 vti_common::store::KeyspaceHandle
Use Cases (2)
- Data Room Record Publication: A credentialed room member writes a new record into a data room, with the storage layer assigning a monotonic version and validating content shape against the room's fixed visibility tier before persi
- Room Key Epoch Rotation: A room owner advances the room's key epoch after removing a member, causing the service to record the new epoch number so future records are sealed correctly, without the service ever learning the enc
📋 Risk Registry (6)
| ID | Title | Severity | Residual | Priority | Effort |
|---|---|---|---|---|---|
| RISK-001 | Storage-layer functions are reachable and fully unauthorized before the Trust-Task dispatch layer lands | Critical | Critical | Immediate | Medium |
| RISK-002 | Irreversible hard-delete operation lacks verifiable authorization and audit trail | Critical | High | Immediate | Medium |
| RISK-003 | Concurrent writes across non-atomic rooms/room_records keyspaces can corrupt version integrity or epoch boundaries | High | Medium | Short-Term | High |
| RISK-004 | Open-tier cleartext storage creates a single point of exposure for sensitive content under operator or backup compromise | High | Medium | Medium-Term | Medium |
| RISK-005 | Unbounded room/record creation and unpaginated listing enable low-cost denial-of-service and cost-exhaustion attacks | Medium | Medium | Short-Term | Medium |
| RISK-006 | Diff content contains reviewer/tooling-manipulation-style markers that could be used to conceal functional changes in future submissions | Low | Low | Long-Term | Low |
⚔️ Attack Scenarios (3)
SC-1: rooms::storage Module
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC1@{ shape: rect, label: "SC-1: rooms::storage Module" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE862@{ shape: rect, label: "CWE-862: Missing Authorization" }
CWE362@{ shape: rect, label: "CWE-362: Race Condition" }
CWE778@{ shape: rect, label: "CWE-778: Insufficient Logging" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC1@{ shape: rect, label: "CAPEC-1: Access Control Bypass" }
CAPEC26@{ shape: rect, label: "CAPEC-26: Leveraging Race Conditions" }
CAPEC268@{ shape: rect, label: "CAPEC-268: Audit Log Manipulation" }
end
subgraph SL4["4. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Missing Authorization Enforcement<br><i>Critical / Very Likely</i>" }
S2@{ shape: rect, label: "STRIDE-2: Unauthenticated Hard Erasure<br><i>Critical / Likely</i>" }
S3@{ shape: rect, label: "STRIDE-3: Non-Atomic Version TOCTOU<br><i>High / Likely</i>" }
S9@{ shape: rect, label: "STRIDE-9: Insufficient Audit Logging<br><i>Medium / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Unauthorized External Caller<br><i>Gain unauthorized data access</i>" }
TA2@{ shape: rect, label: "TA-2: Malicious or Compromised Room Member<br><i>Forge attribution or destroy evidence</i>" }
end
SC1 --> CWE862
SC1 --> CWE362
SC1 --> CWE778
CWE862 --> CAPEC1
CWE362 --> CAPEC26
CWE778 --> CAPEC268
CAPEC1 --> S1
CAPEC1 --> S2
CAPEC26 --> S3
CAPEC268 --> S9
S1 --> TA1
S2 --> TA2
S3 --> TA1
S9 --> TA2
linkStyle 0 stroke:#A50000, stroke-width:2px
linkStyle 1 stroke:#FF0000, stroke-width:2px
linkStyle 2 stroke:#FFA500, stroke-width:2px
linkStyle 3 stroke:#A50000, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FFA500, stroke-width:2px
linkStyle 6 stroke:#A50000, stroke-width:2px
linkStyle 7 stroke:#A50000, stroke-width:2px
linkStyle 8 stroke:#FF0000, stroke-width:2px
linkStyle 9 stroke:#FFA500, stroke-width:2px
linkStyle 10 stroke:#A50000, stroke-width:2px
linkStyle 11 stroke:#FF0000, stroke-width:2px
linkStyle 12 stroke:#FF0000, stroke-width:2px
linkStyle 13 stroke:#FFA500, stroke-width:2px
SC-3: Trust-Task Dispatch Layer (Unlanded)
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC3@{ shape: rect, label: "SC-3: Trust-Task Dispatch Layer (Unlanded)" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE284@{ shape: rect, label: "CWE-284: Improper Access Control" }
CWE345@{ shape: rect, label: "CWE-345: Insufficient Verification of Data Authenticity" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC122@{ shape: rect, label: "CAPEC-122: Privilege Abuse" }
CAPEC194@{ shape: rect, label: "CAPEC-194: Fake the Source of Data" }
end
subgraph SL4["4. Threats"]
direction LR
S1@{ shape: rect, label: "STRIDE-1: Missing Authorization Enforcement<br><i>Critical / Very Likely</i>" }
S8@{ shape: rect, label: "STRIDE-8: Author Field Spoofing<br><i>Medium / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA1@{ shape: rect, label: "TA-1: Unauthorized External Caller<br><i>Gain unauthorized data access</i>" }
TA2@{ shape: rect, label: "TA-2: Malicious or Compromised Room Member<br><i>Forge attribution</i>" }
end
SC3 --> CWE284
SC3 --> CWE345
CWE284 --> CAPEC122
CWE345 --> CAPEC194
CAPEC122 --> S1
CAPEC194 --> S8
S1 --> TA1
S8 --> TA2
linkStyle 0 stroke:#A50000, stroke-width:2px
linkStyle 1 stroke:#FFA500, stroke-width:2px
linkStyle 2 stroke:#A50000, stroke-width:2px
linkStyle 3 stroke:#FFA500, stroke-width:2px
linkStyle 4 stroke:#A50000, stroke-width:2px
linkStyle 5 stroke:#FFA500, stroke-width:2px
linkStyle 6 stroke:#A50000, stroke-width:2px
linkStyle 7 stroke:#FFA500, stroke-width:2px
SC-2: KeyspaceHandle Store Backend
---
config:
layout: dagre
look: classic
theme: dark
---
flowchart LR
subgraph SL1["1. System Component"]
direction LR
SC2@{ shape: rect, label: "SC-2: KeyspaceHandle Store Backend" }
end
subgraph SL2["2. Weaknesses"]
direction LR
CWE312@{ shape: rect, label: "CWE-312: Cleartext Storage of Sensitive Information" }
CWE400@{ shape: rect, label: "CWE-400: Uncontrolled Resource Consumption" }
end
subgraph SL3["3. Attack Patterns"]
direction LR
CAPEC116@{ shape: rect, label: "CAPEC-116: Excavation" }
CAPEC125@{ shape: rect, label: "CAPEC-125: Flooding" }
end
subgraph SL4["4. Threats"]
direction LR
S4@{ shape: rect, label: "STRIDE-4: Cleartext Storage in Open Rooms<br><i>High / Likely</i>" }
S6@{ shape: rect, label: "STRIDE-6: Unbounded Scan Resource Exhaustion<br><i>Medium / Likely</i>" }
end
subgraph SL5["5. Threat Actors"]
direction LR
TA3@{ shape: rect, label: "TA-3: Insider/Operator with Storage Access<br><i>Exfiltrate cleartext content</i>" }
TA4@{ shape: rect, label: "TA-4: Resource-Exhaustion Attacker<br><i>Degrade or disable service</i>" }
end
SC2 --> CWE312
SC2 --> CWE400
CWE312 --> CAPEC116
CWE400 --> CAPEC125
CAPEC116 --> S4
CAPEC125 --> S6
S4 --> TA3
S6 --> TA4
linkStyle 0 stroke:#FF0000, stroke-width:2px
linkStyle 1 stroke:#FFA500, stroke-width:2px
linkStyle 2 stroke:#FF0000, stroke-width:2px
linkStyle 3 stroke:#FFA500, stroke-width:2px
linkStyle 4 stroke:#FF0000, stroke-width:2px
linkStyle 5 stroke:#FFA500, stroke-width:2px
linkStyle 6 stroke:#FF0000, stroke-width:2px
linkStyle 7 stroke:#FFA500, stroke-width:2px
📊 Risk Summary
Total Threats: 12
By Severity: Low: 1 · High: 3 · Medium: 6 · Critical: 2
By Category: Spoofing: 2 · Tampering: 7 · Information Disclosure: 5 · Elevation of Privilege: 2 · Repudiation: 4 · Denial of Service: 3
🎯 Attack Surface
Kill Chain 1: An attacker who discovers any code path invoking rooms::storage functions directly — whether an internal RPC exposed before the Trust-Task dispatch layer lands, a test harness left reachable in production, or a future misrouted handler — can call create_room and put_record with zero credential verification (STRIDE-1), enumerate valid room_ids via the NotFound/Conflict error oracle in get_room (STRIDE-5), then chain into cleartext exfiltration on Open-tier rooms (STRIDE-4) or forged authorship on Attributed-tier rooms (STRIDE-8), fully compromising the confidentiality and integrity guarantees the zero-membership-list design was meant to preserve. Kill Chain 2: Building on the same authorization gap, an attacker escalates from read/write access to destructive capability by invoking purge_record (STRIDE-2), whose truncated implementation shows no visible authorization or audit-logging call, allowing permanent evidence destruction that is compounded by the absence of any wiring to the Ed25519-signed audit checkpoint subsystem (STRIDE-9) — meaning the attack may leave no tamper-evident trace, defeating downstream compliance and forensic review. Kill Chain 3: Independent of the authorization gap, a purely concurrency-based attacker (or an unlucky legitimate high-throughput client) can win races in the non-atomic rooms/room_records read-modify-write to corrupt version monotonicity (STRIDE-3) or straddle an epoch-advance boundary to keep a just-removed member's key valid for freshly-written records (STRIDE-11), directly undermining the key-rotation security guarantee the module documentation states is 'the whole point of advancing.' Kill Chain 4: A resource-focused attacker with no authorization at all can flood create_room/put_record to exhaust storage and backup budget (STRIDE-7) and then trigger unbounded prefix_iter_raw scans via list_records against artificially large rooms (STRIDE-6) to exhaust service memory/CPU, producing a low-cost, high-impact denial of service against all tenants sharing the deployment.
🛡️ Risk Mitigation Strategy
Priority 1 (Immediate): Close the storage-layer authorization gap before any network-reachable route can invoke rooms::storage functions — restrict visibility to pub(crate), add merge-blocking CI checks preventing direct wiring into server.rs ahead of the Trust-Task dispatch layer (issue #346), and require explicit security sign-off for any PR that exposes these functions externally; this single control eliminates the root cause behind RISK-001, RISK-002, and the author-spoofing component of RISK-003. Priority 2 (Immediate): Treat purge_record as a distinct, elevated-privilege operation requiring its own credential scope and an atomic, mandatory audit-checkpoint write, since its current truncated implementation and absent audit wiring represent the highest-impact, least-recoverable failure mode in the module — verify the complete function body and add regression tests asserting no successful purge can occur without a corresponding signed audit entry. Priority 3 (Short-Term): Address the concurrency gaps in put_record and advance_epoch by introducing per-room locking or compare-and-swap transactions spanning the rooms and room_records keyspaces, closing both the version-collision TOCTOU and the epoch-boundary race that could let a removed member's key remain briefly valid — these are architecturally deeper fixes than Priority 1/2 and warrant dedicated design review before implementation. Priority 4 (Medium-Term): Layer independent at-rest encryption under the Open-tier cleartext storage path and add pagination/quota controls to list_records, create_room, and put_record to blunt both insider-exposure risk and low-cost denial-of-service flooding, since these controls provide defense-in-depth even after the primary authorization gap is closed. Priority 5 (Long-Term): Harden the code-review and CI pipeline against embedded reviewer/tooling-manipulation patterns (e.g., pseudo-redaction comment markers) observed in the keyspaces.rs diff, ensuring future contributions canno
Generated by Agentic Sec — Threat Model & Affect Analysis Agent
📊 Summary & findings
| ✅ Confirmed | |
|---|---|
| 3 | 2 |
Confirmed (3)
- 🟡 Non-atomic read-modify-write on room/record version counter allows race condition (triaged HIGH→MEDIUM)
- 🟡 Unauthenticated/unverified hard erasure via purge_record (function body truncated, no visible authorization or audit gate) (triaged HIGH→MEDIUM)
- 🟡 Unbounded record listing without pagination enables resource exhaustion
Must-Review-By-Human (2)
- 🟡 No authorization enforcement in room storage layer (dispatch layer not yet implemented) (triaged CRITICAL→MEDIUM)
- 🟡 Missing authorization/credential verification in rooms::storage public functions (create_room, get_room, advance_epoch, put_record, get_record, list_records, retract_record) (triaged CRITICAL→MEDIUM)
Completes the open tier: rooms/{create,records/{put,get,list},epoch/mint}
now dispatch, on top of the storage layer this branch already carries.
I had called this blocked on the schemas publishing, and it is not. The
schema gate is a #[cfg(test)] conformance sweep scoped to spec/vtc/, and
this family is top-level spec/rooms/ - so the sweep does not cover it -
while parse_payload is generic over any DeserializeOwned and needs no
generated type at all. The wire types are hand-written in rooms::wire
against the schemas in dtgwg-trust-tasks-tf#346, the same shape
vta_sdk::protocols already uses for the VTA's families. When those
bindings publish, replace them rather than keeping both: two definitions
of one wire format is how casing drift gets in.
rooms::authz holds the invariant the whole design rests on. Nothing in it
reads this service's ACL, roster, or the caller's session - a room
operation is authorized by the chain the room itself issued, which is what
makes a room portable. The handlers take no AuthClaims, and that absence
is the point. AuthorizedAction is constructible only by authorize(), so a
handler cannot skip the check or substitute its own.
Deliberately refuses the sealed tiers for now. Cryptographic chain
verification needs the room's DID resolved to a verification method, which
arrives with attributed; until then serving a sealed room would mean
serving one whose chain nobody checked. Better to refuse, loudly, than to
let a caller mistake an unverified chain for a verified one - and there is
a test pinning that refusal so it cannot be relaxed by accident.
The private tier's subject-binding requirement is enforced now even though
the tier is refused, because it is a schema-level property: without it two
parties pool credentials, one contributing membership and the other
authority, and the combination verifies as a single party holding both.
The dispatcher census test now lists the rooms URIs with a note that they
name hand-written constants rather than generated TYPE_URIs, so the
property it guarantees is weaker for them until #346 publishes - stated in
the test rather than left for a reader to infer.
14 new tests across authz and handlers; 950 lib + 104 integration passing;
clippy clean.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Makes the open tier usable end to end: create_room, put_record, get_record, list_records and mint_epoch on VtcClient, plus a RoomSession that carries a caller's standing in one room. Every other method on this client carries an operator token - the community knows who you are and its ACL decides what you may do. A room call carries none. It carries a presentation, and the host decides from that alone. RoomSession therefore holds credentials rather than a session, and room_task is the single place a room call is made so the no-token property is visible in one function rather than repeated across five. RoomSession refuses an empty chain and one deeper than 8 at construction, so a caller learns locally instead of from a rejected request, and the ceiling mirrors the host's because verification is linear in chain length and runs on every operation. The agent case is modelled by construction rather than by a flag: a member's session and their agent's are built identically and differ only in the chain they carry - depth 1 for a grant straight from the room, depth 2 once attenuated. A test pins that, because if the difference ever becomes a parameter on this type rather than a property of the credentials, the design has drifted. subject_binding is attached explicitly via with_subject_binding and omitted rather than serialised as null when absent: a host distinguishes absent from present-but-empty, and on a private room its absence is a refusal rather than a default. 6 tests covering chain-shape refusal, the member/agent distinction, and the camelCase wire shape a host actually reads. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
|
Extended: the open tier is now complete end to end, client included. Added since the first pushDispatch ( Client ( The property worth reviewing forEvery other method on On the service side the same invariant has two structural defences: handlers take no The agent case is modelled by construction rather than a flag — a member's session and their Still deliberately refusedSealed tiers. Chain verification needs That is the one line to change when 0.6.0 publishes, and it is the whole of what
|
Two built-ins the data-rooms design needs, and neither is a new mechanism: provision-integration already mints DIDs from templates, so a room and a room host are integration kinds rather than bespoke paths. room is a data room's own identity. A room is a DTG node, not a row in a host's table: it holds a DID, issues the VIC/VMC/VAC credentials that govern it, and can be messaged. did:webvh rather than did:peer, and that choice is load-bearing - transferring a room is a controller change, and did:peer encodes its keys in the identifier so it can never have one. The DIDComm service is what makes an invitation, a join, or an epoch notice able to reach the room, and WITNESSES is offered because a witnessed log makes a host that serves a stale or suppressed update evident rather than merely possible. room-host is the service that stores records. It exists as a template because a person hosting their own rooms (topology T1) must not do so by adding stranger-facing surface to the VTA: the process guarding the master seed should not also terminate presentations from arbitrary DIDs. A room-host holds ciphertext and verifies presentations - no member list, no room keys - so its ACL entry should be scoped to exactly the oracle verbs it needs, which the description says so an operator provisioning one is told rather than left to infer. BUILTIN_NAMES stays alphabetical; the existing builtin census test validates both new templates without modification. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The group-key half of the attributed tier, behind an off-by-default mls feature so a caller that only reads an open room carries none of OpenMLS - the same gating vtc-service uses for its bbs verifier. An earlier draft hand-rolled this as one symmetric key per epoch sealed to each member on every change. MLS standardises all of that and adds two things that are not optional for a system expected to outlive a compromise: post-compromise security, so a stolen member key stops working at the next commit rather than reading every future epoch until someone notices; and O(log n) membership change, where fan-out is O(n) - fine for five members, wrong for a roster-sized room. The mapping is the design's own shape, which MLS arrived at independently: the DTG is the Authentication Service, the room's host is the Delivery Service trusted for availability only, an MLS epoch is the room epoch the host stores, and a commit is a membership change. One leaf per member - their VTA - so devices and agents hang off it through the oracle model rather than joining the group, which sidesteps MLS's multi-device complexity entirely. Storage keys come from the exporter under a room-specific label rather than from the group's own message keys, following the pattern draft-sullivan-mls-attachments uses after SFrame: a change to how records are sealed cannot then weaken the group's messaging, or the reverse. epoch_authenticator is exposed because it is what gets anchored in the room's witnessed DID log. A host acting as Delivery Service can fork a group - showing different members different commit sequences - and members cannot detect that by comparing through the host, since the host is what they would compare through. An anchor the host cannot forge is what gives each member something to check against. Six tests, and the two that matter are behavioural: two members derive an identical storage key without it ever crossing the host, and removing a member provably changes that key - so forward-only removal is demonstrated rather than asserted. Default build and its 13 tests are unchanged; 19 with the feature on. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
|
Two more layers since the last update. The branch now carries the open tier complete, plus DID templates —
|
Connects the MLS layer to the task surface: SealedRoom pairs a RoomSession - the credentials a host authorizes against - with a RoomGroup - the key material a host never sees. Those are two different things, and this type is the only place they meet. Each record's AEAD associated data commits to roomId|key|version|epoch, so a host that relocates a sealed record to another key, version, epoch or room produces an authentication failure rather than a readable record. It holds every byte and still cannot move one, which is what makes an untrusted host tolerable. Same class of defence vti_common's store encryption already applies by binding values to their (keyspace, key) location - repeated deliberately, because the reasoning was paid for once. One wrinkle worth stating rather than hiding: a record's version is assigned by the host, so a writer does not know it when sealing. seal_record therefore binds the version the writer intends, and a caller that lets the host assign a different one finds the record does not open. That is the correct failure - accepting whatever came back would mean the binding commits to nothing - and the practical shapes are create-only writes or a read before a rewrite, both of which the task surface already supports. room_epoch() converts MLS's 0-based epoch to the room's 1-based one in a single place: an off-by-one here would seal records under an epoch the host rejects, and the failure would read as a key problem rather than an arithmetic one. opaque_key() mints the random keys sealed tiers require, since a descriptive key is readable by the host and defeats the encryption beside it. Six tests, and three carry real properties: a relocated record does not open (four ways - key, version, epoch, room), a non-member cannot open one however identical their session looks, and sealing the same plaintext twice uses a fresh nonce, because a reused one would break the AEAD outright. 25 tests with the feature on; the default build and its 13 are unchanged. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
|
Sealed-tier client integration added — the MLS layer now meets the task surface.
|
The decomposition note asks for a named build-time or testability win before any extraction, and says the spine is not extractable. Both apply here, in opposite directions. The win is a second consumer. A room host - someone hosting their own rooms on their own infrastructure, topology T1 - stores ciphertext and verifies presentations. Without this crate, doing that means shipping an entire community service: member lifecycle, policy, credential issuance, a website, an admin SPA. With it, a room host is this crate plus a dispatch surface. That these three modules extract cleanly is not luck, it is invariant I5 restated as a dependency graph. Because a room is authorized by credentials the room itself issued, the code deciding a room operation cannot need a roster, a policy engine, or a session store - so it does not have one, and the new crate depends on vti-common and nothing else. The compiler now enforces what was previously a convention. The Trust-Task handlers deliberately stay in vtc-service. Dispatch is a service's spine, and the note is explicit that a spine is not extractable; each host writes its own thin handlers over these three layers. The keyspace names move with the storage that uses them, and vtc-service's registry re-exports them rather than declaring a second copy - two hosts naming the same keyspace differently could not serve the same room's data directory. No behaviour change: all 18 tests moved with their modules and pass unchanged, and vtc-service's suite is 932 where it was 950, the difference being exactly the 18 that left. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Track 2.5, and the thing the vti-rooms extraction was for. A room host is a delivery service: it holds records and answers rooms/* against them. It has no member roster, no policy engine, no credential issuance and no admin surface. That is structural rather than minimal. A room is authorized by credentials the room itself issued, so a host keeping its own record of who belongs would become part of that room's membership and the room could no longer move elsewhere without reissuing credentials. The absence of a roster is the portability guarantee - there is nothing in this binary that could consult one. Topology T1 is a person hosting their own rooms on infrastructure they control. Before the extraction that meant running a whole community service - member lifecycle, policy, credentials, a website, an admin SPA - to store some ciphertext. This is the same room protocol with none of it, and it is why the extraction had a win to name. It is also deliberately not part of the VTA: the process guarding a master seed should not also terminate presentations from arbitrary DIDs, which is why the design provisions a room host as an ordinary integration with its own DID (the room-host template added earlier on this branch). Sealed tiers are refused by vti_rooms::authz rather than by anything here, and a test asserts the refusal reads identically to the VTC's - so two hosts of the same room cannot disagree about what is safe to serve. That shared decision is the reason authz is in the crate and not in either service. Five tests over the real router: a record round-trips, an operation with no chain is refused, sealed tiers are refused with the VTC's own message, a listing carries metadata and never bodies, and a task from another family is not served - a room host serves rooms and nothing else. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
cargo-deny went red on RUSTSEC-2026-0173: proc-macro-error2 2.0.1 is unmaintained, the author has said so, and recommends migrating away. It is build-time only and twice removed from anything shipped. It is a proc-macro, so it runs in the compiler and links into no artifact, and its only path into this workspace is vtc-client's optional, off-by-default mls feature: openmls_rust_crypto -> hpke-rs -> libcrux-sha3 -> hax-lib -> hax-lib-macros. A default build of every crate here does not resolve it at all. There is also no version of this to pick differently. OpenMLS 0.9 has one production crypto provider and that is what it depends on; the alternative is not a cleaner MLS but a hand-rolled group-key scheme, which the data-rooms design note declines on the grounds that post-compromise security and O(log n) membership change are not things to reimplement. The entry names what would let us drop it, as every other entry in the list does. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…the list The nitro image build failed on error: failed to select a version for the requirement `vti-common = "^0.0.1"` required by package `room-host v0.0.1 (/build/room-host)` Dockerfile.nitro copies a hand-maintained list of member directories rather than COPY . ., so that editing deploy/nitro/config.toml does not invalidate the Rust build layer. vti-rooms and room-host were added to the workspace members and not to that list, so cargo-chef's 0.0.1 skeleton stub survived into the real build and its path dependencies could not resolve. Both are now copied. The error names none of that, and it arrives six minutes into a Docker build while every other check in CI is green. So the second half of this adds a census test: workspace members against the COPY list, in both directions. A missing member fails in a second with the exact line to add; a stale COPY fails too, because copying a directory nothing compiles invalidates the layer for a path nothing reads, which is the cost the hand-maintained list exists to avoid. Same idiom as the keyspace and retry_safety censuses - a list that must not drift from the thing it describes, pinned by a test rather than by remembering. Verified by deleting the room-host line and watching it fail. Also moves room-host into alphabetical order in members. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
The data_room example is Track 1 of the delivery plan, and the first
thing it did was fail on every call:
unexpected room response (not a Trust Task document): missing field `id`
room-host was answering with a bare payload while vtc-client parses the
reply as a routed document - which is what the VTC has always returned,
through the shared success_response/reject_with helpers. Two hosts of the
same protocol disagreeing about its wire form is the defect the demo
existed to find, and it would not have shown up in room-host's own tests
because they asserted against the shape they sent.
So the dispatch now parses TrustTask<Value>, answers with respond_with,
and refuses with reject_with under a RejectReason mapped from AppError
exactly as vtc-service maps it - NotFound and Conflict to TaskFailed
rather than InternalError, because a room that does not exist is a
caller-visible outcome and InternalError would tell them to retry. An
unroutable body (not a document at all) is the one unrouted error, as it
is on the VTC: there is no issuer to address and no thread to correlate.
An unknown task comes back as unsupportedType naming the URI, so a client
can tell a task this host does not implement from one it refused.
The five tests now build real documents and read the response payload
out, which is the shape a client actually sees.
The example itself runs the real router on a real socket and drives it
with vtc-client's real methods:
Act I - a room registered, two memories written, an agent reading
under a chain one link longer and read-only, and a caller
with no chain refused by the host on its own reading
Act II - a real MLS group, a record sealed under the exporter key,
Bob opening it, an outsider with a valid group of her own
refused, and the record failing to open at another key,
another version and another room
Act III - the seam, shown rather than hidden: a private room registers
and then refuses to be read, because chain verification is
not wired
Act III is honest about where this stands. Joining Acts I and II - sealed
records through a host - needs dtg_credentials::authority::verify_chain,
which needs dtg-credentials 0.6 on crates.io; that repository now has a
release path (dtg-credentials#17) and the tag is the remaining step.
Splitting room-host into a lib and a thin binary is what lets the example
and the tests drive the same router without a socket.
Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
vti_rooms::wire hand-rolls the rooms/* bodies, and the specs are now published and generated into trust-tasks-rs 0.17.7 - two descriptions of one wire form with nothing making them agree. The VTC's conformance sweep does exactly this job but is scoped to spec/vtc/ and says so in its own module docs; rooms publishes at spec/rooms/, outside it. This is the rooms family's equivalent. It found two real drifts on its first run, both in Record::metadata(): updatedAt was a unix integer. The schema types it string/date-time, which is also what every other timestamp on the wire already is (issuedAt on the documents carrying these payloads). The spec is right and the implementation moved: storage keeps unix seconds and the projection renders RFC 3339. Absent optionals were emitted as null. RecordMetadata is additionalProperties: false with epoch typed integer and author typed string, so a tombstone - no author - produced a document that failed validation. An absent member has to be absent. Neither was visible to any existing test, because both sides of a round-trip used the same struct. That is the whole reason the check has to be a schema and not serde: serde ignores const, enum, pattern and minLength, and accepts null into an Option<T> the schema types string. It is the same class as the casing drift that let an empty allowed_contexts mint a super-admin. The listing test runs Record::metadata() rather than a hand-built literal - a literal written beside the projection only ever agrees with itself, and both drifts were in the projection. The projection also now carries title and description on the open tier, which the schema allows and the listing was dropping. It deliberately does not assert the hand-rolled types are identical to the generated ones. They differ on purpose: the generated payloads use newtypes and NonZeroU64 where a storage layer wants plain strings and u64. Agreeing on the wire is the requirement. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Track 2.3. Rooms authorized on shape alone before this: a chain was counted, bounded and checked for a subject binding, and then believed. Sealed tiers were refused outright rather than served on that. The split. vti-rooms keeps the shape checks - presence, depth, membership, the private-tier binding - and reaches the cryptography through a ChainVerifier trait. Not squeamishness about dependencies: verify_chain and proof verification need a credential library and a DID resolver, and the resolver is a different thing on a VTC than on a standalone host. Pinning either choice into vti-rooms would make the storage layer un-reusable for the other, and the seam is what lets ONE decision about what is safe to serve be shared by hosts that resolve DIDs differently. A host that configures no verifier gets RefusesEverything and serves nothing, on every tier - the only safe default a seam like this can have. vti-rooms-dtg is the verifier. Two independent checks and neither substitutes for the other: each credential's proof must verify against the key its verificationMethod names, and the chain must add up to the authority claimed. Doing only the first is the classic mistake - anyone can mint a well-formed VAC naming any scope and any action, and it verifies perfectly as a credential; what makes it worthless is that its chain does not reach the room. governing_party and requested_scope are both the room's DID, which is invariant I5 in one line. It is publish = false, and that is the point: dtg-credentials 0.6 is merged but unreleased, so the git dependency lives in the one crate that can carry it. Swap one line for a registry version when the tag publishes; nothing else changes. Two findings, both from tests that failed the first time they ran. The pooling defence compares the chain's ROOT subject, not its leaf. The first version compared the leaf and refused every agent - correctly by its own rule, because an agent is not a member of anything. Its human is. The root is the grant the room made, so its subject is the member whose standing the chain descends from, and verify_chain has already established that each link's issuer is its parent's subject. Comparing the root admits the agent and still refuses the attack: a chain rooted at Bob cannot be presented with Alice's membership, whoever holds the leaf. A presentation is bound to its presenter, not bearer. verify_chain takes a presenter but uses it only for the audience check on links that name one - binding the leaf is the verifier's job, and a verifier assuming otherwise would authorize every captured presentation. Rooms check it twice, in the verifier and again in authorize, so the property does not depend on every future implementation remembering it. A test presents an agent's chain as the agent's human and expects the refusal. The presenter comes from the request document's own eddsa-jcs-2022 proof, never from a payload field. room-host gains --resolve-dids for it: network resolution is off by default because turning it on means an unauthenticated request can make the host fetch, which is a decision an operator makes rather than one they inherit. Private rooms are still refused, by a SubjectBindingVerifier seam with no implementation shipped. That is the honest position rather than a gap: the subject is withheld by design, so the same-subject property has to be proved in zero knowledge, and which proof is a profile the DTG cred-spec puts explicitly out of scope. A private room whose pooling defence nobody checked is worse than one that will not open. Tests are real keys, real signatures, real chains - a shared fixture behind a feature, because this crate, room-host and the example all need the same setup and three hand-rolled versions would drift into passing for the wrong reason. Every earlier test asserted a refusal, which a verifier that refused everything would also pass. The example now runs the whole path: a record sealed under the MLS exporter key, stored through the host, fetched back and opened by another member, with the host holding every byte and unable to read or move one - and an agent reading under a four-hour read-only chain, refused a write, and refused again when its human replays the presentation. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
50e776a to
ef26959
Compare
Every backup test failed with backup: no AppState handle for keyspace 'rooms' BACKED_UP and backed_up_handle are two lists of the same thing and nothing makes them agree - the match ends in _ => return None, so a keyspace added to the partition and not to the map compiles fine and fails at backup time. That is a runtime error on the one operation an operator reaches for when something has already gone wrong. A room's records are backed up like any other community state. On a sealed tier they are ciphertext this service cannot read, which is no reason to skip them: the host is trusted for availability (invariant I2), and losing the ciphertext loses the room exactly as completely as losing plaintext would. The second half pins the lists in both directions, the way the partition census already pins the partition. A keyspace in BACKED_UP with no handle fails in seconds naming itself; a handle for a keyspace nothing backs up fails too, because dead code there reads as coverage. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…#1241) A VTA must not depend on a VTC client, and rooms/keys/open needs both layers inside vta-service - the VTA is what holds the principal's keys and opens a record on an agent's behalf. Today they live in vtc-client, so that task could not be written at all without a layering inversion. vti-rooms is already the shared home for the parts of a room that are not a service, and it is where the ciphertext is stored. Putting the AEAD binding beside the storage layer that binds to it closes the other half of the argument: the associated data commits to roomId | key | version | epoch, and until now the code that seals and the code that stores those four fields were in different crates. That is how a binding drifts. Both live behind an mls feature, off by default, so a host that only stores ciphertext still compiles no OpenMLS. SealedRoom no longer holds a RoomSession. It holds the room id and the group - and the separation is the honest shape rather than a concession to the move: the credentials a caller presents travel to the host on every request, and the keys never travel anywhere. Pairing them made a client the only place a room could be opened. The move surfaced a duplication that was invisible while it compiled. vtc-client defined its own Visibility, AuthorityPresentation, SealedContent, CleartextContent and three response types, plus all five Type URI constants - identical to vti-rooms' and with nothing checking they stayed identical. The schema-conformance suite added with the open tier validates vti-rooms' copies against the published schemas and could not see the client's at all, so those could have drifted freely. They are re-exports now, which makes the suite cover both. RoomKeyError replaces VtcError for this layer, deliberately not vti_common::AppError: a record that does not open is a legitimate outcome with a specific meaning, and folding it into Internal would say 'this service is broken' about the one case the design most wants to be loud - a host relocated a record. vtc_client::rooms::{mls, sealed} are re-exports, so a caller that had vtc-client with mls is unaffected. Nothing has shipped on that feature yet in any case - it merged in #1237 and has not been released. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
…1273) Data rooms shipped across nine PRs (#1237-#1251) with no operator documentation: the doc set was a design note, two security reviews, and a concept page, none of which tells anyone how to create a room or run a host. This adds the missing guide, written against the implementation rather than the design note. `docs/02-vta/data-rooms.md` covers all four topologies end to end - the pieces a room is made of, choosing a topology and a visibility tier, creating the room (identity, registration, the owner's own credentials, the MLS group), adding a member, equipping an agent through the oracle, using records, renewal, transfer and claim, running each kind of host, a refusal-to-cause table, and a reference of task URIs, gates and constants. `docs/02-vta/data-rooms-guide.html` is the same path as a readable page, in the concept document's design language. Verifying each claim against the code turned up four things worth stating rather than smoothing over, and the guide names all of them: `rooms/create/0.1` is authorized by nothing on either host, so anyone who can reach the endpoint can register a room row; `Role::Application` carries neither `roomPresent` nor `roomOpen`, so the role agents run as cannot use the oracle built for agents; no installer consumes a `room` or `room-host` bundle, so the room's signing key has to be extracted by hand; and the room epoch is the MLS epoch plus one, which is the off-by-one that reads as a key failure. Both HTML pages and the design note also carried a stale status - the note still opened with "Nothing is implemented" while its own section 10 described succession as implemented. All three now say what is built and what is not. Signed-off-by: Glenn Gore <glenn.g@affinidi.com>
Data rooms, end to end: shared credential-governed spaces for people and their AI agents, with the host trusted for availability and nothing else.
Design note:
docs/05-design-notes/data-rooms.md. Schemas:dtgwg-trust-tasks-tf#344 / #346 / #349, released intrust-tasks-rs0.17.7.What works
cargo run -p room-host --example data_roomruns all of it against a real host over HTTP:readfor four hours — and is refused a write.The shape
vti-rooms(published) — storage, wire types, and the shape half of authorization: presence, depth, membership, the private-tier binding. Depends only onvti-common, so a room host can reuse it without a credential library or a DID resolver.vti-rooms-dtg(publish = false) — the cryptographic half, behind aChainVerifiertrait. Two independent checks, and neither substitutes for the other: every credential's proof must verify against the key itsverificationMethodnames, and the chain must reach a root the room issued. Doing only the first is the classic mistake — anyone can mint a well-formed VAC naming any scope and any action.The seam is not squeamishness about dependencies. Verification needs a resolver, and the resolver is a different thing on a VTC than on a standalone host; pinning either choice into
vti-roomswould make the storage layer un-reusable for the other. It is what lets one decision about what is safe to serve be shared by hosts that resolve DIDs differently — and a host that configures no verifier getsRefusesEverythingand serves nothing, on every tier.room-host(publish = false) — a host with no roster, no policy engine, no credential issuance and no admin surface. That absence is structural: a host keeping its own record of who belongs would become part of the room's membership, and the room could no longer move without reissuing credentials.vtc-client—RoomSession, the five room methods (none of which readsself.token), and the MLS + sealing layers behind anmlsfeature.Two findings, both from tests that failed the first time they ran
The pooling defence compares the chain's root, not its leaf. The first version compared the leaf and refused every agent — correctly by its own rule, because an agent is not a member of anything. Its human is. The root is the grant the room made, so its subject is the member whose standing the chain descends from. Comparing the root admits the agent and still refuses the attack: a chain rooted at Bob cannot be presented with Alice's membership, whoever holds the leaf.
A presentation is bound to its presenter, not bearer.
dtg_credentials::authority::verify_chaintakes apresenterbut uses it only for theaudiencecheck on links that name one — binding the leaf is the verifier's job, and a verifier assuming otherwise would authorize every captured presentation. Checked twice, in the verifier and again inauthorize, so the property does not depend on every future implementation remembering it.Defects this branch found and fixed
room-hostanswered with bare JSON wherevtc-clientparses a routed Trust Task document — two hosts of the same protocol disagreeing about its wire form, invisible to room-host's own tests because they asserted the shape they sent.updatedAtwas a unix integer; the published schema types itstring/date-time. Alsonullfor absent optionals underadditionalProperties: false. Both inRecord::metadata(), both invisible to serde — there is now a schema-conformance suite for the rooms family, since the VTC's sweep is scoped tospec/vtc/.vti-common = "^0.0.1". Fixed, plus a census so the next one fails in a second naming the line to add.no AppState handle for keyspace 'rooms'—BACKED_UPandbacked_up_handleare two lists of the same thing with nothing making them agree. Fixed, and pinned in both directions.What is deliberately not done
A
privateroom is refused, and the demo shows the refusal. Its subject is withheld by design, so the same-subject property has to be proved in zero knowledge — and which proof is a profile the DTG cred-spec puts explicitly out of scope.SubjectBindingVerifieris the seam, with no implementation shipped. A private room whose pooling defence nobody checked is worse than one that will not open.dtg-credentialsis a pinned git rev. 0.6.0 is merged and unreleased; that repository had no release path at all until OpenVTC/dtg-credentials#17. It lives in the one crate that ispublish = false, and swaps todtg-credentials = "0.6"on one line when thev0.6.0tag publishes.The
rooms/keys/*oracle is spec'd (#349, released) but not implemented. It is what lets an agent open a sealed record without ever holding the key, and it belongs invta-service— its own PR, and it wants a decision about whether the MLS and sealing layers move fromvtc-clientintovti-roomsfirst, since a VTA needs them too.