534 lines
21 KiB
Rust
534 lines
21 KiB
Rust
//! A SoulStream Space — the persistent community the F6 decision chose to
|
|
//! build on River's patterns rather than River's format: ONE contract per
|
|
//! Space addressed by (owner, space id); an owner-signed CHECKPOINT that IS
|
|
//! the authority (name, mode, the member roster, the pinned playlist) with
|
|
//! a monotonic epoch, so a removal is a new epoch without that member and
|
|
//! nothing about membership is ever inferred from who talked recently; a
|
|
//! bounded window of member-signed MESSAGES admitted only from the current
|
|
//! roster; and, for a private Space, message bodies sealed to a per-epoch
|
|
//! space key the owner hands members through their encrypted inboxes —
|
|
//! rotated on every removal. Deterministic merge, canonical-only
|
|
//! validation: the F5 bouncer, again. BYTE-COMPATIBILITY IS THE CONTRACT:
|
|
//! `crates/space-contract` compiles this very module into the wasm.
|
|
//!
|
|
//! Signing rides `soulstream.friend/v1` like the inbox does (the delegate
|
|
//! generation in the app has no space domain yet).
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::BTreeMap;
|
|
|
|
pub const SCHEMA_VERSION: u32 = 1;
|
|
pub const PARAMS_FAMILY: &[u8] = b"soulstream.space.v1";
|
|
pub const SIGN_DOMAIN: &str = "soulstream.friend/v1";
|
|
pub const MAX_STATE_BYTES: usize = 1024 * 1024;
|
|
pub const MAX_MEMBERS: usize = 256;
|
|
pub const MAX_MESSAGES: usize = 500;
|
|
pub const DEFAULT_MESSAGE_CAP: u32 = 200;
|
|
pub const MAX_BODY_BYTES: usize = 4096;
|
|
pub const MAX_NAME_BYTES: usize = 64;
|
|
pub const MAX_DESC_BYTES: usize = 512;
|
|
pub const MAX_PER_AUTHOR_UNCAPPED: usize = 64;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub enum Role {
|
|
Owner,
|
|
Member,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Member {
|
|
pub key: [u8; 32],
|
|
pub role: Role,
|
|
pub since_epoch: u64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct PlaylistRef {
|
|
pub owner: [u8; 32],
|
|
pub uuid: [u8; 16],
|
|
}
|
|
|
|
/// The owner's word on what the Space is and who is in it.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Checkpoint {
|
|
pub schema_version: u32,
|
|
pub space_id: [u8; 16],
|
|
pub owner: [u8; 32],
|
|
pub epoch: u64,
|
|
pub name: String,
|
|
pub description: String,
|
|
pub private: bool,
|
|
pub members: Vec<Member>,
|
|
pub pinned: Option<PlaylistRef>,
|
|
pub message_cap: u32,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct CheckpointSigBody<'a> {
|
|
schema_version: u32,
|
|
space_id: &'a [u8; 16],
|
|
owner: &'a [u8; 32],
|
|
epoch: u64,
|
|
name: &'a str,
|
|
description: &'a str,
|
|
private: bool,
|
|
members: &'a [Member],
|
|
pinned: &'a Option<PlaylistRef>,
|
|
message_cap: u32,
|
|
}
|
|
|
|
pub fn checkpoint_sig_body(c: &Checkpoint) -> Vec<u8> {
|
|
bincode::serialize(&CheckpointSigBody {
|
|
schema_version: c.schema_version,
|
|
space_id: &c.space_id,
|
|
owner: &c.owner,
|
|
epoch: c.epoch,
|
|
name: &c.name,
|
|
description: &c.description,
|
|
private: c.private,
|
|
members: &c.members,
|
|
pinned: &c.pinned,
|
|
message_cap: c.message_cap,
|
|
})
|
|
.expect("serializes")
|
|
}
|
|
pub fn checkpoint_sign_payload(c: &Checkpoint) -> Vec<u8> {
|
|
blake3::hash(&checkpoint_sig_body(c)).as_bytes().to_vec()
|
|
}
|
|
|
|
/// One line in the Space. Public: `body` is UTF-8. Private: `body` is the
|
|
/// ChaCha20-Poly1305 seal of the UTF-8 under the epoch's space key.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct Message {
|
|
pub space_id: [u8; 16],
|
|
pub epoch: u64,
|
|
pub msg_id: [u8; 16],
|
|
pub author: [u8; 32],
|
|
pub created_at: u64,
|
|
pub body: Vec<u8>,
|
|
pub sig: Vec<u8>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct MessageSigBody<'a> {
|
|
space_id: &'a [u8; 16],
|
|
epoch: u64,
|
|
msg_id: &'a [u8; 16],
|
|
author: &'a [u8; 32],
|
|
created_at: u64,
|
|
body: &'a [u8],
|
|
}
|
|
|
|
pub fn message_sig_body(m: &Message) -> Vec<u8> {
|
|
bincode::serialize(&MessageSigBody {
|
|
space_id: &m.space_id,
|
|
epoch: m.epoch,
|
|
msg_id: &m.msg_id,
|
|
author: &m.author,
|
|
created_at: m.created_at,
|
|
body: &m.body,
|
|
})
|
|
.expect("serializes")
|
|
}
|
|
pub fn message_sign_payload(m: &Message) -> Vec<u8> {
|
|
blake3::hash(&message_sig_body(m)).as_bytes().to_vec()
|
|
}
|
|
|
|
/// The AAD for a sealed body: everything about the message but the body.
|
|
fn message_aad(m: &Message) -> Vec<u8> {
|
|
let mut v = Vec::with_capacity(80);
|
|
v.extend_from_slice(&m.space_id);
|
|
v.extend_from_slice(&m.epoch.to_le_bytes());
|
|
v.extend_from_slice(&m.msg_id);
|
|
v.extend_from_slice(&m.author);
|
|
v.extend_from_slice(&m.created_at.to_le_bytes());
|
|
v
|
|
}
|
|
fn body_key(space_key: &[u8; 32]) -> [u8; 32] {
|
|
blake3::derive_key("soulstream.space-body.v1", space_key)
|
|
}
|
|
fn body_nonce(msg_id: &[u8; 16]) -> [u8; 12] {
|
|
blake3::hash(msg_id).as_bytes()[..12].try_into().unwrap()
|
|
}
|
|
|
|
/// Seal a private message body. Call BEFORE signing (the signature covers
|
|
/// the sealed bytes). `m.body` must hold the plaintext going in.
|
|
pub fn seal_body(space_key: &[u8; 32], m: &Message) -> Vec<u8> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
|
let key = body_key(space_key);
|
|
ChaCha20Poly1305::new(Key::from_slice(&key))
|
|
.encrypt(
|
|
Nonce::from_slice(&body_nonce(&m.msg_id)),
|
|
Payload { msg: &m.body, aad: &message_aad(m) },
|
|
)
|
|
.expect("seal")
|
|
}
|
|
|
|
/// Open a private message body. None = wrong key for this epoch, or tampered.
|
|
pub fn open_body(space_key: &[u8; 32], m: &Message) -> Option<Vec<u8>> {
|
|
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
|
|
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
|
|
let key = body_key(space_key);
|
|
ChaCha20Poly1305::new(Key::from_slice(&key))
|
|
.decrypt(
|
|
Nonce::from_slice(&body_nonce(&m.msg_id)),
|
|
Payload { msg: &m.body, aad: &message_aad(m) },
|
|
)
|
|
.ok()
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct SpaceState {
|
|
pub schema_version: u32,
|
|
pub space_id: [u8; 16],
|
|
pub checkpoint: Option<Checkpoint>,
|
|
pub messages: Vec<Message>,
|
|
}
|
|
|
|
pub fn make_params(owner: &[u8; 32], space_id: &[u8; 16]) -> Vec<u8> {
|
|
let mut p = Vec::with_capacity(PARAMS_FAMILY.len() + 48);
|
|
p.extend_from_slice(PARAMS_FAMILY);
|
|
p.extend_from_slice(owner);
|
|
p.extend_from_slice(space_id);
|
|
p
|
|
}
|
|
|
|
pub fn params_parts(params: &[u8]) -> Option<([u8; 32], [u8; 16])> {
|
|
let t = PARAMS_FAMILY.len();
|
|
if params.len() != t + 48 || ¶ms[..t] != PARAMS_FAMILY {
|
|
return None;
|
|
}
|
|
Some((
|
|
params[t..t + 32].try_into().unwrap(),
|
|
params[t + 32..t + 48].try_into().unwrap(),
|
|
))
|
|
}
|
|
|
|
// ------------------------------------------------------------ room-local keys
|
|
//
|
|
// A member appears on the roster as a key minted FOR THIS SPACE, not as their
|
|
// persona. The contract never cared which it was — `message_valid` only asks
|
|
// whether the author is on the roster and whether the signature checks out
|
|
// against that key — so this costs no contract generation, and the same rules
|
|
// admit both.
|
|
//
|
|
// The owner is the exception, and cannot be otherwise: the Space's address is
|
|
// derived from the owner's persona key (`make_params`), so owning a Space is
|
|
// public by construction. Joining one is what this makes unlinkable.
|
|
//
|
|
// These keys never reach the delegate — a member mints one per Space and the
|
|
// app keeps it — so signing lives here, beside the verification it has to
|
|
// match. Nothing here draws entropy: ed25519 signing is deterministic, which
|
|
// is what keeps getrandom out of the wasm build.
|
|
|
|
/// The public half of a room-local member key.
|
|
pub fn member_public(secret: &[u8; 32]) -> [u8; 32] {
|
|
crate::public_of(secret)
|
|
}
|
|
|
|
/// Sign as a room-local member, in the same domain the roster verifies in.
|
|
pub fn sign_as_member(secret: &[u8; 32], payload: &[u8]) -> Vec<u8> {
|
|
crate::sign_local(SIGN_DOMAIN, secret, payload)
|
|
}
|
|
|
|
/// What a joiner signs with their PERSONA to tell the owner that a room key is
|
|
/// theirs. It travels sealed and stops at the owner: published, it would undo
|
|
/// the whole point by tying the room key back to the persona.
|
|
pub fn join_proof_payload(space_id: &[u8; 16], owner: &[u8; 32], room_key: &[u8; 32]) -> Vec<u8> {
|
|
let mut v = Vec::with_capacity(16 + 32 + 32);
|
|
v.extend_from_slice(space_id);
|
|
v.extend_from_slice(owner);
|
|
v.extend_from_slice(room_key);
|
|
blake3::hash(&v).as_bytes().to_vec()
|
|
}
|
|
|
|
/// True when `persona` really did vouch for `room_key` in this Space.
|
|
pub fn join_proof_valid(
|
|
space_id: &[u8; 16],
|
|
owner: &[u8; 32],
|
|
room_key: &[u8; 32],
|
|
persona: &[u8; 32],
|
|
proof: &[u8],
|
|
) -> bool {
|
|
crate::verify_sig_in(SIGN_DOMAIN, persona, &join_proof_payload(space_id, owner, room_key), proof)
|
|
}
|
|
|
|
/// What a member signs to give up their seat. Prefixed, so a join proof can
|
|
/// never be replayed as a leave, and signed by the key that HOLDS the seat —
|
|
/// holding it is the whole authority to vacate it, and the owner needs no
|
|
/// second fact to act.
|
|
pub fn leave_proof_payload(space_id: &[u8; 16], owner: &[u8; 32], worn: &[u8; 32]) -> Vec<u8> {
|
|
let mut v = Vec::with_capacity(5 + 16 + 32 + 32);
|
|
v.extend_from_slice(b"leave");
|
|
v.extend_from_slice(space_id);
|
|
v.extend_from_slice(owner);
|
|
v.extend_from_slice(worn);
|
|
blake3::hash(&v).as_bytes().to_vec()
|
|
}
|
|
|
|
/// True when whoever sits under `worn` really did ask to leave this Space.
|
|
pub fn leave_proof_valid(space_id: &[u8; 16], owner: &[u8; 32], worn: &[u8; 32], proof: &[u8]) -> bool {
|
|
crate::verify_sig_in(SIGN_DOMAIN, worn, &leave_proof_payload(space_id, owner, worn), proof)
|
|
}
|
|
|
|
pub fn checkpoint_valid(owner: &[u8; 32], space_id: &[u8; 16], c: &Checkpoint) -> bool {
|
|
c.schema_version == SCHEMA_VERSION
|
|
&& &c.space_id == space_id
|
|
&& &c.owner == owner
|
|
&& c.epoch >= 1
|
|
&& c.name.len() <= MAX_NAME_BYTES
|
|
&& c.description.len() <= MAX_DESC_BYTES
|
|
&& !c.members.is_empty()
|
|
&& c.members.len() <= MAX_MEMBERS
|
|
&& c.message_cap >= 1
|
|
&& c.message_cap as usize <= MAX_MESSAGES
|
|
&& c.members.iter().any(|m| &m.key == owner && m.role == Role::Owner)
|
|
&& crate::verify_sig_in(SIGN_DOMAIN, owner, &checkpoint_sign_payload(c), &c.sig)
|
|
}
|
|
|
|
pub fn message_valid(space_id: &[u8; 16], c: &Checkpoint, m: &Message) -> bool {
|
|
&m.space_id == space_id
|
|
&& m.epoch <= c.epoch
|
|
&& m.body.len() <= MAX_BODY_BYTES + 16
|
|
&& c.members.iter().any(|mem| mem.key == m.author)
|
|
&& crate::verify_sig_in(SIGN_DOMAIN, &m.author, &message_sign_payload(m), &m.sig)
|
|
}
|
|
|
|
/// Deterministic multi-writer merge: the highest valid (epoch, hash)
|
|
/// checkpoint wins; messages are admitted only from ITS roster, deduped by
|
|
/// id (smaller content hash wins a collision), fairly bounded per author,
|
|
/// then the newest `message_cap` kept in (created_at, id) order.
|
|
pub fn merge_space(owner: &[u8; 32], space_id: &[u8; 16], candidates: &[&[u8]]) -> SpaceState {
|
|
let mut best: Option<(u64, [u8; 32], Checkpoint)> = None;
|
|
let mut parsed: Vec<SpaceState> = Vec::new();
|
|
for bytes in candidates {
|
|
if bytes.is_empty() || bytes.len() > MAX_STATE_BYTES {
|
|
continue;
|
|
}
|
|
let Ok(st) = bincode::deserialize::<SpaceState>(bytes) else { continue };
|
|
if st.schema_version != SCHEMA_VERSION || &st.space_id != space_id {
|
|
continue;
|
|
}
|
|
if let Some(c) = &st.checkpoint {
|
|
if checkpoint_valid(owner, space_id, c) {
|
|
let rank = (c.epoch, *blake3::hash(&checkpoint_sig_body(c)).as_bytes());
|
|
let better = match &best {
|
|
None => true,
|
|
Some((e, h, _)) => rank > (*e, *h),
|
|
};
|
|
if better {
|
|
best = Some((rank.0, rank.1, c.clone()));
|
|
}
|
|
}
|
|
}
|
|
parsed.push(st);
|
|
}
|
|
let Some((_, _, checkpoint)) = best else {
|
|
return SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: None, messages: vec![] };
|
|
};
|
|
|
|
let mut by_id: BTreeMap<[u8; 16], (Message, [u8; 32])> = BTreeMap::new();
|
|
for st in &parsed {
|
|
for m in &st.messages {
|
|
if !message_valid(space_id, &checkpoint, m) {
|
|
continue;
|
|
}
|
|
let h = *blake3::hash(&bincode::serialize(m).unwrap()).as_bytes();
|
|
match by_id.get(&m.msg_id) {
|
|
Some((_, existing)) if existing <= &h => {}
|
|
_ => {
|
|
by_id.insert(m.msg_id, (m.clone(), h));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut all: Vec<Message> = by_id.into_values().map(|(m, _)| m).collect();
|
|
all.sort_by_key(|m| (m.created_at, m.msg_id));
|
|
// Per-author fairness before the global window: one voice cannot fill
|
|
// the whole window and push everyone else out.
|
|
let mut per_author: BTreeMap<[u8; 32], usize> = BTreeMap::new();
|
|
let mut kept: Vec<Message> = Vec::with_capacity(all.len());
|
|
for m in all.into_iter().rev() {
|
|
let n = per_author.entry(m.author).or_insert(0);
|
|
if *n < MAX_PER_AUTHOR_UNCAPPED {
|
|
*n += 1;
|
|
kept.push(m);
|
|
}
|
|
}
|
|
kept.reverse();
|
|
let cap = checkpoint.message_cap as usize;
|
|
if kept.len() > cap {
|
|
kept.drain(..kept.len() - cap);
|
|
}
|
|
SpaceState { schema_version: SCHEMA_VERSION, space_id: *space_id, checkpoint: Some(checkpoint), messages: kept }
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use ed25519_dalek::{Signer, SigningKey};
|
|
|
|
fn sign(key: &SigningKey, payload: &[u8]) -> Vec<u8> {
|
|
key.sign(&crate::signable(SIGN_DOMAIN, payload)).to_bytes().to_vec()
|
|
}
|
|
fn checkpoint(owner: &SigningKey, epoch: u64, members: &[&SigningKey], private: bool) -> Checkpoint {
|
|
let mut c = Checkpoint {
|
|
schema_version: SCHEMA_VERSION,
|
|
space_id: [4; 16],
|
|
owner: owner.verifying_key().to_bytes(),
|
|
epoch,
|
|
name: "Late Night Ambient".into(),
|
|
description: String::new(),
|
|
private,
|
|
members: std::iter::once(Member { key: owner.verifying_key().to_bytes(), role: Role::Owner, since_epoch: 1 })
|
|
.chain(members.iter().map(|k| Member { key: k.verifying_key().to_bytes(), role: Role::Member, since_epoch: epoch }))
|
|
.collect(),
|
|
pinned: None,
|
|
message_cap: DEFAULT_MESSAGE_CAP,
|
|
sig: vec![],
|
|
};
|
|
c.sig = sign(owner, &checkpoint_sign_payload(&c));
|
|
c
|
|
}
|
|
fn message(author: &SigningKey, epoch: u64, id: u8, at: u64, key: Option<&[u8; 32]>) -> Message {
|
|
let mut m = Message {
|
|
space_id: [4; 16],
|
|
epoch,
|
|
msg_id: [id; 16],
|
|
author: author.verifying_key().to_bytes(),
|
|
created_at: at,
|
|
body: b"hello".to_vec(),
|
|
sig: vec![],
|
|
};
|
|
if let Some(k) = key {
|
|
m.body = seal_body(k, &m);
|
|
}
|
|
m.sig = sign(author, &message_sign_payload(&m));
|
|
m
|
|
}
|
|
fn state(c: Option<&Checkpoint>, msgs: &[Message]) -> Vec<u8> {
|
|
bincode::serialize(&SpaceState { schema_version: SCHEMA_VERSION, space_id: [4; 16], checkpoint: c.cloned(), messages: msgs.to_vec() }).unwrap()
|
|
}
|
|
|
|
/// The whole point of room-local keys: a member on the roster under a key
|
|
/// minted for THIS Space posts and merges exactly like one on their
|
|
/// persona, so nothing about the contract has to change. Their persona
|
|
/// appears nowhere in anything published.
|
|
#[test]
|
|
fn a_room_local_member_is_admitted_like_any_other() {
|
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
|
let ok = owner.verifying_key().to_bytes();
|
|
// Bob's persona, and the key he mints for this Space alone.
|
|
let bob_persona = SigningKey::from_bytes(&[2; 32]);
|
|
let bob_room = SigningKey::from_bytes(&[9; 32]);
|
|
let room_secret = [9u8; 32];
|
|
assert_eq!(member_public(&room_secret), bob_room.verifying_key().to_bytes());
|
|
|
|
let c1 = checkpoint(&owner, 1, &[&bob_room], false);
|
|
let m = message(&bob_room, 1, 10, 100, None);
|
|
let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m])]);
|
|
assert_eq!(merged.messages.len(), 1, "a room-local member must post like anyone else");
|
|
|
|
// Signing through the app-side helper is the same signature.
|
|
let mut m2 = Message {
|
|
space_id: [4; 16],
|
|
epoch: 1,
|
|
msg_id: [11; 16],
|
|
author: member_public(&room_secret),
|
|
created_at: 101,
|
|
body: b"hello".to_vec(),
|
|
sig: vec![],
|
|
};
|
|
m2.sig = sign_as_member(&room_secret, &message_sign_payload(&m2));
|
|
assert!(message_valid(&[4; 16], &c1, &m2), "sign_as_member must satisfy the roster check");
|
|
|
|
// Nothing published mentions the persona.
|
|
let published = state(Some(&c1), &[m2]);
|
|
let persona_bytes = bob_persona.verifying_key().to_bytes();
|
|
assert!(
|
|
!published.windows(32).any(|w| w == persona_bytes),
|
|
"the persona key leaked into published Space bytes"
|
|
);
|
|
}
|
|
|
|
/// The owner still has to know the room key is the person they invited —
|
|
/// sealed, never published.
|
|
#[test]
|
|
fn a_join_proof_binds_one_room_key_to_one_persona_and_space() {
|
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
|
let ok = owner.verifying_key().to_bytes();
|
|
let bob_persona = SigningKey::from_bytes(&[2; 32]);
|
|
let room = member_public(&[9; 32]);
|
|
let proof = sign(&bob_persona, &join_proof_payload(&[4; 16], &ok, &room));
|
|
let bp = bob_persona.verifying_key().to_bytes();
|
|
assert!(join_proof_valid(&[4; 16], &ok, &room, &bp, &proof));
|
|
// Not reusable in another Space, for another key, or by another persona.
|
|
assert!(!join_proof_valid(&[5; 16], &ok, &room, &bp, &proof), "replayed into another Space");
|
|
assert!(!join_proof_valid(&[4; 16], &ok, &member_public(&[8; 32]), &bp, &proof), "bound to the wrong key");
|
|
let mallory = SigningKey::from_bytes(&[3; 32]).verifying_key().to_bytes();
|
|
assert!(!join_proof_valid(&[4; 16], &ok, &room, &mallory, &proof), "anyone could claim it");
|
|
}
|
|
|
|
/// A seat is given up by the key that holds it, and a join proof is not a
|
|
/// leave proof — otherwise the owner's own invitation answer could be
|
|
/// replayed to unseat the person who sent it.
|
|
#[test]
|
|
fn a_leave_proof_comes_from_the_seat_it_gives_up() {
|
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
|
let ok = owner.verifying_key().to_bytes();
|
|
let room_secret = [9u8; 32];
|
|
let room = member_public(&room_secret);
|
|
let proof = sign_as_member(&room_secret, &leave_proof_payload(&[4; 16], &ok, &room));
|
|
assert!(leave_proof_valid(&[4; 16], &ok, &room, &proof));
|
|
// Not reusable in another Space, and not against another seat.
|
|
assert!(!leave_proof_valid(&[5; 16], &ok, &room, &proof), "replayed into another Space");
|
|
assert!(!leave_proof_valid(&[4; 16], &ok, &member_public(&[8; 32]), &proof), "bound to the wrong seat");
|
|
// A join proof carries a different payload, so it cannot stand in.
|
|
let bob_persona = SigningKey::from_bytes(&[2; 32]);
|
|
let bp = bob_persona.verifying_key().to_bytes();
|
|
let join = sign(&bob_persona, &join_proof_payload(&[4; 16], &ok, &bp));
|
|
assert!(!leave_proof_valid(&[4; 16], &ok, &bp, &join), "a join proof unseated its own sender");
|
|
}
|
|
|
|
#[test]
|
|
fn roster_is_the_authority_and_removal_is_an_epoch() {
|
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
|
let bob = SigningKey::from_bytes(&[2; 32]);
|
|
let mallory = SigningKey::from_bytes(&[3; 32]);
|
|
let ok = owner.verifying_key().to_bytes();
|
|
let c1 = checkpoint(&owner, 1, &[&bob], false);
|
|
let m_bob = message(&bob, 1, 10, 100, None);
|
|
let m_mal = message(&mallory, 1, 11, 101, None);
|
|
let merged = merge_space(&ok, &[4; 16], &[&state(Some(&c1), &[]), &state(None, &[m_bob.clone(), m_mal])]);
|
|
assert_eq!(merged.messages.len(), 1, "an off-roster author never enters");
|
|
// the canonical state merges to itself
|
|
let canon = bincode::serialize(&merged).unwrap();
|
|
assert_eq!(bincode::serialize(&merge_space(&ok, &[4; 16], &[&canon])).unwrap(), canon);
|
|
// a newer epoch without bob drops bob's messages; an old checkpoint cannot restore him
|
|
let c2 = checkpoint(&owner, 2, &[], false);
|
|
let after = merge_space(&ok, &[4; 16], &[&canon, &state(Some(&c2), &[])]);
|
|
assert_eq!(after.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
|
assert!(after.messages.is_empty());
|
|
let replay = merge_space(&ok, &[4; 16], &[&bincode::serialize(&after).unwrap(), &state(Some(&c1), &[m_bob])]);
|
|
assert_eq!(replay.checkpoint.as_ref().map(|c| c.epoch), Some(2));
|
|
// a checkpoint signed by a non-owner is not a checkpoint
|
|
let mut forged = checkpoint(&owner, 3, &[&mallory], false);
|
|
forged.sig = sign(&mallory, &checkpoint_sign_payload(&forged));
|
|
assert!(!checkpoint_valid(&ok, &[4; 16], &forged));
|
|
}
|
|
|
|
#[test]
|
|
fn private_bodies_open_only_with_the_epoch_key() {
|
|
let owner = SigningKey::from_bytes(&[1; 32]);
|
|
let key = [9u8; 32];
|
|
let m = message(&owner, 1, 7, 100, Some(&key));
|
|
assert_eq!(open_body(&key, &m).as_deref(), Some(&b"hello"[..]));
|
|
assert_eq!(open_body(&[8u8; 32], &m), None);
|
|
let mut t = m.clone();
|
|
t.created_at += 1;
|
|
assert_eq!(open_body(&key, &t), None, "the AAD binds the header");
|
|
}
|
|
}
|