use crate::managed_group_crypto::ManagedGroupMember;
use anyhow::{anyhow, bail, Result};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet};
use zeroize::Zeroize;
const MAX_ROOM_MEMBERS: usize = 5_000;
#[cfg(feature = "adaptive-room-sentinel")]
pub(crate) const MAX_PREPARE_PAGE_MEMBERS: usize = 4;
#[cfg(not(feature = "adaptive-room-sentinel"))]
const MAX_PREPARE_PAGE_MEMBERS: usize = 4;
#[cfg(feature = "adaptive-room-sentinel")]
pub(crate) const MAX_ARTIFACT_CHUNKS: usize = 512;
#[cfg(not(feature = "adaptive-room-sentinel"))]
const MAX_ARTIFACT_CHUNKS: usize = 512;
const RAW_ARTIFACT_CHUNK_BYTES: usize = 36 * 1024;
const MAX_CIPHERTEXT_BYTES: usize = 48 * 1024;
const MAX_CHANNEL_BYTES: usize = 80;
const MAX_MESSAGE_ID_BYTES: usize = 160;
const MAX_ZONE_ID_BYTES: usize = 160;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManagedPrepareMember {
pub device_id: String,
pub key_package_hash: String,
pub key_package: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManagedPreparePage {
pub preparation_id: String,
pub previous_encryption_epoch: u64,
pub encryption_epoch: u64,
pub committer_device_id: String,
pub group_id: String,
pub member_set_hash: String,
pub page_index: usize,
pub page_count: usize,
pub member_count: usize,
pub welcome_required: bool,
pub members: Vec<ManagedPrepareMember>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub(crate) enum ManagedArtifactKind {
Commit,
Welcome,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct ManagedArtifactChunk {
pub preparation_id: String,
pub previous_encryption_epoch: u64,
pub encryption_epoch: u64,
pub group_id_hash: String,
pub member_set_hash: String,
pub artifact: ManagedArtifactKind,
pub artifact_hash: String,
pub chunk_index: usize,
pub chunk_count: usize,
pub chunk: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(
tag = "type",
rename_all = "kebab-case",
rename_all_fields = "camelCase"
)]
pub(crate) enum ManagedGroupAction {
PublishKeyPackage {
key_package_hash: String,
key_package: String,
},
RequestPreparePage {
preparation_id: String,
page_index: usize,
},
PersistState {
sealed_state: String,
},
SendArtifactChunk {
chunk: ManagedArtifactChunk,
},
AcknowledgeCommit {
preparation_id: String,
encryption_epoch: u64,
group_id_hash: String,
},
}
pub(crate) struct ManagedProtectedPayload {
pub(crate) data: Vec<u8>,
pub(crate) sealed_state: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PreparationHeader {
preparation_id: String,
previous_encryption_epoch: u64,
encryption_epoch: u64,
committer_device_id: String,
group_id: String,
member_set_hash: String,
page_count: usize,
member_count: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingPreparation {
header: PreparationHeader,
pages: BTreeMap<usize, Vec<ManagedPrepareMember>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct PendingArtifact {
header: ManagedArtifactChunk,
chunks: BTreeMap<usize, Vec<u8>>,
}
pub(crate) struct ManagedGroupController {
device_id: String,
wrapping_key: [u8; 32],
member: ManagedGroupMember,
preparation: Option<PendingPreparation>,
artifact: Option<PendingArtifact>,
}
impl Drop for ManagedGroupController {
fn drop(&mut self) {
self.wrapping_key.zeroize();
}
}
impl ManagedGroupController {
pub(crate) fn new(
device_id: impl Into<String>,
wrapping_key: [u8; 32],
sealed_state: Option<&[u8]>,
) -> Result<Self> {
let device_id = device_id.into();
let member = match sealed_state {
Some(state) => {
ManagedGroupMember::restore_persisted_state(&device_id, &wrapping_key, state)?
}
None => ManagedGroupMember::new(&device_id)?,
};
Ok(Self {
device_id,
wrapping_key,
member,
preparation: None,
artifact: None,
})
}
pub(crate) fn publish_key_package(&self) -> Result<ManagedGroupAction> {
let key_package = URL_SAFE_NO_PAD.encode(self.member.key_package()?);
Ok(ManagedGroupAction::PublishKeyPackage {
key_package_hash: hash_bytes(key_package.as_bytes()),
key_package,
})
}
pub(crate) fn encryption_epoch(&self) -> Option<u64> {
self.member.encryption_epoch()
}
pub(crate) fn handle_prepare_page(
&mut self,
page: ManagedPreparePage,
) -> Result<Vec<ManagedGroupAction>> {
validate_prepare_page(&page)?;
if page.committer_device_id != self.device_id {
bail!("managed preparation was delivered to a non-committer");
}
let header = prepare_header(&page);
match self.preparation.as_mut() {
Some(pending) if pending.header == header => {}
Some(_) if page.page_index == 0 => {
self.preparation = Some(PendingPreparation {
header: header.clone(),
pages: BTreeMap::new(),
});
}
Some(_) => bail!("managed preparation page belongs to a stale generation"),
None if page.page_index == 0 => {
self.preparation = Some(PendingPreparation {
header: header.clone(),
pages: BTreeMap::new(),
});
}
None => bail!("managed preparation must begin with page zero"),
}
let pending = self.preparation.as_mut().expect("preparation initialized");
match pending.pages.get(&page.page_index) {
Some(existing) if existing == &page.members => {}
Some(_) => bail!("managed preparation page conflicts with an earlier page"),
None => {
pending.pages.insert(page.page_index, page.members);
}
}
if pending.pages.len() < pending.header.page_count {
let page_index = (0..pending.header.page_count)
.find(|index| !pending.pages.contains_key(index))
.ok_or_else(|| anyhow!("managed preparation page accounting is invalid"))?;
return Ok(vec![ManagedGroupAction::RequestPreparePage {
preparation_id: pending.header.preparation_id.clone(),
page_index,
}]);
}
let pending = self
.preparation
.take()
.ok_or_else(|| anyhow!("managed preparation disappeared"))?;
let mut members = Vec::with_capacity(pending.header.member_count);
for (_, page_members) in pending.pages {
members.extend(page_members);
}
if members.len() != pending.header.member_count {
bail!("managed preparation member count does not match its manifest");
}
validate_member_set(&members, &pending.header.member_set_hash)?;
let packages = members
.iter()
.map(|member| {
URL_SAFE_NO_PAD
.decode(&member.key_package)
.map_err(|_| anyhow!("managed KeyPackage encoding is invalid"))
})
.collect::<Result<Vec<_>>>()?;
for (member, key_package) in members.iter().zip(&packages) {
if ManagedGroupMember::key_package_identity(key_package)? != member.device_id.as_bytes()
{
bail!("managed KeyPackage identity does not match its admitted device");
}
}
match self.member.encryption_epoch() {
None if pending.header.previous_encryption_epoch == 0 => {
self.member.create_group(&pending.header.group_id)?;
}
Some(epoch) if epoch == pending.header.previous_encryption_epoch => {}
Some(0) if pending.header.previous_encryption_epoch == 0 => {}
_ => bail!("managed preparation does not extend the local encryption epoch"),
}
let update = self.member.rekey_members(&packages)?;
if update.epoch != pending.header.encryption_epoch {
bail!("managed preparation produced an unexpected encryption epoch");
}
let group_id_hash = hash_bytes(pending.header.group_id.as_bytes());
let mut actions = vec![self.persist_action()?];
actions.extend(artifact_actions(
&pending.header,
&group_id_hash,
ManagedArtifactKind::Commit,
&update.commit,
)?);
if let Some(welcome) = update.welcome.as_deref() {
actions.extend(artifact_actions(
&pending.header,
&group_id_hash,
ManagedArtifactKind::Welcome,
welcome,
)?);
}
actions.push(ManagedGroupAction::AcknowledgeCommit {
preparation_id: pending.header.preparation_id,
encryption_epoch: pending.header.encryption_epoch,
group_id_hash,
});
Ok(actions)
}
pub(crate) fn handle_artifact_chunk(
&mut self,
chunk: ManagedArtifactChunk,
) -> Result<Vec<ManagedGroupAction>> {
validate_artifact_chunk(&chunk)?;
let bytes = URL_SAFE_NO_PAD
.decode(&chunk.chunk)
.map_err(|_| anyhow!("managed artifact chunk encoding is invalid"))?;
match self.artifact.as_mut() {
Some(pending) if same_artifact(&pending.header, &chunk) => {}
Some(_) if chunk.chunk_index == 0 => {
self.artifact = Some(PendingArtifact {
header: chunk.clone(),
chunks: BTreeMap::new(),
});
}
Some(_) => bail!("managed artifact chunk belongs to a stale artifact"),
None if chunk.chunk_index == 0 => {
self.artifact = Some(PendingArtifact {
header: chunk.clone(),
chunks: BTreeMap::new(),
});
}
None => bail!("managed artifact must begin with chunk zero"),
}
let pending = self.artifact.as_mut().expect("artifact initialized");
match pending.chunks.get(&chunk.chunk_index) {
Some(existing) if existing == &bytes => {}
Some(_) => bail!("managed artifact chunk conflicts with an earlier chunk"),
None => {
pending.chunks.insert(chunk.chunk_index, bytes);
}
}
if pending.chunks.len() < pending.header.chunk_count {
return Ok(Vec::new());
}
let pending = self
.artifact
.take()
.ok_or_else(|| anyhow!("managed artifact disappeared"))?;
let mut artifact = Vec::new();
for (_, bytes) in pending.chunks {
artifact.extend_from_slice(&bytes);
}
if hash_bytes(&artifact) != pending.header.artifact_hash {
bail!("managed artifact hash does not match its manifest");
}
let epoch = match pending.header.artifact {
ManagedArtifactKind::Commit => {
if self.member.encryption_epoch() != Some(pending.header.previous_encryption_epoch)
{
bail!("managed commit does not extend the local encryption epoch");
}
self.member.apply_commit(&artifact)?
}
ManagedArtifactKind::Welcome => {
if self.member.encryption_epoch().is_some() {
bail!("managed Welcome cannot replace an attached group");
}
self.member.join_group(&artifact)?
}
};
if epoch != pending.header.encryption_epoch {
bail!("managed artifact produced an unexpected encryption epoch");
}
Ok(vec![
self.persist_action()?,
ManagedGroupAction::AcknowledgeCommit {
preparation_id: pending.header.preparation_id,
encryption_epoch: pending.header.encryption_epoch,
group_id_hash: pending.header.group_id_hash,
},
])
}
pub(crate) fn seal_payload(
&mut self,
architecture_epoch: u64,
encryption_epoch: u64,
message_id: &str,
channel: &str,
priority: u8,
zone_id: Option<&str>,
payload: &[u8],
) -> Result<ManagedProtectedPayload> {
validate_envelope_metadata(message_id, channel, priority, zone_id)?;
if self.member.encryption_epoch() != Some(encryption_epoch) {
bail!("managed publish encryption epoch is not settled locally");
}
let data = self.member.seal(
payload,
&envelope_aad(
architecture_epoch,
encryption_epoch,
message_id,
channel,
priority,
zone_id,
)?,
)?;
Ok(ManagedProtectedPayload {
data,
sealed_state: self.member.seal_persisted_state(&self.wrapping_key)?,
})
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn open_payload(
&mut self,
architecture_epoch: u64,
encryption_epoch: u64,
message_id: &str,
channel: &str,
priority: u8,
zone_id: Option<&str>,
ciphertext: &[u8],
) -> Result<ManagedProtectedPayload> {
validate_envelope_metadata(message_id, channel, priority, zone_id)?;
if ciphertext.is_empty() || ciphertext.len() > MAX_CIPHERTEXT_BYTES {
bail!("managed ciphertext exceeds its protected bound");
}
if self.member.encryption_epoch() != Some(encryption_epoch) {
bail!("managed receive encryption epoch is not settled locally");
}
let data = self.member.open(
ciphertext,
&envelope_aad(
architecture_epoch,
encryption_epoch,
message_id,
channel,
priority,
zone_id,
)?,
)?;
Ok(ManagedProtectedPayload {
data,
sealed_state: self.member.seal_persisted_state(&self.wrapping_key)?,
})
}
fn persist_action(&self) -> Result<ManagedGroupAction> {
Ok(ManagedGroupAction::PersistState {
sealed_state: URL_SAFE_NO_PAD
.encode(self.member.seal_persisted_state(&self.wrapping_key)?),
})
}
}
fn prepare_header(page: &ManagedPreparePage) -> PreparationHeader {
PreparationHeader {
preparation_id: page.preparation_id.clone(),
previous_encryption_epoch: page.previous_encryption_epoch,
encryption_epoch: page.encryption_epoch,
committer_device_id: page.committer_device_id.clone(),
group_id: page.group_id.clone(),
member_set_hash: page.member_set_hash.clone(),
page_count: page.page_count,
member_count: page.member_count,
}
}
fn validate_prepare_page(page: &ManagedPreparePage) -> Result<()> {
safe_id(&page.preparation_id, 160, "preparation id")?;
safe_id(&page.committer_device_id, 160, "committer device id")?;
safe_id(&page.group_id, 512, "group id")?;
valid_hash(&page.member_set_hash, "member set hash")?;
if page.encryption_epoch != page.previous_encryption_epoch.saturating_add(1) {
bail!("managed preparation encryption epoch is not monotonic");
}
if page.page_count == 0
|| page.page_count > MAX_ROOM_MEMBERS.div_ceil(MAX_PREPARE_PAGE_MEMBERS)
|| page.page_index >= page.page_count
|| page.member_count == 0
|| page.member_count > MAX_ROOM_MEMBERS
|| page.members.is_empty()
|| page.members.len() > MAX_PREPARE_PAGE_MEMBERS
{
bail!("managed preparation page exceeds its protected bounds");
}
for member in &page.members {
safe_id(&member.device_id, 160, "managed member device id")?;
valid_hash(&member.key_package_hash, "KeyPackage hash")?;
if member.key_package.is_empty() || member.key_package.len() > 12 * 1024 {
bail!("managed KeyPackage exceeds its protected bound");
}
if hash_bytes(member.key_package.as_bytes()) != member.key_package_hash {
bail!("managed KeyPackage hash does not match its payload");
}
}
Ok(())
}
fn validate_member_set(members: &[ManagedPrepareMember], expected_hash: &str) -> Result<()> {
let mut identities = BTreeSet::new();
let mut rows = Vec::with_capacity(members.len());
for member in members {
if !identities.insert(member.device_id.clone()) {
bail!("managed preparation contains a duplicate member");
}
rows.push((&member.device_id, &member.key_package_hash));
}
rows.sort();
let encoded = serde_json::to_vec(&rows)
.map_err(|error| anyhow!("managed member set encoding failed: {error}"))?;
if hash_bytes(&encoded) != expected_hash {
bail!("managed member set hash does not match its admitted roster");
}
Ok(())
}
fn artifact_actions(
header: &PreparationHeader,
group_id_hash: &str,
artifact: ManagedArtifactKind,
bytes: &[u8],
) -> Result<Vec<ManagedGroupAction>> {
if bytes.is_empty() {
bail!("managed group artifact is empty");
}
let chunk_count = bytes.len().div_ceil(RAW_ARTIFACT_CHUNK_BYTES);
if chunk_count == 0 || chunk_count > MAX_ARTIFACT_CHUNKS {
bail!("managed group artifact exceeds its protected chunk bound");
}
let artifact_hash = hash_bytes(bytes);
Ok(bytes
.chunks(RAW_ARTIFACT_CHUNK_BYTES)
.enumerate()
.map(
|(chunk_index, bytes)| ManagedGroupAction::SendArtifactChunk {
chunk: ManagedArtifactChunk {
preparation_id: header.preparation_id.clone(),
previous_encryption_epoch: header.previous_encryption_epoch,
encryption_epoch: header.encryption_epoch,
group_id_hash: group_id_hash.to_string(),
member_set_hash: header.member_set_hash.clone(),
artifact,
artifact_hash: artifact_hash.clone(),
chunk_index,
chunk_count,
chunk: URL_SAFE_NO_PAD.encode(bytes),
},
},
)
.collect())
}
fn validate_artifact_chunk(chunk: &ManagedArtifactChunk) -> Result<()> {
safe_id(&chunk.preparation_id, 160, "preparation id")?;
valid_hash(&chunk.group_id_hash, "group id hash")?;
valid_hash(&chunk.member_set_hash, "member set hash")?;
valid_hash(&chunk.artifact_hash, "artifact hash")?;
if chunk.encryption_epoch != chunk.previous_encryption_epoch.saturating_add(1)
|| chunk.chunk_count == 0
|| chunk.chunk_count > MAX_ARTIFACT_CHUNKS
|| chunk.chunk_index >= chunk.chunk_count
|| chunk.chunk.is_empty()
|| chunk.chunk.len() > 48 * 1024
{
bail!("managed artifact chunk exceeds its protected bounds");
}
Ok(())
}
fn same_artifact(left: &ManagedArtifactChunk, right: &ManagedArtifactChunk) -> bool {
left.preparation_id == right.preparation_id
&& left.previous_encryption_epoch == right.previous_encryption_epoch
&& left.encryption_epoch == right.encryption_epoch
&& left.group_id_hash == right.group_id_hash
&& left.member_set_hash == right.member_set_hash
&& left.artifact == right.artifact
&& left.artifact_hash == right.artifact_hash
&& left.chunk_count == right.chunk_count
}
fn validate_envelope_metadata(
message_id: &str,
channel: &str,
priority: u8,
zone_id: Option<&str>,
) -> Result<()> {
safe_id(message_id, MAX_MESSAGE_ID_BYTES, "message id")?;
safe_id(channel, MAX_CHANNEL_BYTES, "channel")?;
if priority > 3 {
bail!("managed message priority is invalid");
}
if let Some(zone_id) = zone_id {
safe_id(zone_id, MAX_ZONE_ID_BYTES, "zone id")?;
}
Ok(())
}
fn envelope_aad(
architecture_epoch: u64,
encryption_epoch: u64,
message_id: &str,
channel: &str,
priority: u8,
zone_id: Option<&str>,
) -> Result<Vec<u8>> {
serde_json::to_vec(&serde_json::json!({
"v": 1,
"architectureEpoch": architecture_epoch,
"encryptionEpoch": encryption_epoch,
"messageId": message_id,
"channel": channel,
"priority": priority,
"scope": zone_id.map_or_else(
|| serde_json::json!({ "kind": "global" }),
|zone_id| serde_json::json!({ "kind": "zone", "zoneId": zone_id }),
),
}))
.map_err(|error| anyhow!("managed envelope AAD encoding failed: {error}"))
}
fn safe_id(value: &str, max: usize, label: &str) -> Result<()> {
if value.is_empty()
|| value.len() > max
|| !value.bytes().all(|byte| {
byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-' | b':' | b'@' | b'.')
})
{
bail!("{label} is invalid");
}
Ok(())
}
fn valid_hash(value: &str, label: &str) -> Result<()> {
if value.len() != 43
|| !value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-'))
{
bail!("{label} is invalid");
}
Ok(())
}
fn hash_bytes(bytes: &[u8]) -> String {
URL_SAFE_NO_PAD.encode(Sha256::digest(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
fn pages(
preparation_id: &str,
previous_epoch: u64,
group_id: &str,
committer: &str,
publications: &[ManagedGroupAction],
) -> Vec<ManagedPreparePage> {
let publications = publications
.iter()
.zip(["device-a", "device-b"])
.map(|(action, device_id)| (device_id.to_string(), action.clone()))
.collect::<Vec<_>>();
pages_for_devices(
preparation_id,
previous_epoch,
group_id,
committer,
&publications,
)
}
fn pages_for_devices(
preparation_id: &str,
previous_epoch: u64,
group_id: &str,
committer: &str,
publications: &[(String, ManagedGroupAction)],
) -> Vec<ManagedPreparePage> {
let mut members = publications
.iter()
.map(|(device_id, action)| match action {
ManagedGroupAction::PublishKeyPackage {
key_package_hash,
key_package,
} => ManagedPrepareMember {
device_id: device_id.clone(),
key_package_hash: key_package_hash.clone(),
key_package: key_package.clone(),
},
_ => panic!("expected KeyPackage publication"),
})
.collect::<Vec<_>>();
members.sort_by(|left, right| left.device_id.cmp(&right.device_id));
let rows = members
.iter()
.map(|member| (&member.device_id, &member.key_package_hash))
.collect::<Vec<_>>();
let member_set_hash = hash_bytes(&serde_json::to_vec(&rows).unwrap());
let member_count = members.len();
let page_count = member_count.div_ceil(MAX_PREPARE_PAGE_MEMBERS);
members
.chunks(MAX_PREPARE_PAGE_MEMBERS)
.enumerate()
.map(|(page_index, members)| ManagedPreparePage {
preparation_id: preparation_id.to_string(),
previous_encryption_epoch: previous_epoch,
encryption_epoch: previous_epoch + 1,
committer_device_id: committer.to_string(),
group_id: group_id.to_string(),
member_set_hash: member_set_hash.clone(),
page_index,
page_count,
member_count,
welcome_required: previous_epoch == 0,
members: members.to_vec(),
})
.collect()
}
fn artifact_chunks(
actions: &[ManagedGroupAction],
kind: ManagedArtifactKind,
) -> Vec<ManagedArtifactChunk> {
actions
.iter()
.filter_map(|action| match action {
ManagedGroupAction::SendArtifactChunk { chunk } if chunk.artifact == kind => {
Some(chunk.clone())
}
_ => None,
})
.collect()
}
#[test]
fn committer_and_joiner_share_one_persisted_epoch_and_envelope() {
let key_a = [1_u8; 32];
let key_b = [2_u8; 32];
let mut a = ManagedGroupController::new("device-a", key_a, None).unwrap();
let mut b = ManagedGroupController::new("device-b", key_b, None).unwrap();
let publications = vec![
a.publish_key_package().unwrap(),
b.publish_key_package().unwrap(),
];
let page = pages("prep-1", 0, "group-1", "device-a", &publications)
.pop()
.unwrap();
let actions = a.handle_prepare_page(page).unwrap();
assert!(matches!(
actions.first(),
Some(ManagedGroupAction::PersistState { .. })
));
assert!(matches!(
actions.last(),
Some(ManagedGroupAction::AcknowledgeCommit { .. })
));
let welcome = artifact_chunks(&actions, ManagedArtifactKind::Welcome);
assert!(!welcome.is_empty());
let mut b_actions = Vec::new();
for chunk in welcome {
b_actions.extend(b.handle_artifact_chunk(chunk).unwrap());
}
assert!(matches!(
b_actions.first(),
Some(ManagedGroupAction::PersistState { .. })
));
assert!(matches!(
b_actions.last(),
Some(ManagedGroupAction::AcknowledgeCommit { .. })
));
assert_eq!(a.encryption_epoch(), Some(1));
assert_eq!(b.encryption_epoch(), Some(1));
let sealed = a
.seal_payload(7, 1, "message-1", "transforms", 1, None, b"hello")
.unwrap();
assert_ne!(sealed.data, b"hello");
assert!(!sealed.sealed_state.is_empty());
let opened = b
.open_payload(7, 1, "message-1", "transforms", 1, None, &sealed.data)
.unwrap();
assert_eq!(opened.data, b"hello",);
assert!(!opened.sealed_state.is_empty());
assert!(b
.open_payload(8, 1, "message-1", "transforms", 1, None, &sealed.data)
.is_err());
let sealed = actions
.iter()
.find_map(|action| match action {
ManagedGroupAction::PersistState { sealed_state } => {
Some(URL_SAFE_NO_PAD.decode(sealed_state).unwrap())
}
_ => None,
})
.unwrap();
let restored = ManagedGroupController::new("device-a", key_a, Some(&sealed)).unwrap();
assert_eq!(restored.encryption_epoch(), Some(1));
}
#[test]
fn fifty_members_share_one_protected_epoch_and_content_blind_envelope() {
const MEMBER_COUNT: usize = 50;
let mut controllers = (0..MEMBER_COUNT)
.map(|index| {
let device_id = format!("device-{index:02}");
ManagedGroupController::new(device_id, [(index + 1) as u8; 32], None).unwrap()
})
.collect::<Vec<_>>();
let publications = controllers
.iter()
.enumerate()
.map(|(index, controller)| {
(
format!("device-{index:02}"),
controller.publish_key_package().unwrap(),
)
})
.collect::<Vec<_>>();
let preparation = pages_for_devices(
"bounded-50-preparation",
0,
"bounded-50-group",
"device-00",
&publications,
);
assert_eq!(
preparation.len(),
MEMBER_COUNT.div_ceil(MAX_PREPARE_PAGE_MEMBERS)
);
let last_page = preparation.len() - 1;
let mut committer_actions = Vec::new();
for (page_index, page) in preparation.into_iter().enumerate() {
let actions = controllers[0].handle_prepare_page(page).unwrap();
if page_index < last_page {
assert_eq!(
actions,
vec![ManagedGroupAction::RequestPreparePage {
preparation_id: "bounded-50-preparation".to_string(),
page_index: page_index + 1,
}]
);
} else {
committer_actions = actions;
}
}
let welcome = artifact_chunks(&committer_actions, ManagedArtifactKind::Welcome);
assert!(!welcome.is_empty());
for controller in controllers.iter_mut().skip(1) {
let mut actions = Vec::new();
for chunk in welcome.iter().cloned() {
actions.extend(controller.handle_artifact_chunk(chunk).unwrap());
}
assert!(matches!(
actions.last(),
Some(ManagedGroupAction::AcknowledgeCommit {
encryption_epoch: 1,
..
})
));
}
assert!(controllers
.iter()
.all(|controller| controller.encryption_epoch() == Some(1)));
let plaintext = b"bounded reliable room payload";
let protected = controllers[0]
.seal_payload(7, 1, "bounded-50-message", "reliable", 2, None, plaintext)
.unwrap();
assert_ne!(protected.data, plaintext);
for controller in controllers.iter_mut().skip(1) {
let opened = controller
.open_payload(
7,
1,
"bounded-50-message",
"reliable",
2,
None,
&protected.data,
)
.unwrap();
assert_eq!(opened.data, plaintext);
}
let protected = controllers[0]
.seal_payload(
7,
1,
"bounded-50-fenced-message",
"reliable",
2,
None,
plaintext,
)
.unwrap();
assert!(controllers[1]
.open_payload(
8,
1,
"bounded-50-fenced-message",
"reliable",
2,
None,
&protected.data,
)
.is_err());
}
#[test]
fn rejects_tampered_member_and_artifact_manifests() {
let mut a = ManagedGroupController::new("device-a", [1_u8; 32], None).unwrap();
let b = ManagedGroupController::new("device-b", [2_u8; 32], None).unwrap();
let mut page = pages(
"prep-1",
0,
"group-1",
"device-a",
&[
a.publish_key_package().unwrap(),
b.publish_key_package().unwrap(),
],
)
.pop()
.unwrap();
page.members[1].device_id = "device-attacker".to_string();
assert!(a.handle_prepare_page(page).is_err());
}
}