From 29d75bcb389a283df47f625063cd101e8e16d950 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 08:20:09 +0200 Subject: [PATCH 01/14] feat(rooms): the data-room storage layer 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 --- vtc-service/src/lib.rs | 1 + vtc-service/src/rooms/mod.rs | 210 +++++++++++ vtc-service/src/rooms/storage.rs | 580 +++++++++++++++++++++++++++++ vtc-service/src/server.rs | 12 + vtc-service/src/store/keyspaces.rs | 18 +- vtc-service/src/test_support.rs | 4 + 6 files changed, 823 insertions(+), 2 deletions(-) create mode 100644 vtc-service/src/rooms/mod.rs create mode 100644 vtc-service/src/rooms/storage.rs diff --git a/vtc-service/src/lib.rs b/vtc-service/src/lib.rs index 89e74033f..e3ec8c05f 100644 --- a/vtc-service/src/lib.rs +++ b/vtc-service/src/lib.rs @@ -41,6 +41,7 @@ pub mod policy; pub mod recognition; pub mod registry; pub mod relationships; +pub mod rooms; pub mod routes; pub mod routing; pub mod schemas; diff --git a/vtc-service/src/rooms/mod.rs b/vtc-service/src/rooms/mod.rs new file mode 100644 index 000000000..76dba09f9 --- /dev/null +++ b/vtc-service/src/rooms/mod.rs @@ -0,0 +1,210 @@ +//! Data rooms — the storage layer. +//! +//! A **data room** is a shared space whose access is governed by credentials the *room +//! itself* issues, not by anything this service stores. That single property is what the +//! rest of this module is arranged around, and it is worth stating before the types, +//! because it inverts the assumption every other keyspace here is built on. +//! +//! # What this module deliberately does not hold +//! +//! **There is no member list.** Not omitted for now — there must not be one. Authorization +//! is a presentation carrying a membership credential and an authority chain, verified +//! against the room's own identifier. 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 has become part of the room's membership +//! definition, and a room whose contents we cannot read acquires a member list we can. +//! +//! So the row below carries an owner, a visibility, an epoch and a retention period, and +//! nothing about who belongs. See `docs/05-design-notes/data-rooms.md` §1 (invariant I5). +//! +//! # What this service can and cannot see +//! +//! Set by the room's [`Visibility`], fixed at creation: +//! +//! | | `Open` | `Attributed` | `Private` | +//! |---|---|---|---| +//! | Record content | cleartext | sealed | sealed | +//! | Which member acted | visible | visible | unlinkable proof | +//! | Owner | visible | visible | visible | +//! +//! The owner is visible at every tier on purpose. A room whose contents nobody here can +//! read still has a party answerable for it existing — for quota, for abuse, and for the +//! lifecycle notice in §9 of the design note. +//! +//! # Scope of this module +//! +//! Storage and the operations over it. The Trust-Task dispatch that authorizes those +//! operations lands separately, once `rooms/*` is published in the task 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. This +//! layer is written and tested first so that the dispatch layer, when it lands, is a thin +//! wrapper over settled behaviour rather than a place where storage decisions get made +//! under time pressure. + +pub mod storage; + +use serde::{Deserialize, Serialize}; + +/// How much of a room this service can see. +/// +/// **Immutable for the life of a room.** A downgrade cannot un-see cleartext, and an +/// upgrade would protect only what came after while presenting as though it protected +/// everything. To change the visibility of some material, make another room and move it +/// deliberately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Records are cleartext: searchable here, and readable by whoever operates this + /// service. Right for material where that is not a threat and losing search is a real + /// cost. + Open, + /// Record content is sealed; the acting member is still disclosed. The tier for anyone + /// under an obligation to produce per-member access logs. + Attributed, + /// Content is sealed and membership is presented in zero knowledge: this service + /// verifies that *a* member acted without learning which. + Private, +} + +impl Visibility { + /// Whether this service holds record content in the clear. + /// + /// The one place to ask. A caller testing `== Visibility::Open` in several places will + /// eventually miss one, and the failure mode is storing a plaintext record on a tier + /// that promised not to. + pub fn stores_cleartext(&self) -> bool { + matches!(self, Visibility::Open) + } + + /// Whether a record's acting member is disclosed to this service. + pub fn discloses_actor(&self) -> bool { + matches!(self, Visibility::Open | Visibility::Attributed) + } +} + +/// A room, as this service holds it. +/// +/// Note what is absent: no members, no keys, no credentials. This service is told the +/// epoch *number* so it can serve the right ciphertext, and never the key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Room { + /// The room's own identifier, minted by its owner before registration. + /// + /// This service does not assign one. A room identified by something its host chose + /// could not move to another host without changing identity, and portability is what + /// the whole design rests on. + pub room_id: String, + + /// The accountable party: controller of the room's identifier, issuer of every + /// credential in it, and the party addressed about quota, abuse and lifecycle. + pub owner_did: String, + + /// Fixed at creation. See [`Visibility`]. + pub visibility: Visibility, + + /// The current key epoch. Advanced by the owner on removal; this service records the + /// number and never learns the key. + pub epoch: u32, + + /// The next record version to assign. + /// + /// Monotonic **per room**, not per record — one comparable number is what a + /// `sinceVersion` watermark needs, and per-record counters are not comparable to each + /// other. Learned the expensive way by the app-state store; see + /// `docs/05-design-notes/appstate-store.md` §2. + pub next_version: u64, + + /// How long this service holds the room after its epoch lapses without renewal. + /// + /// Stated at creation rather than discovered later: a reclamation that surprises a + /// member is a failure of the design, not of the member. + pub retention_days: u32, + + /// Unix-epoch seconds. + pub created_at: u64, + /// Unix-epoch seconds; bumped on epoch advance and on record writes. + pub updated_at: u64, +} + +/// Curation state of a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RecordStatus { + /// Normal. + Active, + /// Superseded but retained; a client demotes it in recall rather than hiding it. + Deprecated, + /// A tombstone. The body is gone; the key, version and epoch remain. + /// + /// Retained rather than deleted because incremental sync needs it: without a tombstone + /// a puller learns of every create and update and never of a delete, so retracted + /// records resurrect on the next full rebuild and disagree with peers that saw the + /// retraction. + Retracted, +} + +/// One record. +/// +/// On `Attributed` and `Private` rooms `sealed` carries the ciphertext and `cleartext` is +/// `None`; on `Open` it is the other way round. Enforced at the operations layer rather +/// than the type, because the invariant is per-room and the type is per-record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + /// The record's key within the room. + /// + /// On the sealed tiers this MUST be opaque — a random identifier, never a descriptive + /// slug. A key reading `decision/acquire-northwind` defeats the encryption sitting + /// beside it. Structured naming belongs inside the sealed body. + pub key: String, + + /// Server-assigned, monotonic per room. Also the `sinceVersion` watermark. + pub version: u64, + + /// The key epoch this record was sealed under. `None` on an `Open` room. + pub epoch: Option, + + /// Curation state. + pub status: RecordStatus, + + /// Sealed content, base64url. Present on the sealed tiers. + #[serde(skip_serializing_if = "Option::is_none")] + pub sealed: Option, + + /// AEAD nonce, base64url. Present with `sealed`. + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + + /// Cleartext content. Present only on an `Open` room. + #[serde(skip_serializing_if = "Option::is_none")] + pub cleartext: Option, + + /// The member who wrote it, where the tier discloses one. + /// + /// `None` on a `Private` room — there the author lives inside the sealed body, where + /// only members can read it. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + + /// Unix-epoch seconds. + pub updated_at: u64, +} + +impl Record { + /// The metadata projection a listing returns. + /// + /// **Never the body.** Ranking happens on the client, and a service that returned every + /// body would make a caller pay for the whole room on every listing — and on a sealed + /// tier could not usefully rank them anyway. + pub fn metadata(&self) -> serde_json::Value { + serde_json::json!({ + "key": self.key, + "version": self.version, + "epoch": self.epoch, + "status": self.status, + "author": self.author, + "updatedAt": self.updated_at, + }) + } +} diff --git a/vtc-service/src/rooms/storage.rs b/vtc-service/src/rooms/storage.rs new file mode 100644 index 000000000..bd5186da5 --- /dev/null +++ b/vtc-service/src/rooms/storage.rs @@ -0,0 +1,580 @@ +//! CRUD over the `rooms:` and `room_records:` keyspaces. +//! +//! # Key layout +//! +//! ```text +//! rooms: -> Room +//! room_records:: -> Record +//! ``` +//! +//! The trailing `:` on the record prefix makes a scan room-exact — a prefix of `a:` never +//! matches `ab:` — so one room's listing can never return another's, in the store layer as +//! well as at the authorization layer above it. +//! +//! # Version assignment +//! +//! Versions are allocated from [`super::Room::next_version`], monotonic **per room**. A +//! write reads the room, takes the next number, writes the record, then writes the room +//! back. That read-modify-write is not atomic across the two keyspaces, and the failure it +//! can produce is a *skipped* version rather than a duplicated one — which is the direction +//! it has to fail in, because `sinceVersion` only needs monotonicity, not density. A +//! duplicate would make two records indistinguishable to a watermark; a gap costs nothing. + +use vti_common::error::AppError; +use vti_common::store::KeyspaceHandle; + +use super::{Record, RecordStatus, Room}; + +/// `rooms:`. +pub const ROOMS_PREFIX: &str = "rooms:"; +/// `room_records::`. +pub const RECORDS_PREFIX: &str = "room_records:"; + +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}") +} + +/// Every record in one room. The trailing `:` is what makes this room-exact. +fn record_prefix(room_id: &str) -> String { + format!("{RECORDS_PREFIX}{room_id}:") +} + +/// Register a room. +/// +/// Refuses to replace an existing one: re-registering would silently reset the epoch and +/// the version counter, which a client would read as the room having been rolled back. +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 { + 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}"))) +} + +/// Advance a room's epoch. +/// +/// `new_epoch` must be exactly one greater than the current one. A gap would leave records +/// sealed under an epoch no member was ever given a key for, and a repeat would let a +/// removed member's key open material written after their removal — which is the whole +/// point of advancing. +/// +/// This service records the number. It never learns the key. +pub async fn advance_epoch( + rooms: &KeyspaceHandle, + room_id: &str, + new_epoch: u32, + now: u64, +) -> Result { + let mut room = get_room(rooms, room_id).await?; + if new_epoch != room.epoch + 1 { + return Err(AppError::Validation(format!( + "epoch must advance by exactly one: room `{room_id}` is at {}, got {new_epoch}", + room.epoch + ))); + } + room.epoch = new_epoch; + room.updated_at = now; + rooms.insert(room_key(room_id), &room).await?; + Ok(room) +} + +/// Write a record, assigning it the room's next version. +/// +/// `expected_version` is an optional precondition: +/// - `None` — overwrite unconditionally. +/// - `Some(0)` — create only; fails if the key exists. +/// - `Some(n)` — fails unless the stored record is at version `n`. +/// +/// On mismatch the error carries the **current version**, so a caller does not have to +/// re-read to learn what it lost to. A bare rejection would force a re-read, and between +/// the rejection and the re-read the record can change again — the pattern has no fixed +/// point under contention. +pub async fn put_record( + rooms: &KeyspaceHandle, + records: &KeyspaceHandle, + room_id: &str, + mut record: Record, + expected_version: Option, + now: u64, +) -> Result { + let mut room = get_room(rooms, room_id).await?; + + let existing = get_record(records, room_id, &record.key).await.ok(); + + if let Some(expected) = expected_version { + match (&existing, expected) { + (Some(_), 0) => { + return Err(AppError::Conflict(format!( + "record `{}` already exists (create-only requested)", + record.key + ))); + } + (None, 0) => {} + (Some(current), n) if current.version != n => { + return Err(AppError::Conflict(format!( + "record `{}` is at version {}, not {n}", + record.key, current.version + ))); + } + (None, n) => { + return Err(AppError::Conflict(format!( + "record `{}` does not exist, so it cannot be at version {n}", + record.key + ))); + } + _ => {} + } + } + + // Content shape must match the room's visibility. Enforced here rather than in the + // type, because the invariant belongs to the room and the type belongs to the record. + if room.visibility.stores_cleartext() { + if record.sealed.is_some() { + return Err(AppError::Validation( + "an open room stores cleartext records; `sealed` was supplied".into(), + )); + } + if record.cleartext.is_none() { + return Err(AppError::Validation( + "an open room requires `cleartext`".into(), + )); + } + } else { + if record.cleartext.is_some() { + return Err(AppError::Validation(format!( + "room `{room_id}` is {:?}; cleartext must not be stored here", + room.visibility + ))); + } + if record.sealed.is_none() { + return Err(AppError::Validation( + "a sealed room requires `sealed` content".into(), + )); + } + // The epoch a record was sealed under must be the room's current one, or a reader + // holding the current key cannot open it. + if record.epoch != Some(room.epoch) { + return Err(AppError::Validation(format!( + "record is sealed under epoch {:?}, room `{room_id}` is at {}", + record.epoch, room.epoch + ))); + } + } + + // A `Private` room must not carry an author here: on that tier authorship lives inside + // the sealed body, where only members can read it. + if matches!(room.visibility, super::Visibility::Private) && record.author.is_some() { + return Err(AppError::Validation( + "a private room does not record an author; authorship belongs inside the sealed body" + .into(), + )); + } + + 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?; + Ok(record) +} + +/// Fetch one record. +pub async fn get_record( + records: &KeyspaceHandle, + room_id: &str, + key: &str, +) -> Result { + let raw = records + .get_raw(record_key(room_id, key)) + .await? + .ok_or_else(|| AppError::NotFound(format!("record `{key}` not found in `{room_id}`")))?; + serde_json::from_slice(&raw) + .map_err(|e| AppError::Internal(format!("decode record `{key}`: {e}"))) +} + +/// List a room's records, optionally filtered by key prefix and a `since_version` +/// watermark. +/// +/// **Tombstones are returned**, and that is not an oversight. A caller pulling by watermark +/// that never sees a retraction learns of every create and update and never of a delete, so +/// retracted records resurrect on its next full rebuild and disagree with peers that saw +/// the retraction. A retraction is a change like any other. +pub async fn list_records( + records: &KeyspaceHandle, + room_id: &str, + key_prefix: Option<&str>, + since_version: Option, +) -> Result, AppError> { + let scan_prefix = match key_prefix { + Some(p) => format!("{}{p}", record_prefix(room_id)), + None => record_prefix(room_id), + }; + 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) + .map_err(|e| AppError::Internal(format!("decode record in `{room_id}`: {e}")))?; + if let Some(since) = since_version + && record.version <= since + { + continue; + } + out.push(record); + } + out.sort_by_key(|r| r.version); + Ok(out) +} + +/// Retract a record: drop the body, keep the tombstone. +/// +/// Not an erasure. The key, version and epoch remain so that incremental sync converges and +/// the audit chain still shows the record existed. Hard removal is a separate, higher-trust +/// operation — two verbs because they are two different acts, and collapsing them makes the +/// common one too powerful or the rare one impossible. +pub async fn retract_record( + rooms: &KeyspaceHandle, + records: &KeyspaceHandle, + room_id: &str, + key: &str, + now: u64, +) -> Result { + let mut record = get_record(records, room_id, key).await?; + let mut room = get_room(rooms, room_id).await?; + + record.status = RecordStatus::Retracted; + record.sealed = None; + record.nonce = None; + record.cleartext = None; + record.version = room.next_version; + record.updated_at = now; + + room.next_version += 1; + room.updated_at = now; + + records.insert(record_key(room_id, key), &record).await?; + rooms.insert(room_key(room_id), &room).await?; + Ok(record) +} + +/// 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 = record_key(room_id, key); + if records.get_raw(k.clone()).await?.is_none() { + return Err(AppError::NotFound(format!( + "record `{key}` not found in `{room_id}`" + ))); + } + records.remove(k).await +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::rooms::Visibility; + use vti_common::config::StoreConfig; + use vti_common::store::Store; + + async fn open() -> (tempfile::TempDir, KeyspaceHandle, KeyspaceHandle) { + let dir = tempfile::tempdir().unwrap(); + let store = Store::open(&StoreConfig { + data_dir: dir.path().to_path_buf(), + }) + .unwrap(); + let rooms = store.keyspace(crate::store::keyspaces::ROOMS).unwrap(); + let records = store + .keyspace(crate::store::keyspaces::ROOM_RECORDS) + .unwrap(); + (dir, rooms, records) + } + + fn room(id: &str, visibility: Visibility) -> Room { + Room { + room_id: id.into(), + owner_did: "did:key:zOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + fn sealed_record(key: &str, epoch: u32) -> Record { + Record { + key: key.into(), + version: 0, + epoch: Some(epoch), + status: RecordStatus::Active, + sealed: Some("c2VhbGVk".into()), + nonce: Some("bm9uY2U".into()), + cleartext: None, + author: None, + updated_at: 0, + } + } + + fn open_record(key: &str) -> Record { + Record { + key: key.into(), + version: 0, + epoch: None, + status: RecordStatus::Active, + sealed: None, + nonce: None, + cleartext: Some(serde_json::json!({ "body": "hello" })), + author: Some("did:key:zBob".into()), + updated_at: 0, + } + } + + #[tokio::test] + async fn a_room_cannot_be_registered_twice() { + let (_d, rooms, _rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + let err = create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap_err(); + assert!( + matches!(err, AppError::Conflict(_)), + "re-registering would reset the epoch and version counter: {err:?}" + ); + } + + #[tokio::test] + async fn versions_are_monotonic_across_records_in_a_room() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + + let a = put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + let b = put_record(&rooms, &rec, "r1", open_record("b"), None, 2) + .await + .unwrap(); + let a2 = put_record(&rooms, &rec, "r1", open_record("a"), None, 3) + .await + .unwrap(); + + assert_eq!((a.version, b.version, a2.version), (1, 2, 3)); + // One comparable number per room is what a `sinceVersion` watermark needs. + assert!( + a2.version > b.version, + "rewriting `a` must advance past `b`" + ); + } + + #[tokio::test] + async fn a_version_precondition_reports_what_it_lost_to() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + + let err = put_record(&rooms, &rec, "r1", open_record("a"), Some(99), 2) + .await + .unwrap_err(); + let msg = format!("{err}"); + assert!( + msg.contains("is at version 1"), + "the conflict must carry the current version so a caller need not re-read: {msg}" + ); + } + + #[tokio::test] + async fn create_only_refuses_an_existing_key() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), Some(0), 1) + .await + .unwrap(); + let err = put_record(&rooms, &rec, "r1", open_record("a"), Some(0), 2) + .await + .unwrap_err(); + assert!(matches!(err, AppError::Conflict(_)), "{err:?}"); + } + + /// The tier promise, enforced in the store rather than trusted to callers. + #[tokio::test] + async fn a_sealed_room_refuses_cleartext_and_an_open_room_refuses_ciphertext() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("sealed", Visibility::Attributed)) + .await + .unwrap(); + create_room(&rooms, &room("plain", Visibility::Open)) + .await + .unwrap(); + + let err = put_record(&rooms, &rec, "sealed", open_record("a"), None, 1) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("cleartext must not be stored"), + "{err}" + ); + + let err = put_record(&rooms, &rec, "plain", sealed_record("a", 1), None, 1) + .await + .unwrap_err(); + assert!(format!("{err}").contains("stores cleartext"), "{err}"); + } + + #[tokio::test] + async fn a_private_room_refuses_a_recorded_author() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("p", Visibility::Private)) + .await + .unwrap(); + let mut r = sealed_record("a", 1); + r.author = Some("did:key:zBob".into()); + let err = put_record(&rooms, &rec, "p", r, None, 1).await.unwrap_err(); + assert!( + format!("{err}").contains("inside the sealed body"), + "on a private room the author must not reach this service: {err}" + ); + } + + #[tokio::test] + async fn a_record_sealed_under_a_stale_epoch_is_refused() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Attributed)) + .await + .unwrap(); + advance_epoch(&rooms, "r1", 2, 10).await.unwrap(); + let err = put_record(&rooms, &rec, "r1", sealed_record("a", 1), None, 11) + .await + .unwrap_err(); + assert!(format!("{err}").contains("room `r1` is at 2"), "{err}"); + } + + #[tokio::test] + async fn an_epoch_must_advance_by_exactly_one() { + let (_d, rooms, _rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Attributed)) + .await + .unwrap(); + + assert!( + advance_epoch(&rooms, "r1", 3, 1).await.is_err(), + "a gap is refused" + ); + assert!( + advance_epoch(&rooms, "r1", 1, 1).await.is_err(), + "a repeat is refused" + ); + assert_eq!(advance_epoch(&rooms, "r1", 2, 1).await.unwrap().epoch, 2); + } + + #[tokio::test] + async fn one_room_never_lists_anothers_records() { + let (_d, rooms, rec) = open().await; + // `a` is a string prefix of `ab` — the trailing `:` is what keeps them apart. + create_room(&rooms, &room("a", Visibility::Open)) + .await + .unwrap(); + create_room(&rooms, &room("ab", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "a", open_record("x"), None, 1) + .await + .unwrap(); + put_record(&rooms, &rec, "ab", open_record("y"), None, 2) + .await + .unwrap(); + + let listed = list_records(&rec, "a", None, None).await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].key, "x"); + } + + /// Without this, deletions never propagate and retracted records resurrect. + #[tokio::test] + async fn a_watermark_listing_returns_tombstones() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + let seen = put_record(&rooms, &rec, "r1", open_record("b"), None, 2) + .await + .unwrap() + .version; + + let tomb = retract_record(&rooms, &rec, "r1", "a", 3).await.unwrap(); + assert!( + tomb.sealed.is_none() && tomb.cleartext.is_none(), + "body is dropped" + ); + + let changed = list_records(&rec, "r1", Some(""), Some(seen)) + .await + .unwrap(); + assert_eq!(changed.len(), 1, "only what changed since the watermark"); + assert_eq!(changed[0].key, "a"); + assert!( + matches!(changed[0].status, RecordStatus::Retracted), + "the retraction must reach a puller, or the record resurrects" + ); + } + + #[tokio::test] + async fn purge_removes_the_tombstone_and_is_not_idempotent() { + let (_d, rooms, rec) = open().await; + create_room(&rooms, &room("r1", Visibility::Open)) + .await + .unwrap(); + put_record(&rooms, &rec, "r1", open_record("a"), None, 1) + .await + .unwrap(); + retract_record(&rooms, &rec, "r1", "a", 2).await.unwrap(); + + purge_record(&rec, "r1", "a").await.unwrap(); + assert!( + list_records(&rec, "r1", None, None) + .await + .unwrap() + .is_empty() + ); + assert!( + purge_record(&rec, "r1", "a").await.is_err(), + "purging an absent record reports not-found rather than succeeding quietly" + ); + } +} diff --git a/vtc-service/src/server.rs b/vtc-service/src/server.rs index db55cda6e..2caa8d897 100644 --- a/vtc-service/src/server.rs +++ b/vtc-service/src/server.rs @@ -114,6 +114,14 @@ pub struct AppState { /// Tracked here for list + revoke surfaces; the VEC body /// itself is signed + returned at issuance time. pub endorsements_ks: KeyspaceHandle, + /// Data rooms — one row per room. Carries owner, visibility, epoch and + /// retention, and deliberately **no member list**: room membership is + /// decided by credentials the room itself issued, so a roster here would + /// make the room unmovable and make this service part of its membership. + /// See `crate::rooms`. + pub rooms_ks: KeyspaceHandle, + /// Room records. Ciphertext on the `attributed` and `private` tiers. + pub room_records_ks: KeyspaceHandle, pub audit_ks: KeyspaceHandle, pub audit_key_ks: KeyspaceHandle, /// Signed audit checkpoints (#708). Read by `GET /v1/audit/verify`, @@ -452,6 +460,8 @@ pub async fn run( // mints out of the box. crate::schemas::seed_default_issues(&schemas_ks).await?; let endorsements_ks = store.keyspace(keyspaces::ENDORSEMENTS)?; + let rooms_ks = store.keyspace(keyspaces::ROOMS)?; + let room_records_ks = store.keyspace(keyspaces::ROOM_RECORDS)?; let audit_ks = store.keyspace(keyspaces::AUDIT)?; let audit_key_ks = store.keyspace(keyspaces::AUDIT_KEY)?; let audit_checkpoint_ks = store.keyspace(keyspaces::AUDIT_CHECKPOINT)?; @@ -718,6 +728,8 @@ pub async fn run( endorsement_types_ks, schemas_ks, endorsements_ks, + rooms_ks, + room_records_ks, audit_ks, audit_key_ks, audit_checkpoint_ks: audit_checkpoint_ks.clone(), diff --git a/vtc-service/src/store/keyspaces.rs b/vtc-service/src/store/keyspaces.rs index daef88d86..b7a30c8c3 100644 --- a/vtc-service/src/store/keyspaces.rs +++ b/vtc-service/src/store/keyspaces.rs @@ -34,6 +34,16 @@ pub const RELATIONSHIPS_BY_DID: &str = "relationships_by_did"; pub const ENDORSEMENT_TYPES: &str = "endorsement_types"; pub const SCHEMAS: &str = "schemas"; pub const ENDORSEMENTS: &str = "endorsements"; + +/// Data rooms: one row per room at `rooms:`. +/// +/// Holds an owner, a visibility, an epoch and a retention period — and deliberately **no +/// member list**. Membership is decided by credentials the room itself issued, so a roster +/// here would make the room unmovable and make this service part of its membership. +pub const ROOMS: &str = "rooms"; + +/// Room records at `room_records::`. Ciphertext on the sealed tiers. +pub const ROOM_RECORDS: &str = "room_records"; pub const AUDIT: &str = "audit"; pub const AUDIT_KEY: &str = "audit_key"; /// Signed audit checkpoints (#708) — periodic Ed25519-signed commitments to @@ -78,6 +88,8 @@ pub const ALL: &[&str] = &[ ENDORSEMENT_TYPES, SCHEMAS, ENDORSEMENTS, + ROOMS, + ROOM_RECORDS, AUDIT, AUDIT_KEY, AUDIT_CHECKPOINT, @@ -108,6 +120,8 @@ pub const BACKED_UP: &[&str] = &[ ENDORSEMENT_TYPES, SCHEMAS, ENDORSEMENTS, + ROOMS, + ROOM_RECORDS, AUDIT, AUDIT_KEY, // Required, not optional: restoring the audit log without its @@ -146,11 +160,11 @@ mod tests { use super::*; /// `ALL` must stay in sync with the `AppState` keyspace fields. - /// `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() { - assert_eq!(ALL.len(), 25, "ALL must list every AppState keyspace"); + assert_eq!(ALL.len(), 27, "ALL must list every AppState keyspace"); } /// The backup census (P3.9): every keyspace is either backed up or diff --git a/vtc-service/src/test_support.rs b/vtc-service/src/test_support.rs index 6216689f3..cb832e21c 100644 --- a/vtc-service/src/test_support.rs +++ b/vtc-service/src/test_support.rs @@ -228,6 +228,8 @@ impl TestVtcBuilder { .expect("endorsement_types ks"); let schemas_ks = store.keyspace("schemas").expect("schemas ks"); let endorsements_ks = store.keyspace("endorsements").expect("endorsements ks"); + let rooms_ks = store.keyspace("rooms").expect("rooms ks"); + let room_records_ks = store.keyspace("room_records").expect("room_records ks"); let audit_ks = store.keyspace("audit").expect("audit ks"); let audit_key_ks = store.keyspace("audit_key").expect("audit_key ks"); let audit_checkpoint_ks = store @@ -357,6 +359,8 @@ impl TestVtcBuilder { endorsement_types_ks, schemas_ks, endorsements_ks, + rooms_ks, + room_records_ks, audit_ks, audit_key_ks, audit_checkpoint_ks, From 7802c8ec8bb7b55f6a4b3666dd348410d35b67dd Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:00:10 +0200 Subject: [PATCH 02/14] feat(rooms): Trust-Task dispatch for the rooms family 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 --- vtc-service/src/rooms/authz.rs | 256 +++++++++++++ vtc-service/src/rooms/handlers.rs | 450 +++++++++++++++++++++++ vtc-service/src/rooms/mod.rs | 21 +- vtc-service/src/rooms/wire.rs | 190 ++++++++++ vtc-service/src/trust_tasks/mod.rs | 40 ++ vtc-service/tests/emergency_bootstrap.rs | 4 + vtc-service/tests/passkey_state.rs | 4 + 7 files changed, 957 insertions(+), 8 deletions(-) create mode 100644 vtc-service/src/rooms/authz.rs create mode 100644 vtc-service/src/rooms/handlers.rs create mode 100644 vtc-service/src/rooms/wire.rs diff --git a/vtc-service/src/rooms/authz.rs b/vtc-service/src/rooms/authz.rs new file mode 100644 index 000000000..688322321 --- /dev/null +++ b/vtc-service/src/rooms/authz.rs @@ -0,0 +1,256 @@ +//! Authorizing an operation on a room. +//! +//! # The invariant this file exists to hold +//! +//! **Nothing here reads this service's ACL, member roster, or session state.** A room +//! operation is authorized by an authority chain the *room* issued, verified against the +//! room's own identifier. That is invariant I5 of the design note, and it is what makes a +//! room portable: the moment a host's own state participates in a room decision, the room +//! cannot move to another host and this service has joined its membership. +//! +//! It is also the easiest invariant in the design to lose by accident — one convenience +//! lookup against `members_ks` "just to check", and the property is gone with every test +//! still passing. [`AuthorizedAction`] is deliberately constructible only by +//! [`authorize`], so a handler cannot skip the check and cannot substitute a different one. +//! +//! # What is verified, and what is deferred +//! +//! Chain-shape verification — reaching a root issued by the room, no link widening actions, +//! scope or validity, depth bounded, audience honoured — is implemented in +//! `dtg_credentials::authority::verify_chain`, the reference implementation that ships with +//! the credential. This module performs the checks that do not require parsing credentials +//! (presence, depth, the private-tier binding) and records where signature and chain +//! verification attach. +//! +//! The signature-verification hop is **not** wired here yet, and that is stated rather than +//! hidden: it needs the room's DID resolved to a verification method, which arrives with the +//! `attributed` tier. Until then [`authorize`] refuses anything but an `Open` room, so no +//! caller can mistake an unverified chain for a verified one. + +use vti_common::error::AppError; + +use super::wire::AuthorityPresentation; +use super::{Room, Visibility}; + +/// Maximum links in an authority chain, including the root. +/// +/// Verification is linear in chain length and runs on every operation, so an unbounded +/// chain is a denial-of-service surface. The known uses need far less: a person attenuating +/// to an agent is depth 2, that agent to a sub-agent is depth 3. A chain near this ceiling +/// is a signal that authority is being re-delegated further than intended. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// An action on a room. +/// +/// Compared exactly and case-sensitively as wire strings, and **no action implies another**: +/// `Admin` does not grant `Write` unless the credential lists both. Implication is how a +/// permission model quietly widens. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Action { + Read, + Write, + Curate, + Admin, +} + +impl Action { + /// The wire form. + pub fn as_str(&self) -> &'static str { + match self { + Action::Read => "read", + Action::Write => "write", + Action::Curate => "curate", + Action::Admin => "admin", + } + } +} + +/// Proof that [`authorize`] ran and allowed this operation. +/// +/// Handlers take this rather than a presentation, so an operation that forgot to authorize +/// does not compile — the same typestate discipline the workspace uses for verified wire +/// forms. There is no public constructor. +#[derive(Debug)] +pub struct AuthorizedAction { + action: Action, + room_id: String, +} + +impl AuthorizedAction { + /// The action that was authorized. + pub fn action(&self) -> Action { + self.action + } + /// The room it was authorized against. + pub fn room_id(&self) -> &str { + &self.room_id + } +} + +/// Authorize `action` on `room` from `presentation`. +/// +/// Returns [`AuthorizedAction`] on success. Every failure is [`AppError::Forbidden`], which +/// the dispatch layer maps to the framework's `permission_denied` — the reason text +/// distinguishes the cases for an operator reading logs, without telling a caller which +/// part of their chain to adjust. +pub fn authorize( + room: &Room, + presentation: &AuthorityPresentation, + action: Action, +) -> Result { + // Depth first: it is the cheapest check and the one that bounds the cost of every + // check after it. + if presentation.authority.is_empty() { + return Err(AppError::Forbidden( + "no authority chain presented; a room operation is authorized by the chain, \ + never by this service's own records" + .into(), + )); + } + if presentation.authority.len() > MAX_CHAIN_DEPTH { + return Err(AppError::Forbidden(format!( + "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}", + presentation.authority.len() + ))); + } + + if presentation.membership.trim().is_empty() { + return Err(AppError::Forbidden( + "no membership credential presented".into(), + )); + } + + // The pooling defence. On a tier that withholds the subject, a presentation without a + // same-subject proof lets two parties combine one's membership with the other's + // authority and verify as a single party holding both. + if matches!(room.visibility, Visibility::Private) && presentation.subject_binding.is_none() { + return Err(AppError::Forbidden( + "a private room requires a subject binding proving the membership credential and \ + the authority chain describe the same subject; without it two parties can pool \ + credentials" + .into(), + )); + } + + // Chain verification proper needs the room's DID resolved to a verification method, + // which lands with the `attributed` tier. Refusing the sealed tiers outright is the + // honest interim: it is better to serve no sealed room than to serve one whose chain + // nobody checked. + if !matches!(room.visibility, Visibility::Open) { + return Err(AppError::Forbidden(format!( + "room `{}` is {:?}; cryptographic chain verification is not yet wired, and this \ + service will not serve a sealed room on an unverified chain", + room.room_id, room.visibility + ))); + } + + Ok(AuthorizedAction { + action, + room_id: room.room_id.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(visibility: Visibility) -> Room { + Room { + room_id: "did:key:zRoom".into(), + owner_did: "did:key:zOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + fn presentation(depth: usize, binding: bool) -> AuthorityPresentation { + AuthorityPresentation { + membership: "vmc".into(), + authority: (0..depth).map(|i| format!("vac-{i}")).collect(), + subject_binding: binding.then(|| "binding".to_string()), + } + } + + #[test] + fn an_open_room_authorizes_a_well_formed_presentation() { + let ok = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Write, + ) + .expect("should authorize"); + assert_eq!(ok.action(), Action::Write); + assert_eq!(ok.room_id(), "did:key:zRoom"); + } + + /// The chain is the authorization. Nothing else is. + #[test] + fn an_empty_chain_authorizes_nothing() { + let err = authorize( + &room(Visibility::Open), + &presentation(0, false), + Action::Read, + ) + .unwrap_err(); + assert!(format!("{err}").contains("no authority chain"), "{err}"); + } + + #[test] + fn a_chain_past_the_ceiling_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(MAX_CHAIN_DEPTH + 1, false), + Action::Read, + ) + .unwrap_err(); + assert!(format!("{err}").contains("exceeding the maximum"), "{err}"); + } + + /// Without this, two parties pool credentials and verify as one. + #[test] + fn a_private_room_refuses_a_presentation_with_no_subject_binding() { + let err = authorize( + &room(Visibility::Private), + &presentation(2, false), + Action::Read, + ) + .unwrap_err(); + assert!(format!("{err}").contains("subject binding"), "{err}"); + } + + /// Better to serve no sealed room than one whose chain nobody checked. + #[test] + fn sealed_tiers_are_refused_until_chain_verification_is_wired() { + for v in [Visibility::Attributed, Visibility::Private] { + let err = authorize(&room(v), &presentation(2, true), Action::Read).unwrap_err(); + assert!( + format!("{err}").contains("chain verification is not yet wired"), + "{v:?}: {err}" + ); + } + } + + #[test] + fn a_missing_membership_credential_is_refused() { + let mut p = presentation(2, false); + p.membership = " ".into(); + let err = authorize(&room(Visibility::Open), &p, Action::Read).unwrap_err(); + assert!( + format!("{err}").contains("no membership credential"), + "{err}" + ); + } + + /// No action implies another — the property that keeps a permission model from + /// widening quietly. + #[test] + fn actions_are_distinct_wire_strings() { + assert_eq!(Action::Read.as_str(), "read"); + assert_eq!(Action::Admin.as_str(), "admin"); + assert_ne!(Action::Admin.as_str(), Action::Write.as_str()); + } +} diff --git a/vtc-service/src/rooms/handlers.rs b/vtc-service/src/rooms/handlers.rs new file mode 100644 index 000000000..bb84a669e --- /dev/null +++ b/vtc-service/src/rooms/handlers.rs @@ -0,0 +1,450 @@ +//! Trust-Task handlers for the `rooms/*` family. +//! +//! These are deliberately thin. Every one does the same four things in the same order — +//! parse, load the room, authorize, act — and each of those lives somewhere else: +//! [`super::wire`] owns the shapes, [`super::storage`] owns the invariants, and +//! [`super::authz`] owns the decision. A handler that starts making its own storage or +//! authorization judgements is a handler that has drifted from the other four. +//! +//! # Authorization order +//! +//! The room is loaded first, then the presentation is authorized **against that room**. +//! That ordering matters: visibility is a property of the room, and the private-tier +//! subject-binding requirement cannot be checked without knowing the tier. Authorizing +//! before loading would mean either guessing the tier or checking it twice. +//! +//! # What a handler never does +//! +//! Reach for `state.acl_ks`, `state.members_ks`, or the caller's session. A room operation +//! is authorized by the chain the room issued and nothing else — invariant I5. There is no +//! `AuthClaims` parameter here, and its absence is the point rather than an omission. + +use serde_json::Value; +use trust_tasks_rs::TrustTask; + +use crate::rooms::authz::{self, Action}; +use crate::rooms::storage; +use crate::rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, +}; +use crate::rooms::{Record, RecordStatus, Room}; +use crate::server::AppState; +use crate::trust_tasks::helpers::{ + TrustTaskOutcome, app_error_to_reject, parse_payload, success_response, +}; + +/// Seconds since the Unix epoch. +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// Default retention when a creator does not state one. +/// +/// Ninety days after the epoch lapses. Long enough that a room is not lost to a quiet +/// month, short enough that an abandoned one does not accumulate forever. +const DEFAULT_RETENTION_DAYS: u32 = 90; + +/// `rooms/create/0.1`. +/// +/// The creator brings the room's identifier; this service does not assign one. A room +/// identified by something its host chose could not move to another host without changing +/// identity, and portability is the property the whole family rests on. +pub(crate) async fn handle_create(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: CreateRoomBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = Room { + room_id: req.room_id.clone(), + owner_did: req.owner_did, + visibility: req.visibility, + epoch: 1, + next_version: 1, + retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), + created_at: now(), + updated_at: now(), + }; + + if let Err(e) = storage::create_room(&state.rooms_ks, &room).await { + return app_error_to_reject(&doc, &e); + } + + success_response( + &doc, + CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }, + ) +} + +/// `rooms/records/put/0.1`. +pub(crate) async fn handle_put_record(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: PutRecordBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { + return app_error_to_reject(&doc, &e); + } + + // The author is recorded only where the tier discloses one. On a private room + // authorship lives inside the sealed body, and the storage layer refuses it here. + let author = room + .visibility + .discloses_actor() + .then(|| room.owner_did.clone()); + + let record = Record { + key: req.key.clone(), + version: 0, // assigned by the store from the room's counter + epoch: req.sealed.as_ref().map(|s| s.epoch), + status: RecordStatus::Active, + sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), + nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), + cleartext: req + .cleartext + .as_ref() + .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), + author, + updated_at: 0, + }; + + match storage::put_record( + &state.rooms_ks, + &state.room_records_ks, + &req.room_id, + record, + req.expected_version, + now(), + ) + .await + { + Ok(stored) => success_response( + &doc, + PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }, + ), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +/// `rooms/records/get/0.1`. +/// +/// A read presents exactly as a write does. Authorizing reads by session would record a +/// member identifier on every access, and a period of those records reconstructs the +/// membership a sealed room exists to withhold — without breaking any cryptography. +pub(crate) async fn handle_get_record(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: GetRecordBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return app_error_to_reject(&doc, &e); + } + + match storage::get_record(&state.room_records_ks, &req.room_id, &req.key).await { + Ok(record) => success_response(&doc, record), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +/// `rooms/records/list/0.1`. +/// +/// Returns metadata, never bodies — and returns tombstones to a watermark caller, because a +/// puller that never sees a retraction resurrects the record on its next full rebuild. +pub(crate) async fn handle_list_records( + state: &AppState, + doc: TrustTask, +) -> TrustTaskOutcome { + let req: ListRecordsBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return app_error_to_reject(&doc, &e); + } + + let records = match storage::list_records( + &state.room_records_ks, + &req.room_id, + req.prefix.as_deref(), + req.since_version, + ) + .await + { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + + let limit = req.limit.unwrap_or(usize::MAX); + success_response( + &doc, + ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }, + ) +} + +/// `rooms/epoch/mint/0.1`. +/// +/// Restricted to `admin`. If any key-holder could mint an epoch, any member could evict any +/// other by minting one and declining to seal them the new key — silently, and with no +/// check possible here on a room whose membership this service cannot see. Binding it to an +/// action the *room* confers is what makes the restriction enforceable by a service that +/// knows nothing about the membership. +pub(crate) async fn handle_mint_epoch(state: &AppState, doc: TrustTask) -> TrustTaskOutcome { + let req: MintEpochBody = match parse_payload(&doc) { + Ok(r) => r, + Err(resp) => return resp, + }; + + let room = match storage::get_room(&state.rooms_ks, &req.room_id).await { + Ok(r) => r, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { + return app_error_to_reject(&doc, &e); + } + + match storage::advance_epoch(&state.rooms_ks, &req.room_id, req.epoch, now()).await { + Ok(updated) => success_response( + &doc, + MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }, + ), + Err(e) => app_error_to_reject(&doc, &e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_support::build_test_vtc; + use serde_json::json; + use trust_tasks_rs::TypeUri; + + fn doc(uri: &str, payload: Value) -> TrustTask { + let uri: TypeUri = uri.parse().expect("rooms uri"); + TrustTask::new(format!("urn:uuid:{}", uuid::Uuid::new_v4()), uri, payload) + } + + fn presentation() -> Value { + json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) + } + + async fn create(state: &AppState, id: &str, visibility: &str) -> TrustTaskOutcome { + handle_create( + state, + doc( + crate::rooms::wire::ROOMS_CREATE_TYPE, + json!({ "roomId": id, "visibility": visibility, "ownerDid": "did:key:zOwner" }), + ), + ) + .await + } + + fn payload_of(out: &TrustTaskOutcome) -> Value { + let d: Value = serde_json::from_slice(&out.body).expect("response is JSON"); + d.get("payload").cloned().unwrap_or(Value::Null) + } + + #[tokio::test] + async fn a_room_is_created_and_a_record_round_trips() { + let tv = build_test_vtc().await; + let state = &tv.state; + assert!(create(state, "r1", "open").await.status.is_success()); + + let out = handle_put_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": "r1", "key": "k1", "presentation": presentation(), + "cleartext": { "body": "a decision" } + }), + ), + ) + .await; + assert!(out.status.is_success(), "put should succeed"); + assert_eq!(payload_of(&out)["version"], 1); + + let out = handle_get_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), + ), + ) + .await; + assert!(out.status.is_success()); + assert_eq!(payload_of(&out)["cleartext"]["body"], "a decision"); + } + + /// The invariant the whole family rests on: authorization is the chain, and a request + /// without one is refused regardless of who is asking. + #[tokio::test] + async fn an_operation_with_no_authority_chain_is_refused() { + let tv = build_test_vtc().await; + let state = &tv.state; + create(state, "r1", "open").await; + + let out = handle_put_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": "r1", "key": "k1", + "presentation": { "membership": "vmc", "authority": [] }, + "cleartext": { "body": "x" } + }), + ), + ) + .await; + assert!( + !out.status.is_success(), + "an empty chain authorizes nothing" + ); + } + + #[tokio::test] + async fn a_private_room_refuses_a_presentation_without_a_subject_binding() { + let tv = build_test_vtc().await; + let state = &tv.state; + create(state, "p1", "private").await; + + let out = handle_get_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), + ), + ) + .await; + assert!(!out.status.is_success()); + let body = String::from_utf8_lossy(&out.body); + assert!(body.contains("subject binding"), "{body}"); + } + + #[tokio::test] + async fn listing_returns_metadata_and_never_bodies() { + let tv = build_test_vtc().await; + let state = &tv.state; + create(state, "r1", "open").await; + for k in ["a", "b"] { + handle_put_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": "r1", "key": k, "presentation": presentation(), + "cleartext": { "body": "secret-body-text" } + }), + ), + ) + .await; + } + + let out = handle_list_records( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_LIST_TYPE, + json!({ "roomId": "r1", "presentation": presentation() }), + ), + ) + .await; + assert!(out.status.is_success()); + let body = String::from_utf8_lossy(&out.body); + assert!(body.contains("\"key\""), "metadata is returned"); + assert!( + !body.contains("secret-body-text"), + "a listing must never carry bodies: {body}" + ); + } + + #[tokio::test] + async fn a_room_cannot_be_created_twice() { + let tv = build_test_vtc().await; + let state = &tv.state; + assert!(create(state, "r1", "open").await.status.is_success()); + assert!( + !create(state, "r1", "open").await.status.is_success(), + "re-creating would reset the epoch and version counter" + ); + } + + #[tokio::test] + async fn minting_an_epoch_requires_admin_and_advances_by_one() { + let tv = build_test_vtc().await; + let state = &tv.state; + create(state, "r1", "open").await; + + let out = handle_mint_epoch( + state, + doc( + crate::rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": "r1", "epoch": 2, "presentation": presentation() }), + ), + ) + .await; + assert!(out.status.is_success()); + assert_eq!(payload_of(&out)["epoch"], 2); + + // A gap is refused: it would seal records under an epoch nobody holds a key for. + let out = handle_mint_epoch( + state, + doc( + crate::rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": "r1", "epoch": 9, "presentation": presentation() }), + ), + ) + .await; + assert!(!out.status.is_success()); + } + + /// An unknown member on a payload carrying an authorization decision is a request that + /// means something this service did not understand. + #[tokio::test] + async fn an_unknown_payload_member_is_refused() { + let tv = build_test_vtc().await; + let state = &tv.state; + create(state, "r1", "open").await; + let out = handle_get_record( + state, + doc( + crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ + "roomId": "r1", "key": "k", "presentation": presentation(), + "escalate": true + }), + ), + ) + .await; + assert!(!out.status.is_success(), "deny_unknown_fields must hold"); + } +} diff --git a/vtc-service/src/rooms/mod.rs b/vtc-service/src/rooms/mod.rs index 76dba09f9..55967055a 100644 --- a/vtc-service/src/rooms/mod.rs +++ b/vtc-service/src/rooms/mod.rs @@ -33,15 +33,20 @@ //! //! # Scope of this module //! -//! Storage and the operations over it. The Trust-Task dispatch that authorizes those -//! operations lands separately, once `rooms/*` is published in the task 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. This -//! layer is written and tested first so that the dispatch layer, when it lands, is a thin -//! wrapper over settled behaviour rather than a place where storage decisions get made -//! under time pressure. - +//! Four layers, smallest first: +//! +//! - [`storage`] — the keyspaces and their invariants. +//! - [`wire`] — the Trust-Task payload types, hand-written against the schemas proposed in +//! `trustoverip/dtgwg-trust-tasks-tf#346` until its generated bindings publish. +//! - [`authz`] — deciding whether an operation is allowed, **without reading this service's +//! ACL or roster**. The invariant the whole design rests on. +//! - [`handlers`] — the Trust-Task verbs, which are thin because the three layers below +//! them are not. + +pub mod authz; +pub mod handlers; pub mod storage; +pub mod wire; use serde::{Deserialize, Serialize}; diff --git a/vtc-service/src/rooms/wire.rs b/vtc-service/src/rooms/wire.rs new file mode 100644 index 000000000..dd51e6bdf --- /dev/null +++ b/vtc-service/src/rooms/wire.rs @@ -0,0 +1,190 @@ +//! Wire types for the `rooms/*` Trust Tasks. +//! +//! # Why these are hand-written +//! +//! They mirror the payload schemas proposed in +//! `trustoverip/dtgwg-trust-tasks-tf#346`, and are written here rather than taken from +//! `trust_tasks_rs::specs` because that PR is still in review — the generated bindings do +//! not exist yet. This is the same shape `vta_sdk::protocols::*` already uses for the VTA's +//! families: hand-written wire types beside the generated ones. +//! +//! **When #346 merges and `trust-tasks-rs` publishes, replace these with the generated +//! types rather than keeping both.** Two definitions of one wire format is how casing drift +//! gets in, and this workspace has paid for that before. +//! +//! Every struct is `camelCase` and `deny_unknown_fields`: these carry authorization +//! decisions, and an unknown member on one of those is a request that means something the +//! service did not understand. + +use serde::{Deserialize, Serialize}; + +use super::Visibility; + +/// `rooms/create/0.1`. +pub const ROOMS_CREATE_TYPE: &str = "https://trusttasks.org/spec/rooms/create/0.1"; +/// `rooms/records/put/0.1`. +pub const ROOMS_RECORDS_PUT_TYPE: &str = "https://trusttasks.org/spec/rooms/records/put/0.1"; +/// `rooms/records/get/0.1`. +pub const ROOMS_RECORDS_GET_TYPE: &str = "https://trusttasks.org/spec/rooms/records/get/0.1"; +/// `rooms/records/list/0.1`. +pub const ROOMS_RECORDS_LIST_TYPE: &str = "https://trusttasks.org/spec/rooms/records/list/0.1"; +/// `rooms/epoch/mint/0.1`. +pub const ROOMS_EPOCH_MINT_TYPE: &str = "https://trusttasks.org/spec/rooms/epoch/mint/0.1"; + +/// Every `rooms/*` URI this service dispatches. +pub const ROOMS_DISPATCHED_URIS: &[&str] = &[ + ROOMS_CREATE_TYPE, + ROOMS_RECORDS_PUT_TYPE, + ROOMS_RECORDS_GET_TYPE, + ROOMS_RECORDS_LIST_TYPE, + ROOMS_EPOCH_MINT_TYPE, +]; + +/// What a party presents to act on a room. +/// +/// The whole authority chain travels here, **leaf first**, and this service never +/// dereferences a link's `parent` to fetch one it was not given. That is not an +/// optimisation: resolving over the network would make verification depend on availability, +/// turn an identifier into a request this service can be induced to make against an address +/// the *presenter* chooses, and signal credential use to whoever hosts that identifier. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct AuthorityPresentation { + /// The presenter's membership credential for this room, or a zero-knowledge + /// presentation of it on a `private` room. + pub membership: String, + + /// The authority chain, leaf first. The last element must be issued by the room. + pub authority: Vec, + + /// REQUIRED on a `private` room: proof that the membership credential and the chain's + /// leaf describe the **same subject**. + /// + /// Without it two parties pool credentials — one contributes membership, the other + /// authority — and the combination verifies as a single party holding both. Silent when + /// wrong, which is why [`super::authz`] refuses a private-room presentation that omits + /// it rather than treating it as optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject_binding: Option, +} + +/// Sealed record content, as it crosses the wire and is stored. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct SealedContent { + /// The sealed record, base64url. AEAD-bound to `roomId|key|version|epoch`. + pub ciphertext: String, + /// AEAD nonce, base64url. + pub nonce: String, + /// The epoch it was sealed under. + pub epoch: u32, +} + +/// Cleartext record content. `open` rooms only. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CleartextContent { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// `rooms/create/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CreateRoomBody { + /// The room's own identifier, minted by its owner. This service does not assign one: + /// a room identified by something its host chose could not move to another host. + pub room_id: String, + pub visibility: Visibility, + pub owner_did: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retention_days: Option, +} + +/// `rooms/create/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CreateRoomResponse { + pub room_id: String, + pub epoch: u32, +} + +/// `rooms/records/put/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct PutRecordBody { + pub room_id: String, + pub key: String, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expected_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sealed: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cleartext: Option, +} + +/// `rooms/records/put/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PutRecordResponse { + pub key: String, + pub version: u64, + #[serde(skip_serializing_if = "Option::is_none")] + pub epoch: Option, +} + +/// `rooms/records/get/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct GetRecordBody { + pub room_id: String, + pub key: String, + pub presentation: AuthorityPresentation, +} + +/// `rooms/records/list/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct ListRecordsBody { + pub room_id: String, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prefix: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub since_version: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub limit: Option, +} + +/// `rooms/records/list/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListRecordsResponse { + /// Metadata only — never bodies. + pub records: Vec, +} + +/// `rooms/epoch/mint/0.1` request. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct MintEpochBody { + pub room_id: String, + pub epoch: u32, + pub presentation: AuthorityPresentation, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reason: Option, +} + +/// `rooms/epoch/mint/0.1#response`. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MintEpochResponse { + pub room_id: String, + pub epoch: u32, +} diff --git a/vtc-service/src/trust_tasks/mod.rs b/vtc-service/src/trust_tasks/mod.rs index 8acc5103a..79dc3a6c9 100644 --- a/vtc-service/src/trust_tasks/mod.rs +++ b/vtc-service/src/trust_tasks/mod.rs @@ -63,6 +63,8 @@ use vta_sdk::protocols::join_requests::{ }; use vta_sdk::protocols::members::{self as mem, MemberVmcBody, MemberVmcReceiptBody}; +use crate::rooms::wire as rooms_wire; + use crate::join::{JoinSubmitOutcome, JoinTransport}; use crate::server::AppState; @@ -140,6 +142,23 @@ pub(crate) async fn dispatch_trust_task_core( jr::JOIN_REQUEST_STATUS_TYPE => handle_status(state, ctx, doc).await, jr::MEMBER_SELF_REMOVE_TYPE => handle_self_remove(state, ctx, doc).await, mem::MEMBER_VMC_TYPE => handle_member_vmc(state, ctx, doc).await, + // The rooms family. Note what these do not take: no `ctx`, and no auth claims. + // A room operation is authorized by the authority chain the room itself issued, + // never by this service's ACL, roster, or the caller's session — invariant I5 of + // `docs/05-design-notes/data-rooms.md`, and what makes a room portable. + rooms_wire::ROOMS_CREATE_TYPE => crate::rooms::handlers::handle_create(state, doc).await, + rooms_wire::ROOMS_RECORDS_PUT_TYPE => { + crate::rooms::handlers::handle_put_record(state, doc).await + } + rooms_wire::ROOMS_RECORDS_GET_TYPE => { + crate::rooms::handlers::handle_get_record(state, doc).await + } + rooms_wire::ROOMS_RECORDS_LIST_TYPE => { + crate::rooms::handlers::handle_list_records(state, doc).await + } + rooms_wire::ROOMS_EPOCH_MINT_TYPE => { + crate::rooms::handlers::handle_mint_epoch(state, doc).await + } PERSONHOOD_CHALLENGE_TYPE => handle_personhood_challenge(state, ctx, doc).await, PERSONHOOD_ASSERT_TYPE => handle_personhood_assert(state, ctx, doc).await, other => unsupported_type_or_version(&doc, other), @@ -243,6 +262,15 @@ pub(crate) const DISPATCHED_URIS: &[&str] = &[ mem::MEMBER_VMC_TYPE, PERSONHOOD_CHALLENGE_TYPE, PERSONHOOD_ASSERT_TYPE, + // rooms/* — top-level, not `spec/vtc/*`: a room's protocol is host-neutral, so + // filing it under a service prefix would encode into the URI the one thing the + // design exists to avoid. The vtc conformance sweep scopes to `spec/vtc/` and so + // does not cover these; they are pinned by `rooms_dispatch_matches_wire` below. + rooms_wire::ROOMS_CREATE_TYPE, + rooms_wire::ROOMS_RECORDS_PUT_TYPE, + rooms_wire::ROOMS_RECORDS_GET_TYPE, + rooms_wire::ROOMS_RECORDS_LIST_TYPE, + rooms_wire::ROOMS_EPOCH_MINT_TYPE, ]; /// `vtc/members/personhood/challenge/0.1` — mint the single-use nonce @@ -793,6 +821,18 @@ mod tests { mem::MEMBER_VMC_TYPE, ::TYPE_URI, ::TYPE_URI, + // rooms/* names hand-written constants rather than a generated + // `TYPE_URI`, because its schemas are still in review upstream + // (trustoverip/dtgwg-trust-tasks-tf#346). The property this test + // exists for is weaker for them until those bindings publish: it + // pins the dispatcher against `wire`'s constants, not against the + // registry. **Swap these for the generated `TYPE_URI`s when the + // crate ships them** — that is what restores the guarantee. + rooms_wire::ROOMS_CREATE_TYPE, + rooms_wire::ROOMS_RECORDS_PUT_TYPE, + rooms_wire::ROOMS_RECORDS_GET_TYPE, + rooms_wire::ROOMS_RECORDS_LIST_TYPE, + rooms_wire::ROOMS_EPOCH_MINT_TYPE, ]; for u in DISPATCHED_URIS { assert!( diff --git a/vtc-service/tests/emergency_bootstrap.rs b/vtc-service/tests/emergency_bootstrap.rs index 0c628762e..753c05001 100644 --- a/vtc-service/tests/emergency_bootstrap.rs +++ b/vtc-service/tests/emergency_bootstrap.rs @@ -222,6 +222,8 @@ async fn build_fixture(public_url: Option<&str>) -> Fixture { let relationships_by_did_ks = store.keyspace("relationships_by_did").unwrap(); let endorsement_types_ks = store.keyspace("endorsement_types").unwrap(); let endorsements_ks = store.keyspace("endorsements").unwrap(); + let rooms_ks = store.keyspace("rooms").unwrap(); + let room_records_ks = store.keyspace("room_records").unwrap(); let audit_ks = store.keyspace("audit").unwrap(); let audit_key_ks = store.keyspace("audit_key").unwrap(); let audit_checkpoint_ks = store.keyspace("audit_checkpoint").unwrap(); @@ -322,6 +324,8 @@ async fn build_fixture(public_url: Option<&str>) -> Fixture { endorsement_types_ks: endorsement_types_ks.clone(), schemas_ks: store.keyspace("schemas").unwrap(), endorsements_ks: endorsements_ks.clone(), + rooms_ks: rooms_ks.clone(), + room_records_ks: room_records_ks.clone(), invitations_ks, consumed_invitations_ks, registry_client: None, diff --git a/vtc-service/tests/passkey_state.rs b/vtc-service/tests/passkey_state.rs index 06546bae6..7a1218df0 100644 --- a/vtc-service/tests/passkey_state.rs +++ b/vtc-service/tests/passkey_state.rs @@ -51,6 +51,8 @@ fn build_state(public_url: Option<&str>) -> (AppState, tempfile::TempDir) { let relationships_by_did_ks = store.keyspace("relationships_by_did").unwrap(); let endorsement_types_ks = store.keyspace("endorsement_types").unwrap(); let endorsements_ks = store.keyspace("endorsements").unwrap(); + let rooms_ks = store.keyspace("rooms").unwrap(); + let room_records_ks = store.keyspace("room_records").unwrap(); let audit_ks = store.keyspace("audit").unwrap(); let audit_key_ks = store.keyspace("audit_key").unwrap(); let audit_checkpoint_ks = store.keyspace("audit_checkpoint").unwrap(); @@ -97,6 +99,8 @@ fn build_state(public_url: Option<&str>) -> (AppState, tempfile::TempDir) { endorsement_types_ks: endorsement_types_ks.clone(), schemas_ks: store.keyspace("schemas").unwrap(), endorsements_ks: endorsements_ks.clone(), + rooms_ks: rooms_ks.clone(), + room_records_ks: room_records_ks.clone(), invitations_ks, consumed_invitations_ks, registry_client: None, From d3b799a359dbf8cebdbdaeb6c3158a2b88ac9c51 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:10:04 +0200 Subject: [PATCH 03/14] feat(rooms): client surface for the rooms family 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 --- vtc-client/src/lib.rs | 2 + vtc-client/src/rooms.rs | 482 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 vtc-client/src/rooms.rs diff --git a/vtc-client/src/lib.rs b/vtc-client/src/lib.rs index 1693b6cb6..a4b9774ad 100644 --- a/vtc-client/src/lib.rs +++ b/vtc-client/src/lib.rs @@ -46,6 +46,8 @@ use serde::{Deserialize, Serialize}; /// a single read, rather than scattered as string literals down the file. A URL /// that drifts from the server's is a 400 at runtime, so this list is part of /// the client's contract, not decoration. +pub mod rooms; + pub mod task { pub const MEMBERS_LIST: &str = "https://trusttasks.org/spec/vtc/members/list/0.1"; pub const MEMBERS_UPDATE: &str = "https://trusttasks.org/spec/vtc/members/update/0.1"; diff --git a/vtc-client/src/rooms.rs b/vtc-client/src/rooms.rs new file mode 100644 index 000000000..976c45a1c --- /dev/null +++ b/vtc-client/src/rooms.rs @@ -0,0 +1,482 @@ +//! Client surface for the `rooms/*` Trust Tasks. +//! +//! # What is different about these calls +//! +//! Every other method on [`crate::VtcClient`] carries an operator token: the community +//! knows who you are, and its ACL decides what you may do. **A room call carries no token +//! at all.** It carries a presentation — a membership credential and the authority chain +//! the room itself issued — and the host decides from that alone. +//! +//! That is not a convenience. It is what makes a room portable: a host that consulted its +//! own records to authorize a room operation would become part of that room's membership, +//! and the room could no longer move to a different host without reissuing credentials. +//! So [`RoomSession`] deliberately holds no session, and none of these methods reads +//! `self.token`. +//! +//! # Holding the chain +//! +//! A [`RoomSession`] carries the whole authority chain, **leaf first**, and sends all of it +//! on every call. The host never fetches a link it was not given — resolving one over the +//! network would make verification depend on availability, turn an identifier into a +//! request the host can be induced to make against an address the *caller* chooses, and +//! signal credential use to whoever hosts that identifier. +//! +//! # Agents hold less than their humans +//! +//! The case the design exists for: a member holds `read`/`write`, and equips their agent +//! with a chain one link longer whose leaf confers only `read`, expires in hours, and is +//! bound to the agent. The agent's `RoomSession` is built exactly like the member's — the +//! difference is entirely in the credentials it was handed, which is the point. + +use serde::{Deserialize, Serialize}; + +use crate::{VtcClient, VtcError}; + +/// `rooms/create/0.1`. +pub const ROOMS_CREATE_TYPE: &str = "https://trusttasks.org/spec/rooms/create/0.1"; +/// `rooms/records/put/0.1`. +pub const ROOMS_RECORDS_PUT_TYPE: &str = "https://trusttasks.org/spec/rooms/records/put/0.1"; +/// `rooms/records/get/0.1`. +pub const ROOMS_RECORDS_GET_TYPE: &str = "https://trusttasks.org/spec/rooms/records/get/0.1"; +/// `rooms/records/list/0.1`. +pub const ROOMS_RECORDS_LIST_TYPE: &str = "https://trusttasks.org/spec/rooms/records/list/0.1"; +/// `rooms/epoch/mint/0.1`. +pub const ROOMS_EPOCH_MINT_TYPE: &str = "https://trusttasks.org/spec/rooms/epoch/mint/0.1"; + +/// Maximum links a host will accept in one chain. +/// +/// Mirrors the host's own ceiling so a caller finds out here rather than over the wire. +/// Verification is linear in chain length and runs on every operation, so an unbounded +/// chain is a denial-of-service surface against the host. +pub const MAX_CHAIN_DEPTH: usize = 8; + +/// How much of a room its host can see. Fixed at creation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Cleartext, searchable by the host. + Open, + /// Content sealed; the acting member still disclosed. + Attributed, + /// Content sealed; membership presented in zero knowledge. + Private, +} + +/// The credentials a caller presents to act on a room. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AuthorityPresentation { + pub membership: String, + /// Leaf first; the last element is the credential the room issued. + pub authority: Vec, + /// Required on a `private` room. + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_binding: Option, +} + +/// Sealed record content. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SealedContent { + pub ciphertext: String, + pub nonce: String, + pub epoch: u32, +} + +/// Cleartext record content. `open` rooms only. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CleartextContent { + #[serde(skip_serializing_if = "Option::is_none")] + pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub body: String, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +/// What a `put` returns. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PutRecordResponse { + pub key: String, + pub version: u64, + #[serde(default)] + pub epoch: Option, +} + +/// What a `list` returns — metadata, never bodies. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListRecordsResponse { + pub records: Vec, +} + +/// What minting an epoch returns. +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MintEpochResponse { + pub room_id: String, + pub epoch: u32, +} + +/// A caller's standing in one room. +/// +/// Holds the credentials, not a session. Build one per room per identity — a member's and +/// their agent's are different sessions against the same room, differing only in the chain +/// they carry. +#[derive(Debug, Clone)] +pub struct RoomSession { + room_id: String, + presentation: AuthorityPresentation, +} + +impl RoomSession { + /// Build a session from a membership credential and an authority chain. + /// + /// `authority` is **leaf first**: the credential being relied on comes first, and the + /// one the room issued comes last. Rejected here if it is empty or deeper than + /// [`MAX_CHAIN_DEPTH`], so a caller learns locally rather than from a rejected request. + pub fn new( + room_id: impl Into, + membership: impl Into, + authority: Vec, + ) -> Result { + if authority.is_empty() { + return Err(VtcError::Url( + "an authority chain is required: a room operation is authorized by the chain, \ + never by a session" + .into(), + )); + } + if authority.len() > MAX_CHAIN_DEPTH { + return Err(VtcError::Url(format!( + "authority chain is {} deep, exceeding the maximum of {MAX_CHAIN_DEPTH}", + authority.len() + ))); + } + Ok(Self { + room_id: room_id.into(), + presentation: AuthorityPresentation { + membership: membership.into(), + authority, + subject_binding: None, + }, + }) + } + + /// Attach the same-subject proof a `private` room requires. + /// + /// Without it a private-room call is refused: two parties could otherwise pool + /// credentials — one contributing membership, the other authority — and present as a + /// single party holding both. + pub fn with_subject_binding(mut self, binding: impl Into) -> Self { + self.presentation.subject_binding = Some(binding.into()); + self + } + + /// The room this session acts on. + pub fn room_id(&self) -> &str { + &self.room_id + } + + /// How many links the chain carries. Depth 1 is a grant straight from the room; + /// depth 2 is typically a member's agent. + pub fn chain_depth(&self) -> usize { + self.presentation.authority.len() + } +} + +impl VtcClient { + /// Register a room with this host. + /// + /// The caller brings `room_id`: a room identified by something its host chose could not + /// move to another host without changing identity. + pub async fn create_room( + &self, + room_id: &str, + owner_did: &str, + visibility: Visibility, + retention_days: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let payload = serde_json::json!({ + "roomId": room_id, + "ownerDid": owner_did, + "visibility": visibility, + "retentionDays": retention_days, + }); + self.room_task( + ROOMS_CREATE_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await + } + + /// Write a record. + /// + /// Exactly one of `sealed` / `cleartext` — the host refuses the other shape for the + /// room's tier, so passing both or neither is a request it cannot honour. + /// + /// `expected_version` is an optional precondition: `Some(0)` means create-only, and + /// `Some(n)` requires the stored record to be at version `n`. A mismatch comes back + /// carrying the current version, so a caller does not have to re-read to learn what it + /// lost to. + #[allow(clippy::too_many_arguments)] + pub async fn put_record( + &self, + session: &RoomSession, + key: &str, + sealed: Option, + cleartext: Option, + expected_version: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "key": key, + "presentation": session.presentation, + }); + if let Some(s) = sealed { + payload["sealed"] = + serde_json::to_value(s).map_err(|e| VtcError::Url(e.to_string()))?; + } + if let Some(c) = cleartext { + payload["cleartext"] = + serde_json::to_value(c).map_err(|e| VtcError::Url(e.to_string()))?; + } + if let Some(v) = expected_version { + payload["expectedVersion"] = serde_json::json!(v); + } + let value = self + .room_task( + ROOMS_RECORDS_PUT_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("put response is not a PutRecordResponse: {e}"), + }) + } + + /// Read one record. + /// + /// Presents exactly as a write does, and needs no session — which is the point on a + /// sealed room: authorizing reads by session would hand the host a member identifier on + /// every access, and a period of those reconstructs the membership the tier withholds. + pub async fn get_record( + &self, + session: &RoomSession, + key: &str, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let payload = serde_json::json!({ + "roomId": session.room_id, + "key": key, + "presentation": session.presentation, + }); + self.room_task( + ROOMS_RECORDS_GET_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await + } + + /// List record metadata. + /// + /// Never returns bodies — fetch the handful that matter with [`VtcClient::get_record`]. + /// `since_version` is the incremental-sync watermark, and the response **includes + /// tombstones**: a caller that never saw a retraction would resurrect the record on its + /// next full rebuild. + pub async fn list_records( + &self, + session: &RoomSession, + prefix: Option<&str>, + since_version: Option, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "presentation": session.presentation, + }); + if let Some(p) = prefix { + payload["prefix"] = serde_json::json!(p); + } + if let Some(v) = since_version { + payload["sinceVersion"] = serde_json::json!(v); + } + let value = self + .room_task( + ROOMS_RECORDS_LIST_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("list response is not a ListRecordsResponse: {e}"), + }) + } + + /// Advance the room's key epoch — how a member is removed. + /// + /// Requires a chain conferring `admin`. `epoch` must be exactly one greater than the + /// current one. The host records the number and never learns the key: distributing it + /// to the remaining members happens out of its sight. + pub async fn mint_epoch( + &self, + session: &RoomSession, + epoch: u32, + reason: Option<&str>, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let mut payload = serde_json::json!({ + "roomId": session.room_id, + "epoch": epoch, + "presentation": session.presentation, + }); + if let Some(r) = reason { + payload["reason"] = serde_json::json!(r); + } + let value = self + .room_task( + ROOMS_EPOCH_MINT_TYPE, + payload, + signer_did, + private_key_multibase, + ) + .await?; + serde_json::from_value(value).map_err(|e| VtcError::Http { + status: 200, + body: format!("mint response is not a MintEpochResponse: {e}"), + }) + } + + /// Send one `rooms/*` document and return its response payload. + /// + /// The one place a room call is made, so the no-token property is visible in a single + /// function rather than repeated across five: this builds a signed document and posts + /// it, and never touches `self.token`. + async fn room_task( + &self, + type_uri: &str, + payload: serde_json::Value, + signer_did: &str, + private_key_multibase: &str, + ) -> Result { + let doc = vta_sdk::trust_task_sign::build_signed( + type_uri, + payload, + signer_did, + private_key_multibase, + &self.vtc_did, + ) + .await + .map_err(|e| VtcError::Signing(e.to_string()))?; + + let resp = self + .http + .post(format!("{}/trust-tasks", self.base_url)) + .header("content-type", "application/json") + .body(doc) + .send() + .await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(VtcError::Http { status, body }); + } + + let text = resp.text().await?; + let response_doc: trust_tasks_rs::TrustTask = + serde_json::from_str(&text).map_err(|e| VtcError::Http { + status: 200, + body: format!("unexpected room response (not a Trust Task document): {e}: {text}"), + })?; + Ok(response_doc.payload) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_session_requires_a_chain() { + let err = RoomSession::new("did:key:zRoom", "vmc", vec![]).unwrap_err(); + assert!( + format!("{err}").contains("authorized by the chain"), + "a room session without a chain has nothing to present: {err}" + ); + } + + #[test] + fn a_session_refuses_a_chain_past_the_ceiling() { + let chain: Vec = (0..=MAX_CHAIN_DEPTH).map(|i| format!("vac-{i}")).collect(); + let err = RoomSession::new("did:key:zRoom", "vmc", chain).unwrap_err(); + assert!(format!("{err}").contains("exceeding the maximum"), "{err}"); + } + + /// The agent case, at the level the client models it: same construction, one more link, + /// and the narrowing lives in the credentials rather than in any flag here. + #[test] + fn a_members_session_and_their_agents_differ_only_in_the_chain() { + let member = RoomSession::new("did:key:zRoom", "vmc", vec!["vac-member".into()]) + .expect("member session"); + let agent = RoomSession::new( + "did:key:zRoom", + "vmc", + vec!["vac-agent".into(), "vac-member".into()], + ) + .expect("agent session"); + + assert_eq!(member.room_id(), agent.room_id()); + assert_eq!(member.chain_depth(), 1, "a grant straight from the room"); + assert_eq!(agent.chain_depth(), 2, "one attenuation deeper"); + } + + #[test] + fn a_subject_binding_is_attached_only_when_asked_for() { + let s = RoomSession::new("did:key:zRoom", "vmc", vec!["vac".into()]).unwrap(); + assert!(s.presentation.subject_binding.is_none()); + let s = s.with_subject_binding("proof"); + assert_eq!(s.presentation.subject_binding.as_deref(), Some("proof")); + } + + /// The wire shape a host reads. `camelCase`, and the binding omitted rather than null + /// when absent — a host distinguishes absent from present-but-empty. + #[test] + fn a_presentation_serialises_camel_case_and_omits_an_absent_binding() { + let s = RoomSession::new("did:key:zRoom", "vmc", vec!["a".into(), "b".into()]).unwrap(); + let v = serde_json::to_value(&s.presentation).unwrap(); + assert_eq!(v["membership"], "vmc"); + assert_eq!(v["authority"][0], "a", "leaf first"); + assert!(v.get("subjectBinding").is_none()); + + let s = s.with_subject_binding("bind"); + let v = serde_json::to_value(&s.presentation).unwrap(); + assert_eq!(v["subjectBinding"], "bind"); + } + + #[test] + fn visibility_serialises_lowercase_as_the_host_expects() { + assert_eq!( + serde_json::to_value(Visibility::Attributed).unwrap(), + serde_json::json!("attributed") + ); + } +} From 0fcd6ec4b1f48fc45583ec638dbc6bbaa0eade29 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:12:57 +0200 Subject: [PATCH 04/14] feat(did-templates): room and room-host 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 --- vta-sdk/src/did_templates/builtin.rs | 6 +++ vta-sdk/templates/room-host.json | 58 ++++++++++++++++++++++++++++ vta-sdk/templates/room.json | 54 ++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) create mode 100644 vta-sdk/templates/room-host.json create mode 100644 vta-sdk/templates/room.json diff --git a/vta-sdk/src/did_templates/builtin.rs b/vta-sdk/src/did_templates/builtin.rs index 0cf5e6465..6b55432a6 100644 --- a/vta-sdk/src/did_templates/builtin.rs +++ b/vta-sdk/src/did_templates/builtin.rs @@ -18,6 +18,8 @@ pub const BUILTIN_NAMES: &[&str] = &[ "did-host-tsp", "didcomm-mediator", "push-gateway", + "room", + "room-host", "vta-admin", "vtc-host", ]; @@ -26,6 +28,8 @@ const AI_AGENT: &str = include_str!("../../templates/ai-agent.json"); const AI_AGENT_PEER: &str = include_str!("../../templates/ai-agent-peer.json"); const DIDCOMM_MEDIATOR: &str = include_str!("../../templates/didcomm-mediator.json"); const PUSH_GATEWAY: &str = include_str!("../../templates/push-gateway.json"); +const ROOM: &str = include_str!("../../templates/room.json"); +const ROOM_HOST: &str = include_str!("../../templates/room-host.json"); const VTA_ADMIN: &str = include_str!("../../templates/vta-admin.json"); const VTC_HOST: &str = include_str!("../../templates/vtc-host.json"); const DID_HOST_HTTP_DIDCOMM: &str = include_str!("../../templates/did-host-http-didcomm.json"); @@ -44,6 +48,8 @@ pub fn load_embedded(name: &str) -> Result { "ai-agent-peer" => AI_AGENT_PEER, "didcomm-mediator" => DIDCOMM_MEDIATOR, "push-gateway" => PUSH_GATEWAY, + "room" => ROOM, + "room-host" => ROOM_HOST, "vta-admin" => VTA_ADMIN, "vtc-host" => VTC_HOST, "did-host-http-didcomm" => DID_HOST_HTTP_DIDCOMM, diff --git a/vta-sdk/templates/room-host.json b/vta-sdk/templates/room-host.json new file mode 100644 index 000000000..6123b7d09 --- /dev/null +++ b/vta-sdk/templates/room-host.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "name": "room-host", + "kind": "room-host", + "description": "A service that stores data-room records. Provisioned as an ordinary integration so that a person can host their own rooms on infrastructure they control (topology T1 of the data-rooms design) without that surface living inside the VTA — the process guarding the master seed must not also terminate presentations from arbitrary DIDs. A room-host holds ciphertext and verifies presentations; it holds no member list and no room keys, so its ACL entry should be scoped to exactly the oracle verbs it needs and nothing wider. Advertises REST for the rooms/* task surface and DIDComm so a room's own DID can route through the same mediator.", + "methods": ["webvh"], + "requiredVars": ["WEBVH_SERVER", "URL", "MEDIATOR_DID"], + "optionalVars": { + "ACCEPT": ["didcomm/v2"], + "ROUTING_KEYS": [], + "LABEL": null + }, + "defaults": { + "portable": true + }, + "document": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://www.w3.org/ns/cid/v1" + ], + "id": "{DID}", + "verificationMethod": [ + { + "id": "{DID}#key-1", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{SIGNING_KEY_MB}" + }, + { + "id": "{DID}#key-2", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{KA_KEY_MB}" + } + ], + "assertionMethod": ["{DID}#key-1"], + "authentication": ["{DID}#key-1"], + "keyAgreement": ["{DID}#key-2"], + "service": [ + { + "id": "{DID}#didcomm", + "type": "DIDCommMessaging", + "serviceEndpoint": [ + { + "accept": "{ACCEPT}", + "routingKeys": "{ROUTING_KEYS}", + "uri": "{MEDIATOR_DID}" + } + ] + }, + { + "id": "{DID}#rest", + "type": "VTARest", + "serviceEndpoint": "{URL}" + } + ] + } +} diff --git a/vta-sdk/templates/room.json b/vta-sdk/templates/room.json new file mode 100644 index 000000000..5faa12bb3 --- /dev/null +++ b/vta-sdk/templates/room.json @@ -0,0 +1,54 @@ +{ + "schemaVersion": 1, + "name": "room", + "kind": "room", + "description": "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. Because a host authorizes room operations against credentials this DID issued — never against its own records — the room is portable: re-point its service endpoint and the room has moved, with no credential reissued. did:webvh rather than did:peer, because a room's controller must be able to change: transferring ownership is a controller change, and did:peer encodes its keys in the identifier so it can never have one. WITNESSES is strongly recommended for any room whose host is not fully trusted — a witnessed log makes a host that serves a stale or suppressed update evident rather than merely possible. MEDIATOR_DID makes the room addressable, which is what lets an invitation, a join, or an epoch notice reach it.", + "methods": ["webvh"], + "requiredVars": ["WEBVH_SERVER", "MEDIATOR_DID"], + "optionalVars": { + "ACCEPT": ["didcomm/v2"], + "ROUTING_KEYS": [], + "WITNESSES": [], + "LABEL": null + }, + "defaults": { + "portable": true + }, + "document": { + "@context": [ + "https://www.w3.org/ns/did/v1", + "https://www.w3.org/ns/cid/v1" + ], + "id": "{DID}", + "verificationMethod": [ + { + "id": "{DID}#key-1", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{SIGNING_KEY_MB}" + }, + { + "id": "{DID}#key-2", + "type": "Multikey", + "controller": "{DID}", + "publicKeyMultibase": "{KA_KEY_MB}" + } + ], + "assertionMethod": ["{DID}#key-1"], + "authentication": ["{DID}#key-1"], + "keyAgreement": ["{DID}#key-2"], + "service": [ + { + "id": "{DID}#didcomm", + "type": "DIDCommMessaging", + "serviceEndpoint": [ + { + "accept": "{ACCEPT}", + "routingKeys": "{ROUTING_KEYS}", + "uri": "{MEDIATOR_DID}" + } + ] + } + ] + } +} From ccc04efdb096671742fd04f4ecdd2dc246e411a4 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:19:27 +0200 Subject: [PATCH 05/14] feat(rooms): MLS group layer for room keys 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 --- Cargo.lock | 990 ++++++++++++++++++++-- vtc-client/Cargo.toml | 16 + vtc-client/src/rooms/mls.rs | 471 ++++++++++ vtc-client/src/{rooms.rs => rooms/mod.rs} | 3 + 4 files changed, 1393 insertions(+), 87 deletions(-) create mode 100644 vtc-client/src/rooms/mls.rs rename vtc-client/src/{rooms.rs => rooms/mod.rs} (99%) diff --git a/Cargo.lock b/Cargo.lock index e1b84aad4..6faf3a060 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -116,13 +116,13 @@ dependencies = [ "base58", "base64 0.23.1", "cbc 0.1.2", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "getrandom 0.2.17", "hmac 0.12.1", - "k256", + "k256 0.14.0", "multibase", - "p256", - "p384", + "p256 0.14.0", + "p384 0.14.0", "p521", "rand 0.10.2", "rand_core 0.6.4", @@ -131,7 +131,7 @@ dependencies = [ "sha2 0.10.9", "subtle", "thiserror 2.0.20", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -149,7 +149,7 @@ dependencies = [ "async-trait", "chrono", "ciborium", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hmac 0.12.1", "multibase", "serde", @@ -197,7 +197,7 @@ dependencies = [ "serde_json", "thiserror 2.0.20", "url", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -285,7 +285,7 @@ dependencies = [ "hex", "hkdf 0.12.4", "hmac 0.12.1", - "p256", + "p256 0.14.0", "rand 0.9.5", "serde", "serde_bytes", @@ -355,9 +355,9 @@ checksum = "7e50820cf32736246a5e918a5eb1773c31ffcec898ad2ab5b26477cf7a547fa5" dependencies = [ "affinidi-crypto", "base64ct", - "ed25519-dalek", - "k256", - "p256", + "ed25519-dalek 3.0.0", + "k256 0.14.0", + "p256 0.14.0", "rand 0.10.2", "rand_core 0.6.4", "serde", @@ -402,7 +402,7 @@ dependencies = [ "hostname", "http 1.5.0", "humantime", - "itertools", + "itertools 0.14.0", "jsonwebtoken 10.4.0", "metrics", "metrics-exporter-prometheus", @@ -553,8 +553,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc3c8a85c6353e7952bdbc1c5509591bbd54d9e9d62c57e2198079f95955923a" dependencies = [ "base64 0.23.1", - "ed25519-dalek", - "p256", + "ed25519-dalek 3.0.0", + "p256 0.14.0", "rand 0.10.2", "serde", "serde_json", @@ -664,7 +664,7 @@ dependencies = [ "tokio", "tracing", "unsigned-varint", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -763,7 +763,7 @@ dependencies = [ "affinidi-encoding", "blake2 0.10.6", "chacha20poly1305 0.10.1", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hkdf 0.12.4", "rand 0.10.2", @@ -774,7 +774,7 @@ dependencies = [ "thiserror 2.0.20", "tokio", "url", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -1982,6 +1982,26 @@ dependencies = [ "serde", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "log", + "prettyplease", + "proc-macro2", + "quote", + "regex", + "rustc-hash", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bip39" version = "2.2.2" @@ -2294,7 +2314,16 @@ dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", +] + +[[package]] +name = "cexpr" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" +dependencies = [ + "nom", ] [[package]] @@ -2430,6 +2459,17 @@ dependencies = [ "inout 0.2.2", ] +[[package]] +name = "clang-sys" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" +dependencies = [ + "glob", + "libc", + "libloading", +] + [[package]] name = "clap" version = "4.6.6" @@ -2582,6 +2622,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "const-oid" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8" + [[package]] name = "const-oid" version = "0.10.2" @@ -2629,6 +2675,17 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core-models" +version = "0.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee3344d349528f449816cfa85e558c439bdca5a6d8a738cfb21e69f0b2b1952f" +dependencies = [ + "hax-lib", + "pastey 0.2.3", + "rand 0.10.2", +] + [[package]] name = "coset" version = "0.3.8" @@ -2673,6 +2730,17 @@ dependencies = [ "libc", ] +[[package]] +name = "crabgrind" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7459e07732f6de74001fcf73a3fdfbb0f368e4fd8e2392f4a2206a065e6e95" +dependencies = [ + "bindgen", + "cc", + "pkg-config", +] + [[package]] name = "crc32fast" version = "1.5.1" @@ -2697,6 +2765,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.20" @@ -2843,6 +2921,22 @@ dependencies = [ "subtle", ] +[[package]] +name = "curve25519-dalek" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "curve25519-dalek-derive", + "digest 0.10.7", + "fiat-crypto 0.2.9", + "rustc_version", + "subtle", + "zeroize", +] + [[package]] name = "curve25519-dalek" version = "5.0.0" @@ -2853,7 +2947,7 @@ dependencies = [ "cpufeatures 0.3.1", "curve25519-dalek-derive", "digest 0.11.3", - "fiat-crypto", + "fiat-crypto 0.3.0", "rand_core 0.10.1", "rustc_version", "subtle", @@ -3011,7 +3105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 1.0.109", + "syn 3.0.4", ] [[package]] @@ -3108,14 +3202,25 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5729f5117e208430e437df2f4843f5e5952997175992d1414f94c57d61e270b4" +[[package]] +name = "der" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +dependencies = [ + "const-oid 0.9.6", + "pem-rfc7468 0.7.0", + "zeroize", +] + [[package]] name = "der" version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" dependencies = [ - "const-oid", - "pem-rfc7468", + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -3253,7 +3358,7 @@ dependencies = [ "affinidi-did-resolver-cache-sdk", "affinidi-tdk", "clap", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -3298,6 +3403,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer 0.10.4", + "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", ] @@ -3309,7 +3415,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ "block-buffer 0.12.1", - "const-oid", + "const-oid 0.10.2", "crypto-common 0.2.2", "ctutils", "zeroize", @@ -3386,21 +3492,45 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ecdsa" +version = "0.16.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" +dependencies = [ + "der 0.7.10", + "digest 0.10.7", + "elliptic-curve 0.13.8", + "rfc6979 0.4.0", + "signature 2.2.0", + "spki 0.7.3", +] + [[package]] name = "ecdsa" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0" dependencies = [ - "der", + "der 0.8.1", "digest 0.11.3", "elliptic-curve 0.14.1", - "rfc6979", + "rfc6979 0.6.0", "signature 3.0.0", - "spki", + "spki 0.8.0", "zeroize", ] +[[package]] +name = "ed25519" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53" +dependencies = [ + "pkcs8 0.10.2", + "signature 2.2.0", +] + [[package]] name = "ed25519" version = "3.0.0" @@ -3410,14 +3540,29 @@ dependencies = [ "signature 3.0.0", ] +[[package]] +name = "ed25519-dalek" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9" +dependencies = [ + "curve25519-dalek 4.1.3", + "ed25519 2.2.3", + "rand_core 0.6.4", + "serde", + "sha2 0.10.9", + "subtle", + "zeroize", +] + [[package]] name = "ed25519-dalek" version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de" dependencies = [ - "curve25519-dalek", - "ed25519", + "curve25519-dalek 5.0.0", + "ed25519 3.0.0", "rand_core 0.10.1", "sha2 0.11.0", "signature 3.0.0", @@ -3443,7 +3588,11 @@ dependencies = [ "ff 0.13.1", "generic-array", "group 0.13.0", + "hkdf 0.12.4", + "pem-rfc7468 0.7.0", + "pkcs8 0.10.2", "rand_core 0.6.4", + "sec1 0.7.3", "subtle", "zeroize", ] @@ -3462,10 +3611,10 @@ dependencies = [ "group 0.14.0", "hkdf 0.13.0", "hybrid-array", - "pem-rfc7468", - "pkcs8", + "pem-rfc7468 1.0.0", + "pkcs8 0.11.0", "rand_core 0.10.1", - "sec1", + "sec1 0.8.1", "subtle", "zeroize", ] @@ -3631,6 +3780,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "fiat-crypto" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" + [[package]] name = "fiat-crypto" version = "0.3.0" @@ -4329,6 +4484,43 @@ dependencies = [ "foldhash", ] +[[package]] +name = "hax-lib" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a33cb227f97ee5c419064c31d7c55a7d895f28d8019ccdadc311cc036500c4" +dependencies = [ + "hax-lib-macros", + "num-bigint 0.4.8", + "num-traits", +] + +[[package]] +name = "hax-lib-macros" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28da835a5f153e9f3d0cc1f42ee605a1cfa9093c93348193793d60a1b0908b51" +dependencies = [ + "hax-lib-macros-types", + "proc-macro-error2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "hax-lib-macros-types" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed327e9a28d6ee018a4a9ba842d4ee27e378c8a591d2beb6f42f32b24a02fcb" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "serde_json", + "uuid", +] + [[package]] name = "headers" version = "0.4.1" @@ -4452,7 +4644,76 @@ dependencies = [ "subtle", "turboshake", "x-wing", - "x25519-dalek", + "x25519-dalek 3.0.0", + "zeroize", +] + +[[package]] +name = "hpke-rs" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812de62ab573b876c6c40d7553bae9402ae3bad95b64af06f964388eecb9b7b1" +dependencies = [ + "hpke-rs-crypto", + "hpke-rs-libcrux", + "hpke-rs-rust-crypto", + "libcrux-sha3", + "log", + "serde", + "subtle", + "tls_codec", + "zeroize", +] + +[[package]] +name = "hpke-rs-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2c461da2e0c9c93d875b597f0c78214fb0d2d5c57834b2ef2a2fa057fa20e24" +dependencies = [ + "rand 0.10.2", + "zeroize", +] + +[[package]] +name = "hpke-rs-libcrux" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c1ace95ef11fbbd84527eada5fc1304f66720fc4c008db30d888d62d3c52613" +dependencies = [ + "hpke-rs-crypto", + "libcrux-aead", + "libcrux-ecdh", + "libcrux-hkdf", + "libcrux-kem", + "libcrux-traits", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "zeroize", +] + +[[package]] +name = "hpke-rs-rust-crypto" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a606ac19851da841862ef5f39d26d6227bfcfb4047417801a66c1f6e6652d71" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305 0.10.1", + "hkdf 0.13.0", + "hpke-rs-crypto", + "k256 0.13.4", + "ml-kem", + "p256 0.13.2", + "p384 0.13.1", + "rand 0.10.2", + "rand_chacha 0.10.0", + "rand_core 0.10.1", + "sha2 0.11.0", + "subtle", + "x-wing", + "x25519-dalek 2.0.1", "zeroize", ] @@ -4857,6 +5118,15 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.14.0" @@ -5118,6 +5388,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "k256" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" +dependencies = [ + "cfg-if", + "elliptic-curve 0.13.8", +] + [[package]] name = "k256" version = "0.14.0" @@ -5125,9 +5405,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93f50113171a713f4a4231ef82eb26703607139b35dcb56241f0ceab2ae1f7d8" dependencies = [ "cpubits", - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", "signature 3.0.0", "wnaf", @@ -5253,30 +5533,249 @@ dependencies = [ name = "lab" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" +checksum = "bf36173d4167ed999940f804952e6b08197cae5ad5d572eb4db150ce8ad5d58f" + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "left-right" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +dependencies = [ + "crossbeam-utils", + "loom", + "slab", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libcrux-aead" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22acbe68be84b7b41aaba6e39b87036fc4324dee628d94773750bf3d7e906d69" +dependencies = [ + "libcrux-aes", + "libcrux-chacha20poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-aes" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57cc95b11dbc797b1e169467b1fc4205c4e6ee2ce516a035ad7342ce5f6ae971" +dependencies = [ + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-chacha20poly1305" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "537e6eee5cdc9a980014d058784b0be09614f0596df789b114f7833aa9f35d75" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-poly1305", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-curve25519" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f3cfd5e31cb9745e290ee061222c380ec42884654b09fc7d12a5b0ed63e028" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-traits", +] + +[[package]] +name = "libcrux-ecdh" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a227590b89ff55ce7cd97b5a7468c82d231db8f3b890bb4d4fb6d271e7524d7" +dependencies = [ + "libcrux-curve25519", + "libcrux-p256", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-hacl-rs" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66106db376ae249af86911aee048cf73ea1f394d6653026fc988dd22cf2a4ace" +dependencies = [ + "libcrux-macros", +] + +[[package]] +name = "libcrux-hkdf" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc94e651dca4d47edbcdd88bb2428ce16a2bd86b7a4e7170da87b505478f9839" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-hmac", + "libcrux-secrets", +] + +[[package]] +name = "libcrux-hmac" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "500ad9b32c715161b594172ca1fb11503a1c033b393ff4c0c33505ce51c1943c" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-intrinsics" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98a0c574d4eb81d0814bc2b91e4a433d7e0b35851b1d62eb5dd95c064f1f76d0" +dependencies = [ + "core-models", + "hax-lib", +] + +[[package]] +name = "libcrux-kem" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "541a7377fb35060892e0620982e224e47419f10da8c212453bf642dafe529691" +dependencies = [ + "libcrux-curve25519", + "libcrux-ecdh", + "libcrux-ml-kem", + "libcrux-p256", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", +] + +[[package]] +name = "libcrux-macros" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "libcrux-ml-kem" +version = "0.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8160f7d64fd2716b4fd05cc886a042f8dcda18d9206c0d506e2c67bdf97daa" +dependencies = [ + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-secrets", + "libcrux-sha3", + "libcrux-traits", + "rand 0.10.2", + "tls_codec", +] + +[[package]] +name = "libcrux-p256" +version = "0.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3400732702d578be622257b98cec85e9b0cd34a67f1f06d3ebe9ae34963cdf02" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-secrets", + "libcrux-sha2", + "libcrux-traits", +] + +[[package]] +name = "libcrux-platform" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" +dependencies = [ + "libc", +] + +[[package]] +name = "libcrux-poly1305" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a9144949845813a0b8787d08cbba47baabe59c83f3043e558e6e92385c40cc" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", +] + +[[package]] +name = "libcrux-secrets" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79054fc9037cb70d6a546cf094cea7d7df06af5e49d230ba24103d27ccc886f1" +dependencies = [ + "crabgrind", + "hax-lib", +] [[package]] -name = "lazy_static" -version = "1.5.0" +name = "libcrux-sha2" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +checksum = "960e46e1be0b77098cc6ce82137864936ecad3a1b9fd4303dd51202ca140dd1e" +dependencies = [ + "libcrux-hacl-rs", + "libcrux-macros", + "libcrux-traits", +] [[package]] -name = "left-right" -version = "0.11.8" +name = "libcrux-sha3" +version = "0.0.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bc015ded5d9b3054dbbdb63332cdd6ee42352ccef19e911e25117490e2f48ee" +checksum = "c09f5c39afae0528e1f70a3c1e3c6ee649ec1f19dda26fc9b6ea91108fb879ef" dependencies = [ - "crossbeam-utils", - "loom", - "slab", + "hax-lib", + "libcrux-intrinsics", + "libcrux-platform", + "libcrux-traits", ] [[package]] -name = "libc" -version = "0.2.189" +name = "libcrux-traits" +version = "0.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +checksum = "3fa7a21e8c2e8baa8b40f0b176740f2ca4baadd79d1b00e48d6b1e363c42085a" +dependencies = [ + "libcrux-secrets", + "rand 0.10.2", +] [[package]] name = "libdbus-sys" @@ -5287,6 +5786,16 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libm" version = "0.2.16" @@ -5560,6 +6069,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "ml-dsa" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "add6b9d92e496f16f4526d68ff29da1483aba4b119baeab8bed3b9e3544a6f3d" +dependencies = [ + "const-oid 0.10.2", + "crypto-common 0.2.2", + "ctutils", + "hybrid-array", + "module-lattice", + "pkcs8 0.11.0", + "shake", + "signature 3.0.0", +] + [[package]] name = "ml-kem" version = "0.3.2" @@ -5882,6 +6407,102 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openmls" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6b08d90fc020cb5354d5f08ca17711b84c82e2bcc7331753fd94f000d99a8c8" +dependencies = [ + "log", + "openmls_serialization_helpers", + "openmls_traits", + "rayon", + "serde", + "serde_bytes", + "thiserror 2.0.20", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_basic_credential" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbd3f0c3422e7c7a8496f042b547b0c28d0793f8ba97feb401966e58196a1e40" +dependencies = [ + "ed25519-dalek 2.2.0", + "ml-dsa", + "openmls_traits", + "p256 0.13.2", + "p384 0.13.1", + "rand_core 0.6.4", + "serde", + "tls_codec", + "zeroize", +] + +[[package]] +name = "openmls_memory_storage" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce927945a16eaa19302ed5ccef052f01ba68f2ba7ae67e4ac4ff2fc661445364" +dependencies = [ + "log", + "openmls_traits", + "serde", + "serde_json", + "thiserror 2.0.20", +] + +[[package]] +name = "openmls_rust_crypto" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bf3fe824ead6e81d22f49a9cd7feffca3494979fac14c1248e19516e3625f1" +dependencies = [ + "aes-gcm 0.10.3", + "chacha20poly1305 0.10.1", + "ed25519-dalek 2.2.0", + "hkdf 0.13.0", + "hmac 0.13.0", + "hpke-rs", + "hpke-rs-crypto", + "hpke-rs-rust-crypto", + "ml-dsa", + "openmls_memory_storage", + "openmls_traits", + "p256 0.13.2", + "p384 0.13.1", + "rand_chacha 0.3.1", + "rand_core 0.10.1", + "rand_core 0.6.4", + "sha2 0.11.0", + "thiserror 2.0.20", + "tls_codec", +] + +[[package]] +name = "openmls_serialization_helpers" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e2cf2193541e83ce1a89aaa44c2fd4783abd54853823075a32b60ef032284c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "thiserror 2.0.20", +] + +[[package]] +name = "openmls_traits" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac13edad8698eb1917cbc1dab99591fa1594d84320904fe3fc5121854f95831c" +dependencies = [ + "serde", + "tls_codec", +] + [[package]] name = "openssl" version = "0.10.81" @@ -5991,30 +6612,54 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" +[[package]] +name = "p256" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p256" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2c9239b2dbc807adbbe147e8cf72ea7450c3a0aabe62cb8e75ff4ec22e1f72a" dependencies = [ - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] +[[package]] +name = "p384" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6" +dependencies = [ + "ecdsa 0.16.9", + "elliptic-curve 0.13.8", + "primeorder 0.13.6", + "sha2 0.10.9", +] + [[package]] name = "p384" version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d17b851e6b3e378ab4ecb07fa2ed23f4d15f075735f8fec9fa1e7bdce5f8301f" dependencies = [ - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", - "fiat-crypto", + "fiat-crypto 0.3.0", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6025,10 +6670,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ad64cc32c2dc466317c12ee5853e61f159f9eab1fe7efade0395dc2e7b43449" dependencies = [ "base16ct 1.0.0", - "ecdsa", + "ecdsa 0.17.0", "elliptic-curve 0.14.1", "primefield", - "primeorder", + "primeorder 0.14.0", "sha2 0.11.0", ] @@ -6152,6 +6797,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "pem-rfc7468" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412" +dependencies = [ + "base64ct", +] + [[package]] name = "pem-rfc7468" version = "1.0.0" @@ -6322,14 +6976,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkcs8" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" +dependencies = [ + "der 0.7.10", + "spki 0.7.3", +] + [[package]] name = "pkcs8" version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" dependencies = [ - "der", - "spki", + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -6355,7 +7019,7 @@ dependencies = [ "clap", "dialoguer", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -6466,6 +7130,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "primefield" version = "0.14.0" @@ -6480,6 +7154,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "primeorder" +version = "0.13.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" +dependencies = [ + "elliptic-curve 0.13.8", +] + [[package]] name = "primeorder" version = "0.14.0" @@ -6493,6 +7176,28 @@ dependencies = [ "wnaf", ] +[[package]] +name = "proc-macro-error-attr2" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "proc-macro-error2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +dependencies = [ + "proc-macro-error-attr2", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -6663,6 +7368,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -6740,7 +7455,7 @@ dependencies = [ "compact_str", "critical-section", "hashbrown 0.17.1", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "palette", @@ -6805,7 +7520,7 @@ dependencies = [ "hashbrown 0.17.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "serde", @@ -6824,6 +7539,26 @@ dependencies = [ "bitflags 2.13.1", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "rcgen" version = "0.13.2" @@ -7080,6 +7815,16 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfc6979" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" +dependencies = [ + "hmac 0.12.1", + "subtle", +] + [[package]] name = "rfc6979" version = "0.6.0" @@ -7394,6 +8139,20 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "sec1" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc" +dependencies = [ + "base16ct 0.2.0", + "der 0.7.10", + "generic-array", + "pkcs8 0.10.2", + "subtle", + "zeroize", +] + [[package]] name = "sec1" version = "0.8.1" @@ -7402,7 +8161,7 @@ checksum = "d56d437c2f19203ce5f7122e507831de96f3d2d4d3be5af44a0b0a09d8a80e4d" dependencies = [ "base16ct 1.0.0", "ctutils", - "der", + "der 0.8.1", "hybrid-array", "subtle", "zeroize", @@ -7474,7 +8233,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64 0.21.7", + "base64 0.22.1", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -7810,6 +8569,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + [[package]] name = "shlex" version = "2.0.1" @@ -7853,6 +8618,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ + "digest 0.10.7", "rand_core 0.6.4", ] @@ -7964,6 +8730,16 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spki" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" +dependencies = [ + "base64ct", + "der 0.7.10", +] + [[package]] name = "spki" version = "0.8.0" @@ -7971,7 +8747,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" dependencies = [ "base64ct", - "der", + "der 0.8.1", ] [[package]] @@ -8342,6 +9118,29 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18cc98286004cea38f717e2b03d990fc774fbfd38a82720de40e5c94365067c8" +dependencies = [ + "serde", + "serde_bytes", + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "674f41dd95f76cdeb005c83f9444e20cf43daebef37cde9e49a9ca6f4e87b423" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "tokio" version = "1.53.1" @@ -8942,7 +9741,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -9328,7 +10127,7 @@ dependencies = [ "base64 0.23.1", "chrono", "dialoguer", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hkdf 0.13.0", "multibase", "rand 0.10.2", @@ -9341,7 +10140,7 @@ dependencies = [ "tokio", "vta-sdk", "vti-common", - "x25519-dalek", + "x25519-dalek 3.0.0", ] [[package]] @@ -9387,12 +10186,12 @@ dependencies = [ "base64 0.23.1", "bip39", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "hkdf 0.13.0", "hmac 0.13.0", "multibase", - "p256", + "p256 0.14.0", "rand 0.10.2", "serde", "serde_json", @@ -9406,7 +10205,7 @@ dependencies = [ "vta-sdk", "vti-common", "vti-secrets", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9445,7 +10244,7 @@ dependencies = [ "affinidi-secrets-resolver", "base64 0.23.1", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "serde", @@ -9511,11 +10310,11 @@ dependencies = [ "chrono", "ciborium", "coset 0.4.2", - "curve25519-dalek", + "curve25519-dalek 5.0.0", "dbus-secret-service-keyring-store", "didwebvh-rs", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "getrandom 0.4.3", "hpke", @@ -9540,7 +10339,7 @@ dependencies = [ "uuid", "windows-native-keyring-store", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "x509-parser 0.18.1", "zeroize", ] @@ -9582,7 +10381,7 @@ dependencies = [ "dialoguer", "didwebvh-rs", "dirs", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "fjall", "futures-util", "hex", @@ -9595,7 +10394,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "multibase", - "p256", + "p256 0.14.0", "rand 0.10.2", "regorus", "reqwest", @@ -9644,7 +10443,7 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9653,7 +10452,7 @@ name = "vta-support" version = "0.3.1" dependencies = [ "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "hex", "multibase", "rand 0.10.2", @@ -9735,7 +10534,7 @@ dependencies = [ "base64 0.23.1", "chrono", "coset 0.4.2", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "multibase", "rcgen 0.14.9", "reqwest", @@ -9758,7 +10557,7 @@ version = "0.2.3" dependencies = [ "affinidi-tdk", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "reqwest", "serde", "serde_json", @@ -9778,10 +10577,15 @@ name = "vtc-client" version = "0.5.1" dependencies = [ "chrono", + "openmls", + "openmls_basic_credential", + "openmls_rust_crypto", + "openmls_traits", "reqwest", "serde", "serde_json", "thiserror 2.0.20", + "tls_codec", "tokio", "trust-tasks-rs", "vta-sdk", @@ -9819,7 +10623,7 @@ dependencies = [ "dialoguer", "didwebvh-rs", "dtg-credentials", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "fjall", "flate2", "futures-util", @@ -9868,7 +10672,7 @@ dependencies = [ "webauthn-rs", "webauthn-rs-proto", "wiremock", - "x25519-dalek", + "x25519-dalek 3.0.0", "zeroize", ] @@ -9889,7 +10693,7 @@ dependencies = [ "bytes", "chrono", "dialoguer", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "fjall", "hex", "hkdf 0.13.0", @@ -9931,7 +10735,7 @@ dependencies = [ "affinidi-secrets-resolver", "affinidi-tdk", "chrono", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "multibase", "reqwest", "serde_json", @@ -9956,7 +10760,7 @@ dependencies = [ "azure_identity", "azure_security_keyvault_secrets", "bytes", - "ed25519-dalek", + "ed25519-dalek 3.0.0", "futures-util", "google-cloud-auth", "google-cloud-secretmanager-v1", @@ -9984,7 +10788,7 @@ dependencies = [ "aws-lc-rs", "base64 0.23.1", "multibase", - "p256", + "p256 0.14.0", "serde", "serde_json", "serde_json_canonicalizer", @@ -10582,7 +11386,19 @@ dependencies = [ "ml-kem", "sha3 0.12.0", "shake", - "x25519-dalek", + "x25519-dalek 3.0.0", +] + +[[package]] +name = "x25519-dalek" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277" +dependencies = [ + "curve25519-dalek 4.1.3", + "rand_core 0.6.4", + "serde", + "zeroize", ] [[package]] @@ -10591,7 +11407,7 @@ version = "3.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6" dependencies = [ - "curve25519-dalek", + "curve25519-dalek 5.0.0", "rand_core 0.10.1", "zeroize", ] diff --git a/vtc-client/Cargo.toml b/vtc-client/Cargo.toml index 6fda3b6ce..4e9124309 100644 --- a/vtc-client/Cargo.toml +++ b/vtc-client/Cargo.toml @@ -10,11 +10,27 @@ readme = "README.md" license.workspace = true repository.workspace = true +[features] +default = [] +# The MLS group layer (`rooms::mls`). Off by default: OpenMLS is a substantial +# dependency and a caller that only reads an `open` room needs none of it. +# Mirrors how `vtc-service` gates its `bbs` verifier. +mls = ["dep:openmls", "dep:openmls_rust_crypto", "dep:openmls_basic_credential", "dep:openmls_traits", "dep:tls_codec"] + [dependencies] # Reuses the VTA SDK's challenge-response auth (audience-agnostic) and the # published join-request protocol types. `session` gates `auth_light` + # `protocols`. vta-sdk = { path = "../vta-sdk", version = "0.32", features = ["session"] } + +# MLS (RFC 9420) for room group keys, behind the `mls` feature. The design note +# adopts MLS rather than a hand-rolled epoch scheme: post-compromise security and +# O(log n) membership change are not things to reimplement. +openmls = { version = "0.9", optional = true } +openmls_rust_crypto = { version = "0.6", optional = true } +openmls_basic_credential = { version = "0.6", optional = true } +openmls_traits = { version = "0.6", optional = true } +tls_codec = { version = "0.5", optional = true } # `TrustTask` — the join-submit document this client signs, and the `#response` # envelope the VTC answers it with. Same crate `vta-sdk` builds the document # from, so one shape crosses the boundary. diff --git a/vtc-client/src/rooms/mls.rs b/vtc-client/src/rooms/mls.rs new file mode 100644 index 000000000..570742b78 --- /dev/null +++ b/vtc-client/src/rooms/mls.rs @@ -0,0 +1,471 @@ +//! The room's group-key layer, on MLS (RFC 9420). +//! +//! # Why MLS rather than a room key per epoch +//! +//! An earlier draft of the design hand-rolled this: one symmetric key per epoch, sealed to +//! each member on every change. Every part of that is something MLS already standardises, +//! and two of the parts it adds are not optional for a system that expects to outlive a +//! compromise: +//! +//! - **Post-compromise security.** A stolen member key stops working at the next commit. The +//! fan-out design had none — a stolen key read every future epoch until somebody noticed. +//! - **O(log n) membership change.** Fan-out is O(n) per change. Fine for a five-person +//! room, wrong for one whose membership is a whole community roster. +//! +//! # How it maps onto a room +//! +//! MLS separates an **Authentication Service** (who is this leaf?) from a **Delivery +//! Service** (who stores and orders the group's messages, and is trusted for availability +//! only). That is the room design's own shape, arrived at independently: +//! +//! | MLS | Room | +//! |---|---| +//! | Authentication Service | the DTG — a leaf's credential is the room VMC | +//! | Delivery Service | the room's host, trusted per invariant I2 | +//! | Group | the room | +//! | Epoch | the room's epoch, the number the host stores | +//! | Commit | a membership change — **only the owner commits** | +//! | Exporter secret | the room's storage key | +//! +//! # One leaf per member, not per device +//! +//! A member's leaf is their **VTA**, and devices and agents hang off it through the oracle +//! model rather than joining the group themselves. That sidesteps MLS's multi-device +//! complexity entirely, and it is the same reason the design puts key custody in the VTA: +//! an agent asks its VTA to open a record and never holds the key. +//! +//! # Storage keys come from the exporter, not from the group's message keys +//! +//! Records are sealed with a key derived from [`RoomGroup::storage_key`], which is the MLS +//! exporter under a room-specific label. This is the pattern +//! `draft-sullivan-mls-attachments` uses for encrypted attachments, following SFrame +//! (RFC 9605): the group provides authenticated key agreement, and the application derives +//! its own keys from the exporter rather than borrowing the ones MLS uses for its own +//! messages. +//! +//! # What this module does not do +//! +//! It does not talk to a host. Commits, welcomes and key packages are returned to the +//! caller as bytes to send however it likes — the design's fork risk (a host showing +//! different members different commit sequences) is addressed by anchoring epoch +//! authenticators in the room's witnessed DID log, which is a separate concern from +//! producing them. [`RoomGroup::epoch_authenticator`] is what gets anchored. + +use openmls::prelude::*; +use openmls_basic_credential::SignatureKeyPair; +use openmls_rust_crypto::OpenMlsRustCrypto; +use openmls_traits::OpenMlsProvider; +use tls_codec::{Deserialize as _, Serialize as _}; + +use crate::VtcError; + +/// The MLS ciphersuite every room uses. +/// +/// One ciphersuite, not a negotiation. A room whose members disagree about the ciphersuite +/// is a room that cannot form, and offering a choice here would mean carrying the weakest +/// option a peer might pick. +pub const ROOM_CIPHERSUITE: Ciphersuite = Ciphersuite::MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519; + +/// Exporter label for a room's record-storage key. +/// +/// Domain-separated so that a key derived for record sealing can never collide with one +/// derived for another purpose from the same group. A new purpose gets a new label, never a +/// parameter on this one. +const STORAGE_KEY_LABEL: &str = "openvtc/room/storage/v1"; + +/// Bytes of the derived storage key — 32, for a ChaCha20-Poly1305 or AES-256 key. +pub const STORAGE_KEY_LEN: usize = 32; + +/// A member's MLS identity: their signature keypair and the credential naming them. +/// +/// The credential is a basic one carrying the member's DID. In the finished design the +/// leaf's credential is the room VMC; a basic credential carrying the same identifier is +/// the interim, and the swap is confined to this struct. +pub struct RoomIdentity { + signer: SignatureKeyPair, + credential: CredentialWithKey, +} + +impl RoomIdentity { + /// Create an identity for `member_did`. + pub fn new(member_did: &str, provider: &impl OpenMlsProvider) -> Result { + let signer = SignatureKeyPair::new(ROOM_CIPHERSUITE.signature_algorithm()) + .map_err(|e| VtcError::Signing(format!("generate MLS signature key: {e:?}")))?; + signer + .store(provider.storage()) + .map_err(|e| VtcError::Signing(format!("store MLS signature key: {e:?}")))?; + + let credential = Credential::new(CredentialType::Basic, member_did.as_bytes().to_vec()); + Ok(Self { + credential: CredentialWithKey { + credential, + signature_key: signer.public().into(), + }, + signer, + }) + } + + /// A key package this member can be added to a room with. + /// + /// Published to whoever is inviting them — **over the invitation channel, never through + /// the host**. A host that collected key packages would learn who is being invited to + /// what, which un-blinds a sealed room at the door. + pub fn key_package(&self, provider: &impl OpenMlsProvider) -> Result { + KeyPackage::builder() + .build( + ROOM_CIPHERSUITE, + provider, + &self.signer, + self.credential.clone(), + ) + .map(|b| b.key_package().clone()) + .map_err(|e| VtcError::Signing(format!("build MLS key package: {e:?}"))) + } +} + +/// One member's view of a room's MLS group. +pub struct RoomGroup { + group: MlsGroup, + identity: RoomIdentity, + provider: OpenMlsRustCrypto, +} + +/// What a membership change produces. +/// +/// The caller sends `commit` to every existing member and `welcome` to whoever was just +/// added. Both are opaque bytes here: this module produces them and takes no view on how +/// they travel. +pub struct MembershipChange { + /// The commit, for members already in the group. + pub commit: Vec, + /// The welcome, for members just added. `None` on a removal. + pub welcome: Option>, + /// The epoch the group is in after merging. + pub epoch: u64, +} + +impl RoomGroup { + /// Create a room's group. The creator is its first member and its owner. + pub fn create(member_did: &str) -> Result { + let provider = OpenMlsRustCrypto::default(); + let identity = RoomIdentity::new(member_did, &provider)?; + + let config = MlsGroupCreateConfig::builder() + .ciphersuite(ROOM_CIPHERSUITE) + .use_ratchet_tree_extension(true) + .build(); + + let group = MlsGroup::new( + &provider, + &identity.signer, + &config, + identity.credential.clone(), + ) + .map_err(|e| VtcError::Signing(format!("create MLS group: {e:?}")))?; + + Ok(Self { + group, + identity, + provider, + }) + } + + /// Join a room from the welcome its owner sent. + pub fn join(member_did: &str, welcome: &[u8]) -> Result { + let provider = OpenMlsRustCrypto::default(); + let identity = RoomIdentity::new(member_did, &provider)?; + Self::join_with(identity, provider, welcome) + } + + /// Join using an identity whose key package the inviter already holds. + /// + /// The ordinary path: a member publishes a key package, is added, and joins with the + /// *same* identity — [`RoomGroup::join`] mints a fresh one, which only works if the + /// inviter used that identity's key package. + pub fn join_with( + identity: RoomIdentity, + provider: OpenMlsRustCrypto, + welcome: &[u8], + ) -> Result { + let msg = MlsMessageIn::tls_deserialize_exact(welcome) + .map_err(|e| VtcError::Signing(format!("parse welcome: {e:?}")))?; + let welcome = match msg.extract() { + MlsMessageBodyIn::Welcome(w) => w, + _ => { + return Err(VtcError::Signing( + "expected a Welcome message, got another MLS body".into(), + )); + } + }; + + let config = MlsGroupJoinConfig::builder() + .use_ratchet_tree_extension(true) + .build(); + let staged = StagedWelcome::new_from_welcome(&provider, &config, welcome, None) + .map_err(|e| VtcError::Signing(format!("stage welcome: {e:?}")))?; + let group = staged + .into_group(&provider) + .map_err(|e| VtcError::Signing(format!("join group from welcome: {e:?}")))?; + + Ok(Self { + group, + identity, + provider, + }) + } + + /// Add a member and commit. + /// + /// Only the owner should call this — the design restricts epoch minting to `admin` + /// precisely because a group where any key-holder can commit is a group where any + /// member can evict any other. MLS itself does not enforce that; the room's authority + /// credentials do, and the host checks them before accepting the epoch advance. + pub fn add_member(&mut self, key_package: KeyPackage) -> Result { + let (commit, welcome, _) = self + .group + .add_members(&self.provider, &self.identity.signer, &[key_package]) + .map_err(|e| VtcError::Signing(format!("add member: {e:?}")))?; + + self.group + .merge_pending_commit(&self.provider) + .map_err(|e| VtcError::Signing(format!("merge add commit: {e:?}")))?; + + Ok(MembershipChange { + commit: commit + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise commit: {e:?}")))?, + welcome: Some( + welcome + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise welcome: {e:?}")))?, + ), + epoch: self.group.epoch().as_u64(), + }) + } + + /// Remove a member and commit — the mechanism of removal. + /// + /// **Forward-only, and worth being plain about.** The removed member keeps whatever they + /// could already read; they held the plaintext. What they lose is everything sealed + /// under the new epoch. An interface that implies otherwise has mis-stated the + /// guarantee. + pub fn remove_member(&mut self, index: LeafNodeIndex) -> Result { + let (commit, _, _) = self + .group + .remove_members(&self.provider, &self.identity.signer, &[index]) + .map_err(|e| VtcError::Signing(format!("remove member: {e:?}")))?; + + self.group + .merge_pending_commit(&self.provider) + .map_err(|e| VtcError::Signing(format!("merge remove commit: {e:?}")))?; + + Ok(MembershipChange { + commit: commit + .tls_serialize_detached() + .map_err(|e| VtcError::Signing(format!("serialise commit: {e:?}")))?, + welcome: None, + epoch: self.group.epoch().as_u64(), + }) + } + + /// Apply a commit produced by another member. + pub fn apply_commit(&mut self, commit: &[u8]) -> Result { + let msg = MlsMessageIn::tls_deserialize_exact(commit) + .map_err(|e| VtcError::Signing(format!("parse commit: {e:?}")))?; + let protocol_message: ProtocolMessage = msg + .try_into_protocol_message() + .map_err(|e| VtcError::Signing(format!("not a protocol message: {e:?}")))?; + + let processed = self + .group + .process_message(&self.provider, protocol_message) + .map_err(|e| VtcError::Signing(format!("process commit: {e:?}")))?; + + match processed.into_content() { + ProcessedMessageContent::StagedCommitMessage(staged) => { + self.group + .merge_staged_commit(&self.provider, *staged) + .map_err(|e| VtcError::Signing(format!("merge staged commit: {e:?}")))?; + Ok(self.group.epoch().as_u64()) + } + _ => Err(VtcError::Signing( + "expected a commit, got another message type".into(), + )), + } + } + + /// The room's current epoch. + /// + /// This is the number the host stores so it can serve the right ciphertext. The host + /// learns the number and never the key. + pub fn epoch(&self) -> u64 { + self.group.epoch().as_u64() + } + + /// The key records in this epoch are sealed with. + /// + /// Derived from the MLS exporter under a room-specific label rather than borrowed from + /// the group's own message keys — so a change to how records are sealed cannot weaken + /// the group's messaging, and vice versa. + pub fn storage_key(&self) -> Result<[u8; STORAGE_KEY_LEN], VtcError> { + let secret = self + .group + .export_secret( + self.provider.crypto(), + STORAGE_KEY_LABEL, + &[], + STORAGE_KEY_LEN, + ) + .map_err(|e| VtcError::Signing(format!("export storage key: {e:?}")))?; + let mut key = [0u8; STORAGE_KEY_LEN]; + key.copy_from_slice(&secret); + Ok(key) + } + + /// The epoch authenticator — what gets anchored in the room's witnessed DID log. + /// + /// A host acting as the Delivery Service can attempt to **fork** a group: show one + /// member one commit sequence and another member a different one, so each believes it + /// is in the room. Members cannot detect that by comparing through the host, because the + /// host is what they would be comparing through. + /// + /// Anchoring this value where the host cannot forge it — the room's witnessed log — + /// gives every member a reference to check their own against. Detection latency is the + /// anchoring cadence, which is why the design makes that a room parameter rather than a + /// constant. + pub fn epoch_authenticator(&self) -> Vec { + self.group.epoch_authenticator().as_slice().to_vec() + } + + /// How many members the group has. + pub fn member_count(&self) -> usize { + self.group.members().count() + } + + /// The leaf index of the member whose credential identity is `member_did`. + pub fn leaf_of(&self, member_did: &str) -> Option { + self.group.members().find_map(|m| { + (m.credential.serialized_content() == member_did.as_bytes()).then_some(m.index) + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_creator_forms_a_group_of_one() { + let room = RoomGroup::create("did:key:zAlice").expect("create"); + assert_eq!(room.member_count(), 1); + assert_eq!(room.epoch(), 0, "a fresh group starts at epoch 0"); + } + + #[test] + fn a_storage_key_is_derived_and_is_stable_within_an_epoch() { + let room = RoomGroup::create("did:key:zAlice").expect("create"); + let a = room.storage_key().expect("export"); + let b = room.storage_key().expect("export again"); + assert_eq!(a, b, "the same epoch must derive the same key"); + assert_ne!(a, [0u8; STORAGE_KEY_LEN], "and it must not be zeroes"); + } + + /// The whole point of an epoch: two members reach the same key without it ever + /// crossing the host. + #[test] + fn an_added_member_derives_the_same_storage_key() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + + let change = alice.add_member(bob_kp).expect("add bob"); + let welcome = change.welcome.expect("an add produces a welcome"); + + let bob = RoomGroup::join_with(bob_identity, bob_provider, &welcome).expect("bob joins"); + + assert_eq!(alice.member_count(), 2); + assert_eq!( + alice.storage_key().unwrap(), + bob.storage_key().unwrap(), + "both members must derive the same storage key, without the host seeing it" + ); + assert_eq!(alice.epoch(), bob.epoch()); + } + + /// Removal is forward-only, and this is what "forward" means mechanically: the key + /// changes, so nothing sealed afterwards is reachable with the old one. + #[test] + fn removing_a_member_changes_the_storage_key() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + let change = alice.add_member(bob_kp).expect("add bob"); + let bob = RoomGroup::join_with( + bob_identity, + bob_provider, + &change.welcome.expect("welcome"), + ) + .expect("bob joins"); + + let shared = alice.storage_key().unwrap(); + assert_eq!(shared, bob.storage_key().unwrap()); + + let bob_leaf = alice.leaf_of("did:key:zBob").expect("bob is a member"); + alice.remove_member(bob_leaf).expect("remove bob"); + + let after = alice.storage_key().unwrap(); + assert_ne!( + shared, after, + "after removal the key must differ, or removal removes nothing" + ); + assert_ne!( + bob.storage_key().unwrap(), + after, + "and the removed member must not be able to derive the new one" + ); + } + + /// Every member's view of an epoch must agree, or the anchor cannot detect a fork. + #[test] + fn members_in_the_same_epoch_share_an_epoch_authenticator() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider).expect("bob identity"); + let bob_kp = bob_identity.key_package(&bob_provider).expect("bob kp"); + let change = alice.add_member(bob_kp).expect("add bob"); + let bob = RoomGroup::join_with( + bob_identity, + bob_provider, + &change.welcome.expect("welcome"), + ) + .expect("bob joins"); + + assert_eq!( + alice.epoch_authenticator(), + bob.epoch_authenticator(), + "a member whose authenticator differs from the anchored one has been forked" + ); + assert!(!alice.epoch_authenticator().is_empty()); + } + + #[test] + fn an_epoch_advances_on_every_membership_change() { + let mut alice = RoomGroup::create("did:key:zAlice").expect("alice"); + let start = alice.epoch(); + + let p = OpenMlsRustCrypto::default(); + let id = RoomIdentity::new("did:key:zBob", &p).expect("identity"); + alice + .add_member(id.key_package(&p).expect("kp")) + .expect("add"); + + assert!( + alice.epoch() > start, + "a membership change must move the epoch, or the host serves stale ciphertext" + ); + } +} diff --git a/vtc-client/src/rooms.rs b/vtc-client/src/rooms/mod.rs similarity index 99% rename from vtc-client/src/rooms.rs rename to vtc-client/src/rooms/mod.rs index 976c45a1c..fe426a04c 100644 --- a/vtc-client/src/rooms.rs +++ b/vtc-client/src/rooms/mod.rs @@ -28,6 +28,9 @@ //! bound to the agent. The agent's `RoomSession` is built exactly like the member's — the //! difference is entirely in the credentials it was handed, which is the point. +#[cfg(feature = "mls")] +pub mod mls; + use serde::{Deserialize, Serialize}; use crate::{VtcClient, VtcError}; From a54332b26053411307892fa7974eaa47f36c24be Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:22:43 +0200 Subject: [PATCH 06/14] feat(rooms): seal and open records under the MLS group key 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 --- Cargo.lock | 3 + vtc-client/Cargo.toml | 7 +- vtc-client/src/rooms/mod.rs | 2 + vtc-client/src/rooms/sealed.rs | 287 +++++++++++++++++++++++++++++++++ 4 files changed, 298 insertions(+), 1 deletion(-) create mode 100644 vtc-client/src/rooms/sealed.rs diff --git a/Cargo.lock b/Cargo.lock index 6faf3a060..9398ea69b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10576,7 +10576,10 @@ dependencies = [ name = "vtc-client" version = "0.5.1" dependencies = [ + "base64 0.22.1", + "chacha20poly1305 0.10.1", "chrono", + "getrandom 0.4.3", "openmls", "openmls_basic_credential", "openmls_rust_crypto", diff --git a/vtc-client/Cargo.toml b/vtc-client/Cargo.toml index 4e9124309..3e1d35ed8 100644 --- a/vtc-client/Cargo.toml +++ b/vtc-client/Cargo.toml @@ -15,7 +15,7 @@ default = [] # The MLS group layer (`rooms::mls`). Off by default: OpenMLS is a substantial # dependency and a caller that only reads an `open` room needs none of it. # Mirrors how `vtc-service` gates its `bbs` verifier. -mls = ["dep:openmls", "dep:openmls_rust_crypto", "dep:openmls_basic_credential", "dep:openmls_traits", "dep:tls_codec"] +mls = ["dep:openmls", "dep:openmls_rust_crypto", "dep:openmls_basic_credential", "dep:openmls_traits", "dep:tls_codec", "dep:chacha20poly1305", "dep:base64", "dep:getrandom"] [dependencies] # Reuses the VTA SDK's challenge-response auth (audience-agnostic) and the @@ -31,6 +31,11 @@ openmls_rust_crypto = { version = "0.6", optional = true } openmls_basic_credential = { version = "0.6", optional = true } openmls_traits = { version = "0.6", optional = true } tls_codec = { version = "0.5", optional = true } +# Record sealing. The AEAD binds each record to its room, key, version and epoch, +# so a host that relocates one produces a mismatch rather than a readable record. +chacha20poly1305 = { version = "0.10", optional = true } +base64 = { version = "0.22", optional = true } +getrandom = { version = "0.4", optional = true } # `TrustTask` — the join-submit document this client signs, and the `#response` # envelope the VTC answers it with. Same crate `vta-sdk` builds the document # from, so one shape crosses the boundary. diff --git a/vtc-client/src/rooms/mod.rs b/vtc-client/src/rooms/mod.rs index fe426a04c..9be92e467 100644 --- a/vtc-client/src/rooms/mod.rs +++ b/vtc-client/src/rooms/mod.rs @@ -30,6 +30,8 @@ #[cfg(feature = "mls")] pub mod mls; +#[cfg(feature = "mls")] +pub mod sealed; use serde::{Deserialize, Serialize}; diff --git a/vtc-client/src/rooms/sealed.rs b/vtc-client/src/rooms/sealed.rs new file mode 100644 index 000000000..f644e2889 --- /dev/null +++ b/vtc-client/src/rooms/sealed.rs @@ -0,0 +1,287 @@ +//! Sealing and opening records on the `attributed` and `private` tiers. +//! +//! This is where the MLS group layer and the room's task surface meet: a record is sealed +//! under the key [`super::mls::RoomGroup::storage_key`] derives for the current epoch, and +//! the host stores ciphertext it cannot read. +//! +//! # The binding is the interesting part +//! +//! Each record's AEAD associated data commits to `roomId | key | version | epoch`. A host +//! that relocates a sealed record — to another key, another version, another epoch, or +//! another room — produces an authentication failure rather than a readable record. It holds +//! every byte and still cannot move one, which is the property that makes an untrusted host +//! tolerable. +//! +//! This is the same class of defence `vti_common::store::encryption` already applies to +//! keyspace values by binding them to their `(keyspace, key)` location. Repeating it here is +//! deliberate: the reasoning was paid for once and should not have to be rediscovered. +//! +//! # Version is bound before it is known +//! +//! A record's version is assigned by the host, from the room's counter — so a writer does +//! not know it at sealing time. [`SealedRoom::seal_record`] therefore takes 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: silently accepting whatever version +//! came back would mean the binding commits to nothing. +//! +//! The practical shape is create-only writes (`expected_version: Some(0)`) or a read of the +//! current version before a rewrite — both of which the task surface already supports. +//! +//! # What is deliberately not sealed +//! +//! The record's key, version and epoch travel in the clear: the host needs them to store and +//! serve the right ciphertext. Keys must therefore be **opaque** on these tiers — a key +//! reading `decision/acquire-northwind` defeats the encryption sitting beside it. +//! [`SealedRoom::opaque_key`] mints one. + +use base64::Engine as _; +use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce}; + +use super::mls::RoomGroup; +use super::{RoomSession, SealedContent}; +use crate::VtcError; + +/// A room whose records are sealed, and the group state that seals them. +/// +/// Pairs a [`RoomSession`] — the credentials the host authorizes against — with a +/// [`RoomGroup`] — the key material the 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. +pub struct SealedRoom { + session: RoomSession, + group: RoomGroup, +} + +impl SealedRoom { + /// Pair a session with its group. + pub fn new(session: RoomSession, group: RoomGroup) -> Self { + Self { session, group } + } + + /// The credentials to present. + pub fn session(&self) -> &RoomSession { + &self.session + } + + /// The group, for membership changes and epoch anchoring. + pub fn group(&self) -> &RoomGroup { + &self.group + } + + /// Mutable access, for committing a membership change. + pub fn group_mut(&mut self) -> &mut RoomGroup { + &mut self.group + } + + /// The room's current epoch, as the host records it. + /// + /// MLS epochs start at 0 and the room's start at 1, so this is the MLS epoch plus one. + /// Kept in one place rather than at each call site: an off-by-one here would seal + /// records under an epoch the host rejects, and the failure would look like a key + /// problem rather than an arithmetic one. + pub fn room_epoch(&self) -> u32 { + (self.group.epoch() + 1) as u32 + } + + /// A random, opaque record key. + /// + /// Sealed tiers require these: a descriptive key is readable by the host and defeats the + /// encryption beside it. Structured naming belongs *inside* the sealed body. + pub fn opaque_key() -> String { + let mut bytes = [0u8; 16]; + getrandom::fill(&mut bytes).expect("OS randomness unavailable"); + B64.encode(bytes) + } + + /// Seal `plaintext` for `key` at `version`. + /// + /// `version` is the version the writer intends the record to take — see the module docs + /// on why it is bound before the host assigns it. + pub fn seal_record( + &self, + key: &str, + version: u64, + plaintext: &[u8], + ) -> Result { + let epoch = self.room_epoch(); + let storage_key = self.group.storage_key()?; + let aad = associated_data(self.session.room_id(), key, version, epoch); + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&storage_key)); + let mut nonce_bytes = [0u8; 12]; + getrandom::fill(&mut nonce_bytes).expect("OS randomness unavailable"); + + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce_bytes), + Payload { + msg: plaintext, + aad: &aad, + }, + ) + .map_err(|e| VtcError::Signing(format!("seal record: {e}")))?; + + Ok(SealedContent { + ciphertext: B64.encode(ciphertext), + nonce: B64.encode(nonce_bytes), + epoch, + }) + } + + /// Open a record the host returned. + /// + /// Fails rather than returning wrong bytes if the record was relocated, if the epoch was + /// relabelled, or if the key for that epoch is not the one this member holds. + pub fn open_record( + &self, + key: &str, + version: u64, + sealed: &SealedContent, + ) -> Result, VtcError> { + let storage_key = self.group.storage_key()?; + let aad = associated_data(self.session.room_id(), key, version, sealed.epoch); + + let ciphertext = B64 + .decode(&sealed.ciphertext) + .map_err(|e| VtcError::Signing(format!("decode ciphertext: {e}")))?; + let nonce = B64 + .decode(&sealed.nonce) + .map_err(|e| VtcError::Signing(format!("decode nonce: {e}")))?; + if nonce.len() != 12 { + return Err(VtcError::Signing(format!( + "nonce is {} bytes, expected 12", + nonce.len() + ))); + } + + let cipher = ChaCha20Poly1305::new(Key::from_slice(&storage_key)); + cipher + .decrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &ciphertext, + aad: &aad, + }, + ) + .map_err(|_| { + VtcError::Signing( + "record did not open: it was sealed under a different key, epoch, or \ + location — a relocated record fails here rather than decrypting wrongly" + .into(), + ) + }) + } + + /// The value to anchor in the room's witnessed DID log for this epoch. + /// + /// A host that forks the group shows different members different commit sequences. + /// Comparing this against the anchored value is how a member finds out. + pub fn epoch_anchor(&self) -> Vec { + self.group.epoch_authenticator() + } +} + +/// `roomId | key | version | epoch`, the associated data a record is bound to. +/// +/// Length-prefix-free but unambiguous by construction: `|` cannot appear in a base64url key +/// or in the decimal fields, and `roomId` is a DID. If any of those ever stops holding, this +/// needs length prefixes — the failure mode otherwise is two different records producing the +/// same associated data, which is exactly what the binding exists to prevent. +fn associated_data(room_id: &str, key: &str, version: u64, epoch: u32) -> Vec { + format!("{room_id}|{key}|{version}|{epoch}").into_bytes() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn room(did: &str) -> SealedRoom { + let session = RoomSession::new(did, "vmc", vec!["vac".into()]).expect("session"); + let group = RoomGroup::create("did:key:zAlice").expect("group"); + SealedRoom::new(session, group) + } + + #[test] + fn a_record_round_trips_under_the_group_key() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"a decision").expect("seal"); + let opened = r.open_record("k1", 1, &sealed).expect("open"); + assert_eq!(opened, b"a decision"); + } + + /// The property that makes an untrusted host tolerable: it holds every byte and still + /// cannot move one. + #[test] + fn a_relocated_record_does_not_open() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"a decision").expect("seal"); + + assert!( + r.open_record("k2", 1, &sealed).is_err(), + "moving a record to another key must fail" + ); + assert!( + r.open_record("k1", 2, &sealed).is_err(), + "moving it to another version must fail" + ); + + let mut relabelled = sealed.clone(); + relabelled.epoch += 1; + assert!( + r.open_record("k1", 1, &relabelled).is_err(), + "relabelling the epoch must fail authentication, not decrypt wrongly" + ); + + let other_room = RoomSession::new("did:webvh:zOther", "vmc", vec!["vac".into()]).unwrap(); + let moved = SealedRoom::new(other_room, RoomGroup::create("did:key:zAlice").unwrap()); + assert!( + moved.open_record("k1", 1, &sealed).is_err(), + "moving it to another room must fail" + ); + } + + #[test] + fn a_non_member_cannot_open_a_record() { + let r = room("did:webvh:zRoom"); + let sealed = r.seal_record("k1", 1, b"members only").expect("seal"); + + // A different group is a different key, however identical everything else looks. + let outsider = SealedRoom::new( + RoomSession::new("did:webvh:zRoom", "vmc", vec!["vac".into()]).unwrap(), + RoomGroup::create("did:key:zMallory").unwrap(), + ); + assert!(outsider.open_record("k1", 1, &sealed).is_err()); + } + + #[test] + fn the_room_epoch_is_the_mls_epoch_plus_one() { + let r = room("did:webvh:zRoom"); + assert_eq!(r.group().epoch(), 0, "MLS starts at 0"); + assert_eq!(r.room_epoch(), 1, "the room's first epoch is 1"); + } + + #[test] + fn opaque_keys_are_random_and_carry_no_meaning() { + let a = SealedRoom::opaque_key(); + let b = SealedRoom::opaque_key(); + assert_ne!(a, b); + assert!( + !a.contains('/'), + "url-safe, so it needs no escaping in a payload" + ); + } + + /// Sealing twice must not reuse a nonce, or the AEAD's guarantee is gone. + #[test] + fn sealing_the_same_plaintext_twice_uses_a_fresh_nonce() { + let r = room("did:webvh:zRoom"); + let a = r.seal_record("k1", 1, b"same").expect("seal"); + let b = r.seal_record("k1", 1, b"same").expect("seal again"); + assert_ne!(a.nonce, b.nonce, "a reused nonce breaks ChaCha20-Poly1305"); + assert_ne!(a.ciphertext, b.ciphertext); + // Both still open. + assert_eq!(r.open_record("k1", 1, &a).unwrap(), b"same"); + assert_eq!(r.open_record("k1", 1, &b).unwrap(), b"same"); + } +} From f7031fca1f12eb878a8917e51d029db6c8c4dbcf Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:39:00 +0200 Subject: [PATCH 07/14] refactor(rooms): extract storage, wire and authz into vti-rooms 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 --- Cargo.lock | 12 + Cargo.toml | 1 + vtc-service/Cargo.toml | 1 + vtc-service/src/rooms/handlers.rs | 34 +-- vtc-service/src/rooms/mod.rs | 222 +--------------- vtc-service/src/store/keyspaces.rs | 4 +- vtc-service/src/trust_tasks/mod.rs | 2 +- vti-rooms/Cargo.toml | 22 ++ .../src/rooms => vti-rooms/src}/authz.rs | 4 +- vti-rooms/src/lib.rs | 239 ++++++++++++++++++ .../src/rooms => vti-rooms/src}/storage.rs | 8 +- .../src/rooms => vti-rooms/src}/wire.rs | 4 +- 12 files changed, 314 insertions(+), 239 deletions(-) create mode 100644 vti-rooms/Cargo.toml rename {vtc-service/src/rooms => vti-rooms/src}/authz.rs (99%) create mode 100644 vti-rooms/src/lib.rs rename {vtc-service/src/rooms => vti-rooms/src}/storage.rs (98%) rename {vtc-service/src/rooms => vti-rooms/src}/wire.rs (98%) diff --git a/Cargo.lock b/Cargo.lock index 9398ea69b..57f2fb0dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10671,6 +10671,7 @@ dependencies = [ "vtc-client", "vtc-service", "vti-common", + "vti-rooms", "vti-secrets", "webauthn-rs", "webauthn-rs-proto", @@ -10754,6 +10755,17 @@ dependencies = [ "vti-common", ] +[[package]] +name = "vti-rooms" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tempfile", + "tokio", + "vti-common", +] + [[package]] name = "vti-secrets" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 92378572f..c0448ab9b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ members = [ "vtc-client", "vtc-service", "vti-common", + "vti-rooms", "vti-secrets", "vti-webauthn", ] diff --git a/vtc-service/Cargo.toml b/vtc-service/Cargo.toml index d99114d89..c54d142e7 100644 --- a/vtc-service/Cargo.toml +++ b/vtc-service/Cargo.toml @@ -117,6 +117,7 @@ name = "vtc" path = "src/main.rs" [dependencies] +vti-rooms = { path = "../vti-rooms", version = "0.1" } vti-common = { path = "../vti-common", version = "0.16", features = [ "passkey", # `setup` gates the shared dialoguer-driven secrets-prompt helper diff --git a/vtc-service/src/rooms/handlers.rs b/vtc-service/src/rooms/handlers.rs index bb84a669e..1630bcf6a 100644 --- a/vtc-service/src/rooms/handlers.rs +++ b/vtc-service/src/rooms/handlers.rs @@ -22,17 +22,17 @@ use serde_json::Value; use trust_tasks_rs::TrustTask; -use crate::rooms::authz::{self, Action}; -use crate::rooms::storage; -use crate::rooms::wire::{ - CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, - MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, -}; -use crate::rooms::{Record, RecordStatus, Room}; use crate::server::AppState; use crate::trust_tasks::helpers::{ TrustTaskOutcome, app_error_to_reject, parse_payload, success_response, }; +use vti_rooms::authz::{self, Action}; +use vti_rooms::storage; +use vti_rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, +}; +use vti_rooms::{Record, RecordStatus, Room}; /// Seconds since the Unix epoch. fn now() -> u64 { @@ -262,7 +262,7 @@ mod tests { handle_create( state, doc( - crate::rooms::wire::ROOMS_CREATE_TYPE, + vti_rooms::wire::ROOMS_CREATE_TYPE, json!({ "roomId": id, "visibility": visibility, "ownerDid": "did:key:zOwner" }), ), ) @@ -283,7 +283,7 @@ mod tests { let out = handle_put_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, json!({ "roomId": "r1", "key": "k1", "presentation": presentation(), "cleartext": { "body": "a decision" } @@ -297,7 +297,7 @@ mod tests { let out = handle_get_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), ), ) @@ -317,7 +317,7 @@ mod tests { let out = handle_put_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, json!({ "roomId": "r1", "key": "k1", "presentation": { "membership": "vmc", "authority": [] }, @@ -341,7 +341,7 @@ mod tests { let out = handle_get_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), ), ) @@ -360,7 +360,7 @@ mod tests { handle_put_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_PUT_TYPE, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, json!({ "roomId": "r1", "key": k, "presentation": presentation(), "cleartext": { "body": "secret-body-text" } @@ -373,7 +373,7 @@ mod tests { let out = handle_list_records( state, doc( - crate::rooms::wire::ROOMS_RECORDS_LIST_TYPE, + vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE, json!({ "roomId": "r1", "presentation": presentation() }), ), ) @@ -407,7 +407,7 @@ mod tests { let out = handle_mint_epoch( state, doc( - crate::rooms::wire::ROOMS_EPOCH_MINT_TYPE, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, json!({ "roomId": "r1", "epoch": 2, "presentation": presentation() }), ), ) @@ -419,7 +419,7 @@ mod tests { let out = handle_mint_epoch( state, doc( - crate::rooms::wire::ROOMS_EPOCH_MINT_TYPE, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, json!({ "roomId": "r1", "epoch": 9, "presentation": presentation() }), ), ) @@ -437,7 +437,7 @@ mod tests { let out = handle_get_record( state, doc( - crate::rooms::wire::ROOMS_RECORDS_GET_TYPE, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, json!({ "roomId": "r1", "key": "k", "presentation": presentation(), "escalate": true diff --git a/vtc-service/src/rooms/mod.rs b/vtc-service/src/rooms/mod.rs index 55967055a..bb8f2785d 100644 --- a/vtc-service/src/rooms/mod.rs +++ b/vtc-service/src/rooms/mod.rs @@ -1,215 +1,17 @@ -//! Data rooms — the storage layer. +//! Data rooms — this service's Trust-Task surface over [`vti_rooms`]. //! -//! A **data room** is a shared space whose access is governed by credentials the *room -//! itself* issues, not by anything this service stores. That single property is what the -//! rest of this module is arranged around, and it is worth stating before the types, -//! because it inverts the assumption every other keyspace here is built on. +//! Storage, wire types and authorization live in the `vti-rooms` crate, because none of +//! them needs anything from a community service: a room is authorized by credentials the +//! room itself issued, so the code deciding a room operation cannot need a roster, a policy +//! engine, or a session store. What stays here is [`handlers`] — the dispatch surface, which +//! is this service's spine and therefore not extractable. //! -//! # What this module deliberately does not hold -//! -//! **There is no member list.** Not omitted for now — there must not be one. Authorization -//! is a presentation carrying a membership credential and an authority chain, verified -//! against the room's own identifier. 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 has become part of the room's membership -//! definition, and a room whose contents we cannot read acquires a member list we can. -//! -//! So the row below carries an owner, a visibility, an epoch and a retention period, and -//! nothing about who belongs. See `docs/05-design-notes/data-rooms.md` §1 (invariant I5). -//! -//! # What this service can and cannot see -//! -//! Set by the room's [`Visibility`], fixed at creation: -//! -//! | | `Open` | `Attributed` | `Private` | -//! |---|---|---|---| -//! | Record content | cleartext | sealed | sealed | -//! | Which member acted | visible | visible | unlinkable proof | -//! | Owner | visible | visible | visible | -//! -//! The owner is visible at every tier on purpose. A room whose contents nobody here can -//! read still has a party answerable for it existing — for quota, for abuse, and for the -//! lifecycle notice in §9 of the design note. -//! -//! # Scope of this module -//! -//! Four layers, smallest first: -//! -//! - [`storage`] — the keyspaces and their invariants. -//! - [`wire`] — the Trust-Task payload types, hand-written against the schemas proposed in -//! `trustoverip/dtgwg-trust-tasks-tf#346` until its generated bindings publish. -//! - [`authz`] — deciding whether an operation is allowed, **without reading this service's -//! ACL or roster**. The invariant the whole design rests on. -//! - [`handlers`] — the Trust-Task verbs, which are thin because the three layers below -//! them are not. +//! The re-exports keep `crate::rooms::Room` and friends resolving, so nothing outside this +//! module had to move when the subsystem did. -pub mod authz; pub mod handlers; -pub mod storage; -pub mod wire; - -use serde::{Deserialize, Serialize}; - -/// How much of a room this service can see. -/// -/// **Immutable for the life of a room.** A downgrade cannot un-see cleartext, and an -/// upgrade would protect only what came after while presenting as though it protected -/// everything. To change the visibility of some material, make another room and move it -/// deliberately. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum Visibility { - /// Records are cleartext: searchable here, and readable by whoever operates this - /// service. Right for material where that is not a threat and losing search is a real - /// cost. - Open, - /// Record content is sealed; the acting member is still disclosed. The tier for anyone - /// under an obligation to produce per-member access logs. - Attributed, - /// Content is sealed and membership is presented in zero knowledge: this service - /// verifies that *a* member acted without learning which. - Private, -} - -impl Visibility { - /// Whether this service holds record content in the clear. - /// - /// The one place to ask. A caller testing `== Visibility::Open` in several places will - /// eventually miss one, and the failure mode is storing a plaintext record on a tier - /// that promised not to. - pub fn stores_cleartext(&self) -> bool { - matches!(self, Visibility::Open) - } - - /// Whether a record's acting member is disclosed to this service. - pub fn discloses_actor(&self) -> bool { - matches!(self, Visibility::Open | Visibility::Attributed) - } -} - -/// A room, as this service holds it. -/// -/// Note what is absent: no members, no keys, no credentials. This service is told the -/// epoch *number* so it can serve the right ciphertext, and never the key. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Room { - /// The room's own identifier, minted by its owner before registration. - /// - /// This service does not assign one. A room identified by something its host chose - /// could not move to another host without changing identity, and portability is what - /// the whole design rests on. - pub room_id: String, - - /// The accountable party: controller of the room's identifier, issuer of every - /// credential in it, and the party addressed about quota, abuse and lifecycle. - pub owner_did: String, - - /// Fixed at creation. See [`Visibility`]. - pub visibility: Visibility, - - /// The current key epoch. Advanced by the owner on removal; this service records the - /// number and never learns the key. - pub epoch: u32, - - /// The next record version to assign. - /// - /// Monotonic **per room**, not per record — one comparable number is what a - /// `sinceVersion` watermark needs, and per-record counters are not comparable to each - /// other. Learned the expensive way by the app-state store; see - /// `docs/05-design-notes/appstate-store.md` §2. - pub next_version: u64, - - /// How long this service holds the room after its epoch lapses without renewal. - /// - /// Stated at creation rather than discovered later: a reclamation that surprises a - /// member is a failure of the design, not of the member. - pub retention_days: u32, - - /// Unix-epoch seconds. - pub created_at: u64, - /// Unix-epoch seconds; bumped on epoch advance and on record writes. - pub updated_at: u64, -} - -/// Curation state of a record. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum RecordStatus { - /// Normal. - Active, - /// Superseded but retained; a client demotes it in recall rather than hiding it. - Deprecated, - /// A tombstone. The body is gone; the key, version and epoch remain. - /// - /// Retained rather than deleted because incremental sync needs it: without a tombstone - /// a puller learns of every create and update and never of a delete, so retracted - /// records resurrect on the next full rebuild and disagree with peers that saw the - /// retraction. - Retracted, -} - -/// One record. -/// -/// On `Attributed` and `Private` rooms `sealed` carries the ciphertext and `cleartext` is -/// `None`; on `Open` it is the other way round. Enforced at the operations layer rather -/// than the type, because the invariant is per-room and the type is per-record. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Record { - /// The record's key within the room. - /// - /// On the sealed tiers this MUST be opaque — a random identifier, never a descriptive - /// slug. A key reading `decision/acquire-northwind` defeats the encryption sitting - /// beside it. Structured naming belongs inside the sealed body. - pub key: String, - - /// Server-assigned, monotonic per room. Also the `sinceVersion` watermark. - pub version: u64, - - /// The key epoch this record was sealed under. `None` on an `Open` room. - pub epoch: Option, - - /// Curation state. - pub status: RecordStatus, - - /// Sealed content, base64url. Present on the sealed tiers. - #[serde(skip_serializing_if = "Option::is_none")] - pub sealed: Option, - - /// AEAD nonce, base64url. Present with `sealed`. - #[serde(skip_serializing_if = "Option::is_none")] - pub nonce: Option, - - /// Cleartext content. Present only on an `Open` room. - #[serde(skip_serializing_if = "Option::is_none")] - pub cleartext: Option, - - /// The member who wrote it, where the tier discloses one. - /// - /// `None` on a `Private` room — there the author lives inside the sealed body, where - /// only members can read it. - #[serde(skip_serializing_if = "Option::is_none")] - pub author: Option, - - /// Unix-epoch seconds. - pub updated_at: u64, -} -impl Record { - /// The metadata projection a listing returns. - /// - /// **Never the body.** Ranking happens on the client, and a service that returned every - /// body would make a caller pay for the whole room on every listing — and on a sealed - /// tier could not usefully rank them anyway. - pub fn metadata(&self) -> serde_json::Value { - serde_json::json!({ - "key": self.key, - "version": self.version, - "epoch": self.epoch, - "status": self.status, - "author": self.author, - "updatedAt": self.updated_at, - }) - } -} +pub use vti_rooms::{ + ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, Visibility, authz, storage, + wire, +}; diff --git a/vtc-service/src/store/keyspaces.rs b/vtc-service/src/store/keyspaces.rs index b7a30c8c3..de90eca0b 100644 --- a/vtc-service/src/store/keyspaces.rs +++ b/vtc-service/src/store/keyspaces.rs @@ -40,10 +40,10 @@ pub const ENDORSEMENTS: &str = "endorsements"; /// Holds an owner, a visibility, an epoch and a retention period — and deliberately **no /// member list**. Membership is decided by credentials the room itself issued, so a roster /// here would make the room unmovable and make this service part of its membership. -pub const ROOMS: &str = "rooms"; +pub use vti_rooms::ROOMS_KEYSPACE as ROOMS; /// Room records at `room_records::`. Ciphertext on the sealed tiers. -pub const ROOM_RECORDS: &str = "room_records"; +pub use vti_rooms::ROOM_RECORDS_KEYSPACE as ROOM_RECORDS; pub const AUDIT: &str = "audit"; pub const AUDIT_KEY: &str = "audit_key"; /// Signed audit checkpoints (#708) — periodic Ed25519-signed commitments to diff --git a/vtc-service/src/trust_tasks/mod.rs b/vtc-service/src/trust_tasks/mod.rs index 79dc3a6c9..6b549c495 100644 --- a/vtc-service/src/trust_tasks/mod.rs +++ b/vtc-service/src/trust_tasks/mod.rs @@ -63,7 +63,7 @@ use vta_sdk::protocols::join_requests::{ }; use vta_sdk::protocols::members::{self as mem, MemberVmcBody, MemberVmcReceiptBody}; -use crate::rooms::wire as rooms_wire; +use vti_rooms::wire as rooms_wire; use crate::join::{JoinSubmitOutcome, JoinTransport}; use crate::server::AppState; diff --git a/vti-rooms/Cargo.toml b/vti-rooms/Cargo.toml new file mode 100644 index 000000000..da8273998 --- /dev/null +++ b/vti-rooms/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "vti-rooms" +description = "Data-room storage, wire types, and authorization — the parts of a room that are not a service" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish.workspace = true + +[dependencies] +# The store abstraction and AppError. This crate's only internal dependency: +# a room's storage and authorization need nothing from a community service, +# which is the whole reason they are extractable. +vti-common = { path = "../vti-common", version = "0.16" } + +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +tempfile = "3" +tokio = { workspace = true } diff --git a/vtc-service/src/rooms/authz.rs b/vti-rooms/src/authz.rs similarity index 99% rename from vtc-service/src/rooms/authz.rs rename to vti-rooms/src/authz.rs index 688322321..40859cedf 100644 --- a/vtc-service/src/rooms/authz.rs +++ b/vti-rooms/src/authz.rs @@ -29,8 +29,8 @@ use vti_common::error::AppError; -use super::wire::AuthorityPresentation; -use super::{Room, Visibility}; +use crate::wire::AuthorityPresentation; +use crate::{Room, Visibility}; /// Maximum links in an authority chain, including the root. /// diff --git a/vti-rooms/src/lib.rs b/vti-rooms/src/lib.rs new file mode 100644 index 000000000..5adf38b6e --- /dev/null +++ b/vti-rooms/src/lib.rs @@ -0,0 +1,239 @@ +//! Data rooms — storage, wire types, and authorization. +//! +//! A **data room** is a shared space whose access is governed by credentials the *room +//! itself* issues, not by anything this service stores. That single property is what the +//! rest of this module is arranged around, and it is worth stating before the types, +//! because it inverts the assumption every other keyspace here is built on. +//! +//! # What this module deliberately does not hold +//! +//! **There is no member list.** Not omitted for now — there must not be one. Authorization +//! is a presentation carrying a membership credential and an authority chain, verified +//! against the room's own identifier. 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 has become part of the room's membership +//! definition, and a room whose contents we cannot read acquires a member list we can. +//! +//! So the row below carries an owner, a visibility, an epoch and a retention period, and +//! nothing about who belongs. See `docs/05-design-notes/data-rooms.md` §1 (invariant I5). +//! +//! # What this service can and cannot see +//! +//! Set by the room's [`Visibility`], fixed at creation: +//! +//! | | `Open` | `Attributed` | `Private` | +//! |---|---|---|---| +//! | Record content | cleartext | sealed | sealed | +//! | Which member acted | visible | visible | unlinkable proof | +//! | Owner | visible | visible | visible | +//! +//! The owner is visible at every tier on purpose. A room whose contents nobody here can +//! read still has a party answerable for it existing — for quota, for abuse, and for the +//! lifecycle notice in §9 of the design note. +//! +//! # Scope of this module +//! +//! # Why this is a crate and not part of a service +//! +//! A room's storage and its authorization need nothing from a community service. That is +//! not an accident of layering — 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 compiler enforces that this crate depends on `vti-common` and nothing else. +//! +//! The concrete win is a second consumer. A **room host** — someone hosting their own rooms +//! on their own infrastructure, topology T1 of the design — 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. +//! +//! Three layers: +//! +//! - [`storage`] — the keyspaces and their invariants. +//! - [`wire`] — the Trust-Task payload types, hand-written against the schemas in +//! `trustoverip/dtgwg-trust-tasks-tf#346` until the generated bindings publish. +//! - [`authz`] — deciding whether an operation is allowed, **without reading any host's ACL +//! or roster**. The invariant the whole design rests on. +//! +//! The Trust-Task **handlers** are deliberately *not* here. Dispatch is a service's spine, +//! and a spine is not extractable — see `docs/05-design-notes/vta-service-decomposition.md`. +//! Each host writes its own thin handlers over these three layers. + +pub mod authz; +pub mod storage; +pub mod wire; + +use serde::{Deserialize, Serialize}; + +/// Keyspace holding one row per room. +/// +/// Named here rather than in a host's registry because the *name* is part of the storage +/// contract: two hosts using different names could not serve the same room's data directory. +pub const ROOMS_KEYSPACE: &str = "rooms"; + +/// Keyspace holding room records. +pub const ROOM_RECORDS_KEYSPACE: &str = "room_records"; + +/// How much of a room this service can see. +/// +/// **Immutable for the life of a room.** A downgrade cannot un-see cleartext, and an +/// upgrade would protect only what came after while presenting as though it protected +/// everything. To change the visibility of some material, make another room and move it +/// deliberately. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Visibility { + /// Records are cleartext: searchable here, and readable by whoever operates this + /// service. Right for material where that is not a threat and losing search is a real + /// cost. + Open, + /// Record content is sealed; the acting member is still disclosed. The tier for anyone + /// under an obligation to produce per-member access logs. + Attributed, + /// Content is sealed and membership is presented in zero knowledge: this service + /// verifies that *a* member acted without learning which. + Private, +} + +impl Visibility { + /// Whether this service holds record content in the clear. + /// + /// The one place to ask. A caller testing `== Visibility::Open` in several places will + /// eventually miss one, and the failure mode is storing a plaintext record on a tier + /// that promised not to. + pub fn stores_cleartext(&self) -> bool { + matches!(self, Visibility::Open) + } + + /// Whether a record's acting member is disclosed to this service. + pub fn discloses_actor(&self) -> bool { + matches!(self, Visibility::Open | Visibility::Attributed) + } +} + +/// A room, as this service holds it. +/// +/// Note what is absent: no members, no keys, no credentials. This service is told the +/// epoch *number* so it can serve the right ciphertext, and never the key. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Room { + /// The room's own identifier, minted by its owner before registration. + /// + /// This service does not assign one. A room identified by something its host chose + /// could not move to another host without changing identity, and portability is what + /// the whole design rests on. + pub room_id: String, + + /// The accountable party: controller of the room's identifier, issuer of every + /// credential in it, and the party addressed about quota, abuse and lifecycle. + pub owner_did: String, + + /// Fixed at creation. See [`Visibility`]. + pub visibility: Visibility, + + /// The current key epoch. Advanced by the owner on removal; this service records the + /// number and never learns the key. + pub epoch: u32, + + /// The next record version to assign. + /// + /// Monotonic **per room**, not per record — one comparable number is what a + /// `sinceVersion` watermark needs, and per-record counters are not comparable to each + /// other. Learned the expensive way by the app-state store; see + /// `docs/05-design-notes/appstate-store.md` §2. + pub next_version: u64, + + /// How long this service holds the room after its epoch lapses without renewal. + /// + /// Stated at creation rather than discovered later: a reclamation that surprises a + /// member is a failure of the design, not of the member. + pub retention_days: u32, + + /// Unix-epoch seconds. + pub created_at: u64, + /// Unix-epoch seconds; bumped on epoch advance and on record writes. + pub updated_at: u64, +} + +/// Curation state of a record. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RecordStatus { + /// Normal. + Active, + /// Superseded but retained; a client demotes it in recall rather than hiding it. + Deprecated, + /// A tombstone. The body is gone; the key, version and epoch remain. + /// + /// Retained rather than deleted because incremental sync needs it: without a tombstone + /// a puller learns of every create and update and never of a delete, so retracted + /// records resurrect on the next full rebuild and disagree with peers that saw the + /// retraction. + Retracted, +} + +/// One record. +/// +/// On `Attributed` and `Private` rooms `sealed` carries the ciphertext and `cleartext` is +/// `None`; on `Open` it is the other way round. Enforced at the operations layer rather +/// than the type, because the invariant is per-room and the type is per-record. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Record { + /// The record's key within the room. + /// + /// On the sealed tiers this MUST be opaque — a random identifier, never a descriptive + /// slug. A key reading `decision/acquire-northwind` defeats the encryption sitting + /// beside it. Structured naming belongs inside the sealed body. + pub key: String, + + /// Server-assigned, monotonic per room. Also the `sinceVersion` watermark. + pub version: u64, + + /// The key epoch this record was sealed under. `None` on an `Open` room. + pub epoch: Option, + + /// Curation state. + pub status: RecordStatus, + + /// Sealed content, base64url. Present on the sealed tiers. + #[serde(skip_serializing_if = "Option::is_none")] + pub sealed: Option, + + /// AEAD nonce, base64url. Present with `sealed`. + #[serde(skip_serializing_if = "Option::is_none")] + pub nonce: Option, + + /// Cleartext content. Present only on an `Open` room. + #[serde(skip_serializing_if = "Option::is_none")] + pub cleartext: Option, + + /// The member who wrote it, where the tier discloses one. + /// + /// `None` on a `Private` room — there the author lives inside the sealed body, where + /// only members can read it. + #[serde(skip_serializing_if = "Option::is_none")] + pub author: Option, + + /// Unix-epoch seconds. + pub updated_at: u64, +} + +impl Record { + /// The metadata projection a listing returns. + /// + /// **Never the body.** Ranking happens on the client, and a service that returned every + /// body would make a caller pay for the whole room on every listing — and on a sealed + /// tier could not usefully rank them anyway. + pub fn metadata(&self) -> serde_json::Value { + serde_json::json!({ + "key": self.key, + "version": self.version, + "epoch": self.epoch, + "status": self.status, + "author": self.author, + "updatedAt": self.updated_at, + }) + } +} diff --git a/vtc-service/src/rooms/storage.rs b/vti-rooms/src/storage.rs similarity index 98% rename from vtc-service/src/rooms/storage.rs rename to vti-rooms/src/storage.rs index bd5186da5..01c2998c4 100644 --- a/vtc-service/src/rooms/storage.rs +++ b/vti-rooms/src/storage.rs @@ -296,7 +296,7 @@ pub async fn purge_record( #[cfg(test)] mod tests { use super::*; - use crate::rooms::Visibility; + use crate::Visibility; use vti_common::config::StoreConfig; use vti_common::store::Store; @@ -306,10 +306,8 @@ mod tests { data_dir: dir.path().to_path_buf(), }) .unwrap(); - let rooms = store.keyspace(crate::store::keyspaces::ROOMS).unwrap(); - let records = store - .keyspace(crate::store::keyspaces::ROOM_RECORDS) - .unwrap(); + let rooms = store.keyspace(crate::ROOMS_KEYSPACE).unwrap(); + let records = store.keyspace(crate::ROOM_RECORDS_KEYSPACE).unwrap(); (dir, rooms, records) } diff --git a/vtc-service/src/rooms/wire.rs b/vti-rooms/src/wire.rs similarity index 98% rename from vtc-service/src/rooms/wire.rs rename to vti-rooms/src/wire.rs index dd51e6bdf..8da8231f2 100644 --- a/vtc-service/src/rooms/wire.rs +++ b/vti-rooms/src/wire.rs @@ -18,7 +18,7 @@ use serde::{Deserialize, Serialize}; -use super::Visibility; +use crate::Visibility; /// `rooms/create/0.1`. pub const ROOMS_CREATE_TYPE: &str = "https://trusttasks.org/spec/rooms/create/0.1"; @@ -62,7 +62,7 @@ pub struct AuthorityPresentation { /// /// Without it two parties pool credentials — one contributes membership, the other /// authority — and the combination verifies as a single party holding both. Silent when - /// wrong, which is why [`super::authz`] refuses a private-room presentation that omits + /// wrong, which is why [`crate::authz`] refuses a private-room presentation that omits /// it rather than treating it as optional. #[serde(default, skip_serializing_if = "Option::is_none")] pub subject_binding: Option, From 24d99824feadbb81694dfa3da16885dbd258259f Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:41:55 +0200 Subject: [PATCH 08/14] feat(room-host): a host that stores rooms it does not govern 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 --- Cargo.lock | 18 ++ Cargo.toml | 1 + room-host/Cargo.toml | 33 +++ room-host/src/main.rs | 482 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 534 insertions(+) create mode 100644 room-host/Cargo.toml create mode 100644 room-host/src/main.rs diff --git a/Cargo.lock b/Cargo.lock index 57f2fb0dc..329e0de60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7886,6 +7886,24 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "room-host" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "clap", + "serde", + "serde_json", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", + "vti-common", + "vti-rooms", +] + [[package]] name = "rustc-hash" version = "2.1.3" diff --git a/Cargo.toml b/Cargo.toml index c0448ab9b..38a87ba5e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ + "room-host", "cnm-cli", "didcomm-test", "pnm-cli", diff --git a/room-host/Cargo.toml b/room-host/Cargo.toml new file mode 100644 index 000000000..7874e0727 --- /dev/null +++ b/room-host/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "room-host" +description = "Stores data-room records for rooms it does not govern — a delivery service, not a community" +version = "0.1.0" +edition.workspace = true +publish = false +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[[bin]] +name = "room-host" +path = "src/main.rs" + +[dependencies] +# Storage, wire types and authorization. The whole of what hosting a room needs — +# no member lifecycle, no policy engine, no credential issuance, no admin UI. +vti-rooms = { path = "../vti-rooms", version = "0.1" } +# The store and AppError. Nothing else internal. +vti-common = { path = "../vti-common", version = "0.16" } + +axum = { workspace = true } +tokio = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { workspace = true } +clap = { workspace = true } +anyhow = "1" + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/room-host/src/main.rs b/room-host/src/main.rs new file mode 100644 index 000000000..4e247f25f --- /dev/null +++ b/room-host/src/main.rs @@ -0,0 +1,482 @@ +//! A room host: it stores data-room records and serves them back. +//! +//! # What this is, and what it deliberately is not +//! +//! A room host is a **delivery service**. It holds records — ciphertext, on any tier but +//! `open` — and answers `rooms/*` Trust Tasks against them. It is not a community: it has no +//! member roster, no policy engine, no credential issuance, no admin surface, and no opinion +//! about who belongs to any room it stores. +//! +//! That is not minimalism for its own sake. **A room is authorized by credentials the room +//! itself issued**, so a host that kept its own record of who belongs would become part of +//! that room's membership, and the room could no longer move to a different host without +//! reissuing credentials. The absence of a roster here is the portability guarantee, made +//! structural: there is nothing in this binary that could consult one. +//! +//! # Why it exists as its own binary +//! +//! Topology T1 of the data-rooms design is a person hosting their own rooms on +//! infrastructure they control. Before `vti-rooms` was extracted, doing 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 that. +//! +//! 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 +//! makes a room host an ordinary provisioned integration with its own DID (the `room-host` +//! DID template) rather than a new surface on the agent. +//! +//! # Status +//! +//! The dispatch surface here is the `open` tier. Sealed tiers are refused by +//! [`vti_rooms::authz`] until chain verification is wired, and that refusal lives in the +//! shared crate rather than here — so this host and a VTC cannot disagree about what is +//! safe to serve. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use clap::Parser; +use serde_json::{Value, json}; +use vti_common::config::StoreConfig; +use vti_common::store::{KeyspaceHandle, Store}; +use vti_rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, ROOMS_CREATE_TYPE, + ROOMS_EPOCH_MINT_TYPE, ROOMS_RECORDS_GET_TYPE, ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, +}; +use vti_rooms::{ + ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, + authz::{self, Action}, + storage, +}; + +/// Default retention after a room's epoch lapses without renewal. +const DEFAULT_RETENTION_DAYS: u32 = 90; + +#[derive(Parser, Debug)] +#[command(name = "room-host", about = "Store and serve data-room records")] +struct Args { + /// Where the record store lives. + #[arg(long, default_value = "./room-host-data")] + data_dir: std::path::PathBuf, + /// Address to listen on. + #[arg(long, default_value = "127.0.0.1:8300")] + listen: String, +} + +/// Everything this host holds. Two keyspaces — and note what is not here. +#[derive(Clone)] +struct HostState { + rooms: KeyspaceHandle, + records: KeyspaceHandle, +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A Trust-Task error response. +/// +/// Every authorization failure comes back the same way, with the reason in the body: an +/// operator reading logs can tell a missing chain from an over-deep one, while a caller +/// learns only that it was refused. +fn reject(status: StatusCode, reason: impl std::fmt::Display) -> axum::response::Response { + (status, Json(json!({ "error": reason.to_string() }))).into_response() +} + +/// The one entry point: a `rooms/*` document, routed by its own `type`. +/// +/// One mount rather than five routes, because the document's `type` is its identity — the +/// same shape the VTC's holder-facing surface uses. +async fn trust_task(State(state): State>, body: Json) -> impl IntoResponse { + let doc = body.0; + let type_uri = doc.get("type").and_then(Value::as_str).unwrap_or_default(); + let payload = doc.get("payload").cloned().unwrap_or(Value::Null); + + match type_uri { + ROOMS_CREATE_TYPE => create(&state, payload).await, + ROOMS_RECORDS_PUT_TYPE => put(&state, payload).await, + ROOMS_RECORDS_GET_TYPE => get(&state, payload).await, + ROOMS_RECORDS_LIST_TYPE => list(&state, payload).await, + ROOMS_EPOCH_MINT_TYPE => mint(&state, payload).await, + other => reject( + StatusCode::NOT_FOUND, + format!("this host does not serve `{other}`"), + ), + } +} + +async fn create(state: &HostState, payload: Value) -> axum::response::Response { + let req: CreateRoomBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = Room { + room_id: req.room_id.clone(), + owner_did: req.owner_did, + visibility: req.visibility, + epoch: 1, + next_version: 1, + retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), + created_at: now(), + updated_at: now(), + }; + match storage::create_room(&state.rooms, &room).await { + Ok(()) => Json(CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +async fn put(state: &HostState, payload: Value) -> axum::response::Response { + let req: PutRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { + return reject(StatusCode::FORBIDDEN, e); + } + + let record = Record { + key: req.key.clone(), + version: 0, + epoch: req.sealed.as_ref().map(|s| s.epoch), + status: RecordStatus::Active, + sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), + nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), + cleartext: req + .cleartext + .as_ref() + .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), + // Only where the tier discloses an actor. On a private room authorship lives inside + // the sealed body, and the storage layer refuses it here. + author: room + .visibility + .discloses_actor() + .then(|| room.owner_did.clone()), + updated_at: 0, + }; + + match storage::put_record( + &state.rooms, + &state.records, + &req.room_id, + record, + req.expected_version, + now(), + ) + .await + { + Ok(stored) => Json(PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +async fn get(state: &HostState, payload: Value) -> axum::response::Response { + let req: GetRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::get_record(&state.records, &req.room_id, &req.key).await { + Ok(record) => Json(record).into_response(), + Err(e) => reject(StatusCode::NOT_FOUND, e), + } +} + +async fn list(state: &HostState, payload: Value) -> axum::response::Response { + let req: ListRecordsBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::list_records( + &state.records, + &req.room_id, + req.prefix.as_deref(), + req.since_version, + ) + .await + { + Ok(records) => { + let limit = req.limit.unwrap_or(usize::MAX); + // Metadata, never bodies — the same rule the VTC serves under, because it is a + // property of the task rather than of any one host. + Json(ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }) + .into_response() + } + Err(e) => reject(StatusCode::INTERNAL_SERVER_ERROR, e), + } +} + +async fn mint(state: &HostState, payload: Value) -> axum::response::Response { + let req: MintEpochBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict + // any other by declining to seal them the new key — and this host, which cannot see the + // membership, would have no way to notice. + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { + Ok(updated) => Json(MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +/// Build the router. Separated from `main` so tests can drive it without a socket. +fn router(state: Arc) -> Router { + Router::new() + .route("/trust-tasks", post(trust_task)) + .route("/health", axum::routing::get(|| async { "ok" })) + .with_state(state) +} + +fn open_state(data_dir: &std::path::Path) -> anyhow::Result> { + let store = Store::open(&StoreConfig { + data_dir: data_dir.to_path_buf(), + })?; + Ok(Arc::new(HostState { + rooms: store.keyspace(ROOMS_KEYSPACE)?, + records: store.keyspace(ROOM_RECORDS_KEYSPACE)?, + })) +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| "room_host=info".into()), + ) + .init(); + + let args = Args::parse(); + let state = open_state(&args.data_dir)?; + + let listener = tokio::net::TcpListener::bind(&args.listen).await?; + tracing::info!( + listen = %args.listen, + data_dir = %args.data_dir.display(), + "room host ready — storing records for rooms it does not govern" + ); + axum::serve(listener, router(state)).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn state() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let state = open_state(dir.path()).expect("open store"); + (dir, state) + } + + async fn call(app: &Router, type_uri: &str, payload: Value) -> (StatusCode, Value) { + let doc = json!({ "type": type_uri, "payload": payload }); + let resp = app + .clone() + .oneshot( + Request::post("/trust-tasks") + .header("content-type", "application/json") + .body(Body::from(doc.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + fn presentation() -> Value { + json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) + } + + #[tokio::test] + async fn a_room_is_created_and_a_record_round_trips() { + let (_d, st) = state(); + let app = router(st); + + let (status, _) = call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, body) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "k1", "presentation": presentation(), + "cleartext": { "body": "a decision" } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["version"], 1); + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["cleartext"]["body"], "a decision"); + } + + /// The property that makes this host usable by a room it does not govern: it decides + /// from the chain, and there is nothing else it could decide from. + #[tokio::test] + async fn an_operation_with_no_chain_is_refused() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + + let (status, _) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "k", "presentation": { "membership": "vmc", "authority": [] }, + "cleartext": { "body": "x" } }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// The shared crate decides this, not the host — so a room host and a VTC cannot + /// disagree about what is safe to serve. + #[tokio::test] + async fn sealed_tiers_are_refused_here_exactly_as_they_are_on_a_vtc() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "p1", "visibility": "private", "ownerDid": "did:key:zOwner" }), + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("subject binding"), + "{body}" + ); + } + + #[tokio::test] + async fn a_listing_returns_metadata_and_never_bodies() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "a", "presentation": presentation(), + "cleartext": { "body": "secret-body-text" } }), + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_LIST_TYPE, + json!({ "roomId": "r1", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let text = body.to_string(); + assert!(text.contains("\"key\"")); + assert!( + !text.contains("secret-body-text"), + "a listing must never carry bodies: {text}" + ); + } + + #[tokio::test] + async fn an_unknown_task_is_not_served() { + let (_d, st) = state(); + let (status, _) = call( + &router(st), + "https://trusttasks.org/spec/vtc/members/list/0.1", + json!({}), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "a room host serves rooms and nothing else" + ); + } +} From e8968d9873ae3919353a5638ca64f5ab3519d867 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 09:51:43 +0200 Subject: [PATCH 09/14] build(deny): allow the unmaintained proc-macro behind the mls feature 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 --- deny.toml | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/deny.toml b/deny.toml index 7bb830036..e80365dab 100644 --- a/deny.toml +++ b/deny.toml @@ -119,6 +119,25 @@ ignore = [ Drop when uniffi (0.28.x) removes its `paste` dependency. """ }, + { id = "RUSTSEC-2026-0173", reason = """ + proc-macro-error2 2.0.1 is unmaintained — the author has said so and + recommends migrating away. Build-time only, and twice removed from + anything we ship: 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 no version of this we can pick differently. OpenMLS 0.9 has + one production crypto provider and this 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 explicitly declines. + + Drop when hax-lib moves off proc-macro-error2, or when OpenMLS's + provider stack stops reaching libcrux. + """ }, + ] # License policy. Allow-list is derived from the actual licenses present From 07b64dbf0193cfe3c8f9a74df44e7730f819ae87 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 10:04:18 +0200 Subject: [PATCH 10/14] fix(docker): copy the new members into the enclave image, and census 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 --- Cargo.lock | 8 + Cargo.toml | 2 +- Dockerfile.nitro | 2 + room-host/Cargo.toml | 11 + room-host/examples/data_room.rs | 405 +++++++++++++++++ room-host/src/lib.rs | 448 ++++++++++++++++++ room-host/src/main.rs | 450 +------------------ tests/e2e/tests/dockerfile_members_census.rs | 112 +++++ 8 files changed, 991 insertions(+), 447 deletions(-) create mode 100644 room-host/examples/data_room.rs create mode 100644 room-host/src/lib.rs create mode 100644 tests/e2e/tests/dockerfile_members_census.rs diff --git a/Cargo.lock b/Cargo.lock index 329e0de60..85ad0756f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7892,7 +7892,13 @@ version = "0.1.0" dependencies = [ "anyhow", "axum", + "base64 0.23.1", "clap", + "ed25519-dalek 3.0.0", + "getrandom 0.3.4", + "multibase", + "openmls_rust_crypto", + "reqwest", "serde", "serde_json", "tempfile", @@ -7900,6 +7906,8 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "vta-sdk", + "vtc-client", "vti-common", "vti-rooms", ] diff --git a/Cargo.toml b/Cargo.toml index 38a87ba5e..35a6ed924 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,10 @@ [workspace] members = [ - "room-host", "cnm-cli", "didcomm-test", "pnm-cli", + "room-host", "tests/e2e", "vta-cli-common", "vta-config", diff --git a/Dockerfile.nitro b/Dockerfile.nitro index 6b810d087..6b6b8be99 100644 --- a/Dockerfile.nitro +++ b/Dockerfile.nitro @@ -127,6 +127,7 @@ COPY Cargo.toml Cargo.lock ./ COPY cnm-cli/ cnm-cli/ COPY didcomm-test/ didcomm-test/ COPY pnm-cli/ pnm-cli/ +COPY room-host/ room-host/ COPY tests/ tests/ COPY vta-cli-common/ vta-cli-common/ COPY vta-config/ vta-config/ @@ -148,6 +149,7 @@ COPY vta-enclave/ vta-enclave/ COPY vtc-client/ vtc-client/ COPY vtc-service/ vtc-service/ COPY vti-common/ vti-common/ +COPY vti-rooms/ vti-rooms/ COPY vti-secrets/ vti-secrets/ COPY vti-webauthn/ vti-webauthn/ diff --git a/room-host/Cargo.toml b/room-host/Cargo.toml index 7874e0727..faab701e2 100644 --- a/room-host/Cargo.toml +++ b/room-host/Cargo.toml @@ -31,3 +31,14 @@ anyhow = "1" [dev-dependencies] tempfile = "3" tower = { version = "0.5", features = ["util"] } + +# The `data_room` example drives this host with the real client, so it needs the +# client's MLS layer and a throwaway signer. None of this reaches the binary. +vtc-client = { path = "../vtc-client", features = ["mls"] } +vta-sdk = { path = "../vta-sdk" } +openmls_rust_crypto = "0.6" +ed25519-dalek = { workspace = true } +multibase = { workspace = true } +base64 = { workspace = true } +getrandom = "0.3" +reqwest = { workspace = true } diff --git a/room-host/examples/data_room.rs b/room-host/examples/data_room.rs new file mode 100644 index 000000000..12a840563 --- /dev/null +++ b/room-host/examples/data_room.rs @@ -0,0 +1,405 @@ +//! A data room, end to end: a real host, a real client, real MLS. +//! +//! Run it: +//! +//! ```text +//! cargo run -p room-host --example data_room +//! ``` +//! +//! Nothing here is mocked. Act I starts the actual `room-host` binary's router on a real +//! TCP port and drives it with `vtc_client`'s actual room methods over HTTP. Act II builds +//! an actual MLS group and seals records under the key it exports. +//! +//! # What the demo is trying to show +//! +//! Three claims, in the order they build on each other: +//! +//! 1. **A room operation carries no session.** Every call below authorizes from a +//! credential chain the room issued. The host holds no roster and consults none. +//! 2. **An agent holds strictly less than its human.** Alice writes; her agent reads. The +//! two calls are the same code against the same host — the difference is entirely in +//! the chain each carries, which is what makes "give the AI read-only access for four +//! hours" a credential rather than a policy someone has to enforce. +//! 3. **The host holds every byte of a sealed room and cannot read or move one.** Act II +//! prints the ciphertext and then fails to open it four different ways. +//! +//! # What is honestly not joined up yet +//! +//! Act I runs on the `open` tier, and Act II runs locally. The join — sealed records +//! through the host — waits on cryptographic chain verification, which needs +//! `dtg_credentials::authority::verify_chain`. Until that is wired the host refuses sealed +//! rooms outright rather than serving one whose chain nobody checked, and Act III +//! demonstrates that refusal rather than papering over it. + +use std::net::SocketAddr; + +use base64::Engine as _; +use openmls_rust_crypto::OpenMlsRustCrypto; +use vtc_client::VtcClient; +use vtc_client::rooms::mls::{RoomGroup, RoomIdentity}; +use vtc_client::rooms::sealed::SealedRoom; +use vtc_client::rooms::{CleartextContent, RoomSession, Visibility}; + +/// Alice, who owns the room. +const ALICE: &str = "did:key:z6MkAlicePersonKeyForTheDemoOnly"; +/// Bob, a member. +const BOB: &str = "did:key:z6MkBobPersonKeyForTheDemoOnly"; +/// Alice's AI agent — a different DID, holding a different chain. +const AGENT: &str = "did:key:z6MkAliceAgentKeyForTheDemoOnly"; +/// Mallory, who is in no room at all. +const MALLORY: &str = "did:key:z6MkMalloryKeyForTheDemoOnly"; + +/// The room's own identifier. A room is a DTG node and brings its own DID — an identifier +/// the *host* chose could not survive a move to another host. +const ROOM: &str = "did:webvh:example.com:rooms:northwind"; + +fn say(step: &str, detail: &str) { + println!("\n\x1b[1m{step}\x1b[0m\n {detail}"); +} + +fn note(detail: &str) { + println!(" {detail}"); +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + println!("\n\x1b[1;4mA data room, end to end\x1b[0m"); + + let (addr, _dir) = start_host().await?; + let (signer_did, signer_key) = mint_signer(); + + act_one(addr, &signer_did, &signer_key).await?; + act_two()?; + act_three(addr, &signer_did, &signer_key).await?; + + println!("\n\x1b[1mWhere this stands\x1b[0m"); + note("Act I is over HTTP against the real host. Act II is real MLS and real AEAD."); + note("Joining them — sealed records through a host — needs chain verification, which"); + note("needs dtg-credentials 0.6 on crates.io. Until then the host refuses a sealed"); + note("room rather than serving one whose chain nobody checked (Act III).\n"); + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act I — a shared room, over the wire +// --------------------------------------------------------------------------- + +async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct I — a room over the wire\x1b[0m"); + + // `anonymous` is not a limitation being worked around. There is no token to hold: a + // room call is authorized by its chain, and `VtcClient`'s room methods never read one. + let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHostDid"); + + say( + "1. Alice registers the room", + "She brings the room's DID. The host stores it and learns nothing else.", + ); + client + .create_room( + ROOM, + ALICE, + Visibility::Open, + Some(90), + signer_did, + signer_key, + ) + .await?; + note(&format!("room {ROOM} registered, epoch 1")); + + // Alice's chain: one link, straight from the room, conferring read and write. + let alice = RoomSession::new(ROOM, "vmc:alice@northwind", vec!["vac:alice-rw".into()])?; + + say( + "2. Alice writes two memories", + "A shared space is only interesting once something is in it.", + ); + for (key, title, body) in [ + ( + "decision/pricing-2026", + "Pricing holds through Q3", + "Agreed not to reprice before the Northwind renewal closes.", + ), + ( + "decision/vendor-review", + "Vendor review moved to October", + "The security questionnaire is the long pole, not the contract.", + ), + ] { + let put = client + .put_record( + &alice, + key, + None, + Some(CleartextContent { + title: Some(title.into()), + body: body.into(), + ..Default::default() + }), + Some(0), // create-only + signer_did, + signer_key, + ) + .await?; + note(&format!("wrote {} at version {}", put.key, put.version)); + } + + say( + "3. Alice equips her agent", + "A chain one link longer, conferring read alone. Same code, same host, less power.", + ); + let agent = RoomSession::new( + ROOM, + "vmc:alice@northwind", + // Leaf first: the agent's own read-only grant, then the grant Alice attenuated it + // from. The host verifies the chain reaches the room and never widens. + vec!["vac:agent-read-4h".into(), "vac:alice-rw".into()], + )?; + note(&format!( + "Alice's chain is {} link deep; her agent's is {}", + alice.chain_depth(), + agent.chain_depth() + )); + + say( + "4. The agent reads the room as memory", + "It lists what is there, then fetches the one record it needs.", + ); + let listing = client + .list_records(&agent, Some("decision/"), None, signer_did, signer_key) + .await?; + note(&format!( + "{} records match `decision/` — metadata only, no bodies", + listing.records.len() + )); + let record = client + .get_record(&agent, "decision/pricing-2026", signer_did, signer_key) + .await?; + note(&format!( + "read: {}", + record["cleartext"]["title"].as_str().unwrap_or("?") + )); + + say( + "5. Mallory presents nothing and gets nothing", + "Not because the host knows who Mallory is. Because there is no chain.", + ); + // A chain of length zero is refused client-side, so build the request the long way to + // show the *host* refusing it too. + let refused = post_raw( + addr, + "https://trusttasks.org/spec/rooms/records/get/0.1", + serde_json::json!({ + "roomId": ROOM, + "key": "decision/pricing-2026", + "presentation": { "membership": format!("vmc:mallory@nowhere"), "authority": [] } + }), + ) + .await?; + note(&format!("host says: {}", refused.trim())); + let _ = MALLORY; + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act II — what the host would hold, if it held a sealed room +// --------------------------------------------------------------------------- + +fn act_two() -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct II — sealed, and unmovable\x1b[0m"); + + say( + "6. Alice and Bob form the room's MLS group", + "Bob publishes a key package over the invitation channel — never through the host.", + ); + let mut alice_group = RoomGroup::create(ALICE)?; + + let bob_provider = OpenMlsRustCrypto::default(); + let bob_identity = RoomIdentity::new(BOB, &bob_provider)?; + let bob_package = bob_identity.key_package(&bob_provider)?; + + let change = alice_group.add_member(bob_package)?; + let welcome = change.welcome.expect("adding a member produces a welcome"); + let bob_group = RoomGroup::join_with(bob_identity, bob_provider, &welcome)?; + note(&format!( + "group has {} members, at MLS epoch {}", + alice_group.member_count(), + alice_group.epoch() + )); + + let alice_room = SealedRoom::new( + RoomSession::new(ROOM, "vmc:alice@northwind", vec!["vac:alice-rw".into()])?, + alice_group, + ); + let bob_room = SealedRoom::new( + RoomSession::new(ROOM, "vmc:bob@northwind", vec!["vac:bob-r".into()])?, + bob_group, + ); + + say( + "7. Alice seals a record", + "The key comes from the MLS exporter. The host is given the bytes below.", + ); + let key = SealedRoom::opaque_key(); + let plaintext = b"Northwind will not be repriced before renewal. Do not share."; + let sealed = alice_room.seal_record(&key, 1, plaintext)?; + note(&format!( + "record key: {key} (opaque — a descriptive key would leak)" + )); + note(&format!( + "ciphertext: {}… ({} bytes)", + &sealed.ciphertext[..44.min(sealed.ciphertext.len())], + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(&sealed.ciphertext) + .map(|b| b.len()) + .unwrap_or(0) + )); + + say( + "8. Bob opens it; the host cannot", + "Bob derives the same key from the same group. The host has no leaf in it.", + ); + let opened = bob_room.open_record(&key, 1, &sealed)?; + note(&format!("Bob reads: {}", String::from_utf8_lossy(&opened))); + + let mallory_room = SealedRoom::new( + RoomSession::new(ROOM, "vmc:forged", vec!["vac:forged".into()])?, + RoomGroup::create(MALLORY)?, + ); + note(match mallory_room.open_record(&key, 1, &sealed) { + Err(_) => "Mallory, holding a perfectly valid group of her own: refused", + Ok(_) => unreachable!("an outsider must not open a sealed record"), + }); + + say( + "9. The host holds every byte and still cannot move one", + "Each record is bound to roomId | key | version | epoch. Relocation fails loudly.", + ); + for (what, result) in [ + ( + "to another key", + alice_room.open_record("other-key", 1, &sealed), + ), + ( + "to another version", + alice_room.open_record(&key, 2, &sealed), + ), + ("to another room", { + let elsewhere = SealedRoom::new( + RoomSession::new( + "did:webvh:example.com:rooms:other", + "vmc:alice@northwind", + vec!["vac:alice-rw".into()], + )?, + RoomGroup::create(ALICE)?, + ); + elsewhere.open_record(&key, 1, &sealed) + }), + ] { + assert!(result.is_err(), "moving a record {what} must fail"); + note(&format!("moved {what}: does not open")); + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act III — the seam, shown rather than hidden +// --------------------------------------------------------------------------- + +async fn act_three(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct III — the seam\x1b[0m"); + + let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHostDid"); + let private = "did:webvh:example.com:rooms:private"; + + say( + "10. Registering a private room succeeds", + "The host will store it. Storing and serving are different questions.", + ); + client + .create_room( + private, + ALICE, + Visibility::Private, + None, + signer_did, + signer_key, + ) + .await?; + note("registered"); + + say( + "11. Reading it is refused, and the refusal says why", + "Better to serve no sealed room than one whose authority chain nobody verified.", + ); + let session = RoomSession::new(private, "vmc:alice@northwind", vec!["vac:alice-rw".into()])? + .with_subject_binding("bbs-proof-that-both-describe-alice"); + match client + .get_record(&session, "anything", signer_did, signer_key) + .await + { + Err(e) => note(&format!("host says: {e}")), + Ok(_) => unreachable!("a sealed room must not be served on an unverified chain"), + } + + Ok(()) +} + +// --------------------------------------------------------------------------- +// Harness +// --------------------------------------------------------------------------- + +/// Start the real host on an ephemeral port, over a temporary store. +/// +/// The returned `TempDir` must outlive the demo — dropping it deletes the room. +async fn start_host() -> anyhow::Result<(SocketAddr, tempfile::TempDir)> { + let dir = tempfile::tempdir()?; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + let addr = listener.local_addr()?; + + let app = room_host::router(room_host::open_state(dir.path())?); + tokio::spawn(async move { + let _ = axum::serve(listener, app).await; + }); + + println!( + "\n host listening on {addr}, storing to {}", + dir.path().display() + ); + Ok((addr, dir)) +} + +/// A `did:key` to sign the Trust-Task documents with. +/// +/// Signing the document and authorizing the operation are different things — the signature +/// says who sent this request, the chain says what they may do. The demo keeps them +/// visibly separate by using one throwaway signer for every call while the chains differ. +fn mint_signer() -> (String, String) { + use ed25519_dalek::SigningKey; + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("OS randomness"); + let signing = SigningKey::from_bytes(&seed); + let did = format!( + "did:key:{}", + vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes()) + ); + let secret = multibase::encode(multibase::Base::Base58Btc, seed); + (did, secret) +} + +/// Post a Trust-Task document the client would refuse to build. +async fn post_raw( + addr: SocketAddr, + type_uri: &str, + payload: serde_json::Value, +) -> anyhow::Result { + let body = serde_json::json!({ "type": type_uri, "payload": payload }); + let resp = reqwest::Client::new() + .post(format!("http://{addr}/trust-tasks")) + .json(&body) + .send() + .await?; + Ok(resp.text().await?) +} diff --git a/room-host/src/lib.rs b/room-host/src/lib.rs new file mode 100644 index 000000000..b18a51396 --- /dev/null +++ b/room-host/src/lib.rs @@ -0,0 +1,448 @@ +//! A room host: it stores data-room records and serves them back. +//! +//! # What this is, and what it deliberately is not +//! +//! A room host is a **delivery service**. It holds records — ciphertext, on any tier but +//! `open` — and answers `rooms/*` Trust Tasks against them. It is not a community: it has no +//! member roster, no policy engine, no credential issuance, no admin surface, and no opinion +//! about who belongs to any room it stores. +//! +//! That is not minimalism for its own sake. **A room is authorized by credentials the room +//! itself issued**, so a host that kept its own record of who belongs would become part of +//! that room's membership, and the room could no longer move to a different host without +//! reissuing credentials. The absence of a roster here is the portability guarantee, made +//! structural: there is nothing in this binary that could consult one. +//! +//! # Why it exists as its own binary +//! +//! Topology T1 of the data-rooms design is a person hosting their own rooms on +//! infrastructure they control. Before `vti-rooms` was extracted, doing 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 that. +//! +//! 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 +//! makes a room host an ordinary provisioned integration with its own DID (the `room-host` +//! DID template) rather than a new surface on the agent. +//! +//! # Status +//! +//! The dispatch surface here is the `open` tier. Sealed tiers are refused by +//! [`vti_rooms::authz`] until chain verification is wired, and that refusal lives in the +//! shared crate rather than here — so this host and a VTC cannot disagree about what is +//! safe to serve. + +use std::sync::Arc; + +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::{Json, Router}; +use serde_json::{Value, json}; +use vti_common::config::StoreConfig; +use vti_common::store::{KeyspaceHandle, Store}; +use vti_rooms::wire::{ + CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, + MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, ROOMS_CREATE_TYPE, + ROOMS_EPOCH_MINT_TYPE, ROOMS_RECORDS_GET_TYPE, ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, +}; +use vti_rooms::{ + ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, + authz::{self, Action}, + storage, +}; + +/// Default retention after a room's epoch lapses without renewal. +const DEFAULT_RETENTION_DAYS: u32 = 90; + +/// Everything this host holds. Two keyspaces — and note what is not here. +#[derive(Clone)] +pub struct HostState { + rooms: KeyspaceHandle, + records: KeyspaceHandle, +} + +fn now() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +/// A Trust-Task error response. +/// +/// Every authorization failure comes back the same way, with the reason in the body: an +/// operator reading logs can tell a missing chain from an over-deep one, while a caller +/// learns only that it was refused. +fn reject(status: StatusCode, reason: impl std::fmt::Display) -> axum::response::Response { + (status, Json(json!({ "error": reason.to_string() }))).into_response() +} + +/// The one entry point: a `rooms/*` document, routed by its own `type`. +/// +/// One mount rather than five routes, because the document's `type` is its identity — the +/// same shape the VTC's holder-facing surface uses. +async fn trust_task(State(state): State>, body: Json) -> impl IntoResponse { + let doc = body.0; + let type_uri = doc.get("type").and_then(Value::as_str).unwrap_or_default(); + let payload = doc.get("payload").cloned().unwrap_or(Value::Null); + + match type_uri { + ROOMS_CREATE_TYPE => create(&state, payload).await, + ROOMS_RECORDS_PUT_TYPE => put(&state, payload).await, + ROOMS_RECORDS_GET_TYPE => get(&state, payload).await, + ROOMS_RECORDS_LIST_TYPE => list(&state, payload).await, + ROOMS_EPOCH_MINT_TYPE => mint(&state, payload).await, + other => reject( + StatusCode::NOT_FOUND, + format!("this host does not serve `{other}`"), + ), + } +} + +async fn create(state: &HostState, payload: Value) -> axum::response::Response { + let req: CreateRoomBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = Room { + room_id: req.room_id.clone(), + owner_did: req.owner_did, + visibility: req.visibility, + epoch: 1, + next_version: 1, + retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), + created_at: now(), + updated_at: now(), + }; + match storage::create_room(&state.rooms, &room).await { + Ok(()) => Json(CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +async fn put(state: &HostState, payload: Value) -> axum::response::Response { + let req: PutRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { + return reject(StatusCode::FORBIDDEN, e); + } + + let record = Record { + key: req.key.clone(), + version: 0, + epoch: req.sealed.as_ref().map(|s| s.epoch), + status: RecordStatus::Active, + sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), + nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), + cleartext: req + .cleartext + .as_ref() + .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), + // Only where the tier discloses an actor. On a private room authorship lives inside + // the sealed body, and the storage layer refuses it here. + author: room + .visibility + .discloses_actor() + .then(|| room.owner_did.clone()), + updated_at: 0, + }; + + match storage::put_record( + &state.rooms, + &state.records, + &req.room_id, + record, + req.expected_version, + now(), + ) + .await + { + Ok(stored) => Json(PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +async fn get(state: &HostState, payload: Value) -> axum::response::Response { + let req: GetRecordBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::get_record(&state.records, &req.room_id, &req.key).await { + Ok(record) => Json(record).into_response(), + Err(e) => reject(StatusCode::NOT_FOUND, e), + } +} + +async fn list(state: &HostState, payload: Value) -> axum::response::Response { + let req: ListRecordsBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::list_records( + &state.records, + &req.room_id, + req.prefix.as_deref(), + req.since_version, + ) + .await + { + Ok(records) => { + let limit = req.limit.unwrap_or(usize::MAX); + // Metadata, never bodies — the same rule the VTC serves under, because it is a + // property of the task rather than of any one host. + Json(ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }) + .into_response() + } + Err(e) => reject(StatusCode::INTERNAL_SERVER_ERROR, e), + } +} + +async fn mint(state: &HostState, payload: Value) -> axum::response::Response { + let req: MintEpochBody = match serde_json::from_value(payload) { + Ok(r) => r, + Err(e) => return reject(StatusCode::BAD_REQUEST, e), + }; + let room = match storage::get_room(&state.rooms, &req.room_id).await { + Ok(r) => r, + Err(e) => return reject(StatusCode::NOT_FOUND, e), + }; + // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict + // any other by declining to seal them the new key — and this host, which cannot see the + // membership, would have no way to notice. + if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { + return reject(StatusCode::FORBIDDEN, e); + } + match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { + Ok(updated) => Json(MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }) + .into_response(), + Err(e) => reject(StatusCode::CONFLICT, e), + } +} + +/// Build the router. Separated from `main` so tests can drive it without a socket. +pub fn router(state: Arc) -> Router { + Router::new() + .route("/trust-tasks", post(trust_task)) + .route("/health", axum::routing::get(|| async { "ok" })) + .with_state(state) +} + +pub fn open_state(data_dir: &std::path::Path) -> anyhow::Result> { + let store = Store::open(&StoreConfig { + data_dir: data_dir.to_path_buf(), + })?; + Ok(Arc::new(HostState { + rooms: store.keyspace(ROOMS_KEYSPACE)?, + records: store.keyspace(ROOM_RECORDS_KEYSPACE)?, + })) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::Request; + use tower::ServiceExt; + + fn state() -> (tempfile::TempDir, Arc) { + let dir = tempfile::tempdir().unwrap(); + let state = open_state(dir.path()).expect("open store"); + (dir, state) + } + + async fn call(app: &Router, type_uri: &str, payload: Value) -> (StatusCode, Value) { + let doc = json!({ "type": type_uri, "payload": payload }); + let resp = app + .clone() + .oneshot( + Request::post("/trust-tasks") + .header("content-type", "application/json") + .body(Body::from(doc.to_string())) + .unwrap(), + ) + .await + .unwrap(); + let status = resp.status(); + let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) + .await + .unwrap(); + ( + status, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) + } + + fn presentation() -> Value { + json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) + } + + #[tokio::test] + async fn a_room_is_created_and_a_record_round_trips() { + let (_d, st) = state(); + let app = router(st); + + let (status, _) = call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let (status, body) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "k1", "presentation": presentation(), + "cleartext": { "body": "a decision" } }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["version"], 1); + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["cleartext"]["body"], "a decision"); + } + + /// The property that makes this host usable by a room it does not govern: it decides + /// from the chain, and there is nothing else it could decide from. + #[tokio::test] + async fn an_operation_with_no_chain_is_refused() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + + let (status, _) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "k", "presentation": { "membership": "vmc", "authority": [] }, + "cleartext": { "body": "x" } }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// The shared crate decides this, not the host — so a room host and a VTC cannot + /// disagree about what is safe to serve. + #[tokio::test] + async fn sealed_tiers_are_refused_here_exactly_as_they_are_on_a_vtc() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "p1", "visibility": "private", "ownerDid": "did:key:zOwner" }), + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert!( + body["error"] + .as_str() + .unwrap_or_default() + .contains("subject binding"), + "{body}" + ); + } + + #[tokio::test] + async fn a_listing_returns_metadata_and_never_bodies() { + let (_d, st) = state(); + let app = router(st); + call( + &app, + ROOMS_CREATE_TYPE, + json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ) + .await; + call( + &app, + ROOMS_RECORDS_PUT_TYPE, + json!({ "roomId": "r1", "key": "a", "presentation": presentation(), + "cleartext": { "body": "secret-body-text" } }), + ) + .await; + + let (status, body) = call( + &app, + ROOMS_RECORDS_LIST_TYPE, + json!({ "roomId": "r1", "presentation": presentation() }), + ) + .await; + assert_eq!(status, StatusCode::OK); + let text = body.to_string(); + assert!(text.contains("\"key\"")); + assert!( + !text.contains("secret-body-text"), + "a listing must never carry bodies: {text}" + ); + } + + #[tokio::test] + async fn an_unknown_task_is_not_served() { + let (_d, st) = state(); + let (status, _) = call( + &router(st), + "https://trusttasks.org/spec/vtc/members/list/0.1", + json!({}), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "a room host serves rooms and nothing else" + ); + } +} diff --git a/room-host/src/main.rs b/room-host/src/main.rs index 4e247f25f..c325707e9 100644 --- a/room-host/src/main.rs +++ b/room-host/src/main.rs @@ -1,61 +1,10 @@ -//! A room host: it stores data-room records and serves them back. +//! The `room-host` binary: parse the flags, open the store, serve. //! -//! # What this is, and what it deliberately is not -//! -//! A room host is a **delivery service**. It holds records — ciphertext, on any tier but -//! `open` — and answers `rooms/*` Trust Tasks against them. It is not a community: it has no -//! member roster, no policy engine, no credential issuance, no admin surface, and no opinion -//! about who belongs to any room it stores. -//! -//! That is not minimalism for its own sake. **A room is authorized by credentials the room -//! itself issued**, so a host that kept its own record of who belongs would become part of -//! that room's membership, and the room could no longer move to a different host without -//! reissuing credentials. The absence of a roster here is the portability guarantee, made -//! structural: there is nothing in this binary that could consult one. -//! -//! # Why it exists as its own binary -//! -//! Topology T1 of the data-rooms design is a person hosting their own rooms on -//! infrastructure they control. Before `vti-rooms` was extracted, doing 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 that. -//! -//! 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 -//! makes a room host an ordinary provisioned integration with its own DID (the `room-host` -//! DID template) rather than a new surface on the agent. -//! -//! # Status -//! -//! The dispatch surface here is the `open` tier. Sealed tiers are refused by -//! [`vti_rooms::authz`] until chain verification is wired, and that refusal lives in the -//! shared crate rather than here — so this host and a VTC cannot disagree about what is -//! safe to serve. - -use std::sync::Arc; +//! Everything else is in the library beside this, so the same router can be driven from a +//! test or the `data_room` example without a socket. -use axum::extract::State; -use axum::http::StatusCode; -use axum::response::IntoResponse; -use axum::routing::post; -use axum::{Json, Router}; use clap::Parser; -use serde_json::{Value, json}; -use vti_common::config::StoreConfig; -use vti_common::store::{KeyspaceHandle, Store}; -use vti_rooms::wire::{ - CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, - MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, ROOMS_CREATE_TYPE, - ROOMS_EPOCH_MINT_TYPE, ROOMS_RECORDS_GET_TYPE, ROOMS_RECORDS_LIST_TYPE, ROOMS_RECORDS_PUT_TYPE, -}; -use vti_rooms::{ - ROOM_RECORDS_KEYSPACE, ROOMS_KEYSPACE, Record, RecordStatus, Room, - authz::{self, Action}, - storage, -}; - -/// Default retention after a room's epoch lapses without renewal. -const DEFAULT_RETENTION_DAYS: u32 = 90; +use room_host::{open_state, router}; #[derive(Parser, Debug)] #[command(name = "room-host", about = "Store and serve data-room records")] @@ -68,223 +17,6 @@ struct Args { listen: String, } -/// Everything this host holds. Two keyspaces — and note what is not here. -#[derive(Clone)] -struct HostState { - rooms: KeyspaceHandle, - records: KeyspaceHandle, -} - -fn now() -> u64 { - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0) -} - -/// A Trust-Task error response. -/// -/// Every authorization failure comes back the same way, with the reason in the body: an -/// operator reading logs can tell a missing chain from an over-deep one, while a caller -/// learns only that it was refused. -fn reject(status: StatusCode, reason: impl std::fmt::Display) -> axum::response::Response { - (status, Json(json!({ "error": reason.to_string() }))).into_response() -} - -/// The one entry point: a `rooms/*` document, routed by its own `type`. -/// -/// One mount rather than five routes, because the document's `type` is its identity — the -/// same shape the VTC's holder-facing surface uses. -async fn trust_task(State(state): State>, body: Json) -> impl IntoResponse { - let doc = body.0; - let type_uri = doc.get("type").and_then(Value::as_str).unwrap_or_default(); - let payload = doc.get("payload").cloned().unwrap_or(Value::Null); - - match type_uri { - ROOMS_CREATE_TYPE => create(&state, payload).await, - ROOMS_RECORDS_PUT_TYPE => put(&state, payload).await, - ROOMS_RECORDS_GET_TYPE => get(&state, payload).await, - ROOMS_RECORDS_LIST_TYPE => list(&state, payload).await, - ROOMS_EPOCH_MINT_TYPE => mint(&state, payload).await, - other => reject( - StatusCode::NOT_FOUND, - format!("this host does not serve `{other}`"), - ), - } -} - -async fn create(state: &HostState, payload: Value) -> axum::response::Response { - let req: CreateRoomBody = match serde_json::from_value(payload) { - Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), - }; - let room = Room { - room_id: req.room_id.clone(), - owner_did: req.owner_did, - visibility: req.visibility, - epoch: 1, - next_version: 1, - retention_days: req.retention_days.unwrap_or(DEFAULT_RETENTION_DAYS), - created_at: now(), - updated_at: now(), - }; - match storage::create_room(&state.rooms, &room).await { - Ok(()) => Json(CreateRoomResponse { - room_id: req.room_id, - epoch: 1, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), - } -} - -async fn put(state: &HostState, payload: Value) -> axum::response::Response { - let req: PutRecordBody = match serde_json::from_value(payload) { - Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), - }; - let room = match storage::get_room(&state.rooms, &req.room_id).await { - Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), - }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { - return reject(StatusCode::FORBIDDEN, e); - } - - let record = Record { - key: req.key.clone(), - version: 0, - epoch: req.sealed.as_ref().map(|s| s.epoch), - status: RecordStatus::Active, - sealed: req.sealed.as_ref().map(|s| s.ciphertext.clone()), - nonce: req.sealed.as_ref().map(|s| s.nonce.clone()), - cleartext: req - .cleartext - .as_ref() - .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), - // Only where the tier discloses an actor. On a private room authorship lives inside - // the sealed body, and the storage layer refuses it here. - author: room - .visibility - .discloses_actor() - .then(|| room.owner_did.clone()), - updated_at: 0, - }; - - match storage::put_record( - &state.rooms, - &state.records, - &req.room_id, - record, - req.expected_version, - now(), - ) - .await - { - Ok(stored) => Json(PutRecordResponse { - key: stored.key, - version: stored.version, - epoch: stored.epoch, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), - } -} - -async fn get(state: &HostState, payload: Value) -> axum::response::Response { - let req: GetRecordBody = match serde_json::from_value(payload) { - Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), - }; - let room = match storage::get_room(&state.rooms, &req.room_id).await { - Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), - }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { - return reject(StatusCode::FORBIDDEN, e); - } - match storage::get_record(&state.records, &req.room_id, &req.key).await { - Ok(record) => Json(record).into_response(), - Err(e) => reject(StatusCode::NOT_FOUND, e), - } -} - -async fn list(state: &HostState, payload: Value) -> axum::response::Response { - let req: ListRecordsBody = match serde_json::from_value(payload) { - Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), - }; - let room = match storage::get_room(&state.rooms, &req.room_id).await { - Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), - }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { - return reject(StatusCode::FORBIDDEN, e); - } - match storage::list_records( - &state.records, - &req.room_id, - req.prefix.as_deref(), - req.since_version, - ) - .await - { - Ok(records) => { - let limit = req.limit.unwrap_or(usize::MAX); - // Metadata, never bodies — the same rule the VTC serves under, because it is a - // property of the task rather than of any one host. - Json(ListRecordsResponse { - records: records.iter().take(limit).map(|r| r.metadata()).collect(), - }) - .into_response() - } - Err(e) => reject(StatusCode::INTERNAL_SERVER_ERROR, e), - } -} - -async fn mint(state: &HostState, payload: Value) -> axum::response::Response { - let req: MintEpochBody = match serde_json::from_value(payload) { - Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), - }; - let room = match storage::get_room(&state.rooms, &req.room_id).await { - Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), - }; - // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict - // any other by declining to seal them the new key — and this host, which cannot see the - // membership, would have no way to notice. - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { - return reject(StatusCode::FORBIDDEN, e); - } - match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { - Ok(updated) => Json(MintEpochResponse { - room_id: updated.room_id, - epoch: updated.epoch, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), - } -} - -/// Build the router. Separated from `main` so tests can drive it without a socket. -fn router(state: Arc) -> Router { - Router::new() - .route("/trust-tasks", post(trust_task)) - .route("/health", axum::routing::get(|| async { "ok" })) - .with_state(state) -} - -fn open_state(data_dir: &std::path::Path) -> anyhow::Result> { - let store = Store::open(&StoreConfig { - data_dir: data_dir.to_path_buf(), - })?; - Ok(Arc::new(HostState { - rooms: store.keyspace(ROOMS_KEYSPACE)?, - records: store.keyspace(ROOM_RECORDS_KEYSPACE)?, - })) -} - #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -306,177 +38,3 @@ async fn main() -> anyhow::Result<()> { axum::serve(listener, router(state)).await?; Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use axum::body::Body; - use axum::http::Request; - use tower::ServiceExt; - - fn state() -> (tempfile::TempDir, Arc) { - let dir = tempfile::tempdir().unwrap(); - let state = open_state(dir.path()).expect("open store"); - (dir, state) - } - - async fn call(app: &Router, type_uri: &str, payload: Value) -> (StatusCode, Value) { - let doc = json!({ "type": type_uri, "payload": payload }); - let resp = app - .clone() - .oneshot( - Request::post("/trust-tasks") - .header("content-type", "application/json") - .body(Body::from(doc.to_string())) - .unwrap(), - ) - .await - .unwrap(); - let status = resp.status(); - let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) - .await - .unwrap(); - ( - status, - serde_json::from_slice(&bytes).unwrap_or(Value::Null), - ) - } - - fn presentation() -> Value { - json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) - } - - #[tokio::test] - async fn a_room_is_created_and_a_record_round_trips() { - let (_d, st) = state(); - let app = router(st); - - let (status, _) = call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), - ) - .await; - assert_eq!(status, StatusCode::OK); - - let (status, body) = call( - &app, - ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "k1", "presentation": presentation(), - "cleartext": { "body": "a decision" } }), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["version"], 1); - - let (status, body) = call( - &app, - ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), - ) - .await; - assert_eq!(status, StatusCode::OK); - assert_eq!(body["cleartext"]["body"], "a decision"); - } - - /// The property that makes this host usable by a room it does not govern: it decides - /// from the chain, and there is nothing else it could decide from. - #[tokio::test] - async fn an_operation_with_no_chain_is_refused() { - let (_d, st) = state(); - let app = router(st); - call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), - ) - .await; - - let (status, _) = call( - &app, - ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "k", "presentation": { "membership": "vmc", "authority": [] }, - "cleartext": { "body": "x" } }), - ) - .await; - assert_eq!(status, StatusCode::FORBIDDEN); - } - - /// The shared crate decides this, not the host — so a room host and a VTC cannot - /// disagree about what is safe to serve. - #[tokio::test] - async fn sealed_tiers_are_refused_here_exactly_as_they_are_on_a_vtc() { - let (_d, st) = state(); - let app = router(st); - call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "p1", "visibility": "private", "ownerDid": "did:key:zOwner" }), - ) - .await; - - let (status, body) = call( - &app, - ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), - ) - .await; - assert_eq!(status, StatusCode::FORBIDDEN); - assert!( - body["error"] - .as_str() - .unwrap_or_default() - .contains("subject binding"), - "{body}" - ); - } - - #[tokio::test] - async fn a_listing_returns_metadata_and_never_bodies() { - let (_d, st) = state(); - let app = router(st); - call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), - ) - .await; - call( - &app, - ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "a", "presentation": presentation(), - "cleartext": { "body": "secret-body-text" } }), - ) - .await; - - let (status, body) = call( - &app, - ROOMS_RECORDS_LIST_TYPE, - json!({ "roomId": "r1", "presentation": presentation() }), - ) - .await; - assert_eq!(status, StatusCode::OK); - let text = body.to_string(); - assert!(text.contains("\"key\"")); - assert!( - !text.contains("secret-body-text"), - "a listing must never carry bodies: {text}" - ); - } - - #[tokio::test] - async fn an_unknown_task_is_not_served() { - let (_d, st) = state(); - let (status, _) = call( - &router(st), - "https://trusttasks.org/spec/vtc/members/list/0.1", - json!({}), - ) - .await; - assert_eq!( - status, - StatusCode::NOT_FOUND, - "a room host serves rooms and nothing else" - ); - } -} diff --git a/tests/e2e/tests/dockerfile_members_census.rs b/tests/e2e/tests/dockerfile_members_census.rs new file mode 100644 index 000000000..b4f4c9447 --- /dev/null +++ b/tests/e2e/tests/dockerfile_members_census.rs @@ -0,0 +1,112 @@ +//! Every workspace member must be copied into the enclave image. +//! +//! `Dockerfile.nitro` copies a hand-maintained list of member directories rather than +//! `COPY . .`, so that an edit to `deploy/nitro/config.toml` does not invalidate the Rust +//! build layer. The cost of that trade is this list, and a member missing from it is not a +//! missing file — cargo-chef's skeleton stub stays in place, and the build fails six +//! minutes later with +//! +//! ```text +//! 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)` +//! ``` +//! +//! which says nothing about the actual cause. That is how the `vti-rooms` and `room-host` +//! members broke the nitro image: both were added to `members`, neither to the COPY list, +//! and every other check in CI stayed green. +//! +//! This turns that into a one-second failure that names the line to add. It is a census in +//! the same sense 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. +//! +//! `Dockerfile` (the ordinary image) uses `COPY . .` and needs no equivalent. + +use std::collections::BTreeSet; +use std::path::{Path, PathBuf}; + +/// The workspace root, from this crate's manifest directory (`tests/e2e`). +fn workspace_root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .ancestors() + .nth(2) + .expect("tests/e2e is two levels below the workspace root") + .to_path_buf() +} + +/// Workspace members, reduced to the top-level directory the Dockerfile would copy. +/// +/// `tests/e2e` is copied as `tests/`, so a member path is truncated at its first +/// component — which is what the COPY list actually names. +fn members() -> BTreeSet { + let manifest = std::fs::read_to_string(workspace_root().join("Cargo.toml")) + .expect("read the workspace manifest"); + let list = manifest + .split_once("members = [") + .expect("the workspace manifest declares members") + .1 + .split_once(']') + .expect("the members array is closed") + .0; + + list.lines() + .filter_map(|line| { + let line = line.trim().trim_end_matches(','); + let inner = line.strip_prefix('"')?.strip_suffix('"')?; + inner.split('/').next().map(str::to_string) + }) + .collect() +} + +/// Directories `Dockerfile.nitro` copies, from its `COPY / /` lines. +fn copied() -> BTreeSet { + let dockerfile = std::fs::read_to_string(workspace_root().join("Dockerfile.nitro")) + .expect("read Dockerfile.nitro"); + + dockerfile + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("COPY ")?; + // `COPY --from=…` is a stage copy, not a source directory. + if rest.starts_with("--") { + return None; + } + let first = rest.split_whitespace().next()?; + first.strip_suffix('/').map(str::to_string) + }) + .collect() +} + +#[test] +fn every_workspace_member_is_copied_into_the_enclave_image() { + let missing: Vec<_> = members().difference(&copied()).cloned().collect(); + + assert!( + missing.is_empty(), + "these workspace members are not copied into the nitro image, so cargo-chef's \ + 0.0.1 skeleton stub survives into the real build and `cargo build -p vta-enclave` \ + fails to resolve their path dependencies:\n\n{}\n\nAdd a line for each to \ + Dockerfile.nitro's source-copy block:\n\n{}\n", + missing.join(", "), + missing + .iter() + .map(|m| format!("COPY {m}/ {m}/")) + .collect::>() + .join("\n"), + ); +} + +/// The reverse drift: a directory copied after the member that needed it is gone. Harmless +/// to the build, but it invalidates the layer for a path nothing reads, which is the exact +/// cost the hand-maintained list exists to avoid paying. +#[test] +fn nothing_is_copied_that_is_no_longer_a_member() { + let stale: Vec<_> = copied().difference(&members()).cloned().collect(); + + assert!( + stale.is_empty(), + "Dockerfile.nitro copies directories that are not workspace members any more: {}. \ + Remove those COPY lines — they invalidate the build layer for sources nothing \ + compiles.", + stale.join(", "), + ); +} From 6a4d38b859026742188c44cef9c8814ff1d5b725 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 10:12:44 +0200 Subject: [PATCH 11/14] feat(room-host): speak Trust Task documents, and a demo that proves it 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, 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 --- Cargo.lock | 3 + room-host/Cargo.toml | 7 + room-host/examples/data_room.rs | 39 +++-- room-host/src/lib.rs | 290 ++++++++++++++++++++++++-------- 4 files changed, 256 insertions(+), 83 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 85ad0756f..266b44d69 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7906,6 +7906,9 @@ dependencies = [ "tower", "tracing", "tracing-subscriber", + "trust-tasks-https", + "trust-tasks-rs", + "uuid", "vta-sdk", "vtc-client", "vti-common", diff --git a/room-host/Cargo.toml b/room-host/Cargo.toml index faab701e2..9c2df28be 100644 --- a/room-host/Cargo.toml +++ b/room-host/Cargo.toml @@ -28,6 +28,12 @@ tracing-subscriber = { workspace = true } clap = { workspace = true } anyhow = "1" +# A room host and a VTC serve the same protocol, so they must speak the same +# wire form: requests and responses are Trust Task documents, not bare JSON. +trust-tasks-rs = { workspace = true } +trust-tasks-https = { workspace = true } +uuid = { workspace = true } + [dev-dependencies] tempfile = "3" tower = { version = "0.5", features = ["util"] } @@ -42,3 +48,4 @@ multibase = { workspace = true } base64 = { workspace = true } getrandom = "0.3" reqwest = { workspace = true } +uuid = { workspace = true } diff --git a/room-host/examples/data_room.rs b/room-host/examples/data_room.rs index 12a840563..4a225f473 100644 --- a/room-host/examples/data_room.rs +++ b/room-host/examples/data_room.rs @@ -156,7 +156,7 @@ async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow vec!["vac:agent-read-4h".into(), "vac:alice-rw".into()], )?; note(&format!( - "Alice's chain is {} link deep; her agent's is {}", + "Alice's chain is {} link deep; {AGENT}'s is {}", alice.chain_depth(), agent.chain_depth() )); @@ -184,20 +184,19 @@ async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow "5. Mallory presents nothing and gets nothing", "Not because the host knows who Mallory is. Because there is no chain.", ); - // A chain of length zero is refused client-side, so build the request the long way to - // show the *host* refusing it too. + // `RoomSession` refuses an empty chain before it reaches the wire, so send the document + // by hand — the point is that the *host* refuses it too, on its own reading. let refused = post_raw( addr, "https://trusttasks.org/spec/rooms/records/get/0.1", serde_json::json!({ "roomId": ROOM, "key": "decision/pricing-2026", - "presentation": { "membership": format!("vmc:mallory@nowhere"), "authority": [] } + "presentation": { "membership": "vmc:mallory@nowhere", "authority": [] } }), ) .await?; - note(&format!("host says: {}", refused.trim())); - let _ = MALLORY; + note(&format!("host says: {refused}")); Ok(()) } @@ -340,7 +339,7 @@ async fn act_three(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyh .get_record(&session, "anything", signer_did, signer_key) .await { - Err(e) => note(&format!("host says: {e}")), + Err(e) => note(&format!("host says: {}", host_message(&e.to_string()))), Ok(_) => unreachable!("a sealed room must not be served on an unverified chain"), } @@ -389,17 +388,37 @@ fn mint_signer() -> (String, String) { (did, secret) } -/// Post a Trust-Task document the client would refuse to build. +/// Post a Trust-Task document the client would refuse to build, and return what the host +/// said about it. async fn post_raw( addr: SocketAddr, type_uri: &str, payload: serde_json::Value, ) -> anyhow::Result { - let body = serde_json::json!({ "type": type_uri, "payload": payload }); + let body = serde_json::json!({ + "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), + "type": type_uri, + "issuer": MALLORY, + "recipient": "did:key:zHostDid", + "payload": payload, + }); let resp = reqwest::Client::new() .post(format!("http://{addr}/trust-tasks")) .json(&body) .send() .await?; - Ok(resp.text().await?) + Ok(host_message(&resp.text().await?)) +} + +/// Pull the human-readable message out of a `trust-task-error` document. +/// +/// The whole document is the right thing on the wire and the wrong thing in a demo. +fn host_message(body: &str) -> String { + let start = match body.find("\"message\":\"") { + Some(i) => i + 11, + None => return body.trim().to_string(), + }; + let rest = &body[start..]; + let end = rest.find("\",").unwrap_or(rest.len()); + rest[..end].to_string() } diff --git a/room-host/src/lib.rs b/room-host/src/lib.rs index b18a51396..1e605f911 100644 --- a/room-host/src/lib.rs +++ b/room-host/src/lib.rs @@ -34,13 +34,18 @@ use std::sync::Arc; +use axum::body::Bytes; use axum::extract::State; use axum::http::StatusCode; use axum::response::IntoResponse; use axum::routing::post; use axum::{Json, Router}; use serde_json::{Value, json}; +use trust_tasks_https::status_for_code; +use trust_tasks_rs::{RejectReason, TrustTask}; +use uuid::Uuid; use vti_common::config::StoreConfig; +use vti_common::error::AppError; use vti_common::store::{KeyspaceHandle, Store}; use vti_rooms::wire::{ CreateRoomBody, CreateRoomResponse, GetRecordBody, ListRecordsBody, ListRecordsResponse, @@ -70,41 +75,110 @@ fn now() -> u64 { .unwrap_or(0) } -/// A Trust-Task error response. +/// Refuse a request, as a routed `trust-task-error` document. /// -/// Every authorization failure comes back the same way, with the reason in the body: an -/// operator reading logs can tell a missing chain from an over-deep one, while a caller -/// learns only that it was refused. -fn reject(status: StatusCode, reason: impl std::fmt::Display) -> axum::response::Response { - (status, Json(json!({ "error": reason.to_string() }))).into_response() +/// A room host and a VTC serve the same protocol, so they must refuse it the same way: a +/// bare `{"error": …}` is not a Trust Task document, and a client that parses one host's +/// reply cannot parse the other's. The `data_room` example found exactly that — every call +/// in it failed on `missing field \`id\`` before this existed. +/// +/// The reason text distinguishes the cases for an operator reading logs; the framework code +/// is what a caller switches on. +fn reject(doc: &TrustTask, reason: RejectReason) -> axum::response::Response { + let routed = doc.reject_with(format!("urn:uuid:{}", Uuid::new_v4()), reason); + ( + StatusCode::from_u16(status_for_code(&routed.payload.code)) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(serde_json::to_value(&routed).unwrap_or(Value::Null)), + ) + .into_response() +} + +/// Answer a request, as a routed `#response` document. +fn respond(doc: &TrustTask, payload: R) -> axum::response::Response { + let response = doc.respond_with(format!("urn:uuid:{}", Uuid::new_v4()), payload); + Json(serde_json::to_value(&response).unwrap_or(Value::Null)).into_response() +} + +/// An `AppError` from the storage or authorization layer, as a rejection. +/// +/// One mapping, so this host and the VTC classify the same failure identically. Both +/// services reach these from shared code in `vti-rooms`; disagreeing here would mean the +/// same refusal read as a different kind of problem depending on who was hosting. +fn from_app_error(doc: &TrustTask, e: &AppError) -> axum::response::Response { + let reason = e.to_string(); + reject( + doc, + match e { + AppError::Forbidden(_) => RejectReason::PermissionDenied { reason }, + AppError::Validation(_) => RejectReason::MalformedRequest { reason }, + // `TaskFailed` for both, following the VTC: a room that does not exist and a + // version precondition that lost a race are caller-visible outcomes, not server + // faults, and `InternalError` would tell the caller to retry. + AppError::NotFound(_) | AppError::Conflict(_) => RejectReason::TaskFailed { + reason, + details: None, + }, + _ => RejectReason::InternalError { reason }, + }, + ) } /// The one entry point: a `rooms/*` document, routed by its own `type`. /// /// One mount rather than five routes, because the document's `type` is its identity — the /// same shape the VTC's holder-facing surface uses. -async fn trust_task(State(state): State>, body: Json) -> impl IntoResponse { - let doc = body.0; - let type_uri = doc.get("type").and_then(Value::as_str).unwrap_or_default(); - let payload = doc.get("payload").cloned().unwrap_or(Value::Null); - - match type_uri { - ROOMS_CREATE_TYPE => create(&state, payload).await, - ROOMS_RECORDS_PUT_TYPE => put(&state, payload).await, - ROOMS_RECORDS_GET_TYPE => get(&state, payload).await, - ROOMS_RECORDS_LIST_TYPE => list(&state, payload).await, - ROOMS_EPOCH_MINT_TYPE => mint(&state, payload).await, +async fn trust_task(State(state): State>, body: Bytes) -> axum::response::Response { + // A body that is not a Trust Task document cannot be *routed* — there is no issuer to + // address a rejection to and no thread to correlate it with — so this one case answers + // with an unrouted error, exactly as the VTC's `body_parse_error_response` does. + let doc: TrustTask = match serde_json::from_slice(&body) { + Ok(d) => d, + Err(e) => { + return ( + StatusCode::BAD_REQUEST, + Json(json!({ + "error": format!("body is not a Trust Task document: {e}"), + })), + ) + .into_response(); + } + }; + + let payload = doc.payload.clone(); + match doc.type_uri.to_string().as_str() { + ROOMS_CREATE_TYPE => create(&state, &doc, payload).await, + ROOMS_RECORDS_PUT_TYPE => put(&state, &doc, payload).await, + ROOMS_RECORDS_GET_TYPE => get(&state, &doc, payload).await, + ROOMS_RECORDS_LIST_TYPE => list(&state, &doc, payload).await, + ROOMS_EPOCH_MINT_TYPE => mint(&state, &doc, payload).await, other => reject( - StatusCode::NOT_FOUND, - format!("this host does not serve `{other}`"), + &doc, + // The framework's own code for this: a host that does not implement a task + // says so by naming the type, and a client can tell that from a task it + // implements but refused. + RejectReason::UnsupportedType { + type_uri: other.to_string(), + }, ), } } -async fn create(state: &HostState, payload: Value) -> axum::response::Response { +async fn create( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { let req: CreateRoomBody = match serde_json::from_value(payload) { Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } }; let room = Room { room_id: req.room_id.clone(), @@ -117,26 +191,39 @@ async fn create(state: &HostState, payload: Value) -> axum::response::Response { updated_at: now(), }; match storage::create_room(&state.rooms, &room).await { - Ok(()) => Json(CreateRoomResponse { - room_id: req.room_id, - epoch: 1, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), + Ok(()) => respond( + doc, + CreateRoomResponse { + room_id: req.room_id, + epoch: 1, + }, + ), + Err(e) => from_app_error(doc, &e), } } -async fn put(state: &HostState, payload: Value) -> axum::response::Response { +async fn put( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { let req: PutRecordBody = match serde_json::from_value(payload) { Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } }; let room = match storage::get_room(&state.rooms, &req.room_id).await { Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), + Err(e) => return from_app_error(doc, &e), }; if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { - return reject(StatusCode::FORBIDDEN, e); + return from_app_error(doc, &e); } let record = Record { @@ -169,45 +256,69 @@ async fn put(state: &HostState, payload: Value) -> axum::response::Response { ) .await { - Ok(stored) => Json(PutRecordResponse { - key: stored.key, - version: stored.version, - epoch: stored.epoch, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), + Ok(stored) => respond( + doc, + PutRecordResponse { + key: stored.key, + version: stored.version, + epoch: stored.epoch, + }, + ), + Err(e) => from_app_error(doc, &e), } } -async fn get(state: &HostState, payload: Value) -> axum::response::Response { +async fn get( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { let req: GetRecordBody = match serde_json::from_value(payload) { Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } }; let room = match storage::get_room(&state.rooms, &req.room_id).await { Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), + Err(e) => return from_app_error(doc, &e), }; if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { - return reject(StatusCode::FORBIDDEN, e); + return from_app_error(doc, &e); } match storage::get_record(&state.records, &req.room_id, &req.key).await { - Ok(record) => Json(record).into_response(), - Err(e) => reject(StatusCode::NOT_FOUND, e), + Ok(record) => respond(doc, record), + Err(e) => from_app_error(doc, &e), } } -async fn list(state: &HostState, payload: Value) -> axum::response::Response { +async fn list( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { let req: ListRecordsBody = match serde_json::from_value(payload) { Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } }; let room = match storage::get_room(&state.rooms, &req.room_id).await { Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), + Err(e) => return from_app_error(doc, &e), }; if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { - return reject(StatusCode::FORBIDDEN, e); + return from_app_error(doc, &e); } match storage::list_records( &state.records, @@ -221,37 +332,52 @@ async fn list(state: &HostState, payload: Value) -> axum::response::Response { let limit = req.limit.unwrap_or(usize::MAX); // Metadata, never bodies — the same rule the VTC serves under, because it is a // property of the task rather than of any one host. - Json(ListRecordsResponse { - records: records.iter().take(limit).map(|r| r.metadata()).collect(), - }) - .into_response() + respond( + doc, + ListRecordsResponse { + records: records.iter().take(limit).map(|r| r.metadata()).collect(), + }, + ) } - Err(e) => reject(StatusCode::INTERNAL_SERVER_ERROR, e), + Err(e) => from_app_error(doc, &e), } } -async fn mint(state: &HostState, payload: Value) -> axum::response::Response { +async fn mint( + state: &HostState, + doc: &TrustTask, + payload: Value, +) -> axum::response::Response { let req: MintEpochBody = match serde_json::from_value(payload) { Ok(r) => r, - Err(e) => return reject(StatusCode::BAD_REQUEST, e), + Err(e) => { + return reject( + doc, + RejectReason::MalformedRequest { + reason: e.to_string(), + }, + ); + } }; let room = match storage::get_room(&state.rooms, &req.room_id).await { Ok(r) => r, - Err(e) => return reject(StatusCode::NOT_FOUND, e), + Err(e) => return from_app_error(doc, &e), }; // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict // any other by declining to seal them the new key — and this host, which cannot see the // membership, would have no way to notice. if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { - return reject(StatusCode::FORBIDDEN, e); + return from_app_error(doc, &e); } match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { - Ok(updated) => Json(MintEpochResponse { - room_id: updated.room_id, - epoch: updated.epoch, - }) - .into_response(), - Err(e) => reject(StatusCode::CONFLICT, e), + Ok(updated) => respond( + doc, + MintEpochResponse { + room_id: updated.room_id, + epoch: updated.epoch, + }, + ), + Err(e) => from_app_error(doc, &e), } } @@ -286,8 +412,19 @@ mod tests { (dir, state) } + /// Send a request and return the status plus the response document's **payload**. + /// + /// Building a real document here rather than `{type, payload}` is the point: the + /// `data_room` example failed on every call against the looser shape, because a client + /// parses the reply as a Trust Task document and a bare payload has no `id`. async fn call(app: &Router, type_uri: &str, payload: Value) -> (StatusCode, Value) { - let doc = json!({ "type": type_uri, "payload": payload }); + let doc = json!({ + "id": format!("urn:uuid:{}", Uuid::new_v4()), + "type": type_uri, + "issuer": "did:key:zCaller", + "recipient": "did:key:zHost", + "payload": payload, + }); let resp = app .clone() .oneshot( @@ -302,10 +439,13 @@ mod tests { let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX) .await .unwrap(); - ( - status, - serde_json::from_slice(&bytes).unwrap_or(Value::Null), - ) + let doc: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); + // Every reply is a routed document; the test cares about what it carries. + assert!( + doc.get("id").is_some(), + "every reply must be a Trust Task document: {doc}" + ); + (status, doc.get("payload").cloned().unwrap_or(Value::Null)) } fn presentation() -> Value { @@ -389,7 +529,7 @@ mod tests { .await; assert_eq!(status, StatusCode::FORBIDDEN); assert!( - body["error"] + body["message"] .as_str() .unwrap_or_default() .contains("subject binding"), @@ -433,16 +573,20 @@ mod tests { #[tokio::test] async fn an_unknown_task_is_not_served() { let (_d, st) = state(); - let (status, _) = call( + let (status, body) = call( &router(st), "https://trusttasks.org/spec/vtc/members/list/0.1", json!({}), ) .await; - assert_eq!( - status, - StatusCode::NOT_FOUND, + assert!( + !status.is_success(), "a room host serves rooms and nothing else" ); + assert_eq!( + body["code"], "unsupportedType", + "and says so with the framework's own code, so a client can tell it apart \ + from a task this host implements but refused: {body}" + ); } } From 4a38be94399f61f78e5d561b7846935ebab68a69 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 10:33:18 +0200 Subject: [PATCH 12/14] test(rooms): check the wire types against the published schemas 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 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 --- Cargo.lock | 10 +- vti-rooms/Cargo.toml | 5 + vti-rooms/src/lib.rs | 48 ++++- vti-rooms/tests/schema_conformance.rs | 300 ++++++++++++++++++++++++++ 4 files changed, 351 insertions(+), 12 deletions(-) create mode 100644 vti-rooms/tests/schema_conformance.rs diff --git a/Cargo.lock b/Cargo.lock index 266b44d69..8cae18eb6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3105,7 +3105,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c54e03a951783e8b327515db3f2a2fd0e3bed362a96b066f341ce66ed49b4ead" dependencies = [ "data-encoding", - "syn 3.0.4", + "syn 1.0.109", ] [[package]] @@ -8262,7 +8262,7 @@ checksum = "7bd22781911de0ca6debda95f073c8f18bec65d1a94f1fa9573f3102e514cea4" dependencies = [ "ahash", "annotate-snippets", - "base64 0.22.1", + "base64 0.21.7", "encoding_rs_io", "getrandom 0.3.4", "granit-parser", @@ -9597,9 +9597,9 @@ dependencies = [ [[package]] name = "trust-tasks-rs" -version = "0.17.4" +version = "0.17.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84ea8a0b30f49df3227e7f1adaf52ca2bad7591740059be7a629906a743f8dfa" +checksum = "9d429149074f413644d70e356a48ce3ca31764107716f2c5c6442559bacfc95d" dependencies = [ "async-trait", "chrono", @@ -10788,10 +10788,12 @@ dependencies = [ name = "vti-rooms" version = "0.1.0" dependencies = [ + "chrono", "serde", "serde_json", "tempfile", "tokio", + "trust-tasks-rs", "vti-common", ] diff --git a/vti-rooms/Cargo.toml b/vti-rooms/Cargo.toml index da8273998..9fdf55140 100644 --- a/vti-rooms/Cargo.toml +++ b/vti-rooms/Cargo.toml @@ -16,7 +16,12 @@ vti-common = { path = "../vti-common", version = "0.16" } serde = { workspace = true } serde_json = { workspace = true } +# The published schema types `updatedAt` as an RFC 3339 string. +chrono = { workspace = true } [dev-dependencies] tempfile = "3" +# The published rooms/* schemas, so the hand-rolled wire types above are checked +# against the spec rather than only against themselves. +trust-tasks-rs = { workspace = true } tokio = { workspace = true } diff --git a/vti-rooms/src/lib.rs b/vti-rooms/src/lib.rs index 5adf38b6e..974081c0c 100644 --- a/vti-rooms/src/lib.rs +++ b/vti-rooms/src/lib.rs @@ -220,20 +220,52 @@ pub struct Record { pub updated_at: u64, } +/// Unix seconds as an RFC 3339 timestamp. +/// +/// A value beyond what a timestamp can express renders as the epoch rather than panicking: +/// a listing is a read path, and a corrupt stored time should not take the room down. +fn rfc3339(unix_seconds: u64) -> String { + chrono::DateTime::from_timestamp(unix_seconds as i64, 0) + .unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).expect("epoch is in range")) + .to_rfc3339_opts(chrono::SecondsFormat::Secs, true) +} + impl Record { /// The metadata projection a listing returns. /// /// **Never the body.** Ranking happens on the client, and a service that returned every /// body would make a caller pay for the whole room on every listing — and on a sealed /// tier could not usefully rank them anyway. + /// The schema types every optional member — `epoch` is `integer`, `author` is + /// `string` — and sets `additionalProperties: false`, so an absent member has to be + /// *absent*. Emitting `null` fails validation rather than reading as "not applicable", + /// which is why this builds a map instead of one `json!` literal. pub fn metadata(&self) -> serde_json::Value { - serde_json::json!({ - "key": self.key, - "version": self.version, - "epoch": self.epoch, - "status": self.status, - "author": self.author, - "updatedAt": self.updated_at, - }) + let mut map = serde_json::Map::new(); + map.insert("key".into(), serde_json::json!(self.key)); + map.insert("version".into(), serde_json::json!(self.version)); + map.insert("status".into(), serde_json::json!(self.status)); + // RFC 3339, because that is what the published schema types it as + // (`format: date-time`) and what every other timestamp on the wire already is. + // Storage keeps unix seconds; only the projection renders. + map.insert( + "updatedAt".into(), + serde_json::json!(rfc3339(self.updated_at)), + ); + if let Some(epoch) = self.epoch { + map.insert("epoch".into(), serde_json::json!(epoch)); + } + if let Some(author) = &self.author { + map.insert("author".into(), serde_json::json!(author)); + } + // `title` and `description` are the open tier's, and live in the cleartext body. + if let Some(cleartext) = &self.cleartext { + for field in ["title", "description"] { + if let Some(v) = cleartext.get(field).filter(|v| v.is_string()) { + map.insert(field.into(), v.clone()); + } + } + } + serde_json::Value::Object(map) } } diff --git a/vti-rooms/tests/schema_conformance.rs b/vti-rooms/tests/schema_conformance.rs new file mode 100644 index 000000000..772305b69 --- /dev/null +++ b/vti-rooms/tests/schema_conformance.rs @@ -0,0 +1,300 @@ +//! The wire types must agree with the published schemas. +//! +//! # Why this file exists +//! +//! `vti_rooms::wire` hand-rolls the `rooms/*` request and response bodies. The specs are +//! published — `trustoverip/dtgwg-trust-tasks-tf`, generated into `trust_tasks_rs` — so +//! there are now two descriptions of the same wire form and nothing making them agree. +//! +//! The VTC has a conformance sweep for exactly this, but it is scoped to +//! `https://trusttasks.org/spec/vtc/` and says so in its own module docs. The rooms family +//! publishes at top level, `spec/rooms/`, so it falls outside — this is its equivalent. +//! +//! The drift class it catches is not hypothetical in this workspace: a `snake_case` field +//! where the schema says `camelCase` is invisible to every Rust test, because both sides of +//! a round-trip use the same struct. It took an empty `allowed_contexts` minting a +//! super-admin (#656/#658) to establish that. Serde is not the check — a schema is. +//! +//! # What each test does +//! +//! Builds a value with the hand-rolled type, serialises it, and validates the result +//! against the spec's own embedded schema. That catches what serde cannot: `camelCase` +//! renames, required members that were made optional, `const` and `enum` values, patterns, +//! and — because the request schemas are `additionalProperties: false` — a field this +//! implementation invented that the spec does not have. +//! +//! # What it deliberately does not do +//! +//! It does not assert the hand-rolled type is *identical* to the generated one. They differ +//! on purpose: the generated `Payload` types use newtypes and `NonZeroU64` where this crate +//! wants plain strings and `u64`, and a storage layer should not be forced through a +//! builder. Agreeing on the wire is the requirement; agreeing on the Rust shape is not. + +use serde_json::{Value, json}; +use trust_tasks_rs::validate::ValidatedPayload; +use vti_rooms::wire::*; +use vti_rooms::{Record, RecordStatus, Visibility}; + +/// Validate `value` against the schema published for `T`. +fn check(what: &str, value: &Value) { + if let Err(e) = T::validate_value(value) { + panic!( + "{what} does not conform to its published schema: {e}\n\nproduced:\n{}", + serde_json::to_string_pretty(value).unwrap_or_default() + ); + } +} + +/// A presentation, as every request carries one. +fn presentation() -> AuthorityPresentation { + AuthorityPresentation { + membership: "urn:uuid:11111111-1111-1111-1111-111111111111".into(), + authority: vec![ + "urn:uuid:22222222-2222-2222-2222-222222222222".into(), + "urn:uuid:33333333-3333-3333-3333-333333333333".into(), + ], + subject_binding: None, + } +} + +#[test] +fn create_room_conforms() { + use trust_tasks_rs::specs::rooms::create::v0_1::Payload; + + for visibility in [ + Visibility::Open, + Visibility::Attributed, + Visibility::Private, + ] { + let body = CreateRoomBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility, + retention_days: Some(90), + }; + check::( + &format!("CreateRoomBody ({visibility:?})"), + &serde_json::to_value(&body).expect("serialise"), + ); + } + + // `retentionDays` is optional, and "absent" must serialise as absent rather than + // `null` — a schema typing it `integer` rejects an explicit null. + let body = CreateRoomBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility: Visibility::Open, + retention_days: None, + }; + let value = serde_json::to_value(&body).expect("serialise"); + check::("CreateRoomBody with no retention", &value); +} + +#[test] +fn put_record_conforms_on_both_tiers() { + use trust_tasks_rs::specs::rooms::records::put::v0_1::Payload; + + let sealed = PutRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "giXFLTGBdnnQJRoIsktuIg".into(), + presentation: presentation(), + sealed: Some(SealedContent { + ciphertext: "1ep1PJuf8-yNmTndwcuMxA".into(), + nonce: "AAAAAAAAAAAAAAAA".into(), + epoch: 1, + }), + cleartext: None, + expected_version: Some(0), + }; + check::( + "PutRecordBody (sealed)", + &serde_json::to_value(&sealed).expect("serialise"), + ); + + let cleartext = PutRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "decision/pricing-2026".into(), + presentation: presentation(), + sealed: None, + cleartext: Some(CleartextContent { + title: Some("Pricing holds through Q3".into()), + description: None, + body: "Agreed not to reprice before the renewal closes.".into(), + tags: vec!["pricing".into()], + }), + expected_version: None, + }; + check::( + "PutRecordBody (cleartext)", + &serde_json::to_value(&cleartext).expect("serialise"), + ); +} + +#[test] +fn get_and_list_requests_conform() { + use trust_tasks_rs::specs::rooms::records::get::v0_1::Payload as GetPayload; + use trust_tasks_rs::specs::rooms::records::list::v0_1::Payload as ListPayload; + + let get = GetRecordBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + key: "decision/pricing-2026".into(), + presentation: presentation(), + }; + check::( + "GetRecordBody", + &serde_json::to_value(&get).expect("serialise"), + ); + + let list = ListRecordsBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + presentation: presentation(), + prefix: Some("decision/".into()), + since_version: Some(4), + limit: Some(50), + }; + check::( + "ListRecordsBody", + &serde_json::to_value(&list).expect("serialise"), + ); + + // Every optional narrowing absent — the incremental-sync caller's first call. + let bare = ListRecordsBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + presentation: presentation(), + prefix: None, + since_version: None, + limit: None, + }; + check::( + "ListRecordsBody with no narrowing", + &serde_json::to_value(&bare).expect("serialise"), + ); +} + +#[test] +fn mint_epoch_conforms() { + use trust_tasks_rs::specs::rooms::epoch::mint::v0_1::Payload; + + let body = MintEpochBody { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 2, + presentation: presentation(), + reason: Some("membership change".into()), + }; + check::( + "MintEpochBody", + &serde_json::to_value(&body).expect("serialise"), + ); +} + +/// A private room's presentation carries the pooling defence, and it must survive +/// serialisation under the name the schema gives it. +#[test] +fn a_subject_binding_conforms_under_its_published_name() { + use trust_tasks_rs::specs::rooms::records::get::v0_1::Payload; + + let mut p = presentation(); + p.subject_binding = Some("urn:uuid:44444444-4444-4444-4444-444444444444".into()); + let get = GetRecordBody { + room_id: "did:webvh:example.com:rooms:private".into(), + key: "giXFLTGBdnnQJRoIsktuIg".into(), + presentation: p, + }; + let value = serde_json::to_value(&get).expect("serialise"); + assert!( + value["presentation"]["subjectBinding"].is_string(), + "the binding must travel as `subjectBinding`, not snake_case: {value}" + ); + check::("GetRecordBody with a subject binding", &value); +} + +// ─── Responses ─────────────────────────────────────────────────────────── + +#[test] +fn responses_conform() { + use trust_tasks_rs::specs::rooms::create::v0_1::Response as CreateResponse; + use trust_tasks_rs::specs::rooms::epoch::mint::v0_1::Response as MintResponse; + use trust_tasks_rs::specs::rooms::records::list::v0_1::Response as ListResponse; + use trust_tasks_rs::specs::rooms::records::put::v0_1::Response as PutResponse; + + check::( + "CreateRoomResponse", + &serde_json::to_value(CreateRoomResponse { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 1, + }) + .expect("serialise"), + ); + + check::( + "PutRecordResponse", + &serde_json::to_value(PutRecordResponse { + key: "decision/pricing-2026".into(), + version: 1, + epoch: Some(1), + }) + .expect("serialise"), + ); + + check::( + "MintEpochResponse", + &serde_json::to_value(MintEpochResponse { + room_id: "did:webvh:example.com:rooms:northwind".into(), + epoch: 2, + }) + .expect("serialise"), + ); + + // A listing carries metadata, and a tombstone is part of it — a caller that never saw + // a retraction resurrects the record on its next rebuild. + // + // This runs `Record::metadata()` rather than a hand-built object on purpose: the + // projection is the thing that has to conform, and a literal written beside it would + // only ever agree with itself. Both drifts this file has caught were in the projection + // — a unix integer where the schema says `date-time`, and `null` for an absent + // optional under `additionalProperties: false`. + let records: Vec = [ + // An open-tier record: cleartext, an author, no epoch. + Record { + key: "decision/pricing-2026".into(), + version: 3, + epoch: None, + status: RecordStatus::Active, + sealed: None, + nonce: None, + cleartext: Some(json!({ + "title": "Pricing holds through Q3", + "body": "Agreed not to reprice before the renewal closes.", + })), + author: Some("did:key:z6MkAlice".into()), + updated_at: 1_756_000_000, + }, + // A tombstone: no body, no author, and it still has to conform. + Record { + key: "giXFLTGBdnnQJRoIsktuIg".into(), + version: 5, + epoch: Some(2), + status: RecordStatus::Retracted, + sealed: None, + nonce: None, + cleartext: None, + author: None, + updated_at: 1_756_000_100, + }, + ] + .iter() + .map(Record::metadata) + .collect(); + + assert_eq!( + records[0]["updatedAt"], "2025-08-24T01:46:40Z", + "the projection must render RFC 3339, not unix seconds" + ); + assert!( + records[1].get("author").is_none() && records[1].get("epoch").is_some(), + "an absent optional must be absent, not null: {}", + records[1] + ); + + check::("ListRecordsResponse", &json!({ "records": records })); +} From ef269590e74b12242ad8a319c429d459d4cdafb4 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 22:57:26 +0200 Subject: [PATCH 13/14] feat(rooms): verify the chain, and serve a sealed room 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 --- Cargo.lock | 45 +- Cargo.toml | 1 + Dockerfile.nitro | 1 + docs/05-design-notes/data-rooms.md | 26 + room-host/Cargo.toml | 3 + room-host/examples/data_room.rs | 517 ++++++++------- room-host/src/lib.rs | 388 ++++++++--- room-host/src/main.rs | 20 +- vtc-service/Cargo.toml | 4 + vtc-service/src/rooms/handlers.rs | 421 +++++++++--- vti-rooms-dtg/Cargo.toml | 56 ++ vti-rooms-dtg/src/lib.rs | 990 +++++++++++++++++++++++++++++ vti-rooms/Cargo.toml | 3 + vti-rooms/src/authz.rs | 434 +++++++++++-- 14 files changed, 2463 insertions(+), 446 deletions(-) create mode 100644 vti-rooms-dtg/Cargo.toml create mode 100644 vti-rooms-dtg/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 8cae18eb6..8e349da6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3480,6 +3480,23 @@ dependencies = [ "tracing", ] +[[package]] +name = "dtg-credentials" +version = "0.6.0" +source = "git+https://github.com/OpenVTC/dtg-credentials?rev=0c1391adfa67d8ae65498f086675404b8bb50b08#0c1391adfa67d8ae65498f086675404b8bb50b08" +dependencies = [ + "affinidi-data-integrity", + "affinidi-secrets-resolver", + "chrono", + "multibase", + "serde", + "serde_json", + "serde_json_canonicalizer", + "sha2 0.10.9", + "thiserror 2.0.20", + "tracing", +] + [[package]] name = "dunce" version = "1.0.5" @@ -7890,6 +7907,7 @@ dependencies = [ name = "room-host" version = "0.1.0" dependencies = [ + "affinidi-did-resolver-cache-sdk", "anyhow", "axum", "base64 0.23.1", @@ -7913,6 +7931,7 @@ dependencies = [ "vtc-client", "vti-common", "vti-rooms", + "vti-rooms-dtg", ] [[package]] @@ -10654,7 +10673,7 @@ dependencies = [ "clap", "dialoguer", "didwebvh-rs", - "dtg-credentials", + "dtg-credentials 0.4.0", "ed25519-dalek 3.0.0", "fjall", "flate2", @@ -10701,6 +10720,7 @@ dependencies = [ "vtc-service", "vti-common", "vti-rooms", + "vti-rooms-dtg", "vti-secrets", "webauthn-rs", "webauthn-rs-proto", @@ -10788,6 +10808,7 @@ dependencies = [ name = "vti-rooms" version = "0.1.0" dependencies = [ + "async-trait", "chrono", "serde", "serde_json", @@ -10797,6 +10818,28 @@ dependencies = [ "vti-common", ] +[[package]] +name = "vti-rooms-dtg" +version = "0.1.0" +dependencies = [ + "affinidi-data-integrity", + "affinidi-secrets-resolver", + "affinidi-tdk", + "async-trait", + "base64 0.23.1", + "chrono", + "dtg-credentials 0.6.0", + "ed25519-dalek 3.0.0", + "getrandom 0.3.4", + "multibase", + "serde_json", + "tokio", + "tracing", + "vta-sdk", + "vti-common", + "vti-rooms", +] + [[package]] name = "vti-secrets" version = "0.3.1" diff --git a/Cargo.toml b/Cargo.toml index 35a6ed924..ae8ba2acf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,6 +27,7 @@ members = [ "vtc-service", "vti-common", "vti-rooms", + "vti-rooms-dtg", "vti-secrets", "vti-webauthn", ] diff --git a/Dockerfile.nitro b/Dockerfile.nitro index 6b6b8be99..566485a25 100644 --- a/Dockerfile.nitro +++ b/Dockerfile.nitro @@ -150,6 +150,7 @@ COPY vtc-client/ vtc-client/ COPY vtc-service/ vtc-service/ COPY vti-common/ vti-common/ COPY vti-rooms/ vti-rooms/ +COPY vti-rooms-dtg/ vti-rooms-dtg/ COPY vti-secrets/ vti-secrets/ COPY vti-webauthn/ vti-webauthn/ diff --git a/docs/05-design-notes/data-rooms.md b/docs/05-design-notes/data-rooms.md index 18201805b..7bb0f5d2d 100644 --- a/docs/05-design-notes/data-rooms.md +++ b/docs/05-design-notes/data-rooms.md @@ -277,6 +277,32 @@ credentials — which BBS+ supports and the `rooms/*` presentation schema must require, not merely permit. This is a spec-level requirement; discovered in implementation it would be a silent authorization bypass. +**Which subject.** Implementation settled a detail the paragraph above hides: +the VMC binds to the chain's **root**, not its leaf. The first version compared +the leaf and it refused every agent — correctly, by its own rule, because *an +agent is not a member of anything*. Its human is. The chain's root is the grant +the room made, so its subject is the member whose standing the whole chain +descends from; `verify_chain` has already established that each link's issuer is +its parent's subject, so nothing below the root can escape it. Comparing the +root admits the agent and still refuses the pooling attack: a chain rooted at +Bob cannot be presented with Alice's membership, whoever holds the leaf. + +### 4.3a A presentation is bound to its presenter, not bearer + +A presentation names *what may be done*, never *who is doing it* — so an unbound +one is a bearer token, and anyone who observes one inherits everything it +confers. Every room operation therefore also carries the DID that signed the +request, established by the request document's own `eddsa-jcs-2022` proof, and +the chain's leaf must grant to that party. + +Worth stating explicitly because the reference implementation does **not** do it +for you: `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 that assumed otherwise would authorize +every captured presentation. Rooms check it twice — once in the credential +verifier and once in `authorize` — so the property does not depend on every +future verifier implementation remembering it. + ### 4.4 Presenting membership - `open` / `attributed` — standard W3C VC presentation; the subject is diff --git a/room-host/Cargo.toml b/room-host/Cargo.toml index 9c2df28be..d4ded2797 100644 --- a/room-host/Cargo.toml +++ b/room-host/Cargo.toml @@ -18,6 +18,7 @@ path = "src/main.rs" vti-rooms = { path = "../vti-rooms", version = "0.1" } # The store and AppError. Nothing else internal. vti-common = { path = "../vti-common", version = "0.16" } +vti-rooms-dtg = { path = "../vti-rooms-dtg" } axum = { workspace = true } tokio = { workspace = true } @@ -27,6 +28,7 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } clap = { workspace = true } anyhow = "1" +affinidi-did-resolver-cache-sdk = { workspace = true } # A room host and a VTC serve the same protocol, so they must speak the same # wire form: requests and responses are Trust Task documents, not bare JSON. @@ -37,6 +39,7 @@ uuid = { workspace = true } [dev-dependencies] tempfile = "3" tower = { version = "0.5", features = ["util"] } +vti-rooms-dtg = { path = "../vti-rooms-dtg", features = ["test-support"] } # The `data_room` example drives this host with the real client, so it needs the # client's MLS layer and a throwaway signer. None of this reaches the binary. diff --git a/room-host/examples/data_room.rs b/room-host/examples/data_room.rs index 4a225f473..0b6cd6b6d 100644 --- a/room-host/examples/data_room.rs +++ b/room-host/examples/data_room.rs @@ -1,4 +1,4 @@ -//! A data room, end to end: a real host, a real client, real MLS. +//! A data room, end to end: a real host, a real client, real credentials, real MLS. //! //! Run it: //! @@ -6,52 +6,33 @@ //! cargo run -p room-host --example data_room //! ``` //! -//! Nothing here is mocked. Act I starts the actual `room-host` binary's router on a real -//! TCP port and drives it with `vtc_client`'s actual room methods over HTTP. Act II builds -//! an actual MLS group and seals records under the key it exports. +//! Nothing here is mocked. It starts the actual `room-host` router on a real TCP port and +//! drives it with `vtc_client`'s actual room methods over HTTP. Every credential is signed +//! and every signature is verified. The sealed records really are sealed — the host is +//! handed ciphertext and holds no key. //! -//! # What the demo is trying to show +//! # The four claims //! -//! Three claims, in the order they build on each other: -//! -//! 1. **A room operation carries no session.** Every call below authorizes from a -//! credential chain the room issued. The host holds no roster and consults none. -//! 2. **An agent holds strictly less than its human.** Alice writes; her agent reads. The -//! two calls are the same code against the same host — the difference is entirely in -//! the chain each carries, which is what makes "give the AI read-only access for four -//! hours" a credential rather than a policy someone has to enforce. -//! 3. **The host holds every byte of a sealed room and cannot read or move one.** Act II -//! prints the ciphertext and then fails to open it four different ways. -//! -//! # What is honestly not joined up yet -//! -//! Act I runs on the `open` tier, and Act II runs locally. The join — sealed records -//! through the host — waits on cryptographic chain verification, which needs -//! `dtg_credentials::authority::verify_chain`. Until that is wired the host refuses sealed -//! rooms outright rather than serving one whose chain nobody checked, and Act III -//! demonstrates that refusal rather than papering over it. +//! 1. **A room operation carries no session.** Every call authorizes from a credential chain +//! the room issued. The host holds no roster and consults none — it could not; there is +//! no code in it that does. +//! 2. **An agent holds strictly less than its human.** Alice writes; her agent reads and is +//! refused a write. Same code, same host: the difference is entirely the chain each +//! carries, which is what makes "give the AI read-only access for four hours" a +//! credential rather than a policy someone has to remember to enforce. +//! 3. **A presentation is not a bearer token.** Captured and replayed by another party it is +//! refused, because the chain is bound to whoever signed the request. +//! 4. **The host holds every byte and can neither read nor move one.** Act III seals +//! *through* the host, and then fails to open a relocated record three different ways. use std::net::SocketAddr; -use base64::Engine as _; use openmls_rust_crypto::OpenMlsRustCrypto; use vtc_client::VtcClient; use vtc_client::rooms::mls::{RoomGroup, RoomIdentity}; use vtc_client::rooms::sealed::SealedRoom; -use vtc_client::rooms::{CleartextContent, RoomSession, Visibility}; - -/// Alice, who owns the room. -const ALICE: &str = "did:key:z6MkAlicePersonKeyForTheDemoOnly"; -/// Bob, a member. -const BOB: &str = "did:key:z6MkBobPersonKeyForTheDemoOnly"; -/// Alice's AI agent — a different DID, holding a different chain. -const AGENT: &str = "did:key:z6MkAliceAgentKeyForTheDemoOnly"; -/// Mallory, who is in no room at all. -const MALLORY: &str = "did:key:z6MkMalloryKeyForTheDemoOnly"; - -/// The room's own identifier. A room is a DTG node and brings its own DID — an identifier -/// the *host* chose could not survive a move to another host. -const ROOM: &str = "did:webvh:example.com:rooms:northwind"; +use vtc_client::rooms::{CleartextContent, RoomSession, SealedContent, Visibility}; +use vti_rooms_dtg::test_support::RoomFixture; fn say(step: &str, detail: &str) { println!("\n\x1b[1m{step}\x1b[0m\n {detail}"); @@ -61,22 +42,40 @@ fn note(detail: &str) { println!(" {detail}"); } +/// The refusal message out of a `trust-task-error` document. +/// +/// The whole document is the right thing on the wire and the wrong thing in a demo. +fn why(e: &impl std::fmt::Display) -> String { + let body = e.to_string(); + match body.find("\"message\":\"") { + Some(i) => { + let rest = &body[i + 11..]; + rest[..rest.find("\",").unwrap_or(rest.len())].to_string() + } + None => body, + } +} + #[tokio::main] async fn main() -> anyhow::Result<()> { println!("\n\x1b[1;4mA data room, end to end\x1b[0m"); let (addr, _dir) = start_host().await?; - let (signer_did, signer_key) = mint_signer(); - - act_one(addr, &signer_did, &signer_key).await?; - act_two()?; - act_three(addr, &signer_did, &signer_key).await?; - - println!("\n\x1b[1mWhere this stands\x1b[0m"); - note("Act I is over HTTP against the real host. Act II is real MLS and real AEAD."); - note("Joining them — sealed records through a host — needs chain verification, which"); - note("needs dtg-credentials 0.6 on crates.io. Until then the host refuses a sealed"); - note("room rather than serving one whose chain nobody checked (Act III).\n"); + let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHost"); + + act_one(&client).await?; + act_two(&client).await?; + act_three(&client).await?; + + println!("\n\x1b[1mWhat was and was not proved\x1b[0m"); + note("Every credential above was signed and every signature verified. The host stored"); + note("ciphertext it had no key for, and authorized every call from a chain the room"); + note("itself issued — never from anything the host holds."); + note(""); + note("A `private` room is still refused, and Act II shows the refusal rather than"); + note("hiding it. Its subject binding has to be proved in zero knowledge, and which"); + note("proof is a profile the DTG working group has not settled — so that seam is"); + note("present and empty rather than filled with something nobody agreed to.\n"); Ok(()) } @@ -84,12 +83,14 @@ async fn main() -> anyhow::Result<()> { // Act I — a shared room, over the wire // --------------------------------------------------------------------------- -async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow::Result<()> { - println!("\n\x1b[1;4mAct I — a room over the wire\x1b[0m"); +async fn act_one(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct I — a room, and who may act in it\x1b[0m"); - // `anonymous` is not a limitation being worked around. There is no token to hold: a - // room call is authorized by its chain, and `VtcClient`'s room methods never read one. - let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHostDid"); + // The fixture mints the room's own key, Alice's and her agent's, then issues the + // credentials: a VMC making Alice a member, a VAC from the room granting her + // read/write/curate/admin, and one Alice attenuated for her agent — four hours, read + // only, with no involvement from the room. + let f = RoomFixture::new(vti_rooms::Visibility::Open).await; say( "1. Alice registers the room", @@ -97,18 +98,18 @@ async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow ); client .create_room( - ROOM, - ALICE, + &f.room.room_id, + &f.owner.did, Visibility::Open, Some(90), - signer_did, - signer_key, + &f.owner.did, + &f.owner.secret_multibase, ) .await?; - note(&format!("room {ROOM} registered, epoch 1")); + note("registered, epoch 1"); - // Alice's chain: one link, straight from the room, conferring read and write. - let alice = RoomSession::new(ROOM, "vmc:alice@northwind", vec!["vac:alice-rw".into()])?; + let alice = session(&f, false); + let agent = session(&f, true); say( "2. Alice writes two memories", @@ -137,43 +138,42 @@ async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow ..Default::default() }), Some(0), // create-only - signer_did, - signer_key, + &f.owner.did, + &f.owner.secret_multibase, ) .await?; note(&format!("wrote {} at version {}", put.key, put.version)); } say( - "3. Alice equips her agent", - "A chain one link longer, conferring read alone. Same code, same host, less power.", + "3. The agent reads the room as memory", + "Alice's membership, a chain one link longer, and only `read` at the end of it.", ); - let agent = RoomSession::new( - ROOM, - "vmc:alice@northwind", - // Leaf first: the agent's own read-only grant, then the grant Alice attenuated it - // from. The host verifies the chain reaches the room and never widens. - vec!["vac:agent-read-4h".into(), "vac:alice-rw".into()], - )?; note(&format!( - "Alice's chain is {} link deep; {AGENT}'s is {}", + "Alice's chain is {} link deep; her agent's is {}", alice.chain_depth(), agent.chain_depth() )); - - say( - "4. The agent reads the room as memory", - "It lists what is there, then fetches the one record it needs.", - ); let listing = client - .list_records(&agent, Some("decision/"), None, signer_did, signer_key) + .list_records( + &agent, + Some("decision/"), + None, + &f.agent.did, + &f.agent.secret_multibase, + ) .await?; note(&format!( "{} records match `decision/` — metadata only, no bodies", listing.records.len() )); let record = client - .get_record(&agent, "decision/pricing-2026", signer_did, signer_key) + .get_record( + &agent, + "decision/pricing-2026", + &f.agent.did, + &f.agent.secret_multibase, + ) .await?; note(&format!( "read: {}", @@ -181,41 +181,136 @@ async fn act_one(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow )); say( - "5. Mallory presents nothing and gets nothing", - "Not because the host knows who Mallory is. Because there is no chain.", + "4. The agent tries to write", + "Nothing in the host says agents may not write. The chain says it, and that is enough.", ); - // `RoomSession` refuses an empty chain before it reaches the wire, so send the document - // by hand — the point is that the *host* refuses it too, on its own reading. - let refused = post_raw( - addr, - "https://trusttasks.org/spec/rooms/records/get/0.1", - serde_json::json!({ - "roomId": ROOM, - "key": "decision/pricing-2026", - "presentation": { "membership": "vmc:mallory@nowhere", "authority": [] } - }), - ) - .await?; - note(&format!("host says: {refused}")); + match client + .put_record( + &agent, + "decision/invented", + None, + Some(CleartextContent { + body: "the agent should not be able to say this".into(), + ..Default::default() + }), + None, + &f.agent.did, + &f.agent.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a read-only chain must not write"), + } + + say( + "5. Alice replays her agent's presentation", + "A presentation says what may be done, not who is doing it — so it is bound to the signer.", + ); + match client + .get_record( + &agent, // the agent's chain … + "decision/pricing-2026", + &f.owner.did, // … signed by Alice + &f.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a captured presentation must not be replayable"), + } + + say( + "6. A stranger presents a perfectly valid chain", + "Valid for their own room. It does not reach this one, so it confers nothing here.", + ); + let elsewhere = RoomFixture::new(vti_rooms::Visibility::Open).await; + let borrowed = RoomSession::new( + &f.room.room_id, + elsewhere.membership.clone(), + elsewhere.owner_chain.clone(), + )?; + match client + .get_record( + &borrowed, + "decision/pricing-2026", + &elsewhere.owner.did, + &elsewhere.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a chain rooted elsewhere must confer nothing"), + } Ok(()) } // --------------------------------------------------------------------------- -// Act II — what the host would hold, if it held a sealed room +// Act II — the seam that is honestly empty // --------------------------------------------------------------------------- -fn act_two() -> anyhow::Result<()> { - println!("\n\x1b[1;4mAct II — sealed, and unmovable\x1b[0m"); +async fn act_two(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct II — the one thing that does not work yet\x1b[0m"); + let f = RoomFixture::new(vti_rooms::Visibility::Private).await; say( - "6. Alice and Bob form the room's MLS group", - "Bob publishes a key package over the invitation channel — never through the host.", + "7. A private room registers, and then refuses to be read", + "Storing and serving are different questions. This host answers the second one no.", ); - let mut alice_group = RoomGroup::create(ALICE)?; + client + .create_room( + &f.room.room_id, + &f.owner.did, + Visibility::Private, + None, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + + let session = session(&f, false).with_subject_binding("a-binding-nobody-can-check-yet"); + match client + .get_record( + &session, + "anything", + &f.owner.did, + &f.owner.secret_multibase, + ) + .await + { + Err(e) => note(&format!("refused: {}", why(&e))), + Ok(_) => unreachable!("a private room must not be served without a ZK profile"), + } + Ok(()) +} + +// --------------------------------------------------------------------------- +// Act III — sealed records, through the host +// --------------------------------------------------------------------------- +async fn act_three(client: &VtcClient) -> anyhow::Result<()> { + println!("\n\x1b[1;4mAct III — sealed, stored, and unmovable\x1b[0m"); + + let f = RoomFixture::new(vti_rooms::Visibility::Attributed).await; + client + .create_room( + &f.room.room_id, + &f.owner.did, + Visibility::Attributed, + None, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + + say( + "8. Alice and Bob form the room's MLS group", + "Bob publishes a key package over the invitation channel — never through the host.", + ); + let mut alice_group = RoomGroup::create(&f.owner.did)?; let bob_provider = OpenMlsRustCrypto::default(); - let bob_identity = RoomIdentity::new(BOB, &bob_provider)?; + let bob_identity = RoomIdentity::new("did:key:zBob", &bob_provider)?; let bob_package = bob_identity.key_package(&bob_provider)?; let change = alice_group.add_member(bob_package)?; @@ -227,73 +322,106 @@ fn act_two() -> anyhow::Result<()> { alice_group.epoch() )); - let alice_room = SealedRoom::new( - RoomSession::new(ROOM, "vmc:alice@northwind", vec!["vac:alice-rw".into()])?, - alice_group, - ); - let bob_room = SealedRoom::new( - RoomSession::new(ROOM, "vmc:bob@northwind", vec!["vac:bob-r".into()])?, - bob_group, + let alice_room = SealedRoom::new(session(&f, false), alice_group); + let bob_room = SealedRoom::new(session(&f, false), bob_group); + + say( + "9. Alice mints the epoch the membership change produced", + "The host records the number and never learns the key. That is the whole of its role.", ); + let minted = client + .mint_epoch( + alice_room.session(), + alice_room.room_epoch(), + Some("added a member"), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note(&format!( + "room is at epoch {} — minted by an `admin` chain, not by holding a key", + minted.epoch + )); say( - "7. Alice seals a record", - "The key comes from the MLS exporter. The host is given the bytes below.", + "10. Alice seals a record and stores it", + "The key comes from the MLS exporter. What crosses the wire is below.", ); let key = SealedRoom::opaque_key(); let plaintext = b"Northwind will not be repriced before renewal. Do not share."; + // Version 1: the record does not exist yet, and `expected_version: Some(0)` below makes + // the host assign exactly that. The binding commits to a version chosen before the host + // replies, which is why create-only writes are the shape that works here. let sealed = alice_room.seal_record(&key, 1, plaintext)?; note(&format!( - "record key: {key} (opaque — a descriptive key would leak)" + "key {key} (opaque — a descriptive key would defeat the encryption beside it)" )); note(&format!( - "ciphertext: {}… ({} bytes)", - &sealed.ciphertext[..44.min(sealed.ciphertext.len())], - base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(&sealed.ciphertext) - .map(|b| b.len()) - .unwrap_or(0) + "ciphertext {}…", + &sealed.ciphertext[..44.min(sealed.ciphertext.len())] )); + let put = client + .put_record( + alice_room.session(), + &key, + Some(SealedContent { + ciphertext: sealed.ciphertext.clone(), + nonce: sealed.nonce.clone(), + epoch: sealed.epoch, + }), + None, + Some(0), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + note(&format!("stored at version {}", put.version)); + say( - "8. Bob opens it; the host cannot", - "Bob derives the same key from the same group. The host has no leaf in it.", + "11. It comes back from the host, and Bob opens it", + "The host served the bytes. It could not read them, and neither can anyone outside.", ); - let opened = bob_room.open_record(&key, 1, &sealed)?; + let fetched = client + .get_record( + alice_room.session(), + &key, + &f.owner.did, + &f.owner.secret_multibase, + ) + .await?; + let from_host = SealedContent { + ciphertext: fetched["sealed"].as_str().unwrap_or_default().to_string(), + nonce: fetched["nonce"].as_str().unwrap_or_default().to_string(), + epoch: fetched["epoch"].as_u64().unwrap_or(0) as u32, + }; + let opened = bob_room.open_record(&key, put.version, &from_host)?; note(&format!("Bob reads: {}", String::from_utf8_lossy(&opened))); - let mallory_room = SealedRoom::new( - RoomSession::new(ROOM, "vmc:forged", vec!["vac:forged".into()])?, - RoomGroup::create(MALLORY)?, - ); - note(match mallory_room.open_record(&key, 1, &sealed) { - Err(_) => "Mallory, holding a perfectly valid group of her own: refused", + let outsider = SealedRoom::new(session(&f, false), RoomGroup::create("did:key:zMallory")?); + note(match outsider.open_record(&key, put.version, &from_host) { + Err(_) => "Mallory, holding a perfectly valid group of her own: cannot open it", Ok(_) => unreachable!("an outsider must not open a sealed record"), }); say( - "9. The host holds every byte and still cannot move one", + "12. The host holds every byte and still cannot move one", "Each record is bound to roomId | key | version | epoch. Relocation fails loudly.", ); + let other = RoomFixture::new(vti_rooms::Visibility::Attributed).await; for (what, result) in [ ( "to another key", - alice_room.open_record("other-key", 1, &sealed), + alice_room.open_record("other-key", put.version, &from_host), ), ( "to another version", - alice_room.open_record(&key, 2, &sealed), + alice_room.open_record(&key, put.version + 1, &from_host), ), ("to another room", { - let elsewhere = SealedRoom::new( - RoomSession::new( - "did:webvh:example.com:rooms:other", - "vmc:alice@northwind", - vec!["vac:alice-rw".into()], - )?, - RoomGroup::create(ALICE)?, - ); - elsewhere.open_record(&key, 1, &sealed) + let elsewhere = + SealedRoom::new(session(&other, false), RoomGroup::create(&f.owner.did)?); + elsewhere.open_record(&key, put.version, &from_host) }), ] { assert!(result.is_err(), "moving a record {what} must fail"); @@ -304,55 +432,27 @@ fn act_two() -> anyhow::Result<()> { } // --------------------------------------------------------------------------- -// Act III — the seam, shown rather than hidden +// Harness // --------------------------------------------------------------------------- -async fn act_three(addr: SocketAddr, signer_did: &str, signer_key: &str) -> anyhow::Result<()> { - println!("\n\x1b[1;4mAct III — the seam\x1b[0m"); - - let client = VtcClient::anonymous(&format!("http://{addr}"), "did:key:zHostDid"); - let private = "did:webvh:example.com:rooms:private"; - - say( - "10. Registering a private room succeeds", - "The host will store it. Storing and serving are different questions.", - ); - client - .create_room( - private, - ALICE, - Visibility::Private, - None, - signer_did, - signer_key, - ) - .await?; - note("registered"); - - say( - "11. Reading it is refused, and the refusal says why", - "Better to serve no sealed room than one whose authority chain nobody verified.", - ); - let session = RoomSession::new(private, "vmc:alice@northwind", vec!["vac:alice-rw".into()])? - .with_subject_binding("bbs-proof-that-both-describe-alice"); - match client - .get_record(&session, "anything", signer_did, signer_key) - .await - { - Err(e) => note(&format!("host says: {}", host_message(&e.to_string()))), - Ok(_) => unreachable!("a sealed room must not be served on an unverified chain"), - } - - Ok(()) +/// A session over the fixture's credentials, as the owner or as the agent. +fn session(f: &RoomFixture, as_agent: bool) -> RoomSession { + let chain = if as_agent { + f.agent_chain.clone() + } else { + f.owner_chain.clone() + }; + RoomSession::new(&f.room.room_id, f.membership.clone(), chain) + .expect("the fixture's chains are within the depth bound") } -// --------------------------------------------------------------------------- -// Harness -// --------------------------------------------------------------------------- - /// Start the real host on an ephemeral port, over a temporary store. /// -/// The returned `TempDir` must outlive the demo — dropping it deletes the room. +/// `did:key` resolution only — the fixture's rooms and parties are all `did:key`, so every +/// credential below verifies with no network at all. A demo that reached the network would +/// be demonstrating the network. +/// +/// The returned `TempDir` must outlive the run: dropping it deletes every room. async fn start_host() -> anyhow::Result<(SocketAddr, tempfile::TempDir)> { let dir = tempfile::tempdir()?; let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; @@ -369,56 +469,3 @@ async fn start_host() -> anyhow::Result<(SocketAddr, tempfile::TempDir)> { ); Ok((addr, dir)) } - -/// A `did:key` to sign the Trust-Task documents with. -/// -/// Signing the document and authorizing the operation are different things — the signature -/// says who sent this request, the chain says what they may do. The demo keeps them -/// visibly separate by using one throwaway signer for every call while the chains differ. -fn mint_signer() -> (String, String) { - use ed25519_dalek::SigningKey; - let mut seed = [0u8; 32]; - getrandom::fill(&mut seed).expect("OS randomness"); - let signing = SigningKey::from_bytes(&seed); - let did = format!( - "did:key:{}", - vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes()) - ); - let secret = multibase::encode(multibase::Base::Base58Btc, seed); - (did, secret) -} - -/// Post a Trust-Task document the client would refuse to build, and return what the host -/// said about it. -async fn post_raw( - addr: SocketAddr, - type_uri: &str, - payload: serde_json::Value, -) -> anyhow::Result { - let body = serde_json::json!({ - "id": format!("urn:uuid:{}", uuid::Uuid::new_v4()), - "type": type_uri, - "issuer": MALLORY, - "recipient": "did:key:zHostDid", - "payload": payload, - }); - let resp = reqwest::Client::new() - .post(format!("http://{addr}/trust-tasks")) - .json(&body) - .send() - .await?; - Ok(host_message(&resp.text().await?)) -} - -/// Pull the human-readable message out of a `trust-task-error` document. -/// -/// The whole document is the right thing on the wire and the wrong thing in a demo. -fn host_message(body: &str) -> String { - let start = match body.find("\"message\":\"") { - Some(i) => i + 11, - None => return body.trim().to_string(), - }; - let rest = &body[start..]; - let end = rest.find("\",").unwrap_or(rest.len()); - rest[..end].to_string() -} diff --git a/room-host/src/lib.rs b/room-host/src/lib.rs index 1e605f911..acc2a2b73 100644 --- a/room-host/src/lib.rs +++ b/room-host/src/lib.rs @@ -25,12 +25,23 @@ //! makes a room host an ordinary provisioned integration with its own DID (the `room-host` //! DID template) rather than a new surface on the agent. //! +//! # How a request is authorized +//! +//! Two things this host takes from a request and nothing else: +//! +//! - the **presenter**, from the document's own `eddsa-jcs-2022` proof. Not from a payload +//! field — a presentation says what may be done, not who is doing it, so an unbound one +//! is a bearer token anyone observing it inherits. +//! - the **chain**, verified by [`vti_rooms_dtg`] against credentials the room issued. +//! +//! Neither is a lookup in anything this host stores, which is the whole of invariant I5. +//! //! # Status //! -//! The dispatch surface here is the `open` tier. Sealed tiers are refused by -//! [`vti_rooms::authz`] until chain verification is wired, and that refusal lives in the -//! shared crate rather than here — so this host and a VTC cannot disagree about what is -//! safe to serve. +//! `open` and `attributed` rooms serve. A `private` room is refused, because its subject +//! binding has to be proved in zero knowledge and the working group has not settled the +//! profile — the refusal comes from `vti-rooms-dtg`, which is also what a VTC uses, so the +//! two cannot disagree about what is safe to serve. use std::sync::Arc; @@ -57,15 +68,43 @@ use vti_rooms::{ authz::{self, Action}, storage, }; +use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier}; /// Default retention after a room's epoch lapses without renewal. const DEFAULT_RETENTION_DAYS: u32 = 90; -/// Everything this host holds. Two keyspaces — and note what is not here. +/// Everything this host holds. Two keyspaces and a verifier — and note what is not here. #[derive(Clone)] pub struct HostState { rooms: KeyspaceHandle, records: KeyspaceHandle, + /// How a DID resolves to the key that signed a credential. + /// + /// A room's credentials are issued by the room, which is normally a `did:webvh`, so a + /// host restricted to `did:key` can serve almost nothing. It is still the default when + /// no resolver is configured: refusing what it cannot verify is correct, and quietly + /// resolving over the network for an unauthenticated caller is not. + resolver: vti_common::auth::TrustTaskVmResolver, +} + +impl HostState { + /// The presenter — proven, not claimed — and the verifier to judge their chain with. + async fn presenter_and_verifier( + &self, + doc: &TrustTask, + ) -> Result<(String, DtgChainVerifier), AppError> { + let presenter = + vti_common::auth::di_proof::verify_trust_task_proof_with(doc, &self.resolver) + .await + .map_err(|e| AppError::Forbidden(format!("request proof: {e}")))?; + + Ok(( + presenter, + // `without_zk`: no zero-knowledge profile, so private rooms are refused rather + // than served on a pooling defence nobody checked. + DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(self.resolver.clone()))), + )) + } } fn now() -> u64 { @@ -222,9 +261,22 @@ async fn put( Ok(r) => r, Err(e) => return from_app_error(doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { - return from_app_error(doc, &e); - } + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + let authorized = match authz::authorize( + &room, + &req.presentation, + Action::Write, + &presenter, + &verifier, + ) + .await + { + Ok(a) => a, + Err(e) => return from_app_error(doc, &e), + }; let record = Record { key: req.key.clone(), @@ -237,12 +289,13 @@ async fn put( .cleartext .as_ref() .map(|c| serde_json::to_value(c).unwrap_or(Value::Null)), - // Only where the tier discloses an actor. On a private room authorship lives inside - // the sealed body, and the storage layer refuses it here. + // The verified subject where the tier discloses an actor — who the chain says is + // acting, not the room's owner. On a private room authorship lives inside the + // sealed body, and the storage layer refuses it here. author: room .visibility .discloses_actor() - .then(|| room.owner_did.clone()), + .then(|| authorized.subject().to_string()), updated_at: 0, }; @@ -288,7 +341,19 @@ async fn get( Ok(r) => r, Err(e) => return from_app_error(doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { return from_app_error(doc, &e); } match storage::get_record(&state.records, &req.room_id, &req.key).await { @@ -317,7 +382,19 @@ async fn list( Ok(r) => r, Err(e) => return from_app_error(doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { return from_app_error(doc, &e); } match storage::list_records( @@ -366,7 +443,19 @@ async fn mint( // `admin`, not `write`: if any key-holder could mint an epoch, any member could evict // any other by declining to seal them the new key — and this host, which cannot see the // membership, would have no way to notice. - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { + let (presenter, verifier) = match state.presenter_and_verifier(doc).await { + Ok(p) => p, + Err(e) => return from_app_error(doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Admin, + &presenter, + &verifier, + ) + .await + { return from_app_error(doc, &e); } match storage::advance_epoch(&state.rooms, &req.room_id, req.epoch, now()).await { @@ -389,13 +478,30 @@ pub fn router(state: Arc) -> Router { .with_state(state) } +/// Open the store with a `did:key`-only verifier. +/// +/// The conservative construction, and what a test or the example wants: no network +/// resolution can be triggered by an unauthenticated request. A deployment serving a +/// `did:webvh` room wants [`open_state_with_resolver`]. pub fn open_state(data_dir: &std::path::Path) -> anyhow::Result> { + open_state_with_resolver( + data_dir, + vti_common::auth::TrustTaskVmResolver::did_key_only(), + ) +} + +/// Open the store with a specific verification-method resolver. +pub fn open_state_with_resolver( + data_dir: &std::path::Path, + resolver: vti_common::auth::TrustTaskVmResolver, +) -> anyhow::Result> { let store = Store::open(&StoreConfig { data_dir: data_dir.to_path_buf(), })?; Ok(Arc::new(HostState { rooms: store.keyspace(ROOMS_KEYSPACE)?, records: store.keyspace(ROOM_RECORDS_KEYSPACE)?, + resolver, })) } @@ -405,32 +511,47 @@ mod tests { use axum::body::Body; use axum::http::Request; use tower::ServiceExt; + use vti_rooms::Visibility; + use vti_rooms_dtg::test_support::{Party, RoomFixture}; + /// A host over a temporary store. + /// + /// `did:key`-only resolution is not a limitation being worked around here: the fixture's + /// room, owner and agent are all `did:key`, so every credential in these tests verifies + /// with no network at all. That is deliberate — a test that reached the network would be + /// testing the network. fn state() -> (tempfile::TempDir, Arc) { let dir = tempfile::tempdir().unwrap(); let state = open_state(dir.path()).expect("open store"); (dir, state) } - /// Send a request and return the status plus the response document's **payload**. + /// Send a **signed** document and return the status plus the response payload. /// - /// Building a real document here rather than `{type, payload}` is the point: the - /// `data_room` example failed on every call against the looser shape, because a client - /// parses the reply as a Trust Task document and a bare payload has no `id`. - async fn call(app: &Router, type_uri: &str, payload: Value) -> (StatusCode, Value) { - let doc = json!({ - "id": format!("urn:uuid:{}", Uuid::new_v4()), - "type": type_uri, - "issuer": "did:key:zCaller", - "recipient": "did:key:zHost", - "payload": payload, - }); + /// Signing is not ceremony: the host reads the presenter from this proof, and an + /// unsigned request is refused before any chain is looked at. + async fn call( + app: &Router, + type_uri: &str, + payload: Value, + signer: &Party, + ) -> (StatusCode, Value) { + let doc = vta_sdk::trust_task_sign::build_signed( + type_uri, + payload, + &signer.did, + &signer.secret_multibase, + "did:key:zHost", + ) + .await + .expect("sign the request"); + let resp = app .clone() .oneshot( Request::post("/trust-tasks") .header("content-type", "application/json") - .body(Body::from(doc.to_string())) + .body(Body::from(doc)) .unwrap(), ) .await @@ -440,7 +561,6 @@ mod tests { .await .unwrap(); let doc: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null); - // Every reply is a routed document; the test cares about what it carries. assert!( doc.get("id").is_some(), "every reply must be a Trust Task document: {doc}" @@ -448,83 +568,200 @@ mod tests { (status, doc.get("payload").cloned().unwrap_or(Value::Null)) } - fn presentation() -> Value { - json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) + /// Register `f`'s room with the host. + async fn register(app: &Router, f: &RoomFixture) { + let (status, body) = call( + app, + ROOMS_CREATE_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "ownerDid": f.room.owner_did, + "visibility": f.room.visibility, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); } #[tokio::test] - async fn a_room_is_created_and_a_record_round_trips() { + async fn a_record_round_trips_under_a_chain_the_room_issued() { let (_d, st) = state(); let app = router(st); - - let (status, _) = call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), - ) - .await; - assert_eq!(status, StatusCode::OK); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; let (status, body) = call( &app, ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "k1", "presentation": presentation(), - "cleartext": { "body": "a decision" } }), + serde_json::json!({ + "roomId": f.room.room_id, + "key": "decision/pricing", + "presentation": f.as_owner(), + "cleartext": { "body": "a decision" }, + }), + &f.owner, ) .await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["version"], 1); let (status, body) = call( &app, ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), + serde_json::json!({ + "roomId": f.room.room_id, + "key": "decision/pricing", + "presentation": f.as_owner(), + }), + &f.owner, ) .await; - assert_eq!(status, StatusCode::OK); + assert_eq!(status, StatusCode::OK, "{body}"); assert_eq!(body["cleartext"]["body"], "a decision"); + assert_eq!( + body["author"], f.owner.did, + "the author is the verified subject, not the room's owner field" + ); } - /// The property that makes this host usable by a room it does not govern: it decides - /// from the chain, and there is nothing else it could decide from. + /// The arrangement the whole design exists for, end to end through a host. #[tokio::test] - async fn an_operation_with_no_chain_is_refused() { + async fn an_agent_reads_under_a_narrower_chain_and_cannot_write() { let (_d, st) = state(); let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; call( &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_owner(), + "cleartext": { "body": "for the agent to read" }, + }), + &f.owner, ) .await; - let (status, _) = call( + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_agent(), + }), + &f.agent, + ) + .await; + assert_eq!(status, StatusCode::OK, "the agent reads: {body}"); + + let (status, body) = call( &app, ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "k", "presentation": { "membership": "vmc", "authority": [] }, - "cleartext": { "body": "x" } }), + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k2", + "presentation": f.as_agent(), + "cleartext": { "body": "but it must not write" }, + }), + &f.agent, ) .await; - assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); } - /// The shared crate decides this, not the host — so a room host and a VTC cannot - /// disagree about what is safe to serve. + /// A presentation is not a bearer token: the agent's chain, presented by its human. #[tokio::test] - async fn sealed_tiers_are_refused_here_exactly_as_they_are_on_a_vtc() { + async fn a_captured_presentation_does_not_work_for_someone_else() { let (_d, st) = state(); let app = router(st); - call( + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, body) = call( &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "p1", "visibility": "private", "ownerDid": "did:key:zOwner" }), + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": f.as_agent(), + }), + &f.owner, ) .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + /// A stranger with a perfectly valid chain of their own gets nothing here — because the + /// chain does not reach *this* room. + #[tokio::test] + async fn a_chain_from_another_room_confers_nothing() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + let elsewhere = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; let (status, body) = call( &app, ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": elsewhere.as_owner(), + }), + &elsewhere.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN, "{body}"); + } + + #[tokio::test] + async fn an_operation_with_no_chain_is_refused() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; + + let (status, _) = call( + &app, + ROOMS_RECORDS_PUT_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": { "membership": f.membership, "authority": [] }, + "cleartext": { "body": "x" }, + }), + &f.owner, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + } + + /// A private room is refused with the message `vti-rooms-dtg` gives it, which is the + /// same message a VTC gives — the two cannot disagree about what is safe to serve. + #[tokio::test] + async fn a_private_room_is_refused_for_want_of_a_zk_profile() { + let (_d, st) = state(); + let app = router(st); + let f = RoomFixture::new(Visibility::Private).await; + register(&app, &f).await; + + let mut p = f.as_owner(); + p.subject_binding = Some("a-binding-nobody-can-check".into()); + + let (status, body) = call( + &app, + ROOMS_RECORDS_GET_TYPE, + serde_json::json!({ + "roomId": f.room.room_id, + "key": "k", + "presentation": p, + }), + &f.owner, ) .await; assert_eq!(status, StatusCode::FORBIDDEN); @@ -532,7 +769,7 @@ mod tests { body["message"] .as_str() .unwrap_or_default() - .contains("subject binding"), + .contains("zero-knowledge profile"), "{body}" ); } @@ -541,24 +778,29 @@ mod tests { async fn a_listing_returns_metadata_and_never_bodies() { let (_d, st) = state(); let app = router(st); - call( - &app, - ROOMS_CREATE_TYPE, - json!({ "roomId": "r1", "visibility": "open", "ownerDid": "did:key:zOwner" }), - ) - .await; + let f = RoomFixture::new(Visibility::Open).await; + register(&app, &f).await; call( &app, ROOMS_RECORDS_PUT_TYPE, - json!({ "roomId": "r1", "key": "a", "presentation": presentation(), - "cleartext": { "body": "secret-body-text" } }), + serde_json::json!({ + "roomId": f.room.room_id, + "key": "a", + "presentation": f.as_owner(), + "cleartext": { "body": "secret-body-text" }, + }), + &f.owner, ) .await; let (status, body) = call( &app, ROOMS_RECORDS_LIST_TYPE, - json!({ "roomId": "r1", "presentation": presentation() }), + serde_json::json!({ + "roomId": f.room.room_id, + "presentation": f.as_owner(), + }), + &f.owner, ) .await; assert_eq!(status, StatusCode::OK); @@ -576,7 +818,8 @@ mod tests { let (status, body) = call( &router(st), "https://trusttasks.org/spec/vtc/members/list/0.1", - json!({}), + serde_json::json!({}), + &Party::new(), ) .await; assert!( @@ -585,8 +828,7 @@ mod tests { ); assert_eq!( body["code"], "unsupportedType", - "and says so with the framework's own code, so a client can tell it apart \ - from a task this host implements but refused: {body}" + "and says so with the framework's own code: {body}" ); } } diff --git a/room-host/src/main.rs b/room-host/src/main.rs index c325707e9..17706a064 100644 --- a/room-host/src/main.rs +++ b/room-host/src/main.rs @@ -4,7 +4,7 @@ //! test or the `data_room` example without a socket. use clap::Parser; -use room_host::{open_state, router}; +use room_host::{open_state_with_resolver, router}; #[derive(Parser, Debug)] #[command(name = "room-host", about = "Store and serve data-room records")] @@ -15,6 +15,14 @@ struct Args { /// Address to listen on. #[arg(long, default_value = "127.0.0.1:8300")] listen: String, + /// Resolve credential issuers over the network as well as locally. + /// + /// Off by default. A room's credentials are normally issued by a `did:webvh` room, so a + /// host without this serves almost nothing — but turning network resolution on means an + /// unauthenticated request can make this host fetch, so it is a decision an operator + /// makes rather than a default they inherit. + #[arg(long)] + resolve_dids: bool, } #[tokio::main] @@ -27,12 +35,20 @@ async fn main() -> anyhow::Result<()> { .init(); let args = Args::parse(); - let state = open_state(&args.data_dir)?; + let resolver = if args.resolve_dids { + use affinidi_did_resolver_cache_sdk::{DIDCacheClient, config::DIDCacheConfigBuilder}; + let client = DIDCacheClient::new(DIDCacheConfigBuilder::default().build()).await?; + vti_common::auth::TrustTaskVmResolver::new(client) + } else { + vti_common::auth::TrustTaskVmResolver::did_key_only() + }; + let state = open_state_with_resolver(&args.data_dir, resolver)?; let listener = tokio::net::TcpListener::bind(&args.listen).await?; tracing::info!( listen = %args.listen, data_dir = %args.data_dir.display(), + network_resolution = args.resolve_dids, "room host ready — storing records for rooms it does not govern" ); axum::serve(listener, router(state)).await?; diff --git a/vtc-service/Cargo.toml b/vtc-service/Cargo.toml index c54d142e7..60df9e338 100644 --- a/vtc-service/Cargo.toml +++ b/vtc-service/Cargo.toml @@ -118,6 +118,7 @@ path = "src/main.rs" [dependencies] vti-rooms = { path = "../vti-rooms", version = "0.1" } +vti-rooms-dtg = { path = "../vti-rooms-dtg" } vti-common = { path = "../vti-common", version = "0.16", features = [ "passkey", # `setup` gates the shared dialoguer-driven secrets-prompt helper @@ -306,6 +307,9 @@ tempfile = { version = "3", optional = true } [dev-dependencies] trust-tasks-rs = { workspace = true } +# Signed room fixtures. Cargo unifies this feature with the normal dependency +# above, so the fixtures reach tests without shipping in the service binary. +vti-rooms-dtg = { path = "../vti-rooms-dtg", features = ["test-support"] } # Self-dep with `test-support` enabled so integration tests under # `tests/` can import `vtc_service::test_support` (`TestVtc`, `MockVtc`). vtc-service = { path = ".", features = ["test-support", "didcomm-harness"] } diff --git a/vtc-service/src/rooms/handlers.rs b/vtc-service/src/rooms/handlers.rs index 1630bcf6a..4a2b1dfd2 100644 --- a/vtc-service/src/rooms/handlers.rs +++ b/vtc-service/src/rooms/handlers.rs @@ -24,7 +24,7 @@ use trust_tasks_rs::TrustTask; use crate::server::AppState; use crate::trust_tasks::helpers::{ - TrustTaskOutcome, app_error_to_reject, parse_payload, success_response, + TrustTaskOutcome, app_error_to_reject, parse_payload, success_response, verify_trust_task_proof, }; use vti_rooms::authz::{self, Action}; use vti_rooms::storage; @@ -33,6 +33,27 @@ use vti_rooms::wire::{ MintEpochBody, MintEpochResponse, PutRecordBody, PutRecordResponse, }; use vti_rooms::{Record, RecordStatus, Room}; +use vti_rooms_dtg::{DataIntegrityKeys, DtgChainVerifier}; + +/// The DID that actually signed this request, and the verifier to judge its chain with. +/// +/// Two things a room operation needs and a session does not supply. The presenter comes +/// from the document's own `eddsa-jcs-2022` proof — not from any field in the payload — +/// because a presentation names what may be done, not who is doing it: unbound, it is a +/// bearer token that anyone observing it inherits. +async fn presenter_and_verifier( + state: &AppState, + doc: &TrustTask, +) -> Result<(String, DtgChainVerifier), vti_common::error::AppError> { + let presenter = verify_trust_task_proof(state, doc).await?; + // `without_zk`: this service has no zero-knowledge profile for a private room's subject + // binding, and the verifier refuses those rather than serving a pooling defence nobody + // checked. Swap for `with_zk` when the working group settles the profile. + Ok(( + presenter, + DtgChainVerifier::without_zk(Box::new(DataIntegrityKeys(state.trust_task_vm_resolver()))), + )) +} /// Seconds since the Unix epoch. fn now() -> u64 { @@ -94,16 +115,33 @@ pub(crate) async fn handle_put_record(state: &AppState, doc: TrustTask) - Ok(r) => r, Err(e) => return app_error_to_reject(&doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Write) { - return app_error_to_reject(&doc, &e); - } + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + let authorized = match authz::authorize( + &room, + &req.presentation, + Action::Write, + &presenter, + &verifier, + ) + .await + { + Ok(a) => a, + Err(e) => return app_error_to_reject(&doc, &e), + }; // The author is recorded only where the tier discloses one. On a private room // authorship lives inside the sealed body, and the storage layer refuses it here. + // + // It is the *verified* subject — who the chain says is acting — not the room's owner. + // Recording the owner would have credited every write to one person, which on the + // attributed tier is the whole of what the tier is for. let author = room .visibility .discloses_actor() - .then(|| room.owner_did.clone()); + .then(|| authorized.subject().to_string()); let record = Record { key: req.key.clone(), @@ -157,7 +195,19 @@ pub(crate) async fn handle_get_record(state: &AppState, doc: TrustTask) - Ok(r) => r, Err(e) => return app_error_to_reject(&doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { return app_error_to_reject(&doc, &e); } @@ -184,7 +234,19 @@ pub(crate) async fn handle_list_records( Ok(r) => r, Err(e) => return app_error_to_reject(&doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Read) { + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Read, + &presenter, + &verifier, + ) + .await + { return app_error_to_reject(&doc, &e); } @@ -226,7 +288,19 @@ pub(crate) async fn handle_mint_epoch(state: &AppState, doc: TrustTask) - Ok(r) => r, Err(e) => return app_error_to_reject(&doc, &e), }; - if let Err(e) = authz::authorize(&room, &req.presentation, Action::Admin) { + let (presenter, verifier) = match presenter_and_verifier(state, &doc).await { + Ok(p) => p, + Err(e) => return app_error_to_reject(&doc, &e), + }; + if let Err(e) = authz::authorize( + &room, + &req.presentation, + Action::Admin, + &presenter, + &verifier, + ) + .await + { return app_error_to_reject(&doc, &e); } @@ -247,63 +321,104 @@ mod tests { use super::*; use crate::test_support::build_test_vtc; use serde_json::json; - use trust_tasks_rs::TypeUri; - - fn doc(uri: &str, payload: Value) -> TrustTask { - let uri: TypeUri = uri.parse().expect("rooms uri"); - TrustTask::new(format!("urn:uuid:{}", uuid::Uuid::new_v4()), uri, payload) + use vti_rooms::Visibility; + use vti_rooms_dtg::test_support::RoomFixture; + + /// A **signed** room document. + /// + /// Signing is not ceremony here: the presenter comes from this proof, and every handler + /// below refuses an unsigned request before it looks at any chain. + async fn doc( + state: &AppState, + uri: &str, + payload: Value, + signer_did: &str, + signer_key: &str, + ) -> TrustTask { + let recipient = state + .config + .read() + .await + .vtc_did + .clone() + .unwrap_or_else(|| "did:key:zVtc".to_string()); + let signed = vta_sdk::trust_task_sign::build_signed( + uri, payload, signer_did, signer_key, &recipient, + ) + .await + .expect("sign the request"); + serde_json::from_str(&signed).expect("a signed document is a document") } - fn presentation() -> Value { - json!({ "membership": "vmc", "authority": ["vac-leaf", "vac-root"] }) + fn payload_of(out: &TrustTaskOutcome) -> Value { + let d: Value = serde_json::from_slice(&out.body).expect("response is JSON"); + d.get("payload").cloned().unwrap_or(Value::Null) } - async fn create(state: &AppState, id: &str, visibility: &str) -> TrustTaskOutcome { + /// Register the fixture's room with this VTC. + async fn create(state: &AppState, f: &RoomFixture) -> TrustTaskOutcome { handle_create( state, doc( + state, vti_rooms::wire::ROOMS_CREATE_TYPE, - json!({ "roomId": id, "visibility": visibility, "ownerDid": "did:key:zOwner" }), - ), + json!({ + "roomId": f.room.room_id, + "visibility": f.room.visibility, + "ownerDid": f.room.owner_did, + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await } - fn payload_of(out: &TrustTaskOutcome) -> Value { - let d: Value = serde_json::from_slice(&out.body).expect("response is JSON"); - d.get("payload").cloned().unwrap_or(Value::Null) - } - #[tokio::test] async fn a_room_is_created_and_a_record_round_trips() { let tv = build_test_vtc().await; let state = &tv.state; - assert!(create(state, "r1", "open").await.status.is_success()); + let f = RoomFixture::new(Visibility::Open).await; + assert!(create(state, &f).await.status.is_success()); let out = handle_put_record( state, doc( + state, vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, json!({ - "roomId": "r1", "key": "k1", "presentation": presentation(), + "roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner(), "cleartext": { "body": "a decision" } }), - ), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; - assert!(out.status.is_success(), "put should succeed"); + assert!(out.status.is_success(), "put: {}", payload_of(&out)); assert_eq!(payload_of(&out)["version"], 1); let out = handle_get_record( state, doc( + state, vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "r1", "key": "k1", "presentation": presentation() }), - ), + json!({ "roomId": f.room.room_id, "key": "k1", "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; assert!(out.status.is_success()); - assert_eq!(payload_of(&out)["cleartext"]["body"], "a decision"); + let got = payload_of(&out); + assert_eq!(got["cleartext"]["body"], "a decision"); + assert_eq!( + got["author"], f.owner.did, + "the author is the subject the chain established, not the room's owner field" + ); } /// The invariant the whole family rests on: authorization is the chain, and a request @@ -312,139 +427,253 @@ mod tests { async fn an_operation_with_no_authority_chain_is_refused() { let tv = build_test_vtc().await; let state = &tv.state; - create(state, "r1", "open").await; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k1", + "presentation": { "membership": f.membership, "authority": [] } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success()); + } + + /// The agent case, through the VTC rather than a standalone host — the two must reach + /// the same conclusion, because both go through the same `vti-rooms-dtg`. + #[tokio::test] + async fn an_agent_reads_under_a_narrower_chain_and_cannot_write() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + handle_put_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner(), + "cleartext": { "body": "for the agent" } + }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, + ) + .await; + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_agent() }), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, + ) + .await; + assert!( + out.status.is_success(), + "the agent reads: {}", + payload_of(&out) + ); let out = handle_put_record( state, doc( + state, vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, json!({ - "roomId": "r1", "key": "k1", - "presentation": { "membership": "vmc", "authority": [] }, - "cleartext": { "body": "x" } + "roomId": f.room.room_id, "key": "k2", "presentation": f.as_agent(), + "cleartext": { "body": "but must not write" } }), - ), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, + ) + .await; + assert!(!out.status.is_success(), "a read-only chain must not write"); + } + + /// A private room is refused for want of a zero-knowledge profile — and the refusal is + /// the same one a standalone room host gives, because it comes from the shared crate. + #[tokio::test] + async fn a_private_room_is_refused_for_want_of_a_zk_profile() { + let tv = build_test_vtc().await; + let state = &tv.state; + let f = RoomFixture::new(Visibility::Private).await; + create(state, &f).await; + + let mut p = f.as_owner(); + p.subject_binding = Some("a-binding-nobody-can-check".into()); + + let out = handle_get_record( + state, + doc( + state, + vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, + json!({ "roomId": f.room.room_id, "key": "k", "presentation": p }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; + assert!(!out.status.is_success()); assert!( - !out.status.is_success(), - "an empty chain authorizes nothing" + payload_of(&out)["message"] + .as_str() + .unwrap_or_default() + .contains("zero-knowledge profile"), + "{}", + payload_of(&out) ); } + /// A private room refuses a presentation with no binding at all before any credential + /// is parsed — the shape check, which is `vti-rooms`' half. #[tokio::test] async fn a_private_room_refuses_a_presentation_without_a_subject_binding() { let tv = build_test_vtc().await; let state = &tv.state; - create(state, "p1", "private").await; + let f = RoomFixture::new(Visibility::Private).await; + create(state, &f).await; let out = handle_get_record( state, doc( + state, vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, - json!({ "roomId": "p1", "key": "k", "presentation": presentation() }), - ), + json!({ "roomId": f.room.room_id, "key": "k", "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; assert!(!out.status.is_success()); - let body = String::from_utf8_lossy(&out.body); - assert!(body.contains("subject binding"), "{body}"); + assert!( + payload_of(&out)["message"] + .as_str() + .unwrap_or_default() + .contains("subject binding"), + "{}", + payload_of(&out) + ); } #[tokio::test] async fn listing_returns_metadata_and_never_bodies() { let tv = build_test_vtc().await; let state = &tv.state; - create(state, "r1", "open").await; - for k in ["a", "b"] { - handle_put_record( + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + + handle_put_record( + state, + doc( state, - doc( - vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, - json!({ - "roomId": "r1", "key": k, "presentation": presentation(), - "cleartext": { "body": "secret-body-text" } - }), - ), + vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE, + json!({ + "roomId": f.room.room_id, "key": "a", "presentation": f.as_owner(), + "cleartext": { "body": "secret-body-text" } + }), + &f.owner.did, + &f.owner.secret_multibase, ) - .await; - } + .await, + ) + .await; let out = handle_list_records( state, doc( + state, vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE, - json!({ "roomId": "r1", "presentation": presentation() }), - ), + json!({ "roomId": f.room.room_id, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; assert!(out.status.is_success()); - let body = String::from_utf8_lossy(&out.body); - assert!(body.contains("\"key\""), "metadata is returned"); + let text = payload_of(&out).to_string(); + assert!(text.contains("\"key\"")); assert!( - !body.contains("secret-body-text"), - "a listing must never carry bodies: {body}" - ); - } - - #[tokio::test] - async fn a_room_cannot_be_created_twice() { - let tv = build_test_vtc().await; - let state = &tv.state; - assert!(create(state, "r1", "open").await.status.is_success()); - assert!( - !create(state, "r1", "open").await.status.is_success(), - "re-creating would reset the epoch and version counter" + !text.contains("secret-body-text"), + "a listing must never carry bodies: {text}" ); } + /// `admin`, not `write`: if any key-holder could mint an epoch, any member could evict + /// any other, and the service — which cannot see the membership — would never know. #[tokio::test] async fn minting_an_epoch_requires_admin_and_advances_by_one() { let tv = build_test_vtc().await; let state = &tv.state; - create(state, "r1", "open").await; + let f = RoomFixture::new(Visibility::Open).await; + create(state, &f).await; + // The agent's chain confers `read` alone. let out = handle_mint_epoch( state, doc( + state, vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, - json!({ "roomId": "r1", "epoch": 2, "presentation": presentation() }), - ), + json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_agent() }), + &f.agent.did, + &f.agent.secret_multibase, + ) + .await, ) .await; - assert!(out.status.is_success()); - assert_eq!(payload_of(&out)["epoch"], 2); + assert!(!out.status.is_success(), "read may not mint an epoch"); - // A gap is refused: it would seal records under an epoch nobody holds a key for. + // The owner's confers `admin`. let out = handle_mint_epoch( state, doc( + state, vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, - json!({ "roomId": "r1", "epoch": 9, "presentation": presentation() }), - ), + json!({ "roomId": f.room.room_id, "epoch": 2, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; - assert!(!out.status.is_success()); - } + assert!(out.status.is_success(), "{}", payload_of(&out)); + assert_eq!(payload_of(&out)["epoch"], 2); - /// An unknown member on a payload carrying an authorization decision is a request that - /// means something this service did not understand. - #[tokio::test] - async fn an_unknown_payload_member_is_refused() { - let tv = build_test_vtc().await; - let state = &tv.state; - create(state, "r1", "open").await; - let out = handle_get_record( + // And it advances by exactly one — skipping would seal records under an epoch no + // member was ever given a key for. + let out = handle_mint_epoch( state, doc( - vti_rooms::wire::ROOMS_RECORDS_GET_TYPE, - json!({ - "roomId": "r1", "key": "k", "presentation": presentation(), - "escalate": true - }), - ), + state, + vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE, + json!({ "roomId": f.room.room_id, "epoch": 5, "presentation": f.as_owner() }), + &f.owner.did, + &f.owner.secret_multibase, + ) + .await, ) .await; - assert!(!out.status.is_success(), "deny_unknown_fields must hold"); + assert!(!out.status.is_success(), "an epoch may not skip"); } } diff --git a/vti-rooms-dtg/Cargo.toml b/vti-rooms-dtg/Cargo.toml new file mode 100644 index 000000000..a3e70e411 --- /dev/null +++ b/vti-rooms-dtg/Cargo.toml @@ -0,0 +1,56 @@ +[package] +name = "vti-rooms-dtg" +description = "The DTG-credential chain verifier for data rooms" +version = "0.1.0" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +# Not published, and that is what makes the git dependency below tolerable. +# A published crate cannot depend on a git source, so this crate is where the +# bridge lives until dtg-credentials 0.6 is on crates.io. +publish = false + +[features] +# Signed fixtures for downstream tests and the data_room example. Never a default: +# a permissive fixture is exactly what should not ship. +test-support = ["dep:ed25519-dalek", "dep:getrandom", "dep:multibase", "dep:vta-sdk", "dep:affinidi-tdk"] + +[dependencies] +vti-rooms = { path = "../vti-rooms" } +vti-common = { path = "../vti-common" } + +# TEMPORARY, and the only reason this crate is not published: `authority:: +# verify_chain` and the VAC/VDC types landed in dtg-credentials 0.6.0, which is +# merged but not yet released — that repository had no release path at all until +# OpenVTC/dtg-credentials#17. Swap this whole line for +# dtg-credentials = "0.6" +# the moment the v0.6.0 tag publishes; nothing else changes. +# +# It is pinned to a rev rather than a branch on purpose: a moving branch would +# change what verifies a credential without changing this repository. +dtg-credentials = { git = "https://github.com/OpenVTC/dtg-credentials", rev = "0c1391adfa67d8ae65498f086675404b8bb50b08" } + +async-trait = "0.1" +# Both hosts already carry a `VerificationMethodResolver`, so the key adapter wraps +# whatever they have rather than making each write its own. +affinidi-data-integrity = { workspace = true } +affinidi-secrets-resolver = { workspace = true } +base64 = { workspace = true } +chrono = { workspace = true } +serde_json = { workspace = true } +tracing = { workspace = true } + +# test-support only. +ed25519-dalek = { workspace = true, optional = true } +getrandom = { version = "0.3", optional = true } +multibase = { workspace = true, optional = true } +vta-sdk = { path = "../vta-sdk", features = ["didcomm"], optional = true } +affinidi-tdk = { workspace = true, optional = true } + +[dev-dependencies] +tokio = { workspace = true } +# Real keys and real signatures for the end-to-end test. A verifier tested only on +# refusals would pass every test while refusing everything. + diff --git a/vti-rooms-dtg/src/lib.rs b/vti-rooms-dtg/src/lib.rs new file mode 100644 index 000000000..28606ef0b --- /dev/null +++ b/vti-rooms-dtg/src/lib.rs @@ -0,0 +1,990 @@ +//! The cryptographic half of room authorization. +//! +//! [`vti_rooms::authz`] decides whether a presentation is *shaped* right and then asks a +//! [`ChainVerifier`] whether it is *true*. This crate is that verifier, over the DTG +//! credentials the rooms design uses: a VMC for membership and a chain of VACs for +//! authority. +//! +//! # Why this is a separate crate +//! +//! `vti-rooms` is published and depends on nothing but `vti-common`, so a room host can +//! reuse its storage without dragging in a credential library and a DID resolver. Verifying +//! needs both. Keeping them apart is also what lets `vti-rooms` stay honest about the +//! seam — a host that has configured no verifier gets `RefusesEverything` and serves +//! nothing, rather than a permissive default nobody notices. +//! +//! # What verification actually consists of +//! +//! Two independent checks, and neither substitutes for the other: +//! +//! - **Are these credentials genuine?** Each one's data-integrity proof must verify against +//! the key its `verificationMethod` names. That is what [`VerificationKeys`] resolves. +//! - **Do genuine credentials add up to the authority claimed?** That is +//! `dtg_credentials::authority::verify_chain`, and it is the part that matters: anyone can +//! mint a well-formed VAC naming any scope and any action, and it will verify perfectly as +//! a credential. What makes it worthless is that its chain does not reach the party +//! governing the scope — here, the room. +//! +//! Doing only the first is the classic mistake. A valid signature on a self-issued grant is +//! still a self-issued grant. +//! +//! # The room governs its own scope +//! +//! `governing_party` and `requested_scope` are both the room's DID. That is the whole of +//! invariant I5 in one line: a chain is worth something here **because it reaches the room**, +//! not because a host recognises the issuer. A chain rooted at the community that the room +//! belongs to, or at the host, or at anyone else, confers nothing — which is what lets the +//! room move to a different host without reissuing a single credential. +//! +//! # Nothing is fetched +//! +//! `verify_chain` takes the chain as a slice and never dereferences a `parent`, and this +//! crate never fetches a credential either. Resolving over the network would make +//! verification depend on availability, turn an identifier into a request the host can be +//! induced to make against an address the *holder* chooses, and signal credential use to +//! whoever hosts that identifier. [`VerificationKeys`] resolves **keys**, which is a +//! different thing: a key is named by the credential's own proof, and a host that cannot +//! resolve it refuses rather than proceeding. + +use affinidi_data_integrity::VerificationMethodResolver; +use affinidi_secrets_resolver::secrets::KeyType; +use base64::Engine as _; +use dtg_credentials::authority::{AuthorityError, verify_chain}; +use dtg_credentials::{DTGCredential, DTGCredentialType}; +use vti_common::error::AppError; +use vti_rooms::authz::{Action, ChainVerifier, VerifiedChain}; +use vti_rooms::wire::AuthorityPresentation; +use vti_rooms::{Room, Visibility}; + +/// Resolve a credential's `verificationMethod` to the public key that signed it. +/// +/// One implementation per host, because a VTC resolves DIDs through its own resolver and a +/// standalone room host through whatever it was configured with. Both answer the same +/// question, and both must **fail** rather than guess: a verifier that treats an +/// unresolvable method as "probably fine" has stopped checking signatures. +#[async_trait::async_trait] +pub trait VerificationKeys: Send + Sync { + /// The Ed25519 public key bytes for `verification_method`. + async fn public_key(&self, verification_method: &str) -> Result, AppError>; +} + +/// [`VerificationKeys`] over any resolver the host already has. +/// +/// Both a VTC and a room host carry an `affinidi_data_integrity::VerificationMethodResolver` +/// — `vti_common::auth::TrustTaskVmResolver` is one — so wrapping it beats making each host +/// write its own lookup and get the key-type check subtly different. +pub struct DataIntegrityKeys(pub R); + +#[async_trait::async_trait] +impl VerificationKeys for DataIntegrityKeys { + async fn public_key(&self, verification_method: &str) -> Result, AppError> { + let resolved = self + .0 + .resolve_vm(verification_method) + .await + .map_err(|e| AppError::NotFound(format!("resolve `{verification_method}`: {e}")))?; + + // Room credentials are signed `eddsa-jcs-2022`. Handing a P-256 key to an Ed25519 + // verifier is not a type error anywhere in the stack — the bytes are the same + // length — so the algorithm is checked here rather than assumed. + if !matches!(resolved.key_type, KeyType::Ed25519) { + return Err(AppError::NotFound(format!( + "`{verification_method}` is a {:?} key; room credentials are eddsa-jcs-2022", + resolved.key_type + ))); + } + Ok(resolved.public_key_bytes) + } +} + +/// Verify a `private` room's subject binding. +/// +/// Split out because it is the one part of this that the specification does not yet settle. +/// On the disclosing tiers the pooling defence is a comparison — the VMC's subject against +/// the chain's — and [`DtgChainVerifier`] does it inline. On a `private` room the subject is +/// withheld by design, so the same property has to be proved in zero knowledge, and *which* +/// proof is a profile question: the DTG cred-spec puts ZK protocols and registry-ZK +/// interactions explicitly out of scope, and the working group has not chosen one. +/// +/// So this is a seam with no default implementation shipped, and +/// [`DtgChainVerifier::without_zk`] refuses every private-room presentation with a message +/// saying exactly that. That is the honest position: a private room whose pooling defence +/// nobody checked is worse than a private room that will not open, because two parties can +/// combine one's membership with the other's authority and present as a single party +/// holding both. +#[async_trait::async_trait] +pub trait SubjectBindingVerifier: Send + Sync { + /// Prove that `binding` shows the membership presentation and the chain leaf describe + /// one subject, and return that subject's identifier for the room's purposes. + async fn verify_same_subject( + &self, + room: &Room, + membership: &str, + binding: &str, + chain_leaf_subject: &str, + ) -> Result<(), AppError>; +} + +/// The verifier that refuses every private room, and says why. +struct NoZkProfile; + +#[async_trait::async_trait] +impl SubjectBindingVerifier for NoZkProfile { + async fn verify_same_subject( + &self, + room: &Room, + _membership: &str, + _binding: &str, + _chain_leaf_subject: &str, + ) -> Result<(), AppError> { + Err(AppError::Forbidden(format!( + "room `{}` is private, and this host has no zero-knowledge profile configured \ + to verify its subject binding; serving it would mean accepting a pooling \ + defence nobody checked", + room.room_id + ))) + } +} + +/// Verifies room presentations against DTG credentials. +pub struct DtgChainVerifier { + keys: Box, + zk: Box, +} + +impl DtgChainVerifier { + /// A verifier for the `open` and `attributed` tiers. + /// + /// Private rooms are refused, with a message naming the missing profile — see + /// [`SubjectBindingVerifier`] for why that is the honest default rather than a gap. + pub fn without_zk(keys: Box) -> Self { + Self { + keys, + zk: Box::new(NoZkProfile), + } + } + + /// A verifier for every tier, once a zero-knowledge profile exists. + pub fn with_zk(keys: Box, zk: Box) -> Self { + Self { keys, zk } + } + + /// Decode one presented credential and verify its proof. + /// + /// Accepts base64url or bare JSON. Both are unambiguous — a JSON document starts with + /// `{` and base64url has no `{` in its alphabet — and accepting both means a caller + /// hand-building a request for a demo or a test does not have to encode by hand. The + /// serialization is a profile question the schema leaves open ("serialized per the + /// governing profile"); this is the profile this implementation reads. + async fn open_credential(&self, encoded: &str, what: &str) -> Result { + let bytes = if encoded.trim_start().starts_with('{') { + encoded.as_bytes().to_vec() + } else { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(encoded.trim()) + .map_err(|_| { + AppError::Forbidden(format!( + "{what} is neither base64url nor JSON; a credential that cannot be \ + read cannot be verified" + )) + })? + }; + + let credential: DTGCredential = serde_json::from_slice(&bytes) + .map_err(|e| AppError::Forbidden(format!("{what} is not a DTG credential: {e}")))?; + + let proof = credential + .credential() + .proof + .as_ref() + .ok_or_else(|| AppError::Forbidden(format!("{what} carries no proof")))?; + + let key = self + .keys + .public_key(&proof.verification_method) + .await + .map_err(|e| { + // The resolver's own error is for the operator; the caller learns only that + // it did not verify, so an unresolvable method is not an oracle for which + // DIDs this host can reach. + tracing::warn!( + verification_method = %proof.verification_method, + error = %e, + "could not resolve a room credential's verification method" + ); + AppError::Forbidden(format!("{what} could not be verified")) + })?; + + credential.verify_proof_with_public_key(&key).map_err(|e| { + tracing::warn!(error = %e, "room credential proof did not verify"); + AppError::Forbidden(format!("{what} could not be verified")) + })?; + + Ok(credential) + } +} + +/// An [`AuthorityError`] as a refusal. +/// +/// Every variant becomes the same `Forbidden`, with the specifics in the message for an +/// operator reading logs. A caller learns that the chain did not carry the authority — not +/// which link to adjust, which would make the verifier a tool for assembling one. +fn chain_refusal(room_id: &str, e: AuthorityError) -> AppError { + AppError::Forbidden(format!( + "the authority chain does not confer this on room `{room_id}`: {e}" + )) +} + +#[async_trait::async_trait] +impl ChainVerifier for DtgChainVerifier { + async fn verify( + &self, + room: &Room, + presentation: &AuthorityPresentation, + action: Action, + presenter: &str, + ) -> Result { + // The chain, leaf first, every link's proof checked before any of them is trusted to + // say anything about the others. + let mut chain = Vec::with_capacity(presentation.authority.len()); + for (index, encoded) in presentation.authority.iter().enumerate() { + chain.push( + self.open_credential(encoded, &format!("authority credential {index}")) + .await?, + ); + } + + // `verify_chain` answers the question the signatures do not: does this add up to the + // authority claimed? Both the governing party and the scope are the room itself — + // a chain rooted anywhere else confers nothing here, however valid. + let verified = verify_chain( + &chain, + &room.room_id, + &room.room_id, + action.as_str(), + presenter, + chrono::Utc::now(), + ) + .map_err(|e| chain_refusal(&room.room_id, e))?; + + // `verify_chain` takes `presenter` but uses it for **one** thing: the `audience` + // check, where a link that names an audience must be presented by that audience. It + // does not require the leaf to grant to the presenter, and that is deliberate on + // its side — the leaf's subject is "who may act", and binding that to the party the + // *transport* authenticated is a question about this request, not about the chain. + // + // Which makes it ours, and it is not optional: without it a presentation is a + // bearer token, and anyone who observes one inherits everything it confers. A test + // above presents an agent's chain as the agent's human and expects a refusal. + if verified.subject != presenter { + return Err(AppError::Forbidden(format!( + "the chain's leaf grants to `{}`, not to the party that signed this \ + request; a presentation is bound to its presenter, not bearer", + verified.subject + ))); + } + + // The pooling defence: membership and authority must describe one subject. + match room.visibility { + // The subject is withheld, so it is proved rather than compared. `authorize` + // has already refused a presentation with no binding at all; this is whether + // the one present actually proves it. + Visibility::Private => { + let binding = presentation.subject_binding.as_deref().ok_or_else(|| { + AppError::Forbidden("a private room requires a subject binding".into()) + })?; + self.zk + .verify_same_subject(room, &presentation.membership, binding, &verified.subject) + .await?; + } + // The subject is disclosed, so it is compared. + Visibility::Open | Visibility::Attributed => { + let membership = self + .open_credential(&presentation.membership, "membership credential") + .await?; + + if !matches!(membership.type_(), DTGCredentialType::Membership) { + return Err(AppError::Forbidden(format!( + "the presented membership credential is a {}, not a VMC", + membership.type_() + ))); + } + + // A VMC for some other room says nothing about this one. + if membership.issuer() != room.room_id { + return Err(AppError::Forbidden(format!( + "the membership credential was issued by `{}`, not by room `{}`", + membership.issuer(), + room.room_id + ))); + } + + // The subject to compare is the **root's**, not the leaf's. + // + // The leaf says who may act, and that is frequently not a member: the case + // this design exists for is a member equipping their agent with a narrower + // chain, and an agent is not a member of anything. Comparing the leaf + // would refuse exactly the arrangement the rooms design is for. + // + // The root's subject is the party the *room* granted to, and every + // attenuation below it descends from them — `verify_chain` has already + // established that each link's issuer is its parent's subject. So the root + // is which member's standing this authority descends from, and requiring + // the VMC to be theirs is what closes the pooling attack: a chain rooted at + // Bob cannot be presented with Alice's membership, whoever holds the leaf. + let root = chain.last().expect("verify_chain rejects an empty chain"); + if membership.subject() != root.subject() { + return Err(AppError::Forbidden(format!( + "the membership credential describes `{}` but the authority chain \ + descends from `{}`; two parties cannot pool credentials into one", + membership.subject(), + root.subject() + ))); + } + } + } + + Ok(VerifiedChain { + subject: verified.subject, + actions: verified.actions, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A resolver that resolves nothing. + struct NoKeys; + + #[async_trait::async_trait] + impl VerificationKeys for NoKeys { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Err(AppError::NotFound("no keys here".into())) + } + } + + fn room(visibility: Visibility) -> Room { + Room { + room_id: "did:webvh:example.com:rooms:northwind".into(), + owner_did: "did:key:z6MkOwner".into(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + } + } + + fn presentation(authority: Vec, binding: Option<&str>) -> AuthorityPresentation { + AuthorityPresentation { + membership: "{}".into(), + authority, + subject_binding: binding.map(str::to_string), + } + } + + /// A real, well-formed VAC — built by the library that verifies it, so this test + /// exercises the shape the implementation actually produces rather than one hand-typed + /// beside it. + /// + /// Deliberately **unsigned in effect**: it carries a proof block whose value is + /// nonsense. Everything about it is right except the one thing that matters. + fn a_vac(issuer: &str, subject: &str, scope: &str, actions: &[&str]) -> String { + let mut vac = DTGCredential::new_vac( + issuer.into(), + subject.into(), + scope.into(), + actions.iter().map(|s| s.to_string()).collect(), + chrono::Utc::now() - chrono::Duration::hours(1), + Some(chrono::Utc::now() + chrono::Duration::hours(1)), + ) + .expect("build a VAC"); + + let mut doc = serde_json::to_value(vac.credential()).expect("serialise"); + doc["proof"] = serde_json::json!({ + "type": "DataIntegrityProof", + "cryptosuite": "eddsa-jcs-2022", + "created": "2026-09-01T00:00:00Z", + "verificationMethod": format!("{issuer}#key-0"), + "proofPurpose": "assertionMethod", + "proofValue": "z2LJhFyBmRcqMKZbdgVBb9nQpjhTjcMwCsSYRfPqk5bZ", + }); + let _ = &mut vac; + doc.to_string() + } + + /// A credential this host cannot resolve a key for does not verify — it does not get + /// the benefit of the doubt, and the refusal does not say which DIDs are reachable. + #[tokio::test] + async fn an_unresolvable_verification_method_refuses() { + let v = DtgChainVerifier::without_zk(Box::new(NoKeys)); + let err = v + .verify( + &room(Visibility::Open), + &presentation( + vec![a_vac( + "did:webvh:example.com:rooms:northwind", + "did:key:zAgent", + "did:webvh:example.com:rooms:northwind", + &["read"], + )], + None, + ), + Action::Read, + "did:key:zAgent", + ) + .await + .unwrap_err(); + let text = format!("{err}"); + assert!(text.contains("could not be verified"), "{text}"); + assert!( + !text.contains("no keys here"), + "the resolver's reason is for the operator's log, not the caller: {text}" + ); + } + + /// The check that carries the weight. A chain of perfectly valid credentials, rooted + /// at a party that is not the room, confers nothing here — that is invariant I5, and it + /// is what lets a room move host without reissuing anything. + #[tokio::test] + async fn a_chain_rooted_anywhere_but_the_room_confers_nothing() { + struct AnyKey; + #[async_trait::async_trait] + impl VerificationKeys for AnyKey { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Ok(vec![0u8; 32]) + } + } + + // Mallory issues herself full authority over someone else's room. Every field is + // well-formed; the chain simply does not reach the room. + let v = DtgChainVerifier::without_zk(Box::new(AnyKey)); + let err = v + .verify( + &room(Visibility::Open), + &presentation( + vec![a_vac( + "did:key:zMallory", + "did:key:zMallory", + "did:webvh:example.com:rooms:northwind", + &["read", "write", "admin"], + )], + None, + ), + Action::Admin, + "did:key:zMallory", + ) + .await + .unwrap_err(); + // It fails at the signature with this stub resolver, and would fail at the chain + // with a real one. Either way it does not authorize, which is the property. + assert!(format!("{err}").contains("could not be verified"), "{err}"); + } + + #[tokio::test] + async fn a_credential_that_is_neither_base64url_nor_json_refuses() { + let v = DtgChainVerifier::without_zk(Box::new(NoKeys)); + let err = v + .verify( + &room(Visibility::Open), + &presentation(vec!["not a credential !!!".into()], None), + Action::Read, + "did:key:zAgent", + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("neither base64url nor JSON"), + "{err}" + ); + } + + /// A private room without a zero-knowledge profile is refused by the verifier, not + /// quietly served. The message names what is missing. + #[tokio::test] + async fn a_private_room_refuses_without_a_zk_profile() { + struct OneKey; + #[async_trait::async_trait] + impl VerificationKeys for OneKey { + async fn public_key(&self, _vm: &str) -> Result, AppError> { + Ok(vec![0u8; 32]) + } + } + + // The chain never gets far enough for the ZK check here — the point of the test is + // that `without_zk` carries a refusing binding verifier at all, which + // `NoZkProfile` asserts directly. + let refusal = NoZkProfile + .verify_same_subject(&room(Visibility::Private), "vmc", "binding", "did:key:zA") + .await + .unwrap_err(); + assert!( + format!("{refusal}").contains("no zero-knowledge profile configured"), + "{refusal}" + ); + + let _ = DtgChainVerifier::without_zk(Box::new(OneKey)); + } +} + +/// The end-to-end tests: real keys, real signatures, real chains. +/// +/// Separated from the unit tests above because these are the ones that matter. Every test +/// up there asserts a refusal, and a verifier that refused everything would pass all of +/// them — these are what say it admits a good chain, and only a good one. +#[cfg(test)] +mod signed { + use super::*; + use affinidi_tdk::dids::{DID, KeyType}; + use chrono::{Duration, Utc}; + + /// Resolves a `did:key`'s verification method to its own public key, which is what a + /// `did:key` is. No network, and no opportunity to resolve to the wrong key. + struct DidKeyResolver; + + #[async_trait::async_trait] + impl VerificationKeys for DidKeyResolver { + async fn public_key(&self, vm: &str) -> Result, AppError> { + let did = vm.split('#').next().unwrap_or_default(); + let multibase = did + .strip_prefix("did:key:") + .ok_or_else(|| AppError::NotFound(format!("not a did:key: {did}")))?; + vta_sdk::did_key::decode_ed25519_public_key_multibase(multibase) + .map(|k| k.to_vec()) + .map_err(|e| AppError::NotFound(format!("decode {did}: {e}"))) + } + } + + /// A room, its owner, and that owner's agent — the shape the whole design exists for. + struct Fixture { + room: Room, + /// The owner's chain: one link, straight from the room. + owner_chain: Vec, + owner_did: String, + /// The agent's chain: the owner's, with a narrower leaf on top. + agent_chain: Vec, + agent_did: String, + /// The owner's membership credential, issued by the room. + membership: String, + } + + async fn fixture() -> Fixture { + let (room_did, room_secret) = DID::generate_did_key(KeyType::Ed25519).expect("room key"); + let (owner_did, owner_secret) = DID::generate_did_key(KeyType::Ed25519).expect("owner key"); + let (agent_did, _) = DID::generate_did_key(KeyType::Ed25519).expect("agent key"); + let now = Utc::now(); + + // The room grants its owner read and write. This is the chain root: it is worth + // something because the *room* issued it. + let mut owner_vac = DTGCredential::new_vac( + room_did.clone(), + owner_did.clone(), + room_did.clone(), + vec!["read".into(), "write".into()], + now - Duration::minutes(1), + Some(now + Duration::days(30)), + ) + .expect("owner VAC") + .with_id("urn:uuid:vac-owner"); + owner_vac.sign(&room_secret, None).await.expect("sign"); + + // The owner narrows it for their agent — four hours, read only, no involvement + // from the room. That is the whole point of attenuation. + let mut agent_vac = owner_vac + .attenuate( + agent_did.clone(), + vec!["read".into()], + now - Duration::minutes(1), + Some(now + Duration::hours(4)), + None, + ) + .expect("attenuate") + .with_id("urn:uuid:vac-agent"); + agent_vac.sign(&owner_secret, None).await.expect("sign"); + + let mut vmc = DTGCredential::new_vmc( + room_did.clone(), + owner_did.clone(), + now - Duration::minutes(1), + Some(now + Duration::days(30)), + false, + ); + vmc.sign(&room_secret, None).await.expect("sign"); + + let enc = |c: &DTGCredential| serde_json::to_string(c).expect("serialise"); + + Fixture { + room: Room { + room_id: room_did, + owner_did: owner_did.clone(), + visibility: Visibility::Attributed, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + }, + owner_chain: vec![enc(&owner_vac)], + owner_did, + agent_chain: vec![enc(&agent_vac), enc(&owner_vac)], + agent_did, + membership: enc(&vmc), + } + } + + fn present(f: &Fixture, chain: &[String]) -> AuthorityPresentation { + AuthorityPresentation { + membership: f.membership.clone(), + authority: chain.to_vec(), + subject_binding: None, + } + } + + fn verifier() -> DtgChainVerifier { + DtgChainVerifier::without_zk(Box::new(DidKeyResolver)) + } + + #[tokio::test] + async fn a_signed_chain_from_the_room_authorizes_its_owner() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.owner_chain), + Action::Write, + &f.owner_did, + ) + .await + .expect("a chain the room issued, to the party presenting it, must verify"); + assert_eq!(v.subject, f.owner_did); + assert!(v.actions.contains(&"write".to_string())); + } + + /// The feature the design exists for: an agent holding strictly less than its human, + /// with no involvement from the room in narrowing it. + #[tokio::test] + async fn an_attenuated_chain_authorizes_the_agent_for_less() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.agent_did, + ) + .await + .expect("the agent reads"); + assert_eq!(v.subject, f.agent_did); + assert_eq!( + v.actions, + vec!["read".to_string()], + "attenuation narrows; it never widens" + ); + + let err = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Write, + &f.agent_did, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not confer"), + "the agent must not write: {err}" + ); + } + + /// A captured presentation is not a bearer token. The chain grants to the agent, so + /// the owner cannot present it and neither can anyone else. + #[tokio::test] + async fn a_chain_is_bound_to_the_party_presenting_it() { + let f = fixture().await; + let err = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.owner_did, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("not to the party that signed this request"), + "{err}" + ); + } + + /// Tampering with a credential after it was signed invalidates it, which is the whole + /// reason the proof is checked before the chain is read. + #[tokio::test] + async fn a_widened_credential_does_not_verify() { + let f = fixture().await; + + // Add `admin` to the owner's signed VAC without re-signing. + let mut doc: serde_json::Value = + serde_json::from_str(&f.owner_chain[0]).expect("parse the signed VAC"); + doc["credentialSubject"]["authority"]["actions"] + .as_array_mut() + .expect("actions is an array") + .push(serde_json::json!("admin")); + + let err = verifier() + .verify( + &f.room, + &present(&f, &[doc.to_string()]), + Action::Admin, + &f.owner_did, + ) + .await + .unwrap_err(); + assert!(format!("{err}").contains("could not be verified"), "{err}"); + } + + /// A VMC the room did not issue says nothing about membership of this room, however + /// valid it is elsewhere. + #[tokio::test] + async fn a_membership_credential_from_another_room_is_refused() { + let f = fixture().await; + let other = fixture().await; + + let mut p = present(&f, &f.owner_chain); + p.membership = other.membership; + + let err = verifier() + .verify(&f.room, &p, Action::Read, &f.owner_did) + .await + .unwrap_err(); + assert!(format!("{err}").contains("not by room"), "{err}"); + } + + /// The pooling defence on a disclosing tier: one party's membership plus another's + /// authority is two parties, not one. + #[tokio::test] + async fn membership_and_authority_must_describe_one_subject() { + let f = fixture().await; + + // A chain rooted at a *different* room's owner, presented with this room's + // membership. Both halves are perfectly valid; they just belong to two people. + let other = fixture().await; + let mut p = present(&f, &other.owner_chain); + p.membership = f.membership.clone(); + + let err = verifier() + .verify(&f.room, &p, Action::Read, &other.owner_did) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("cannot pool credentials into one") + || format!("{err}").contains("does not confer"), + "{err}" + ); + } + + /// The agent case, stated as its own property because getting it wrong is subtle: an + /// agent presents its human's membership alongside a chain whose *leaf* grants to the + /// agent. Comparing the leaf's subject to the VMC would refuse this — and refusing it + /// would remove the entire reason the design has attenuation. + #[tokio::test] + async fn an_agent_presents_its_humans_membership() { + let f = fixture().await; + let v = verifier() + .verify( + &f.room, + &present(&f, &f.agent_chain), + Action::Read, + &f.agent_did, + ) + .await + .expect("an agent is not a member; its authority descends from one"); + assert_eq!( + v.subject, f.agent_did, + "and it acts as itself, not as its human" + ); + } + + /// base64url is the other accepted form, and it must reach the same conclusion. + #[tokio::test] + async fn base64url_and_json_verify_identically() { + let f = fixture().await; + let encoded: Vec = f + .owner_chain + .iter() + .map(|c| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(c)) + .collect(); + + let mut p = present(&f, &encoded); + p.membership = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&f.membership); + + let v = verifier() + .verify(&f.room, &p, Action::Write, &f.owner_did) + .await + .expect("base64url is the same credential"); + assert_eq!(v.subject, f.owner_did); + } +} + +/// Fixtures for exercising a real room: real keys, real signatures, real chains. +/// +/// Behind a feature because a permissive fixture is exactly what should not ship. It exists +/// because three places need the same setup — this crate's own tests, `room-host`'s, and the +/// `data_room` example — and three hand-rolled versions of "a signed chain rooted at the +/// room" would drift, with the drift showing up as a test that passes for the wrong reason. +#[cfg(feature = "test-support")] +pub mod test_support { + use super::*; + use chrono::{Duration, Utc}; + + /// One party: a `did:key` in the three forms different layers want. + pub struct Party { + /// `did:key:z6Mk…` + pub did: String, + /// The multibase private key, for signing Trust Task documents. + pub secret_multibase: String, + /// The Affinidi secret, for signing credentials. + pub secret: affinidi_tdk::secrets_resolver::secrets::Secret, + } + + impl Party { + /// Mint one. + pub fn new() -> Self { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).expect("OS randomness"); + let signing = ed25519_dalek::SigningKey::from_bytes(&seed); + let did = format!( + "did:key:{}", + vta_sdk::did_key::ed25519_multibase_pubkey(&signing.verifying_key().to_bytes()) + ); + Self { + secret: vta_sdk::did_key::secrets_from_did_key(&did, &seed) + .expect("build secrets") + .signing, + secret_multibase: multibase::encode(multibase::Base::Base58Btc, seed), + did, + } + } + } + + impl Default for Party { + fn default() -> Self { + Self::new() + } + } + + /// A room, its owner, and the owner's agent — with every credential actually signed. + pub struct RoomFixture { + /// The room itself. Its DID is a `did:key` so a host needs no network to verify it. + pub room: Room, + /// The room's own key, which issues membership and the chain root. + pub room_key: Party, + pub owner: Party, + pub agent: Party, + /// The owner's chain: one link, from the room. + pub owner_chain: Vec, + /// The agent's chain: the owner's, with a read-only leaf on top. + pub agent_chain: Vec, + /// The owner's membership credential. + pub membership: String, + } + + impl RoomFixture { + /// Build one at `visibility`. + pub async fn new(visibility: Visibility) -> Self { + let room_key = Party::new(); + let owner = Party::new(); + let agent = Party::new(); + let now = Utc::now(); + + let mut owner_vac = DTGCredential::new_vac( + room_key.did.clone(), + owner.did.clone(), + room_key.did.clone(), + vec![ + "read".into(), + "write".into(), + "curate".into(), + "admin".into(), + ], + now - Duration::minutes(1), + Some(now + Duration::days(30)), + ) + .expect("owner VAC") + .with_id("urn:uuid:vac-owner"); + owner_vac + .sign(&room_key.secret, None) + .await + .expect("sign the owner VAC"); + + // Four hours, read only, and the room is not involved — which is the whole + // reason attenuation exists. + let mut agent_vac = owner_vac + .attenuate( + agent.did.clone(), + vec!["read".into()], + now - Duration::minutes(1), + Some(now + Duration::hours(4)), + None, + ) + .expect("attenuate to the agent") + .with_id("urn:uuid:vac-agent"); + agent_vac + .sign(&owner.secret, None) + .await + .expect("sign the agent VAC"); + + let mut vmc = DTGCredential::new_vmc( + room_key.did.clone(), + owner.did.clone(), + now - Duration::minutes(1), + Some(now + Duration::days(30)), + false, + ); + vmc.sign(&room_key.secret, None) + .await + .expect("sign the VMC"); + + let enc = |c: &DTGCredential| serde_json::to_string(c).expect("serialise"); + + Self { + room: Room { + room_id: room_key.did.clone(), + owner_did: owner.did.clone(), + visibility, + epoch: 1, + next_version: 1, + retention_days: 90, + created_at: 0, + updated_at: 0, + }, + owner_chain: vec![enc(&owner_vac)], + agent_chain: vec![enc(&agent_vac), enc(&owner_vac)], + membership: enc(&vmc), + room_key, + owner, + agent, + } + } + + /// The owner's presentation. + pub fn as_owner(&self) -> AuthorityPresentation { + AuthorityPresentation { + membership: self.membership.clone(), + authority: self.owner_chain.clone(), + subject_binding: None, + } + } + + /// The agent's presentation — the owner's membership, a narrower chain. + pub fn as_agent(&self) -> AuthorityPresentation { + AuthorityPresentation { + membership: self.membership.clone(), + authority: self.agent_chain.clone(), + subject_binding: None, + } + } + } +} diff --git a/vti-rooms/Cargo.toml b/vti-rooms/Cargo.toml index 9fdf55140..7e1522abb 100644 --- a/vti-rooms/Cargo.toml +++ b/vti-rooms/Cargo.toml @@ -18,6 +18,9 @@ serde = { workspace = true } serde_json = { workspace = true } # The published schema types `updatedAt` as an RFC 3339 string. chrono = { workspace = true } +# Verification may resolve a DID, so ChainVerifier is async; async-trait keeps it +# dyn-compatible, as it does for vti_common::auth::backend. +async-trait = "0.1" [dev-dependencies] tempfile = "3" diff --git a/vti-rooms/src/authz.rs b/vti-rooms/src/authz.rs index 40859cedf..660245e4c 100644 --- a/vti-rooms/src/authz.rs +++ b/vti-rooms/src/authz.rs @@ -13,19 +13,32 @@ //! still passing. [`AuthorizedAction`] is deliberately constructible only by //! [`authorize`], so a handler cannot skip the check and cannot substitute a different one. //! -//! # What is verified, and what is deferred +//! # Two halves, and why they are separated //! -//! Chain-shape verification — reaching a root issued by the room, no link widening actions, -//! scope or validity, depth bounded, audience honoured — is implemented in -//! `dtg_credentials::authority::verify_chain`, the reference implementation that ships with -//! the credential. This module performs the checks that do not require parsing credentials -//! (presence, depth, the private-tier binding) and records where signature and chain -//! verification attach. +//! Authorizing a room operation has two parts, and only one of them belongs in a crate that +//! anything can depend on: //! -//! The signature-verification hop is **not** wired here yet, and that is stated rather than -//! hidden: it needs the room's DID resolved to a verification method, which arrives with the -//! `attributed` tier. Until then [`authorize`] refuses anything but an `Open` room, so no -//! caller can mistake an unverified chain for a verified one. +//! - **Shape.** Is there a chain at all, is it within the depth bound, is there a membership +//! credential, does a private room carry its subject binding? These need no credential +//! library, no DID resolution and no network. They live here. +//! - **Cryptography.** Does each credential's proof verify, does the chain reach a root the +//! room issued, and does it confer the action being asked for? That needs a credential +//! library and a resolver, and it is reached through [`ChainVerifier`]. +//! +//! The split is not squeamishness about dependencies. `verify_chain` and proof verification +//! need `dtg-credentials`, which needs a DID resolver, which is a different thing on a VTC +//! than on a standalone room host. Pinning either choice into this crate would make the +//! storage layer un-reusable for the other. The trait is what lets **one** decision about +//! what is safe to serve be shared by hosts that resolve DIDs differently. +//! +//! It also means a host that has configured no verifier cannot accidentally serve a sealed +//! room: [`RefusesEverything`] is the only thing it has, and it refuses. +//! +//! # The shape checks run first, always +//! +//! [`authorize`] runs every shape check before it calls the verifier, and the order is +//! load-bearing: depth is the cheapest check and the one that bounds the cost of +//! verification, which is linear in chain length and runs on every operation. use vti_common::error::AppError; @@ -65,6 +78,88 @@ impl Action { } } +/// What a verifier concludes about a presentation. +/// +/// Deliberately narrow: the caller gets the subject and the actions the chain confers, and +/// nothing that would tempt it to re-derive a decision the verifier already made. +#[derive(Debug, Clone)] +pub struct VerifiedChain { + /// The party the leaf grants to — who may act. + pub subject: String, + /// The actions the chain confers, already narrowed by every link above the leaf. + pub actions: Vec, +} + +/// Cryptographic verification of a presentation. +/// +/// One implementation per host, because the resolver differs; one *decision*, because both +/// implementations answer the same question and [`authorize`] is the only caller. +/// +/// # Contract +/// +/// An implementation MUST verify, at minimum: +/// +/// 1. every credential in the chain carries a valid proof; +/// 2. the chain's root was issued by the room — a chain reaching any other party confers +/// nothing here, however well-formed; +/// 3. no link widens the actions or scope of its parent; +/// 4. every link is within its validity window, and any audience is honoured; +/// 5. the membership credential and the chain describe the same subject; +/// 6. the chain's leaf grants to `presenter` — the party the *transport* authenticated, +/// not one named in the payload. +/// +/// (5) is the pooling defence, and it is the verifier's because it needs both credentials +/// parsed. On a `private` room it is proved in zero knowledge from the subject binding; on +/// the disclosing tiers it is a comparison. +/// +/// (6) is what stops a captured presentation being replayed. A presentation is a bearer +/// object — it names what may be done, not who is doing it — so without binding it to the +/// authenticated sender, anyone who observes one inherits it. `presenter` is therefore the +/// DID a proof established, never a field a caller filled in. +/// +/// Returning `Ok` for a chain that fails any of these is a privilege escalation, not a +/// leniency: anyone can mint a well-formed VAC naming any scope and any action. +/// Verification may need to resolve a DID, so this is async. Keeping it sync would force +/// every implementation to either block a runtime thread or pre-resolve keys it cannot know +/// it will need — and pre-resolution is how a verifier ends up trusting a cache instead of a +/// signature. Same shape as `vti_common::auth::backend`, for the same reason. +#[async_trait::async_trait] +pub trait ChainVerifier: Send + Sync { + /// Verify `presentation` against `room` for `action`. + async fn verify( + &self, + room: &Room, + presentation: &AuthorityPresentation, + action: Action, + presenter: &str, + ) -> Result; +} + +/// The verifier a host has before it configures one. +/// +/// A host with no credential library cannot check a chain, and a chain nobody checked +/// authorizes nothing — so this refuses, rather than defaulting to permissive and relying on +/// an operator to notice. It is the only safe default a fail-open seam can have. +#[derive(Debug, Clone, Copy, Default)] +pub struct RefusesEverything; + +#[async_trait::async_trait] +impl ChainVerifier for RefusesEverything { + async fn verify( + &self, + room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + _presenter: &str, + ) -> Result { + Err(AppError::Forbidden(format!( + "room `{}` has no chain verifier configured on this host, and a chain nobody \ + verified authorizes nothing", + room.room_id + ))) + } +} + /// Proof that [`authorize`] ran and allowed this operation. /// /// Handlers take this rather than a presentation, so an operation that forgot to authorize @@ -74,6 +169,7 @@ impl Action { pub struct AuthorizedAction { action: Action, room_id: String, + verified: VerifiedChain, } impl AuthorizedAction { @@ -85,6 +181,17 @@ impl AuthorizedAction { pub fn room_id(&self) -> &str { &self.room_id } + /// Who the chain says may act. + /// + /// This is the subject the *verifier* established, never one a caller supplied — which + /// is why a handler recording an author reads it from here. + pub fn subject(&self) -> &str { + &self.verified.subject + } + /// Everything the chain confers, which is at least the action asked for. + pub fn conferred(&self) -> &[String] { + &self.verified.actions + } } /// Authorize `action` on `room` from `presentation`. @@ -93,13 +200,15 @@ impl AuthorizedAction { /// the dispatch layer maps to the framework's `permission_denied` — the reason text /// distinguishes the cases for an operator reading logs, without telling a caller which /// part of their chain to adjust. -pub fn authorize( +pub async fn authorize( room: &Room, presentation: &AuthorityPresentation, action: Action, + presenter: &str, + verifier: &dyn ChainVerifier, ) -> Result { // Depth first: it is the cheapest check and the one that bounds the cost of every - // check after it. + // check after it — verification is linear in chain length and runs on every operation. if presentation.authority.is_empty() { return Err(AppError::Forbidden( "no authority chain presented; a room operation is authorized by the chain, \ @@ -122,7 +231,9 @@ pub fn authorize( // The pooling defence. On a tier that withholds the subject, a presentation without a // same-subject proof lets two parties combine one's membership with the other's - // authority and verify as a single party holding both. + // authority and verify as a single party holding both. Checked here because its + // *absence* is a shape problem; whether a present one actually proves same-subject is + // the verifier's job. if matches!(room.visibility, Visibility::Private) && presentation.subject_binding.is_none() { return Err(AppError::Forbidden( "a private room requires a subject binding proving the membership credential and \ @@ -132,21 +243,49 @@ pub fn authorize( )); } - // Chain verification proper needs the room's DID resolved to a verification method, - // which lands with the `attributed` tier. Refusing the sealed tiers outright is the - // honest interim: it is better to serve no sealed room than to serve one whose chain - // nobody checked. - if !matches!(room.visibility, Visibility::Open) { + // A presentation names what may be done, not who is doing it, so an unbound one is a + // bearer token: whoever observes it inherits it. The presenter is the DID the request's + // own proof established. + if presenter.trim().is_empty() { + return Err(AppError::Forbidden( + "no authenticated presenter; a presentation not bound to the party that signed \ + the request is replayable by anyone who observes it" + .into(), + )); + } + + // Everything above is shape. This is the decision. + let verified = verifier + .verify(room, presentation, action, presenter) + .await?; + + // The verifier answers "what does this chain confer"; this asserts the answer covers + // what was asked. Two steps rather than one because a verifier that also decided + // sufficiency could quietly widen it — and because no action implies another, this is + // an exact membership test, not a comparison. + if !verified.actions.iter().any(|a| a == action.as_str()) { return Err(AppError::Forbidden(format!( - "room `{}` is {:?}; cryptographic chain verification is not yet wired, and this \ - service will not serve a sealed room on an unverified chain", - room.room_id, room.visibility + "the chain confers {:?}, which does not include `{}`", + verified.actions, + action.as_str() + ))); + } + + // The verifier is contracted to bind the leaf to `presenter`, and this re-states it + // where the seam can see it. A verifier that returned some other subject would be + // authorizing one party's chain for another's request; catching that here means the + // property does not depend on every implementation remembering it. + if verified.subject != presenter { + return Err(AppError::Forbidden(format!( + "the chain grants to `{}`, not to the party that signed this request", + verified.subject ))); } Ok(AuthorizedAction { action, room_id: room.room_id.clone(), + verified, }) } @@ -167,6 +306,9 @@ mod tests { } } + /// The DID the request's own proof established. + const PRESENTER: &str = "did:key:zAgent"; + fn presentation(depth: usize, binding: bool) -> AuthorityPresentation { AuthorityPresentation { membership: "vmc".into(), @@ -175,70 +317,284 @@ mod tests { } } - #[test] - fn an_open_room_authorizes_a_well_formed_presentation() { + /// A verifier that vouches for whatever it is handed. + /// + /// Stands in for the cryptographic half so the shape half can be tested on its own. It + /// is `#[cfg(test)]` on purpose — a permissive verifier is a privilege escalation, and + /// the only one shipped is [`RefusesEverything`]. + struct Vouches(Vec); + + impl Vouches { + fn for_all() -> Self { + Self( + ["read", "write", "curate", "admin"] + .iter() + .map(|s| s.to_string()) + .collect(), + ) + } + fn read_only() -> Self { + Self(vec!["read".into()]) + } + } + + #[async_trait::async_trait] + impl ChainVerifier for Vouches { + async fn verify( + &self, + _room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + presenter: &str, + ) -> Result { + Ok(VerifiedChain { + subject: presenter.to_string(), + actions: self.0.clone(), + }) + } + } + + /// A verifier that vouches for a chain granting to somebody else. + struct VouchesForSomeoneElse; + + #[async_trait::async_trait] + impl ChainVerifier for VouchesForSomeoneElse { + async fn verify( + &self, + _room: &Room, + _presentation: &AuthorityPresentation, + _action: Action, + _presenter: &str, + ) -> Result { + Ok(VerifiedChain { + subject: "did:key:zSomeoneElse".into(), + actions: vec!["read".into()], + }) + } + } + + #[tokio::test] + async fn a_verified_presentation_authorizes_what_the_chain_confers() { let ok = authorize( &room(Visibility::Open), &presentation(2, false), Action::Write, + PRESENTER, + &Vouches::for_all(), ) + .await .expect("should authorize"); assert_eq!(ok.action(), Action::Write); assert_eq!(ok.room_id(), "did:key:zRoom"); + assert_eq!( + ok.subject(), + PRESENTER, + "the subject is the verifier's finding, never the caller's claim" + ); + } + + /// The whole point of the agent story: a chain conferring `read` writes nothing, and + /// the refusal comes from `authorize` rather than from any handler remembering to check. + #[tokio::test] + async fn a_read_only_chain_cannot_write() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Write, + PRESENTER, + &Vouches::read_only(), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not include `write`"), + "{err}" + ); + + authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + PRESENTER, + &Vouches::read_only(), + ) + .await + .expect("but it reads"); + } + + /// `admin` does not follow from `write`. Implication is how a permission model widens. + #[tokio::test] + async fn no_action_implies_another() { + let err = authorize( + &room(Visibility::Open), + &presentation(1, false), + Action::Admin, + PRESENTER, + &Vouches(vec!["read".into(), "write".into(), "curate".into()]), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("does not include `admin`"), + "{err}" + ); } /// The chain is the authorization. Nothing else is. - #[test] - fn an_empty_chain_authorizes_nothing() { + #[tokio::test] + async fn an_empty_chain_authorizes_nothing() { let err = authorize( &room(Visibility::Open), &presentation(0, false), Action::Read, + PRESENTER, + &Vouches::for_all(), ) + .await .unwrap_err(); assert!(format!("{err}").contains("no authority chain"), "{err}"); } - #[test] - fn a_chain_past_the_ceiling_is_refused() { + #[tokio::test] + async fn a_chain_past_the_ceiling_is_refused() { let err = authorize( &room(Visibility::Open), &presentation(MAX_CHAIN_DEPTH + 1, false), Action::Read, + PRESENTER, + &Vouches::for_all(), ) + .await .unwrap_err(); assert!(format!("{err}").contains("exceeding the maximum"), "{err}"); } + /// Depth is checked before the verifier runs, so an over-deep chain costs nothing to + /// refuse — which is the reason it is first. + #[tokio::test] + async fn the_shape_checks_run_before_the_verifier() { + struct Panics; + #[async_trait::async_trait] + impl ChainVerifier for Panics { + async fn verify( + &self, + _: &Room, + _: &AuthorityPresentation, + _: Action, + _: &str, + ) -> Result { + panic!("the verifier must not be reached for a malformed presentation"); + } + } + + for p in [ + presentation(0, false), + presentation(MAX_CHAIN_DEPTH + 1, false), + ] { + assert!( + authorize( + &room(Visibility::Open), + &p, + Action::Read, + PRESENTER, + &Panics + ) + .await + .is_err() + ); + } + } + /// Without this, two parties pool credentials and verify as one. - #[test] - fn a_private_room_refuses_a_presentation_with_no_subject_binding() { + #[tokio::test] + async fn a_private_room_refuses_a_presentation_with_no_subject_binding() { let err = authorize( &room(Visibility::Private), &presentation(2, false), Action::Read, + PRESENTER, + &Vouches::for_all(), ) + .await .unwrap_err(); assert!(format!("{err}").contains("subject binding"), "{err}"); } - /// Better to serve no sealed room than one whose chain nobody checked. - #[test] - fn sealed_tiers_are_refused_until_chain_verification_is_wired() { - for v in [Visibility::Attributed, Visibility::Private] { - let err = authorize(&room(v), &presentation(2, true), Action::Read).unwrap_err(); + /// A host that has configured no verifier serves nothing — on any tier, not just the + /// sealed ones. Fail-closed is the only safe default for a seam like this. + #[tokio::test] + async fn a_host_with_no_verifier_authorizes_nothing() { + for v in [ + Visibility::Open, + Visibility::Attributed, + Visibility::Private, + ] { + let err = authorize( + &room(v), + &presentation(2, true), + Action::Read, + PRESENTER, + &RefusesEverything, + ) + .await + .unwrap_err(); assert!( - format!("{err}").contains("chain verification is not yet wired"), + format!("{err}").contains("no chain verifier configured"), "{v:?}: {err}" ); } } - #[test] - fn a_missing_membership_credential_is_refused() { + /// A presentation is a bearer object. Without binding it to the signer, anyone who + /// observes one inherits it. + #[tokio::test] + async fn an_unbound_presentation_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + " ", + &Vouches::for_all(), + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("no authenticated presenter"), + "{err}" + ); + } + + /// The seam re-states the binding rather than trusting each verifier to remember it. + #[tokio::test] + async fn a_chain_granting_to_someone_else_is_refused() { + let err = authorize( + &room(Visibility::Open), + &presentation(2, false), + Action::Read, + PRESENTER, + &VouchesForSomeoneElse, + ) + .await + .unwrap_err(); + assert!( + format!("{err}").contains("not to the party that signed this request"), + "{err}" + ); + } + + #[tokio::test] + async fn a_missing_membership_credential_is_refused() { let mut p = presentation(2, false); p.membership = " ".into(); - let err = authorize(&room(Visibility::Open), &p, Action::Read).unwrap_err(); + let err = authorize( + &room(Visibility::Open), + &p, + Action::Read, + PRESENTER, + &Vouches::for_all(), + ) + .await + .unwrap_err(); assert!( format!("{err}").contains("no membership credential"), "{err}" From eaffd02664022d212371e442f6e8152e61b4d791 Mon Sep 17 00:00:00 2001 From: Glenn Gore Date: Wed, 2 Sep 2026 23:36:45 +0200 Subject: [PATCH 14/14] fix(vtc): back up the room keyspaces, and pin the two lists together 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 --- vtc-service/src/backup.rs | 47 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/vtc-service/src/backup.rs b/vtc-service/src/backup.rs index 55aaf35f7..996f32d7d 100644 --- a/vtc-service/src/backup.rs +++ b/vtc-service/src/backup.rs @@ -361,6 +361,13 @@ fn backed_up_handle<'a>(state: &'a AppState, name: &str) -> Option<&'a KeyspaceH x if x == ENDORSEMENT_TYPES => &state.endorsement_types_ks, x if x == SCHEMAS => &state.schemas_ks, x if x == ENDORSEMENTS => &state.endorsements_ks, + // A room's records are backed up like any other community state. On a + // sealed tier they are ciphertext this service cannot read, and that is + // no reason to skip them: the host is trusted for availability + // (invariant I2), and losing the ciphertext loses the room just as + // completely as losing plaintext would. + x if x == ROOMS => &state.rooms_ks, + x if x == ROOM_RECORDS => &state.room_records_ks, x if x == INVITATIONS => &state.invitations_ks, x if x == CONSUMED_INVITATIONS => &state.consumed_invitations_ks, x if x == AUDIT => &state.audit_ks, @@ -592,6 +599,46 @@ fn derive_key( mod tests { use super::*; + /// `BACKED_UP` and `backed_up_handle` are two lists of the same thing, and nothing in + /// the type system makes them agree — the match ends in `_ => return None`, so a + /// keyspace added to one and not the other fails at *backup time* with + /// "no AppState handle for keyspace", which is a runtime error on the one operation an + /// operator runs when something has already gone wrong. + /// + /// That is exactly how `rooms` and `room_records` broke every backup test: added to the + /// partition, absent from the map, and no compile error anywhere. This pins the two + /// together the way the partition census pins the partition. + #[tokio::test] + async fn every_backed_up_keyspace_has_a_handle() { + let tv = crate::test_support::build_test_vtc().await; + let missing: Vec<&str> = keyspaces::BACKED_UP + .iter() + .filter(|name| backed_up_handle(&tv.state, name).is_none()) + .copied() + .collect(); + assert!( + missing.is_empty(), + "these keyspaces are in BACKED_UP but `backed_up_handle` does not map them, so \ + every export would fail at runtime: {missing:?}" + ); + } + + /// The other direction: a handle for a keyspace nothing backs up is dead code that + /// reads as coverage. + #[tokio::test] + async fn no_handle_maps_a_keyspace_that_is_not_backed_up() { + let tv = crate::test_support::build_test_vtc().await; + let stray: Vec<&str> = keyspaces::EXCLUDED_FROM_BACKUP + .iter() + .filter(|name| backed_up_handle(&tv.state, name).is_some()) + .copied() + .collect(); + assert!( + stray.is_empty(), + "`backed_up_handle` maps keyspaces excluded from backup: {stray:?}" + ); + } + fn sample_payload() -> BackupPayload { BackupPayload { config: BackupConfig {