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 openmls::prelude::LeafNodeIndex;
use crate::error::RoomKeyError;
use crate::mls::{MembershipChange, RoomGroup};
use crate::retention::{self, EpochKeyChain};
use crate::wire::{EpochLink, SealedContent};
pub struct SealedRoom {
room_id: String,
group: RoomGroup,
chain: EpochKeyChain,
}
impl SealedRoom {
pub fn new(room_id: impl Into<String>, group: RoomGroup) -> Self {
let room_id = room_id.into();
Self {
chain: EpochKeyChain::new(&room_id),
room_id,
group,
}
}
fn reanchor(&mut self) -> Result<(), RoomKeyError> {
let epoch = self.room_epoch();
let key = self.group.storage_key()?;
self.chain.reanchor(epoch, key);
Ok(())
}
pub fn add_links(&mut self, links: impl IntoIterator<Item = EpochLink>) {
self.chain.add_links(links);
}
pub fn links(&self) -> Vec<EpochLink> {
self.chain.links()
}
pub fn earliest_readable_epoch(&mut self) -> Result<u32, RoomKeyError> {
self.reanchor()?;
Ok(self.chain.earliest_reachable())
}
pub fn room_id(&self) -> &str {
&self.room_id
}
pub fn group(&self) -> &RoomGroup {
&self.group
}
pub fn add_member(
&mut self,
key_package: &[u8],
) -> Result<(MembershipChange, Option<EpochLink>), RoomKeyError> {
let outgoing = self.group.storage_key()?;
let change = self.group.add_member_from_bytes(key_package)?;
let link = self.mint_link(outgoing)?;
self.chain.add_links(link.clone());
Ok((change, link))
}
pub fn remove_member(
&mut self,
index: LeafNodeIndex,
) -> Result<(MembershipChange, Option<EpochLink>), RoomKeyError> {
let outgoing = self.group.storage_key()?;
let change = self.group.remove_member(index)?;
let link = self.mint_link(outgoing)?;
self.chain.add_links(link.clone());
Ok((change, link))
}
pub fn apply_commit(
&mut self,
commit: &[u8],
) -> Result<(u32, Option<EpochLink>), RoomKeyError> {
let outgoing = self.group.storage_key()?;
let mls_epoch = self.group.apply_commit(commit)?;
let link = self.mint_link(outgoing)?;
self.chain.add_links(link.clone());
let epoch = u32::try_from(mls_epoch + 1)
.map_err(|_| RoomKeyError::Group(format!("epoch {mls_epoch} exceeds u32")))?;
Ok((epoch, link))
}
fn mint_link(
&self,
outgoing: retention::StorageKey,
) -> Result<Option<EpochLink>, RoomKeyError> {
let epoch = self.room_epoch();
if epoch < 2 {
return Ok(None);
}
Ok(Some(retention::seal_link(
&self.room_id,
epoch,
&self.group.storage_key()?,
&outgoing,
)?))
}
pub fn room_epoch(&self) -> u32 {
(self.group.epoch() + 1) as u32
}
pub fn opaque_key() -> String {
let mut bytes = [0u8; 16];
getrandom::fill(&mut bytes).expect("OS randomness unavailable");
B64.encode(bytes)
}
pub fn seal_record(
&self,
key: &str,
version: u64,
plaintext: &[u8],
) -> Result<SealedContent, RoomKeyError> {
let epoch = self.room_epoch();
let storage_key = self.group.storage_key()?;
let aad = associated_data(&self.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| RoomKeyError::Seal(format!("seal record: {e}")))?;
Ok(SealedContent {
ciphertext: B64.encode(ciphertext),
nonce: B64.encode(nonce_bytes),
epoch,
})
}
pub fn open_record(
&mut self,
key: &str,
version: u64,
sealed: &SealedContent,
) -> Result<Vec<u8>, RoomKeyError> {
self.reanchor()?;
let storage_key = self.chain.key_for(sealed.epoch)?;
let aad = associated_data(&self.room_id, key, version, sealed.epoch);
let ciphertext = B64
.decode(&sealed.ciphertext)
.map_err(|e| RoomKeyError::Seal(format!("decode ciphertext: {e}")))?;
let nonce = B64
.decode(&sealed.nonce)
.map_err(|e| RoomKeyError::Seal(format!("decode nonce: {e}")))?;
if nonce.len() != 12 {
return Err(RoomKeyError::Seal(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(|_| RoomKeyError::DidNotOpen)
}
pub fn epoch_anchor(&self) -> Vec<u8> {
self.group.epoch_authenticator()
}
}
fn associated_data(room_id: &str, key: &str, version: u64, epoch: u32) -> Vec<u8> {
format!("{room_id}|{key}|{version}|{epoch}").into_bytes()
}
#[cfg(test)]
mod tests {
use super::*;
fn room(did: &str) -> SealedRoom {
let group = RoomGroup::create("did:key:zAlice").expect("group");
SealedRoom::new(did, group)
}
#[test]
fn a_record_round_trips_under_the_group_key() {
let mut 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");
}
#[test]
fn a_relocated_record_does_not_open() {
let mut 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 mut moved = SealedRoom::new(
"did:webvh:zOther",
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");
let mut outsider = SealedRoom::new(
"did:webvh:zRoom",
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"
);
}
#[test]
fn sealing_the_same_plaintext_twice_uses_a_fresh_nonce() {
let mut 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);
assert_eq!(r.open_record("k1", 1, &a).unwrap(), b"same");
assert_eq!(r.open_record("k1", 1, &b).unwrap(), b"same");
}
}