use base64::Engine as _;
use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
use vti_common::error::AppError;
use vti_common::store::KeyspaceHandle;
use vti_rooms::mls::{GroupSnapshot, IdentitySnapshot, RoomGroup};
use vti_rooms::sealed::SealedRoom;
use vti_rooms::wire::EpochLink;
const GROUP_PREFIX: &str = "room-group:";
fn group_key(room_id: &str) -> String {
format!("{GROUP_PREFIX}{room_id}")
}
pub(super) fn invitation_key(credential_id: &str) -> String {
format!("room-vic:{credential_id}")
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PendingKeyPackage {
pub snapshot: IdentitySnapshot,
pub key_package: String,
pub expires_at: u64,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RoomGroupRecord {
pub snapshot: GroupSnapshot,
pub member_did: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub links: Vec<EpochLink>,
pub updated_at: u64,
}
pub async fn mint_key_package(
groups: &KeyspaceHandle,
room_id: &str,
member_did: &str,
lifetime_secs: u64,
now: u64,
) -> Result<PendingKeyPackage, AppError> {
let (snapshot, package) = IdentitySnapshot::mint(member_did)
.map_err(|e| AppError::Internal(format!("mint a key package: {e}")))?;
let pending = PendingKeyPackage {
snapshot,
key_package: B64.encode(&package),
expires_at: now + lifetime_secs,
};
groups
.insert(pending_key(room_id), &pending)
.await
.map_err(|e| AppError::Internal(format!("store the pending key package: {e}")))?;
Ok(pending)
}
fn pending_key(room_id: &str) -> String {
format!("room-kp:{room_id}")
}
pub async fn join(
groups: &KeyspaceHandle,
room_id: &str,
member_did: &str,
welcome: &[u8],
now: u64,
) -> Result<u64, AppError> {
if load(groups, room_id).await?.is_some() {
return Err(AppError::Conflict(format!(
"this VTA already holds group state for room `{room_id}`; leaving and rejoining \
is a removal and a fresh invitation, not a second welcome"
)));
}
let pending: Option<PendingKeyPackage> = groups
.get(pending_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("read the pending key package: {e}")))?;
let pending = pending.ok_or_else(|| {
AppError::Validation(format!(
"no key package was minted for room `{room_id}`; a welcome can only be accepted \
against the package the owner was given"
))
})?;
let group = RoomGroup::join_from_identity(&pending.snapshot, welcome)
.map_err(|e| AppError::Validation(format!("the welcome did not process: {e}")))?;
let epoch = group.epoch();
store(groups, room_id, member_did, &group, Vec::new(), now).await?;
groups
.remove(pending_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("clear the pending key package: {e}")))?;
Ok(epoch)
}
pub async fn apply_commit(
groups: &KeyspaceHandle,
room_id: &str,
commit: &[u8],
claimed_epoch: u64,
now: u64,
) -> Result<u64, AppError> {
let record = load(groups, room_id).await?.ok_or_else(|| {
AppError::NotFound(format!(
"this VTA holds no group state for room `{room_id}`"
))
})?;
let mut room = SealedRoom::new(
room_id,
RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group: {e}")))?,
);
let current = room.group().epoch();
if claimed_epoch == current {
return Ok(current);
}
if claimed_epoch != current + 1 {
return Err(AppError::Conflict(format!(
"commit produces epoch {claimed_epoch} but room `{room_id}` is at {current}; \
resume from {}",
current + 1
)));
}
let (_, link) = room
.apply_commit(commit)
.map_err(|e| AppError::Validation(format!("the commit did not process: {e}")))?;
let epoch = room.group().epoch();
let mut links = record.links;
if let Some(link) = link {
links.push(link);
}
store(
groups,
room_id,
&record.member_did,
room.group(),
links,
now,
)
.await?;
Ok(epoch)
}
pub async fn seal_record(
groups: &KeyspaceHandle,
room_id: &str,
key: &str,
version: u64,
plaintext: &[u8],
) -> Result<vti_rooms::wire::SealedContent, AppError> {
let record = load(groups, room_id).await?.ok_or_else(|| {
AppError::NotFound(format!(
"this VTA holds no group state for room `{room_id}`"
))
})?;
let group = RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group: {e}")))?;
SealedRoom::new(room_id, group)
.seal_record(key, version, plaintext)
.map_err(|e| AppError::Validation(e.to_string()))
}
pub async fn list_rooms(groups: &KeyspaceHandle) -> Result<Vec<HeldRoom>, AppError> {
let pairs = groups.prefix_iter_raw(GROUP_PREFIX.to_string()).await?;
let mut out = Vec::with_capacity(pairs.len());
for (k, v) in pairs {
let record: RoomGroupRecord = serde_json::from_slice(&v).map_err(|e| {
let which = String::from_utf8_lossy(&k).to_string();
AppError::Internal(format!("decode group state at `{which}`: {e}"))
})?;
let room_id = String::from_utf8_lossy(&k)
.strip_prefix(GROUP_PREFIX)
.unwrap_or_default()
.to_string();
let group = RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group for `{room_id}`: {e}")))?;
let mut room = SealedRoom::new(&room_id, group);
room.add_links(record.links);
let epoch = room.room_epoch();
let earliest = room
.earliest_readable_epoch()
.map_err(|e| AppError::Internal(format!("walk the chain for `{room_id}`: {e}")))?;
out.push(HeldRoom {
room_id,
epoch,
earliest_readable_epoch: earliest,
});
}
out.sort_by(|a, b| a.room_id.cmp(&b.room_id));
Ok(out)
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct HeldRoom {
pub room_id: String,
pub epoch: u32,
pub earliest_readable_epoch: u32,
}
pub async fn store_links(
groups: &KeyspaceHandle,
room_id: &str,
incoming: Vec<EpochLink>,
now: u64,
) -> Result<(u32, usize), AppError> {
let record = load(groups, room_id).await?.ok_or_else(|| {
AppError::NotFound(format!(
"this VTA holds no group state for room `{room_id}`"
))
})?;
let mut links = record.links;
let mut stored = 0;
for link in incoming {
if !links.iter().any(|held| held.epoch == link.epoch) {
links.push(link);
stored += 1;
}
}
links.sort_by_key(|l| l.epoch);
let group = RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group: {e}")))?;
let mut room = SealedRoom::new(room_id, group);
room.add_links(links.clone());
let earliest = room
.earliest_readable_epoch()
.map_err(|e| AppError::Internal(format!("walk the chain: {e}")))?;
store(
groups,
room_id,
&record.member_did,
room.group(),
links,
now,
)
.await?;
Ok((earliest, stored))
}
pub async fn open_record(
groups: &KeyspaceHandle,
room_id: &str,
key: &str,
version: u64,
ciphertext: &str,
nonce: &str,
epoch: u32,
) -> Result<Vec<u8>, AppError> {
let record = load(groups, room_id).await?.ok_or_else(|| {
AppError::NotFound(format!(
"this VTA holds no group state for room `{room_id}`"
))
})?;
let group = RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group: {e}")))?;
let held = group.epoch() + 1;
if u64::from(epoch) > held {
return Err(AppError::Validation(format!(
"record is sealed under epoch {epoch} and this VTA holds room `{room_id}` at \
epoch {held}; a commit has not been delivered"
)));
}
let mut room = SealedRoom::new(room_id, group);
room.add_links(record.links);
room.open_record(
key,
version,
&vti_rooms::wire::SealedContent {
ciphertext: ciphertext.to_string(),
nonce: nonce.to_string(),
epoch,
},
)
.map_err(|e| AppError::Validation(e.to_string()))
}
pub async fn consume_invitation(
invitations: &KeyspaceHandle,
credential_id: &str,
room_id: &str,
now: u64,
) -> Result<(), AppError> {
let key = invitation_key(credential_id);
if invitations
.get_raw(key.clone())
.await
.map_err(|e| AppError::Internal(format!("read the invitation record: {e}")))?
.is_some()
{
return Err(AppError::Conflict(format!(
"invitation `{credential_id}` has already been used"
)));
}
invitations
.insert(
key,
&serde_json::json!({ "roomId": room_id, "consumedAt": now }),
)
.await
.map_err(|e| AppError::Internal(format!("record the invitation: {e}")))
}
pub async fn load(
groups: &KeyspaceHandle,
room_id: &str,
) -> Result<Option<RoomGroupRecord>, AppError> {
groups
.get(group_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("read group state for `{room_id}`: {e}")))
}
async fn store(
groups: &KeyspaceHandle,
room_id: &str,
member_did: &str,
group: &RoomGroup,
links: Vec<EpochLink>,
now: u64,
) -> Result<(), AppError> {
let record = RoomGroupRecord {
snapshot: group
.snapshot()
.map_err(|e| AppError::Internal(format!("snapshot the group: {e}")))?,
member_did: member_did.to_string(),
links,
updated_at: now,
};
groups
.insert(group_key(room_id), &record)
.await
.map_err(|e| AppError::Internal(format!("store group state for `{room_id}`: {e}")))
}
const ROOTS_PREFIX: &str = "room-roots:";
fn roots_key(room_id: &str) -> String {
format!("{ROOTS_PREFIX}{room_id}")
}
const KEEP_ROOTS: usize = 16;
#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RootHistory {
pub seen: Vec<RootObservation>,
pub highest: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub caught_at: Option<u64>,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct RootObservation {
pub head_version: u64,
pub root: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RootVerdict {
Agree,
Conflict,
NoneHeld,
}
pub async fn observe_head(
groups: &KeyspaceHandle,
room_id: &str,
head_version: u64,
root: &str,
) -> Result<RootVerdict, AppError> {
let key = roots_key(room_id);
let mut history: RootHistory = groups
.get(key.clone())
.await
.map_err(|e| AppError::Internal(format!("read the root history of `{room_id}`: {e}")))?
.unwrap_or_default();
let verdict = match history.seen.iter().find(|o| o.head_version == head_version) {
Some(prior) if prior.root == root => RootVerdict::Agree,
Some(_) => RootVerdict::Conflict,
None => RootVerdict::NoneHeld,
};
if matches!(verdict, RootVerdict::Conflict) {
history.caught_at = Some(
history
.caught_at
.map_or(head_version, |v| v.min(head_version)),
);
}
if matches!(verdict, RootVerdict::NoneHeld) {
history.seen.push(RootObservation {
head_version,
root: root.to_string(),
});
history.seen.sort_by_key(|o| o.head_version);
if history.seen.len() > KEEP_ROOTS {
let excess = history.seen.len() - KEEP_ROOTS;
history.seen.drain(0..excess);
}
}
history.highest = history.highest.max(head_version);
groups
.insert(key, &history)
.await
.map_err(|e| AppError::Internal(format!("record the root history of `{room_id}`: {e}")))?;
Ok(verdict)
}
pub async fn root_history(groups: &KeyspaceHandle, room_id: &str) -> Result<RootHistory, AppError> {
groups
.get(roots_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("read the root history of `{room_id}`: {e}")))
.map(Option::unwrap_or_default)
}
pub async fn forget(groups: &KeyspaceHandle, room_id: &str) -> Result<(), AppError> {
groups
.remove(group_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("discard group state for `{room_id}`: {e}")))?;
groups
.remove(pending_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("discard a pending key package: {e}")))?;
groups
.remove(roots_key(room_id))
.await
.map_err(|e| AppError::Internal(format!("discard the root history of `{room_id}`: {e}")))
}
pub async fn epoch_authenticator(
groups: &KeyspaceHandle,
room_id: &str,
) -> Result<(u64, Vec<u8>), AppError> {
let record = load(groups, room_id).await?.ok_or_else(|| {
AppError::Validation(format!(
"this agent holds no group state for `{room_id}`, so it cannot say what epoch the \
room is at"
))
})?;
let group = RoomGroup::restore(&record.snapshot)
.map_err(|e| AppError::Internal(format!("restore the group for `{room_id}`: {e}")))?;
Ok((group.epoch(), group.epoch_authenticator()))
}
#[cfg(test)]
mod root_memory_tests {
use super::*;
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";
async fn open() -> (tempfile::TempDir, 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_first_reading_holds_nothing_to_compare() {
let (_d, ks) = open().await;
assert_eq!(
observe_head(&ks, ROOM, 412, A).await.unwrap(),
RootVerdict::NoneHeld
);
}
#[tokio::test]
async fn two_roots_at_one_version_is_a_host_caught() {
let (_d, ks) = open().await;
observe_head(&ks, ROOM, 412, A).await.unwrap();
assert_eq!(
observe_head(&ks, ROOM, 412, A).await.unwrap(),
RootVerdict::Agree
);
assert_eq!(
observe_head(&ks, ROOM, 412, B).await.unwrap(),
RootVerdict::Conflict
);
}
#[tokio::test]
async fn a_different_version_is_a_different_moment() {
let (_d, ks) = open().await;
observe_head(&ks, ROOM, 412, A).await.unwrap();
assert_eq!(
observe_head(&ks, ROOM, 413, B).await.unwrap(),
RootVerdict::NoneHeld,
"a write moved the head, so a different root explains itself"
);
}
#[tokio::test]
async fn a_head_that_goes_backwards_is_still_compared() {
let (_d, ks) = open().await;
observe_head(&ks, ROOM, 412, A).await.unwrap();
observe_head(&ks, ROOM, 500, B).await.unwrap();
assert_eq!(
observe_head(&ks, ROOM, 412, B).await.unwrap(),
RootVerdict::Conflict,
"the older version is still held, and its root still disagrees"
);
}
#[tokio::test]
async fn the_history_is_bounded_and_the_high_water_mark_survives_it() {
let (_d, ks) = open().await;
for v in 1..=(KEEP_ROOTS as u64 + 5) {
observe_head(&ks, ROOM, v, A).await.unwrap();
}
let history = root_history(&ks, ROOM).await.unwrap();
assert_eq!(history.seen.len(), KEEP_ROOTS);
assert_eq!(history.highest, KEEP_ROOTS as u64 + 5);
assert_eq!(
history.seen.first().unwrap().head_version,
6,
"the oldest versions are what gets dropped"
);
}
#[tokio::test]
async fn forgetting_a_room_forgets_what_its_host_said() {
let (_d, ks) = open().await;
observe_head(&ks, ROOM, 412, A).await.unwrap();
forget(&ks, ROOM).await.unwrap();
assert_eq!(
observe_head(&ks, ROOM, 412, B).await.unwrap(),
RootVerdict::NoneHeld,
"nothing survived the removal to compare against"
);
}
}