Skip to content

feat(rooms): data rooms end to end — storage, dispatch, verification, MLS, and a host - #1237

Merged
stormer78 merged 15 commits into
mainfrom
feat/rooms-open-tier
Sep 3, 2026
Merged

feat(rooms): data rooms end to end — storage, dispatch, verification, MLS, and a host#1237
stormer78 merged 15 commits into
mainfrom
feat/rooms-open-tier

Conversation

@stormer78

@stormer78 stormer78 commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

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 in trust-tasks-rs 0.17.7.

What works

cargo run -p room-host --example data_room runs all of it against a real host over HTTP:

  • Alice registers a room and writes two records.
  • Her agent reads them under a chain one link longer, conferring read for four hours — and is refused a write.
  • Alice replays her agent's presentation and is refused: the chain is bound to whoever signed the request.
  • A stranger presents a perfectly valid chain from their own room and gets nothing here.
  • A private room registers and then refuses to be read (see below).
  • Alice and Bob form an MLS group, she mints the epoch the membership change produced, seals a record under the exporter key and stores it. Bob fetches it from the host and opens it. The host held every byte, could not read one, and cannot move one — the record fails to open at another key, another version, or another room.

The shape

vti-rooms (published) — storage, wire types, and the shape half of authorization: presence, depth, membership, the private-tier binding. Depends only on vti-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 a ChainVerifier trait. Two independent checks, and neither substitutes for the other: every credential's proof must verify against the key its verificationMethod names, 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-rooms would 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 gets RefusesEverything and 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-clientRoomSession, the five room methods (none of which reads self.token), and the MLS + sealing layers behind an mls feature.

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_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. Checked twice, in the verifier and again in authorize, so the property does not depend on every future implementation remembering it.

Defects this branch found and fixed

  • room-host answered with bare JSON where vtc-client parses 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.
  • updatedAt was a unix integer; the published schema types it string/date-time. Also null for absent optionals under additionalProperties: false. Both in Record::metadata(), both invisible to serde — there is now a schema-conformance suite for the rooms family, since the VTC's sweep is scoped to spec/vtc/.
  • The nitro image copies a hand-maintained member list; both new crates were missing, so cargo-chef's stub survived and the build died six minutes in on vti-common = "^0.0.1". Fixed, plus a census so the next one fails in a second naming the line to add.
  • Every backup test failed on no AppState handle for keyspace 'rooms'BACKED_UP and backed_up_handle are two lists of the same thing with nothing making them agree. Fixed, and pinned in both directions.
  • cargo-deny on RUSTSEC-2026-0173 — build-time only, unavoidable through OpenMLS's only crypto provider, behind an off-by-default feature. Scoped exception with the drop condition named.

What is deliberately not done

A private room 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. SubjectBindingVerifier is the seam, with no implementation shipped. A private room whose pooling defence nobody checked is worse than one that will not open.

dtg-credentials is 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 is publish = false, and swaps to dtg-credentials = "0.6" on one line when the v0.6.0 tag 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 in vta-service — its own PR, and it wants a decision about whether the MLS and sealing layers move from vtc-client into vti-rooms first, since a VTA needs them too.

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>
@stormer78
stormer78 requested a review from a team as a code owner September 2, 2026 06:20
@affinidi-appsecurity-bot

affinidi-appsecurity-bot commented Sep 2, 2026

Copy link
Copy Markdown

🛡️ AI Agentic Security Code Review

3 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

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch feat/rooms-open-tiermain
Validated 2026-09-05
Scan ID 44c13529
Validator AI Security Validation Agent

🗺️ Scan Coverage

Modules scanned: 1 · with findings: 1 · files: 6 · findings: 7

Module Files scanned Findings
vtc-service 6 7

⚖️ Cross-Finding Reconciliation

1 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:

  • vtc-service/src/rooms/storage.rs (CWE-362) — verdicts: validated, duplicate
    • validated: Non-atomic read-modify-write on room/record version counter allows race condition
    • duplicate: TOCTOU race condition in put_record version-check and epoch/version assignment

Executive Summary

Category Confirmed Must-Review-By-Human
Security Issues 3 2

