use nostr_sdk::prelude::*;
use super::cipher;
use super::derive::channel_pseudonym;
use super::{ChannelId, ChannelKey, Epoch};
use crate::stored_event::event_kind;
const PROTOCOL_VERSION: &str = "1";
const TAG_VERSION: &str = "v";
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
const TAG_MS: &str = "ms";
#[derive(Debug)]
pub enum EnvelopeError {
Sign(String),
Encrypt(String),
Decrypt(String),
InnerParse(String),
BadVersion(Option<String>),
KindMismatch { outer: u16, inner: u16 },
ChannelMismatch,
EpochMismatch,
BadSignature,
MissingTag(&'static str),
DuplicateTag(&'static str),
NoHeldEpoch,
}
impl std::fmt::Display for EnvelopeError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
EnvelopeError::Sign(e) => write!(f, "sign: {e}"),
EnvelopeError::Encrypt(e) => write!(f, "encrypt: {e}"),
EnvelopeError::Decrypt(e) => write!(f, "decrypt: {e}"),
EnvelopeError::InnerParse(e) => write!(f, "inner parse: {e}"),
EnvelopeError::BadVersion(v) => write!(f, "unsupported protocol version: {v:?}"),
EnvelopeError::KindMismatch { outer, inner } => {
write!(f, "kind mismatch: outer {outer} != inner {inner}")
}
EnvelopeError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"),
EnvelopeError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"),
EnvelopeError::BadSignature => write!(f, "inner author signature invalid"),
EnvelopeError::MissingTag(t) => write!(f, "missing inner tag: {t}"),
EnvelopeError::DuplicateTag(t) => write!(f, "duplicate inner tag: {t}"),
EnvelopeError::NoHeldEpoch => write!(f, "no held epoch key for this pseudonym"),
}
}
}
impl std::error::Error for EnvelopeError {}
#[derive(Debug, Clone)]
pub struct OpenedMessage {
pub message_id: EventId,
pub author: PublicKey,
pub content: String,
pub channel_id: ChannelId,
pub epoch: Epoch,
pub ms: Option<u64>,
pub created_at: Timestamp,
pub kind: u16,
pub attachments: Vec<crate::types::Attachment>,
pub citation: Option<super::edition::AuthorityCitation>,
pub wrapper_id: EventId,
pub tags: Tags,
}
pub fn seal_message(
author_keys: &Keys,
channel_key: &ChannelKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
ms: u64,
) -> Result<Event, EnvelopeError> {
seal_message_with_ephemeral(&Keys::generate(), author_keys, channel_key, channel_id, epoch, content, ms)
}
pub fn seal_message_with_ephemeral(
ephemeral: &Keys,
author_keys: &Keys,
channel_key: &ChannelKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
ms: u64,
) -> Result<Event, EnvelopeError> {
let inner = build_inner_event(author_keys.public_key(), channel_id, epoch, content, ms, None)
.sign_with_keys(author_keys)
.map_err(|e| EnvelopeError::Sign(e.to_string()))?;
seal_with_signed_inner(ephemeral, &inner, channel_key, channel_id, epoch)
}
pub fn build_inner_event(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
content: &str,
ms: u64,
reply_to: Option<&str>,
) -> UnsignedEvent {
build_inner_typed(author, channel_id, epoch, event_kind::COMMUNITY_MESSAGE, content, ms, reply_to, &[])
}
pub fn build_inner_typed(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
kind: u16,
content: &str,
ms: u64,
reference: Option<&str>,
emoji_tags: &[crate::types::EmojiTag],
) -> UnsignedEvent {
build_inner_full(author, channel_id, epoch, kind, content, ms, reference, emoji_tags, &[])
}
pub fn build_inner_full(
author: PublicKey,
channel_id: &ChannelId,
epoch: Epoch,
kind: u16,
content: &str,
ms: u64,
reference: Option<&str>,
emoji_tags: &[crate::types::EmojiTag],
extra_tags: &[Tag],
) -> UnsignedEvent {
let created_secs = ms / 1000;
let ms_offset = ms % 1000;
let mut tags = vec![
Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel_id.to_hex()]),
Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]),
Tag::custom(TagKind::Custom(TAG_MS.into()), [ms_offset.to_string()]),
];
if let Some(target) = reference.filter(|t| !t.is_empty()) {
tags.push(Tag::custom(TagKind::e(), [target.to_string(), String::new(), "reply".to_string()]));
}
for et in emoji_tags {
tags.push(Tag::custom(TagKind::Custom("emoji".into()), [et.shortcode.clone(), et.url.clone()]));
}
tags.extend(extra_tags.iter().cloned());
EventBuilder::new(Kind::Custom(kind), content)
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_secs))
.build(author)
}
pub fn seal_with_signed_inner(
ephemeral: &Keys,
inner: &Event,
channel_key: &ChannelKey,
channel_id: &ChannelId,
epoch: Epoch,
) -> Result<Event, EnvelopeError> {
let inner_kind = inner.kind.as_u16();
let allowed = (event_kind::COMMUNITY_MESSAGE..=event_kind::COMMUNITY_EDIT).contains(&inner_kind)
|| inner_kind == event_kind::COMMUNITY_DELETE
|| inner_kind == event_kind::COMMUNITY_PRESENCE
|| inner_kind == event_kind::COMMUNITY_KICK
|| inner_kind == event_kind::COMMUNITY_WEBXDC
|| inner_kind == event_kind::COMMUNITY_TYPING;
if !allowed {
return Err(EnvelopeError::KindMismatch {
outer: event_kind::COMMUNITY_MESSAGE,
inner: inner_kind,
});
}
match unique_tag(inner, TAG_CHANNEL)? {
Some(c) if c == channel_id.to_hex() => {}
_ => return Err(EnvelopeError::ChannelMismatch),
}
match unique_tag(inner, TAG_EPOCH)? {
Some(e) if e == epoch.0.to_string() => {}
_ => return Err(EnvelopeError::EpochMismatch),
}
let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes())
.map_err(EnvelopeError::Encrypt)?;
let pseudonym = channel_pseudonym(channel_key, channel_id, epoch);
EventBuilder::new(Kind::Custom(inner_kind), content_b64)
.tags([
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[pseudonym.to_hex()],
),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(ephemeral)
.map_err(|e| EnvelopeError::Sign(e.to_string()))
}
pub fn open_message(
outer: &Event,
channel_key: &ChannelKey,
channel_id: &ChannelId,
epoch: Epoch,
) -> Result<OpenedMessage, EnvelopeError> {
match find_tag(outer, TAG_VERSION).as_deref() {
Some(PROTOCOL_VERSION) => {}
other => return Err(EnvelopeError::BadVersion(other.map(str::to_string))),
}
let plaintext = cipher::open(channel_key.as_bytes(), &outer.content)
.map_err(EnvelopeError::Decrypt)?;
let json = String::from_utf8(plaintext).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
let inner = Event::from_json(&json).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?;
inner.verify().map_err(|_| EnvelopeError::BadSignature)?;
if inner.kind.as_u16() != outer.kind.as_u16() {
return Err(EnvelopeError::KindMismatch {
outer: outer.kind.as_u16(),
inner: inner.kind.as_u16(),
});
}
let inner_channel = unique_tag(&inner, TAG_CHANNEL)?.ok_or(EnvelopeError::MissingTag(TAG_CHANNEL))?;
if inner_channel != channel_id.to_hex() {
return Err(EnvelopeError::ChannelMismatch);
}
let inner_epoch = unique_tag(&inner, TAG_EPOCH)?.ok_or(EnvelopeError::MissingTag(TAG_EPOCH))?;
if inner_epoch != epoch.0.to_string() {
return Err(EnvelopeError::EpochMismatch);
}
let ms = Some(crate::rumor::resolve_message_timestamp(
inner.created_at.as_secs(),
unique_tag(&inner, TAG_MS)?.as_deref(),
));
let attachments = super::attachments::attachments_from_tags(
inner.tags.iter(),
&crate::db::get_download_dir(),
);
Ok(OpenedMessage {
message_id: inner.id,
author: inner.pubkey,
content: inner.content.clone(),
channel_id: *channel_id,
epoch,
ms,
created_at: inner.created_at,
kind: inner.kind.as_u16(),
attachments,
citation: super::edition::AuthorityCitation::from_tags(&inner.tags),
wrapper_id: outer.id,
tags: inner.tags.clone(),
})
}
pub fn open_message_multi(
outer: &Event,
channel_id: &ChannelId,
epoch_keys: &[(Epoch, ChannelKey)],
) -> Result<OpenedMessage, EnvelopeError> {
let z = find_tag(outer, "z").ok_or(EnvelopeError::MissingTag("z"))?;
for (epoch, key) in epoch_keys {
if channel_pseudonym(key, channel_id, *epoch).to_hex() == z {
return open_message(outer, key, channel_id, *epoch);
}
}
Err(EnvelopeError::NoHeldEpoch)
}
fn find_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == name).then(|| s[1].clone())
})
}
fn unique_tag(event: &Event, name: &'static str) -> Result<Option<String>, EnvelopeError> {
let mut found: Option<String> = None;
for t in event.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == name {
if found.is_some() {
return Err(EnvelopeError::DuplicateTag(name));
}
found = Some(s[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
fn key() -> ChannelKey {
ChannelKey([0x11u8; 32])
}
fn chan() -> ChannelId {
ChannelId([0xaau8; 32])
}
fn channel_tag() -> Tag {
Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()])
}
fn epoch0_tag() -> Tag {
Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()])
}
fn wrap_inner(inner: &Event) -> Event {
let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
.tags([
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&Keys::generate())
.unwrap()
}
#[test]
fn round_trip() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "gm fren", 1_700_000_000_000)
.expect("seal");
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
assert_eq!(opened.content, "gm fren");
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.ms, Some(1_700_000_000_000));
assert_eq!(opened.channel_id, chan());
assert_eq!(opened.epoch, Epoch(0));
}
#[tokio::test]
async fn signer_path_matches_local_keys_path() {
let author = Keys::generate();
let ephemeral = Keys::generate();
let unsigned = build_inner_event(author.public_key(), &chan(), Epoch(0), "via signer", 1_700_000_000_777, None);
let inner: Event = unsigned.sign(&author).await.expect("remote-style sign");
let outer = seal_with_signed_inner(&ephemeral, &inner, &key(), &chan(), Epoch(0)).expect("seal");
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open");
assert_eq!(opened.content, "via signer");
assert_eq!(opened.author, author.public_key(), "authorship is the identity key, not ephemeral");
assert_eq!(opened.ms, Some(1_700_000_000_777));
assert_eq!(outer.pubkey, ephemeral.public_key());
}
#[test]
fn reply_reference_round_trips() {
let author = Keys::generate();
let target = "a".repeat(64);
let inner = build_inner_event(author.public_key(), &chan(), Epoch(0), "re: hi", 5, Some(&target))
.sign_with_keys(&author)
.unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
assert_eq!(msg.replied_to, target);
let plain = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 6).unwrap();
let opened2 = open_message(&plain, &key(), &chan(), Epoch(0)).unwrap();
assert!(crate::community::inbound::build_message(&opened2, &Keys::generate().public_key()).replied_to.is_empty());
}
#[test]
fn custom_emoji_tags_round_trip() {
let author = Keys::generate();
let tags = vec![crate::types::EmojiTag {
shortcode: "fire".into(),
url: "https://blossom/fire.png".into(),
}];
let inner = build_inner_typed(
author.public_key(), &chan(), Epoch(0),
crate::stored_event::event_kind::COMMUNITY_MESSAGE, "gm :fire:", 1, None, &tags,
)
.sign_with_keys(&author)
.unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
assert_eq!(msg.emoji_tags.len(), 1);
assert_eq!(msg.emoji_tags[0].shortcode, "fire");
assert_eq!(msg.emoji_tags[0].url, "https://blossom/fire.png");
}
#[test]
fn far_future_inner_timestamp_is_clamped() {
let author = Keys::generate();
let far_future = 99_999_999_999_999u64; let outer = seal_message(&author, &key(), &chan(), Epoch(0), "from the future", far_future).unwrap();
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
assert!(opened.ms.unwrap() < far_future, "far-future ordering ms must be clamped to ~now");
}
#[test]
fn duplicate_reply_tag_on_a_message_is_tolerated() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
.tags([
Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()]),
Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]),
Tag::custom(TagKind::Custom(TAG_MS.into()), ["1".to_string()]),
Tag::custom(TagKind::e(), ["aa".repeat(32), String::new(), "reply".to_string()]),
Tag::custom(TagKind::e(), ["bb".repeat(32), String::new(), "reply".to_string()]),
])
.sign_with_keys(&author)
.unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
assert!(open_message(&outer, &key(), &chan(), Epoch(0)).is_ok(),
"a message must not be dropped over an ambiguous (cosmetic) reply pointer");
}
#[test]
fn multi_attachment_message_round_trips_caption_and_imeta() {
use crate::types::{Attachment, ImageMetadata};
let mk = |name: &str, ext: &str, img: bool| Attachment {
id: "x".into(),
key: "0".repeat(64),
nonce: format!("{:0<24}", crate::simd::hex::bytes_to_hex_string(name.as_bytes())),
extension: ext.into(),
name: name.into(),
url: format!("https://blossom.example/{name}"),
path: String::new(),
size: 1234,
img_meta: img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 64, height: 48 }),
downloading: false,
downloaded: false,
webxdc_topic: None,
group_id: None,
original_hash: Some("a".repeat(64)),
scheme_version: None,
mls_filename: None,
};
let imetas = vec![
super::super::attachments::attachment_to_imeta(&mk("photo.png", "png", true)),
super::super::attachments::attachment_to_imeta(&mk("notes.pdf", "pdf", false)),
];
let author = Keys::generate();
let reply_target = "bb".repeat(32);
let inner = build_inner_full(
author.public_key(), &chan(), Epoch(0),
event_kind::COMMUNITY_MESSAGE, "look at these", 1_700_000_000_123,
Some(&reply_target), &[], &imetas,
).sign_with_keys(&author).unwrap();
let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap();
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
assert_eq!(opened.content, "look at these");
let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key());
assert_eq!(msg.replied_to, reply_target);
assert_eq!(opened.attachments.len(), 2, "both attachments parse");
assert_eq!(opened.attachments[0].name, "photo.png");
assert_eq!(opened.attachments[0].key, "0".repeat(64));
assert_eq!(opened.attachments[0].extension, "png");
assert!(opened.attachments[0].img_meta.is_some(), "image carries thumbhash/dim");
assert!(opened.attachments[0].group_id.is_none(), "Community attachment uses explicit key/nonce");
assert_eq!(opened.attachments[1].name, "notes.pdf");
assert_eq!(opened.attachments[1].extension, "pdf");
assert!(opened.attachments[1].img_meta.is_none());
let plain = seal_message(&author, &key(), &chan(), Epoch(0), "just text", 1).unwrap();
assert!(open_message(&plain, &key(), &chan(), Epoch(0)).unwrap().attachments.is_empty());
}
#[test]
fn seal_rejects_inner_bound_to_wrong_channel() {
let author = Keys::generate();
let other = ChannelId([0xbbu8; 32]);
let inner = build_inner_event(author.public_key(), &other, Epoch(0), "x", 1, None)
.sign_with_keys(&author)
.unwrap();
let err = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}");
}
#[test]
fn ms_splits_to_created_at_and_offset_and_reconstructs() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap();
assert_eq!(outer_inner_created_at_secs(&outer), 1234);
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
assert_eq!(opened.ms, Some(1_234_567), "full ms reconstructed from secs*1000 + offset");
assert_eq!(opened.created_at.as_secs(), 1234);
}
fn outer_inner_created_at_secs(outer: &Event) -> u64 {
let pt = cipher::open(key().as_bytes(), &outer.content).unwrap();
let inner = Event::from_json(&String::from_utf8(pt).unwrap()).unwrap();
inner.created_at.as_secs()
}
#[test]
fn outer_signer_is_ephemeral_not_author() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 1).unwrap();
assert_ne!(
outer.pubkey,
author.public_key(),
"outer event must be ephemeral-signed, not author-signed"
);
let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap();
assert_eq!(opened.author, author.public_key());
}
#[test]
fn identical_plaintext_yields_distinct_ciphertext() {
let author = Keys::generate();
let a = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap();
let b = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap();
assert_ne!(a.content, b.content, "per-message nonce must randomize ciphertext");
}
#[test]
fn wrong_key_is_rejected() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "secret", 1).unwrap();
let wrong = ChannelKey([0x22u8; 32]);
let err = open_message(&outer, &wrong, &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}");
}
#[test]
fn cross_channel_splice_is_rejected() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap();
let chan_b = ChannelId([0xbbu8; 32]);
let err = open_message(&outer, &key(), &chan_b, Epoch(0));
assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}");
}
#[test]
fn cross_epoch_splice_is_rejected() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "epoch 0", 1).unwrap();
let err = open_message(&outer, &key(), &chan(), Epoch(1));
assert!(matches!(err, Err(EnvelopeError::EpochMismatch)), "got {err:?}");
}
#[test]
fn tampered_ciphertext_is_rejected() {
let author = Keys::generate();
let mut outer =
seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap();
let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap();
let mid = bytes.len() / 2;
bytes[mid] ^= 0xff;
let corrupted = base64_simd::STANDARD.encode_to_string(&bytes);
let ephemeral = Keys::generate();
outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), corrupted)
.tags([
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&ephemeral)
.unwrap();
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}");
}
#[test]
fn missing_version_tag_is_rejected() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
.sign_with_keys(&author)
.unwrap();
let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
let ephemeral = Keys::generate();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
.sign_with_keys(&ephemeral)
.unwrap();
assert!(matches!(
open_message(&outer, &key(), &chan(), Epoch(0)),
Err(EnvelopeError::BadVersion(None))
));
}
#[test]
fn two_members_exchange() {
let alice = Keys::generate();
let bob = Keys::generate();
let shared = key();
let from_alice = seal_message(&alice, &shared, &chan(), Epoch(0), "yo bob", 10).unwrap();
let seen_by_bob = open_message(&from_alice, &shared, &chan(), Epoch(0)).unwrap();
assert_eq!(seen_by_bob.author, alice.public_key());
assert_eq!(seen_by_bob.content, "yo bob");
let from_bob = seal_message(&bob, &shared, &chan(), Epoch(0), "hey alice", 11).unwrap();
let seen_by_alice = open_message(&from_bob, &shared, &chan(), Epoch(0)).unwrap();
assert_eq!(seen_by_alice.author, bob.public_key());
assert_eq!(seen_by_alice.content, "hey alice");
assert_ne!(seen_by_bob.message_id, seen_by_alice.message_id);
}
#[test]
fn unknown_version_is_rejected_before_decrypt() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
.sign_with_keys(&author)
.unwrap();
let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
let ephemeral = Keys::generate();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
.tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])])
.sign_with_keys(&ephemeral)
.unwrap();
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "999"), "got {err:?}");
}
#[test]
fn inner_kind_mismatch_is_rejected() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REACTION), "+")
.tags([channel_tag(), epoch0_tag()])
.sign_with_keys(&author)
.unwrap();
let outer = wrap_inner(&inner);
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(
matches!(err, Err(EnvelopeError::KindMismatch { outer: 3300, inner: 3301 })),
"got {err:?}"
);
}
#[test]
fn forged_inner_signature_is_rejected() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "real")
.tags([channel_tag(), epoch0_tag()])
.sign_with_keys(&author)
.unwrap();
let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap();
v["content"] = serde_json::Value::String("forged".into());
let tampered = serde_json::to_string(&v).unwrap();
let content = cipher::seal(key().as_bytes(), tampered.as_bytes()).unwrap();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
.tags([
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&Keys::generate())
.unwrap();
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::BadSignature)), "got {err:?}");
}
#[test]
fn missing_channel_tag_is_rejected() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi")
.tags([epoch0_tag()])
.sign_with_keys(&author)
.unwrap();
let outer = wrap_inner(&inner);
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::MissingTag(t)) if t == TAG_CHANNEL), "got {err:?}");
}
#[test]
fn duplicate_channel_tag_is_rejected() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi")
.tags([channel_tag(), channel_tag(), epoch0_tag()])
.sign_with_keys(&author)
.unwrap();
let outer = wrap_inner(&inner);
let err = open_message(&outer, &key(), &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::DuplicateTag(t)) if t == TAG_CHANNEL), "got {err:?}");
}
#[test]
fn version_is_checked_before_decryption() {
let author = Keys::generate();
let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x")
.tags([channel_tag(), epoch0_tag()])
.sign_with_keys(&author)
.unwrap();
let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap();
let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content)
.tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["7".to_string()])])
.sign_with_keys(&Keys::generate())
.unwrap();
let wrong_key = ChannelKey([0x99u8; 32]);
let err = open_message(&outer, &wrong_key, &chan(), Epoch(0));
assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "7"), "got {err:?}");
}
#[test]
fn truncated_ciphertext_is_rejected() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "intact", 1).unwrap();
let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap();
bytes.truncate(bytes.len().saturating_sub(5));
let truncated = base64_simd::STANDARD.encode_to_string(&bytes);
let mangled = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), truncated)
.tags([
Tag::custom(
TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)),
[super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()],
),
Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]),
])
.sign_with_keys(&Keys::generate())
.unwrap();
assert!(matches!(open_message(&mangled, &key(), &chan(), Epoch(0)), Err(EnvelopeError::Decrypt(_))));
}
#[test]
fn seal_uses_matching_inner_and_outer_kind() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap();
assert_eq!(outer.kind.as_u16(), event_kind::COMMUNITY_MESSAGE);
let plaintext = cipher::open(key().as_bytes(), &outer.content).unwrap();
let inner = Event::from_json(&String::from_utf8(plaintext).unwrap()).unwrap();
assert_eq!(inner.kind.as_u16(), outer.kind.as_u16());
}
#[test]
fn seal_tags_outer_with_correct_pseudonym_and_version() {
let author = Keys::generate();
let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap();
let expected = super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex();
assert_eq!(find_tag(&outer, "z").as_deref(), Some(expected.as_str()));
assert_eq!(find_tag(&outer, TAG_VERSION).as_deref(), Some("1"));
}
}