update concord backend
This commit is contained in:
@@ -12,9 +12,10 @@ hkdf.workspace = true
|
||||
sha2.workspace = true
|
||||
data-encoding.workspace = true
|
||||
rand.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
nostr-memory.workspace = true
|
||||
serde_json.workspace = true
|
||||
smol.workspace = true
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
use anyhow::{Result, bail};
|
||||
use nostr_sdk::prelude::{Event, Keys, PublicKey, Timestamp, UnsignedEvent};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::derive::{
|
||||
community_id_of, control_group_key, control_signer_group_key, verify_community_id,
|
||||
};
|
||||
use crate::edition::{EditionFields, ParsedEdition, build_edition, parse_edition, vsk};
|
||||
use crate::stream::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with};
|
||||
use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32};
|
||||
|
||||
pub const MAX_NAME_BYTES: usize = 64;
|
||||
pub const MAX_DESCRIPTION_BYTES: usize = 10_000;
|
||||
pub const MAX_RELAYS: usize = 5;
|
||||
|
||||
pub const GENERAL_CHANNEL: &str = "general";
|
||||
pub const ROOT_EPOCH: Epoch = Epoch(0);
|
||||
|
||||
/// The first edition every entity starts at. Genuinely 1, not 0: a version of
|
||||
/// 0 is what the fold treats as a gap.
|
||||
const GENESIS_VERSION: u64 = 1;
|
||||
|
||||
type Extra = Map<String, Value>;
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ImageRef {
|
||||
pub url: String,
|
||||
pub key: String,
|
||||
pub nonce: String,
|
||||
pub hash: String,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityMetadata {
|
||||
pub name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub relays: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub banner: Option<ImageRef>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ChannelMetadata {
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub voice: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub deleted: Option<bool>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub custom: Option<Extra>,
|
||||
#[serde(flatten)]
|
||||
pub extra: Extra,
|
||||
}
|
||||
|
||||
/// A community's permanent identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommunityIdentity {
|
||||
pub community_id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
}
|
||||
|
||||
impl CommunityIdentity {
|
||||
pub fn verify(&self) -> bool {
|
||||
verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt)
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything a creation mints:
|
||||
///
|
||||
/// - Identity
|
||||
/// - Roots an invite will carry
|
||||
/// - First channel
|
||||
/// - Two genesis wraps to publish
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CommunityGenesis {
|
||||
pub identity: CommunityIdentity,
|
||||
pub community_root: [u8; 32],
|
||||
pub control_root: [u8; 32],
|
||||
pub channel_id: ChannelId,
|
||||
pub wraps: Vec<Event>,
|
||||
}
|
||||
|
||||
/// Mints a community and signs its two genesis editions.
|
||||
pub fn genesis(
|
||||
owner: &Keys,
|
||||
metadata: &CommunityMetadata,
|
||||
at_secs: u64,
|
||||
) -> Result<CommunityGenesis> {
|
||||
let mut metadata = metadata.clone();
|
||||
|
||||
if metadata.name.len() > MAX_NAME_BYTES {
|
||||
bail!("community name exceeds {MAX_NAME_BYTES} bytes");
|
||||
}
|
||||
|
||||
if metadata
|
||||
.description
|
||||
.as_ref()
|
||||
.is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES)
|
||||
{
|
||||
bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes");
|
||||
}
|
||||
|
||||
metadata.relays.truncate(MAX_RELAYS);
|
||||
|
||||
let owner_salt = random_32()?;
|
||||
let identity = CommunityIdentity {
|
||||
community_id: community_id_of(&owner.public_key().to_bytes(), &owner_salt),
|
||||
owner: owner.public_key(),
|
||||
owner_salt,
|
||||
};
|
||||
|
||||
let community_root = random_32()?;
|
||||
let control_root = random_32()?;
|
||||
let channel_id = ChannelId::from_bytes(random_32()?);
|
||||
|
||||
let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?;
|
||||
|
||||
let metadata_content = serde_json::to_string(&metadata)?;
|
||||
let channel_content = serde_json::to_string(&ChannelMetadata {
|
||||
name: GENERAL_CHANNEL.to_owned(),
|
||||
private: false,
|
||||
..ChannelMetadata::default()
|
||||
})?;
|
||||
|
||||
let editions = [
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::COMMUNITY_METADATA,
|
||||
entity: *identity.community_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &metadata_content,
|
||||
at_secs,
|
||||
}),
|
||||
build_edition(EditionFields {
|
||||
author: identity.owner,
|
||||
subkind: vsk::CHANNEL_METADATA,
|
||||
entity: *channel_id.as_bytes(),
|
||||
version: GENESIS_VERSION,
|
||||
prev: None,
|
||||
citation: None,
|
||||
content: &channel_content,
|
||||
at_secs,
|
||||
}),
|
||||
];
|
||||
|
||||
let mut wraps = Vec::with_capacity(editions.len());
|
||||
|
||||
for edition in &editions {
|
||||
wraps.push(seal_edition(edition, owner, &read, &signer, at_secs)?);
|
||||
}
|
||||
|
||||
Ok(CommunityGenesis {
|
||||
identity,
|
||||
community_root,
|
||||
control_root,
|
||||
channel_id,
|
||||
wraps,
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens a Control Plane wrap from its reading key alone.
|
||||
pub fn open_edition(
|
||||
wrap: &Event,
|
||||
read: &GroupKey,
|
||||
address: &PublicKey,
|
||||
verify_wrap_signature: bool,
|
||||
) -> Result<ParsedEdition> {
|
||||
let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?;
|
||||
|
||||
if opened.seal_form != SealForm::Plaintext {
|
||||
bail!("control editions require a plaintext seal");
|
||||
}
|
||||
|
||||
Ok(parse_edition(&opened.rumor)?)
|
||||
}
|
||||
|
||||
fn seal_edition(
|
||||
edition: &UnsignedEvent,
|
||||
owner: &Keys,
|
||||
read: &GroupKey,
|
||||
signer: &GroupKey,
|
||||
at_secs: u64,
|
||||
) -> Result<Event> {
|
||||
let seal = build_seal(edition, SealForm::Plaintext, read, owner)?;
|
||||
|
||||
let (wrap, _) = wrap_seal_with(
|
||||
&seal,
|
||||
read.conversation(),
|
||||
signer.keys(),
|
||||
KIND_WRAP,
|
||||
Timestamp::from_secs(at_secs),
|
||||
&[],
|
||||
)?;
|
||||
|
||||
Ok(wrap)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
use super::*;
|
||||
use crate::edition::{EditionMeta, fold};
|
||||
use crate::store::{CommunityState, load_state, save_state};
|
||||
|
||||
#[test]
|
||||
fn genesis_reopens_for_a_second_holder() {
|
||||
let owner = Keys::generate();
|
||||
let metadata = CommunityMetadata {
|
||||
name: "coop".to_owned(),
|
||||
relays: vec!["wss://relay.example".to_owned()],
|
||||
..CommunityMetadata::default()
|
||||
};
|
||||
let at_secs = 1_700_000_000;
|
||||
|
||||
let minted = genesis(&owner, &metadata, at_secs).expect("mints");
|
||||
assert!(minted.identity.verify(), "identity is self-certifying");
|
||||
|
||||
// The second client holds only what an invite hands over: the roots,
|
||||
// the community id and the owner salt.
|
||||
let read = control_group_key(
|
||||
&minted.community_root,
|
||||
&minted.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("derives");
|
||||
let address = control_signer_group_key(
|
||||
&minted.control_root,
|
||||
&minted.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)
|
||||
.expect("derives")
|
||||
.pk();
|
||||
|
||||
let mut editions = Vec::new();
|
||||
for wrap in &minted.wraps {
|
||||
editions.push(open_edition(wrap, &read, &address, true).expect("opens"));
|
||||
}
|
||||
|
||||
assert_eq!(editions.len(), 2);
|
||||
|
||||
let community = &editions[0];
|
||||
assert_eq!(community.subkind, vsk::COMMUNITY_METADATA);
|
||||
assert_eq!(community.entity, *minted.identity.community_id.as_bytes());
|
||||
assert_eq!(community.author, owner.public_key());
|
||||
assert_eq!((community.version, community.prev), (1, None));
|
||||
assert_eq!(
|
||||
serde_json::from_str::<CommunityMetadata>(&community.content)
|
||||
.expect("parses")
|
||||
.name,
|
||||
"coop"
|
||||
);
|
||||
|
||||
let channel = &editions[1];
|
||||
assert_eq!(channel.subkind, vsk::CHANNEL_METADATA);
|
||||
assert_eq!(channel.entity, *minted.channel_id.as_bytes());
|
||||
|
||||
for edition in &editions {
|
||||
let folded = fold(&[EditionMeta::from(edition)], 0, None);
|
||||
assert_eq!(folded.head, Some(0));
|
||||
assert!(
|
||||
folded.anchored && !folded.gap,
|
||||
"genesis anchors at its floor"
|
||||
);
|
||||
}
|
||||
|
||||
let state =
|
||||
CommunityState::from_genesis(&minted, &editions, at_secs * 1_000).expect("projects");
|
||||
|
||||
smol::block_on(async {
|
||||
let database = MemoryDatabase::unbounded();
|
||||
save_state(&database, &state).await.expect("saves");
|
||||
let loaded = load_state(&database, &minted.identity.community_id)
|
||||
.await
|
||||
.expect("loads")
|
||||
.expect("present");
|
||||
|
||||
assert_eq!(loaded.community_root, minted.community_root);
|
||||
assert_eq!(loaded.control_root, Some(minted.control_root));
|
||||
assert_eq!(loaded.channels.len(), 1);
|
||||
assert_eq!(loaded.heads.len(), 2);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt;
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::decode_hex_32;
|
||||
use crate::stream::build_rumor_secs;
|
||||
|
||||
pub const KIND_CONTROL: u16 = 3308;
|
||||
|
||||
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
|
||||
|
||||
/// Entity types an edition can address (CORD-02 Appendix B).
|
||||
pub mod vsk {
|
||||
pub const COMMUNITY_METADATA: &str = "0";
|
||||
pub const ROLE: &str = "1";
|
||||
pub const CHANNEL_METADATA: &str = "2";
|
||||
pub const GRANT: &str = "3";
|
||||
pub const BANLIST: &str = "4";
|
||||
pub const INVITE_LIVE: &str = "6";
|
||||
pub const INVITE_LINKS: &str = "8";
|
||||
pub const INVITE_REVOKED: &str = "9";
|
||||
pub const DISSOLVED: &str = "10";
|
||||
pub const PINS: &str = "11";
|
||||
}
|
||||
|
||||
const TAG_SUBKIND: &str = "vsk";
|
||||
const TAG_ENTITY: &str = "eid";
|
||||
const TAG_VERSION: &str = "ev";
|
||||
const TAG_PREV: &str = "ep";
|
||||
const TAG_CITATION: &str = "vac";
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EditionError {
|
||||
BadKind(u16),
|
||||
BadField(&'static str),
|
||||
Duplicate(&'static str),
|
||||
Missing(&'static str),
|
||||
}
|
||||
|
||||
impl fmt::Display for EditionError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
|
||||
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
|
||||
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
|
||||
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct AuthorityCitation {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub hash: [u8; 32],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ParsedEdition {
|
||||
pub author: PublicKey,
|
||||
pub subkind: String,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: String,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
pub struct EditionFields<'a> {
|
||||
pub author: PublicKey,
|
||||
pub subkind: &'a str,
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub citation: Option<AuthorityCitation>,
|
||||
pub content: &'a str,
|
||||
pub at_secs: u64,
|
||||
}
|
||||
|
||||
fn signing_bytes(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> Vec<u8> {
|
||||
let mut bytes =
|
||||
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
|
||||
|
||||
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(EDITION_LABEL);
|
||||
bytes.extend_from_slice(entity);
|
||||
bytes.extend_from_slice(&version.to_be_bytes());
|
||||
|
||||
match prev {
|
||||
Some(prev) => {
|
||||
bytes.push(1);
|
||||
bytes.extend_from_slice(prev);
|
||||
}
|
||||
None => {
|
||||
bytes.push(0);
|
||||
bytes.extend_from_slice(&[0u8; 32]);
|
||||
}
|
||||
}
|
||||
|
||||
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
|
||||
bytes.extend_from_slice(content);
|
||||
bytes
|
||||
}
|
||||
|
||||
pub fn edition_hash(
|
||||
entity: &[u8; 32],
|
||||
version: u64,
|
||||
prev: Option<&[u8; 32]>,
|
||||
content: &[u8],
|
||||
) -> [u8; 32] {
|
||||
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
|
||||
}
|
||||
|
||||
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
|
||||
let mut tags = vec![
|
||||
Tag::custom(TAG_SUBKIND, [fields.subkind]),
|
||||
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
|
||||
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
|
||||
];
|
||||
|
||||
if let Some(prev) = fields.prev {
|
||||
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
|
||||
}
|
||||
|
||||
if let Some(citation) = fields.citation {
|
||||
tags.push(Tag::custom(
|
||||
TAG_CITATION,
|
||||
[
|
||||
HEXLOWER.encode(&citation.entity),
|
||||
citation.version.to_string(),
|
||||
HEXLOWER.encode(&citation.hash),
|
||||
],
|
||||
));
|
||||
}
|
||||
|
||||
build_rumor_secs(
|
||||
KIND_CONTROL,
|
||||
fields.author,
|
||||
fields.content,
|
||||
tags,
|
||||
fields.at_secs,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
|
||||
let kind = rumor.kind.as_u16();
|
||||
|
||||
if kind != KIND_CONTROL {
|
||||
return Err(EditionError::BadKind(kind));
|
||||
}
|
||||
|
||||
let subkind = value(rumor, TAG_SUBKIND)?
|
||||
.ok_or(EditionError::Missing(TAG_SUBKIND))?
|
||||
.to_owned();
|
||||
|
||||
if canonical_decimal(&subkind).is_none() {
|
||||
return Err(EditionError::BadField(TAG_SUBKIND));
|
||||
}
|
||||
|
||||
let entity = hex32(
|
||||
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
|
||||
TAG_ENTITY,
|
||||
)?;
|
||||
|
||||
let version =
|
||||
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
|
||||
.ok_or(EditionError::BadField(TAG_VERSION))?;
|
||||
|
||||
let prev = match value(rumor, TAG_PREV)? {
|
||||
Some(raw) => Some(hex32(raw, TAG_PREV)?),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let citation = match fields(rumor, TAG_CITATION)? {
|
||||
Some(fields) if fields.len() == 4 => Some(AuthorityCitation {
|
||||
entity: hex32(&fields[1], TAG_CITATION)?,
|
||||
version: canonical_decimal(&fields[2]).ok_or(EditionError::BadField(TAG_CITATION))?,
|
||||
hash: hex32(&fields[3], TAG_CITATION)?,
|
||||
}),
|
||||
Some(_) => return Err(EditionError::BadField(TAG_CITATION)),
|
||||
None => None,
|
||||
};
|
||||
|
||||
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
|
||||
|
||||
Ok(ParsedEdition {
|
||||
author: rumor.pubkey,
|
||||
subkind,
|
||||
entity,
|
||||
version,
|
||||
prev,
|
||||
citation,
|
||||
content: rumor.content.clone(),
|
||||
self_hash,
|
||||
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct EditionMeta {
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub prev: Option<[u8; 32]>,
|
||||
pub tiebreak_id: EventId,
|
||||
}
|
||||
|
||||
impl From<&ParsedEdition> for EditionMeta {
|
||||
fn from(edition: &ParsedEdition) -> Self {
|
||||
Self {
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
prev: edition.prev,
|
||||
tiebreak_id: edition.rumor_id,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub struct FoldResult {
|
||||
pub head: Option<usize>,
|
||||
pub gap: bool,
|
||||
pub anchored: bool,
|
||||
}
|
||||
|
||||
/// The highest version whose chain is intact, given a held floor.
|
||||
pub fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
|
||||
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
|
||||
|
||||
for (index, edition) in editions.iter().enumerate() {
|
||||
if edition.version < floor {
|
||||
continue;
|
||||
}
|
||||
|
||||
match by_version.get(&edition.version) {
|
||||
Some(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
||||
_ => {
|
||||
by_version.insert(edition.version, index);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
||||
return FoldResult::default();
|
||||
};
|
||||
|
||||
let lowest = editions[lowest_index];
|
||||
|
||||
let anchored = if floor == 0 {
|
||||
lowest_version == 1 && lowest.prev.is_none()
|
||||
} else if lowest_version == floor {
|
||||
floor_hash == Some(&lowest.self_hash)
|
||||
} else if lowest_version == floor + 1 {
|
||||
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let mut head = Some(lowest_index);
|
||||
let mut gap = !anchored;
|
||||
let mut previous_version = lowest_version;
|
||||
let mut previous_hash = lowest.self_hash;
|
||||
|
||||
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
||||
let edition = editions[index];
|
||||
|
||||
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
||||
head = Some(index);
|
||||
previous_version = version;
|
||||
previous_hash = edition.self_hash;
|
||||
} else {
|
||||
gap = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
FoldResult {
|
||||
head,
|
||||
gap,
|
||||
anchored,
|
||||
}
|
||||
}
|
||||
|
||||
/// The highest version overall, ignoring contiguity.
|
||||
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)
|
||||
}
|
||||
})
|
||||
.map(|(index, _)| index)
|
||||
}
|
||||
|
||||
fn canonical_decimal(raw: &str) -> Option<u64> {
|
||||
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
|
||||
return None;
|
||||
}
|
||||
|
||||
if raw.len() > 1 && raw.starts_with('0') {
|
||||
return None;
|
||||
}
|
||||
|
||||
raw.parse().ok()
|
||||
}
|
||||
|
||||
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
|
||||
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
|
||||
}
|
||||
|
||||
fn fields<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a [String]>, EditionError> {
|
||||
let mut found: Option<&[String]> = None;
|
||||
|
||||
for tag in rumor.tags.iter() {
|
||||
let tag_fields = tag.as_slice();
|
||||
|
||||
if tag_fields.first().map(String::as_str) != Some(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if found.is_some() {
|
||||
return Err(EditionError::Duplicate(name));
|
||||
}
|
||||
|
||||
found = Some(tag_fields);
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn value<'a>(
|
||||
rumor: &'a UnsignedEvent,
|
||||
name: &'static str,
|
||||
) -> Result<Option<&'a str>, EditionError> {
|
||||
match fields(rumor, name)? {
|
||||
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
|
||||
Some(_) => Err(EditionError::BadField(name)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn edition_hash_matches_the_cross_client_vector() {
|
||||
let entity = [0x11u8; 32];
|
||||
|
||||
assert_eq!(
|
||||
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
|
||||
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
|
||||
);
|
||||
|
||||
// The golden vector only exercises the absent-prev encoding; pin the
|
||||
// present-prev branch structurally so a swapped flag stays visible.
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
pub mod control;
|
||||
pub mod derive;
|
||||
pub mod edition;
|
||||
pub mod store;
|
||||
pub mod stream;
|
||||
|
||||
@@ -8,6 +10,9 @@ use std::str::FromStr;
|
||||
use anyhow::{Result, anyhow, bail};
|
||||
use data_encoding::HEXLOWER;
|
||||
pub use derive::GroupKey;
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
macro_rules! hex_id {
|
||||
($(#[$meta:meta])* $name:ident) => {
|
||||
@@ -54,6 +59,19 @@ macro_rules! hex_id {
|
||||
Ok(Self(decode_hex_32(value)?))
|
||||
}
|
||||
}
|
||||
|
||||
impl Serialize for $name {
|
||||
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||
serializer.serialize_str(&self.to_hex())
|
||||
}
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for $name {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
|
||||
let value = String::deserialize(deserializer)?;
|
||||
value.parse().map_err(serde::de::Error::custom)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -72,7 +90,9 @@ hex_id! {
|
||||
/// A key-rotation counter attached to each Community key.
|
||||
///
|
||||
/// It bumps only on a Rekey, a membership change where somebody is removed.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default)]
|
||||
#[derive(
|
||||
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
|
||||
)]
|
||||
pub struct Epoch(pub u64);
|
||||
|
||||
impl From<u64> for Epoch {
|
||||
@@ -94,7 +114,7 @@ impl fmt::Display for Epoch {
|
||||
}
|
||||
|
||||
/// Uppercase and other non-canonical spellings are rejected.
|
||||
fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
pub(crate) fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.map_err(|error| anyhow!("invalid hex: {error}"))?;
|
||||
@@ -110,3 +130,15 @@ fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
|
||||
|
||||
Ok(decoded)
|
||||
}
|
||||
|
||||
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
|
||||
SysRng
|
||||
.try_fill_bytes(bytes)
|
||||
.map_err(|error| anyhow!("os rng: {error}"))
|
||||
}
|
||||
|
||||
pub(crate) fn random_32() -> Result<[u8; 32]> {
|
||||
let mut bytes = [0u8; 32];
|
||||
fill_random(&mut bytes)?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
+146
-1
@@ -3,9 +3,13 @@ use std::sync::LazyLock;
|
||||
|
||||
use anyhow::{Result, anyhow};
|
||||
use nostr_sdk::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::ChannelId;
|
||||
use crate::control::{ChannelMetadata, CommunityGenesis, CommunityMetadata, ROOT_EPOCH};
|
||||
use crate::derive::control_signer_group_key;
|
||||
use crate::edition::{ParsedEdition, vsk};
|
||||
use crate::stream::OpenedStream;
|
||||
use crate::{ChannelId, CommunityId, Epoch};
|
||||
|
||||
static LOCAL_KEYS: LazyLock<Keys> = LazyLock::new(Keys::generate);
|
||||
|
||||
@@ -14,6 +18,7 @@ const MARK_TAG: SingleLetterTag = SingleLetterTag::LOWERCASE_T;
|
||||
const MARK_VALUE: &str = "concord";
|
||||
const WRAP_TAG: &str = "e";
|
||||
const KIND_TAG: &str = "k";
|
||||
const STATE_PREFIX: &str = "concord/";
|
||||
|
||||
pub async fn cache_rumor(
|
||||
database: &dyn NostrDatabase,
|
||||
@@ -84,6 +89,146 @@ pub async fn query_rumors(
|
||||
Ok(rumors)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct EntityHead {
|
||||
pub entity: [u8; 32],
|
||||
pub version: u64,
|
||||
pub self_hash: [u8; 32],
|
||||
pub rumor_id: EventId,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ChannelKeyRef {
|
||||
pub id: ChannelId,
|
||||
pub name: String,
|
||||
pub private: bool,
|
||||
pub epoch: Epoch,
|
||||
}
|
||||
|
||||
/// One local document per community, keyed by `concord/<community_id>`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommunityState {
|
||||
pub id: CommunityId,
|
||||
pub owner: PublicKey,
|
||||
pub owner_salt: [u8; 32],
|
||||
pub community_root: [u8; 32],
|
||||
pub root_epoch: Epoch,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub control_root: Option<[u8; 32]>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub control_pks: BTreeMap<u64, PublicKey>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub channels: Vec<ChannelKeyRef>,
|
||||
pub relays: Vec<RelayUrl>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub heads: Vec<EntityHead>,
|
||||
pub added_at_ms: u64,
|
||||
}
|
||||
|
||||
impl CommunityState {
|
||||
pub fn from_genesis(
|
||||
genesis: &CommunityGenesis,
|
||||
editions: &[ParsedEdition],
|
||||
added_at_ms: u64,
|
||||
) -> Result<Self> {
|
||||
let mut channels = Vec::new();
|
||||
let mut heads = Vec::with_capacity(editions.len());
|
||||
let mut relays = Vec::new();
|
||||
|
||||
for edition in editions {
|
||||
heads.push(EntityHead {
|
||||
entity: edition.entity,
|
||||
version: edition.version,
|
||||
self_hash: edition.self_hash,
|
||||
rumor_id: edition.rumor_id,
|
||||
});
|
||||
|
||||
match edition.subkind.as_str() {
|
||||
vsk::COMMUNITY_METADATA => {
|
||||
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
|
||||
relays.extend(
|
||||
metadata
|
||||
.relays
|
||||
.iter()
|
||||
.filter_map(|relay| RelayUrl::parse(relay).ok()),
|
||||
);
|
||||
}
|
||||
vsk::CHANNEL_METADATA => {
|
||||
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
|
||||
channels.push(ChannelKeyRef {
|
||||
id: ChannelId::from_bytes(edition.entity),
|
||||
name: metadata.name,
|
||||
private: metadata.private,
|
||||
epoch: ROOT_EPOCH,
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let control_pks = BTreeMap::from([(
|
||||
ROOT_EPOCH.0,
|
||||
control_signer_group_key(
|
||||
&genesis.control_root,
|
||||
&genesis.identity.community_id,
|
||||
ROOT_EPOCH,
|
||||
)?
|
||||
.pk(),
|
||||
)]);
|
||||
|
||||
Ok(Self {
|
||||
id: genesis.identity.community_id,
|
||||
owner: genesis.identity.owner,
|
||||
owner_salt: genesis.identity.owner_salt,
|
||||
community_root: genesis.community_root,
|
||||
root_epoch: ROOT_EPOCH,
|
||||
control_root: Some(genesis.control_root),
|
||||
control_pks,
|
||||
channels,
|
||||
relays,
|
||||
heads,
|
||||
added_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> String {
|
||||
state_identifier(&self.id)
|
||||
}
|
||||
}
|
||||
|
||||
fn state_identifier(id: &CommunityId) -> String {
|
||||
format!("{STATE_PREFIX}{}", id.to_hex())
|
||||
}
|
||||
|
||||
pub async fn save_state<D>(database: &D, state: &CommunityState) -> Result<()>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let event = EventBuilder::new(Kind::ApplicationSpecificData, serde_json::to_string(state)?)
|
||||
.tags([Tag::identifier(state.identifier())])
|
||||
.finalize_async(&*LOCAL_KEYS)
|
||||
.await?;
|
||||
|
||||
database.save_event(&event).await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn load_state<D>(database: &D, id: &CommunityId) -> Result<Option<CommunityState>>
|
||||
where
|
||||
D: NostrDatabase,
|
||||
{
|
||||
let filter = Filter::new()
|
||||
.kind(Kind::ApplicationSpecificData)
|
||||
.identifier(state_identifier(id))
|
||||
.limit(1);
|
||||
|
||||
match database.query(filter).await?.into_iter().next() {
|
||||
Some(event) => Ok(Some(serde_json::from_str(&event.content)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nostr_memory::MemoryDatabase;
|
||||
|
||||
@@ -6,8 +6,6 @@ use nostr_sdk::prelude::{
|
||||
Event, EventBuilder, EventId, FinalizeEvent, Keys, Kind, PublicKey, Tag, Timestamp,
|
||||
UnsignedEvent,
|
||||
};
|
||||
use rand::TryRng as _;
|
||||
use rand::rngs::SysRng;
|
||||
|
||||
use crate::derive::GroupKey;
|
||||
use crate::{ChannelId, Epoch};
|
||||
@@ -206,6 +204,25 @@ pub fn wrap_seal(
|
||||
wrap_kind: u16,
|
||||
at: Timestamp,
|
||||
extra: &[Tag],
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
wrap_seal_with(
|
||||
seal,
|
||||
group.conversation(),
|
||||
group.keys(),
|
||||
wrap_kind,
|
||||
at,
|
||||
extra,
|
||||
)
|
||||
}
|
||||
|
||||
/// Signs with `signer` while encrypting under `conversation`.
|
||||
pub fn wrap_seal_with(
|
||||
seal: &Event,
|
||||
conversation: &ConversationKey,
|
||||
signer: &Keys,
|
||||
wrap_kind: u16,
|
||||
at: Timestamp,
|
||||
extra: &[Tag],
|
||||
) -> Result<(Event, Keys), StreamError> {
|
||||
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
|
||||
return Err(StreamError::BadWrapKind(wrap_kind));
|
||||
@@ -214,7 +231,7 @@ pub fn wrap_seal(
|
||||
let json = seal.as_json();
|
||||
check_plaintext_cap(json.len())?;
|
||||
|
||||
let content = BASE64.encode(&encrypt(group.conversation(), json.as_bytes())?);
|
||||
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
|
||||
let ephemeral = Keys::generate();
|
||||
|
||||
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
|
||||
@@ -223,7 +240,7 @@ pub fn wrap_seal(
|
||||
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
|
||||
.tags(tags)
|
||||
.custom_created_at(at)
|
||||
.finalize(group.keys())
|
||||
.finalize(signer)
|
||||
.map_err(|error| StreamError::Sign(error.to_string()))?;
|
||||
|
||||
Ok((wrap, ephemeral))
|
||||
@@ -335,9 +352,7 @@ pub fn check_channel_binding(
|
||||
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
|
||||
let mut nonce = [0u8; 32];
|
||||
|
||||
SysRng
|
||||
.try_fill_bytes(&mut nonce)
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
|
||||
|
||||
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
|
||||
.map_err(|error| StreamError::Encrypt(error.to_string()))
|
||||
|
||||
Reference in New Issue
Block a user