use std::collections::BTreeMap;
use base64::engine::{Engine as _, general_purpose};
use nostro2_traits::NostrKeypair;
use nostro2_traits::hex::Hexable;
use zeroize::Zeroize;
use super::{MESSAGE_EVENT_KIND, Nip104Crypto, Nip104Error, SenderKeyState};
type Result<T> = std::result::Result<T, Nip104Error>;
pub const GROUP_MESSAGE_KIND: u32 = MESSAGE_EVENT_KIND;
pub const GROUP_SENDER_KEY_DISTRIBUTION_KIND: u32 = 10446;
pub const GROUP_CHAT_MESSAGE_KIND: u32 = 14;
#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
pub struct SenderKeyDistribution {
#[bourne(rename = "groupId")]
pub group_id: String,
#[bourne(rename = "keyId")]
pub key_id: u32,
#[bourne(rename = "senderEventPubkey")]
pub sender_event_pubkey: String,
#[bourne(rename = "chainKey")]
pub chain_key: String,
pub iteration: u32,
#[bourne(rename = "createdAt")]
pub created_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
pub struct GroupSenderKeyMessage {
pub group_id: String,
pub sender_event_pubkey: String,
pub key_id: u32,
pub message_number: u32,
pub created_at: i64,
pub ciphertext: String,
}
#[derive(Debug, Clone)]
struct SendingChain {
sender_event_pubkey: String,
sender_event_secret: [u8; 32],
state: SenderKeyState,
}
impl Drop for SendingChain {
fn drop(&mut self) {
self.sender_event_secret.zeroize();
}
}
#[derive(Debug, Clone, Default)]
struct GroupRecord {
sending: Option<SendingChain>,
receiving: BTreeMap<String, SenderKeyState>,
}
#[derive(Debug, Clone)]
pub struct SendingChainSnapshot {
pub sender_event_pubkey: String,
pub sender_event_secret: [u8; 32],
pub state: SenderKeyState,
}
impl Drop for SendingChainSnapshot {
fn drop(&mut self) {
self.sender_event_secret.zeroize();
}
}
#[derive(Debug, Clone)]
pub struct GroupSnapshot {
pub group_id: String,
pub sending: Option<SendingChainSnapshot>,
pub receiving: Vec<(String, SenderKeyState)>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GroupReceivedMessage {
pub group_id: String,
pub sender_event_pubkey: String,
pub plaintext: Vec<u8>,
}
#[derive(Debug, Clone)]
pub struct GroupManager<K: NostrKeypair> {
our_pubkey: String,
groups: BTreeMap<String, GroupRecord>,
sender_to_group: BTreeMap<String, String>,
_marker: std::marker::PhantomData<fn() -> K>,
}
impl<K: NostrKeypair> GroupManager<K> {
#[must_use]
pub fn new(our_pubkey: impl Into<String>) -> Self {
Self {
our_pubkey: our_pubkey.into(),
groups: BTreeMap::new(),
sender_to_group: BTreeMap::new(),
_marker: std::marker::PhantomData,
}
}
#[must_use]
pub fn our_pubkey(&self) -> &str {
&self.our_pubkey
}
#[must_use]
pub fn has_sending_chain(&self, group_id: &str) -> bool {
self.groups
.get(group_id)
.is_some_and(|g| g.sending.is_some())
}
#[must_use]
pub fn known_senders(&self, group_id: &str) -> Vec<String> {
self.groups
.get(group_id)
.map(|g| g.receiving.keys().cloned().collect())
.unwrap_or_default()
}
pub fn rotate_sending_chain(
&mut self,
group_id: &str,
key_id: u32,
created_at: i64,
) -> Result<SenderKeyDistribution> {
let sender_event = K::generate();
let sender_event_pubkey = sender_event.public_key();
let sender_event_secret = sender_event.secret_bytes();
let chain_key = K::generate().secret_bytes();
let state = SenderKeyState::new(key_id, &chain_key, 0);
let dist = SenderKeyDistribution {
group_id: group_id.to_owned(),
key_id,
sender_event_pubkey: sender_event_pubkey.clone(),
chain_key: chain_key.to_hex(),
iteration: 0,
created_at,
};
self.sender_to_group
.insert(sender_event_pubkey.clone(), group_id.to_owned());
self.groups.entry(group_id.to_owned()).or_default().sending = Some(SendingChain {
sender_event_pubkey,
sender_event_secret,
state,
});
Ok(dist)
}
pub fn current_distribution(
&self,
group_id: &str,
created_at: i64,
) -> Result<SenderKeyDistribution> {
let send = self
.groups
.get(group_id)
.and_then(|g| g.sending.as_ref())
.ok_or(Nip104Error::SessionNotReady)?;
Ok(SenderKeyDistribution {
group_id: group_id.to_owned(),
key_id: send.state.key_id(),
sender_event_pubkey: send.sender_event_pubkey.clone(),
chain_key: send.state.chain_key_hex(),
iteration: send.state.iteration(),
created_at,
})
}
pub fn apply_distribution(&mut self, dist: &SenderKeyDistribution) -> Result<()> {
let chain_key = K::decode_hex_32(&dist.chain_key)?;
let state = SenderKeyState::new(dist.key_id, &chain_key, dist.iteration);
self.sender_to_group
.insert(dist.sender_event_pubkey.clone(), dist.group_id.clone());
self.groups
.entry(dist.group_id.clone())
.or_default()
.receiving
.insert(dist.sender_event_pubkey.clone(), state);
Ok(())
}
pub fn distribution_to_rumor(
&self,
dist: &SenderKeyDistribution,
created_at: i64,
now_ms: i64,
) -> Result<nostro2::NostrNote> {
let mut tags = nostro2::NostrTags::new();
tags.add_custom_tag("l", &dist.group_id);
tags.add_custom_tag("key", &dist.key_id.to_string());
tags.add_custom_tag("ms", &now_ms.to_string());
let mut rumor = nostro2::NostrNote {
pubkey: self.our_pubkey.clone(),
kind: GROUP_SENDER_KEY_DISTRIBUTION_KIND,
content: json_bourne::to_string(dist)?,
created_at,
tags,
..Default::default()
};
rumor
.serialize_id()
.map_err(|e| Nip104Error::Json(e.to_string()))?;
Ok(rumor)
}
pub fn apply_distribution_rumor(
&mut self,
rumor: &nostro2::NostrNote,
) -> Result<Option<SenderKeyDistribution>> {
if rumor.kind != GROUP_SENDER_KEY_DISTRIBUTION_KIND {
return Ok(None);
}
let dist: SenderKeyDistribution = json_bourne::parse_str(&rumor.content)?;
self.apply_distribution(&dist)?;
Ok(Some(dist))
}
pub fn encrypt(
&mut self,
group_id: &str,
plaintext: &[u8],
created_at: i64,
) -> Result<GroupSenderKeyMessage> {
let send = self
.groups
.get_mut(group_id)
.and_then(|g| g.sending.as_mut())
.ok_or(Nip104Error::SessionNotReady)?;
let key_id = send.state.key_id();
let (message_number, ciphertext) = send.state.encrypt::<K>(plaintext)?;
Ok(GroupSenderKeyMessage {
group_id: group_id.to_owned(),
sender_event_pubkey: send.sender_event_pubkey.clone(),
key_id,
message_number,
created_at,
ciphertext,
})
}
pub fn decrypt(&mut self, msg: &GroupSenderKeyMessage) -> Result<GroupReceivedMessage> {
let chain = self
.groups
.get_mut(&msg.group_id)
.and_then(|g| g.receiving.get_mut(&msg.sender_event_pubkey))
.ok_or(Nip104Error::SessionNotReady)?;
let plan = chain.plan_decrypt::<K>(msg.key_id, msg.message_number, &msg.ciphertext)?;
let plaintext = chain.apply_decrypt(plan);
Ok(GroupReceivedMessage {
group_id: msg.group_id.clone(),
sender_event_pubkey: msg.sender_event_pubkey.clone(),
plaintext,
})
}
pub fn encrypt_to_event(
&mut self,
group_id: &str,
plaintext: &[u8],
created_at: i64,
) -> Result<nostro2::NostrNote> {
let send = self
.groups
.get_mut(group_id)
.and_then(|g| g.sending.as_mut())
.ok_or(Nip104Error::SessionNotReady)?;
let key_id = send.state.key_id();
let (message_number, ciphertext_b64) = send.state.encrypt::<K>(plaintext)?;
let content = Self::encode_outer_content(key_id, message_number, &ciphertext_b64)?;
let signer =
K::from_secret_bytes(&send.sender_event_secret).map_err(Nip104Error::Signer)?;
let mut note = nostro2::NostrNote {
kind: GROUP_MESSAGE_KIND,
content,
created_at,
tags: nostro2::NostrTags::new(),
..Default::default()
};
note.sign_with(&signer)
.map_err(|_| Nip104Error::Signer(nostro2_traits::SignerError::InvalidSignature))?;
Ok(note)
}
pub fn decrypt_event(
&mut self,
event: &nostro2::NostrNote,
) -> Result<Option<GroupReceivedMessage>> {
use nostro2::NostrEvent as _;
if event.kind != GROUP_MESSAGE_KIND {
return Ok(None);
}
let Some(group_id) = self.sender_to_group.get(&event.pubkey).cloned() else {
return Ok(None);
};
let known = self
.groups
.get(&group_id)
.is_some_and(|g| g.receiving.contains_key(&event.pubkey));
if !known {
return Ok(None);
}
if !event.verify() {
return Err(Nip104Error::InvalidHeader);
}
let (key_id, message_number, ciphertext_b64) = Self::decode_outer_content(&event.content)?;
let msg = GroupSenderKeyMessage {
group_id,
sender_event_pubkey: event.pubkey.clone(),
key_id,
message_number,
created_at: event.created_at,
ciphertext: ciphertext_b64,
};
Ok(Some(self.decrypt(&msg)?))
}
#[must_use]
pub fn snapshot(&self) -> Vec<GroupSnapshot> {
self.groups
.iter()
.map(|(group_id, record)| GroupSnapshot {
group_id: group_id.clone(),
sending: record.sending.as_ref().map(|s| SendingChainSnapshot {
sender_event_pubkey: s.sender_event_pubkey.clone(),
sender_event_secret: s.sender_event_secret,
state: s.state.clone(),
}),
receiving: record
.receiving
.iter()
.map(|(k, v)| (k.clone(), v.clone()))
.collect(),
})
.collect()
}
pub fn restore_group(&mut self, snap: GroupSnapshot) {
let mut record = GroupRecord::default();
if let Some(s) = snap.sending.as_ref() {
self.sender_to_group
.insert(s.sender_event_pubkey.clone(), snap.group_id.clone());
record.sending = Some(SendingChain {
sender_event_pubkey: s.sender_event_pubkey.clone(),
sender_event_secret: s.sender_event_secret,
state: s.state.clone(),
});
}
for (sender_event_pubkey, state) in snap.receiving {
self.sender_to_group
.insert(sender_event_pubkey.clone(), snap.group_id.clone());
record.receiving.insert(sender_event_pubkey, state);
}
self.groups.insert(snap.group_id, record);
}
fn encode_outer_content(
key_id: u32,
message_number: u32,
ciphertext_b64: &str,
) -> Result<String> {
let nip44_bytes = general_purpose::STANDARD
.decode(ciphertext_b64)
.map_err(|_| Nip104Error::InvalidHeader)?;
let mut payload = Vec::with_capacity(8 + nip44_bytes.len());
payload.extend_from_slice(&key_id.to_be_bytes());
payload.extend_from_slice(&message_number.to_be_bytes());
payload.extend_from_slice(&nip44_bytes);
Ok(general_purpose::STANDARD.encode(&payload))
}
fn decode_outer_content(content: &str) -> Result<(u32, u32, String)> {
let bytes = general_purpose::STANDARD
.decode(content)
.map_err(|_| Nip104Error::InvalidHeader)?;
if bytes.len() < 8 {
return Err(Nip104Error::InvalidHeader);
}
let key_id = u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
let message_number = u32::from_be_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]);
let ciphertext_b64 = general_purpose::STANDARD.encode(&bytes[8..]);
Ok((key_id, message_number, ciphertext_b64))
}
}
#[cfg(test)]
mod tests {
use super::*;
type K = crate::tests::NipTester;
fn mgr(id: &str) -> GroupManager<K> {
GroupManager::<K>::new(id.to_owned())
}
#[test]
fn one_to_many_two_members() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let mut carol = mgr("carol");
let dist = alice.rotate_sending_chain("g1", 1, 1000).unwrap();
bob.apply_distribution(&dist).unwrap();
carol.apply_distribution(&dist).unwrap();
let msg = alice.encrypt("g1", b"gm everyone", 1001).unwrap();
assert_eq!(bob.decrypt(&msg).unwrap().plaintext, b"gm everyone");
assert_eq!(carol.decrypt(&msg).unwrap().plaintext, b"gm everyone");
}
#[test]
fn snapshot_round_trip_resumes_both_directions() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g1", 1, 1000).unwrap();
bob.apply_distribution(&dist).unwrap();
let m1 = alice.encrypt("g1", b"first", 1001).unwrap();
assert_eq!(bob.decrypt(&m1).unwrap().plaintext, b"first");
let alice_snaps = alice.snapshot();
let bob_snaps = bob.snapshot();
let mut alice2 = GroupManager::<K>::new(alice.our_pubkey().to_owned());
for s in alice_snaps {
alice2.restore_group(s);
}
let mut bob2 = GroupManager::<K>::new(bob.our_pubkey().to_owned());
for s in bob_snaps {
bob2.restore_group(s);
}
let m2 = alice2.encrypt("g1", b"after restore", 1002).unwrap();
assert_eq!(bob2.decrypt(&m2).unwrap().plaintext, b"after restore");
assert!(bob2.known_senders("g1").contains(&dist.sender_event_pubkey));
}
#[test]
fn snapshot_preserves_skipped_keys() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g1", 7, 2000).unwrap();
bob.apply_distribution(&dist).unwrap();
let a0 = alice.encrypt("g1", b"msg0", 2001).unwrap();
let a1 = alice.encrypt("g1", b"msg1", 2002).unwrap();
let a2 = alice.encrypt("g1", b"msg2", 2003).unwrap();
assert_eq!(bob.decrypt(&a2).unwrap().plaintext, b"msg2");
let bob_snaps = bob.snapshot();
let mut bob2 = GroupManager::<K>::new(bob.our_pubkey().to_owned());
for s in bob_snaps {
bob2.restore_group(s);
}
assert_eq!(bob2.decrypt(&a0).unwrap().plaintext, b"msg0");
assert_eq!(bob2.decrypt(&a1).unwrap().plaintext, b"msg1");
}
#[test]
fn sequential_messages_advance_all() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist).unwrap();
for i in 0..5 {
let m = alice.encrypt("g", format!("m{i}").as_bytes(), i).unwrap();
assert_eq!(
bob.decrypt(&m).unwrap().plaintext,
format!("m{i}").as_bytes()
);
}
}
#[test]
fn late_joiner_gets_current_distribution() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let mut dave = mgr("dave");
let dist0 = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist0).unwrap();
let _m0 = alice.encrypt("g", b"before dave", 1).unwrap();
let dist_now = alice.current_distribution("g", 2).unwrap();
assert_eq!(dist_now.iteration, 1);
dave.apply_distribution(&dist_now).unwrap();
let m1 = alice.encrypt("g", b"after dave", 3).unwrap();
assert_eq!(bob.decrypt(&m1).unwrap().plaintext, b"after dave");
assert_eq!(dave.decrypt(&m1).unwrap().plaintext, b"after dave");
}
#[test]
fn two_senders_route_independently() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let mut carol = mgr("carol");
let da = alice.rotate_sending_chain("g", 1, 0).unwrap();
let db = bob.rotate_sending_chain("g", 1, 0).unwrap();
assert_ne!(da.sender_event_pubkey, db.sender_event_pubkey);
carol.apply_distribution(&da).unwrap();
carol.apply_distribution(&db).unwrap();
let ma = alice.encrypt("g", b"from alice", 1).unwrap();
let mb = bob.encrypt("g", b"from bob", 1).unwrap();
assert_eq!(carol.decrypt(&mb).unwrap().plaintext, b"from bob");
assert_eq!(carol.decrypt(&ma).unwrap().plaintext, b"from alice");
assert_eq!(carol.known_senders("g").len(), 2);
}
#[test]
fn decrypt_without_distribution_fails() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
alice.rotate_sending_chain("g", 1, 0).unwrap();
let m = alice.encrypt("g", b"secret", 1).unwrap();
assert!(matches!(bob.decrypt(&m), Err(Nip104Error::SessionNotReady)));
}
#[test]
fn rotation_replaces_sending_chain() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let d1 = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&d1).unwrap();
let _ = alice.encrypt("g", b"old", 1).unwrap();
let d2 = alice.rotate_sending_chain("g", 2, 2).unwrap();
assert_ne!(d1.sender_event_pubkey, d2.sender_event_pubkey);
bob.apply_distribution(&d2).unwrap();
let m = alice.encrypt("g", b"new", 3).unwrap();
assert_eq!(m.key_id, 2);
assert_eq!(bob.decrypt(&m).unwrap().plaintext, b"new");
}
#[test]
fn distribution_json_roundtrips() {
let mut alice = mgr("alice");
let dist = alice.rotate_sending_chain("g", 1, 1234).unwrap();
let json = json_bourne::to_string(&dist).unwrap();
let back: SenderKeyDistribution = json_bourne::parse_str(&json).unwrap();
assert_eq!(dist, back);
}
#[test]
fn message_json_roundtrips() {
let mut alice = mgr("alice");
alice.rotate_sending_chain("g", 1, 0).unwrap();
let msg = alice.encrypt("g", b"hi", 7).unwrap();
let json = json_bourne::to_string(&msg).unwrap();
let back: GroupSenderKeyMessage = json_bourne::parse_str(&json).unwrap();
assert_eq!(msg, back);
}
#[test]
fn distribution_rumor_roundtrips_over_session() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g7", 1, 1000).unwrap();
let rumor = alice.distribution_to_rumor(&dist, 1000, 1_000_000).unwrap();
assert_eq!(rumor.kind, GROUP_SENDER_KEY_DISTRIBUTION_KIND);
assert_eq!(rumor.pubkey, "alice");
assert!(rumor.id.is_some());
assert_eq!(rumor.tags.find_tags("l"), vec!["g7".to_owned()]);
assert_eq!(rumor.tags.find_tags("key"), vec!["1".to_owned()]);
assert_eq!(rumor.tags.find_tags("ms"), vec!["1000000".to_owned()]);
let applied = bob.apply_distribution_rumor(&rumor).unwrap().unwrap();
assert_eq!(applied, dist);
let ev = alice.encrypt_to_event("g7", b"after distro", 1001).unwrap();
let got = bob.decrypt_event(&ev).unwrap().unwrap();
assert_eq!(got.plaintext, b"after distro");
}
#[test]
fn apply_distribution_rumor_ignores_other_kinds() {
let mut bob = mgr("bob");
let mut other = nostro2::NostrNote {
kind: GROUP_CHAT_MESSAGE_KIND,
content: "hi".to_owned(),
..Default::default()
};
let _ = other.serialize_id();
assert!(bob.apply_distribution_rumor(&other).unwrap().is_none());
}
#[test]
fn outer_event_roundtrip_one_to_many() {
use nostro2::NostrEvent as _;
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let mut carol = mgr("carol");
let dist = alice.rotate_sending_chain("g1", 1, 1000).unwrap();
bob.apply_distribution(&dist).unwrap();
carol.apply_distribution(&dist).unwrap();
let ev = alice.encrypt_to_event("g1", b"gm group", 1001).unwrap();
assert_eq!(ev.kind, GROUP_MESSAGE_KIND);
assert_eq!(ev.pubkey, dist.sender_event_pubkey);
assert!(ev.verify());
assert_eq!(ev.tags.iter().count(), 0);
let b = bob.decrypt_event(&ev).unwrap().unwrap();
let c = carol.decrypt_event(&ev).unwrap().unwrap();
assert_eq!(b.plaintext, b"gm group");
assert_eq!(c.plaintext, b"gm group");
assert_eq!(b.group_id, "g1");
}
#[test]
fn decrypt_event_ignores_unknown_author() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
alice.rotate_sending_chain("g", 1, 0).unwrap();
let ev = alice.encrypt_to_event("g", b"secret", 1).unwrap();
assert!(bob.decrypt_event(&ev).unwrap().is_none());
}
#[test]
fn large_group_fan_out() {
const MEMBERS: usize = 256;
let mut alice = mgr("alice");
let dist = alice.rotate_sending_chain("big", 1, 1000).unwrap();
let mut members: Vec<GroupManager<K>> = (0..MEMBERS)
.map(|i| {
let mut m = mgr(&format!("member-{i}"));
m.apply_distribution(&dist).unwrap();
m
})
.collect();
let ev = alice
.encrypt_to_event("big", b"hello everyone", 1001)
.unwrap();
for (i, m) in members.iter_mut().enumerate() {
let got = m
.decrypt_event(&ev)
.unwrap()
.unwrap_or_else(|| panic!("member {i} failed to decrypt"));
assert_eq!(got.plaintext, b"hello everyone");
}
}
#[test]
fn high_message_volume_group() {
const N: i64 = 2_000;
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let mut carol = mgr("carol");
let dist = alice.rotate_sending_chain("busy", 1, 0).unwrap();
bob.apply_distribution(&dist).unwrap();
carol.apply_distribution(&dist).unwrap();
for i in 0..N {
let body = format!("event #{i}");
let ev = alice.encrypt_to_event("busy", body.as_bytes(), i).unwrap();
assert_eq!(
bob.decrypt_event(&ev).unwrap().unwrap().plaintext,
body.as_bytes()
);
assert_eq!(
carol.decrypt_event(&ev).unwrap().unwrap().plaintext,
body.as_bytes()
);
}
}
#[test]
fn late_joiner_after_heavy_traffic() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist0 = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist0).unwrap();
let mut backlog = Vec::new();
for i in 0..1_000 {
backlog.push(
alice
.encrypt_to_event("g", format!("old-{i}").as_bytes(), i)
.unwrap(),
);
}
let mut eve = mgr("eve");
let dist_now = alice.current_distribution("g", 2000).unwrap();
assert_eq!(dist_now.iteration, 1000);
eve.apply_distribution(&dist_now).unwrap();
let fresh = alice.encrypt_to_event("g", b"after eve", 3000).unwrap();
assert_eq!(
eve.decrypt_event(&fresh).unwrap().unwrap().plaintext,
b"after eve"
);
let old = backlog.last().unwrap();
assert!(eve.decrypt_event(old).is_err());
}
#[test]
fn tampered_outer_event_rejected_without_advancing() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist).unwrap();
let ev = alice.encrypt_to_event("g", b"genuine", 1).unwrap();
let mut forged = ev.clone();
forged.content.push('A');
assert!(matches!(
bob.decrypt_event(&forged),
Err(Nip104Error::InvalidHeader)
));
assert_eq!(
bob.decrypt_event(&ev).unwrap().unwrap().plaintext,
b"genuine"
);
}
#[test]
fn malformed_outer_content_is_an_error() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist).unwrap();
let sender_pk = dist.sender_event_pubkey.clone();
assert!(matches!(
GroupManager::<K>::decode_outer_content(""),
Err(Nip104Error::InvalidHeader)
));
assert!(matches!(
GroupManager::<K>::decode_outer_content("!!!not base64!!!"),
Err(Nip104Error::InvalidHeader)
));
let short = general_purpose::STANDARD.encode([0_u8; 4]);
assert!(matches!(
GroupManager::<K>::decode_outer_content(&short),
Err(Nip104Error::InvalidHeader)
));
let _ = sender_pk;
}
#[test]
fn replayed_group_message_rejected() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let dist = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&dist).unwrap();
let ev = alice.encrypt_to_event("g", b"once", 1).unwrap();
assert_eq!(bob.decrypt_event(&ev).unwrap().unwrap().plaintext, b"once");
assert!(bob.decrypt_event(&ev).is_err());
}
#[test]
fn cross_group_messages_do_not_leak() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let da = alice.rotate_sending_chain("groupA", 1, 0).unwrap();
let db = alice.rotate_sending_chain("groupB", 1, 0).unwrap();
assert_ne!(da.sender_event_pubkey, db.sender_event_pubkey);
bob.apply_distribution(&da).unwrap();
bob.apply_distribution(&db).unwrap();
let ev_a = alice.encrypt_to_event("groupA", b"secret A", 1).unwrap();
let got = bob.decrypt_event(&ev_a).unwrap().unwrap();
assert_eq!(got.group_id, "groupA");
assert_eq!(got.plaintext, b"secret A");
assert!(
bob.known_senders("groupA")
.contains(&da.sender_event_pubkey)
);
assert!(
!bob.known_senders("groupB")
.contains(&da.sender_event_pubkey)
);
}
#[test]
fn stale_key_id_after_rotation_rejected() {
let mut alice = mgr("alice");
let mut bob = mgr("bob");
let d1 = alice.rotate_sending_chain("g", 1, 0).unwrap();
bob.apply_distribution(&d1).unwrap();
let stale = alice.encrypt("g", b"old-key", 1).unwrap();
let d2 = alice.rotate_sending_chain("g", 2, 2).unwrap();
bob.apply_distribution(&d2).unwrap();
let mut wrong = stale;
wrong.sender_event_pubkey = d2.sender_event_pubkey.clone();
assert!(matches!(
bob.decrypt(&wrong),
Err(Nip104Error::InvalidHeader)
));
}
#[test]
fn outer_content_frames_match_reference_layout() {
let content = GroupManager::<K>::encode_outer_content(0x0102_0304, 0x0506_0708, &{
general_purpose::STANDARD.encode([0xAA_u8; 40])
})
.unwrap();
let raw = general_purpose::STANDARD.decode(&content).unwrap();
assert_eq!(&raw[..4], &[0x01, 0x02, 0x03, 0x04]);
assert_eq!(&raw[4..8], &[0x05, 0x06, 0x07, 0x08]);
assert_eq!(&raw[8..], &[0xAA_u8; 40]);
let (k, n, ct) = GroupManager::<K>::decode_outer_content(&content).unwrap();
assert_eq!(k, 0x0102_0304);
assert_eq!(n, 0x0506_0708);
assert_eq!(general_purpose::STANDARD.decode(ct).unwrap(), [0xAA_u8; 40]);
}
}