feat: implement concord protocol (#49)

Only backend, no UI yet.

Reviewed-on: #49
This commit was merged in pull request #49.
This commit is contained in:
2026-09-17 04:30:12 +00:00
parent 5500082426
commit fdabfcf026
17 changed files with 11285 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
[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
chacha20.workspace = true
hmac.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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+407
View File
@@ -0,0 +1,407 @@
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 secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
let keys = Keys::new(secret_key);
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
Ok(Self { keys, conversation })
}
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()
}
}
/// `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 read 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`, so any retained member recovers any epoch's rekey.
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
);
}
}
+552
View File
@@ -0,0 +1,552 @@
use std::cmp::Reverse;
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`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
pub version: u64,
pub hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct ParsedEdition {
pub author: PublicKey,
pub subkind: String,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: String,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
pub struct EditionFields<'a> {
pub author: PublicKey,
pub subkind: &'a str,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: &'a str,
pub at_secs: u64,
}
fn signing_bytes(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> Vec<u8> {
let mut bytes =
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
bytes.extend_from_slice(EDITION_LABEL);
bytes.extend_from_slice(entity);
bytes.extend_from_slice(&version.to_be_bytes());
match prev {
Some(prev) => {
bytes.push(1);
bytes.extend_from_slice(prev);
}
None => {
bytes.push(0);
bytes.extend_from_slice(&[0u8; 32]);
}
}
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
bytes.extend_from_slice(content);
bytes
}
pub fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
)
}
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
if fields.len() != 4 {
return None;
}
Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION).ok()?,
version: canonical_decimal(&fields[2])?,
hash: hex32(&fields[3], TAG_CITATION).ok()?,
})
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
];
if let Some(prev) = fields.prev {
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
}
if let Some(citation) = fields.citation {
tags.push(citation_tag(&citation));
}
build_rumor_secs(
KIND_CONTROL,
fields.author,
fields.content,
tags,
fields.at_secs,
)
}
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
let kind = rumor.kind.as_u16();
if kind != KIND_CONTROL {
return Err(EditionError::BadKind(kind));
}
let subkind = value(rumor, TAG_SUBKIND)?
.ok_or(EditionError::Missing(TAG_SUBKIND))?
.to_owned();
if canonical_decimal(&subkind).is_none() {
return Err(EditionError::BadField(TAG_SUBKIND));
}
let entity = hex32(
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
TAG_ENTITY,
)?;
let version =
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
.ok_or(EditionError::BadField(TAG_VERSION))?;
let prev = match value(rumor, TAG_PREV)? {
Some(raw) => Some(hex32(raw, TAG_PREV)?),
None => None,
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
None => None,
};
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
Ok(ParsedEdition {
author: rumor.pubkey,
subkind,
entity,
version,
prev,
citation,
content: rumor.content.clone(),
self_hash,
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionMeta {
pub version: u64,
pub self_hash: [u8; 32],
pub prev: Option<[u8; 32]>,
pub tiebreak_id: EventId,
}
impl From<&ParsedEdition> for EditionMeta {
fn from(edition: &ParsedEdition) -> Self {
Self {
version: edition.version,
self_hash: edition.self_hash,
prev: edition.prev,
tiebreak_id: edition.rumor_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
.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, so pin the flag.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}
+890
View File
@@ -0,0 +1,890 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::edition::{
AuthorityCitation, TAG_CITATION, canonical_decimal, citation_from, citation_tag,
};
use crate::stream::{
KIND_WRAP, OpenedStream, SealForm, StreamError, build_rumor_ms, build_seal, open_wrap,
wrap_seal,
};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug)]
pub enum GuestbookError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
}
impl fmt::Display for GuestbookError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
GuestbookError::Stream(error) => write!(f, "stream: {error}"),
GuestbookError::NotEncryptedSealed => {
write!(f, "guestbook rumor must ride an encrypted seal")
}
GuestbookError::UnknownKind(kind) => {
write!(f, "not a guestbook rumor kind: {kind}")
}
GuestbookError::MissingTag(name) => write!(f, "missing guestbook tag: {name}"),
GuestbookError::DuplicateTag(name) => write!(f, "duplicate guestbook tag: {name}"),
GuestbookError::BadTag(name) => write!(f, "malformed guestbook tag: {name}"),
}
}
}
impl std::error::Error for GuestbookError {}
impl From<StreamError> for GuestbookError {
fn from(error: StreamError) -> Self {
GuestbookError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
at_ms: u64,
/// The `(creator, label)` an invite attributed the join to.
invited_by: Option<(String, String)>,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
at_ms: u64,
citation: Option<AuthorityCitation>,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub at_ms: u64,
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberState {
Joined {
at_ms: u64,
invited_by: Option<(String, String)>,
},
Left {
at_ms: u64,
},
Kicked {
at_ms: u64,
actor: PublicKey,
},
}
pub fn build_join(
member: PublicKey,
invited_by: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invited_by {
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
}
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
}
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
}
pub fn build_kick(
actor: PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
}
pub fn build_snapshot_chunks(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
let total = chunks.len() as u32;
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
let content = format!(
"[{}]",
hex.iter()
.map(|member| format!("\"{member}\""))
.collect::<Vec<_>>()
.join(",")
);
let tags = vec![Tag::custom(
TAG_SNAP,
[
HEXLOWER.encode(&snapshot_id),
(index as u32 + 1).to_string(),
total.to_string(),
],
)];
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &Keys,
) -> Result<(Event, Keys), GuestbookError> {
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author)?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
pub fn open(
wrap: &Event,
group: &GroupKey,
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let entry = entry_of(&opened)?;
let rumor = GuestbookRumor {
id: opened.rumor_id,
author: opened.author,
kind: opened.rumor.kind,
at_ms: opened.at_ms,
entry,
};
Ok((opened, rumor))
}
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authority: Option<&PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
for rumor in rumors {
if rumor.at_ms > horizon {
continue;
}
match &rumor.entry {
GuestbookEntry::Join {
member,
at_ms,
invited_by,
} => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: invited_by.clone(),
},
),
GuestbookEntry::Leave { member, at_ms } => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Left { at_ms: *at_ms },
),
GuestbookEntry::Kick {
actor,
target,
at_ms,
citation,
} => {
if !can_kick(actor, target, citation.as_ref()) {
continue;
}
offer(
&mut states,
*target,
*at_ms,
rumor.id,
MemberState::Kicked {
at_ms: *at_ms,
actor: *actor,
},
);
}
GuestbookEntry::Snapshot {
refounder,
members,
at_ms,
..
} => {
if snapshot_authority != Some(refounder) {
continue;
}
for member in members {
offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: None,
},
);
}
}
}
}
states
.into_iter()
.map(|(member, (_, _, state))| (member, state))
.collect()
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
granted: &BTreeSet<PublicKey>,
banned: &BTreeSet<PublicKey>,
banned_at: &BTreeMap<PublicKey, u64>,
) -> BTreeSet<PublicKey> {
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
candidates.extend(observed.keys());
candidates.extend(granted.iter());
let mut members = BTreeSet::new();
for member in candidates {
let mut inclusion = observed.get(member).copied();
if let Some(state) = coalesced.get(member) {
match state {
MemberState::Joined { at_ms, .. } => {
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
}
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
}
}
if inclusion.is_none() && granted.contains(member) {
inclusion = Some(0);
}
let mut exclusion = match coalesced.get(member) {
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
Some(*at_ms)
}
_ => None,
};
if banned.contains(member) {
exclusion = Some(match banned_at.get(member) {
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
None => u64::MAX,
});
}
if let Some(inclusion) = inclusion
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
{
members.insert(*member);
}
}
members
}
fn offer(
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
member: PublicKey,
at_ms: u64,
id: EventId,
state: MemberState,
) {
let candidate = (at_ms, Reverse(id));
if let Some(existing) = states.get(&member)
&& (existing.0, existing.1) >= candidate
{
return;
}
states.insert(member, (at_ms, Reverse(id), state));
}
fn is_guestbook_kind(kind: u16) -> bool {
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
}
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
let rumor = &opened.rumor;
let author = opened.author;
let at_ms = opened.at_ms;
match rumor.kind.as_u16() {
KIND_JOIN_LEAVE => match rumor.content.as_str() {
CONTENT_JOIN => Ok(GuestbookEntry::Join {
member: author,
at_ms,
invited_by: invite_of(rumor),
}),
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
member: author,
at_ms,
}),
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
},
KIND_KICK => Ok(GuestbookEntry::Kick {
actor: author,
target: tagged_pubkey(rumor, TAG_TARGET)?,
at_ms,
citation: optional_citation(rumor)?,
}),
KIND_SNAPSHOT => {
let (snapshot_id, chunk) = snapshot_of(rumor)?;
let members = members_of(&rumor.content)?;
Ok(GuestbookEntry::Snapshot {
refounder: author,
members,
snapshot_id,
chunk,
at_ms,
})
}
other => Err(GuestbookError::UnknownKind(other)),
}
}
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
rumor.tags.iter().find_map(|candidate| {
let fields = candidate.as_slice();
(fields.len() >= 3 && fields[0] == TAG_INVITE)
.then(|| (fields[1].clone(), fields[2].clone()))
})
}
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
let entries: Vec<String> =
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
if entries.len() > MAX_SNAPSHOT_CHUNK {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
entries
.iter()
.map(|entry| pubkey(entry, TAG_CONTENT))
.collect()
}
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
let fields = required(rumor, TAG_SNAP)?;
if fields.len() != 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let index = decimal(&fields[2])?;
let total = decimal(&fields[3])?;
if index == 0 || index > total {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
Ok((snapshot_id, (index, total)))
}
fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, GuestbookError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(GuestbookError::BadTag(TAG_CITATION))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], GuestbookError> {
tag(rumor, name)?.ok_or(GuestbookError::MissingTag(name))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, GuestbookError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(GuestbookError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, GuestbookError> {
fields
.get(1)
.map(String::as_str)
.ok_or(GuestbookError::BadTag(name))
}
fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, GuestbookError> {
let bytes = decode_hex_32(hex).map_err(|_| GuestbookError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| GuestbookError::BadTag(name))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::guestbook_group_key;
use crate::stream::build_rumor_secs;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
const AT: u64 = 1_700_000_000_000;
fn community() -> CommunityId {
CommunityId::from_bytes([0x11u8; 32])
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
fn citation() -> AuthorityCitation {
AuthorityCitation {
entity: [0x33u8; 32],
version: 1,
hash: [0x44u8; 32],
}
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = seal_rumor(rumor, &group(), author).expect("seals").0;
open(&wrap, &group()).expect("opens").1
}
#[test]
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
let alice = Keys::generate();
let bob = Keys::generate();
let carol = Keys::generate();
let dave = Keys::generate();
let frank = Keys::generate();
let grace = Keys::generate();
let owner = Keys::generate();
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
let mut rumors = vec![
publish(
&build_join(
alice.public_key(),
Some((&"ab".repeat(32), "Reddit")),
AT + 1_000,
),
&alice,
),
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
publish(
&build_kick(
carol.public_key(),
&dave.public_key(),
Some(&citation()),
AT + 5_000,
),
&carol,
),
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
];
let snapshot_id = "77".repeat(32);
let chunks =
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
for (index, chunk) in chunks.iter().enumerate() {
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
== [
TAG_SNAP,
snapshot_id.as_str(),
&(index + 1).to_string(),
"2"
]));
rumors.push(publish(chunk, &carol));
}
let can_kick =
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, Some(&carol.public_key()), can_kick);
assert_eq!(
states.get(&alice.public_key()),
Some(&MemberState::Joined {
at_ms: AT + 1_000,
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
})
);
assert_eq!(
states.get(&bob.public_key()),
Some(&MemberState::Left { at_ms: AT + 3_000 })
);
assert_eq!(
states.get(&dave.public_key()),
Some(&MemberState::Kicked {
at_ms: AT + 5_000,
actor: carol.public_key(),
})
);
assert!(
survivors
.iter()
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
"every chunk seeds its own members"
);
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, Some(&carol.public_key()), can_kick),
states,
"arrival order cannot change the fold"
);
let observed = BTreeMap::from([
(bob.public_key(), AT + 9_000),
(carol.public_key(), AT + 5_000),
]);
let granted = BTreeSet::from([grace.public_key()]);
let banned = BTreeSet::from([frank.public_key()]);
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
let mut expected = BTreeSet::from([
alice.public_key(),
bob.public_key(),
carol.public_key(),
grace.public_key(),
]);
expected.extend(survivors.iter().copied());
assert_eq!(members, expected);
assert!(
!members.contains(&dave.public_key()),
"a kicked member is out"
);
assert!(
!members.contains(&frank.public_key()),
"a ban wins over a later join"
);
}
#[test]
fn a_kick_or_snapshot_without_authority_is_dropped() {
let moderator = Keys::generate();
let outsider = Keys::generate();
let owner = Keys::generate();
let kicked = Keys::generate();
let uncited = Keys::generate();
let unranked = Keys::generate();
let refounder = Keys::generate();
let impostor = Keys::generate();
let seeded = Keys::generate();
let smuggled = Keys::generate();
let can_kick = |actor: &PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
};
let rumors = vec![
publish(
&build_kick(
moderator.public_key(),
&kicked.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
publish(
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
&moderator,
),
publish(
&build_kick(
outsider.public_key(),
&unranked.public_key(),
Some(&citation()),
AT,
),
&outsider,
),
publish(
&build_kick(
moderator.public_key(),
&owner.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
];
let states = coalesce(&rumors, AT + 1_000, None, can_kick);
assert_eq!(
states.get(&kicked.public_key()),
Some(&MemberState::Kicked {
at_ms: AT,
actor: moderator.public_key(),
})
);
assert!(
!states.contains_key(&uncited.public_key()),
"a kick cites the Grant it acts under"
);
assert!(
!states.contains_key(&unranked.public_key()),
"a kick needs KICK"
);
assert!(
!states.contains_key(&owner.public_key()),
"nobody kicks the owner"
);
let by_refounder = build_snapshot_chunks(
refounder.public_key(),
&[seeded.public_key()],
[0x77u8; 32],
AT,
)
.remove(0);
let by_impostor = build_snapshot_chunks(
impostor.public_key(),
&[smuggled.public_key()],
[0x88u8; 32],
AT,
)
.remove(0);
for authority in [None, Some(refounder.public_key())] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
authority.as_ref(),
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
authority.is_some(),
"only the epoch's refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
"a foreign snapshot never seeds"
);
}
}
#[test]
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
let member = Keys::generate();
let moderator = Keys::generate();
let target = Keys::generate();
let future = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
&member,
);
let horizon = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
&member,
);
assert!(
coalesce(&[future], AT, None, |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, None, |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
let bad_ms = build_rumor_secs(
KIND_JOIN_LEAVE,
member.public_key(),
CONTENT_JOIN,
vec![Tag::custom("ms", ["1000"])],
AT / 1000,
);
assert!(matches!(
open(
&seal_rumor(&bad_ms, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
));
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&seal_rumor(&bad_verb, &group(), &member).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
));
let ambiguous = build_rumor_ms(
KIND_KICK,
moderator.public_key(),
"",
vec![
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
citation_tag(&citation()),
citation_tag(&citation()),
],
AT,
);
assert!(matches!(
open(
&seal_rumor(&ambiguous, &group(), &moderator)
.expect("seals")
.0,
&group()
),
Err(GuestbookError::DuplicateTag(TAG_CITATION))
));
for fields in [
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
vec![snapshot_id(), "1".to_owned()],
] {
let rumor = build_rumor_ms(
KIND_SNAPSHOT,
moderator.public_key(),
"[]",
vec![Tag::custom(TAG_SNAP, fields)],
AT,
);
assert!(matches!(
open(
&seal_rumor(&rumor, &group(), &moderator).expect("seals").0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
+954
View File
@@ -0,0 +1,954 @@
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::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 = stream::seal_to_self(keys, json.as_bytes())?;
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 = stream::open_to_self(keys, &event.content)?;
serde_json::from_slice(&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(_))
));
}
}
+146
View File
@@ -0,0 +1,146 @@
pub mod chat;
pub mod control;
pub mod derive;
pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod pins;
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, 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 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]> {
decode_hex_lower::<32>(value)
}
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected {N} 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)
}
+476
View File
@@ -0,0 +1,476 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::invite::{ChannelGrant, CommunityInvite};
use crate::stream::{self, 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 {}
impl From<stream::StreamError> for ListError {
fn from(error: stream::StreamError) -> Self {
ListError::Crypto(error.to_string())
}
}
#[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 = stream::seal_to_self(keys, json.as_bytes())?;
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 = stream::open_to_self(keys, &event.content)?;
serde_json::from_slice(&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;
}
+817
View File
@@ -0,0 +1,817 @@
use std::fmt;
use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use data_encoding::{BASE64, HEXLOWER};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::chat::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
use crate::edition::canonical_decimal;
use crate::stream::{self, OpenedStream, SealForm, resolve_ms_strict};
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
pub const PIN_MAX_ENTRIES: usize = 25;
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
/// The serialized disclosure: `chacha_key[32] || chacha_nonce[12] || hmac_key[32]`.
pub const MESSAGE_KEYS_BYTES: usize = 76;
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
const TAG_TARGET: &str = "e";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinError {
NotEncryptedSeal,
BadPayload,
Unverifiable,
Unreadable,
TooManyEntries,
Oversize(usize),
Seal(String),
Encode(String),
}
impl fmt::Display for PinError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PinError::NotEncryptedSeal => write!(f, "pin requires an encrypted seal"),
PinError::BadPayload => write!(f, "the seal payload does not open"),
PinError::Unverifiable => write!(f, "the entry would not verify"),
PinError::Unreadable => {
write!(f, "refusing to publish a pin list this client cannot read")
}
PinError::TooManyEntries => write!(f, "pin list exceeds {PIN_MAX_ENTRIES} entries"),
PinError::Oversize(len) => {
write!(
f,
"pin list content is {len} bytes (cap {PIN_MAX_CONTENT_BYTES})"
)
}
PinError::Seal(error) => write!(f, "seal: {error}"),
PinError::Encode(error) => write!(f, "encode: {error}"),
}
}
}
impl std::error::Error for PinError {}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct MessageKeys {
chacha_key: [u8; 32],
chacha_nonce: [u8; 12],
hmac_key: [u8; 32],
}
impl MessageKeys {
pub fn to_hex(&self) -> String {
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
packed[0..32].copy_from_slice(&self.chacha_key);
packed[32..44].copy_from_slice(&self.chacha_nonce);
packed[44..76].copy_from_slice(&self.hmac_key);
HEXLOWER.encode(&packed)
}
pub fn from_hex(value: &str) -> Option<Self> {
let bytes = decode_hex_lower::<MESSAGE_KEYS_BYTES>(value).ok()?;
Some(Self {
chacha_key: bytes[0..32].try_into().ok()?,
chacha_nonce: bytes[32..44].try_into().ok()?,
hmac_key: bytes[44..76].try_into().ok()?,
})
}
fn derive(conversation_key: &[u8; 32], nonce: &[u8]) -> Option<Self> {
let hkdf = Hkdf::<Sha256>::from_prk(conversation_key).ok()?;
let mut key_material = [0u8; MESSAGE_KEYS_BYTES];
hkdf.expand(nonce, &mut key_material).ok()?;
Some(Self {
chacha_key: key_material[0..32].try_into().ok()?,
chacha_nonce: key_material[32..44].try_into().ok()?,
hmac_key: key_material[44..76].try_into().ok()?,
})
}
}
struct Payload {
nonce: [u8; 32],
ciphertext: Vec<u8>,
mac: [u8; 32],
}
fn decode_payload(payload: &str) -> Option<Payload> {
let data = BASE64.decode(payload.as_bytes()).ok()?;
if data.len() < 99 || data[0] != 2 {
return None;
}
let mac_at = data.len() - 32;
Some(Payload {
nonce: data[1..33].try_into().ok()?,
ciphertext: data[33..mac_at].to_vec(),
mac: data[mac_at..].try_into().ok()?,
})
}
fn disclose_keys(payload: &str, conversation_key: &[u8; 32]) -> Option<MessageKeys> {
let decoded = decode_payload(payload)?;
MessageKeys::derive(conversation_key, &decoded.nonce)
}
fn open_payload(payload: &str, keys: &MessageKeys) -> Option<String> {
let decoded = decode_payload(payload)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&keys.hmac_key).ok()?;
mac.update(&decoded.nonce);
mac.update(&decoded.ciphertext);
mac.verify_slice(&decoded.mac).ok()?;
let mut padded = decoded.ciphertext;
let mut cipher = ChaCha20::new((&keys.chacha_key).into(), (&keys.chacha_nonce).into());
cipher.apply_keystream(&mut padded);
unpad(&padded)
}
fn unpad(padded: &[u8]) -> Option<String> {
let (len, prefix) = plaintext_length(padded)?;
let unpadded = padded.get(prefix..prefix.checked_add(len)?)?;
if len < 1 || padded.len() != prefix.checked_add(padded_len(len)?)? {
return None;
}
String::from_utf8(unpadded.to_vec()).ok()
}
fn plaintext_length(padded: &[u8]) -> Option<(usize, usize)> {
let short = u16::from_be_bytes(padded.get(..2)?.try_into().ok()?);
if short != 0 {
return Some((short as usize, 2));
}
let long = u32::from_be_bytes(padded.get(2..6)?.try_into().ok()?);
if long < 65_536 {
return None;
}
Some((long as usize, 6))
}
fn padded_len(len: usize) -> Option<usize> {
if len < 1 {
return None;
}
if len <= 32 {
return Some(32);
}
let next_power = 1usize.checked_shl(usize::BITS - (len - 1).leading_zeros())?;
let chunk = if next_power <= 256 {
32
} else {
next_power / 8
};
Some(chunk * ((len - 1) / chunk + 1))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEditBundle {
pub seal: Event,
pub keys: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEntry {
pub seal: Event,
pub keys: String,
/// An unverifiable locator hint; a mismatch is expected and never fatal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<PinEditBundle>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditedContent {
pub content: String,
pub at_ms: u64,
}
#[derive(Debug, Clone)]
pub struct VerifiedPin {
pub rumor_id: EventId,
pub author: PublicKey,
pub kind: u16,
pub content: String,
pub tags: Tags,
pub epoch: Epoch,
pub at_ms: u64,
pub created_at: u64,
pub wrap: Option<String>,
pub edited: Option<EditedContent>,
pub entry: PinEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ReadPinList {
pub entries: Vec<PinEntry>,
pub sealed: bool,
}
pub fn build_entry(
opened: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> Result<PinEntry, PinError> {
let keys = disclosed_keys(opened, group)?;
let entry = PinEntry {
seal: opened.seal.clone(),
keys: keys.to_hex(),
wrap: Some(opened.wrapper_id.to_hex()),
edit: None,
extra: Extra::default(),
};
if verify_entry(&entry, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(entry)
}
pub fn build_edit_bundle(
edit: &OpenedStream,
group: &GroupKey,
original: &VerifiedPin,
channel: &ChannelId,
) -> Result<PinEditBundle, PinError> {
let bundle = PinEditBundle {
seal: edit.seal.clone(),
keys: disclosed_keys(edit, group)?.to_hex(),
};
if verify_edit_bundle(&bundle, &original.author, &original.rumor_id, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(bundle)
}
pub fn with_proven_edit(
entry: &PinEntry,
edit: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> PinEntry {
let Some(original) = verify_entry(entry, channel) else {
return entry.clone();
};
let Ok(bundle) = build_edit_bundle(edit, group, &original, channel) else {
return entry.clone();
};
let mut refreshed = entry.clone();
refreshed.edit = Some(bundle);
refreshed
}
fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys, PinError> {
if opened.seal_form != SealForm::Encrypted {
return Err(PinError::NotEncryptedSeal);
}
let conversation: [u8; 32] = group
.conversation()
.as_bytes()
.try_into()
.map_err(|_| PinError::BadPayload)?;
let keys = disclose_keys(&opened.seal.content, &conversation).ok_or(PinError::BadPayload)?;
if open_payload(&opened.seal.content, &keys).is_none() {
return Err(PinError::BadPayload);
}
Ok(keys)
}
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
let seal = &entry.seal;
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&entry.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
// NIP-59's impersonation check: the renderer shows the rumor's fields.
if rumor.pubkey != seal.pubkey {
return None;
}
let kind = rumor.kind.as_u16();
if kind != KIND_MESSAGE && kind != KIND_COMMENT {
return None;
}
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
rumor.verify_id().ok()?;
let rumor_id = rumor.compute_id();
let edited = entry
.edit
.as_ref()
.and_then(|bundle| verify_edit_bundle(bundle, &rumor.pubkey, &rumor_id, channel));
Some(VerifiedPin {
author: rumor.pubkey,
content: edited
.as_ref()
.map_or_else(|| rumor.content.clone(), |edited| edited.content.clone()),
epoch,
at_ms: resolve_ms_strict(&rumor).ok()?,
created_at: rumor.created_at.as_secs(),
tags: rumor.tags.clone(),
wrap: entry.wrap.clone(),
edited,
entry: entry.clone(),
kind,
rumor_id,
})
}
fn verify_edit_bundle(
bundle: &PinEditBundle,
original_author: &PublicKey,
original_id: &EventId,
channel: &ChannelId,
) -> Option<EditedContent> {
let seal = &bundle.seal;
// Checkable before any crypto: nobody else may revise another member's words.
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
return None;
}
if seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&bundle.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
if rumor.pubkey != seal.pubkey || rumor.kind.as_u16() != KIND_EDIT {
return None;
}
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
if tag_value(&rumor, TAG_TARGET)? != original_id.to_hex() {
return None;
}
rumor.verify_id().ok()?;
Some(EditedContent {
content: rumor.content.clone(),
at_ms: resolve_ms_strict(&rumor).ok()?,
})
}
fn tag_value<'a>(rumor: &'a UnsignedEvent, name: &str) -> Option<&'a str> {
rumor
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some(name))
.and_then(|tag| tag.as_slice().get(1))
.map(String::as_str)
}
#[derive(Serialize, Deserialize)]
struct PlainForm {
entries: Vec<PinEntry>,
}
pub fn publishable(
read: &ReadPinList,
private: bool,
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if read.sealed {
return Err(PinError::Unreadable);
}
if private {
serialize_sealed(&read.entries, group, epoch)
} else {
serialize_public(&read.entries)
}
}
fn serialize_public(entries: &[PinEntry]) -> Result<String, PinError> {
let content = encode_form(entries)?;
check_caps(entries.len(), &content)?;
Ok(content)
}
fn serialize_sealed(
entries: &[PinEntry],
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if entries.len() > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
let inner = encode_form(entries)?;
let sealed = stream::seal_bytes(group.conversation(), inner.as_bytes())
.map_err(|error| PinError::Seal(error.to_string()))?;
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
check_caps(entries.len(), &content)?;
Ok(content)
}
fn encode_form(entries: &[PinEntry]) -> Result<String, PinError> {
serde_json::to_string(&PlainForm {
entries: entries.to_vec(),
})
.map_err(|error| PinError::Encode(error.to_string()))
}
fn check_caps(count: usize, content: &str) -> Result<(), PinError> {
if count > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
if content.len() > PIN_MAX_CONTENT_BYTES {
return Err(PinError::Oversize(content.len()));
}
Ok(())
}
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList {
const EMPTY: ReadPinList = ReadPinList {
entries: Vec::new(),
sealed: false,
};
if content.len() > PIN_MAX_CONTENT_BYTES {
return EMPTY;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return EMPTY;
};
if value.get("entries").is_some() {
return match serde_json::from_value::<PlainForm>(value) {
Ok(form) if form.entries.len() <= PIN_MAX_ENTRIES => ReadPinList {
entries: form.entries,
sealed: false,
},
_ => EMPTY,
};
}
let (Some(epoch), Some(sealed)) = (
value.get("epoch").and_then(serde_json::Value::as_str),
value.get("sealed").and_then(serde_json::Value::as_str),
) else {
return EMPTY;
};
let Some(epoch) = canonical_decimal(epoch) else {
return EMPTY;
};
let Some(group) = unseal(Epoch(epoch)) else {
return ReadPinList {
sealed: true,
..EMPTY
};
};
let Ok(inner) = stream::open_bytes(group.conversation(), sealed) else {
return EMPTY;
};
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
return EMPTY;
};
if form.entries.len() > PIN_MAX_ENTRIES {
return EMPTY;
}
ReadPinList {
entries: form.entries,
sealed: false,
}
}
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool {
delete.author == pin.author
&& matches!(&delete.action, ChatAction::Delete { target, .. } if *target == pin.rumor_id)
}
#[cfg(test)]
mod tests {
use nostr::nips::nip44::v2::{self, ConversationKey};
use super::*;
use crate::chat::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
use crate::derive::channel_group_key;
const AT_MS: u64 = 1_700_000_000_000;
const SECRET: [u8; 32] = [0x21u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group() -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
}
fn conversation() -> ConversationKey {
*group().conversation()
}
/// A real message through the production seal/open pipeline, as a pinner sees it.
fn sealed_message(author: &Keys, text: &str, at_ms: u64) -> (OpenedStream, ChatRumor) {
let rumor = build_message(
author.public_key(),
&channel(),
Epoch(0),
text,
None,
at_ms,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group(), author, false).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
}
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
let (opened, _) = sealed_message(author, text, AT_MS);
let entry = build_entry(&opened, &group(), &channel()).expect("builds");
(entry, opened)
}
fn some(entries: Vec<PinEntry>) -> ReadPinList {
ReadPinList {
entries,
sealed: false,
}
}
/// The load-bearing primitive: the reproduction must open what nostr's own
/// encryption produced, through the disclosure alone.
#[test]
fn a_disclosure_opens_its_message_and_nothing_else() {
let nonce = [0x5au8; 32];
let disclosure =
MessageKeys::derive(conversation().as_bytes().try_into().expect("32"), &nonce)
.expect("derives");
for text in ["a", "hello world", &"padding boundary ".repeat(40)] {
let raw = v2::encrypt_to_bytes_with_nonce(&conversation(), text.as_bytes(), nonce)
.expect("encrypts");
let payload = BASE64.encode(&raw);
assert_eq!(open_payload(&payload, &disclosure).as_deref(), Some(text));
}
// Another nonce discloses different keys, which open nothing else.
let other = v2::encrypt_to_bytes_with_nonce(&conversation(), b"second", [0x99u8; 32])
.expect("encrypts");
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
let hex = disclosure.to_hex();
assert_eq!(
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
Some(hex.clone())
);
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
}
#[test]
fn a_built_entry_proves_its_author_and_cannot_cross_channels() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "pin me");
let verified = verify_entry(&entry, &channel()).expect("verifies");
assert_eq!(verified.author, author.public_key());
assert_eq!(verified.content, "pin me");
assert_eq!(verified.rumor_id, opened.rumor_id);
assert_eq!(verified.at_ms, AT_MS);
assert_eq!(verified.epoch, Epoch(0));
// A keyholder must not be able to pin channel X's message into Y's list.
let foreign = ChannelId::from_bytes([0xcdu8; 32]);
assert!(verify_entry(&entry, &foreign).is_none());
// Tampered keys and a re-signed seal both fail.
let mut bad_keys = entry.clone();
bad_keys.keys = format!("00{}", &entry.keys[2..]);
assert!(verify_entry(&bad_keys, &channel()).is_none());
let mut forged = entry.clone();
forged.seal.pubkey = Keys::generate().public_key();
assert!(verify_entry(&forged, &channel()).is_none());
// A rumor carrying a claimed id that is not its own is refused.
let plaintext = stream::open_bytes(&conversation(), &opened.seal.content).expect("opens");
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
value["id"] = serde_json::Value::String("00".repeat(32));
let raw = v2::encrypt_to_bytes_with_nonce(
&conversation(),
value.to_string().as_bytes(),
[0x11u8; 32],
)
.expect("encrypts");
let content = BASE64.encode(&raw);
let seal = EventBuilder::new(Kind::Custom(stream::KIND_SEAL_ENCRYPTED), &content)
.custom_created_at(opened.seal.created_at)
.finalize(&author)
.expect("signs");
let lying = PinEntry {
keys: disclose_keys(&content, conversation().as_bytes().try_into().expect("32"))
.expect("discloses")
.to_hex(),
seal,
wrap: None,
edit: None,
extra: Extra::default(),
};
assert!(verify_entry(&lying, &channel()).is_none());
}
#[test]
fn a_proven_edit_replaces_the_words_and_a_stranger_cannot_revise() {
let author = Keys::generate();
let (entry, original) = entry_for(&author, "teh typo");
let edit = build_edit(
author.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"the typo, fixed",
AT_MS + 5_000,
None,
);
let (wrap, _) = seal_rumor(&edit, &group(), &author, false).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
let verified = verify_entry(&refreshed, &channel()).expect("verifies");
assert_eq!(verified.content, "the typo, fixed");
assert_eq!(verified.edited.expect("edited").at_ms, AT_MS + 5_000);
// A stranger's edit of the same message never attaches.
let stranger = Keys::generate();
let hijack = build_edit(
stranger.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"hijacked",
AT_MS + 6_000,
None,
);
let (wrap, _) = seal_rumor(&hijack, &group(), &stranger, false).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
assert!(unchanged.edit.is_none());
}
#[test]
fn both_list_forms_round_trip_and_obey_their_caps() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "hello");
let public =
publishable(&some(vec![entry.clone()]), false, &group(), Epoch(0)).expect("publishes");
let read = read_list(&public, |_| None);
assert!(!read.sealed);
assert_eq!(read.entries.len(), 1);
assert!(verify_entry(&read.entries[0], &channel()).is_some());
// A sealed list stays dark without its key, lights with it, and a wrong
// key reads empty rather than panicking.
let at_epoch_4 = channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives");
let sealed = publishable(&some(vec![entry.clone()]), true, &at_epoch_4, Epoch(4))
.expect("publishes");
let dark = read_list(&sealed, |_| None);
assert!(dark.sealed && dark.entries.is_empty());
let lit = read_list(&sealed, |epoch| {
(epoch == Epoch(4))
.then(|| channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives"))
});
assert!(!lit.sealed);
assert!(verify_entry(&lit.entries[0], &channel()).is_some());
assert!(read_list(&sealed, |_| Some(group())).entries.is_empty());
// 26 entries: the writer refuses, and a hand-built violating edition
// reads as empty rather than forking the chain.
let many = vec![entry; PIN_MAX_ENTRIES + 1];
assert_eq!(
publishable(&some(many.clone()), false, &group(), Epoch(0)),
Err(PinError::TooManyEntries)
);
let violating = serde_json::json!({ "entries": many }).to_string();
assert!(read_list(&violating, |_| None).entries.is_empty());
// Garbage never panics and never reads as a list.
for bad in [
"",
"not json",
"[]",
"42",
r#"{"entries": 7}"#,
r#"{"epoch":"04","sealed":"y"}"#,
] {
let read = read_list(bad, |_| None);
assert!(read.entries.is_empty() && !read.sealed, "{bad}");
}
}
#[test]
fn a_dark_list_is_never_reformed_and_only_the_author_kills_a_pin() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "delete me later");
let dark = ReadPinList {
entries: vec![entry.clone()],
sealed: true,
};
assert_eq!(
publishable(&dark, false, &group(), Epoch(0)),
Err(PinError::Unreadable)
);
let verified = verify_entry(&entry, &channel()).expect("verifies");
for author_keys in [&author, &Keys::generate()] {
let delete = build_delete(
author_keys.public_key(),
&channel(),
Epoch(0),
verified.rumor_id,
Some(KIND_MESSAGE),
None,
AT_MS + 1_000,
);
let (wrap, _) = seal_rumor(&delete, &group(), author_keys, false).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!(
killed_by(&verified, &rumor),
author_keys.public_key() == author.public_key()
);
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+647
View File
@@ -0,0 +1,647 @@
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/";
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<bool> {
if chat::expiration_of(&opened.rumor)?.is_some_and(|expiration| expiration <= Timestamp::now())
{
return Ok(false);
}
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(true)
}
pub async fn purge_expired(
database: &dyn NostrDatabase,
channel: &ChannelId,
now: Timestamp,
) -> Result<usize> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
.custom_tag(MARK_TAG, MARK_VALUE)
.custom_tag(CHANNEL_TAG, channel.to_hex());
let mut expired = Vec::new();
for event in database.query(filter).await? {
let Ok(rumor) = UnsignedEvent::from_json(&event.content) else {
continue;
};
let Ok(Some(expiration)) = chat::expiration_of(&rumor) else {
continue;
};
if expiration <= now {
expired.push(event.id);
}
}
let purged = expired.len();
if purged > 0 {
database.delete(Filter::new().ids(expired)).await?;
}
Ok(purged)
}
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 {
if 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,
None,
);
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");
});
}
#[test]
fn an_expired_rumor_is_refused_at_ingest_and_purged_by_the_sweep() {
let database = MemoryDatabase::unbounded();
let channel = ChannelId::from_bytes([0x77u8; 32]);
let author = Keys::generate();
let group = channel_group_key(&SECRET, &channel, Epoch(0)).expect("derives");
let now = Timestamp::now().as_secs();
smol::block_on(async {
// A live timer is stored; one that already elapsed is refused at ingest.
assert!(
cache(
&database,
&group,
&channel,
&author,
"live",
Some(3_600),
now
)
.await
);
assert!(
!cache(
&database,
&group,
&channel,
&author,
"gone",
Some(1),
now - 120
)
.await
);
let stored = query_rumors(&database, &channel, None, 10)
.await
.expect("queries");
assert_eq!(stored.len(), 1);
assert_eq!(stored[0].content, "live");
// Hiding is not disappearing: the sweep removes the row itself,
// judged on the rumor's own signed tag.
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 7_200))
.await
.expect("sweeps");
assert_eq!(purged, 1);
assert!(
query_rumors(&database, &channel, None, 10)
.await
.expect("queries")
.is_empty()
);
// An untimed rumor is never swept, whatever the clock says.
assert!(cache(&database, &group, &channel, &author, "timeless", None, now).await);
let purged = purge_expired(&database, &channel, Timestamp::from_secs(now + 86_400))
.await
.expect("sweeps");
assert_eq!(purged, 0);
});
}
async fn cache(
database: &MemoryDatabase,
group: &GroupKey,
channel: &ChannelId,
author: &Keys,
content: &str,
timer: Option<u64>,
at_secs: u64,
) -> bool {
let rumor = build_message(
author.public_key(),
channel,
Epoch(0),
content,
None,
at_secs * 1_000,
timer,
);
let (wrap, _) = seal_rumor(&rumor, group, author, false).expect("seals");
let opened = open_wrap(&wrap, group).expect("opens");
cache_rumor(database, channel, &opened)
.await
.expect("caches")
}
}
+700
View File
@@ -0,0 +1,700 @@
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: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
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> {
check_plaintext_cap(plaintext.len())?;
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()))
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
}
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);
}
}