⚠️ 2 finding(s) need human review. These could not be conclusively confirmed or dismissed automatically (insufficient evidence). They are not dismissed — a developer / security team member must read and decide.


🔒 Security Issues

Confirmed Vulnerabilities (3)

🟡 Unauthenticated/unverified hard erasure via purge_record (function body truncated, no visible authorization or audit gate)

Field Detail
Severity MEDIUM
Location vtc-service/src/rooms/storage.rs:560
Finding ID github_pr-52ca17d1d314
CWE CWE-862, CWE-778
OWASP A01:2021 - Broken Access Control
MITRE ATT&CK T1070 - Indicator Removal, T1485 - Data Destruction
CAPEC CAPEC-17, CAPEC-268
CVSS 4.0 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)
DREAD 6.2
Reachability 🔴 Reachable
Exploit Maturity theoretical
Detection Source skill_scan

🧠 AI Triage:

  • Severity reassessed: HIGH → 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: 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.

/// Permanently remove a record, tombstone included.
///
/// The erasure path, separate from [`retract_record`] on purpose.
pub async fn purge_record(
    records: &KeyspaceHandle,
    room_id: &str,
    key: &str,
) -> Result<(), AppError> {
    let k = re
... [diff truncated]

Vulnerable lines: 560, 580

🔎 Evidence: vtc-service/src/rooms/storage.rs:560

pub async fn purge_record(
    records: &KeyspaceHandle,
    room_id: &str,
    key: &str,
) -> Result<(), AppError> {
    let k = re

💥 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:

  • Network exposure: internal
  • Auth barrier: none
  • Attack path: EP-008 (function call) → rooms::storage::purge_record → hard delete of room_records entry, no visible auth gate

⚖️ Triage Factors:

Factor Value
Fixable ✅ Yes
Exploitability low
Business impact high
Public exploit None known
Environment unknown

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:

⚠️ AI-generated fix. Review, test in staging, and validate against your architecture before applying.

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:

pub async fn purge_record(
    records: &KeyspaceHandle,
    room_id: &str,
    key: &str,
) -> Result<(), AppError> {
    // implementation not fully visible; no auth parameter in signature
}

Secure code:

pub(crate) async fn purge_record(
    records: &KeyspaceHandle,
    audit: &KeyspaceHandle,
    room_id: &str,
    key: &str,
    elevated_credential: &VerifiedOwnerOrComplianceRole,
    now: u64,
) -> Result<(), AppError> {
    elevated_credential.verify_owns_or_authorized(room_id)?;
    audit.append_signed_entry(room_id, key, "purge", now).await?;
    // ... then perform the deletion
}

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 90%
  • AI Validation Evidence: EVIDENCE FOUND: The full purge_record body IS visible in source_files (not actually truncated there): pub async fn purge_record(records: &KeyspaceHandle, room_id: &str, key: &str) -> Result<(), AppError> { let k = record_key(room_id, key); if records.get_raw(k.clone()).await?.is_none() { return Err(AppError::NotFound(...)); } records.remove(k).await } — confirms zero authorization check and no audit-log call inside this irreversible-delete function. EVIDENCE NOT FOUND: No credential verificati
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

🟡 Non-atomic read-modify-write on room/record version counter allows race condition

Field Detail
Severity MEDIUM
Location vtc-service/src/rooms/storage.rs:1
Finding ID github_pr-706763a0ed44
CWE CWE-362
OWASP A04:2021-Insecure Design
Detection Source threat_model

🧠 AI Triage:

  • Severity reassessed: HIGH → MEDIUM — While the code evidence conclusively proves the race condition exists (non-atomic get-then-insert with no CAS/lock), this is a data-integrity/availability issue, not RCE/auth-bypass, and requires no CVSS≥9 equivalent severity of impact. Reachability is unconfirmed ('no-info') and environment/lifecycle is 'unknown'. Per the evidence-ceiling and severity gates, without confirmed production deployment, confirmed reachability, or any exploit maturity signal, this cannot meet the HIGH bar (which requires Environment≥8 AND Reachability≥5 AND Exploitability≥5). The vulnerability is real and should be fixed, but calibrated severity is MEDIUM given the impact is data overwrite/corruption in a race window, not confidentiality breach or system compromise.
  • Composite score: 4.5
  • Environment: unknown

📝 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: vtc-service/src/rooms/storage.rs:1

let mut room = get_room(rooms, room_id).await?;

    let existing = get_record(records, room_id, &record.key).await.ok();
...
    record.version = room.next_version;
    record.updated_at = now;
    room.next_version += 1;
    room.updated_at = now;

    records
        .insert(record_key(room_id, &record.key), &record)
        .await?;
    rooms.insert(room_key(room_id), &room).await?;

🎯 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.

Also flagged at this location (same code, other weakness framings): CWE-400 — Unbounded record listing without pagination enables resource exhaustion

🔍 Validation Log

  • Verdict: ✅ Confirmed True Positive
  • Confidence: 65%
  • AI Validation Evidence: EVIDENCE FOUND: put_record performs let mut room = get_room(rooms, room_id).await?; then later record.version = room.next_version; room.next_version += 1; ... records.insert(record_key(room_id, &record.key), &record).await?; rooms.insert(room_key(room_id), &room).await?; — two separate non-transactional writes across the rooms and room_records keyspaces, with the module's own doc admitting: 'That read-modify-write is not atomic across the two keyspaces'. EVIDENCE NOT FOUND: No locking, t
  • Validation Effort: Cloned repo, read source file, verified vulnerability claim against actual code. Confirmed exploitability in scan context.

⚠️ Must-Review-By-Human (2)

Validated up to a point, but inconclusive — a human must read the code and make the final call. Reported (not dismissed) so developers and the security team receive them.

🟡 No authorization enforcement in room storage layer (dispatch layer not yet implemented)

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.

Complementary: 🛡️ **Threat Model & Affect Analysis**
Details

🛡️ Threat Model & Affect Analysis — PR #1237

Field Value
Repository OpenVTC/verifiable-trust-infrastructure
Branch feat/rooms-open-tiermain
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:

  1. 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.
  2. 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 arbitrary owner_did and room_id without presenting any membership credential.
  3. Because create_room in 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.
  4. 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.
  5. Attacker calls get_record/list_records to read Open-tier cleartext content of rooms they do not own, since visibility gating governs content shape, not caller identity.
  6. 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:

  1. 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.
  2. Unlike retract_record, which merely tombstones data, purge_record is described as 'Permanently remove a record, tombstone included' — a genuinely destructive operation.
  3. Attacker calls purge_record with an arbitrary room_id/key pair for a room they do not own, deleting the tombstone entirely.
  4. 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.
  5. 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:

  1. Two concurrent callers (or one attacker racing a legitimate writer) invoke put_record on the same room_id at nearly the same time.
  2. Both calls execute let mut room = get_room(rooms, room_id).await?; and independently read the same next_version value before either write completes, since there is no lock or transaction spanning rooms and room_records keyspaces (module doc explicitly acknowledges: 'That read-modify-write is not atomic across the two keyspaces').
  3. Attacker's write and the legitimate write both pass the expected_version precondition check against the same stale room state (e.g., both believe current version is N).
  4. 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's next_version counter after the other, resulting in a duplicate/collided version instead of the documented 'skipped version' failure mode.
  5. Downstream consumers relying on strict per-room version monotonicity for sinceVersion watermarking 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:

  1. A room is created with Visibility::Open, which per design 'stores_cleartext()' returns true, meaning put_record requires record.cleartext to be populated and rejects sealed content.
  2. 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.
  3. 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.
  4. 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:

  1. Attacker without any valid credential repeatedly calls get_room(rooms, room_id) or create_room with guessed/brute-forced room_id values (room_id is owner-minted and could follow predictable patterns such as DID-derived slugs).
  2. get_room returns AppError::NotFound for non-existent rooms and successfully returns Room data (or a distinguishable Conflict on create) for existing ones, creating a binary oracle for room existence.
  3. 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.
  4. 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:

  1. Attacker (or any caller, absent upstream rate limiting) creates or targets a room and writes an extremely large number of records via repeated put_record calls (each call is individually cheap and passes validation).
  2. Attacker then calls list_records(records, room_id, key_prefix, since_version) on that room.
  3. 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.
  4. Every record is deserialized with serde_json::from_slice and pushed into out, 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.
  5. 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:

  1. Attacker with any function/API access repeatedly calls create_room with 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).
  2. 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.
  3. Attacker further amplifies impact by writing many put_record calls into each spam room, since put_record has no per-room record-count cap.
  4. 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:

  1. 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 Record struct with an arbitrary author field value impersonating a legitimate member's identifier.
  2. 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-checks record.author against any verified caller identity/credential.
  3. 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.
  4. 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:

  1. 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.
  2. 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').
  3. Similarly, retract_record tombstones content and purge_record erases it entirely, both without a visible audit-log call in this module.
  4. 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:

  1. 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.
  2. This pattern surrounds a semantically meaningful change: the expected keyspace count assertion changes from 25 to 27 (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.
  3. 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.
  4. 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:

  1. 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.
  2. A concurrent legitimate (or attacker-controlled, if member removal was itself reactive to compromise) put_record call that was already in flight — having read the room's old epoch via get_room moments before the epoch advance committed — proceeds to write a record with record.epoch != Some(room.epoch) validated against the stale in-memory room snapshot rather than the just-updated value.
  3. Because put_record re-fetches room internally (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.
  4. 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:

  1. Attacker (with access to create_room or put_record, e.g., via STRIDE-1) supplies a room_id value such as victim-room:extra or a key value containing a colon, e.g., ../other-room:secret.
  2. 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.
  3. 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.
  4. 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
Loading

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
Loading

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
Loading

📊 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 ⚠️ Must-Review-By-Human
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>
@stormer78 stormer78 changed the title feat(rooms): the data-room storage layer feat(rooms): the open tier — storage, authorization, and dispatch Sep 2, 2026
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>
@stormer78 stormer78 changed the title feat(rooms): the open tier — storage, authorization, and dispatch feat(rooms): the open tier — storage, authorization, dispatch, and client Sep 2, 2026
@stormer78

Copy link
Copy Markdown
Contributor Author

Extended: the open tier is now complete end to end, client included.

Added since the first push

Dispatch (rooms::{wire,authz,handlers}) — and a correction. I had said this was blocked
on rooms/* publishing. It wasn't, and checking rather than repeating the claim is what
unblocked it: the schema gate is a #[cfg(test)] conformance sweep scoped to spec/vtc/,
and this family is top-level spec/rooms/, while parse_payload is generic over
DeserializeOwned and needs no generated type. Wire types are hand-written in rooms::wire
against dtgwg-trust-tasks-tf#346
(now merged) — swap them for the generated ones when the crate publishes, don't keep both.

Client (vtc-client/src/rooms.rs) — create_room, put_record, get_record,
list_records, mint_epoch, and a RoomSession.

The property worth reviewing for

Every other method on VtcClient carries an operator token. A room call carries none.
RoomSession holds credentials, not a session, and room_task is the single place a room
call is made so that "never reads self.token" is checkable in one function rather than
across five.

On the service side the same invariant has two structural defences: handlers take no
AuthClaims
, and AuthorizedAction is constructible only by authorize().

The agent case is modelled by construction rather than a flag — a member's session and their
agent's are built identically and differ only in the chain they carry (depth 1 vs 2). There
is a test pinning that, because if the difference ever becomes a parameter on the type
rather than a property of the credentials, the design has drifted.

Still deliberately refused

Sealed tiers. Chain verification needs dtg_credentials::authority::verify_chain
(dtg-credentials#15, merged but not yet
on crates.io — the workspace pins dtg-credentials = "0.4"). Serving a sealed room before
that is wired means serving one whose chain nobody checked, so it refuses, with a test
pinning the refusal.

That is the one line to change when 0.6.0 publishes, and it is the whole of what
attributed needs on this side.

cargo test: vtc-service 950 lib + 104 integration, vtc-client 13. Clippy clean.

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>
@stormer78 stormer78 changed the title feat(rooms): the open tier — storage, authorization, dispatch, and client feat(rooms): rooms end to end — storage, dispatch, client, DID templates, MLS Sep 2, 2026
@stormer78

Copy link
Copy Markdown
Contributor Author

Two more layers since the last update. The branch now carries the open tier complete, plus
the two pieces of attributed that do not depend on an upstream publish.

DID templates — room and room-host

Neither is a new mechanism: provision-integration already mints DIDs from templates, so a
room and a room host are integration kinds.

room is a data room's own identity — it holds a DID, issues the VIC/VMC/VAC credentials
that govern it, and is messageable. did:webvh rather than did:peer, and that 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.

room-host exists so that hosting your own rooms (topology T1) does not mean adding
stranger-facing surface to the VTA — the process guarding the master seed should not also
terminate presentations from arbitrary DIDs.

MLS group layer (vtc-client, mls feature)

Off by default, matching how vtc-service gates its bbs verifier: a caller that only
reads an open room carries none of OpenMLS.

The mapping is the design's own shape, which MLS arrived at independently — DTG as
Authentication Service, the host as Delivery Service trusted for availability only, an MLS
epoch as the room epoch the host stores. 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.

Storage keys come from the exporter under a room-specific label rather than the group's
own message keys — the draft-sullivan-mls-attachments pattern after SFrame — so a change
to record sealing cannot 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, and members cannot detect
that by comparing through the host. An anchor it cannot forge is what gives each member
something to check against.

Two tests carry the argument: two members derive an identical storage key without it
ever crossing the host, and removing a member provably changes that key — forward-only
removal demonstrated rather than asserted.

What is left, and what it waits on

Wiring dtg_credentials::authority::verify_chain into rooms::authz — the last thing
attributed needs here. dtg-credentials#15
is merged but crates.io still has 0.5.0 and that repo has no publish workflow, so it
needs a manual cargo publish. Until then authz refuses the sealed tiers, with a test
pinning the refusal.

cargo test: vtc-service 950 lib + 104 integration · vtc-client 13 default / 19 with
mls
· vta-sdk 300. Clippy clean, including with the feature on.

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>
@stormer78

Copy link
Copy Markdown
Contributor Author

Sealed-tier client integration added — the MLS layer now meets 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: authorization travels to the host; keys do not.

Each record's AEAD associated data commits to roomId|key|version|epoch, so a host that
relocates a sealed record produces an authentication failure rather than a readable record.
It holds every byte and still cannot move one — the same class of defence
vti_common::store::encryption already applies by binding values to their
(keyspace, key) location, repeated deliberately because the reasoning was paid for once.

One wrinkle worth review

A record's version is assigned by the host, so a writer does not know it when sealing.
seal_record binds the version the writer intends, and a caller that lets the host assign
a different one will find the record does not open.

That is the correct failure — accepting whatever came back would mean the binding commits to
nothing — but it does constrain usage to create-only writes (expected_version: Some(0)) or
a read-before-rewrite. Both are already supported by the task surface, and the module docs
say so rather than leaving it to be discovered.

Tests

Six, three of which carry real properties:

  • A relocated record does not open — checked four ways: another key, another version, a
    relabelled epoch, another room.
  • A non-member cannot open one, however identical their session looks — a different
    group is a different key.
  • Sealing the same plaintext twice uses a fresh nonce, because a reused one would break
    ChaCha20-Poly1305 outright.

cargo test -p vtc-client --features mls: 25 passing. Default build unchanged at 13.
Clippy clean both ways.

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>
@stormer78
stormer78 force-pushed the feat/rooms-open-tier branch from 50e776a to ef26959 Compare September 2, 2026 20:57
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>
@stormer78 stormer78 changed the title feat(rooms): rooms end to end — storage, dispatch, client, DID templates, MLS feat(rooms): data rooms end to end — storage, dispatch, verification, MLS, and a host Sep 2, 2026
@stormer78
stormer78 merged commit 47ce7a8 into main Sep 3, 2026
15 of 16 checks passed
@stormer78
stormer78 deleted the feat/rooms-open-tier branch September 3, 2026 05:26
stormer78 added a commit that referenced this pull request Sep 3, 2026
…#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>
stormer78 added a commit that referenced this pull request Sep 7, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants