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
+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))
));
}
}