Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ecd08273eb | ||
|
|
79a4dd387d | ||
|
|
ea9abae554 | ||
|
|
d1b83fdc33 | ||
|
|
4329385abe | ||
|
|
d926c1e3ea | ||
|
|
66f75ad105 | ||
|
|
5f2a5d7a37 | ||
|
|
319b1038d2 | ||
|
|
5500082426 |
Generated
+17
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(¤t) 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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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@"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user