add pins, disappearing messages and hardenin
This commit is contained in:
+253
-22
@@ -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(¬ice, &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))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user