add pins, disappearing messages and hardenin

This commit is contained in:
2026-09-17 10:35:41 +07:00
parent ecd08273eb
commit fd39be0eda
12 changed files with 1677 additions and 90 deletions
+2
View File
@@ -10,6 +10,8 @@ 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
+253 -22
View File
@@ -22,6 +22,7 @@ pub const KIND_REACTION: u16 = 7;
pub const KIND_DELETE: u16 = 5;
pub const KIND_EDIT: u16 = 3302;
pub const KIND_FILE: u16 = 15;
pub const KIND_TIMER_NOTICE: u16 = 1740;
pub const KIND_WEBXDC: u16 = 3310;
pub const KIND_TYPING: u16 = 23311;
@@ -33,6 +34,7 @@ const TAG_ROOT_KIND: &str = "K";
const TAG_ROOT_AUTHOR: &str = "P";
const TAG_TARGET_AUTHOR: &str = "p";
const TAG_EXPIRATION: &str = "expiration";
const TAG_TIMER: &str = "timer";
#[derive(Debug)]
pub enum ChatError {
@@ -42,6 +44,9 @@ pub enum ChatError {
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// A delete is a tombstone and a timer notice documents the policy, so
/// neither may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for ChatError {
@@ -53,6 +58,9 @@ impl fmt::Display for ChatError {
ChatError::MissingTag(name) => write!(f, "missing chat tag: {name}"),
ChatError::DuplicateTag(name) => write!(f, "duplicate chat tag: {name}"),
ChatError::BadTag(name) => write!(f, "malformed chat tag: {name}"),
ChatError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
@@ -102,6 +110,9 @@ pub enum ChatAction {
},
Typing,
Opaque,
TimerNotice {
seconds: u64,
},
}
#[derive(Debug, Clone)]
@@ -142,6 +153,7 @@ pub fn build_message(
content: &str,
quote: Option<&ReplyRef>,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -149,11 +161,14 @@ pub fn build_message(
tags.push(reply_tag(TAG_QUOTE, quote));
}
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms)
}
/// A NIP-22 comment. `parent` is the immediate parent and `root` the thread's
/// immutable root; `None` means the parent is itself the root.
#[allow(clippy::too_many_arguments)]
pub fn build_comment(
author: PublicKey,
channel: &ChannelId,
@@ -162,6 +177,7 @@ pub fn build_comment(
parent: &Target,
root: Option<&Target>,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let root = root.unwrap_or(parent);
let mut tags = channel_binding_tags(channel, epoch);
@@ -178,6 +194,8 @@ pub fn build_comment(
tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()]));
}
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms)
}
@@ -188,6 +206,7 @@ pub fn build_reaction(
target: &Target,
emoji: &str,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
@@ -197,6 +216,8 @@ pub fn build_reaction(
}
tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()]));
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms)
}
@@ -207,13 +228,37 @@ pub fn build_edit(
target: EventId,
content: &str,
at_ms: u64,
timer: Option<u64>,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TARGET, [target.to_hex()]));
tags.extend(expiration_tag(at_ms, timer));
build_rumor_ms(KIND_EDIT, author, content, tags, at_ms)
}
/// CORD-08 §4: an informational row in the timeline, gated by the roster rather
/// than by the fold, so it is built like any other chat rumor.
pub fn build_timer_notice(
author: PublicKey,
channel: &ChannelId,
epoch: Epoch,
seconds: u64,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = channel_binding_tags(channel, epoch);
tags.push(Tag::custom(TAG_TIMER, [seconds.to_string()]));
build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms)
}
/// The tag is derived from the rumor's own signed `created_at`, so a later
/// metadata edit can never reach back into history.
fn expiration_tag(at_ms: u64, timer: Option<u64>) -> Option<Tag> {
timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()]))
}
pub fn build_delete(
author: PublicKey,
channel: &ChannelId,
@@ -327,6 +372,7 @@ pub fn plane_keys(
pub fn fold(
rumors: &[ChatRumor],
now: Timestamp,
can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool,
) -> Vec<ChatMessage> {
let mut order: Vec<usize> = (0..rumors.len()).collect();
@@ -338,12 +384,20 @@ pub fn fold(
for index in order {
let rumor = &rumors[index];
let ChatAction::Message {
reply_to,
thread_root,
} = &rumor.action
else {
if expired(rumor, now) {
continue;
}
let (reply_to, thread_root) = match &rumor.action {
ChatAction::Message {
reply_to,
thread_root,
} => (
reply_to.map(|reply| reply.id),
thread_root.map(|reply| reply.id),
),
ChatAction::TimerNotice { .. } => (None, None),
_ => continue,
};
slot.insert(rumor.id, messages.len());
@@ -354,8 +408,8 @@ pub fn fold(
epoch: rumor.epoch,
kind: rumor.kind,
content: rumor.content.clone(),
reply_to: reply_to.map(|reply| reply.id),
thread_root: thread_root.map(|reply| reply.id),
reply_to,
thread_root,
at_ms: rumor.at_ms,
expiration: rumor.expiration,
edited_at: None,
@@ -404,7 +458,10 @@ pub fn fold(
messages[slot].reactions.insert(rumor.author, emoji.clone());
}
ChatAction::Message { .. } | ChatAction::Typing | ChatAction::Opaque => {}
ChatAction::Message { .. }
| ChatAction::Typing
| ChatAction::TimerNotice { .. }
| ChatAction::Opaque => {}
}
}
@@ -413,6 +470,11 @@ pub fn fold(
messages
}
/// CORD-08 §3: an expired rumor is never displayed, whatever its ingest path.
pub fn expired(rumor: &ChatRumor, now: Timestamp) -> bool {
rumor.expiration.is_some_and(|expiration| expiration <= now)
}
fn is_chat_kind(kind: u16) -> bool {
matches!(
kind,
@@ -422,12 +484,19 @@ fn is_chat_kind(kind: u16) -> bool {
| KIND_DELETE
| KIND_EDIT
| KIND_FILE
| KIND_TIMER_NOTICE
| KIND_WEBXDC
| KIND_TYPING
)
}
fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<ChatRumor, ChatError> {
let expiration = expiration_of(rumor)?;
if expiration.is_some() && matches!(rumor.kind.as_u16(), KIND_DELETE | KIND_TIMER_NOTICE) {
return Err(ChatError::ExemptExpiration);
}
Ok(ChatRumor {
id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
author: rumor.pubkey,
@@ -436,7 +505,7 @@ fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<Cha
epoch,
at_ms: resolve_ms_strict(rumor)?,
content: rumor.content.clone(),
expiration: expiration_of(rumor)?,
expiration,
action: action_of(rumor)?,
})
}
@@ -467,11 +536,20 @@ fn action_of(rumor: &UnsignedEvent) -> Result<ChatAction, ChatError> {
citation: optional_citation(rumor)?,
}),
KIND_TYPING => Ok(ChatAction::Typing),
KIND_TIMER_NOTICE => Ok(ChatAction::TimerNotice {
seconds: timer_of(rumor)?,
}),
KIND_WEBXDC => Ok(ChatAction::Opaque),
other => Err(ChatError::UnknownKind(other)),
}
}
fn timer_of(rumor: &UnsignedEvent) -> Result<u64, ChatError> {
let fields = tag(rumor, TAG_TIMER)?.ok_or(ChatError::MissingTag(TAG_TIMER))?;
canonical_decimal(value(fields, TAG_TIMER)?).ok_or(ChatError::BadTag(TAG_TIMER))
}
fn optional_reply(
rumor: &UnsignedEvent,
name: &'static str,
@@ -520,7 +598,7 @@ fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>,
.ok_or(ChatError::BadTag(TAG_CITATION))
}
fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
pub fn expiration_of(rumor: &UnsignedEvent) -> Result<Option<Timestamp>, ChatError> {
let Some(fields) = tag(rumor, TAG_EXPIRATION)? else {
return Ok(None);
};
@@ -594,6 +672,11 @@ mod tests {
const SECRET: [u8; 32] = [0x2du8; 32];
const AT: u64 = 1_700_000_000_417;
/// Well past every timestamp these tests use.
fn now() -> Timestamp {
Timestamp::from_secs(2_000_000_000)
}
fn channel() -> ChannelId {
ChannelId::from_bytes([0x9cu8; 32])
}
@@ -628,7 +711,15 @@ mod tests {
let carol = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
let id = message.compute_id();
let rumors = vec![
@@ -641,6 +732,7 @@ mod tests {
&target(id, &alice),
"🔥",
AT + 1_000,
None,
),
&group,
&carol,
@@ -654,6 +746,7 @@ mod tests {
id,
"hello (fixed)",
AT + 2_000,
None,
),
&group,
&alice,
@@ -675,7 +768,7 @@ mod tests {
),
];
let folded = fold(&rumors, |_, _, _| false);
let folded = fold(&rumors, now(), |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].id, id);
@@ -694,7 +787,15 @@ mod tests {
let bob = Keys::generate();
let group = group();
let message = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
let id = message.compute_id();
let rumors = vec![
@@ -707,6 +808,7 @@ mod tests {
id,
"mine now",
AT + 1_000,
None,
),
&group,
&bob,
@@ -728,7 +830,7 @@ mod tests {
),
];
let folded = fold(&rumors, |_, _, _| false);
let folded = fold(&rumors, now(), |_, _, _| false);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].content, "hello");
@@ -742,7 +844,15 @@ mod tests {
let bob = Keys::generate();
let group = group();
let root = build_message(alice.public_key(), &channel(), Epoch(0), "root", None, AT);
let root = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"root",
None,
AT,
None,
);
let root_id = root.compute_id();
let parent = build_message(
bob.public_key(),
@@ -751,6 +861,7 @@ mod tests {
"parent",
None,
AT + 1_000,
None,
);
let parent_id = parent.compute_id();
@@ -762,6 +873,7 @@ mod tests {
&target(parent_id, &bob),
Some(&target(root_id, &alice)),
AT + 2_000,
None,
);
assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"]));
@@ -796,7 +908,15 @@ mod tests {
let alice = Keys::generate();
let group = group();
let plain = build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT);
let plain = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
);
assert!(
open(
&sealed(&plain, &group, &alice),
@@ -820,7 +940,15 @@ mod tests {
Err(ChatError::Stream(StreamError::ChannelMismatch))
));
let stale = build_message(alice.public_key(), &channel(), Epoch(1), "stale", None, AT);
let stale = build_message(
alice.public_key(),
&channel(),
Epoch(1),
"stale",
None,
AT,
None,
);
assert!(matches!(
open(
&sealed(&stale, &group, &alice),
@@ -882,7 +1010,15 @@ mod tests {
let group = group();
let message = read(
&build_message(alice.public_key(), &channel(), Epoch(0), "hello", None, AT),
&build_message(
alice.public_key(),
&channel(),
Epoch(0),
"hello",
None,
AT,
None,
),
&group,
&alice,
Epoch(0),
@@ -922,26 +1058,121 @@ mod tests {
ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation
));
assert!(
fold(&cited, can_delete)[0].deleted,
fold(&cited, now(), can_delete)[0].deleted,
"a cited moderator delete lands"
);
let uncited = vec![message.clone(), delete(&moderator, None)];
assert!(
!fold(&uncited, can_delete)[0].deleted,
!fold(&uncited, now(), can_delete)[0].deleted,
"an uncited delete names no rank"
);
let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))];
assert!(
!fold(&peer_delete, can_delete)[0].deleted,
!fold(&peer_delete, now(), can_delete)[0].deleted,
"a peer's delete is not authority"
);
let own = vec![message.clone(), delete(&alice, None)];
assert!(
fold(&own, |_, _, _| false)[0].deleted,
fold(&own, now(), |_, _, _| false)[0].deleted,
"a self-delete never consults the predicate"
);
}
#[test]
fn a_timer_rides_durable_rumors_and_expiry_gates_the_fold() {
let alice = Keys::generate();
let group = group();
let expires = (AT / 1000 + 60).to_string();
// Computed from the signed `created_at`, and mirrored onto the wrap so
// relays drop the ciphertext too.
let message = build_message(
alice.public_key(),
&channel(),
Epoch(0),
"tick",
None,
AT,
Some(60),
);
assert!(
message
.tags
.iter()
.any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()])
);
assert!(
sealed(&message, &group, &alice)
.tags
.iter()
.any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()])
);
let live = read(&message, &group, &alice, Epoch(0));
assert_eq!(live.expiration, Some(Timestamp::from_secs(AT / 1000 + 60)));
assert!(!expired(&live, Timestamp::from_secs(AT / 1000 + 59)));
assert!(expired(&live, Timestamp::from_secs(AT / 1000 + 60)));
assert_eq!(
fold(
std::slice::from_ref(&live),
Timestamp::from_secs(AT / 1000 + 59),
|_, _, _| false
)
.len(),
1
);
assert_eq!(
fold(&[live], Timestamp::from_secs(AT / 1000 + 60), |_, _, _| {
false
})
.len(),
0
);
// A delete is a tombstone and a notice documents the policy, so neither
// may be erased by the policy it carries.
let mut expiring = channel_binding_tags(&channel(), Epoch(0));
expiring.push(Tag::custom(TAG_EXPIRATION, ["1"]));
expiring.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)]));
for kind in [KIND_DELETE, KIND_TIMER_NOTICE] {
let rumor = build_rumor_ms(kind, alice.public_key(), "", expiring.clone(), AT);
assert!(matches!(
open(
&sealed(&rumor, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::ExemptExpiration)
));
}
// A notice is a row of its own; whether its author may be believed
// about policy is the roster's call, not the fold's.
let notice = build_timer_notice(alice.public_key(), &channel(), Epoch(0), 3_600, AT);
let folded = fold(
&[read(&notice, &group, &alice, Epoch(0))],
now(),
|_, _, _| false,
);
assert_eq!(folded.len(), 1);
assert_eq!(folded[0].kind, Kind::Custom(KIND_TIMER_NOTICE));
let mut malformed = channel_binding_tags(&channel(), Epoch(0));
malformed.push(Tag::custom(TAG_TIMER, ["060"]));
let rumor = build_rumor_ms(KIND_TIMER_NOTICE, alice.public_key(), "", malformed, AT);
assert!(matches!(
open(
&sealed(&rumor, &group, &alice),
&group,
&channel(),
Epoch(0)
),
Err(ChatError::BadTag(TAG_TIMER))
));
}
}
+295 -6
View File
@@ -6,14 +6,15 @@ use serde::{Deserialize, Serialize};
use crate::derive::{
banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator,
invite_links_locator, verify_community_id,
invite_links_locator, pins_locator, verify_community_id,
};
use crate::edition::{
AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition,
build_edition, fold_head, parse_edition, vsk,
};
use crate::roles::{
AuthorityEdition, CommunityRoles, Grant, Permissions, Role, Roster, citation_ok, fold_roster,
AuthorityEdition, CommunityRoles, Grant, MAX_BANLIST, Permissions, Role, Roster, citation_ok,
fold_roster,
};
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32};
@@ -49,12 +50,32 @@ pub struct CommunityMetadata {
pub icon: Option<ImageRef>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub banner: Option<ImageRef>,
/// CORD-08's disappearing-messages timer, in seconds.
#[serde(
default,
deserialize_with = "timer_seconds",
skip_serializing_if = "Option::is_none"
)]
pub message_expiration: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub custom: Option<Extra>,
#[serde(flatten)]
pub extra: Extra,
}
/// CORD-08 §1: absent, `0` and malformed all mean off, and a reader must not
/// guess a default from garbage.
fn timer_seconds<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
let value = Option::<serde_json::Value>::deserialize(deserializer)?;
Ok(value
.and_then(|value| value.as_u64())
.filter(|seconds| *seconds > 0))
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct ChannelMetadata {
pub name: String,
@@ -249,6 +270,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if metadata.name.len() > MAX_NAME_BYTES {
bail!("channel name exceeds {MAX_NAME_BYTES} bytes");
}
let content = serde_json::to_string(metadata)?;
self.publish(
@@ -272,6 +297,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if role.name.len() > MAX_NAME_BYTES {
bail!("role name exceeds {MAX_NAME_BYTES} bytes");
}
let content = role.to_content()?;
self.publish(
@@ -320,6 +349,10 @@ impl ControlWriter {
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
if banned.len() > MAX_BANLIST {
bail!("banlist exceeds {MAX_BANLIST} entries");
}
let entries: Vec<String> = banned.iter().map(PublicKey::to_hex).collect();
let content = serde_json::to_string(&entries)?;
@@ -366,6 +399,32 @@ impl ControlWriter {
at_secs,
)
}
/// `content` is the whole Pin List, in whichever of CORD-04 §7's two
/// self-describing forms the Channel's folded type calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
&self,
keys: &Keys,
community_id: &CommunityId,
channel: &ChannelId,
content: &str,
head: Option<&EntityHead>,
citation: Option<AuthorityCitation>,
at_secs: u64,
) -> Result<(Event, EntityHead)> {
self.publish(
keys,
Edition {
subkind: vsk::PINS,
entity: pins_locator(community_id, channel),
content,
head,
citation,
},
at_secs,
)
}
}
fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
@@ -383,6 +442,7 @@ fn encode_metadata(metadata: &CommunityMetadata) -> Result<String> {
let mut metadata = metadata.clone();
metadata.relays.truncate(MAX_RELAYS);
metadata.message_expiration = metadata.message_expiration.filter(|seconds| *seconds > 0);
Ok(serde_json::to_string(&metadata)?)
}
@@ -395,6 +455,9 @@ pub struct ControlFold {
pub channels: BTreeMap<ChannelId, ChannelMetadata>,
/// Each creator's live link-signer set.
pub registries: BTreeMap<PublicKey, Vec<PublicKey>>,
/// Head content per `pins_locator`: a Pin List is addressed by a one-way
/// coordinate, so a fold cannot name the Channel it belongs to.
pub pins: BTreeMap<[u8; 32], String>,
pub floors: Floors,
pub gapped: bool,
}
@@ -403,6 +466,12 @@ impl ControlFold {
pub fn is_public(&self) -> bool {
self.registries.values().any(|links| !links.is_empty())
}
pub fn pin_content(&self, community_id: &CommunityId, channel: &ChannelId) -> Option<&str> {
self.pins
.get(&pins_locator(community_id, channel))
.map(String::as_str)
}
}
pub fn fold_control(
@@ -429,6 +498,7 @@ pub fn fold_control(
community: metadata.community,
channels: metadata.channels,
registries: metadata.registries,
pins: metadata.pins,
floors,
gapped: roster.gapped || metadata.gapped,
}
@@ -439,6 +509,7 @@ struct MetadataFold {
community: Option<CommunityMetadata>,
channels: BTreeMap<ChannelId, ChannelMetadata>,
registries: BTreeMap<PublicKey, Vec<PublicKey>>,
pins: BTreeMap<[u8; 32], String>,
floors: Floors,
gapped: bool,
}
@@ -483,7 +554,14 @@ fn fold_metadata(
Permissions::MANAGE_METADATA,
&mut fold.gapped,
) {
fold.community = serde_json::from_str(&head.content).ok();
fold.community = serde_json::from_str::<CommunityMetadata>(&head.content)
.ok()
.map(|mut metadata| {
// Up to 5 relays is a recommendation, so a longer set is
// truncated rather than refused, on read as well as on write.
metadata.relays.truncate(MAX_RELAYS);
metadata
});
fold.floors.insert(head.entity, EntityHead::from(head));
}
@@ -507,10 +585,44 @@ fn fold_metadata(
}
fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold.pins = fold_pins(&judge, editions, &mut fold.floors, &mut fold.gapped);
fold
}
/// A Pin List's coordinate derives one-way, so unlike the banlist, a grant or a
/// registry there is nothing to check the `eid` against: an edition at an
/// unknown coordinate is simply never read. Its content is stored verbatim,
/// because a violating list still folds but reads as empty (CORD-04 §7).
fn fold_pins(
judge: &Judge<'_>,
editions: &[ParsedEdition],
floors: &mut Floors,
gapped: &mut bool,
) -> BTreeMap<[u8; 32], String> {
let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new();
for edition in editions {
if edition.subkind == vsk::PINS {
candidates.entry(edition.entity).or_default().push(edition);
}
}
let mut pins = BTreeMap::new();
for (entity, group) in &candidates {
let Some(head) = authorized_head(judge, *entity, group, Permissions::PIN_MESSAGES, gapped)
else {
continue;
};
floors.insert(*entity, EntityHead::from(head));
pins.insert(*entity, head.content.clone());
}
pins
}
fn fold_registries(
judge: &Judge<'_>,
editions: &[ParsedEdition],
@@ -630,11 +742,12 @@ mod tests {
use nostr_memory::MemoryDatabase;
use super::*;
use crate::derive::grant_locator;
use crate::chat::{self, build_message, seal_rumor};
use crate::derive::{channel_group_key, grant_locator};
use crate::edition::fold;
use crate::roles::{Grant, Role, RoleScope};
use crate::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope};
use crate::store::{CommunityState, load_state, save_state};
use crate::{Extra, RoleId};
use crate::{Extra, RoleId, pins};
const AT: u64 = 1_700_000_000;
@@ -976,4 +1089,180 @@ mod tests {
Some("coop by mod")
);
}
#[test]
fn a_pin_list_folds_under_its_coordinate_for_a_second_client() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let channel = minted.channel_id;
let group =
channel_group_key(&minted.community_root, &channel, ROOT_EPOCH).expect("derives");
let author = Keys::generate();
let rumor = build_message(
author.public_key(),
&channel,
ROOT_EPOCH,
"pin me",
None,
AT * 1_000,
None,
);
let (wrap, _) = seal_rumor(&rumor, &group, &author, false).expect("seals");
let opened = chat::open(&wrap, &group, &channel, ROOT_EPOCH)
.expect("opens")
.0;
let entry = pins::build_entry(&opened, &group, &channel).expect("pins");
let content = pins::publishable(
&pins::ReadPinList {
entries: vec![entry],
sealed: false,
},
false,
&group,
ROOT_EPOCH,
)
.expect("publishes");
let writer = ControlWriter {
author: owner_pk,
read: read.clone(),
signer: signer.clone(),
};
let (pin_wrap, _) = writer
.set_pin_list(
&owner,
&community_id,
&channel,
&content,
None,
None,
AT + 1,
)
.expect("publishes");
let mut editions = open_all(&minted.wraps, &read, &signer.pk());
editions.extend(open_all(&[pin_wrap], &read, &signer.pk()));
let folded = fold_control(
&owner_pk,
&community_id,
&editions,
&Floors::new(),
&BTreeSet::new(),
);
// The coordinate derives one-way, so the list is found by naming the Channel.
let found = pins::read_list(
folded
.pin_content(&community_id, &channel)
.expect("the list folds"),
|_| None,
);
assert_eq!(found.entries.len(), 1);
assert_eq!(
pins::verify_entry(&found.entries[0], &channel)
.expect("verifies")
.content,
"pin me"
);
let other = ChannelId::from_bytes([0x77; 32]);
assert!(folded.pin_content(&community_id, &other).is_none());
}
#[test]
fn the_timer_is_never_guessed_and_the_write_caps_hold() {
let owner = Keys::generate();
let minted = genesis(&owner, &metadata("coop"), AT).expect("mints");
let community_id = minted.identity.community_id;
let owner_pk = owner.public_key();
let (read, signer) = holder(&minted);
let writer = ControlWriter {
author: owner_pk,
read,
signer,
};
let fold = |metadata: &CommunityMetadata| {
fold_control(
&owner_pk,
&community_id,
&open_all(
&[writer
.set_community_metadata(&owner, &community_id, metadata, None, None, AT + 1)
.expect("publishes")
.0],
&writer.read,
&writer.signer.pk(),
),
&Floors::new(),
&BTreeSet::new(),
)
.community
.expect("folds")
};
let mut timed = metadata("coop");
timed.message_expiration = Some(2_592_000);
assert_eq!(fold(&timed).message_expiration, Some(2_592_000));
// Absent, zero and garbage all mean off, and garbage never poisons the rest.
assert_eq!(fold(&metadata("coop")).message_expiration, None);
let mut off = metadata("coop");
off.message_expiration = Some(0);
assert_eq!(fold(&off).message_expiration, None);
let garbage = serde_json::json!({
"name": "coop",
"message_expiration": "later",
})
.to_string();
let folded: CommunityMetadata = serde_json::from_str(&garbage).expect("parses");
assert_eq!(folded.name, "coop");
assert_eq!(folded.message_expiration, None);
// The caps the folds apply also hold on the way out.
let banned: BTreeSet<PublicKey> = (0..=MAX_BANLIST)
.map(|_| Keys::generate().public_key())
.collect();
assert!(
writer
.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)
.is_err()
);
let grant = Grant {
member: owner_pk,
role_ids: (0..=MAX_ROLES_PER_MEMBER)
.map(|index| RoleId::from_bytes([index as u8; 32]))
.collect(),
control_wrap: None,
extra: Extra::default(),
};
assert!(grant.to_content().is_err());
assert!(
writer
.set_channel_metadata(
&owner,
&minted.channel_id,
&ChannelMetadata {
name: "x".repeat(MAX_NAME_BYTES + 1),
private: false,
..ChannelMetadata::default()
},
None,
None,
AT + 3,
)
.is_err()
);
}
}
+7 -2
View File
@@ -5,6 +5,7 @@ pub mod edition;
pub mod guestbook;
pub mod invite;
pub mod list;
pub mod pins;
pub mod rekey;
pub mod roles;
pub mod store;
@@ -125,14 +126,18 @@ impl fmt::Display for Epoch {
/// 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; 32] = bytes
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))?;
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
+825
View File
@@ -0,0 +1,825 @@
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 fmt::Debug for MessageKeys {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("MessageKeys(<disclosed>)")
}
}
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 for a path that decrypts no wrap: without
// this, a private Channel's keyholder could pin its messages into a public
// list, disclosing them community-wide with proof.
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
// Every reader recomputes the identity; 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;
// Nobody else may revise another member's words, and this is checkable
// before any crypto.
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());
assert_eq!(
MessageKeys::from_hex(&disclosure.to_hex()),
Some(disclosure)
);
assert!(MessageKeys::from_hex(&disclosure.to_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()
);
}
}
}
+4 -38
View File
@@ -21,12 +21,7 @@ use crate::stream::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamErr
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
pub const KIND_REKEY: u16 = 3303;
/// The send cap. A rekey rides the CORD-01 double envelope, so each blob costs two
/// NIP-44 base64 expansions: 120 blobs measure ~77 KB and a 64 KB relay refuses
/// them, while 80 measure ~55 KB. CORD-06 states 120 — an erratum this reproduces.
pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80;
/// The accept cap stays at the spec's 120, above the send cap, so a chunk minted by
/// another client at the spec limit still parses.
pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120;
pub const MAX_REKEY_EPOCH: u64 = 1 << 40;
@@ -115,8 +110,7 @@ pub enum RekeyScope {
}
impl RekeyScope {
/// The all-zero sentinel addresses the base; a channel id is random, so it
/// never collides. The value is stamped inside every blob's ciphertext.
/// The all-zero sentinel addresses the base; a channel id is random.
pub fn id32(self) -> [u8; 32] {
match self {
RekeyScope::Channel(channel) => *channel.as_bytes(),
@@ -259,9 +253,6 @@ pub fn parse_blob_plaintext(
return Err(RekeyError::ControlPairMismatch);
}
// A width past 136 is a form this client predates. Refusing it would park
// the member at the old epoch, so the frozen prefix and the appended fields
// that still verify are kept and the rest freezes.
return Ok(KeyDelivery {
new_key,
control_pk: Some(control_pk),
@@ -276,9 +267,7 @@ pub fn parse_blob_plaintext(
})
}
/// The rekey plane's address for a scope. A standalone channel rotation rides the
/// current root; one forced by a removal rides the prior root beside the base
/// rotation, which is exactly what lets a base-fork loser still open it.
/// The rekey plane's address for a scope.
pub fn rekey_group(
scope: RekeyScope,
addressing_root: &[u8; 32],
@@ -324,9 +313,6 @@ pub fn build_blob(
})
}
/// The locator is public and authenticates nothing, so it is not gated here:
/// the pairwise decrypt, plus the scope and epoch bound inside the ciphertext,
/// are the whole gate.
pub fn open_blob(
recipient: &Keys,
rotator: &PublicKey,
@@ -342,8 +328,6 @@ pub fn open_blob(
parse_blob_plaintext(&plaintext, scope, epoch, community_id)
}
/// Every blob at my locator. Anyone can publish a blob at mine, since the locator
/// is public, so the caller tries each and adopts the first that opens.
pub fn find_my_blobs<'a>(
blobs: &'a [RekeyBlob],
rotator: &PublicKey,
@@ -352,7 +336,6 @@ pub fn find_my_blobs<'a>(
epoch: Epoch,
) -> impl Iterator<Item = &'a RekeyBlob> {
let wanted = blob_locator(rotator, me, scope, epoch);
blobs.iter().filter(move |blob| blob.locator == wanted)
}
@@ -362,7 +345,6 @@ fn seal_to(
plaintext: &[u8],
) -> Result<String, RekeyError> {
let conversation = ConversationKey::derive(secret, recipient).map_err(crypto_error)?;
Ok(stream::seal_bytes(&conversation, plaintext)?)
}
@@ -379,8 +361,7 @@ pub struct RekeyChunk {
pub severed: bool,
}
/// The key that groups the chunks of one rotation. Two rotators racing the same
/// epoch, or one rotator over two channels, never alias.
/// The key that groups the chunks of one rotation.
pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]);
impl RekeyChunk {
@@ -404,8 +385,6 @@ pub struct Rotation {
pub blobs: Vec<RekeyBlob>,
pub declared: u32,
pub held: BTreeSet<u32>,
/// OR across chunks: an extension minted without the marker must not launder
/// a severed rotation back into an ordinary one.
pub severed: bool,
pub citation: Option<AuthorityCitation>,
}
@@ -450,8 +429,6 @@ pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec<Rotation> {
rotation.severed |= chunk.severed;
rotation.held.insert(chunk.chunk.0);
// A union, never first-wins: two chunks can claim one index after a
// catch-up, and a recipient dropped from the union reads as removed.
for blob in &chunk.blobs {
if !rotation.blobs.iter().any(|held| held == blob) {
rotation.blobs.push(blob.clone());
@@ -502,9 +479,6 @@ fn continuity(
}
}
/// The winner among concurrent rotations at one continuity point: the lowest key,
/// adopted only when it strictly lowers a key already held. A settled epoch heals
/// down and never re-forks upward.
pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option<usize> {
let (index, winner) = candidates.iter().enumerate().min_by_key(|(_, key)| **key)?;
@@ -514,8 +488,6 @@ pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option<u
}
}
/// Holding a key is never authority, so a rotation is honored only from an actor
/// with the permission who strictly outranks every target it removes.
pub fn rekey_authorized(
roles: &CommunityRoles,
owner: &PublicKey,
@@ -554,9 +526,7 @@ pub fn plan_refounding(epoch: Epoch) -> Result<Refounding> {
})
}
/// Carries the settled heads across a refounding. The control plane is
/// plaintext-sealed precisely so this preserves the original authors' signatures
/// instead of re-signing a snapshot as the refounder.
/// Carries the settled heads across a refounding.
pub fn compact(
seals: &[Event],
read: &GroupKey,
@@ -742,10 +712,6 @@ pub struct DissolvedTombstone {
pub owner: PublicKey,
}
/// The `eid` commits the community, deliberately diverging from the all-zero
/// placeholder CORD-02 §9 shows: the dissolved address derives from the public
/// `community_id`, so a zero binding lets an owner's genuine tombstone for one of
/// their communities be re-wrapped at another and kill it.
pub fn dissolved_tombstone_rumor(
owner: PublicKey,
community_id: &CommunityId,
+5 -1
View File
@@ -1,6 +1,6 @@
use std::collections::{BTreeMap, BTreeSet, HashSet};
use anyhow::Result;
use anyhow::{Result, bail};
use nostr_sdk::prelude::PublicKey;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
@@ -127,6 +127,10 @@ impl Grant {
}
pub fn to_content(&self) -> Result<String> {
if self.role_ids.len() > MAX_ROLES_PER_MEMBER {
bail!("grant exceeds {MAX_ROLES_PER_MEMBER} roles");
}
Ok(serde_json::to_string(self)?)
}
}
+145 -5
View File
@@ -25,11 +25,18 @@ const WRAP_TAG: &str = "e";
const KIND_TAG: &str = "k";
const STATE_PREFIX: &str = "concord/";
/// CORD-08 §3: an already-expired rumor is refused at ingest, never stored.
/// Returns whether the rumor was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
opened: &OpenedStream,
) -> Result<()> {
) -> 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()]),
@@ -47,7 +54,42 @@ pub async fn cache_rumor(
database.save_event(&event).await?;
Ok(())
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(
@@ -300,8 +342,9 @@ pub async fn backfill(
let (fresh, next) = advance(&page, &planes, channel, cursor, limit, &mut seen);
for (opened, rumor) in fresh {
cache_rumor(database, channel, &opened).await?;
found.push(rumor);
if cache_rumor(database, channel, &opened).await? {
found.push(rumor);
}
}
match next {
@@ -420,7 +463,15 @@ mod tests {
("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);
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);
}
@@ -505,4 +556,93 @@ mod tests {
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")
}
}
+1
View File
@@ -185,6 +185,7 @@ pub fn seal_content(
}
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
check_plaintext_cap(plaintext.len())?;
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
}