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