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;
fn group_key(room_id: &str) -> String {
format!("room-group:{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 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}")))
}
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}")))
}