This commit is contained in:
2026-09-17 11:13:13 +07:00
parent fd39be0eda
commit fe2d956d40
13 changed files with 507 additions and 1391 deletions
+10 -22
View File
@@ -44,8 +44,7 @@ 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.
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
@@ -73,16 +72,14 @@ impl From<StreamError> for ChatError {
}
}
/// A chat event another chat event refers to: a quote, a comment's parent, a
/// reaction's target. The author slot is a SHOULD on the wire, so it is optional.
/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReplyRef {
pub id: EventId,
pub author: Option<PublicKey>,
}
/// A reference that also names the referenced event's kind, which a comment
/// (`K`/`k`) and a reaction (`k`) must commit on the wire.
/// A reference that also names the referenced event's kind, which `K`/`k` must commit on the wire.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Target {
pub reply: ReplyRef,
@@ -166,8 +163,7 @@ pub fn build_message(
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.
/// `parent` is the immediate parent; a `None` root means the parent is the thread's root.
#[allow(clippy::too_many_arguments)]
pub fn build_comment(
author: PublicKey,
@@ -238,8 +234,7 @@ pub fn build_edit(
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.
/// CORD-08 §4: informational, gated by the roster rather than by the fold.
pub fn build_timer_notice(
author: PublicKey,
channel: &ChannelId,
@@ -253,8 +248,7 @@ pub fn build_timer_notice(
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.
/// Derived from the signed `created_at`, so a later metadata edit never reaches back.
fn expiration_tag(at_ms: u64, timer: Option<u64>) -> Option<Tag> {
timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()]))
}
@@ -297,8 +291,7 @@ pub fn build_typing(
)
}
/// Seals a chat rumor and wraps it at the channel's address. `ephemeral` picks
/// the 21059 wrap, which relays must not store.
/// `ephemeral` picks the 21059 wrap, which relays must not store.
pub fn seal_rumor(
rumor: &UnsignedEvent,
group: &GroupKey,
@@ -318,8 +311,7 @@ pub fn seal_rumor(
KIND_WRAP
};
// CORD-08 §2: a NIP-40 expiration rides the wrap as well, so relays drop the
// stored event on schedule; the inner copy is what drives a local purge.
// The wrap's copy is for relays; the inner one drives the local purge.
let expiration: Vec<Tag> = rumor
.tags
.iter()
@@ -336,9 +328,7 @@ pub fn seal_rumor(
)?)
}
/// Opens a wrap against the plane whose key is tried. The channel and epoch the
/// rumor claims must both be the ones that opened it, so a keyholder of two
/// planes cannot re-seal a rumor elsewhere or replay it across an epoch.
/// The claimed channel and epoch must both be the ones that opened the wrap.
pub fn open(
wrap: &Event,
group: &GroupKey,
@@ -358,9 +348,7 @@ pub fn open(
Ok((opened, chat))
}
/// Every epoch's group key for one channel. `secret` is whatever feeds the
/// channel at that epoch: the `community_root` for a public one, its own key
/// for a private one.
/// `secret` is the `community_root` for a public channel, its own key for a private one.
pub fn plane_keys(
held: &[(Epoch, [u8; 32])],
channel: &ChannelId,
+7 -18
View File
@@ -63,8 +63,7 @@ pub struct CommunityMetadata {
pub extra: Extra,
}
/// CORD-08 §1: absent, `0` and malformed all mean off, and a reader must not
/// guess a default from garbage.
/// CORD-08 §1: absent, `0` and malformed all mean off.
fn timer_seconds<'de, D>(deserializer: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
@@ -193,7 +192,6 @@ pub fn open_edition(
Ok(parse_edition(&opened.rumor)?)
}
/// Appends editions to entity chains.
pub struct ControlWriter {
pub author: PublicKey,
pub read: GroupKey,
@@ -204,9 +202,7 @@ pub struct Edition<'a> {
pub subkind: &'a str,
pub entity: [u8; 32],
pub content: &'a str,
/// The head this edition supersedes.
///
/// `None` starts the chain.
/// The head this edition supersedes; `None` starts the chain.
pub head: Option<&'a EntityHead>,
pub citation: Option<AuthorityCitation>,
}
@@ -400,8 +396,7 @@ impl ControlWriter {
)
}
/// `content` is the whole Pin List, in whichever of CORD-04 §7's two
/// self-describing forms the Channel's folded type calls for.
/// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for.
#[allow(clippy::too_many_arguments)]
pub fn set_pin_list(
&self,
@@ -455,8 +450,7 @@ 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.
/// Head content per `pins_locator`; the coordinate is one-way, so a fold cannot name its Channel.
pub pins: BTreeMap<[u8; 32], String>,
pub floors: Floors,
pub gapped: bool,
@@ -533,8 +527,7 @@ fn fold_metadata(
for edition in editions {
match edition.subkind.as_str() {
// A channel addressed at the community's own coordinate would share, and
// corrupt, the metadata chain's floor.
// A channel at the community's own coordinate would corrupt the metadata chain's floor.
vsk::COMMUNITY_METADATA if edition.entity == community_entity => {
community.push(edition)
}
@@ -557,8 +550,7 @@ fn fold_metadata(
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.
// Up to 5 relays is a recommendation, so a longer set is truncated, not refused.
metadata.relays.truncate(MAX_RELAYS);
metadata
});
@@ -590,10 +582,7 @@ fn fold_metadata(
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).
/// A one-way coordinate leaves the `eid` unchecked; violating content reads as empty.
fn fold_pins(
judge: &Judge<'_>,
editions: &[ParsedEdition],
+5 -49
View File
@@ -1,6 +1,3 @@
use std::collections::HashMap;
use std::sync::{LazyLock, Mutex, PoisonError};
use anyhow::{Result, bail};
use hkdf::Hkdf;
use nostr::nips::nip44::v2::ConversationKey;
@@ -77,27 +74,11 @@ pub struct GroupKey {
impl GroupKey {
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
let key = memo_key(label, secret, id32, epoch);
if let Some(hit) = lock_memo().get(&key) {
return Ok(hit.clone());
}
let info = build_info(label, id32, epoch);
let secret_key = hkdf_to_secret_key(secret, &info)?;
let secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
let keys = Keys::new(secret_key);
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
let group_key = Self { keys, conversation };
let mut memo = lock_memo();
if memo.len() >= 1024 {
memo.clear();
}
memo.insert(key, group_key.clone());
Ok(group_key)
Ok(Self { keys, conversation })
}
pub fn pk(&self) -> PublicKey {
@@ -125,27 +106,6 @@ impl std::fmt::Debug for GroupKey {
}
}
static MEMO: LazyLock<Mutex<HashMap<[u8; 32], GroupKey>>> = LazyLock::new(Default::default);
fn lock_memo() -> std::sync::MutexGuard<'static, HashMap<[u8; 32], GroupKey>> {
MEMO.lock().unwrap_or_else(PoisonError::into_inner)
}
pub fn clear_memo() {
lock_memo().clear()
}
fn memo_key(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(label.as_bytes());
hasher.update([0x00]);
hasher.update(secret);
hasher.update(id32);
hasher.update(epoch.unwrap_or(u64::MAX).to_be_bytes());
hasher.update([epoch.is_some() as u8]);
hasher.finalize().into()
}
/// `secret` is the `community_root` for a public channel.
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
@@ -165,8 +125,7 @@ pub fn control_group_key(
)
}
/// The plane's address and wrap signer, held only by staff.
/// Wraps still encrypt under [`control_group_key`].
/// The plane's address and wrap signer, held only by staff; wraps still read under [`control_group_key`].
pub fn control_signer_group_key(
control_root: &[u8; 32],
community_id: &CommunityId,
@@ -180,9 +139,7 @@ pub fn control_signer_group_key(
)
}
/// Member-writable, unlike the Control Plane:
///
/// - A join or a leave is each member's own word.
/// Member-writable, unlike the Control Plane: a join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
@@ -196,8 +153,7 @@ pub fn guestbook_group_key(
)
}
/// Keyed by the prior `community_root` rather than the channel key,
/// so any retained member recovers any epoch's rekey without a ratchet.
/// Keyed by the prior `community_root`, so any retained member recovers any epoch's rekey.
pub fn channel_rekey_group_key(
prior_root: &[u8; 32],
channel: &ChannelId,
+4 -23
View File
@@ -1,3 +1,4 @@
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
@@ -55,8 +56,7 @@ impl fmt::Display for EditionError {
impl std::error::Error for EditionError {}
/// A `vac` citation: the Grant edition an actor claims rank under, pinned by
/// coordinate, version and hash. It is a sync floor, not the verdict.
/// A `vac`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
@@ -312,16 +312,7 @@ pub fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.reduce(|(best_index, best), (index, candidate)| {
let supersedes = candidate.version > best.version
|| (candidate.version == best.version && candidate.tiebreak_id < best.tiebreak_id);
if supersedes {
(index, candidate)
} else {
(best_index, best)
}
})
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
.map(|(index, _)| index)
}
@@ -554,18 +545,8 @@ mod tests {
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
);
// The golden vector only exercises the absent-prev encoding; pin the
// present-prev branch structurally so a swapped flag stays visible.
// The golden vector only exercises the absent-prev encoding, so pin the flag.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(
bytes.len(),
8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + 5
);
assert_eq!(&bytes[8..8 + EDITION_LABEL.len()], EDITION_LABEL);
assert_eq!(
bytes[8 + EDITION_LABEL.len() + 32..][..8],
1u64.to_be_bytes()
);
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}
+3 -11
View File
@@ -6,7 +6,6 @@ use data_encoding::BASE64URL_NOPAD;
use nostr::nips::nip01::Coordinate;
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
use nostr::nips::nip44::v2::ConversationKey;
use nostr::nips::nip44::{self, Version};
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
@@ -595,13 +594,7 @@ pub fn build_invite_list(keys: &Keys, list: &InviteList) -> Result<Event, Invite
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
let content = stream::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize(keys)
@@ -613,10 +606,9 @@ pub fn parse_invite_list(keys: &Keys, event: &Event) -> Result<InviteList, Invit
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
let json = stream::open_to_self(keys, &event.content)?;
serde_json::from_str(&json).map_err(json_error)
serde_json::from_slice(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
+1 -14
View File
@@ -86,8 +86,7 @@ macro_rules! hex_id {
}
hex_id! {
/// A self-certifying commitment to the owner's key, carried inside invites and
/// never on the wire.
/// A self-certifying commitment to the owner's key, never on the wire.
CommunityId
}
@@ -106,18 +105,6 @@ hex_id! {
)]
pub struct Epoch(pub u64);
impl From<u64> for Epoch {
fn from(value: u64) -> Self {
Self(value)
}
}
impl From<Epoch> for u64 {
fn from(value: Epoch) -> Self {
value.0
}
}
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
+10 -12
View File
@@ -2,12 +2,11 @@ use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use nostr::nips::nip44::{self, Version};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::invite::{ChannelGrant, CommunityInvite};
use crate::stream::NIP44_MAX_PLAINTEXT;
use crate::stream::{self, NIP44_MAX_PLAINTEXT};
use crate::{CommunityId, Epoch, Extra};
pub const KIND_COMMUNITY_LIST: u16 = 13302;
@@ -43,6 +42,12 @@ impl fmt::Display for ListError {
impl std::error::Error for ListError {}
impl From<stream::StreamError> for ListError {
fn from(error: stream::StreamError) -> Self {
ListError::Crypto(error.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct JoinMaterial {
pub community_id: CommunityId,
@@ -182,13 +187,7 @@ pub fn build_list_event(keys: &Keys, list: &CommunityList) -> Result<Event, List
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = nip44::encrypt(
keys.secret_key(),
&keys.public_key(),
json.as_bytes(),
Version::V2,
)
.map_err(crypto_error)?;
let content = stream::seal_to_self(keys, json.as_bytes())?;
EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content)
.finalize(keys)
@@ -200,10 +199,9 @@ pub fn parse_list_event(keys: &Keys, event: &Event) -> Result<CommunityList, Lis
return Err(ListError::Kind(event.kind.as_u16()));
}
let json = nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content)
.map_err(crypto_error)?;
let json = stream::open_to_self(keys, &event.content)?;
serde_json::from_str(&json).map_err(json_error)
serde_json::from_slice(&json).map_err(json_error)
}
#[derive(Clone, Copy, PartialEq, Eq)]
+7 -15
View File
@@ -67,12 +67,6 @@ pub struct MessageKeys {
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];
@@ -340,16 +334,14 @@ pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin
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.
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
// Every reader recomputes the identity; a claimed `id` is never trusted.
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
rumor.verify_id().ok()?;
let rumor_id = rumor.compute_id();
@@ -383,8 +375,7 @@ fn verify_edit_bundle(
) -> Option<EditedContent> {
let seal = &bundle.seal;
// Nobody else may revise another member's words, and this is checkable
// before any crypto.
// Checkable before any crypto: nobody else may revise another member's words.
if seal.kind.as_u16() != stream::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
return None;
}
@@ -632,11 +623,12 @@ mod tests {
.expect("encrypts");
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
let hex = disclosure.to_hex();
assert_eq!(
MessageKeys::from_hex(&disclosure.to_hex()),
Some(disclosure)
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
Some(hex.clone())
);
assert!(MessageKeys::from_hex(&disclosure.to_hex().to_uppercase()).is_none());
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
}
#[test]
+3 -1
View File
@@ -224,7 +224,9 @@ pub fn parse_blob_plaintext(
});
}
if width != MEMBER_BASE_BLOB_LEN && width != STAFF_BASE_BLOB_LEN && width < STAFF_BASE_BLOB_LEN
// Between the frozen forms is malformed; wider is a future form, kept below.
if (CHANNEL_BLOB_LEN + 1..MEMBER_BASE_BLOB_LEN).contains(&width)
|| (MEMBER_BASE_BLOB_LEN + 1..STAFF_BASE_BLOB_LEN).contains(&width)
{
return Err(RekeyError::BadBaseBlobWidth(width));
}
+1 -2
View File
@@ -25,8 +25,7 @@ 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.
/// An already-expired rumor is refused at ingest. Returns whether it was kept.
pub async fn cache_rumor(
database: &dyn NostrDatabase,
channel: &ChannelId,
+18 -2
View File
@@ -107,8 +107,7 @@ pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
/// Build a rumor carrying a full epoch-ms time: `created_at`
/// holds the seconds and an `["ms", 0..=999]` tag the remainder.
/// Build a rumor carrying a full epoch-ms time: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
pub fn build_rumor_ms(
kind: u16,
author: PublicKey,
@@ -198,6 +197,23 @@ pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub fn seal_to_self(keys: &Keys, plaintext: &[u8]) -> Result<String, StreamError> {
seal_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Encrypt(error.to_string()))?,
plaintext,
)
}
pub fn open_to_self(keys: &Keys, content: &str) -> Result<Vec<u8>, StreamError> {
open_bytes(
&ConversationKey::derive(keys.secret_key(), &keys.public_key())
.map_err(|error| StreamError::Decrypt(error.to_string()))?,
content,
)
}
pub fn build_seal(
rumor: &UnsignedEvent,
form: SealForm,