10 Commits
Author SHA1 Message Date
reya ecd08273eb add rekeys, refounding and dissolution 2026-09-17 08:16:41 +07:00
reya 79a4dd387d add invites and community list 2026-09-17 07:49:07 +07:00
reya ea9abae554 add guestbook and moderation 2026-09-17 07:30:38 +07:00
reya d1b83fdc33 add chat plane 2026-09-16 20:46:22 +07:00
reya 4329385abe add control fold and roster 2026-09-16 20:05:51 +07:00
reya d926c1e3ea update concord backend 2026-09-16 17:35:33 +07:00
reya 66f75ad105 add basic concord backend 2026-09-16 16:57:04 +07:00
reya 5f2a5d7a37 add concord crate and basic cryptography 2026-09-16 16:21:38 +07:00
reya 319b1038d2 add plan 2026-09-16 11:03:33 +07:00
reya 5500082426 chore: update metadata 2026-09-16 10:23:53 +07:00
23 changed files with 10502 additions and 18 deletions
Generated
+17
View File
@@ -1302,6 +1302,23 @@ version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
[[package]]
name = "concord"
version = "1.0.2"
dependencies = [
"anyhow",
"data-encoding",
"hkdf",
"nostr",
"nostr-memory",
"nostr-sdk",
"rand 0.10.2",
"serde",
"serde_json",
"sha2 0.10.9",
"smol",
]
[[package]]
name = "concurrent-queue"
version = "2.5.0"
+3
View File
@@ -31,6 +31,9 @@ nostr = { git = "https://github.com/rust-nostr/nostr", features = [ "nip59", "ni
aes-gcm = "0.10"
sha2 = "0.10"
data-encoding = "2"
hkdf = "0.12"
# Pinned to the instance `nostr-sdk` already builds, so NIP-44 nonces share it
rand = { version = "0.10", default-features = false, features = [ "std", "sys_rng" ] }
# Others
anyhow = "1.0.44"
+1103
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "aurora",
"name": "Aurora",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fdfcfeff",
"surface_background": "#f8f8ffff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "forest",
"name": "Forest",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fbfefcff",
"surface_background": "#f4fbf6ff",
+1 -1
View File
@@ -2,7 +2,7 @@
"id": "ocean",
"name": "Ocean",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://coopchat.xyz",
"light": {
"background": "#fafefeff",
"surface_background": "#f2fbfaff",
+21
View File
@@ -0,0 +1,21 @@
[package]
name = "concord"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr.workspace = true
nostr-sdk.workspace = true
hkdf.workspace = true
sha2.workspace = true
data-encoding.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
nostr-memory.workspace = true
smol.workspace = true
+947
View File
@@ -0,0 +1,947 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use anyhow::Result;
use nostr_sdk::prelude::*;
use crate::derive::channel_group_key;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, StreamError, build_rumor_ms,
build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict,
wrap_seal,
};
use crate::{ChannelId, Epoch, GroupKey, decode_hex_32};
pub const KIND_MESSAGE: u16 = 9;
pub const KIND_COMMENT: u16 = 1111;
pub const KIND_REACTION: u16 = 7;
pub const KIND_DELETE: u16 = 5;
pub const KIND_EDIT: u16 = 3302;
pub const KIND_FILE: u16 = 15;
pub const KIND_WEBXDC: u16 = 3310;
pub const KIND_TYPING: u16 = 23311;
const TAG_QUOTE: &str = "q";
const TAG_TARGET: &str = "e";
const TAG_TARGET_KIND: &str = "k";
const TAG_ROOT: &str = "E";
const TAG_ROOT_KIND: &str = "K";
const TAG_ROOT_AUTHOR: &str = "P";
const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
#[derive(Debug)]
pub enum ChatError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for ChatError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ChatError::Stream(error) => write!(f, "stream: {error}"),
ChatError::NotEncryptedSealed => write!(f, "chat rumor must ride an encrypted seal"),
ChatError::UnknownKind(kind) => write!(f, "not a chat rumor kind: {kind}"),
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
}
}
}
impl std::error::Error for ChatError {}
impl From<StreamError> for ChatError {
fn from(error: StreamError) -> Self {
ChatError::Stream(error)
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef {
pub id: EventId,
pub author: Option<PublicKey>,
}
/// A reference that also names the referenced event's kind, which a comment
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub reply: ReplyRef,
pub kind: u16,
}
#[derive(Debug, Clone)]
pub enum ChatAction {
Message {
reply_to: Option<ReplyRef>,
thread_root: Option<ReplyRef>,
},
Reaction {
target: EventId,
emoji: String,
},
Edit {
target: EventId,
content: String,
},
Delete {
target: EventId,
target_kind: Option<u16>,
citation: Option<AuthorityCitation>,
},
Typing,
Opaque,
}
#[derive(Debug, Clone)]
pub struct ChatRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub channel: ChannelId,
pub epoch: Epoch,
pub at_ms: u64,
pub content: String,
pub expiration: Option<Timestamp>,
pub action: ChatAction,
}
/// A channel's timeline row, with every edit, delete and reaction folded in.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChatMessage {
pub id: EventId,
pub author: PublicKey,
pub channel: ChannelId,
pub epoch: Epoch,
pub kind: Kind,
pub content: String,
pub reply_to: Option<EventId>,
pub thread_root: Option<EventId>,
pub at_ms: u64,
pub expiration: Option<Timestamp>,
pub edited_at: Option<u64>,
pub deleted: bool,
pub reactions: BTreeMap<PublicKey, String>,
}
pub fn build_message(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
content: &str,
quote: Option<&ReplyRef>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
if let Some(quote) = quote {
tags.push(reply_tag(TAG_QUOTE, quote));
}
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
}
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
/// immutable root; `None` means the parent is itself the root.
pub fn build_comment(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
content: &str,
parent: &Target,
root: Option<&Target>,
at_ms: u64,
) -> UnsignedEvent {
let root = root.unwrap_or(parent);
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()]));
tags.push(reply_tag(TAG_ROOT, &root.reply));
if let Some(root_author) = root.reply.author {
tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()]));
}
tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()]));
tags.push(reply_tag(TAG_TARGET, &parent.reply));
if let Some(parent_author) = parent.reply.author {
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
}
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
}
pub fn build_reaction(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: &Target,
emoji: &str,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()]));
if let Some(target_author) = target.reply.author {
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()]));
}
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
}
pub fn build_edit(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: EventId,
content: &str,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
}
pub fn build_delete(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
target: EventId,
target_kind: Option<u16>,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
if let Some(target_kind) = target_kind {
tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()]));
}
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_DELETE, author, "", tags, at_ms)
}
pub fn build_typing(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
at_ms: u64,
) -> UnsignedEvent {
build_rumor_ms(
KIND_TYPING,
author,
"",
channel_binding_tags(channel, epoch),
at_ms,
)
}
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
/// the 21059 wrap, which relays must not store.
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
ephemeral: bool,
) -> Result<(Event, Keys), ChatError> {
let kind = rumor.kind.as_u16();
if !is_chat_kind(kind) {
return Err(ChatError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
let wrap_kind = if ephemeral {
KIND_WRAP_EPHEMERAL
} else {
KIND_WRAP
};
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
// stored event on schedule; the inner copy is what drives a local purge.
let expiration: Vec<Tag> = rumor
.tags
.iter()
.filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION))
.cloned()
.collect();
Ok(wrap_seal(
&seal,
group,
wrap_kind,
rumor.created_at,
&expiration,
)?)
}
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
/// rumor claims must both be the ones that opened it, so a keyholder of two
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
pub fn open(
wrap: &Event,
group: &GroupKey,
channel: &ChannelId,
epoch: Epoch,
) -> Result<(OpenedStream, ChatRumor), ChatError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(ChatError::NotEncryptedSealed);
}
check_channel_binding(&opened.rumor, channel, epoch)?;
let chat = typed(&opened.rumor, channel, epoch)?;
Ok((opened, chat))
}
/// Every epoch's group key for one channel. `secret` is whatever feeds the
/// channel at that epoch: the `community_root` for a public one, its own key
/// for a private one.
pub fn plane_keys(
held: &[(Epoch, [u8; 32])],
channel: &ChannelId,
) -> Result<Vec<(Epoch, GroupKey)>> {
held.iter()
.map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?)))
.collect()
}
pub fn fold(
rumors: &[ChatRumor],
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id));
let mut messages: Vec<ChatMessage> = Vec::new();
let mut slot: BTreeMap<EventId, usize> = BTreeMap::new();
for index in order {
let rumor = &rumors[index];
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
continue;
};
slot.insert(rumor.id, messages.len());
messages.push(ChatMessage {
id: rumor.id,
author: rumor.author,
channel: rumor.channel,
epoch: rumor.epoch,
kind: rumor.kind,
content: rumor.content.clone(),
reply_to: reply_to.map(|reply| reply.id),
thread_root: thread_root.map(|reply| reply.id),
at_ms: rumor.at_ms,
expiration: rumor.expiration,
edited_at: None,
deleted: false,
reactions: BTreeMap::new(),
});
}
let mut mutations: Vec<usize> = (0..rumors.len()).collect();
mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id)));
for index in mutations {
let rumor = &rumors[index];
match &rumor.action {
ChatAction::Edit { target, content } => {
let Some(&slot) = slot.get(target) else {
continue;
};
let message = &mut messages[slot];
if message.deleted || message.author != rumor.author {
continue;
}
message.content = content.clone();
message.edited_at = Some(rumor.at_ms);
}
ChatAction::Delete {
target, citation, ..
} => {
let Some(&slot) = slot.get(target) else {
continue;
};
let author = messages[slot].author;
if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) {
messages[slot].deleted = true;
}
}
ChatAction::Reaction { target, emoji } => {
let Some(&slot) = slot.get(target) else {
continue;
};
messages[slot].reactions.insert(rumor.author, emoji.clone());
}
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
}
}
messages.sort_by_key(|message| (Reverse(message.at_ms), message.id));
messages
}
fn is_chat_kind(kind: u16) -> bool {
matches!(
kind,
KIND_MESSAGE
| KIND_COMMENT
| KIND_REACTION
| KIND_DELETE
| KIND_EDIT
| KIND_FILE
| KIND_WEBXDC
| KIND_TYPING
)
}
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
Ok(ChatRumor {
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
author: rumor.pubkey,
kind: rumor.kind,
channel: *channel,
epoch,
at_ms: resolve_ms_strict(rumor)?,
content: rumor.content.clone(),
expiration: expiration_of(rumor)?,
action: action_of(rumor)?,
})
}
fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
let kind = rumor.kind.as_u16();
match kind {
KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message {
reply_to: optional_reply(rumor, TAG_QUOTE)?,
thread_root: None,
}),
KIND_COMMENT => Ok(ChatAction::Message {
reply_to: optional_reply(rumor, TAG_TARGET)?,
thread_root: optional_reply(rumor, TAG_ROOT)?,
}),
KIND_REACTION => Ok(ChatAction::Reaction {
target: required_id(rumor, TAG_TARGET)?,
emoji: rumor.content.clone(),
}),
KIND_EDIT => Ok(ChatAction::Edit {
target: required_id(rumor, TAG_TARGET)?,
content: rumor.content.clone(),
}),
KIND_DELETE => Ok(ChatAction::Delete {
target: required_id(rumor, TAG_TARGET)?,
target_kind: optional_kind(rumor, TAG_TARGET_KIND)?,
citation: optional_citation(rumor)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_WEBXDC => Ok(ChatAction::Opaque),
other => Err(ChatError::UnknownKind(other)),
}
}
fn optional_reply(
rumor: &UnsignedEvent,
name: &'static str,
) -> Result<Option<ReplyRef>, ChatError> {
let Some(fields) = tag(rumor, name)? else {
return Ok(None);
};
// NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3.
let author = match fields.get(3).map(String::as_str) {
Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?),
_ => None,
};
Ok(Some(ReplyRef {
id: hex_id(fields, name)?,
author,
}))
}
fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result<EventId, ChatError> {
let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?;
hex_id(fields, name)
}
fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<u16>, ChatError> {
let Some(fields) = tag(rumor, name)? else {
return Ok(None);
};
let raw = value(fields, name)?;
let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?;
u16::try_from(kind)
.map(Some)
.map_err(|_| ChatError::BadTag(name))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, ChatError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(ChatError::BadTag(TAG_CITATION))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
};
let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?)
.ok_or(ChatError::BadTag(TAG_EXPIRATION))?;
Ok(Some(Timestamp::from_secs(seconds)))
}
fn reply_tag(name: &str, reply: &ReplyRef) -> Tag {
Tag::custom(
name,
[
reply.id.to_hex(),
String::new(),
reply
.author
.map(|author| author.to_hex())
.unwrap_or_default(),
],
)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, ChatError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(ChatError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, ChatError> {
fields
.get(1)
.map(String::as_str)
.ok_or(ChatError::BadTag(name))
}
fn hex_id(fields: &[String], name: &'static str) -> Result<EventId, ChatError> {
let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?;
EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, ChatError> {
let bytes = decode_hex_32(hex).map_err(|_| ChatError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| ChatError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
fn channel() -> ChannelId {
ChannelId::from_bytes([0x9cu8; 32])
}
fn group() -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
}
fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event {
seal_rumor(rumor, group, author, false).expect("seals").0
}
fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor {
open(&sealed(rumor, group, author), group, &channel(), epoch)
.expect("opens")
.1
}
fn target(id: EventId, author: &Keys) -> Target {
Target {
reply: ReplyRef {
id,
author: Some(author.public_key()),
},
kind: KIND_MESSAGE,
}
}
#[test]
fn a_second_holder_folds_edits_reactions_and_a_self_delete() {
let alice = Keys::generate();
let carol = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let id = message.compute_id();
let rumors = vec![
read(&message, &group, &alice, Epoch(0)),
read(
&build_reaction(
carol.public_key(),
&channel(),
Epoch(0),
&target(id, &alice),
"🔥",
AT + 1_000,
),
&group,
&carol,
Epoch(0),
),
read(
&build_edit(
alice.public_key(),
&channel(),
Epoch(0),
id,
"hello (fixed)",
AT + 2_000,
),
&group,
&alice,
Epoch(0),
),
read(
&build_delete(
alice.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 3_000,
),
&group,
&alice,
Epoch(0),
),
];
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
assert_eq!(folded[0].content, "hello (fixed)");
assert_eq!(folded[0].edited_at, Some(AT + 2_000));
assert_eq!(
folded[0].reactions.get(&carol.public_key()),
Some(&"🔥".to_owned())
);
assert!(folded[0].deleted);
}
#[test]
fn an_edit_or_delete_from_another_author_is_ignored() {
let alice = Keys::generate();
let bob = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let id = message.compute_id();
let rumors = vec![
read(&message, &group, &alice, Epoch(0)),
read(
&build_edit(
bob.public_key(),
&channel(),
Epoch(0),
id,
"mine now",
AT + 1_000,
),
&group,
&bob,
Epoch(0),
),
read(
&build_delete(
bob.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
None,
AT + 2_000,
),
&group,
&bob,
Epoch(0),
),
];
let folded = fold(&rumors, |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
assert_eq!(folded[0].edited_at, None);
assert!(!folded[0].deleted);
}
#[test]
fn a_comment_carries_its_root_and_its_parent() {
let alice = Keys::generate();
let bob = Keys::generate();
let group = group();
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
let root_id = root.compute_id();
let parent = build_message(
bob.public_key(),
&channel(),
Epoch(0),
"parent",
None,
AT + 1_000,
);
let parent_id = parent.compute_id();
let comment = build_comment(
alice.public_key(),
&channel(),
Epoch(0),
"deep",
&target(parent_id, &bob),
Some(&target(root_id, &alice)),
AT + 2_000,
);
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
assert!(
comment
.tags
.iter()
.any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() })
);
assert!(
comment
.tags
.iter()
.any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() })
);
let rumor = read(&comment, &group, &alice, Epoch(0));
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
panic!("a comment is a message row")
};
assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id));
assert_eq!(thread_root.map(|root| root.id), Some(root_id));
}
#[test]
fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() {
let alice = Keys::generate();
let group = group();
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
assert!(
open(
&sealed(&plain, &group, &alice),
&group,
&channel(),
Epoch(0)
)
.is_ok()
);
// The keyholder re-addresses their own rumor: the binding is judged
// against the plane whose key opened the wrap, never the rumor's claim.
let elsewhere = ChannelId::from_bytes([0xeeu8; 32]);
assert!(matches!(
open(
&sealed(&plain, &group, &alice),
&group,
&elsewhere,
Epoch(0)
),
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
assert!(matches!(
open(
&sealed(&stale, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::Stream(StreamError::EpochMismatch))
));
// Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a
// chat rumor however well-formed it looks.
let seal = build_seal(&plain, SealForm::Plaintext, &group, &alice).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(AT / 1000),
&[],
)
.expect("wraps");
assert!(matches!(
open(&wrap, &group, &channel(), Epoch(0)),
Err(ChatError::NotEncryptedSealed)
));
let ghost = build_rumor_ms(
3300,
alice.public_key(),
"v1 ghost",
channel_binding_tags(&channel(), Epoch(0)),
AT,
);
assert!(matches!(
seal_rumor(&ghost, &group, &alice, false),
Err(ChatError::UnknownKind(3300))
));
let mut tags = channel_binding_tags(&channel(), Epoch(0));
tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)]));
let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT);
assert!(matches!(
open(
&sealed(&ambiguous, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::DuplicateTag(TAG_TARGET))
));
}
#[test]
fn a_moderator_delete_needs_the_roster_and_a_citation() {
let alice = Keys::generate();
let moderator = Keys::generate();
let peer = Keys::generate();
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&group,
&alice,
Epoch(0),
);
let id = message.id;
let citation = AuthorityCitation {
entity: [0x33; 32],
version: 1,
hash: [0x44; 32],
};
let delete = |author: &Keys, citation: Option<&AuthorityCitation>| {
read(
&build_delete(
author.public_key(),
&channel(),
Epoch(0),
id,
Some(KIND_MESSAGE),
citation,
AT + 1_000,
),
&group,
author,
Epoch(0),
)
};
let can_delete =
|actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| {
actor != author && citation.is_some() && actor == &moderator.public_key()
};
let cited = vec![message.clone(), delete(&moderator, Some(&citation))];
assert!(matches!(
&cited[1].action,
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
"an uncited delete names no rank"
);
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
assert!(
!fold(&peer_delete, can_delete)[0].deleted,
"a peer's delete is not authority"
);
let own = vec![message.clone(), delete(&alice, None)];
assert!(
fold(&own, |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
}
+979
View File
@@ -0,0 +1,979 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::{Result, bail};
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
use serde::{Deserialize, Serialize};
use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
invite_links_locator, verify_community_id,
};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
pub const MAX_NAME_BYTES: usize = 64;
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
pub const MAX_RELAYS: usize = 5;
pub const MAX_REGISTRY_LINKS: usize = 64;
pub const GENERAL_CHANNEL: &str = "general";
pub const ROOT_EPOCH: Epoch = Epoch(0);
const GENESIS_VERSION: u64 = 1;
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ImageRef {
pub url: String,
pub key: String,
pub nonce: String,
pub hash: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityMetadata {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChannelMetadata {
pub name: String,
pub private: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub voice: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub deleted: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommunityIdentity {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
}
impl CommunityIdentity {
pub fn verify(&self) -> bool {
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
}
}
#[derive(Debug, Clone)]
pub struct CommunityGenesis {
pub identity: CommunityIdentity,
pub community_root: [u8; 32],
pub control_root: [u8; 32],
pub channel_id: ChannelId,
pub wraps: Vec<Event>,
}
pub fn genesis(
owner: &Keys,
metadata: &CommunityMetadata,
at_secs: u64,
) -> Result<CommunityGenesis> {
let metadata_content = encode_metadata(metadata)?;
let owner_salt = random_32()?;
let identity = CommunityIdentity {
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
owner: owner.public_key(),
owner_salt,
};
let community_root = random_32()?;
let control_root = random_32()?;
let channel_id = ChannelId::from_bytes(random_32()?);
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
let channel_content = serde_json::to_string(&ChannelMetadata {
name: GENERAL_CHANNEL.to_owned(),
private: false,
..ChannelMetadata::default()
})?;
let editions = [
build_edition(EditionFields {
author: identity.owner,
subkind: vsk::COMMUNITY_METADATA,
entity: *identity.community_id.as_bytes(),
version: GENESIS_VERSION,
prev: None,
citation: None,
content: &metadata_content,
at_secs,
}),
build_edition(EditionFields {
author: identity.owner,
subkind: vsk::CHANNEL_METADATA,
entity: *channel_id.as_bytes(),
version: GENESIS_VERSION,
prev: None,
citation: None,
content: &channel_content,
at_secs,
}),
];
let mut wraps = Vec::with_capacity(editions.len());
for edition in &editions {
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
}
Ok(CommunityGenesis {
identity,
community_root,
control_root,
channel_id,
wraps,
})
}
/// Opens a Control Plane wrap from its reading key alone.
pub fn open_edition(
wrap: &Event,
read: &GroupKey,
address: &PublicKey,
verify_wrap_signature: bool,
) -> Result<ParsedEdition> {
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
if opened.seal_form != SealForm::Plaintext {
bail!("control editions require a plaintext seal");
}
Ok(parse_edition(&opened.rumor)?)
}
/// Appends editions to entity chains.
pub struct ControlWriter {
pub author: PublicKey,
pub read: GroupKey,
pub signer: GroupKey,
}
pub struct Edition<'a> {
pub subkind: &'a str,
pub entity: [u8; 32],
pub content: &'a str,
/// The head this edition supersedes.
///
/// `None` starts the chain.
pub head: Option<&'a EntityHead>,
pub citation: Option<AuthorityCitation>,
}
impl ControlWriter {
pub fn publish(
&self,
keys: &Keys,
edition: Edition<'_>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let rumor = build_edition(EditionFields {
author: self.author,
subkind: edition.subkind,
entity: edition.entity,
version: edition
.head
.map_or(GENESIS_VERSION, |head| head.version + 1),
prev: edition.head.map(|head| head.self_hash),
citation: edition.citation,
content: edition.content,
at_secs,
});
let parsed = parse_edition(&rumor)?;
let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs)?;
Ok((wrap, EntityHead::from(&parsed)))
}
pub fn set_community_metadata(
&self,
keys: &Keys,
community_id: &CommunityId,
metadata: &CommunityMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = encode_metadata(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_channel_metadata(
&self,
keys: &Keys,
channel: &ChannelId,
metadata: &ChannelMetadata,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = serde_json::to_string(metadata)?;
self.publish(
keys,
Edition {
subkind: vsk::CHANNEL_METADATA,
entity: *channel.as_bytes(),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_role(
&self,
keys: &Keys,
role: &Role,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = role.to_content()?;
self.publish(
keys,
Edition {
subkind: vsk::ROLE,
entity: *role.role_id.as_bytes(),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_grant(
&self,
keys: &Keys,
community_id: &CommunityId,
grant: &Grant,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let content = grant.to_content()?;
self.publish(
keys,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(community_id, &grant.member.to_bytes()),
content: &content,
head,
citation,
},
at_secs,
)
}
pub fn set_banlist(
&self,
keys: &Keys,
community_id: &CommunityId,
banned: &BTreeSet<PublicKey>,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let entries: Vec<String> = banned.iter().map(PublicKey::to_hex).collect();
let content = serde_json::to_string(&entries)?;
self.publish(
keys,
Edition {
subkind: vsk::BANLIST,
entity: banlist_locator(community_id),
content: &content,
head,
citation,
},
at_secs,
)
}
#[allow(clippy::too_many_arguments)]
pub fn set_registry(
&self,
keys: &Keys,
community_id: &CommunityId,
creator: &PublicKey,
links: &[PublicKey],
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
let entries: Vec<String> = links
.iter()
.take(MAX_REGISTRY_LINKS)
.map(PublicKey::to_hex)
.collect();
let content = serde_json::to_string(&entries)?;
self.publish(
keys,
Edition {
subkind: vsk::INVITE_LINKS,
entity: invite_links_locator(community_id, &creator.to_bytes()),
content: &content,
head,
citation,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
}
if metadata
.description
.as_ref()
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
{
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
}
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
Ok(serde_json::to_string(&metadata)?)
}
#[derive(Debug, Clone, Default)]
pub struct ControlFold {
pub roles: CommunityRoles,
pub banned: BTreeSet<PublicKey>,
pub community: Option<CommunityMetadata>,
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
/// Each creator's live link-signer set.
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
pub floors: Floors,
pub gapped: bool,
}
impl ControlFold {
pub fn is_public(&self) -> bool {
self.registries.values().any(|links| !links.is_empty())
}
}
pub fn fold_control(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
floors: &Floors,
held_bans: &BTreeSet<PublicKey>,
) -> ControlFold {
let authority: Vec<AuthorityEdition> = editions
.iter()
.filter_map(|edition| AuthorityEdition::parse(edition, community_id))
.collect();
let roster = fold_roster(owner, community_id, &authority, floors, held_bans);
let metadata = fold_metadata(owner, community_id, editions, &roster, floors);
let mut floors = roster.floors;
floors.extend(metadata.floors);
ControlFold {
roles: roster.roles,
banned: roster.banned,
community: metadata.community,
channels: metadata.channels,
registries: metadata.registries,
floors,
gapped: roster.gapped || metadata.gapped,
}
}
#[derive(Debug, Default)]
struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
floors: Floors,
gapped: bool,
}
fn fold_metadata(
owner: &PublicKey,
community_id: &CommunityId,
editions: &[ParsedEdition],
roster: &Roster,
floors: &Floors,
) -> MetadataFold {
let judge = Judge {
owner,
community_id,
roster,
floors,
};
let community_entity = *community_id.as_bytes();
let mut community: Vec<&ParsedEdition> = Vec::new();
let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
match edition.subkind.as_str() {
// A channel addressed at the community's own coordinate would share, and
// corrupt, the metadata chain's floor.
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
community.push(edition)
}
vsk::CHANNEL_METADATA if edition.entity != community_entity => {
channels.entry(edition.entity).or_default().push(edition);
}
_ => {}
}
}
let mut fold = MetadataFold::default();
if let Some(head) = authorized_head(
&judge,
community_entity,
&community,
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.floors.insert(head.entity, EntityHead::from(head));
}
for (entity, candidates) in &channels {
let Some(head) = authorized_head(
&judge,
*entity,
candidates,
Permissions::MANAGE_CHANNELS,
&mut fold.gapped,
) else {
continue;
};
fold.floors.insert(*entity, EntityHead::from(head));
if let Ok(metadata) = serde_json::from_str::<ChannelMetadata>(&head.content) {
fold.channels
.insert(ChannelId::from_bytes(*entity), metadata);
}
}
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold
}
fn fold_registries(
judge: &Judge<'_>,
editions: &[ParsedEdition],
floors: &mut Floors,
gapped: &mut bool,
) -> BTreeMap<PublicKey, Vec<PublicKey>> {
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
if edition.subkind == vsk::INVITE_LINKS
&& invite_links_locator(judge.community_id, &edition.author.to_bytes())
== edition.entity
{
candidates.entry(edition.entity).or_default().push(edition);
}
}
let mut registries = BTreeMap::new();
for (entity, group) in &candidates {
let Some(head) = authorized_head(judge, *entity, group, Permissions::CREATE_INVITE, gapped)
else {
continue;
};
floors.insert(*entity, EntityHead::from(head));
let Ok(links) = serde_json::from_str::<Vec<String>>(&head.content) else {
continue;
};
registries.insert(
head.author,
links
.iter()
.filter_map(|link| PublicKey::from_hex(link).ok())
.take(MAX_REGISTRY_LINKS)
.collect(),
);
}
registries
}
struct Judge<'a> {
owner: &'a PublicKey,
community_id: &'a CommunityId,
roster: &'a Roster,
floors: &'a Floors,
}
fn authorized_head<'a>(
judge: &Judge<'_>,
entity: [u8; 32],
candidates: &[&'a ParsedEdition],
permission: u64,
gapped: &mut bool,
) -> Option<&'a ParsedEdition> {
let authorized: Vec<&ParsedEdition> = candidates
.iter()
.copied()
.filter(|edition| {
// A banned npub's edits are dropped even while a grant naming them still carries the bit.
!judge.roster.banned.contains(&edition.author)
&& judge
.roster
.roles
.is_authorized(&edition.author, judge.owner, permission)
&& citation_ok(
judge.owner,
judge.community_id,
&edition.author,
edition.citation.as_ref(),
&judge.roster.floors,
)
})
.collect();
if authorized.is_empty() {
return None;
}
let metas: Vec<EditionMeta> = authorized
.iter()
.map(|edition| EditionMeta::from(*edition))
.collect();
let selection = fold_head(&metas, judge.floors.get(&entity));
*gapped |= selection.gap;
selection.head.map(|index| authorized[index])
}
fn seal_edition(
edition: &UnsignedEvent,
owner: &Keys,
read: &GroupKey,
signer: &GroupKey,
at_secs: u64,
) -> Result<Event> {
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
let (wrap, _) = wrap_seal_with(
&seal,
read.conversation(),
signer.keys(),
KIND_WRAP,
Timestamp::from_secs(at_secs),
&[],
)?;
Ok(wrap)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::derive::grant_locator;
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
const AT: u64 = 1_700_000_000;
fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) {
let community_id = minted.identity.community_id;
(
control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"),
control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH)
.expect("derives"),
)
}
fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec<ParsedEdition> {
wraps
.iter()
.map(|wrap| open_edition(wrap, read, address, true).expect("opens"))
.collect()
}
fn metadata(name: &str) -> CommunityMetadata {
CommunityMetadata {
name: name.to_owned(),
..CommunityMetadata::default()
}
}
#[test]
fn genesis_reopens_for_a_second_holder() {
let owner = Keys::generate();
let community_metadata = CommunityMetadata {
name: "coop".to_owned(),
relays: vec!["wss://relay.example".to_owned()],
..CommunityMetadata::default()
};
let minted = genesis(&owner, &community_metadata, AT).expect("mints");
assert!(minted.identity.verify(), "identity is self-certifying");
// Only what an invite hands over: the roots, the community id and the owner salt.
let (read, signer) = holder(&minted);
let editions = open_all(&minted.wraps, &read, &signer.pk());
assert_eq!(editions.len(), 2);
let community = &editions[0];
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
assert_eq!(community.author, owner.public_key());
assert_eq!((community.version, community.prev), (1, None));
assert_eq!(
serde_json::from_str::<CommunityMetadata>(&community.content)
.expect("parses")
.name,
"coop"
);
let channel = &editions[1];
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
for edition in &editions {
let folded = fold(&[EditionMeta::from(edition)], 0, None);
assert_eq!(folded.head, Some(0));
assert!(
folded.anchored && !folded.gap,
"genesis anchors at its floor"
);
}
let state = CommunityState::from_genesis(&minted, &editions, AT * 1_000).expect("projects");
smol::block_on(async {
let database = MemoryDatabase::unbounded();
save_state(&database, &state).await.expect("saves");
let loaded = load_state(&database, &minted.identity.community_id)
.await
.expect("loads")
.expect("present");
assert_eq!(loaded.community_root, minted.community_root);
assert_eq!(loaded.control_root, Some(minted.control_root));
assert_eq!(loaded.channels.len(), 1);
assert_eq!(loaded.heads.len(), 2);
});
}
#[test]
fn metadata_and_channel_edits_reach_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let genesis_editions = open_all(&minted.wraps, &read, &signer.pk());
let roster = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
roster.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let community_head = roster.floors.get(community_id.as_bytes()).expect("head");
let channel_head = roster
.floors
.get(minted.channel_id.as_bytes())
.expect("head");
let (community_wrap, _) = writer
.set_community_metadata(
&owner,
&community_id,
&CommunityMetadata {
relays: vec!["wss://relay.example".to_owned()],
..metadata("coop two")
},
Some(community_head),
None,
AT + 1,
)
.expect("publishes");
let (channel_wrap, _) = writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "lobby".to_owned(),
private: false,
..ChannelMetadata::default()
},
Some(channel_head),
None,
AT + 2,
)
.expect("publishes");
let mut edited = genesis_editions.clone();
edited.extend(open_all(
&[community_wrap, channel_wrap],
&read,
&signer.pk(),
));
let folded = fold_control(
&owner_pk,
&community_id,
&edited,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop two")
);
assert_eq!(
folded
.channels
.get(&minted.channel_id)
.map(|channel| channel.name.as_str()),
Some("lobby")
);
// A relay serving only the editions a client already folded past must not walk
// the community backwards.
let stale = fold_control(
&owner_pk,
&community_id,
&genesis_editions,
&folded.floors,
&BTreeSet::new(),
);
assert!(stale.community.is_none());
assert!(stale.channels.is_empty());
let mut state =
CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects");
state.apply_fold(&folded);
assert_eq!(state.channels.len(), 1);
assert_eq!(state.channels[0].name, "lobby");
assert_eq!(state.relays.len(), 1);
}
#[test]
fn a_delegated_member_edits_metadata_only_under_its_own_grant() {
let owner = Keys::generate();
let member = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let role_id = RoleId::from_bytes([0x07; 32]);
let role = Role {
role_id,
name: "Mod".to_owned(),
position: 1,
permissions: Permissions(Permissions::MANAGE_METADATA),
scope: RoleScope::Server,
color: 0,
extra: Extra::default(),
};
let (role_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::ROLE,
entity: *role_id.as_bytes(),
content: &role.to_content().expect("serializes"),
head: None,
citation: None,
},
AT + 1,
)
.expect("publishes");
let (grant_wrap, _) = writer
.publish(
&owner,
Edition {
subkind: vsk::GRANT,
entity: grant_locator(&community_id, &member.public_key().to_bytes()),
content: &Grant {
member: member.public_key(),
role_ids: vec![role_id],
control_wrap: None,
extra: Extra::default(),
}
.to_content()
.expect("serializes"),
head: None,
citation: None,
},
AT + 2,
)
.expect("publishes");
let mut base = open_all(&minted.wraps, &read, &signer.pk());
base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk()));
let roster = fold_control(
&owner_pk,
&community_id,
&base,
&Floors::new(),
&BTreeSet::new(),
);
assert!(roster.roles.is_staff(&member.public_key(), &owner_pk));
let grant = roster
.floors
.get(&grant_locator(
&community_id,
&member.public_key().to_bytes(),
))
.expect("the member's grant folded");
let head = roster.floors.get(community_id.as_bytes()).expect("head");
// The member seals with their own keys and wraps with the staff write key.
let member_writer = ControlWriter {
author: member.public_key(),
read,
signer: signer.clone(),
};
let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes");
let (uncited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: None,
},
AT + 3,
)
.expect("publishes");
let (cited, _) = member_writer
.publish(
&member,
Edition {
subkind: vsk::COMMUNITY_METADATA,
entity: *community_id.as_bytes(),
content: &content,
head: Some(head),
citation: Some(AuthorityCitation {
entity: grant.entity,
version: grant.version,
hash: grant.self_hash,
}),
},
AT + 4,
)
.expect("publishes");
// Uncited, the edit claims an authority the member never showed.
let mut forged = base.clone();
forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&forged,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop")
);
let mut edited_editions = base;
edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&edited_editions,
&Floors::new(),
&BTreeSet::new(),
);
assert_eq!(
folded.community.as_ref().map(|meta| meta.name.as_str()),
Some("coop by mod")
);
}
}
+451
View File
@@ -0,0 +1,451 @@
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use anyhow::{Result, bail};
use hkdf::Hkdf;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
use sha2::{Digest, Sha256};
use crate::{ChannelId, CommunityId, Epoch};
pub const TOKEN_LEN: usize = 16;
const LABEL_CHANNEL: &str = "concord/channel";
const LABEL_CONTROL: &str = "concord/control";
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym";
const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym";
const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym";
const LABEL_GUESTBOOK: &str = "concord/guestbook";
const LABEL_DISSOLVED: &str = "concord/dissolved";
const LABEL_GRANT: &str = "concord/grant";
const LABEL_BANLIST: &str = "concord/banlist";
const LABEL_PINS: &str = "concord/pins";
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
const LABEL_INVITE_KEY: &str = "concord/invite-key";
const LABEL_COMMUNITY: &str = "concord/community";
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
const ZERO32: [u8; 32] = [0u8; 32];
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
info.extend_from_slice(label.as_bytes());
info.push(0x00);
info.extend_from_slice(id32);
if let Some(epoch) = epoch {
info.extend_from_slice(&epoch.to_be_bytes());
}
info
}
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let mut okm = [0u8; 32];
Hkdf::<Sha256>::new(None, ikm)
.expand(info, &mut okm)
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
okm
}
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
return Ok(secret_key);
}
for counter in 0u8..=u8::MAX {
let mut info = Vec::with_capacity(base_info.len() + 1);
info.extend_from_slice(base_info);
info.push(counter);
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
return Ok(secret_key);
}
}
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
}
#[derive(Clone)]
pub struct GroupKey {
keys: Keys,
conversation: ConversationKey,
}
impl GroupKey {
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
let key = memo_key(label, secret, id32, epoch);
if let Some(hit) = lock_memo().get(&key) {
return Ok(hit.clone());
}
let info = build_info(label, id32, epoch);
let secret_key = hkdf_to_secret_key(secret, &info)?;
let keys = Keys::new(secret_key);
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
let group_key = Self { keys, conversation };
let mut memo = lock_memo();
if memo.len() >= 1024 {
memo.clear();
}
memo.insert(key, group_key.clone());
Ok(group_key)
}
pub fn pk(&self) -> PublicKey {
self.keys.public_key()
}
pub fn pk_hex(&self) -> String {
self.keys.public_key().to_hex()
}
pub fn keys(&self) -> &Keys {
&self.keys
}
pub fn conversation(&self) -> &ConversationKey {
&self.conversation
}
}
impl std::fmt::Debug for GroupKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupKey")
.field("pk", &self.pk_hex())
.finish()
}
}
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn clear_memo() {
lock_memo().clear()
}
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(label.as_bytes());
hasher.update([0x00]);
hasher.update(secret);
hasher.update(id32);
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
hasher.update([epoch.is_some() as u8]);
hasher.finalize().into()
}
/// `secret` is the `community_root` for a public channel.
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
}
/// The plane's read key: its conversation key encrypts the wraps for every member.
pub fn control_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// The plane's address and wrap signer, held only by staff.
/// Wraps still encrypt under [`control_group_key`].
pub fn control_signer_group_key(
control_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL_SIGNER,
control_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Member-writable, unlike the Control Plane:
///
/// - A join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_GUESTBOOK,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Keyed by the prior `community_root` rather than the channel key,
/// so any retained member recovers any epoch's rekey without a ratchet.
pub fn channel_rekey_group_key(
prior_root: &[u8; 32],
channel: &ChannelId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_REKEY_PSEUDONYM,
prior_root,
channel.as_bytes(),
Some(new_epoch.0),
)
}
pub fn base_rekey_group_key(
prior_root: &[u8; 32],
community_id: &CommunityId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_BASE_REKEY_PSEUDONYM,
prior_root,
community_id.as_bytes(),
Some(new_epoch.0),
)
}
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
}
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
let mut hasher = Sha256::new();
hasher.update(LABEL_COMMUNITY.as_bytes());
hasher.update(owner_xonly);
hasher.update(owner_salt);
CommunityId::from_bytes(hasher.finalize().into())
}
pub fn verify_community_id(
community_id: &CommunityId,
owner_xonly: &[u8; 32],
owner_salt: &[u8; 32],
) -> bool {
community_id_of(owner_xonly, owner_salt) == *community_id
}
/// The continuity a rekey blob must satisfy against the key currently held.
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
hasher.update(previous_epoch.0.to_be_bytes());
hasher.update(previous_key);
hasher.finalize().into()
}
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_GRANT, member_xonly, None),
)
}
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_BANLIST, &ZERO32, None),
)
}
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_PINS, channel.as_bytes(), None),
)
}
/// Bound to the creator, so each creator owns exactly their own registry.
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
)
}
/// Built from public inputs only, so a locator match proves nothing about authenticity
pub fn recipient_locator(
rotator_xonly: &[u8; 32],
recipient_xonly: &[u8; 32],
scope_id: &[u8; 32],
new_epoch: Epoch,
) -> [u8; 32] {
let mut ikm = [0u8; 64];
ikm[..32].copy_from_slice(rotator_xonly);
ikm[32..].copy_from_slice(recipient_xonly);
hkdf32(
&ikm,
&build_info(LABEL_RECIPIENT_PSEUDONYM, scope_id, Some(new_epoch.0)),
)
}
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
}
#[cfg(test)]
mod tests {
use super::*;
const CHANNEL_E0_SEED: &str =
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
const CHANNEL_EMULTI_PK: &str =
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
const CONTROL_SIGNER_E0_SEED: &str =
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
const CONTROL_SIGNER_E0_PK: &str =
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
const CONTROL_SIGNER_EMULTI_PK: &str =
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
const GUESTBOOK_E0_PK: &str =
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
const CHANNEL_REKEY_E1_PK: &str =
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
const BASE_REKEY_E1_PK: &str =
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
const BANLIST_LOCATOR: &str =
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
const INVITE_LINKS_LOCATOR: &str =
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
const RECIPIENT_LOCATOR: &str =
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
const EPOCH_COMMITMENT: &str =
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
const EPOCH_MULTI: u64 = 0x0102030405060708;
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
fn secret() -> [u8; 32] {
let mut key = [0u8; 32];
for (index, byte) in key.iter_mut().enumerate() {
*byte = index as u8;
}
key
}
fn id32() -> [u8; 32] {
let mut id = [0u8; 32];
for (index, byte) in id.iter_mut().enumerate() {
*byte = 255 - index as u8;
}
id
}
fn hex(bytes: &[u8]) -> String {
data_encoding::HEXLOWER.encode(bytes)
}
#[test]
fn golden_vectors() {
let secret = secret();
let id = id32();
let alt = [0x11u8; 32];
let community_id = CommunityId::from_bytes(id);
let channel = ChannelId::from_bytes(id);
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
assert_eq!(
hex(channel_e0.keys().secret_key().as_secret_bytes()),
CHANNEL_E0_SEED
);
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
assert_eq!(
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CHANNEL_EMULTI_PK
);
assert_eq!(
control_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
CONTROL_E0_PK
);
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
assert_eq!(
hex(signer.keys().secret_key().as_secret_bytes()),
CONTROL_SIGNER_E0_SEED
);
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
assert_eq!(
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CONTROL_SIGNER_EMULTI_PK
);
assert_eq!(
guestbook_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
GUESTBOOK_E0_PK
);
assert_eq!(
channel_rekey_group_key(&secret, &channel, Epoch(1))
.expect("derives")
.pk_hex(),
CHANNEL_REKEY_E1_PK
);
assert_eq!(
base_rekey_group_key(&secret, &community_id, Epoch(1))
.expect("derives")
.pk_hex(),
BASE_REKEY_E1_PK
);
assert_eq!(
dissolved_group_key(&community_id)
.expect("derives")
.pk_hex(),
DISSOLVED_PK
);
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
assert_eq!(
hex(&invite_links_locator(&community_id, &alt)),
INVITE_LINKS_LOCATOR
);
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
assert_eq!(
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
RECIPIENT_LOCATOR
);
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
assert_eq!(
hex(&epoch_key_commitment(Epoch(2), &secret)),
EPOCH_COMMITMENT
);
}
}
+571
View File
@@ -0,0 +1,571 @@
use std::collections::BTreeMap;
use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::decode_hex_32;
use crate::stream::build_rumor_secs;
pub const KIND_CONTROL: u16 = 3308;
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
/// Entity types an edition can address.
pub mod vsk {
pub const COMMUNITY_METADATA: &str = "0";
pub const ROLE: &str = "1";
pub const CHANNEL_METADATA: &str = "2";
pub const GRANT: &str = "3";
pub const BANLIST: &str = "4";
pub const INVITE_LIVE: &str = "6";
pub const INVITE_LINKS: &str = "8";
pub const INVITE_REVOKED: &str = "9";
pub const DISSOLVED: &str = "10";
pub const PINS: &str = "11";
}
pub const TAG_SUBKIND: &str = "vsk";
pub const TAG_CITATION: &str = "vac";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
#[derive(Debug)]
pub enum EditionError {
BadKind(u16),
BadField(&'static str),
Duplicate(&'static str),
Missing(&'static str),
}
impl fmt::Display for EditionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
}
}
}
impl std::error::Error for EditionError {}
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
/// coordinate, version and hash. It is a sync floor, not the verdict.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
pub version: u64,
pub hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct ParsedEdition {
pub author: PublicKey,
pub subkind: String,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: String,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
pub struct EditionFields<'a> {
pub author: PublicKey,
pub subkind: &'a str,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: &'a str,
pub at_secs: u64,
}
fn signing_bytes(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> Vec<u8> {
let mut bytes =
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
bytes.extend_from_slice(EDITION_LABEL);
bytes.extend_from_slice(entity);
bytes.extend_from_slice(&version.to_be_bytes());
match prev {
Some(prev) => {
bytes.push(1);
bytes.extend_from_slice(prev);
}
None => {
bytes.push(0);
bytes.extend_from_slice(&[0u8; 32]);
}
}
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
bytes.extend_from_slice(content);
bytes
}
pub fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
)
}
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
if fields.len() != 4 {
return None;
}
Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION).ok()?,
version: canonical_decimal(&fields[2])?,
hash: hex32(&fields[3], TAG_CITATION).ok()?,
})
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
];
if let Some(prev) = fields.prev {
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
}
if let Some(citation) = fields.citation {
tags.push(citation_tag(&citation));
}
build_rumor_secs(
KIND_CONTROL,
fields.author,
fields.content,
tags,
fields.at_secs,
)
}
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
let kind = rumor.kind.as_u16();
if kind != KIND_CONTROL {
return Err(EditionError::BadKind(kind));
}
let subkind = value(rumor, TAG_SUBKIND)?
.ok_or(EditionError::Missing(TAG_SUBKIND))?
.to_owned();
if canonical_decimal(&subkind).is_none() {
return Err(EditionError::BadField(TAG_SUBKIND));
}
let entity = hex32(
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
TAG_ENTITY,
)?;
let version =
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
.ok_or(EditionError::BadField(TAG_VERSION))?;
let prev = match value(rumor, TAG_PREV)? {
Some(raw) => Some(hex32(raw, TAG_PREV)?),
None => None,
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
None => None,
};
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
Ok(ParsedEdition {
author: rumor.pubkey,
subkind,
entity,
version,
prev,
citation,
content: rumor.content.clone(),
self_hash,
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionMeta {
pub version: u64,
pub self_hash: [u8; 32],
pub prev: Option<[u8; 32]>,
pub tiebreak_id: EventId,
}
impl From<&ParsedEdition> for EditionMeta {
fn from(edition: &ParsedEdition) -> Self {
Self {
version: edition.version,
self_hash: edition.self_hash,
prev: edition.prev,
tiebreak_id: edition.rumor_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.reduce(|(best_index, best), (index, candidate)| {
let supersedes = candidate.version > best.version
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
if supersedes {
(index, candidate)
} else {
(best_index, best)
}
})
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
if raw.len() > 1 && raw.starts_with('0') {
return None;
}
raw.parse().ok()
}
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
}
fn fields<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, EditionError> {
let mut found: Option<&[String]> = None;
for tag in rumor.tags.iter() {
let tag_fields = tag.as_slice();
if tag_fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(EditionError::Duplicate(name));
}
found = Some(tag_fields);
}
Ok(found)
}
fn value<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a str>, EditionError> {
match fields(rumor, name)? {
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
Some(_) => Err(EditionError::BadField(name)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];
assert_eq!(
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
);
// The golden vector only exercises the absent-prev encoding; pin the
// present-prev branch structurally so a swapped flag stays visible.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(
bytes.len(),
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
);
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
assert_eq!(
bytes[8 + EDITION_LABEL.len() + 32..][..8],
1u64.to_be_bytes()
);
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}
+890
View File
@@ -0,0 +1,890 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
at_ms: u64,
/// The `(creator, label)` an invite attributed the join to.
invited_by: Option<(String, String)>,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
at_ms: u64,
citation: Option<AuthorityCitation>,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub at_ms: u64,
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberState {
Joined {
at_ms: u64,
invited_by: Option<(String, String)>,
},
Left {
at_ms: u64,
},
Kicked {
at_ms: u64,
actor: PublicKey,
},
}
pub fn build_join(
member: PublicKey,
invited_by: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invited_by {
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
}
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
}
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
}
pub fn build_kick(
actor: PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
}
pub fn build_snapshot_chunks(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
let total = chunks.len() as u32;
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
let content = format!(
"[{}]",
hex.iter()
.map(|member| format!("\"{member}\""))
.collect::<Vec<_>>()
.join(",")
);
let tags = vec![Tag::custom(
TAG_SNAP,
[
HEXLOWER.encode(&snapshot_id),
(index as u32 + 1).to_string(),
total.to_string(),
],
)];
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
pub fn open(
wrap: &Event,
group: &GroupKey,
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let entry = entry_of(&opened)?;
let rumor = GuestbookRumor {
id: opened.rumor_id,
author: opened.author,
kind: opened.rumor.kind,
at_ms: opened.at_ms,
entry,
};
Ok((opened, rumor))
}
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
for rumor in rumors {
if rumor.at_ms > horizon {
continue;
}
match &rumor.entry {
GuestbookEntry::Join {
member,
at_ms,
invited_by,
} => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: invited_by.clone(),
},
),
GuestbookEntry::Leave { member, at_ms } => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Left { at_ms: *at_ms },
),
GuestbookEntry::Kick {
actor,
target,
at_ms,
citation,
} => {
if !can_kick(actor, target, citation.as_ref()) {
continue;
}
offer(
&mut states,
*target,
*at_ms,
rumor.id,
MemberState::Kicked {
at_ms: *at_ms,
actor: *actor,
},
);
}
GuestbookEntry::Snapshot {
refounder,
members,
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
continue;
}
for member in members {
offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: None,
},
);
}
}
}
}
states
.into_iter()
.map(|(member, (_, _, state))| (member, state))
.collect()
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
granted: &BTreeSet<PublicKey>,
banned: &BTreeSet<PublicKey>,
banned_at: &BTreeMap<PublicKey, u64>,
) -> BTreeSet<PublicKey> {
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
candidates.extend(observed.keys());
candidates.extend(granted.iter());
let mut members = BTreeSet::new();
for member in candidates {
let mut inclusion = observed.get(member).copied();
if let Some(state) = coalesced.get(member) {
match state {
MemberState::Joined { at_ms, .. } => {
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
}
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
}
}
if inclusion.is_none() && granted.contains(member) {
inclusion = Some(0);
}
let mut exclusion = match coalesced.get(member) {
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
Some(*at_ms)
}
_ => None,
};
if banned.contains(member) {
exclusion = Some(match banned_at.get(member) {
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
None => u64::MAX,
});
}
if let Some(inclusion) = inclusion
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
{
members.insert(*member);
}
}
members
}
fn offer(
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
member: PublicKey,
at_ms: u64,
id: EventId,
state: MemberState,
) {
let candidate = (at_ms, Reverse(id));
if let Some(existing) = states.get(&member)
&& (existing.0, existing.1) >= candidate
{
return;
}
states.insert(member, (at_ms, Reverse(id), state));
}
fn is_guestbook_kind(kind: u16) -> bool {
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
}
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
let rumor = &opened.rumor;
let author = opened.author;
let at_ms = opened.at_ms;
match rumor.kind.as_u16() {
KIND_JOIN_LEAVE => match rumor.content.as_str() {
CONTENT_JOIN => Ok(GuestbookEntry::Join {
member: author,
at_ms,
invited_by: invite_of(rumor),
}),
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
member: author,
at_ms,
}),
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
},
KIND_KICK => Ok(GuestbookEntry::Kick {
actor: author,
target: tagged_pubkey(rumor, TAG_TARGET)?,
at_ms,
citation: optional_citation(rumor)?,
}),
KIND_SNAPSHOT => {
let (snapshot_id, chunk) = snapshot_of(rumor)?;
let members = members_of(&rumor.content)?;
Ok(GuestbookEntry::Snapshot {
refounder: author,
members,
snapshot_id,
chunk,
at_ms,
})
}
other => Err(GuestbookError::UnknownKind(other)),
}
}
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
rumor.tags.iter().find_map(|candidate| {
let fields = candidate.as_slice();
(fields.len() >= 3 && fields[0] == TAG_INVITE)
.then(|| (fields[1].clone(), fields[2].clone()))
})
}
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
let entries: Vec<String> =
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
if entries.len() > MAX_SNAPSHOT_CHUNK {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
entries
.iter()
.map(|entry| pubkey(entry, TAG_CONTENT))
.collect()
}
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
let fields = required(rumor, TAG_SNAP)?;
if fields.len() != 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let index = decimal(&fields[2])?;
let total = decimal(&fields[3])?;
if index == 0 || index > total {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::guestbook_group_key;
use crate::stream::build_rumor_secs;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
const AT: u64 = 1_700_000_000_000;
fn community() -> CommunityId {
CommunityId::from_bytes([0x11u8; 32])
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
fn citation() -> AuthorityCitation {
AuthorityCitation {
entity: [0x33u8; 32],
version: 1,
hash: [0x44u8; 32],
}
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
open(&wrap, &group()).expect("opens").1
}
#[test]
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
let alice = Keys::generate();
let bob = Keys::generate();
let carol = Keys::generate();
let dave = Keys::generate();
let frank = Keys::generate();
let grace = Keys::generate();
let owner = Keys::generate();
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
let mut rumors = vec![
publish(
&build_join(
alice.public_key(),
Some((&"ab".repeat(32), "Reddit")),
AT + 1_000,
),
&alice,
),
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
publish(
&build_kick(
carol.public_key(),
&dave.public_key(),
Some(&citation()),
AT + 5_000,
),
&carol,
),
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
];
let snapshot_id = "77".repeat(32);
let chunks =
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
for (index, chunk) in chunks.iter().enumerate() {
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
== [
TAG_SNAP,
snapshot_id.as_str(),
&(index + 1).to_string(),
"2"
]));
rumors.push(publish(chunk, &carol));
}
let can_kick =
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
assert_eq!(
states.get(&alice.public_key()),
Some(&MemberState::Joined {
at_ms: AT + 1_000,
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
})
);
assert_eq!(
states.get(&bob.public_key()),
Some(&MemberState::Left { at_ms: AT + 3_000 })
);
assert_eq!(
states.get(&dave.public_key()),
Some(&MemberState::Kicked {
at_ms: AT + 5_000,
actor: carol.public_key(),
})
);
assert!(
survivors
.iter()
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
"every chunk seeds its own members"
);
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
states,
"arrival order cannot change the fold"
);
let observed = BTreeMap::from([
(bob.public_key(), AT + 9_000),
(carol.public_key(), AT + 5_000),
]);
let granted = BTreeSet::from([grace.public_key()]);
let banned = BTreeSet::from([frank.public_key()]);
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
let mut expected = BTreeSet::from([
alice.public_key(),
bob.public_key(),
carol.public_key(),
grace.public_key(),
]);
expected.extend(survivors.iter().copied());
assert_eq!(members, expected);
assert!(
!members.contains(&dave.public_key()),
"a kicked member is out"
);
assert!(
!members.contains(&frank.public_key()),
"a ban wins over a later join"
);
}
#[test]
fn a_kick_or_snapshot_without_authority_is_dropped() {
let moderator = Keys::generate();
let outsider = Keys::generate();
let owner = Keys::generate();
let kicked = Keys::generate();
let uncited = Keys::generate();
let unranked = Keys::generate();
let refounder = Keys::generate();
let impostor = Keys::generate();
let seeded = Keys::generate();
let smuggled = Keys::generate();
let can_kick = |actor: &PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
};
let rumors = vec![
publish(
&build_kick(
moderator.public_key(),
&kicked.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
publish(
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
&moderator,
),
publish(
&build_kick(
outsider.public_key(),
&unranked.public_key(),
Some(&citation()),
AT,
),
&outsider,
),
publish(
&build_kick(
moderator.public_key(),
&owner.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
assert_eq!(
states.get(&kicked.public_key()),
Some(&MemberState::Kicked {
at_ms: AT,
actor: moderator.public_key(),
})
);
assert!(
!states.contains_key(&uncited.public_key()),
"a kick cites the Grant it acts under"
);
assert!(
!states.contains_key(&unranked.public_key()),
"a kick needs KICK"
);
assert!(
!states.contains_key(&owner.public_key()),
"nobody kicks the owner"
);
let by_refounder = build_snapshot_chunks(
refounder.public_key(),
&[seeded.public_key()],
[0x77u8; 32],
AT,
)
.remove(0);
let by_impostor = build_snapshot_chunks(
impostor.public_key(),
&[smuggled.public_key()],
[0x88u8; 32],
AT,
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
"a foreign snapshot never seeds"
);
}
}
#[test]
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
let member = Keys::generate();
let moderator = Keys::generate();
let target = Keys::generate();
let future = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
&member,
);
let horizon = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
let bad_ms = build_rumor_secs(
KIND_JOIN_LEAVE,
member.public_key(),
CONTENT_JOIN,
vec![Tag::custom("ms", ["1000"])],
AT / 1000,
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
));
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
));
let ambiguous = build_rumor_ms(
KIND_KICK,
moderator.public_key(),
"",
vec![
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
citation_tag(&citation()),
citation_tag(&citation()),
],
AT,
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
.expect("seals")
.0,
&group()
),
Err(GuestbookError::DuplicateTag(TAG_CITATION))
));
for fields in [
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
vec![snapshot_id(), "1".to_owned()],
] {
let rumor = build_rumor_ms(
KIND_SNAPSHOT,
moderator.public_key(),
"[]",
vec![Tag::custom(TAG_SNAP, fields)],
AT,
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
+962
View File
@@ -0,0 +1,962 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use data_encoding::BASE64URL_NOPAD;
use nostr::nips::nip01::Coordinate;
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
use nostr::nips::nip44::v2::ConversationKey;
use nostr::nips::nip44::{self, Version};
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::control::{ImageRef, MAX_RELAYS};
use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::edition::{TAG_SUBKIND, vsk};
use crate::list::{canonical, union};
use crate::stream::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_INVITE_LIST: u16 = 13303;
pub const KIND_DIRECT_INVITE: u16 = 3313;
pub const FRAGMENT_VERSION: u8 = 4;
pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
pub const MAX_INVITE_ENTRIES: usize = 64;
const FLAG_STOCK_SET: u8 = 0x01;
const INVITE_PATH: &str = "/invite/";
const TAG_IDENTIFIER: &str = "d";
const TAG_EXPIRATION: &str = "expiration";
const RELAY_DICT: [&str; 4] = [
"wss://jskitty.com/nostr",
"wss://asia.vectorapp.io/nostr",
"wss://relay.ditto.pub",
"wss://relay.dreamith.to",
];
#[derive(Debug)]
pub enum InviteError {
Stream(StreamError),
Json(String),
BadHex(&'static str),
TooManyChannels(usize),
TooManyInvites(usize),
Oversize(usize),
Kind(u16),
EpochTooLarge(u64),
OwnerMismatch,
BadFragment(&'static str),
BadVersion(u8),
BadLink(&'static str),
BadEvent(&'static str),
Crypto(String),
}
impl fmt::Display for InviteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InviteError::Stream(error) => write!(f, "stream: {error}"),
InviteError::Json(error) => write!(f, "json: {error}"),
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
InviteError::TooManyChannels(count) => {
write!(
f,
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
)
}
InviteError::TooManyInvites(count) => {
write!(
f,
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
)
}
InviteError::Oversize(len) => {
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
InviteError::OwnerMismatch => {
write!(f, "bundle owner does not reproduce its community_id")
}
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
InviteError::BadVersion(version) => {
write!(f, "unsupported invite fragment version {version}")
}
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
}
}
}
impl std::error::Error for InviteError {}
impl From<StreamError> for InviteError {
fn from(error: StreamError) -> Self {
InviteError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelGrant {
pub id: ChannelId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub epoch: Epoch,
#[serde(default)]
pub name: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityInvite {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
/// Unix **ms**: past it the preview still renders, joining refuses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creator_npub: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityInvite {
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
let mut invite: Self =
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(invite.channels.len()));
}
invite.relays.truncate(MAX_RELAYS);
invite.validate()?;
Ok(invite)
}
pub fn validate(&self) -> Result<(), InviteError> {
if self.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(self.channels.len()));
}
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
if epoch.0 > MAX_BUNDLE_EPOCH {
return Err(InviteError::EpochTooLarge(epoch.0));
}
}
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
hex32(&self.community_root, "community_root")?;
for channel in &self.channels {
if let Some(key) = &channel.key {
hex32(key, "channel key")?;
}
}
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
return Err(InviteError::OwnerMismatch);
}
Ok(())
}
pub fn expired(&self, now_ms: u64) -> bool {
self.expires_at.is_some_and(|expires| now_ms > expires)
}
}
#[derive(Debug, Clone)]
pub enum BundleState {
Live(Box<CommunityInvite>),
Revoked,
}
pub fn build_bundle_event(
link_signer: &Keys,
invite: &CommunityInvite,
bundle_key: &[u8; 32],
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let content = seal_bundle(bundle_key, &json)?;
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn parse_bundle_event(
event: &Event,
expected_signer: &PublicKey,
bundle_key: &[u8; 32],
) -> Result<BundleState, InviteError> {
if event.kind.as_u16() != KIND_BUNDLE {
return Err(InviteError::BadEvent("wrong kind"));
}
if event.pubkey != *expected_signer {
return Err(InviteError::BadEvent("author is not the link signer"));
}
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
return Err(InviteError::BadEvent(
"bundle is not at the link's coordinate",
));
}
event
.verify()
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
match first_tag(event, TAG_SUBKIND).as_deref() {
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
Some(vsk::INVITE_LIVE) => {}
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
}
let json = open_bundle(bundle_key, &event.content)?;
Ok(BundleState::Live(Box::new(
CommunityInvite::from_bundle_json(&json)?,
)))
}
pub fn stock_relays() -> Vec<String> {
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
}
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
let stock = relays == RELAY_DICT;
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
bytes.push(FRAGMENT_VERSION);
if stock {
bytes.push(FLAG_STOCK_SET);
} else {
bytes.push(0x00);
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
bytes.push(bounded.len() as u8);
for relay in bounded {
match dict_id(relay) {
Some(id) => bytes.push(id),
None => {
let (lead, literal) = match relay.strip_prefix("wss://") {
Some(host) => (0x00, host),
None => (0xff, relay.as_str()),
};
if literal.len() > u8::MAX as usize {
return Err(InviteError::BadFragment("relay too long"));
}
bytes.extend_from_slice(&[lead, literal.len() as u8]);
bytes.extend_from_slice(literal.as_bytes());
}
}
}
}
bytes.extend_from_slice(token);
Ok(BASE64URL_NOPAD.encode(&bytes))
}
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
let bytes = BASE64URL_NOPAD
.decode(fragment.trim().as_bytes())
.map_err(|_| InviteError::BadFragment("not base64url"))?;
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
if version != FRAGMENT_VERSION {
return Err(InviteError::BadVersion(version));
}
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
let mut offset = 2;
let mut relays = Vec::new();
if flags & FLAG_STOCK_SET != 0 {
relays = stock_relays();
} else {
let count = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
if count > MAX_BOOTSTRAP_RELAYS {
return Err(InviteError::BadFragment("too many bootstrap relays"));
}
for _ in 0..count {
let lead = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))?;
offset += 1;
if (1..=254).contains(&lead) {
if let Some(url) = dict_url(lead) {
relays.push(url.to_string());
}
continue;
}
let len = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
let end = offset
.checked_add(len)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
let text = std::str::from_utf8(raw)
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
relays.push(match lead {
0x00 => format!("wss://{text}"),
0xff => text.to_string(),
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
});
offset = end;
}
}
let end = offset
.checked_add(TOKEN_LEN)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
if end != bytes.len() {
return Err(InviteError::BadFragment("trailing bytes"));
}
let mut token = [0u8; TOKEN_LEN];
token.copy_from_slice(raw);
Ok((token, relays))
}
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
let coordinate = Coordinate {
kind: Kind::Custom(KIND_BUNDLE),
public_key: *link_signer,
identifier: String::new(),
};
Nip19::Coordinate(Nip19Coordinate {
coordinate,
relays: Vec::new(),
})
.to_bech32()
.map_err(|_| InviteError::BadLink("invalid naddr"))
}
pub fn build_invite_url(
base: &str,
link_signer: &PublicKey,
token: &[u8; TOKEN_LEN],
relays: &[String],
) -> Result<String, InviteError> {
let naddr = bundle_naddr(link_signer)?;
let fragment = encode_fragment(token, relays)?;
Ok(format!(
"{}{INVITE_PATH}{naddr}#{fragment}",
base.trim_end_matches('/')
))
}
#[derive(Debug, Clone)]
pub struct ParsedInviteLink {
/// The bundle coordinate's author.
pub link_signer: PublicKey,
pub token: [u8; TOKEN_LEN],
pub bootstrap_relays: Vec<String>,
/// The bare naddr as it appeared in the link, for the fetch.
pub naddr: String,
}
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
let (locator, fragment) = input
.trim()
.split_once('#')
.ok_or(InviteError::BadLink("no fragment"))?;
if fragment.is_empty() {
return Err(InviteError::BadLink("empty fragment"));
}
let naddr = match locator.find(INVITE_PATH) {
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
None => locator.trim_start_matches("nostr:"),
};
let link_signer = signer_from_naddr(naddr)?;
let (token, bootstrap_relays) = decode_fragment(fragment)?;
Ok(ParsedInviteLink {
link_signer,
token,
bootstrap_relays,
naddr: naddr.to_string(),
})
}
pub fn build_direct_invite(
inviter: &Keys,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json)
.finalize_unsigned(inviter.public_key());
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
if let Some(expires_at) = invite.expires_at {
tags.push(Tag::custom(
TAG_EXPIRATION,
[(expires_at / 1000).to_string()],
));
}
GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags)
.finalize(inviter)
.map_err(crypto_error)
}
pub fn unwrap_direct_invite(
wrap: &Event,
recipient: &Keys,
) -> Result<(PublicKey, CommunityInvite), InviteError> {
let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap).map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
}
Ok((
unwrapped.sender,
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteEntry {
/// The link's unlock secret, and its merge key.
pub token: String,
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
pub signer_sk: String,
pub community_id: CommunityId,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteTombstone {
pub token: String,
pub community_id: CommunityId,
#[serde(flatten)]
pub extra: Extra,
}
/// A creator's own link bookkeeping.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InviteList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<InviteEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<InviteTombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl InviteList {
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
pub fn is_live(&self, token: &str) -> bool {
self.entries.iter().any(|entry| entry.token == token)
&& !self
.tombstones
.iter()
.any(|tombstone| tombstone.token == token)
}
pub fn fits(&self) -> Result<(), InviteError> {
if self.entries.len() > MAX_INVITE_ENTRIES {
return Err(InviteError::TooManyInvites(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(InviteError::Oversize(json.len()));
}
Ok(())
}
}
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => {
let merged = merge_entry(slot.get(), &entry);
*slot.get_mut() = merged;
}
}
}
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
if canonical(&tombstone) < canonical(slot.get()) {
*slot.get_mut() = tombstone;
}
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
InviteList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, InviteError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, InviteError> {
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
let (winner, loser) = if canonical(incoming) < canonical(held) {
(incoming, held)
} else {
(held, incoming)
};
let mut merged = winner.clone();
union(&mut merged.extra, loser.extra.clone());
merged
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(stream::seal_bytes(
&ConversationKey::new(*bundle_key),
json.as_bytes(),
)?)
}
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
let plaintext = stream::open_bytes(&ConversationKey::new(*bundle_key), content)?;
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
}
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
Ok(Nip19::Coordinate(coordinate))
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
&& coordinate.coordinate.identifier.is_empty() =>
{
Ok(coordinate.coordinate.public_key)
}
_ => Err(InviteError::BadLink(
"naddr is not an invite-bundle coordinate",
)),
}
}
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
}
fn dict_id(relay: &str) -> Option<u8> {
RELAY_DICT
.iter()
.position(|known| *known == relay)
.map(|index| index as u8 + 1)
}
fn dict_url(id: u8) -> Option<&'static str> {
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
}
fn empty_identifier() -> Tag {
Tag::identifier("")
}
fn subkind_tag(value: &str) -> Tag {
Tag::custom(TAG_SUBKIND, [value])
}
fn first_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|tag| {
let fields = tag.as_slice();
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
})
}
fn json_error(error: serde_json::Error) -> InviteError {
InviteError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> InviteError {
InviteError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use data_encoding::HEXLOWER;
use super::*;
use crate::derive::{community_id_of, invite_bundle_key};
const SALT: [u8; 32] = [0x33u8; 32];
fn bundle() -> CommunityInvite {
let owner = Keys::generate();
CommunityInvite {
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
owner: owner.public_key(),
owner_salt: HEXLOWER.encode(&SALT),
community_root: "44".repeat(32),
root_epoch: Epoch(0),
control_pk: None,
channels: vec![ChannelGrant {
id: ChannelId::from_bytes([0x9cu8; 32]),
key: Some("55".repeat(32)),
epoch: Epoch(1),
name: "lounge".to_owned(),
extra: Extra::default(),
}],
relays: vec!["wss://relay.example".to_owned()],
name: "Test community".to_owned(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Extra::default(),
}
}
fn token16() -> [u8; TOKEN_LEN] {
std::array::from_fn(|i| i as u8)
}
#[test]
fn fragment_goldens_pin_the_wire_layout() {
let token = token16();
// [04 version][01 stock flag][token 00..0f]
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&stock).expect("decodes"),
(token, stock_relays())
);
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
let encoded = encode_fragment(&token, &mixed).expect("encodes");
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
let verbatim = vec!["ws://h".to_owned()];
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&encoded).expect("decodes"),
(token, verbatim)
);
}
#[test]
fn a_fragment_is_strict_about_framing_and_counts() {
let token = token16();
for version in [3u8, 5] {
let mut bytes = vec![version, FLAG_STOCK_SET];
bytes.extend_from_slice(&token);
let encoded = BASE64URL_NOPAD.encode(&bytes);
assert!(
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
"a legacy and a future version are both refused"
);
}
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
trailing.extend_from_slice(&token);
trailing.push(0xff);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
Err(InviteError::BadFragment(_))
));
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
over.extend_from_slice(&token);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
Err(InviteError::BadFragment(_))
));
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
unknown.extend_from_slice(&token);
let (decoded, relays) =
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
assert_eq!(decoded, token);
assert!(relays.is_empty());
}
#[test]
fn a_link_round_trips_and_refuses_a_non_invite() {
let link_signer = Keys::generate();
let token = token16();
let relays = vec!["wss://a.example".to_owned()];
let url = build_invite_url(
"https://vectorapp.io/",
&link_signer.public_key(),
&token,
&relays,
)
.expect("builds");
let parsed = parse_link(&url).expect("parses");
assert_eq!(parsed.link_signer, link_signer.public_key());
assert_eq!(parsed.token, token);
assert_eq!(parsed.bootstrap_relays, relays);
let fragment = url.split('#').nth(1).expect("carries a fragment");
let bare = format!("{}#{fragment}", parsed.naddr);
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
assert_eq!(reparsed.link_signer, link_signer.public_key());
assert_eq!(reparsed.token, token);
assert!(
parse_link("https://x/invite/#frag").is_err(),
"the naddr is not optional"
);
}
#[test]
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
assert_eq!(event.pubkey, link_signer.public_key());
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
BundleState::Live(opened) => {
assert_eq!(opened.community_id, invite.community_id);
assert_eq!(opened.channels.len(), 1);
}
BundleState::Revoked => panic!("expected a live bundle"),
}
let revocation = build_revocation(&link_signer).expect("builds");
assert!(matches!(
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
Ok(BundleState::Revoked)
));
// The token is the only way in, and a squatter is a different coordinate.
assert!(
parse_bundle_event(
&event,
&link_signer.public_key(),
&invite_bundle_key(&[8u8; TOKEN_LEN])
)
.is_err()
);
let squatter = Keys::generate();
assert!(matches!(
parse_bundle_event(&event, &squatter.public_key(), &key),
Err(InviteError::BadEvent(_))
));
}
#[test]
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
let json = serde_json::to_string(&invite).expect("serializes");
let content = seal_bundle(&key, &json).expect("seals");
// The fetch filters on the author, so the empty `d` is pinned here: a
// signature-valid event of the same author at another `d` is not the bundle.
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
.finalize(&link_signer)
.expect("signs");
assert!(matches!(
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
Err(InviteError::BadEvent(_))
));
let mut forged = bundle();
forged.owner = Keys::generate().public_key();
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
assert!(matches!(
build_bundle_event(&link_signer, &forged, &key),
Err(InviteError::OwnerMismatch)
));
let mut malformed = bundle();
malformed.community_root = "not hex".to_owned();
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
let mut crowded = bundle();
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
.map(|_| ChannelGrant {
id: ChannelId::from_bytes([0x01; 32]),
key: None,
epoch: Epoch(0),
name: String::new(),
extra: Extra::default(),
})
.collect();
assert!(matches!(
crowded.validate(),
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
));
}
#[test]
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let invite = bundle();
let wrap = build_direct_invite(&inviter, &recipient.public_key(), &invite).expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(
wrap.pubkey,
inviter.public_key(),
"the wrap author is ephemeral"
);
assert!(
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
"the k tag is what makes an invite indexable"
);
let (sender, opened) = unwrap_direct_invite(&wrap, &recipient).expect("unwraps");
assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open...
let stranger = Keys::generate();
assert!(unwrap_direct_invite(&wrap, &stranger).is_err());
// ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::chat::KIND_MESSAGE), "hello")
.finalize_unsigned(recipient.public_key());
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
.finalize(&recipient)
.expect("wraps");
assert!(matches!(
unwrap_direct_invite(&wrap, &recipient),
Err(InviteError::BadEvent(_))
));
}
}
+154
View File
@@ -0,0 +1,154 @@
pub mod chat;
pub mod control;
pub mod derive;
pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod rekey;
pub mod roles;
pub mod store;
pub mod stream;
use std::fmt;
use std::str::FromStr;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
pub use derive::GroupKey;
use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
HEXLOWER.encode(&self.0)
}
}
impl From<[u8; 32]> for $name {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl FromStr for $name {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
Ok(Self(decode_hex_32(value)?))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
};
}
hex_id! {
/// A self-certifying commitment to the owner's key, carried inside invites and
/// never on the wire.
CommunityId
}
hex_id! {
ChannelId
}
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
pub struct Epoch(pub u64);
impl From<u64> for Epoch {
fn from(value: u64) -> Self {
Self(value)
}
}
impl From<Epoch> for u64 {
fn from(value: Epoch) -> Self {
value.0
}
}
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
/// Uppercase and other non-canonical spellings are rejected.
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; 32] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
}
Ok(decoded)
}
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
SysRng
.try_fill_bytes(bytes)
.map_err(|error| anyhow!("os rng: {error}"))
}
pub(crate) fn random_32() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
fill_random(&mut bytes)?;
Ok(bytes)
}
+478
View File
@@ -0,0 +1,478 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use nostr::nips::nip44::{self, Version};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::invite::{ChannelGrant, CommunityInvite};
use crate::stream::NIP44_MAX_PLAINTEXT;
use crate::{CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302;
pub const MAX_MEMBERSHIPS: usize = 50;
#[derive(Debug)]
pub enum ListError {
Kind(u16),
Crypto(String),
Json(String),
TooManyMemberships(usize),
Oversize(usize),
}
impl fmt::Display for ListError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"),
ListError::Crypto(error) => write!(f, "crypto: {error}"),
ListError::Json(error) => write!(f, "json: {error}"),
ListError::TooManyMemberships(count) => {
write!(
f,
"list carries {count} memberships (cap {MAX_MEMBERSHIPS})"
)
}
ListError::Oversize(len) => {
write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
}
}
}
impl std::error::Error for ListError {}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// Present only when the holder is staff.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
pub name: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityListEntry {
pub community_id: CommunityId,
pub seed: JoinMaterial,
pub current: JoinMaterial,
pub added_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Tombstone {
pub community_id: CommunityId,
pub removed_at: u64,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct CommunityList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<CommunityListEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<Tombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityList {
pub fn is_live(&self, community_id: &CommunityId) -> bool {
let added = self
.entries
.iter()
.find(|entry| entry.community_id == *community_id)
.map(|entry| entry.added_at);
match added {
None => false,
Some(added) => self
.tombstones
.iter()
.find(|tombstone| tombstone.community_id == *community_id)
.is_none_or(|tombstone| added > tombstone.removed_at),
}
}
pub fn fits(&self) -> Result<(), ListError> {
if self.entries.len() > MAX_MEMBERSHIPS {
return Err(ListError::TooManyMemberships(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(ListError::Oversize(json.len()));
}
Ok(())
}
}
pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial {
JoinMaterial {
community_id: invite.community_id,
owner: invite.owner,
owner_salt: invite.owner_salt.clone(),
community_root: invite.community_root.clone(),
root_epoch: invite.root_epoch,
control_pk: invite.control_pk,
control_root: control_root.map(|key| data_encoding::HEXLOWER.encode(key)),
channels: invite.channels.clone(),
relays: invite.relays.clone(),
name: invite.name.clone(),
extra: Extra::default(),
}
}
pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList {
let mut entries: BTreeMap<CommunityId, CommunityListEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.community_id) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry),
}
}
let mut tombstones: BTreeMap<CommunityId, Tombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.community_id) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
let held = slot.get_mut();
held.removed_at = held.removed_at.max(tombstone.removed_at);
union(&mut held.extra, tombstone.extra);
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
CommunityList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, ListError> {
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
.map_err(crypto_error)
}
pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, ListError> {
if event.kind.as_u16() != KIND_COMMUNITY_LIST {
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
serde_json::from_str(&json).map_err(json_error)
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum Snapshot {
Seed,
Current,
}
fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) {
held.added_at = held.added_at.max(incoming.added_at);
held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone();
held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone();
union(&mut held.extra, incoming.extra);
}
fn pick<'a>(
held: &'a JoinMaterial,
incoming: &'a JoinMaterial,
which: Snapshot,
) -> &'a JoinMaterial {
let preferred = match which {
Snapshot::Seed => incoming.root_epoch < held.root_epoch,
Snapshot::Current => incoming.root_epoch > held.root_epoch,
};
if preferred {
return incoming;
}
if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) {
return incoming;
}
held
}
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
fn json_error(error: serde_json::Error) -> ListError {
ListError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> ListError {
ListError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn id(byte: u8) -> CommunityId {
CommunityId::from_bytes([byte; 32])
}
fn material(
community_id: CommunityId,
owner: PublicKey,
name: &str,
epoch: u64,
) -> JoinMaterial {
JoinMaterial {
community_id,
owner,
owner_salt: "33".repeat(32),
community_root: "44".repeat(32),
root_epoch: Epoch(epoch),
control_pk: None,
control_root: None,
channels: vec![],
relays: vec!["wss://relay.example".to_owned()],
name: name.to_owned(),
extra: Extra::default(),
}
}
fn entry(
community_id: CommunityId,
seed: JoinMaterial,
current: JoinMaterial,
added_at: u64,
) -> CommunityListEntry {
CommunityListEntry {
community_id,
seed,
current,
added_at,
extra: Extra::default(),
}
}
fn list(entries: Vec<CommunityListEntry>) -> CommunityList {
CommunityList {
entries,
..Default::default()
}
}
fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList {
CommunityList {
tombstones: vec![Tombstone {
community_id,
removed_at,
extra: Extra::default(),
}],
..Default::default()
}
}
#[test]
fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() {
let owner = Keys::generate().public_key();
let older = material(id(0x11), owner, "Room", 1);
let newer = material(id(0x11), owner, "Room", 3);
let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]);
let b = list(vec![entry(id(0x11), newer, older, 5_000)]);
for merged in [merge(a.clone(), b.clone()), merge(b, a)] {
let merged = merged.entries.first().expect("one membership");
assert_eq!(
merged.seed.root_epoch,
Epoch(1),
"seed anchors the earliest epoch held"
);
assert_eq!(merged.current.root_epoch, Epoch(3));
assert_eq!(merged.added_at, 5_000);
}
// An epoch tie breaks on the whole snapshot's bytes, and does so for both
// orders, so two devices never flap competing republishes.
let alpha = material(id(0x11), owner, "Alpha", 2);
let beta = material(id(0x11), owner, "Beta", 2);
let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]);
let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]);
let first = merge(a.clone(), b.clone());
assert_eq!(first, merge(b, a));
assert_eq!(first.entries[0].current.name, "Alpha");
}
#[test]
fn a_tombstone_is_terminal_until_a_newer_join_outruns_it() {
let owner = Keys::generate().public_key();
let joined = entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
5_000,
);
let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000));
assert!(!left.is_live(&id(0x11)));
assert_eq!(
left.entries.len(),
1,
"a retired entry stays in the document"
);
// A stale device re-merging the entry cannot resurrect it.
assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11)));
// A re-join genuinely newer than the removal does.
let rejoined = list(vec![entry(
id(0x11),
material(id(0x11), owner, "Room", 0),
material(id(0x11), owner, "Room", 0),
7_000,
)]);
let live = merge(left, rejoined);
assert!(live.is_live(&id(0x11)));
// And the older removal is not re-applied on top of it.
assert!(merge(live, removal(id(0x11), 6_000)).is_live(&id(0x11)));
}
#[test]
fn a_second_device_reconstructs_membership_from_13302() {
let me = Keys::generate();
let owner = Keys::generate().public_key();
let mine = CommunityList {
entries: vec![
entry(
id(0x11),
material(id(0x11), owner, "Room", 1),
material(id(0x11), owner, "Room", 4),
AT,
),
entry(
id(0x22),
material(id(0x22), owner, "Other", 0),
material(id(0x22), owner, "Other", 0),
AT + 1,
),
],
tombstones: vec![Tombstone {
community_id: id(0x33),
removed_at: AT,
extra: Extra::default(),
}],
extra: Extra::default(),
};
let event = build_list_event(&me, &mine).expect("builds");
assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST));
assert_eq!(parse_list_event(&me, &event).expect("parses"), mine);
assert!(
!parse_list_event(&me, &event)
.expect("parses")
.is_live(&id(0x33))
);
// Only the member's own keys open it, and an unreadable list is "no news".
let stranger = Keys::generate();
assert!(parse_list_event(&stranger, &event).is_err());
// Unknown fields survive the round trip, so a republish cannot wipe them.
let mut held = mine.clone();
held.extra
.insert("future".to_owned(), serde_json::json!({"deep": [1, 2]}));
held.entries[0]
.current
.extra
.insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}]));
let rebuilt =
parse_list_event(&me, &build_list_event(&me, &held).expect("builds")).expect("parses");
assert_eq!(rebuilt, held);
// The write gate refuses an over-cap or oversized List before publishing.
let crowded = list(
(0..=MAX_MEMBERSHIPS)
.map(|index| {
let community_id = CommunityId::from_bytes([index as u8; 32]);
entry(
community_id,
material(community_id, owner, "Room", 0),
material(community_id, owner, "Room", 0),
AT,
)
})
.collect(),
);
assert!(matches!(
build_list_event(&me, &crowded),
Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1
));
let oversized = list(vec![entry(
id(0x11),
material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0),
material(id(0x11), owner, "Room", 0),
AT,
)]);
assert!(matches!(oversized.fits(), Err(ListError::Oversize(_))));
}
const AT: u64 = 1_719_800_000_000;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+508
View File
@@ -0,0 +1,508 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::LazyLock;
use anyhow::{Result, anyhow};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::chat::{self, ChatRumor, plane_keys};
use crate::control::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::derive::control_signer_group_key;
use crate::edition::{EntityHead, Floors, ParsedEdition, vsk};
use crate::stream::{KIND_WRAP_EPHEMERAL, OpenedStream};
use crate::{ChannelId, CommunityId, Epoch, GroupKey};
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
const MAX_PAGES: usize = 8;
const CHANNEL_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_C;
const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
const MARK_VALUE: &str = "concord";
const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<()> {
let tags = vec![
Tag::identifier(opened.rumor_id),
Tag::custom(KIND_TAG, [opened.rumor.kind.to_string()]),
Tag::custom(WRAP_TAG, [opened.wrapper_id.to_string()]),
Tag::custom(MARK_TAG.as_str(), [MARK_VALUE]),
Tag::custom(CHANNEL_TAG.as_str(), [channel.to_hex()]),
Tag::public_key(opened.author),
];
let at = Timestamp::from_secs(opened.at_ms / 1000);
let event = EventBuilder::new(Kind::ApplicationSpecificData, opened.rumor.as_json())
.tags(tags)
.custom_created_at(at)
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn query_rumors(
database: &dyn NostrDatabase,
channel: &ChannelId,
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<UnsignedEvent>> {
let mut filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
if let Some(until) = until {
filter = filter.until(until);
}
let mut newest: BTreeMap<String, Event> = BTreeMap::new();
for event in database.query(filter).await? {
let Some(rumor_id) = event.tags.identifier() else {
continue;
};
match newest.get(&rumor_id) {
Some(existing) if existing.created_at >= event.created_at => {}
_ => {
newest.insert(rumor_id, event);
}
}
}
let mut events: Vec<Event> = newest.into_values().collect();
events.sort_by_key(|event| std::cmp::Reverse(event.created_at));
events.truncate(limit);
let mut rumors = Vec::with_capacity(events.len());
for event in events {
let rumor = UnsignedEvent::from_json(event.content)
.map_err(|error| anyhow!("cached rumor is not a valid event: {error}"))?;
rumors.push(rumor);
}
Ok(rumors)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
#[serde(default)]
pub dissolved: bool,
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
banned: BTreeSet::new(),
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
}),
None => {}
}
}
}
}
fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
where
D: NostrDatabase,
{
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
.tags([Tag::identifier(state.identifier())])
.finalize_async(&*LOCAL_KEYS)
.await?;
database.save_event(&event).await?;
Ok(())
}
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
where
D: NostrDatabase,
{
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.identifier(state_identifier(id))
.limit(1);
match database.query(filter).await?.into_iter().next() {
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
None => Ok(None),
}
}
pub async fn backfill(
client: &Client,
database: &dyn NostrDatabase,
channel: &ChannelId,
held: &[(Epoch, [u8; 32])],
until: Option<Timestamp>,
limit: usize,
) -> Result<Vec<ChatRumor>> {
let planes = plane_keys(held, channel)?;
let authors: Vec<PublicKey> = planes.iter().map(|(_, group)| group.pk()).collect();
let mut cursor = until;
let mut seen: BTreeSet<EventId> = BTreeSet::new();
let mut found: Vec<ChatRumor> = Vec::new();
for _ in 0..MAX_PAGES {
let page = fetch_page(client, &authors, cursor, limit).await?;
if page.is_empty() {
break;
}
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
cache_rumor(database, channel, &opened).await?;
found.push(rumor);
}
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
found.truncate(limit);
Ok(found)
}
fn advance(
page: &BTreeSet<Event>,
planes: &[(Epoch, GroupKey)],
channel: &ChannelId,
cursor: Option<Timestamp>,
limit: usize,
seen: &mut BTreeSet<EventId>,
) -> (Vec<(OpenedStream, ChatRumor)>, Option<Timestamp>) {
let mut fresh = Vec::new();
for wrap in page {
let Some((epoch, group)) = planes.iter().find(|(_, group)| group.pk() == wrap.pubkey)
else {
continue;
};
let Ok((opened, rumor)) = chat::open(wrap, group, channel, *epoch) else {
continue;
};
if seen.insert(rumor.id) {
fresh.push((opened, rumor));
}
}
if fresh.is_empty() || page.len() < limit {
return (fresh, None);
}
let oldest = page.iter().map(|event| event.created_at).min();
match oldest {
Some(oldest) if cursor != Some(oldest) => (fresh, Some(oldest)),
_ => (fresh, None),
}
}
async fn fetch_page(
client: &Client,
authors: &[PublicKey],
until: Option<Timestamp>,
limit: usize,
) -> Result<BTreeSet<Event>> {
let mut filter = Filter::new()
.kinds([Kind::GiftWrap, Kind::Custom(KIND_WRAP_EPHEMERAL)])
.authors(authors.iter().copied())
.limit(limit);
if let Some(until) = until {
filter = filter.until(until);
}
Ok(client.fetch_events(filter).await?)
}
#[cfg(test)]
mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::Epoch;
use crate::chat::{build_message, seal_rumor};
use crate::derive::channel_group_key;
use crate::stream::{
KIND_WRAP, SealForm, build_rumor_ms, build_seal, channel_binding_tags, open_wrap, wrap_seal,
};
const SECRET: [u8; 32] = [0x07u8; 32];
const NEXT_SECRET: [u8; 32] = [0x11u8; 32];
/// What a relay does with an inclusive `until` and a `limit`.
fn serve_page(
relay: &BTreeSet<Event>,
cursor: Option<Timestamp>,
limit: usize,
) -> BTreeSet<Event> {
let mut events: Vec<Event> = relay
.iter()
.filter(|event| cursor.is_none_or(|cursor| event.created_at <= cursor))
.cloned()
.collect();
events.sort_by_key(|event| Reverse(event.created_at));
events.truncate(limit);
events.into_iter().collect()
}
#[test]
fn history_pages_back_across_a_rekey() {
let channel = ChannelId::from_bytes([0x9cu8; 32]);
let author = Keys::generate();
let held = [(Epoch(0), SECRET), (Epoch(1), NEXT_SECRET)];
let planes = plane_keys(&held, &channel).expect("derives");
// Three messages a second apart: a page boundary falls between each.
let base = 1_700_000_000_000;
let mut relay: BTreeSet<Event> = BTreeSet::new();
for (content, secret, epoch, at_ms) in [
("before the rekey", &SECRET, Epoch(0), base),
("still before", &SECRET, Epoch(0), base + 1_000),
("after the rekey", &NEXT_SECRET, Epoch(1), base + 2_000),
] {
let group = channel_group_key(secret, &channel, epoch).expect("derives");
let rumor = build_message(author.public_key(), &channel, epoch, content, None, at_ms);
relay.insert(seal_rumor(&rumor, &group, &author, false).expect("seals").0);
}
let mut seen = BTreeSet::new();
let mut found = Vec::new();
let mut cursor = None;
for _ in 0..3 {
let page = serve_page(&relay, cursor, 2);
let (fresh, next) = advance(&page, &planes, &channel, cursor, 2, &mut seen);
found.extend(fresh.into_iter().map(|(_, rumor)| rumor));
match next {
Some(next) => cursor = Some(next),
None => break,
}
}
found.sort_by_key(|rumor| (Reverse(rumor.at_ms), rumor.id));
let contents: Vec<&str> = found.iter().map(|rumor| rumor.content.as_str()).collect();
assert_eq!(
contents,
["after the rekey", "still before", "before the rekey"]
);
}
#[test]
fn rumors_read_back_after_a_restart() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0xabu8; 32]);
let author = Keys::generate();
smol::block_on(async {
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
for (content, at_ms) in [("first", 1_000_000u64), ("second", 2_000_000)] {
let rumor = build_rumor_ms(
9,
author.public_key(),
content,
channel_binding_tags(&channel, Epoch(0)),
at_ms,
);
let seal = build_seal(&rumor, SealForm::Encrypted, &group, &author).expect("seals");
let (wrap, _) = wrap_seal(
&seal,
&group,
KIND_WRAP,
Timestamp::from_secs(at_ms / 1000),
&[],
)
.expect("wraps");
let opened = open_wrap(&wrap, &group).expect("opens");
cache_rumor(&database, &channel, &opened)
.await
.expect("caches");
}
// The group key is gone; only the local cache stands in for it.
let rumors = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(rumors.len(), 2, "both messages come back");
assert_eq!(rumors[0].content, "second", "newest first");
assert_eq!(rumors[1].content, "first");
// A page boundary in message time, not in cache time.
let until = Timestamp::from_secs(1_500);
let page = query_rumors(&database, &channel, Some(until), 10)
.await
.expect("queries");
assert_eq!(page.len(), 1);
assert_eq!(page[0].content, "first");
let capped = query_rumors(&database, &channel, None, 1)
.await
.expect("queries");
assert_eq!(capped.len(), 1);
assert_eq!(capped[0].content, "second");
});
}
}
+683
View File
@@ -0,0 +1,683 @@
use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
UnsignedEvent,
};
use crate::derive::GroupKey;
use crate::{ChannelId, Epoch};
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,
BadWrapSignature,
BadSealKind(u16),
BadSealSignature,
AuthorMismatch,
BadRumorId,
BadMs,
ChannelMismatch,
EpochMismatch,
MissingTag(&'static str),
DuplicateTag(&'static str),
NotRewrappable,
}
impl fmt::Display for StreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StreamError::Sign(error) => write!(f, "sign: {error}"),
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
StreamError::Parse(error) => write!(f, "parse: {error}"),
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
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 is not a canonical decimal in 0..=999"),
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
}
}
}
impl std::error::Error for StreamError {}
#[derive(Debug, Clone)]
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
pub rumor: UnsignedEvent,
}
pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
/// Build a rumor carrying a full epoch-ms time: `created_at`
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
pub fn build_rumor_ms(
kind: u16,
author: PublicKey,
content: &str,
mut tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let (seconds, offset) = split_ms(at_ms);
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
build_rumor_secs(kind, author, content, tags, seconds)
}
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
pub fn build_rumor_secs(
kind: u16,
author: PublicKey,
content: &str,
tags: Vec<Tag>,
at_secs: u64,
) -> UnsignedEvent {
let mut rumor = UnsignedEvent::new(
author,
Timestamp::from_secs(at_secs),
Kind::Custom(kind),
tags,
content,
);
rumor.ensure_id();
rumor
}
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
let mut tag: Option<Option<String>> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) == Some(TAG_MS) {
tag = Some(fields.get(1).cloned());
break;
}
}
let Some(raw) = tag else {
return Ok(seconds);
};
let raw = raw.ok_or(StreamError::BadMs)?;
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(StreamError::BadMs);
}
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
return Err(StreamError::BadMs);
}
Ok(seconds.saturating_add(offset))
}
pub fn seal_content(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
) -> Result<String, StreamError> {
let json = rumor.as_json();
check_plaintext_cap(json.len())?;
match form {
SealForm::Plaintext => Ok(json),
SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()),
}
}
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
}
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
let payload = BASE64
.decode(content.as_bytes())
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
decrypt_to_bytes(conversation, &payload)
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub fn build_seal(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &Keys,
) -> Result<Event, StreamError> {
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize(author)
.map_err(|error| StreamError::Sign(error.to_string()))
}
pub fn wrap_seal(
seal: &Event,
group: &GroupKey,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
wrap_seal_with(
seal,
group.conversation(),
group.keys(),
wrap_kind,
at,
extra,
)
}
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,
signer: &Keys,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
let json = seal.as_json();
check_plaintext_cap(json.len())?;
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
let ephemeral = Keys::generate();
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
tags.extend_from_slice(extra);
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
.tags(tags)
.custom_created_at(at)
.finalize(signer)
.map_err(|error| StreamError::Sign(error.to_string()))?;
Ok((wrap, ephemeral))
}
pub fn rewrap_seal(
seal: &Event,
read: &GroupKey,
signer: &GroupKey,
at: Timestamp,
) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable);
}
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
}
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
}
pub fn open_wrap_at(
wrap: &Event,
address: &PublicKey,
conversation: &ConversationKey,
verify_wrap_signature: bool,
) -> 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 != *address {
return Err(StreamError::WrongStream);
}
if verify_wrap_signature && wrap.verify().is_err() {
return Err(StreamError::BadWrapSignature);
}
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
.map_err(|error| StreamError::Parse(error.to_string()))?;
let seal_kind = seal.kind.as_u16();
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
let rumor_json = match seal_form {
SealForm::Plaintext => seal.content.clone(),
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
};
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
.map_err(|error| StreamError::Parse(error.to_string()))?;
if rumor.pubkey != seal.pubkey {
return Err(StreamError::AuthorMismatch);
}
let computed = rumor.compute_id();
if let Some(claimed) = rumor.id
&& 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 channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
vec![
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
]
}
pub fn check_channel_binding(
rumor: &UnsignedEvent,
channel: &ChannelId,
epoch: Epoch,
) -> Result<(), StreamError> {
match unique_tag(rumor, TAG_CHANNEL)? {
Some(value) if value == channel.to_hex() => {}
Some(_) => return Err(StreamError::ChannelMismatch),
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
}
match unique_tag(rumor, TAG_EPOCH)? {
Some(value) if value == epoch.0.to_string() => {}
Some(_) => return Err(StreamError::EpochMismatch),
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
}
Ok(())
}
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
let mut nonce = [0u8; 32];
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
let plaintext = open_bytes(conversation, content)?;
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
}
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
if len > NIP44_MAX_PLAINTEXT {
return Err(StreamError::Oversize(len));
}
Ok(())
}
fn unique_tag(rumor: &UnsignedEvent, name: &'static str) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for tag in rumor.tags.iter() {
let fields = tag.as_slice();
if fields.len() >= 2 && fields[0] == name {
if found.is_some() {
return Err(StreamError::DuplicateTag(name));
}
found = Some(fields[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group(epoch: u64) -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
}
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
wrap.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.and_then(|tag| tag.as_slice().get(1).cloned())
}
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(
9,
author,
content,
channel_binding_tags(&channel(), Epoch(0)),
at_ms,
)
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
build_seal(rumor, form, &group(0), author).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
.expect("wraps")
.0
}
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
let rumor = bound_rumor(content, author.public_key(), at_ms);
wrapped(
&sealed(&rumor, SealForm::Encrypted, author),
kind,
at_ms / 1000,
)
}
#[test]
fn both_seal_forms_round_trip() {
let author = Keys::generate();
let at_ms = 1_686_840_217_417;
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
let opened = open_wrap(&wrap, &group(0)).expect("opens");
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "Hey chat!");
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
assert_eq!(opened.wrapper_id, wrap.id);
assert_eq!(opened.at_ms, at_ms);
assert_eq!(opened.seal_form, SealForm::Encrypted);
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
// The wrap's `p` tag must identify neither the stream nor the author.
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
assert_ne!(p, group(0).pk_hex());
assert_ne!(p, author.public_key().to_hex());
// Ephemeral actions ride the same structure at a kind relays must drop.
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
assert_eq!(typing.kind.as_u16(), 21059);
assert_eq!(
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
"typing"
);
// The plaintext form carries the rumor's bytes verbatim, which is what
// lets a compaction re-wrap the signed edition into a later epoch.
let edition = build_rumor_secs(
3308,
author.public_key(),
"an edition",
vec![],
1_700_000_000,
);
let seal = sealed(&edition, SealForm::Plaintext, &author);
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
.expect("rewraps");
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
assert_eq!(reopened.author, author.public_key());
assert_eq!(
reopened.seal.sig, opened.seal.sig,
"the signature rides whole"
);
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
assert!(matches!(
rewrap_seal(
&sealed(&edition, SealForm::Encrypted, &author),
&group(1),
&group(1),
Timestamp::from_secs(2)
),
Err(StreamError::NotRewrappable)
));
}
#[test]
fn hostile_wraps_are_dropped_in_order() {
let author = Keys::generate();
let impostor = Keys::generate();
// Kind and address are settled before any decryption is attempted.
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
wrong_kind.kind = Kind::Custom(1058);
assert!(matches!(
open_wrap(&wrong_kind, &group(0)),
Err(StreamError::BadWrapKind(1058))
));
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
assert!(matches!(
open_wrap(&wrap, &foreign),
Err(StreamError::WrongStream)
));
// A flipped ciphertext byte fails the NIP-44 MAC.
let mut payload = BASE64
.decode(wrap.content.as_bytes())
.expect("content is base64");
payload[40] ^= 0x01;
let mut tampered = wrap.clone();
tampered.content = BASE64.encode(&payload);
assert!(matches!(
open_wrap(&tampered, &group(0)),
Err(StreamError::Decrypt(_))
));
// A seal claiming an author it holds no signature for.
let seal = sealed(
&bound_rumor("spoof", author.public_key(), 1_000),
SealForm::Encrypted,
&impostor,
);
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadSealSignature)
));
// A seal that does not vouch for the rumor's author.
let seal = sealed(
&bound_rumor("spoof", impostor.public_key(), 1_000),
SealForm::Encrypted,
&author,
);
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::AuthorMismatch)
));
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
// smuggles the forgery through verbatim.
let rumor = bound_rumor("real", author.public_key(), 1_000);
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
forged["id"] = serde_json::Value::String("00".repeat(32));
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
.custom_created_at(rumor.created_at)
.finalize(&author)
.expect("seals");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadRumorId)
));
// Binding splices: another channel, another epoch, a duplicate or none.
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
let rumor = bound_rumor("x", author.public_key(), 1_000);
assert!(matches!(
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
Err(StreamError::ChannelMismatch)
));
assert!(matches!(
check_channel_binding(&rumor, &channel(), Epoch(1)),
Err(StreamError::EpochMismatch)
));
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
assert!(matches!(
check_channel_binding(&duplicate, &channel(), Epoch(0)),
Err(StreamError::DuplicateTag(_))
));
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
assert!(matches!(
check_channel_binding(&unbound, &channel(), Epoch(0)),
Err(StreamError::MissingTag(_))
));
let oversize = build_rumor_ms(
9,
author.public_key(),
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
vec![],
1_000,
);
assert!(matches!(
seal_content(&oversize, SealForm::Encrypted, &group(0)),
Err(StreamError::Oversize(_))
));
}
#[test]
fn ms_is_a_drop_gate() {
let author = Keys::generate();
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
for malformed in ["1000", "007", "abc", "+5", ""] {
let rumor = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
1_000,
);
assert!(
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
"{malformed:?} must be malformed"
);
}
// Present but valueless is malformed, not an offset-0 default.
let valueless = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
1_000,
);
assert!(matches!(
resolve_ms_strict(&valueless),
Err(StreamError::BadMs)
));
// A valued duplicate takes the first, matching Armada.
let repeated = build_rumor_secs(
9,
author.public_key(),
"x",
vec![
Tag::custom(TAG_MS, ["1".to_string()]),
Tag::custom(TAG_MS, ["2".to_string()]),
],
1_000,
);
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
}
}
+2 -2
View File
@@ -64,7 +64,7 @@ impl Default for ThemeFamily {
id: "coop".into(),
name: "Coop Default Theme".into(),
author: "Coop".into(),
url: "https://github.com/lumehq/coop".into(),
url: "https://github.com/reyakov/coop".into(),
light: ThemeColors::light(),
dark: ThemeColors::dark(),
}
@@ -186,7 +186,7 @@ mod tests {
"id": "test-theme",
"name": "Test Theme",
"author": "Coop",
"url": "https://github.com/lumehq/coop",
"url": "https://github.com/reyakov/coop",
"light": {
"background": "#ffffff",
"surface_background": "#fafafa",
@@ -35,13 +35,13 @@
<content_attribute id="social-audio">intense</content_attribute>
</content_rating>
<url type="homepage">https://reya.su/coop</url>
<url type="bugtracker">https://github.com/lumehq/coop/issues</url>
<url type="faq">https://github.com/lumehq/coop</url>
<url type="help">https://github.com/lumehq/coop/issues</url>
<url type="contact">https://reya.su/</url>
<url type="vcs-browser">https://github.com/lumehq/coop</url>
<url type="contribute">https://github.com/lumehq/coop/blob/main/CONTRIBUTING.md</url>
<url type="homepage">https://coopchat.xyz</url>
<url type="bugtracker">https://github.com/reyakov/coop/issues</url>
<url type="faq">https://github.com/reyakov/coop</url>
<url type="help">https://github.com/reyakov/coop/issues</url>
<url type="contact">reyakov@proton.me</url>
<url type="vcs-browser">https://github.com/reykov/coop</url>
<url type="contribute">https://github.com/reyakov/coop/blob/main/CONTRIBUTING.md</url>
<supports>
<internet>yes</internet>
+4 -4
View File
@@ -14,10 +14,10 @@ description: |
grade: stable
confinement: classic
compression: lzo
website: https://reya.su/coop
source-code: https://github.com/lumehq/coop
issues: https://github.com/lumehq/coop/issues
contact: https://reya.su
website: https://reya.info/coop
source-code: https://git.reya.info/reya/coop
issues: https://github.com/reyakov/coop/issues
contact: https://coopchat.xyz
parts:
coop:
+2 -2
View File
@@ -14,7 +14,7 @@ cd "$(dirname "$0")/.."
# Configuration
APP_ID="su.reya.coop"
APP_NAME="Coop"
REPO_URL="https://git.reya.su/reya/coop"
REPO_URL="https://git.reya.info/reya/coop"
BRANDING_LIGHT="#FFE629"
BRANDING_DARK="#FFE629"
@@ -173,7 +173,7 @@ modules:
sources:
# Main source code - specific commit
- type: git
url: https://git.reya.su/reya/coop.git
url: https://git.reya.info/reya/coop.git
commit: "@COMMIT@"
tag: "v@VERSION@"