use serde_json::Value;
use vti_common::error::AppError;
use crate::operations::outbound::{Outbound, ReplyTrust};
use crate::operations::room_issuance::{SigningContext, VtaKeySigner};
pub fn build_room_task(task: &str, host: &str, issuer: &str, payload: Value) -> Value {
serde_json::json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": task,
"recipient": host,
"issuer": issuer,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
})
}
pub fn read_reply(doc: &Value, expected: &str, host: &str) -> Result<Value, AppError> {
read_reply_typed(doc, expected, host).map_err(RoomTaskError::into_app_error)
}
#[derive(Debug)]
pub enum RoomTaskError {
Refused {
host: String,
code: String,
reason: String,
},
Unknown(AppError),
}
impl RoomTaskError {
pub fn into_app_error(self) -> AppError {
match self {
Self::Refused { host, code, reason } => {
AppError::Forbidden(format!("room host `{host}` refused: {code}: {reason}"))
}
Self::Unknown(e) => e,
}
}
}
pub fn read_reply_typed(doc: &Value, expected: &str, host: &str) -> Result<Value, RoomTaskError> {
let doc_type = doc.get("type").and_then(Value::as_str).unwrap_or_default();
if doc_type == expected {
return Ok(doc.get("payload").cloned().unwrap_or(Value::Null));
}
if doc_type.starts_with("https://trusttasks.org/spec/trust-task-error/") {
let payload = doc.get("payload").cloned().unwrap_or(Value::Null);
let code = payload
.get("code")
.and_then(Value::as_str)
.unwrap_or("unspecified");
let reason = payload
.get("reason")
.or_else(|| payload.get("message"))
.and_then(Value::as_str)
.unwrap_or("no reason given");
return Err(RoomTaskError::Refused {
host: host.to_string(),
code: code.to_string(),
reason: reason.to_string(),
});
}
Err(RoomTaskError::Unknown(AppError::Internal(format!(
"room host `{host}` answered `{doc_type}` where `{expected}` was expected; a reply that \
threads to our request but answers a different task is a contract break rather than a \
task failure"
))))
}
const MUTATING_ROOM_TASKS: &[&str] = &[
vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE,
vti_rooms::wire::ROOMS_RECORDS_CURATE_TYPE,
vti_rooms::wire::ROOMS_EPOCH_MINT_TYPE,
vti_rooms::wire::ROOMS_OWNER_TRANSFER_TYPE,
];
pub async fn refuse_if_caught(
groups: &vti_common::store::KeyspaceHandle,
member: &crate::operations::room_groups::Member,
task: &str,
) -> Result<(), AppError> {
if !MUTATING_ROOM_TASKS.contains(&task) {
return Ok(());
}
let room_id = member.room_id();
let history = match crate::operations::room_groups::root_history(groups, member).await {
Ok(h) => h,
Err(e) => {
tracing::error!(
room = %room_id,
error = %e,
"could not read this agent's root history; allowing the write rather than \
turning a local fault into an accusation"
);
return Ok(());
}
};
if let Some(caught) = history.caught_at {
return Err(AppError::Forbidden(format!(
"this agent has been given two different record sets for `{room_id}`, both \
claimed at version {caught}. One of them is wrong, and a write cannot explain \
the difference — a write moves the version. Reading this room still works, and \
is how the evidence is gathered; writing to a host that has contradicted itself \
is refused."
)));
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
pub async fn send_room_task(
ctx: SigningContext<'_>,
groups: &vti_common::store::KeyspaceHandle,
member: &crate::operations::room_groups::Member,
out: &Outbound<'_>,
host: &str,
signing_key_id: &str,
issuer: &str,
verification_method: &str,
task: &str,
response_task: &str,
payload: Value,
) -> Result<Value, AppError> {
send_room_task_typed(
ctx,
groups,
member,
out,
host,
signing_key_id,
issuer,
verification_method,
task,
response_task,
payload,
)
.await
.map_err(RoomTaskError::into_app_error)
}
#[allow(clippy::too_many_arguments)]
pub async fn send_room_task_typed(
ctx: SigningContext<'_>,
groups: &vti_common::store::KeyspaceHandle,
member: &crate::operations::room_groups::Member,
out: &Outbound<'_>,
host: &str,
signing_key_id: &str,
issuer: &str,
verification_method: &str,
task: &str,
response_task: &str,
payload: Value,
) -> Result<Value, RoomTaskError> {
let mut document = build_room_task(task, host, issuer, payload);
let signer = VtaKeySigner::new(ctx, signing_key_id, verification_method);
refuse_if_caught(groups, member, task)
.await
.map_err(RoomTaskError::Unknown)?;
let proof = affinidi_data_integrity::DataIntegrityProof::sign(
&document,
&signer,
affinidi_data_integrity::SignOptions::new(),
)
.await
.map_err(|e| {
let mut cause = String::new();
let mut src: Option<&(dyn std::error::Error + 'static)> = std::error::Error::source(&e);
while let Some(inner) = src {
cause = format!("{cause}: {inner}");
src = inner.source();
}
RoomTaskError::Unknown(AppError::Internal(format!(
"sign the request to room host `{host}`: {e}{cause}"
)))
})?;
document["proof"] = serde_json::to_value(proof).map_err(|e| {
RoomTaskError::Unknown(AppError::Internal(format!("serialise the proof: {e}")))
})?;
send_signed_room_task_typed(groups, member, out, host, document, response_task).await
}
pub async fn send_signed_room_task_typed(
groups: &vti_common::store::KeyspaceHandle,
member: &crate::operations::room_groups::Member,
out: &Outbound<'_>,
host: &str,
document: Value,
response_task: &str,
) -> Result<Value, RoomTaskError> {
let task = document
.get("type")
.and_then(Value::as_str)
.unwrap_or_default();
refuse_if_caught(groups, member, task)
.await
.map_err(RoomTaskError::Unknown)?;
let reply = out
.send(host, document, ReplyTrust::SignedByRecipient)
.await
.map_err(RoomTaskError::Unknown)?;
read_reply_typed(&reply, response_task, host)
}
pub type OwnerKey = crate::operations::persona::PersonaKey;
pub type OwnerKeyStores<'a> = crate::operations::persona::PersonaStores<'a>;
pub async fn owner_signing_key(
stores: OwnerKeyStores<'_>,
auth: &crate::auth::AuthClaims,
owner: &str,
vta_did: Option<&str>,
) -> Result<OwnerKey, AppError> {
if vta_did == Some(owner) {
return Ok(OwnerKey {
key_id: format!("{owner}#key-0"),
verification_method: crate::operations::credentials::vta_signing_vm(owner),
});
}
crate::operations::persona::persona_signing_key(stores, auth, owner)
.await?
.ok_or_else(|| {
AppError::Forbidden(format!(
"this agent holds no signing key for `{owner}` that you may use, so it cannot \
register a room in its name. Name as `ownerDid` a DID this agent minted in a \
context you act in — a host records as owner only the party that signed the \
request"
))
})
}
#[cfg(test)]
mod tests {
use super::*;
struct Owner {
did: String,
key_id: String,
public_key: [u8; 32],
}
struct Fixture {
ts: crate::test_support::TestStore,
seed: std::sync::Arc<dyn vti_secrets::SeedStore>,
}
impl Fixture {
async fn new() -> Self {
let ts = crate::test_support::open_test_store().await;
for (id, name) in [("rooms", "Rooms"), ("other", "Other")] {
crate::contexts::create_context(&ts.contexts_ks, id, name)
.await
.expect("create context");
}
Self {
ts,
seed: std::sync::Arc::new(crate::test_support::TestSeedStore(vec![7u8; 32])),
}
}
fn stores(&self) -> OwnerKeyStores<'_> {
OwnerKeyStores {
keys_ks: &self.ts.keys_ks,
#[cfg(feature = "webvh")]
webvh_ks: &self.ts.webvh_ks,
}
}
fn signing<'a>(&'a self, auth: &'a crate::auth::AuthClaims) -> SigningContext<'a> {
SigningContext {
keys_ks: &self.ts.keys_ks,
imported_ks: &self.ts.imported_ks,
internal_ks: &self.ts.internal_ks,
contexts_ks: &self.ts.contexts_ks,
acl_ks: &self.ts.acl_ks,
seed_store: &self.seed,
audit: &self.ts.audit,
auth,
}
}
async fn mint_key(&self, context: &str, key_id: &str) -> (String, [u8; 32]) {
let raw = crate::operations::persona::test_fixture::mint_key(
&self.ts.keys_ks,
&self.ts.internal_ks,
&self.ts.contexts_ks,
&self.ts.acl_ks,
&self.seed,
&self.ts.audit,
context,
key_id,
)
.await;
(vta_sdk::did_key::ed25519_multibase_pubkey(&raw), raw)
}
#[cfg(feature = "webvh")]
async fn owner_in(&self, context: &str) -> Owner {
let p = crate::operations::persona::test_fixture::webvh_persona(
&self.ts.keys_ks,
&self.ts.internal_ks,
&self.ts.contexts_ks,
&self.ts.acl_ks,
&self.ts.webvh_ks,
&self.seed,
&self.ts.audit,
context,
&format!("owner-signing-{context}"),
)
.await;
Owner {
did: p.did,
key_id: p.key_id,
public_key: p.public_key,
}
}
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn register_signs_rooms_create_as_the_owner() {
let f = Fixture::new().await;
let owner = f.owner_in("rooms").await;
let caller = crate::test_support::admin_claims_for_context("rooms");
let key = owner_signing_key(
f.stores(),
&caller,
&owner.did,
Some("did:webvh:QmAgent:agent.example"),
)
.await
.expect("the owner's key");
assert_eq!(key.key_id, owner.key_id);
assert_eq!(key.verification_method, format!("{}#key-1", owner.did));
let room = "did:webvh:QmRoom:rooms.example:northwind";
let document = build_room_task(
vti_rooms::wire::ROOMS_CREATE_TYPE,
"did:webvh:QmHost:community.example",
&owner.did,
serde_json::json!({ "roomId": room, "visibility": "attributed", "ownerDid": owner.did }),
);
let signer = VtaKeySigner::new(f.signing(&caller), &key.key_id, &key.verification_method);
let proof = affinidi_data_integrity::DataIntegrityProof::sign(
&document,
&signer,
affinidi_data_integrity::SignOptions::new(),
)
.await
.expect("sign as the owner");
assert_eq!(document["issuer"], owner.did);
assert_eq!(proof.verification_method, format!("{}#key-1", owner.did));
assert_eq!(proof.proof_purpose, "assertionMethod");
proof
.verify_with_public_key(
&document,
&owner.public_key,
affinidi_data_integrity::VerifyOptions::new(),
)
.expect("the proof verifies against the owner's published key");
vti_rooms::authz::authorize_create(room, &owner.did, &proof.verification_method)
.expect("a host records the signer as the owner it names");
}
#[cfg(feature = "webvh")]
#[tokio::test]
async fn an_owner_in_another_context_is_refused() {
let f = Fixture::new().await;
let elsewhere = f.owner_in("other").await;
let caller = crate::test_support::admin_claims_for_context("rooms");
let err = owner_signing_key(f.stores(), &caller, &elsewhere.did, None)
.await
.expect_err("another context's DID");
assert!(matches!(err, AppError::Forbidden(_)), "{err}");
let unknown = owner_signing_key(
f.stores(),
&caller,
"did:webvh:QmNobody:nowhere.example",
None,
)
.await
.expect_err("an unknown DID");
assert_eq!(
err.to_string(),
unknown
.to_string()
.replace("did:webvh:QmNobody:nowhere.example", &elsewhere.did,),
"held-elsewhere and unknown read the same"
);
}
#[tokio::test]
async fn a_caller_whose_key_this_agent_does_not_hold_is_refused() {
let f = Fixture::new().await;
let caller = crate::test_support::admin_claims_for_context("rooms");
let err = owner_signing_key(f.stores(), &caller, &caller.did, None)
.await
.expect_err("a client-held key");
assert!(matches!(err, AppError::Forbidden(_)), "{err}");
assert!(err.to_string().contains("ownerDid"), "{err}");
}
#[tokio::test]
async fn a_held_did_key_owner_signs_as_itself() {
let f = Fixture::new().await;
let (_, raw) = f.mint_key("rooms", "a-did-key-owner").await;
let id = vta_sdk::did_key::ed25519_multibase_pubkey(&raw);
let did = format!("did:key:{id}");
let caller = crate::test_support::admin_claims_for_context("rooms");
let key = owner_signing_key(f.stores(), &caller, &did, None)
.await
.expect("a held did:key");
assert_eq!(key.key_id, "a-did-key-owner");
assert_eq!(key.verification_method, format!("{did}#{id}"));
let other = crate::test_support::admin_claims_for_context("other");
owner_signing_key(f.stores(), &other, &did, None)
.await
.expect_err("not from another context");
}
#[tokio::test]
async fn the_agents_own_did_signs_with_its_own_key() {
let f = Fixture::new().await;
let caller = crate::test_support::super_admin_claims();
let vta = "did:webvh:QmAgent:agent.example";
let key = owner_signing_key(f.stores(), &caller, vta, Some(vta))
.await
.expect("the agent itself");
assert_eq!(key.key_id, format!("{vta}#key-0"));
assert_eq!(key.verification_method, format!("{vta}#key-0"));
}
#[test]
fn the_document_is_addressed_and_attributed() {
let doc = build_room_task(
"https://trusttasks.org/spec/rooms/epoch/chain/0.1",
"did:example:host",
"did:example:agent",
serde_json::json!({ "roomId": "did:example:room" }),
);
assert_eq!(doc["recipient"], "did:example:host");
assert_eq!(doc["issuer"], "did:example:agent");
assert_eq!(doc["payload"]["roomId"], "did:example:room");
assert!(doc["id"].as_str().unwrap().starts_with("urn:uuid:"));
}
#[test]
fn a_response_yields_its_payload() {
let reply = serde_json::json!({
"type": "https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"payload": { "links": [] }
});
let got = read_reply(
&reply,
"https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"did:example:host",
)
.expect("a response");
assert_eq!(got["links"], serde_json::json!([]));
}
#[test]
fn a_refusal_carries_the_hosts_own_code_and_reason() {
let reply = serde_json::json!({
"type": "https://trusttasks.org/spec/trust-task-error/0.5",
"payload": { "code": "private-tier-not-enabled", "reason": "this community has not enabled private rooms" }
});
let err = read_reply(&reply, "irrelevant", "did:example:host").expect_err("a refusal");
let msg = err.to_string();
assert!(msg.contains("private-tier-not-enabled"), "{msg}");
assert!(msg.contains("has not enabled private rooms"), "{msg}");
assert!(
matches!(err, AppError::Forbidden(_)),
"a refusal, not a 502"
);
}
#[test]
fn a_reply_answering_a_different_task_is_a_contract_break() {
let reply =
serde_json::json!({ "type": "https://trusttasks.org/spec/rooms/create/0.1#response" });
let err = read_reply(
&reply,
"https://trusttasks.org/spec/rooms/epoch/chain/0.1#response",
"did:example:host",
)
.expect_err("wrong task");
assert!(err.to_string().contains("contract break"));
}
}
#[cfg(test)]
mod write_gate_tests {
use super::*;
use crate::operations::room_groups::observe_head;
use vti_common::config::StoreConfig;
use vti_common::store::Store;
const ROOM: &str = "did:webvh:example.com:rooms:northwind";
const A: &str = "zQmbWqxBEKC3P8tqsKc98xmWNzrzDtRLMiMPL8wBuTGsMnR";
const B: &str = "zQmXo1sV5aJ7bT2kQdF9wRnPzYcH4uMgLtEjV6NrBqWsDpK";
fn member() -> crate::operations::room_groups::Member {
crate::operations::room_groups::Member::new(ROOM, "did:key:zAlice").unwrap()
}
async fn open() -> (tempfile::TempDir, vti_common::store::KeyspaceHandle) {
let dir = tempfile::tempdir().unwrap();
let store = Store::open(&StoreConfig {
data_dir: dir.path().to_path_buf(),
})
.unwrap();
let ks = store.keyspace(crate::keyspaces::ROOM_GROUPS).unwrap();
(dir, ks)
}
#[tokio::test]
async fn a_caught_host_is_refused_writes_and_still_serves_reads() {
let (_d, ks) = open().await;
observe_head(&ks, &member(), 412, A).await.unwrap();
observe_head(&ks, &member(), 412, B).await.unwrap();
let refused = refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE)
.await
.expect_err("a write to a caught host must be refused");
let said = refused.to_string();
assert!(
said.contains("412") && said.contains("Reading this room still works"),
"the refusal must name what was observed and what still works: {said}"
);
refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_GET_TYPE)
.await
.expect("reads are served");
refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_LIST_TYPE)
.await
.expect("listings are served");
}
#[tokio::test]
async fn a_host_that_has_not_been_caught_is_not_refused() {
let (_d, ks) = open().await;
observe_head(&ks, &member(), 412, A).await.unwrap();
observe_head(&ks, &member(), 413, B).await.unwrap();
refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE)
.await
.expect("two moments are not a contradiction");
}
#[tokio::test]
async fn being_caught_survives_the_history_being_pruned() {
let (_d, ks) = open().await;
observe_head(&ks, &member(), 1, A).await.unwrap();
observe_head(&ks, &member(), 1, B).await.unwrap();
for v in 2..=64u64 {
observe_head(&ks, &member(), v, A).await.unwrap();
}
assert!(
refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE)
.await
.is_err(),
"reading past a conflict must not clear it"
);
}
#[tokio::test]
async fn a_room_with_no_history_at_all_is_written_to() {
let (_d, ks) = open().await;
refuse_if_caught(&ks, &member(), vti_rooms::wire::ROOMS_RECORDS_PUT_TYPE)
.await
.expect("nothing observed is not something caught");
}
}