use serde_json::{Value, json};
use super::VtaClient;
use crate::error::VtaError;
use crate::trust_tasks;
const ROOM_TT_TIMEOUT: u64 = 30;
impl VtaClient {
pub async fn room_present(&self, room_id: &str, action: &str) -> Result<Value, VtaError> {
let payload = json!({
"roomId": room_id,
"action": action,
});
self.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_PRESENT_0_2,
payload,
ROOM_TT_TIMEOUT,
)
.await
}
pub async fn room_open(
&self,
room_id: &str,
key: &str,
version: u64,
ciphertext: &str,
nonce: &str,
epoch: u32,
) -> Result<Value, VtaError> {
let payload = json!({
"roomId": room_id,
"key": key,
"version": version,
"sealed": {
"ciphertext": ciphertext,
"nonce": nonce,
"epoch": epoch,
},
});
self.dispatch_at_or_before(
trust_tasks::TASK_ROOMS_KEYS_OPEN_0_2,
trust_tasks::TASK_ROOMS_KEYS_OPEN_0_1,
payload,
)
.await
}
async fn dispatch_at_or_before(
&self,
current: &str,
previous: &str,
payload: Value,
) -> Result<Value, VtaError> {
match self
.dispatch_trust_task(current, payload.clone(), ROOM_TT_TIMEOUT)
.await
{
Err(VtaError::UnsupportedTaskType { .. }) => {
self.dispatch_trust_task(previous, payload, ROOM_TT_TIMEOUT)
.await
}
other => other,
}
}
pub async fn room_seal(
&self,
room_id: &str,
key: &str,
version: u64,
plaintext: &[u8],
) -> Result<trust_tasks_rs::specs::rooms::keys::seal::v0_1::Response, VtaError> {
let payload = json!({
"roomId": room_id,
"key": key,
"version": version,
"plaintext": base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
plaintext,
),
});
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_SEAL_0_1,
payload,
ROOM_TT_TIMEOUT,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/keys/seal response does not match its schema: {e}"
))
})
}
pub async fn room_seal_as_principal(
&self,
room_id: &str,
key: &str,
version: u64,
body: &[u8],
) -> Result<trust_tasks_rs::specs::rooms::keys::seal::v0_2::Response, VtaError> {
let payload = json!({
"roomId": room_id,
"key": key,
"version": version,
"plaintext": base64::Engine::encode(
&base64::engine::general_purpose::URL_SAFE_NO_PAD,
body,
),
"signAsPrincipal": true,
});
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_SEAL_0_2,
payload,
ROOM_TT_TIMEOUT,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/keys/seal/0.2 response does not match its schema: {e}"
))
})
}
pub async fn room_act(
&self,
document: &trust_tasks_rs::specs::rooms::keys::act::v0_3::UnsignedRoomRequest,
) -> Result<trust_tasks_rs::specs::rooms::keys::act::v0_3::Response, VtaError> {
let payload = json!({ "document": document });
let value = match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_ACT_0_3,
payload.clone(),
ROOM_TT_TIMEOUT,
)
.await
{
Err(VtaError::UnsupportedTaskType { .. })
if !is_offer_type(document.type_.as_str()) =>
{
self.dispatch_at_or_before(
trust_tasks::TASK_ROOMS_KEYS_ACT_0_2,
trust_tasks::TASK_ROOMS_KEYS_ACT_0_1,
payload,
)
.await?
}
other => other?,
};
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/keys/act response does not match its schema: {e}"
))
})
}
pub async fn room_file_key(
&self,
room_id: &str,
file_id: &str,
open_epoch: Option<u64>,
) -> Result<trust_tasks_rs::specs::rooms::keys::file_key::v0_2::Response, VtaError> {
let mut payload = json!({
"roomId": room_id,
"fileId": file_id,
"purpose": if open_epoch.is_some() { "open" } else { "seal" },
});
if let Some(epoch) = open_epoch {
payload["epoch"] = json!(epoch);
}
let value = self
.dispatch_at_or_before(
trust_tasks::TASK_ROOMS_KEYS_FILE_KEY_0_2,
trust_tasks::TASK_ROOMS_KEYS_FILE_KEY_0_1,
payload,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/keys/file-key response does not match its schema: {e}"
))
})
}
pub async fn room_group_create(
&self,
room_id: &str,
signing_key_id: &str,
member: Option<&str>,
) -> Result<trust_tasks_rs::specs::rooms::owner::group::create::v0_1::Response, VtaError> {
let mut payload = json!({ "roomId": room_id, "signingKeyId": signing_key_id });
if let Some(m) = member {
payload["member"] = json!(m);
}
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_GROUP_CREATE_0_1,
payload,
ROOM_TT_TIMEOUT,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/owner/group/create response does not match its schema: {e}"
))
})
}
pub async fn room_group_add(
&self,
room_id: &str,
host_did: &str,
member: &str,
key_package: &str,
reason: Option<&str>,
) -> Result<trust_tasks_rs::specs::rooms::owner::group::add::v0_2::Response, VtaError> {
let mut payload = json!({
"roomId": room_id,
"host": host_did,
"member": member,
"keyPackage": key_package,
});
if let Some(r) = reason {
payload["reason"] = json!(r);
}
let timeout = ROOM_TT_TIMEOUT * 4;
let value = match self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_GROUP_ADD_0_2,
payload.clone(),
timeout,
)
.await
{
Err(VtaError::UnsupportedTaskType { .. }) => {
self.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_GROUP_ADD_0_1,
payload,
timeout,
)
.await?
}
other => other?,
};
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/owner/group/add response does not match its schema: {e}"
))
})
}
pub async fn room_group_remove(
&self,
room_id: &str,
host_did: &str,
member: &str,
reason: Option<&str>,
) -> Result<trust_tasks_rs::specs::rooms::owner::group::remove::v0_1::Response, VtaError> {
let mut payload = json!({
"roomId": room_id,
"host": host_did,
"member": member,
});
if let Some(r) = reason {
payload["reason"] = json!(r);
}
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_GROUP_REMOVE_0_1,
payload,
ROOM_TT_TIMEOUT * 4,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/owner/group/remove response does not match its schema: {e}"
))
})
}
pub async fn room_group_renew(
&self,
room_id: &str,
host_did: &str,
reason: Option<&str>,
) -> Result<trust_tasks_rs::specs::rooms::owner::group::renew::v0_1::Response, VtaError> {
let mut payload = json!({
"roomId": room_id,
"host": host_did,
});
if let Some(r) = reason {
payload["reason"] = json!(r);
}
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_GROUP_RENEW_0_1,
payload,
ROOM_TT_TIMEOUT * 4,
)
.await?;
serde_json::from_value(value).map_err(|e| {
VtaError::Protocol(format!(
"rooms/owner/group/renew response does not match its schema: {e}"
))
})
}
pub async fn room_owner_register(
&self,
request: RoomRegistration<'_>,
) -> Result<trust_tasks_rs::specs::rooms::owner::register::v0_1::Response, VtaError> {
let payload = room_register_payload(&request)?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_REGISTER_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT * 2,
)
.await?;
parse_response(value, "rooms/owner/register")
}
pub async fn room_owner_invite(
&self,
room_id: &str,
signing_key_id: &str,
subject: &str,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<trust_tasks_rs::specs::rooms::owner::invite::v0_1::Response, VtaError> {
use trust_tasks_rs::specs::rooms::owner::invite::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.signing_key_id(signing_key_id)
.subject(subject)
.valid_until(valid_until);
let payload = Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/owner/invite request: {e}")))?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_INVITE_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/owner/invite")
}
pub async fn room_owner_issue_membership(
&self,
room_id: &str,
signing_key_id: &str,
subject: &str,
valid_until: Option<chrono::DateTime<chrono::Utc>>,
) -> Result<trust_tasks_rs::specs::rooms::owner::issue_membership::v0_1::Response, VtaError>
{
use trust_tasks_rs::specs::rooms::owner::issue_membership::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.signing_key_id(signing_key_id)
.subject(subject)
.valid_until(valid_until);
let payload = Payload::try_from(payload).map_err(|e| {
VtaError::Validation(format!("rooms/owner/issue-membership request: {e}"))
})?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_ISSUE_MEMBERSHIP_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/owner/issue-membership")
}
pub async fn room_owner_issue_authority(
&self,
room_id: &str,
signing_key_id: &str,
subject: &str,
actions: Vec<RoomAction>,
valid_until: chrono::DateTime<chrono::Utc>,
) -> Result<trust_tasks_rs::specs::rooms::owner::issue_authority::v0_2::Response, VtaError>
{
let payload =
room_issue_authority_payload(room_id, signing_key_id, subject, actions, valid_until)?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_OWNER_ISSUE_AUTHORITY_0_2,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/owner/issue-authority")
}
pub async fn room_key_package(
&self,
room_id: &str,
invitation: &str,
) -> Result<trust_tasks_rs::specs::rooms::keys::key_package::v0_1::Response, VtaError> {
use trust_tasks_rs::specs::rooms::keys::key_package::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.invitation(Some(invitation.to_string()));
let payload = Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/keys/key-package request: {e}")))?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_KEY_PACKAGE_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/keys/key-package")
}
pub async fn room_welcome(
&self,
room_id: &str,
invitation: &str,
welcome: &str,
ratchet_tree: Option<&str>,
) -> Result<trust_tasks_rs::specs::rooms::keys::welcome::v0_1::Response, VtaError> {
use trust_tasks_rs::specs::rooms::keys::welcome::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.invitation(Some(invitation.to_string()))
.welcome(welcome)
.ratchet_tree(ratchet_tree.map(str::to_string));
let payload = Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/keys/welcome request: {e}")))?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_WELCOME_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/keys/welcome")
}
pub async fn room_commit(
&self,
room_id: &str,
commit: &str,
mls_epoch: std::num::NonZeroU64,
) -> Result<trust_tasks_rs::specs::rooms::keys::commit::v0_1::Response, VtaError> {
use trust_tasks_rs::specs::rooms::keys::commit::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.commit(commit)
.epoch(mls_epoch);
let payload = Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/keys/commit request: {e}")))?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_COMMIT_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/keys/commit")
}
pub async fn room_backfill(
&self,
room_id: &str,
host_did: &str,
from_epoch: Option<std::num::NonZeroU64>,
) -> Result<trust_tasks_rs::specs::rooms::keys::backfill::v0_1::Response, VtaError> {
use trust_tasks_rs::specs::rooms::keys::backfill::v0_1::Payload;
let payload = Payload::builder()
.room_id(room_id)
.host(host_did)
.from_epoch(from_epoch);
let payload = Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/keys/backfill request: {e}")))?;
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_BACKFILL_0_1,
serde_json::to_value(payload)?,
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/keys/backfill")
}
pub async fn room_keys_list(
&self,
) -> Result<trust_tasks_rs::specs::rooms::keys::list::v0_1::Response, VtaError> {
let value = self
.dispatch_trust_task(
trust_tasks::TASK_ROOMS_KEYS_LIST_0_1,
json!({}),
ROOM_TT_TIMEOUT,
)
.await?;
parse_response(value, "rooms/keys/list")
}
}
pub type RoomAction =
trust_tasks_rs::specs::rooms::owner::issue_authority::v0_2::PayloadActionsItem;
pub type RoomVisibility = trust_tasks_rs::specs::rooms::owner::register::v0_1::Visibility;
pub type RoomRetentionPolicy = trust_tasks_rs::specs::rooms::owner::register::v0_1::RetentionPolicy;
#[derive(Debug, Clone)]
pub struct RoomRegistration<'a> {
pub room_id: &'a str,
pub host_did: &'a str,
pub visibility: RoomVisibility,
pub owner_did: Option<&'a str>,
pub retention_days: Option<std::num::NonZeroU64>,
pub retention_policy: Option<RoomRetentionPolicy>,
}
fn room_register_payload(
r: &RoomRegistration<'_>,
) -> Result<trust_tasks_rs::specs::rooms::owner::register::v0_1::Payload, VtaError> {
use trust_tasks_rs::specs::rooms::owner::register::v0_1::Payload;
let payload = Payload::builder()
.room_id(r.room_id)
.host(r.host_did)
.visibility(r.visibility)
.owner_did(r.owner_did.map(str::to_string))
.retention_days(r.retention_days)
.retention_policy(r.retention_policy);
Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/owner/register request: {e}")))
}
fn room_issue_authority_payload(
room_id: &str,
signing_key_id: &str,
subject: &str,
actions: Vec<RoomAction>,
valid_until: chrono::DateTime<chrono::Utc>,
) -> Result<trust_tasks_rs::specs::rooms::owner::issue_authority::v0_2::Payload, VtaError> {
use trust_tasks_rs::specs::rooms::owner::issue_authority::v0_2::Payload;
if actions.is_empty() {
return Err(VtaError::Validation(
"rooms/owner/issue-authority needs at least one action (read, write, curate, admin)"
.into(),
));
}
let payload = Payload::builder()
.room_id(room_id)
.signing_key_id(signing_key_id)
.subject(subject)
.actions(actions)
.valid_until(valid_until);
Payload::try_from(payload)
.map_err(|e| VtaError::Validation(format!("rooms/owner/issue-authority request: {e}")))
}
fn parse_response<T: serde::de::DeserializeOwned>(value: Value, task: &str) -> Result<T, VtaError> {
serde_json::from_value(value)
.map_err(|e| VtaError::Protocol(format!("{task} response does not match its schema: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn registration() -> RoomRegistration<'static> {
RoomRegistration {
room_id: "did:webvh:Qm:rooms.example.org:deal",
host_did: "did:web:vtc.example.org",
visibility: "attributed".parse().unwrap(),
owner_did: Some("did:webvh:Qm:example.org:alice"),
retention_days: std::num::NonZeroU64::new(30),
retention_policy: Some("chained".parse().unwrap()),
}
}
#[test]
fn a_registration_is_built_through_the_generated_payload() {
let v = serde_json::to_value(room_register_payload(®istration()).unwrap()).unwrap();
assert_eq!(
v,
json!({
"roomId": "did:webvh:Qm:rooms.example.org:deal",
"host": "did:web:vtc.example.org",
"visibility": "attributed",
"ownerDid": "did:webvh:Qm:example.org:alice",
"retentionDays": 30,
"retentionPolicy": "chained",
})
);
let mut r = registration();
r.owner_did = None;
r.retention_days = None;
r.retention_policy = None;
let v = serde_json::to_value(room_register_payload(&r).unwrap()).unwrap();
assert!(v.get("ownerDid").is_none(), "{v}");
assert!(v.get("retentionPolicy").is_none(), "{v}");
}
#[test]
fn an_authority_grant_with_no_action_is_refused_before_it_is_sent() {
let until = chrono::Utc::now() + chrono::Duration::days(30);
let err =
room_issue_authority_payload("did:key:zRoom", "key-1", "did:key:zA", vec![], until)
.expect_err("an empty action list is refused");
assert!(err.to_string().contains("at least one action"), "{err}");
let ok = room_issue_authority_payload(
"did:key:zRoom",
"key-1",
"did:key:zA",
vec!["read".parse().unwrap(), "admin".parse().unwrap()],
until,
)
.unwrap();
let v = serde_json::to_value(ok).unwrap();
assert_eq!(v["actions"], json!(["read", "admin"]));
assert!(
v["validUntil"].is_string(),
"validUntil is always sent: {v}"
);
}
#[test]
fn only_the_four_room_actions_parse() {
for a in ["read", "write", "curate", "admin"] {
assert!(a.parse::<RoomAction>().is_ok(), "{a}");
}
assert!("all".parse::<RoomAction>().is_err());
assert!("Admin".parse::<RoomAction>().is_err());
}
}
pub fn unsigned_room_request(
type_uri: &str,
host: &str,
issuer: Option<&str>,
payload: Value,
) -> Result<trust_tasks_rs::specs::rooms::keys::act::v0_3::UnsignedRoomRequest, VtaError> {
if issuer.is_none() && is_offer_type(type_uri) {
return Err(VtaError::Validation(format!(
"`{type_uri}` is a reply to a community's offer: name the DID the offer is \
addressed to as `issuer`"
)));
}
if issuer.is_none() && payload.get("roomId").is_none() {
return Err(VtaError::Validation(format!(
"`{type_uri}` names no room, so the member to act as is the document's `issuer`: \
name the member whose request opened the transfer"
)));
}
let mut doc = json!({
"id": format!("urn:uuid:{}", uuid::Uuid::new_v4()),
"type": type_uri,
"recipient": host,
"issuedAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
"payload": payload,
});
if let Some(i) = issuer {
doc["issuer"] = json!(i);
}
serde_json::from_value(doc)
.map_err(|e| VtaError::Validation(format!("not a room request this can complete: {e}")))
}
pub fn is_offer_type(type_uri: &str) -> bool {
use trust_tasks_rs::Payload as _;
use trust_tasks_rs::specs::vtc::rooms::offers::{accept, list};
let bare = type_uri.strip_suffix("#request").unwrap_or(type_uri);
bare == list::v0_1::Payload::TYPE_URI || bare == accept::v0_1::Payload::TYPE_URI
}
#[cfg(test)]
mod act_tests {
use super::*;
#[test]
fn an_unsigned_room_request_names_no_one_but_the_host() {
let doc = unsigned_room_request(
"https://trusttasks.org/spec/rooms/records/get/0.1",
"did:key:zHost",
None,
json!({ "roomId": "did:key:zRoom", "key": "k" }),
)
.expect("a room request");
let v = serde_json::to_value(&doc).unwrap();
assert_eq!(v["recipient"], "did:key:zHost");
assert!(v.get("issuer").is_none());
assert!(v.get("proof").is_none());
assert!(v["id"].as_str().unwrap().starts_with("urn:uuid:"));
let named = unsigned_room_request(
"https://trusttasks.org/spec/rooms/records/get/0.1",
"did:key:zHost",
Some("did:webvh:example.com:people:alice"),
json!({ "roomId": "did:key:zRoom", "key": "k" }),
)
.unwrap();
assert_eq!(
serde_json::to_value(&named).unwrap()["issuer"],
"did:webvh:example.com:people:alice"
);
}
#[test]
fn a_request_that_is_not_a_room_request_is_refused() {
assert!(
unsigned_room_request(
"https://trusttasks.org/spec/keys/sign/0.1",
"did:key:zHost",
None,
json!({}),
)
.is_err()
);
assert!(
unsigned_room_request(
"https://trusttasks.org/spec/rooms/info/0.1",
"https://host.example",
None,
json!({ "roomId": "did:key:zRoom" }),
)
.is_err()
);
}
#[test]
fn an_offer_names_its_member() {
for t in [
"https://trusttasks.org/spec/vtc/rooms/offers/list/0.1",
"https://trusttasks.org/spec/vtc/rooms/offers/accept/0.1",
] {
let payload = if t.ends_with("accept/0.1") {
json!({ "roomId": "did:key:zRoom" })
} else {
json!({})
};
assert!(is_offer_type(t));
let err = unsigned_room_request(t, "did:key:zVtc", None, payload.clone())
.expect_err("no issuer");
assert!(matches!(err, VtaError::Validation(_)), "{t}: {err}");
unsigned_room_request(t, "did:key:zVtc", Some("did:key:zAlice"), payload)
.expect("named");
}
assert!(
unsigned_room_request(
"https://trusttasks.org/spec/vtc/rooms/community/members/add/0.1",
"did:key:zVtc",
Some("did:key:zAlice"),
json!({ "roomId": "did:key:zRoom" }),
)
.is_err()
);
}
#[test]
fn a_room_less_request_names_its_member() {
for (type_uri, payload) in [
(
"https://trusttasks.org/spec/rooms/blobs/chunk/0.1",
json!({ "downloadId": "d", "index": 0 }),
),
(
"https://trusttasks.org/spec/rooms/blobs/upload/commit/0.2",
json!({ "uploadId": "u" }),
),
(
"https://trusttasks.org/spec/rooms/blobs/upload/abort/0.2",
json!({ "uploadId": "u" }),
),
] {
let err = unsigned_room_request(type_uri, "did:key:zHost", None, payload.clone())
.expect_err("no issuer");
assert!(matches!(err, VtaError::Validation(_)), "{type_uri}: {err}");
let named = unsigned_room_request(
type_uri,
"did:key:zHost",
Some("did:webvh:example.com:people:alice"),
payload,
)
.expect("named");
assert_eq!(
serde_json::to_value(&named).unwrap()["issuer"],
"did:webvh:example.com:people:alice"
);
}
}
}