use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey};
use nostr_sdk::prelude::{Event, EventBuilder, EventId, JsonUtil, Keys, Kind, PublicKey, Tag, TagKind, Timestamp, UnsignedEvent};
use super::super::{ChannelId, Epoch};
use super::derive::GroupKey;
pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms";
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm {
Encrypted,
Plaintext,
}
impl SealForm {
pub fn kind(self) -> u16 {
match self {
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
}
}
fn from_kind(kind: u16) -> Option<Self> {
match kind {
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
_ => None,
}
}
}
#[derive(Debug)]
pub enum StreamError {
Sign(String),
Encrypt(String),
Decrypt(String),
Parse(String),
Oversize(usize),
BadWrapKind(u16),
WrongStream,
BadSealKind(u16),
BadSealSignature,
AuthorMismatch,
BadRumorId,
BadMs,
ChannelMismatch,
EpochMismatch,
MissingTag(&'static str),
DuplicateTag(&'static str),
NotRewrappable,
}
impl std::fmt::Display for StreamError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
StreamError::Sign(e) => write!(f, "sign: {e}"),
StreamError::Encrypt(e) => write!(f, "encrypt: {e}"),
StreamError::Decrypt(e) => write!(f, "decrypt: {e}"),
StreamError::Parse(e) => write!(f, "parse: {e}"),
StreamError::Oversize(n) => write!(f, "plaintext {n} bytes exceeds NIP-44 cap"),
StreamError::BadWrapKind(k) => write!(f, "not a stream wrap kind: {k}"),
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
StreamError::BadSealKind(k) => write!(f, "not a seal kind: {k}"),
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
StreamError::BadMs => write!(f, "ms tag outside 0..=999"),
StreamError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"),
StreamError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"),
StreamError::MissingTag(t) => write!(f, "missing rumor tag: {t}"),
StreamError::DuplicateTag(t) => write!(f, "duplicate rumor tag: {t}"),
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
}
}
}
impl std::error::Error for StreamError {}
#[derive(Debug, Clone)]
pub struct OpenedStream {
pub rumor: UnsignedEvent,
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
}
pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let secs = rumor.created_at.as_secs();
let mut offset: Option<u64> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.first().map(|k| k.as_str()) != Some(TAG_MS) {
continue;
}
if offset.is_some() {
return Err(StreamError::BadMs);
}
let raw = s.get(1).ok_or(StreamError::BadMs)?;
if raw.is_empty() || !raw.bytes().all(|b| b.is_ascii_digit()) {
return Err(StreamError::BadMs);
}
let n: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
if n > 999 || (raw.len() > 1 && raw.starts_with('0')) {
return Err(StreamError::BadMs);
}
offset = Some(n);
}
Ok(secs.saturating_mul(1000).saturating_add(offset.unwrap_or(0)))
}
pub fn build_rumor_ms(
kind: u16,
author: PublicKey,
content: &str,
mut tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let (secs, offset) = split_ms(at_ms);
tags.push(Tag::custom(TagKind::Custom(TAG_MS.into()), [offset.to_string()]));
build_rumor_secs(kind, author, content, tags, secs)
}
pub fn build_rumor_secs(
kind: u16,
author: PublicKey,
content: &str,
tags: Vec<Tag>,
at_secs: u64,
) -> UnsignedEvent {
let mut rumor = EventBuilder::new(Kind::Custom(kind), content)
.tags(tags)
.allow_self_tagging()
.custom_created_at(Timestamp::from_secs(at_secs))
.build(author);
rumor.ensure_id();
rumor
}
pub fn seal_content(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey) -> Result<String, StreamError> {
let json = rumor.as_json();
cap(json.len())?;
match form {
SealForm::Plaintext => Ok(json),
SealForm::Encrypted => {
let ct = encrypt_to_bytes(group.conv_key(), json.as_bytes()).map_err(|e| StreamError::Encrypt(e.to_string()))?;
Ok(base64_simd::STANDARD.encode_to_string(&ct))
}
}
}
pub fn build_seal(rumor: &UnsignedEvent, form: SealForm, group: &GroupKey, author_keys: &Keys) -> Result<Event, StreamError> {
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.sign_with_keys(author_keys)
.map_err(|e| StreamError::Sign(e.to_string()))
}
pub fn wrap_seal(seal: &Event, group: &GroupKey, wrap_kind: u16, wrap_at: Timestamp) -> Result<(Event, Keys), StreamError> {
wrap_seal_with_tags(seal, group, wrap_kind, wrap_at, &[])
}
pub fn wrap_seal_with_tags(
seal: &Event,
group: &GroupKey,
wrap_kind: u16,
wrap_at: Timestamp,
extra_tags: &[Tag],
) -> Result<(Event, Keys), StreamError> {
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
let seal_json = seal.as_json();
cap(seal_json.len())?;
let ct = encrypt_to_bytes(group.conv_key(), seal_json.as_bytes()).map_err(|e| StreamError::Encrypt(e.to_string()))?;
let ephemeral = Keys::generate();
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
tags.extend_from_slice(extra_tags);
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), base64_simd::STANDARD.encode_to_string(&ct))
.tags(tags)
.custom_created_at(wrap_at)
.sign_with_keys(group.keys())
.map_err(|e| StreamError::Sign(e.to_string()))?;
Ok((wrap, ephemeral))
}
pub fn rewrap_seal(seal: &Event, new_group: &GroupKey, wrap_at: Timestamp) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable);
}
wrap_seal(seal, new_group, KIND_WRAP, wrap_at)
}
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
let wrap_kind = wrap.kind.as_u16();
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
if wrap.pubkey != group.pk() {
return Err(StreamError::WrongStream);
}
let seal_json = open_nip44(group.conv_key(), &wrap.content)?;
let seal: Event = Event::from_json(&seal_json).map_err(|e| StreamError::Parse(e.to_string()))?;
let seal_form = SealForm::from_kind(seal.kind.as_u16()).ok_or(StreamError::BadSealKind(seal.kind.as_u16()))?;
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
let rumor_json = match seal_form {
SealForm::Plaintext => seal.content.clone(),
SealForm::Encrypted => open_nip44(group.conv_key(), &seal.content)?,
};
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes()).map_err(|e| StreamError::Parse(e.to_string()))?;
if rumor.pubkey != seal.pubkey {
return Err(StreamError::AuthorMismatch);
}
let computed = EventId::new(&rumor.pubkey, &rumor.created_at, &rumor.kind, &rumor.tags, &rumor.content);
if let Some(claimed) = rumor.id {
if claimed != computed {
return Err(StreamError::BadRumorId);
}
}
rumor.id = Some(computed);
let at_ms = resolve_ms_strict(&rumor)?;
Ok(OpenedStream {
rumor_id: computed,
author: seal.pubkey,
seal_form,
seal,
wrapper_id: wrap.id,
at_ms,
rumor,
})
}
pub fn check_channel_binding(rumor: &UnsignedEvent, channel_id: &ChannelId, epoch: Epoch) -> Result<(), StreamError> {
match unique_tag_unsigned(rumor, TAG_CHANNEL)? {
Some(c) if c == channel_id.to_hex() => {}
Some(_) => return Err(StreamError::ChannelMismatch),
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
}
match unique_tag_unsigned(rumor, TAG_EPOCH)? {
Some(e) if e == epoch.0.to_string() => {}
Some(_) => return Err(StreamError::EpochMismatch),
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
}
Ok(())
}
pub fn channel_binding_tags(channel_id: &ChannelId, epoch: Epoch) -> Vec<Tag> {
vec![
Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel_id.to_hex()]),
Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]),
]
}
fn cap(len: usize) -> Result<(), StreamError> {
if len > NIP44_MAX_PLAINTEXT {
return Err(StreamError::Oversize(len));
}
Ok(())
}
fn open_nip44(conv_key: &ConversationKey, content_b64: &str) -> Result<String, StreamError> {
let ct = base64_simd::STANDARD
.decode_to_vec(content_b64.as_bytes())
.map_err(|e| StreamError::Decrypt(e.to_string()))?;
let pt = decrypt_to_bytes(conv_key, &ct).map_err(|e| StreamError::Decrypt(e.to_string()))?;
String::from_utf8(pt).map_err(|e| StreamError::Parse(e.to_string()))
}
fn unique_tag_unsigned(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for t in rumor.tags.iter() {
let s = t.as_slice();
if s.len() >= 2 && s[0] == name {
if found.is_some() {
return Err(StreamError::DuplicateTag(name));
}
found = Some(s[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::super::super::{ChannelId, Epoch};
use super::super::derive::channel_group_key;
use super::super::kind;
use super::*;
fn group() -> GroupKey {
channel_group_key(&[7u8; 32], &chan(), Epoch(0))
}
fn chan() -> ChannelId {
ChannelId([0xabu8; 32])
}
fn send(author: &Keys, group: &GroupKey, form: SealForm, content: &str, at_ms: u64) -> Event {
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), content, tags, at_ms);
let seal = build_seal(&rumor, form, group, author).unwrap();
wrap_seal(&seal, group, KIND_WRAP, Timestamp::from_secs(1_700_000_000)).unwrap().0
}
#[test]
fn encrypted_round_trip_preserves_author_content_and_ms() {
let author = Keys::generate();
let wrap = send(&author, &group(), SealForm::Encrypted, "Hey chat!", 1_686_840_217_417);
assert_eq!(wrap.kind.as_u16(), KIND_WRAP);
assert_eq!(wrap.pubkey, group().pk(), "wrap is signed by the stream key");
let opened = open_wrap(&wrap, &group()).unwrap();
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "Hey chat!");
assert_eq!(opened.at_ms, 1_686_840_217_417);
assert_eq!(opened.seal_form, SealForm::Encrypted);
check_channel_binding(&opened.rumor, &chan(), Epoch(0)).unwrap();
}
#[test]
fn plaintext_seal_round_trip_carries_rumor_verbatim() {
let author = Keys::generate();
let wrap = send(&author, &group(), SealForm::Plaintext, "an edition", 1_686_840_217_000);
let opened = open_wrap(&wrap, &group()).unwrap();
assert_eq!(opened.seal_form, SealForm::Plaintext);
assert_eq!(opened.seal.content, opened.rumor.as_json());
}
#[test]
fn wrong_stream_key_cannot_open() {
let author = Keys::generate();
let wrap = send(&author, &group(), SealForm::Encrypted, "secret", 1_000);
let other = channel_group_key(&[8u8; 32], &chan(), Epoch(0));
assert!(matches!(open_wrap(&wrap, &other), Err(StreamError::WrongStream)));
}
#[test]
fn tampered_wrap_content_fails_the_mac() {
let author = Keys::generate();
let mut wrap = send(&author, &group(), SealForm::Encrypted, "x", 1_000);
let mut json: serde_json::Value = serde_json::from_str(&wrap.as_json()).unwrap();
let ct = json["content"].as_str().unwrap().to_string();
let mut bytes = ct.into_bytes();
bytes[20] = if bytes[20] == b'B' { b'C' } else { b'B' };
json["content"] = serde_json::Value::String(String::from_utf8(bytes).unwrap());
wrap = Event::from_json(json.to_string()).unwrap();
assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::Decrypt(_))));
}
#[test]
fn forged_seal_signature_is_rejected() {
let author = Keys::generate();
let impostor = Keys::generate();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "hi", tags, 1_000);
let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &impostor).unwrap();
let mut json: serde_json::Value = serde_json::from_str(&seal.as_json()).unwrap();
json["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
let forged = Event::from_json(json.to_string());
let Ok(forged) = forged else { return }; let (wrap, _) = wrap_seal(&forged, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadSealSignature)));
}
#[test]
fn rumor_author_must_match_seal_author() {
let author = Keys::generate();
let other = Keys::generate();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, other.public_key(), "spoof", tags, 1_000);
let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::AuthorMismatch)));
}
#[test]
fn forged_rumor_id_is_rejected() {
let author = Keys::generate();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "real", tags, 1_000);
let mut json: serde_json::Value = serde_json::from_str(&rumor.as_json()).unwrap();
json["id"] = serde_json::Value::String("00".repeat(32));
let forged_json = json.to_string();
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged_json)
.custom_created_at(rumor.created_at)
.sign_with_keys(&author)
.unwrap();
let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP, Timestamp::from_secs(1)).unwrap();
assert!(matches!(open_wrap(&wrap, &group()), Err(StreamError::BadRumorId)));
}
#[test]
fn ms_is_strict_absent_is_zero_invalid_is_dropped() {
let author = Keys::generate();
let rumor = build_rumor_secs(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
assert_eq!(resolve_ms_strict(&rumor).unwrap(), 1_000_000);
let ok = build_rumor_secs(
kind::MESSAGE,
author.public_key(),
"x",
vec![Tag::custom(TagKind::Custom("ms".into()), ["999".to_string()])],
1_000,
);
assert_eq!(resolve_ms_strict(&ok).unwrap(), 1_000_999);
for bad in ["1000", "-1", "12.5", "abc", "007", "", "+5", "+0", "+000", "+999"] {
let r = build_rumor_secs(
kind::MESSAGE,
author.public_key(),
"x",
vec![Tag::custom(TagKind::Custom("ms".into()), [bad.to_string()])],
1_000,
);
assert!(
matches!(resolve_ms_strict(&r), Err(StreamError::BadMs)),
"ms={bad:?} must be malformed"
);
}
}
#[test]
fn a_valueless_or_duplicated_ms_tag_is_malformed_not_silently_zero() {
let author = Keys::generate();
let bare = build_rumor_secs(
kind::MESSAGE,
author.public_key(),
"x",
vec![Tag::custom(TagKind::Custom("ms".into()), Vec::<String>::new())],
1_000,
);
assert!(matches!(resolve_ms_strict(&bare), Err(StreamError::BadMs)));
let two = build_rumor_secs(
kind::MESSAGE,
author.public_key(),
"x",
vec![
Tag::custom(TagKind::Custom("ms".into()), Vec::<String>::new()),
Tag::custom(TagKind::Custom("ms".into()), ["5".to_string()]),
],
1_000,
);
assert!(matches!(resolve_ms_strict(&two), Err(StreamError::BadMs)));
let two_valued = build_rumor_secs(
kind::MESSAGE,
author.public_key(),
"x",
vec![
Tag::custom(TagKind::Custom("ms".into()), ["1".to_string()]),
Tag::custom(TagKind::Custom("ms".into()), ["2".to_string()]),
],
1_000,
);
assert!(matches!(resolve_ms_strict(&two_valued), Err(StreamError::BadMs)));
}
#[test]
fn binding_rejects_splices_and_duplicates() {
let author = Keys::generate();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
assert!(matches!(
check_channel_binding(&rumor, &ChannelId([0xcd; 32]), Epoch(0)),
Err(StreamError::ChannelMismatch)
));
assert!(matches!(
check_channel_binding(&rumor, &chan(), Epoch(1)),
Err(StreamError::EpochMismatch)
));
let mut tags = channel_binding_tags(&chan(), Epoch(0));
tags.extend(channel_binding_tags(&chan(), Epoch(0)));
let dup = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
assert!(matches!(
check_channel_binding(&dup, &chan(), Epoch(0)),
Err(StreamError::DuplicateTag(_))
));
let bare = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", vec![], 1_000);
assert!(matches!(
check_channel_binding(&bare, &chan(), Epoch(0)),
Err(StreamError::MissingTag(_))
));
}
#[test]
fn oversize_plaintext_is_refused_at_build_time() {
let author = Keys::generate();
let big = "x".repeat(NIP44_MAX_PLAINTEXT + 1);
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), &big, vec![], 1_000);
assert!(matches!(
seal_content(&rumor, SealForm::Encrypted, &group()),
Err(StreamError::Oversize(_))
));
}
#[test]
fn ephemeral_wrap_round_trips_and_bad_wrap_kind_rejects() {
let author = Keys::generate();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::TYPING, author.public_key(), "", tags, 5_000);
let seal = build_seal(&rumor, SealForm::Encrypted, &group(), &author).unwrap();
let (wrap, _) = wrap_seal(&seal, &group(), KIND_WRAP_EPHEMERAL, Timestamp::from_secs(5)).unwrap();
assert_eq!(wrap.kind.as_u16(), KIND_WRAP_EPHEMERAL);
assert_eq!(open_wrap(&wrap, &group()).unwrap().rumor.kind.as_u16(), kind::TYPING);
assert!(matches!(
wrap_seal(&seal, &group(), 1058, Timestamp::from_secs(5)),
Err(StreamError::BadWrapKind(1058))
));
}
#[test]
fn rewrap_preserves_rumor_id_and_signature_across_epochs() {
let author = Keys::generate();
let wrap = send(&author, &group(), SealForm::Plaintext, "the head edition", 9_000);
let opened = open_wrap(&wrap, &group()).unwrap();
let next = channel_group_key(&[7u8; 32], &chan(), Epoch(1));
let (rewrapped, _) = rewrap_seal(&opened.seal, &next, Timestamp::from_secs(2_000)).unwrap();
let reopened = open_wrap(&rewrapped, &next).unwrap();
assert_eq!(reopened.rumor_id, opened.rumor_id, "rumor id survives the re-wrap");
assert_eq!(reopened.author, author.public_key(), "authorship survives");
assert_eq!(reopened.seal.sig, opened.seal.sig, "the original signature rides verbatim");
assert_ne!(reopened.wrapper_id, opened.wrapper_id, "outer identity differs per wrap");
let enc = send(&author, &group(), SealForm::Encrypted, "no", 9_000);
let enc_opened = open_wrap(&enc, &group()).unwrap();
assert!(matches!(
rewrap_seal(&enc_opened.seal, &next, Timestamp::from_secs(2_000)),
Err(StreamError::NotRewrappable)
));
}
#[test]
fn wrap_p_tag_is_ephemeral_not_the_stream_or_author() {
let author = Keys::generate();
let g = group();
let tags = channel_binding_tags(&chan(), Epoch(0));
let rumor = build_rumor_ms(kind::MESSAGE, author.public_key(), "x", tags, 1_000);
let seal = build_seal(&rumor, SealForm::Encrypted, &g, &author).unwrap();
let (wrap, ephemeral) = wrap_seal(&seal, &g, KIND_WRAP, Timestamp::from_secs(1)).unwrap();
let p = wrap
.tags
.iter()
.find_map(|t| {
let s = t.as_slice();
(s.len() >= 2 && s[0] == "p").then(|| s[1].clone())
})
.expect("wrap carries a p tag");
assert_eq!(p, ephemeral.public_key().to_hex());
assert_ne!(p, g.pk_hex());
assert_ne!(p, author.public_key().to_hex());
}
}