diff --git a/crates/vector-core/src/concord/mod.rs b/crates/vector-core/src/concord/mod.rs new file mode 100644 index 00000000..5ab0a239 --- /dev/null +++ b/crates/vector-core/src/concord/mod.rs @@ -0,0 +1,13 @@ +//! Concord — Vector's serverless communities protocol. +//! +//! Versioned side by side: [`v1`] is the original in-house protocol (spec in +//! `docs/concord/`); v2 (the public CORD spec) will live beside it. The +//! communities surface reads from every version but new communities are only +//! created on the newest one. +//! +//! `vector_core::community` is a crate-root alias for [`v1`] so existing +//! consumers (src-tauri, vector-sdk, vector-agent, concord-cli) keep working +//! unchanged. + +pub mod v1; +pub mod v2; diff --git a/crates/vector-core/src/community/attachments.rs b/crates/vector-core/src/concord/v1/attachments.rs similarity index 100% rename from crates/vector-core/src/community/attachments.rs rename to crates/vector-core/src/concord/v1/attachments.rs diff --git a/crates/vector-core/src/community/cache.rs b/crates/vector-core/src/concord/v1/cache.rs similarity index 100% rename from crates/vector-core/src/community/cache.rs rename to crates/vector-core/src/concord/v1/cache.rs diff --git a/crates/vector-core/src/community/cipher.rs b/crates/vector-core/src/concord/v1/cipher.rs similarity index 100% rename from crates/vector-core/src/community/cipher.rs rename to crates/vector-core/src/concord/v1/cipher.rs diff --git a/crates/vector-core/src/db/community.rs b/crates/vector-core/src/concord/v1/db.rs similarity index 96% rename from crates/vector-core/src/db/community.rs rename to crates/vector-core/src/concord/v1/db.rs index 22568b39..2b4bf642 100644 --- a/crates/vector-core/src/db/community.rs +++ b/crates/vector-core/src/concord/v1/db.rs @@ -51,7 +51,7 @@ pub(crate) fn hex_id_to_32(hex: &str) -> Result<[u8; 32], String> { /// Persist a Community and all its channels (upsert). Secrets are stored as raw /// blobs in the account-scoped DB. pub fn save_community(community: &Community) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?; let community_id = community.id.to_hex(); @@ -153,7 +153,7 @@ pub fn save_community(community: &Community) -> Result<(), String> { /// key for a contested epoch over a previously-stored loser (the only legitimate same-coordinate /// overwrite; an epoch key is otherwise immutable). pub fn store_epoch_key(community_id: &str, scope_id: &str, epoch: u64, key: &[u8; 32]) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; store_epoch_key_tx(&conn, community_id, scope_id, epoch, key) } @@ -191,7 +191,7 @@ pub fn advance_channel_epoch( new_epoch: u64, new_key: &[u8; 32], ) -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let tx = conn.unchecked_transaction().map_err(|e| format!("advance channel epoch tx: {e}"))?; // Archive always (PK includes epoch → never clobbers another epoch's key). store_epoch_key_tx(&tx, community_id, channel_id, new_epoch, new_key)?; @@ -225,7 +225,7 @@ pub fn advance_channel_epoch( /// caught-up OLDER base epoch is archived (its control/base history stays decryptable) but never /// regresses the head. Returns whether the head advanced. pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: &[u8; 32]) -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let tx = conn.unchecked_transaction().map_err(|e| format!("advance server root tx: {e}"))?; // Archive always, under the all-zero server-root scope sentinel (PK includes epoch → never clobbers // another epoch's root). @@ -258,7 +258,7 @@ pub fn advance_server_root_epoch(community_id: &str, new_epoch: u64, new_root: & /// + swaps the head, but ONLY while we're still AT `epoch` (a later real rotation must win over a stale /// converge). Returns whether it switched. pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8; 32]) -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let tx = conn.unchecked_transaction().map_err(|e| format!("converge server root tx: {e}"))?; store_epoch_key_tx(&tx, community_id, crate::community::SERVER_ROOT_SCOPE_HEX, epoch, new_root)?; let enc = enc_key(new_root)?; @@ -278,7 +278,7 @@ pub fn converge_server_root_epoch(community_id: &str, epoch: u64, new_root: &[u8 /// addressed under the converged server root), replacing the one we minted in our own losing fork. Switches /// only while the channel is still AT `epoch`. Returns whether it switched. pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, new_key: &[u8; 32]) -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let tx = conn.unchecked_transaction().map_err(|e| format!("converge channel tx: {e}"))?; store_epoch_key_tx(&tx, community_id, channel_id, epoch, new_key)?; let enc = enc_key(new_key)?; @@ -297,7 +297,7 @@ pub fn converge_channel_epoch(community_id: &str, channel_id: &str, epoch: u64, /// returned epoch (`#z` OR-set) so cross-epoch history isn't stranded. Sorted in Rust (not SQL): /// epoch is a u64 stored as i64, so a SQL `ORDER BY` would mis-order epochs >= 2^63. pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT epoch, key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2") .map_err(|e| e.to_string())?; @@ -318,7 +318,7 @@ pub fn held_epoch_keys(community_id: &str, scope_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let blob: Option> = conn .query_row( "SELECT key FROM community_epoch_keys WHERE community_id = ?1 AND scope_id = ?2 AND epoch = ?3", @@ -334,7 +334,7 @@ pub fn held_epoch_key(community_id: &str, scope_id: &str, epoch: u64) -> Result< /// `created_at` is set on the first save and preserved across metadata re-saves, so it tracks /// the join moment. Used to sort a not-yet-active community by join time. `None` if unknown. pub fn community_created_at_ms(id: &CommunityId) -> Option { - let conn = super::get_db_connection_guard_static().ok()?; + let conn = crate::db::get_db_connection_guard_static().ok()?; conn.query_row( "SELECT created_at FROM communities WHERE community_id = ?1", params![id.to_hex()], @@ -348,7 +348,7 @@ pub fn community_created_at_ms(id: &CommunityId) -> Option { /// Load a Community and its channels by id. Returns `None` if not stored locally. pub fn load_community(id: &CommunityId) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let id_hex = id.to_hex(); let row = conn @@ -513,7 +513,7 @@ pub fn store_message_key( ephemeral: &Keys, relays: &[String], ) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let relays_json = serde_json::to_string(relays).map_err(|e| e.to_string())?; let sk_bytes = to_32(ephemeral.secret_key().as_secret_bytes())?; let enc_secret = enc_key(&sk_bytes)?; @@ -540,7 +540,7 @@ pub fn store_message_key( /// or already deleted). Peek-only so the key survives a failed deletion publish; the /// caller removes it with [`delete_message_key`] only after the publish succeeds. pub fn get_message_key(message_id: &str) -> Result)>, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let row = conn .query_row( "SELECT ephemeral_secret, outer_event_id, relays @@ -561,7 +561,7 @@ pub fn get_message_key(message_id: &str) -> Result Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "DELETE FROM community_message_keys WHERE message_id = ?1", params![message_id], @@ -583,7 +583,7 @@ pub fn take_message_key(message_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; conn.query_row( "SELECT community_id FROM community_channels WHERE channel_id = ?1", params![channel_id], @@ -596,7 +596,7 @@ pub fn community_id_for_channel(channel_id: &str) -> Result, Stri /// Whether a Community with this id is already stored locally (joined). Cheaper than /// `load_community` when only existence matters (e.g. inbound-invite dedup). pub fn community_exists(id: &CommunityId) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let found: Option = conn .query_row( "SELECT 1 FROM communities WHERE community_id = ?1", @@ -631,7 +631,7 @@ pub fn save_pending_invite( /// (#298). Newest-wins: a stale months-old park is the safe thing to shed. const MAX_PENDING_INVITES: usize = 100; - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let enc_bundle = enc_txt(bundle_json)?; let enc_inviter = enc_txt(inviter_npub)?; let changed = conn @@ -664,7 +664,7 @@ pub fn save_pending_invite( /// those communities (so the ingest-time `community_exists` guard saw nothing yet). Returns the /// count purged. pub fn purge_pending_invites_for_held_communities() -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let n = conn .execute( "DELETE FROM pending_community_invites @@ -677,7 +677,7 @@ pub fn purge_pending_invites_for_held_communities() -> Result { /// All parked invites, newest first. pub fn list_pending_invites() -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare( "SELECT community_id, bundle_json, inviter_npub, received_at @@ -705,7 +705,7 @@ pub fn list_pending_invites() -> Result, String> { /// owner/authority collision), so the row must survive a rejected accept — peek here, /// then [`delete_pending_invite`] only after the join succeeds. pub fn get_pending_invite(community_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let raw: Option = conn .query_row( "SELECT bundle_json FROM pending_community_invites WHERE community_id = ?1", @@ -719,7 +719,7 @@ pub fn get_pending_invite(community_id: &str) -> Result, String> /// Drop a parked invite without joining (the user declined). pub fn delete_pending_invite(community_id: &str) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "DELETE FROM pending_community_invites WHERE community_id = ?1", params![community_id], @@ -730,7 +730,7 @@ pub fn delete_pending_invite(community_id: &str) -> Result<(), String> { /// Whether an invite for this id is already parked (inbound dedup). pub fn pending_invite_exists(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let found: Option = conn .query_row( "SELECT 1 FROM pending_community_invites WHERE community_id = ?1", @@ -766,7 +766,7 @@ pub fn save_public_invite( expires_at: Option, label: Option<&str>, ) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // token + url are the link's secret; encrypted, the token PK becomes per-write-unique (random // nonce) so this is effectively an INSERT — fine, mints generate a fresh token each time. let enc_token = enc_txt(token)?; @@ -785,7 +785,7 @@ pub fn save_public_invite( /// All minted public-invite links for a Community, newest first. pub fn list_public_invites(community_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare( "SELECT token, community_id, url, expires_at, created_at, label @@ -824,7 +824,7 @@ pub fn list_public_invites(community_id: &str) -> Result /// Forget a minted public-invite token (after revoking it on relays). pub fn delete_public_invite(token: &str) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // Stored tokens are encrypted (random nonce), so an equality DELETE can't match — scan, // decrypt, and delete the row whose plaintext token matches (by rowid). Few rows, owner-only. let rows: Vec<(i64, String)> = { @@ -847,7 +847,7 @@ pub fn delete_public_invite(token: &str) -> Result<(), String> { /// All minted public-invite links across ALL communities (backfill source for the synced Invite List). pub fn list_all_public_invites() -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare( "SELECT token, community_id, url, expires_at, created_at, label @@ -886,7 +886,7 @@ pub fn upsert_public_invite( created_at: i64, label: Option<&str>, ) -> Result { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; let already = { let mut stmt = conn .prepare("SELECT token FROM community_public_invites WHERE community_id = ?1") @@ -932,7 +932,7 @@ pub fn delete_community_retain_keys(community_id: &str) -> Result<(), String> { } fn delete_community_inner(community_id: &str, retain_keys: bool) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // Atomic: a crash/error mid-delete must not orphan channel/invite rows under a // now-missing parent (community_id_for_channel would still resolve them). let tx = conn.unchecked_transaction().map_err(|e| format!("delete community tx: {e}"))?; @@ -993,7 +993,7 @@ pub fn community_member_activity(community_id: &str) -> Result = Vec::new(); for ch in &community.channels { - if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { + if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { chat_ints.push(cid); } } @@ -1011,7 +1011,7 @@ pub fn community_member_activity(community_id: &str) -> Result>().join(","); { @@ -1130,7 +1130,7 @@ pub fn community_invite_join_counts( }; let mut chat_ints: Vec = Vec::new(); for ch in &community.channels { - if let Ok(cid) = super::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { + if let Ok(cid) = crate::db::id_cache::get_chat_id_by_identifier(&ch.id.to_hex()) { chat_ints.push(cid); } } @@ -1138,7 +1138,7 @@ pub fn community_invite_join_counts( return Ok(HashMap::new()); } let sys = crate::stored_event::event_kind::APPLICATION_SPECIFIC; - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let placeholders = chat_ints.iter().map(|_| "?").collect::>().join(","); let sql = format!( "SELECT npub, tags FROM events \ @@ -1183,7 +1183,7 @@ pub fn community_invite_join_counts( /// so the stored banlist can never roll backwards. pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) -> Result<(), String> { let json = enc_txt(&serde_json::to_string(banned_hex).map_err(|e| e.to_string())?)?; - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET banlist = ?1, banlist_at = ?2 WHERE community_id = ?3", params![json, at, community_id], @@ -1195,7 +1195,7 @@ pub fn set_community_banlist(community_id: &str, banned_hex: &[String], at: i64) /// The `created_at` (secs) of the banlist edition currently stored, or 0 if none. The version /// floor the rollback guard compares against. pub fn get_community_banlist_at(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let at: Option = conn .query_row( "SELECT banlist_at FROM communities WHERE community_id = ?1", @@ -1217,7 +1217,7 @@ pub fn set_community_roles( at: i64, ) -> Result<(), String> { let json = enc_txt(&serde_json::to_string(roles).map_err(|e| e.to_string())?)?; - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET roles = ?1, roles_at = ?2 WHERE community_id = ?3", params![json, at, community_id], @@ -1230,7 +1230,7 @@ pub fn set_community_roles( pub fn get_community_roles( community_id: &str, ) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let json: Option = conn .query_row( "SELECT roles FROM communities WHERE community_id = ?1", @@ -1244,7 +1244,7 @@ pub fn get_community_roles( /// The `created_at` (secs) of the role-graph edition currently stored, or 0 if none. pub fn get_community_roles_at(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let at: Option = conn .query_row( "SELECT roles_at FROM communities WHERE community_id = ?1", @@ -1271,7 +1271,7 @@ pub fn set_edition_head_with_id(community_id: &str, entity_id: &str, version: u6 } fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: Option<&[u8; 32]>) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // MONOTONIC, EPOCH-PRIMARY: the head IS the refuse-downgrade floor. The recorded `epoch` is // the community's current server-root epoch (re-founding bumps it + resets versions to 1). A higher // epoch ALWAYS supersedes (so a re-founding's v1 lands over a held v21); within an epoch, version @@ -1302,7 +1302,7 @@ fn set_edition_head_inner(community_id: &str, entity_id: &str, version: u64, sel /// so it heals to a ranked id). The version-advance path is unchanged and still handled by /// [`set_edition_head_with_id`]; callers run BOTH (advance covers v+1, converge covers a same-v fork). pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, self_hash: &[u8; 32], inner_id: &[u8; 32]) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // Scoped to the CURRENT epoch's head: a fork is resolved within an epoch, never across one (an epoch // bump is a re-founding, handled by the advance path). `epoch` matches the community's current epoch. conn.execute( @@ -1323,7 +1323,7 @@ pub fn converge_edition_head(community_id: &str, entity_id: &str, version: u64, /// NULL/None held id is "always replaceable") — so it never applies a display edit the head write would /// then refuse. pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let row: Option>> = conn .query_row( "SELECT inner_id FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2", @@ -1345,7 +1345,7 @@ pub fn get_edition_head_inner_id(community_id: &str, entity_id: &str) -> Result< /// The current head `(version, self_hash)` of a control entity's edition chain, or `None` if no /// edition is held yet (so the next edition is the genesis, version 1, no prev_hash). pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let row: Option<(i64, Vec)> = conn .query_row( "SELECT version, self_hash FROM community_edition_heads WHERE community_id = ?1 AND entity_id = ?2", @@ -1369,7 +1369,7 @@ pub fn get_edition_head(community_id: &str, entity_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT entity_id FROM community_edition_heads WHERE community_id = ?1") .map_err(|e| e.to_string())?; @@ -1390,7 +1390,7 @@ pub fn edition_head_entity_ids(community_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT entity_id, version, self_hash FROM community_edition_heads WHERE community_id = ?1") .map_err(|e| e.to_string())?; @@ -1416,7 +1416,7 @@ pub fn get_all_edition_heads(community_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT entity_id, epoch, version, self_hash FROM community_edition_heads WHERE community_id = ?1") .map_err(|e| e.to_string())?; @@ -1439,7 +1439,7 @@ pub fn get_all_edition_heads_epoched(community_id: &str) -> Result Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let json: Option = conn .query_row( "SELECT banlist FROM communities WHERE community_id = ?1", @@ -1456,7 +1456,7 @@ pub fn get_community_banlist(community_id: &str) -> Result, String> /// (the registry's own entity), so this is just the content cache (mirrors `set_community_banlist`). pub fn set_community_invite_registry(community_id: &str, link_locators: &[String]) -> Result<(), String> { let json = enc_txt(&serde_json::to_string(link_locators).map_err(|e| e.to_string())?)?; - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET invite_registry = ?1 WHERE community_id = ?2", params![json, community_id], @@ -1468,7 +1468,7 @@ pub fn set_community_invite_registry(community_id: &str, link_locators: &[String /// A Community's current invite-link registry (active link locators, hex). Empty for an unknown /// community or a Private one. `is_public` = this is non-empty (computed mode). pub fn get_community_invite_registry(community_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let json: Option = conn .query_row( "SELECT invite_registry FROM communities WHERE community_id = ?1", @@ -1491,7 +1491,7 @@ pub struct InviteLinkSetRow { /// Replacing wholesale (not upserting) drops a creator who has revoked every link, so the per-creator /// view stays in lockstep with the flat registry computed in the same fold. pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) -> Result<(), String> { - let mut conn = super::get_write_connection_guard_static()?; + let mut conn = crate::db::get_write_connection_guard_static()?; let tx = conn.transaction().map_err(|e| format!("invite-link-sets tx: {e}"))?; tx.execute("DELETE FROM community_invite_link_sets WHERE community_id = ?1", params![community_id]) .map_err(|e| format!("clear invite-link-sets: {e}"))?; @@ -1516,7 +1516,7 @@ pub fn replace_invite_link_sets(community_id: &str, sets: &[InviteLinkSetRow]) - /// Upsert ONE creator's invite-link set (optimistic local update after the local user mints/revokes their /// own links, mirroring the flat-registry merge). An empty set removes the row. pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: &[String]) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; // `creator` is encrypted (random nonce), so locate any existing row by decrypting + matching. let existing_rowid: Option = { let mut stmt = conn @@ -1566,7 +1566,7 @@ pub fn upsert_invite_link_set(community_id: &str, creator_hex: &str, locators: & /// Every creator's active invite-link set for a Community (creator hex + locators). Empty for a Private /// community (or one not yet re-folded since this table was added). pub fn get_invite_link_sets(community_id: &str) -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT creator, locators FROM community_invite_link_sets WHERE community_id = ?1") .map_err(|e| format!("prepare invite-link-sets: {e}"))?; @@ -1590,7 +1590,7 @@ pub fn get_invite_link_sets(community_id: &str) -> Result, /// the re-seal is attempted and cleared only when it succeeds, so a transient failure is retried later /// instead of silently leaving a banned member with read access. pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET read_cut_pending = ?1 WHERE community_id = ?2", params![pending as i64, community_id], @@ -1603,7 +1603,7 @@ pub fn set_read_cut_pending(community_id: &str, pending: bool) -> Result<(), Str /// is no un-dissolve). Idempotent: re-setting an already-dissolved community is a harmless no-op. Once /// set, the control fold stops advancing and the inbound path drops every subsequent event. pub fn set_community_dissolved(community_id: &str) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET dissolved = 1 WHERE community_id = ?1", params![community_id], @@ -1615,7 +1615,7 @@ pub fn set_community_dissolved(community_id: &str) -> Result<(), String> { /// Whether a community has been sealed by a folded + owner-verified GroupDissolved tombstone. /// `false` for an unknown community. pub fn get_community_dissolved(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let v: Option = conn .query_row( "SELECT dissolved FROM communities WHERE community_id = ?1", @@ -1630,7 +1630,7 @@ pub fn get_community_dissolved(community_id: &str) -> Result { /// Whether a PRIVATE-community read-cut re-seal is still outstanding (a prior attempt failed). The ban /// flow retries the re-seal whenever this is set. `false` for an unknown community. pub fn get_read_cut_pending(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let v: Option = conn .query_row( "SELECT read_cut_pending FROM communities WHERE community_id = ?1", @@ -1647,7 +1647,7 @@ pub fn get_read_cut_pending(community_id: &str) -> Result { /// to `server_root_epoch + 1` on a fresh exclusion delta (ban add / privatize); left untouched on a pure /// resume so the in-flight target is preserved. pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE communities SET read_cut_target_epoch = ?1 WHERE community_id = ?2", params![target as i64, community_id], @@ -1659,7 +1659,7 @@ pub fn set_read_cut_target_epoch(community_id: &str, target: u64) -> Result<(), /// The base epoch a pending read-cut must reach (see [`set_read_cut_target_epoch`]). `0` for an unknown /// community. Reinterpreted i64->u64 (lossless) for epochs >= 2^63. pub fn get_read_cut_target_epoch(community_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let v: Option = conn .query_row( "SELECT read_cut_target_epoch FROM communities WHERE community_id = ?1", @@ -1674,7 +1674,7 @@ pub fn get_read_cut_target_epoch(community_id: &str) -> Result { /// The base (server-root) epoch a channel was last rekeyed FOR during a read-cut — the per-channel /// progress marker that lets a resumed re-founding skip channels already cut. `0` if unknown. pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> Result { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let v: Option = conn .query_row( "SELECT rekeyed_at_server_epoch FROM community_channels WHERE community_id = ?1 AND channel_id = ?2", @@ -1690,7 +1690,7 @@ pub fn channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str) -> /// Best-effort progress marker: written after the channel rekey lands, so a crash before it just re-rotates /// the channel on resume (safe, the rekey is monotonic) rather than skipping a channel that needed cutting. pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str, server_epoch: u64) -> Result<(), String> { - let conn = super::get_write_connection_guard_static()?; + let conn = crate::db::get_write_connection_guard_static()?; conn.execute( "UPDATE community_channels SET rekeyed_at_server_epoch = ?1 WHERE community_id = ?2 AND channel_id = ?3", params![server_epoch as i64, community_id, channel_id], @@ -1701,7 +1701,7 @@ pub fn mark_channel_rekeyed_at_server_epoch(community_id: &str, channel_id: &str /// Ids of every locally-stored Community. pub fn list_community_ids() -> Result, String> { - let conn = super::get_db_connection_guard_static()?; + let conn = crate::db::get_db_connection_guard_static()?; let mut stmt = conn .prepare("SELECT community_id FROM communities ORDER BY created_at") .map_err(|e| e.to_string())?; diff --git a/crates/vector-core/src/community/derive.rs b/crates/vector-core/src/concord/v1/derive.rs similarity index 100% rename from crates/vector-core/src/community/derive.rs rename to crates/vector-core/src/concord/v1/derive.rs diff --git a/crates/vector-core/src/community/edition.rs b/crates/vector-core/src/concord/v1/edition.rs similarity index 100% rename from crates/vector-core/src/community/edition.rs rename to crates/vector-core/src/concord/v1/edition.rs diff --git a/crates/vector-core/src/community/envelope.rs b/crates/vector-core/src/concord/v1/envelope.rs similarity index 100% rename from crates/vector-core/src/community/envelope.rs rename to crates/vector-core/src/concord/v1/envelope.rs diff --git a/crates/vector-core/src/community/inbound.rs b/crates/vector-core/src/concord/v1/inbound.rs similarity index 100% rename from crates/vector-core/src/community/inbound.rs rename to crates/vector-core/src/concord/v1/inbound.rs diff --git a/crates/vector-core/src/community/invite.rs b/crates/vector-core/src/concord/v1/invite.rs similarity index 100% rename from crates/vector-core/src/community/invite.rs rename to crates/vector-core/src/concord/v1/invite.rs diff --git a/crates/vector-core/src/community/invite_list.rs b/crates/vector-core/src/concord/v1/invite_list.rs similarity index 100% rename from crates/vector-core/src/community/invite_list.rs rename to crates/vector-core/src/concord/v1/invite_list.rs diff --git a/crates/vector-core/src/community/list.rs b/crates/vector-core/src/concord/v1/list.rs similarity index 100% rename from crates/vector-core/src/community/list.rs rename to crates/vector-core/src/concord/v1/list.rs diff --git a/crates/vector-core/src/community/metadata.rs b/crates/vector-core/src/concord/v1/metadata.rs similarity index 100% rename from crates/vector-core/src/community/metadata.rs rename to crates/vector-core/src/concord/v1/metadata.rs diff --git a/crates/vector-core/src/community/mod.rs b/crates/vector-core/src/concord/v1/mod.rs similarity index 99% rename from crates/vector-core/src/community/mod.rs rename to crates/vector-core/src/concord/v1/mod.rs index 54e45375..887c5afd 100644 --- a/crates/vector-core/src/community/mod.rs +++ b/crates/vector-core/src/concord/v1/mod.rs @@ -9,6 +9,7 @@ pub mod attachments; pub mod cache; pub mod cipher; +pub mod db; pub mod derive; pub mod envelope; pub mod inbound; diff --git a/crates/vector-core/src/community/owner.rs b/crates/vector-core/src/concord/v1/owner.rs similarity index 100% rename from crates/vector-core/src/community/owner.rs rename to crates/vector-core/src/concord/v1/owner.rs diff --git a/crates/vector-core/src/community/public_invite.rs b/crates/vector-core/src/concord/v1/public_invite.rs similarity index 100% rename from crates/vector-core/src/community/public_invite.rs rename to crates/vector-core/src/concord/v1/public_invite.rs diff --git a/crates/vector-core/src/community/realtime.rs b/crates/vector-core/src/concord/v1/realtime.rs similarity index 100% rename from crates/vector-core/src/community/realtime.rs rename to crates/vector-core/src/concord/v1/realtime.rs diff --git a/crates/vector-core/src/community/rekey.rs b/crates/vector-core/src/concord/v1/rekey.rs similarity index 100% rename from crates/vector-core/src/community/rekey.rs rename to crates/vector-core/src/concord/v1/rekey.rs diff --git a/crates/vector-core/src/community/roles.rs b/crates/vector-core/src/concord/v1/roles.rs similarity index 100% rename from crates/vector-core/src/community/roles.rs rename to crates/vector-core/src/concord/v1/roles.rs diff --git a/crates/vector-core/src/community/roster.rs b/crates/vector-core/src/concord/v1/roster.rs similarity index 100% rename from crates/vector-core/src/community/roster.rs rename to crates/vector-core/src/concord/v1/roster.rs diff --git a/crates/vector-core/src/community/send.rs b/crates/vector-core/src/concord/v1/send.rs similarity index 100% rename from crates/vector-core/src/community/send.rs rename to crates/vector-core/src/concord/v1/send.rs diff --git a/crates/vector-core/src/community/service.rs b/crates/vector-core/src/concord/v1/service.rs similarity index 100% rename from crates/vector-core/src/community/service.rs rename to crates/vector-core/src/concord/v1/service.rs diff --git a/crates/vector-core/src/community/transport.rs b/crates/vector-core/src/concord/v1/transport.rs similarity index 100% rename from crates/vector-core/src/community/transport.rs rename to crates/vector-core/src/concord/v1/transport.rs diff --git a/crates/vector-core/src/community/version.rs b/crates/vector-core/src/concord/v1/version.rs similarity index 100% rename from crates/vector-core/src/community/version.rs rename to crates/vector-core/src/concord/v1/version.rs diff --git a/crates/vector-core/src/concord/v2/community.rs b/crates/vector-core/src/concord/v2/community.rs new file mode 100644 index 00000000..03513541 --- /dev/null +++ b/crates/vector-core/src/concord/v2/community.rs @@ -0,0 +1,604 @@ +//! The in-memory Community: keys held, channels known, and the founding / +//! joining flows that mint them (CORD-02). +//! +//! This is the orchestration point over the pure pieces: `found` mints the +//! genesis (exactly two owner-signed editions — metadata and `#general`, +//! nothing more), `join` turns a validated bundle into held keys, and the +//! address helpers answer "where does this Community live on relays" for a +//! transport. + +use nostr_sdk::prelude::*; +use rand::RngCore; + +use super::control::{ChannelMetadata, CommunityMetadata, ControlFold}; +use super::derive::{self, GroupKey}; +use super::edition::build_edition_rumor; +use super::invite::{ChannelGrant, CommunityInvite, InviteError}; +use super::stream::{build_rumor, TAG_MS}; +use super::{ + kind, split_ms, vsk, ChannelId, ChannelKey, CommunityId, CommunityRoot, Epoch, OwnerSalt, +}; + +/// One Channel as held locally. +#[derive(Debug, Clone)] +pub struct Channel { + pub id: ChannelId, + pub name: String, + pub private: bool, + pub deleted: bool, + /// A Private Channel's independent secret; `None` for Public Channels + /// (their key derives from the root) and for Private ones not granted. + pub key: Option, + /// A Private Channel's own epoch; Public Channels ride the root's. + pub epoch: Epoch, +} + +/// A Community as held locally: identity, access keys, channels. +#[derive(Debug, Clone)] +pub struct Community { + pub id: CommunityId, + pub owner: PublicKey, + pub owner_salt: OwnerSalt, + pub root: CommunityRoot, + pub root_epoch: Epoch, + pub name: String, + /// Presentation only — follows the metadata fold, like `name`. + pub description: Option, + pub relays: Vec, + pub channels: Vec, +} + +fn random_32() -> [u8; 32] { + let mut bytes = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + bytes +} + +/// A freshly founded Community plus its genesis edition rumors, ready to be +/// plaintext-sealed and wrapped at the Control Plane address. +pub struct Founded { + pub community: Community, + /// Exactly two owner-signed editions: the metadata and one public + /// `#general` — no default roles, no scaffolding. + pub genesis: Vec, +} + +impl Community { + /// Found a Community (CORD-02 §1): mint the salt (identity), the root + /// (access), and `#general`. + pub fn found(owner: PublicKey, name: &str, relays: Vec, unix_ms: u64) -> Result { + let metadata = CommunityMetadata { + name: name.to_string(), + description: None, + relays: relays.clone(), + icon: None, + banner: None, + custom: None, + extra: Default::default(), + }; + metadata.validate()?; + + let owner_salt = OwnerSalt(random_32()); + let id = derive::community_id(&owner.to_bytes(), &owner_salt); + let root = CommunityRoot(random_32()); + let general = ChannelId(random_32()); + + let community = Community { + id, + owner, + owner_salt, + root, + root_epoch: Epoch(0), + name: name.to_string(), + description: None, + relays, + channels: vec![Channel { + id: general, + name: "general".into(), + private: false, + deleted: false, + key: None, + epoch: Epoch(0), + }], + }; + + let (secs, _) = split_ms(unix_ms); + let meta_content = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + let general_meta = ChannelMetadata { + name: "general".into(), + private: false, + deleted: false, + custom: None, + extra: Default::default(), + }; + let general_content = serde_json::to_string(&general_meta).map_err(|e| e.to_string())?; + let genesis = vec![ + build_edition_rumor(owner, vsk::COMMUNITY_METADATA, &id.0, 1, None, &meta_content, secs, None), + build_edition_rumor(owner, vsk::CHANNEL_METADATA, &general.0, 1, None, &general_content, secs, None), + ]; + + Ok(Founded { community, genesis }) + } + + /// Join from a bundle: validate (self-certifying owner, bounds) and + /// refuse an expired one — the keys ARE membership, this just holds them. + pub fn join(mut bundle: CommunityInvite, now_ms: u64) -> Result { + bundle.validate()?; + if bundle.is_expired(now_ms) { + return Err(InviteError::Expired); + } + let id = bundle + .community_id_typed() + .ok_or_else(|| InviteError::Malformed("community_id".into()))?; + let owner_bytes = crate::simd::hex::hex_to_bytes_32_checked(&bundle.owner) + .ok_or_else(|| InviteError::Malformed("owner".into()))?; + let owner = PublicKey::from_slice(&owner_bytes).map_err(|e| InviteError::Malformed(e.to_string()))?; + let owner_salt = crate::simd::hex::hex_to_bytes_32_checked(&bundle.owner_salt) + .map(OwnerSalt) + .ok_or_else(|| InviteError::Malformed("owner_salt".into()))?; + let root = bundle + .community_root_typed() + .ok_or_else(|| InviteError::Malformed("community_root".into()))?; + + let channels = bundle + .channels + .iter() + .filter_map(|grant: &ChannelGrant| { + Some(Channel { + id: grant.channel_id()?, + name: grant.name.clone(), + private: grant.key.is_some(), + deleted: false, + key: grant.channel_key(), + epoch: Epoch(grant.epoch), + }) + }) + .collect(); + + Ok(Community { + id, + owner, + owner_salt, + root, + root_epoch: Epoch(bundle.root_epoch), + name: bundle.name.clone(), + description: None, + relays: bundle.relays.clone(), + channels, + }) + } + + /// Mint an invite bundle granting the given channels (Private ones carry + /// their keys; Public ones ride the root). + pub fn invite_bundle( + &self, + grant_channels: &[ChannelId], + expires_at: Option, + creator_npub: Option, + label: Option, + ) -> CommunityInvite { + let channels = self + .channels + .iter() + .filter(|c| !c.deleted && grant_channels.contains(&c.id)) + .map(|c| ChannelGrant { + id: c.id.to_hex(), + key: c.key.as_ref().map(|k| crate::simd::hex::bytes_to_hex_32(k.as_bytes())), + epoch: if c.private { c.epoch.0 } else { self.root_epoch.0 }, + name: c.name.clone(), + }) + .collect(); + CommunityInvite { + community_id: self.id.to_hex(), + owner: self.owner.to_hex(), + owner_salt: crate::simd::hex::bytes_to_hex_32(&self.owner_salt.0), + community_root: crate::simd::hex::bytes_to_hex_32(self.root.as_bytes()), + root_epoch: self.root_epoch.0, + channels, + relays: self.relays.clone(), + name: self.name.clone(), + icon: None, + expires_at, + creator_npub, + label, + extra: Default::default(), + } + } + + // --- plane addresses --- + + pub fn control_key(&self) -> GroupKey { + derive::control_key(&self.root, &self.id, self.root_epoch) + } + + pub fn guestbook_key(&self) -> GroupKey { + derive::guestbook_key(&self.root, &self.id, self.root_epoch) + } + + pub fn dissolved_key(&self) -> GroupKey { + derive::dissolved_address(&self.id) + } + + pub fn channel(&self, id: &ChannelId) -> Option<&Channel> { + self.channels.iter().find(|c| c.id == *id) + } + + /// A Channel's group key at its current epoch, `None` for a Private + /// Channel whose key we don't hold. + pub fn channel_key(&self, id: &ChannelId) -> Option { + let chan = self.channel(id)?; + if chan.private { + Some(derive::private_channel_key(chan.key.as_ref()?, &chan.id, chan.epoch)) + } else { + Some(derive::public_channel_key(&self.root, &chan.id, self.root_epoch)) + } + } + + /// A Channel's effective epoch: its own for Private, the root's for Public. + pub fn channel_epoch(&self, id: &ChannelId) -> Option { + let chan = self.channel(id)?; + Some(if chan.private { chan.epoch } else { self.root_epoch }) + } + + /// The precomputed next-rekey subscription addresses (CORD-06 §2): per + /// held Private Channel the NEXT channel-epoch's rekey address, plus the + /// next base-rotation address. + pub fn rekey_subscription_authors(&self) -> Vec { + let mut authors: Vec = self + .channels + .iter() + .filter(|c| c.private && c.key.is_some() && !c.deleted) + .map(|c| derive::rekey_address(&self.root, &c.id, Epoch(c.epoch.0 + 1)).public_key()) + .collect(); + authors.push( + derive::base_rekey_address(&self.root, &self.id, Epoch(self.root_epoch.0 + 1)).public_key(), + ); + authors + } + + // --- key rotation application (CORD-06) --- + + /// Adopt a new base root at its epoch (a Refounding's outcome for a + /// surviving member). + pub fn apply_new_root(&mut self, new_root: CommunityRoot, new_epoch: Epoch) { + if new_epoch > self.root_epoch { + self.root = new_root; + self.root_epoch = new_epoch; + } + } + + /// Adopt a Private Channel's new key at its epoch. + pub fn apply_channel_key(&mut self, id: &ChannelId, key: ChannelKey, epoch: Epoch) { + if let Some(chan) = self.channels.iter_mut().find(|c| c.id == *id) { + if epoch > chan.epoch || chan.key.is_none() { + chan.key = Some(key); + chan.epoch = epoch; + chan.private = true; + } + } + } + + /// Refresh presentation + channel definitions from the folded Control + /// Plane (the fold is always the authority; the bundle copy was a + /// join-time snapshot). Held keys are never touched. + pub fn apply_control(&mut self, fold: &ControlFold) { + if let Some(meta) = fold.metadata() { + self.name = meta.name.clone(); + self.description = meta.description.clone(); + if !meta.relays.is_empty() { + self.relays = meta.effective_relays().to_vec(); + } + } + for (id, meta) in fold.channels() { + match self.channels.iter_mut().find(|c| c.id == id) { + Some(chan) => { + chan.name = meta.name.clone(); + chan.deleted = meta.deleted; + chan.private = meta.private; + } + None => self.channels.push(Channel { + id, + name: meta.name.clone(), + private: meta.private, + deleted: meta.deleted, + key: None, + epoch: Epoch(0), + }), + } + } + } +} + +// ============================================================================ +// Chat rumor builders (CORD-03 §3, shapes per the registry) +// ============================================================================ + +/// A kind 9 message (NIP-C7 shape), channel/epoch-bound. +pub fn build_message(author: PublicKey, channel: &ChannelId, epoch: Epoch, text: &str, unix_ms: u64) -> UnsignedEvent { + build_rumor(author, kind::MESSAGE, text, channel, epoch, unix_ms, vec![]) +} + +/// A reply: quotes the parent with a `q` tag citing the parent's *rumor* id +/// (never the outer wrap's, which differs per re-wrap). +pub fn build_reply( + author: PublicKey, + channel: &ChannelId, + epoch: Epoch, + text: &str, + parent_rumor_id: &EventId, + parent_author: &PublicKey, + unix_ms: u64, +) -> UnsignedEvent { + let q = Tag::custom( + TagKind::q(), + [parent_rumor_id.to_hex(), String::new(), parent_author.to_hex()], + ); + build_rumor(author, kind::MESSAGE, text, channel, epoch, unix_ms, vec![q]) +} + +/// A kind 7 reaction (NIP-25 shape). +pub fn build_reaction( + author: PublicKey, + channel: &ChannelId, + epoch: Epoch, + reaction: &str, + message_rumor_id: &EventId, + message_author: &PublicKey, + unix_ms: u64, +) -> UnsignedEvent { + let tags = vec![ + Tag::custom(TagKind::e(), [message_rumor_id.to_hex()]), + Tag::public_key(*message_author), + Tag::custom(TagKind::k(), [kind::MESSAGE.to_string()]), + ]; + build_rumor(author, kind::REACTION, reaction, channel, epoch, unix_ms, tags) +} + +/// A kind 5 delete of one's own message (NIP-09 shape). Semantic within the +/// plane only — honored always, even post-Dissolution. +pub fn build_delete( + author: PublicKey, + channel: &ChannelId, + epoch: Epoch, + own_message_rumor_id: &EventId, + unix_ms: u64, +) -> UnsignedEvent { + let tags = vec![ + Tag::custom(TagKind::e(), [own_message_rumor_id.to_hex()]), + Tag::custom(TagKind::k(), [kind::MESSAGE.to_string()]), + ]; + build_rumor(author, kind::DELETE, "", channel, epoch, unix_ms, tags) +} + +/// A kind 3302 edit of one's own message: `content` is the replacement text. +pub fn build_edit( + author: PublicKey, + channel: &ChannelId, + epoch: Epoch, + replacement: &str, + own_message_rumor_id: &EventId, + unix_ms: u64, +) -> UnsignedEvent { + let tags = vec![Tag::custom(TagKind::e(), [own_message_rumor_id.to_hex()])]; + build_rumor(author, kind::EDIT, replacement, channel, epoch, unix_ms, tags) +} + +/// A kind 23311 typing indicator: presence is the signal, the rumor carries +/// nothing, and every layer rides the ephemeral range. +pub fn build_typing(author: PublicKey, channel: &ChannelId, epoch: Epoch, unix_ms: u64) -> UnsignedEvent { + build_rumor(author, kind::TYPING, "", channel, epoch, unix_ms, vec![]) +} + +/// The `ms` tag helper other planes share. +pub fn ms_tag(unix_ms: u64) -> Tag { + let (_, remainder) = split_ms(unix_ms); + Tag::custom(TagKind::Custom(TAG_MS.into()), [remainder.to_string()]) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::concord::v2::control::FoldMode; + use crate::concord::v2::edition::parse_edition; + use crate::concord::v2::stream::{self, SealForm}; + + const NOW: u64 = 1_722_500_000_123; + + #[test] + fn founding_genesis_folds_end_to_end() { + let owner = Keys::generate(); + let Founded { community, genesis } = + Community::found(owner.public_key(), "Vector", vec!["wss://a".into()], NOW).unwrap(); + + // Wrap the genesis editions at the Control Plane address (plaintext + // seals) and fold them back like any member would. + let control = community.control_key(); + let mut fold = ControlFold::verified( + community.id, + community.owner, + &community.owner_salt, + FoldMode::Tracking, + ) + .unwrap(); + for rumor in &genesis { + let wrap = stream::wrap_rumor(&control, &owner, rumor, SealForm::Plaintext, Keys::generate().public_key()).unwrap(); + let opened = stream::open(&control, &wrap).unwrap(); + assert_eq!(opened.seal_form, SealForm::Plaintext, "Control Plane seals are plaintext"); + fold.ingest([parse_edition(&opened.rumor).unwrap()]); + } + + assert_eq!(fold.metadata().unwrap().name, "Vector"); + let channels = fold.channels(); + assert_eq!(channels.len(), 1); + assert_eq!(channels[0].1.name, "general"); + assert_eq!(channels[0].0, community.channels[0].id); + assert!(!fold.is_public(), "genesis is Private: no live links"); + } + + #[test] + fn join_from_bundle_and_message_roundtrip() { + let owner = Keys::generate(); + let Founded { community, .. } = + Community::found(owner.public_key(), "Vector", vec!["wss://a".into()], NOW).unwrap(); + let general = community.channels[0].id; + + // Founder mints a bundle; a member joins from it. + let bundle = community.invite_bundle(&[general], None, None, None); + let member_keys = Keys::generate(); + let joined = Community::join(bundle, NOW).unwrap(); + assert_eq!(joined.id, community.id); + assert_eq!(joined.root_epoch, Epoch(0)); + + // Both derive the same channel address (a Public Channel needs no + // key delivery). + let founder_gk = community.channel_key(&general).unwrap(); + let member_gk = joined.channel_key(&general).unwrap(); + assert_eq!(founder_gk.public_key(), member_gk.public_key()); + + // Member sends; founder reads. + let rumor = build_message(member_keys.public_key(), &general, Epoch(0), "Hey chat!", NOW); + let wrap = stream::wrap_rumor(&member_gk, &member_keys, &rumor, SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + let opened = stream::open(&founder_gk, &wrap).unwrap(); + stream::check_binding(&opened.rumor, &general, Epoch(0)).unwrap(); + assert_eq!(opened.rumor.content, "Hey chat!"); + assert_eq!(opened.author, member_keys.public_key()); + assert_eq!(opened.timestamp_ms(), Some(NOW)); + } + + #[test] + fn expired_bundle_refuses_joining() { + let owner = Keys::generate(); + let Founded { community, .. } = Community::found(owner.public_key(), "V", vec![], NOW).unwrap(); + let general = community.channels[0].id; + let bundle = community.invite_bundle(&[general], Some(NOW - 1), None, None); + assert!(matches!(Community::join(bundle, NOW), Err(InviteError::Expired))); + } + + #[test] + fn private_channel_grants_carry_keys_public_do_not() { + let owner = Keys::generate(); + let Founded { mut community, .. } = Community::found(owner.public_key(), "V", vec![], NOW).unwrap(); + let general = community.channels[0].id; + let testers = ChannelId([0x99; 32]); + community.channels.push(Channel { + id: testers, + name: "testers".into(), + private: true, + deleted: false, + key: Some(ChannelKey([0x42; 32])), + epoch: Epoch(1), + }); + + let bundle = community.invite_bundle(&[general, testers], None, None, None); + let public_grant = bundle.channels.iter().find(|c| c.name == "general").unwrap(); + let private_grant = bundle.channels.iter().find(|c| c.name == "testers").unwrap(); + assert!(public_grant.key.is_none()); + assert_eq!(private_grant.key.as_deref(), Some(crate::simd::hex::bytes_to_hex_32(&[0x42; 32]).as_str())); + assert_eq!(private_grant.epoch, 1); + + let joined = Community::join(bundle, NOW).unwrap(); + assert_eq!( + joined.channel_key(&testers).unwrap().public_key(), + community.channel_key(&testers).unwrap().public_key() + ); + } + + #[test] + fn rekey_subscription_covers_private_channels_and_base() { + let owner = Keys::generate(); + let Founded { mut community, .. } = Community::found(owner.public_key(), "V", vec![], NOW).unwrap(); + community.channels.push(Channel { + id: ChannelId([0x99; 32]), + name: "testers".into(), + private: true, + deleted: false, + key: Some(ChannelKey([0x42; 32])), + epoch: Epoch(1), + }); + let authors = community.rekey_subscription_authors(); + // One private channel (next epoch 2) + the base (next epoch 1). + assert_eq!(authors.len(), 2); + assert_eq!( + authors[0], + derive::rekey_address(&community.root, &ChannelId([0x99; 32]), Epoch(2)).public_key() + ); + assert_eq!( + authors[1], + derive::base_rekey_address(&community.root, &community.id, Epoch(1)).public_key() + ); + } + + #[test] + fn root_rotation_only_moves_forward() { + let owner = Keys::generate(); + let Founded { mut community, .. } = Community::found(owner.public_key(), "V", vec![], NOW).unwrap(); + let old_root = community.root.clone(); + community.apply_new_root(CommunityRoot([0x50; 32]), Epoch(1)); + assert_eq!(community.root_epoch, Epoch(1)); + // A stale (lower-epoch) rotation never regresses the root. + community.apply_new_root(CommunityRoot([0x60; 32]), Epoch(1)); + assert_eq!(community.root, CommunityRoot([0x50; 32])); + assert_ne!(community.root, old_root); + } + + #[test] + fn chat_rumor_shapes_match_the_registry() { + let author = Keys::generate().public_key(); + let parent_author = Keys::generate().public_key(); + let chan = ChannelId([0x77; 32]); + let parent = EventId::from_slice(&[1; 32]).unwrap(); + + let reply = build_reply(author, &chan, Epoch(0), "Welcome!", &parent, &parent_author, NOW); + let q = reply.tags.iter().find(|t| t.kind() == TagKind::q()).unwrap(); + assert_eq!(q.as_slice()[1], parent.to_hex()); + assert_eq!(q.as_slice()[3], parent_author.to_hex()); + + let reaction = build_reaction(author, &chan, Epoch(0), "🔥", &parent, &parent_author, NOW); + assert_eq!(reaction.kind.as_u16(), kind::REACTION); + assert_eq!(reaction.content, "🔥"); + let k = reaction.tags.iter().find(|t| t.kind() == TagKind::k()).and_then(|t| t.content()); + assert_eq!(k, Some("9")); + + let delete = build_delete(author, &chan, Epoch(0), &parent, NOW); + assert_eq!(delete.kind.as_u16(), kind::DELETE); + + let edit = build_edit(author, &chan, Epoch(0), "fixed", &parent, NOW); + assert_eq!(edit.kind.as_u16(), kind::EDIT); + assert_eq!(edit.content, "fixed"); + + let typing = build_typing(author, &chan, Epoch(0), NOW); + assert_eq!(typing.kind.as_u16(), kind::TYPING); + assert!(typing.content.is_empty()); + // All carry the binding. + for rumor in [&reply, &reaction, &delete, &edit, &typing] { + stream::check_binding(rumor, &chan, Epoch(0)).unwrap(); + } + } + + #[test] + fn apply_control_follows_the_fold_without_touching_keys() { + let owner = Keys::generate(); + let Founded { mut community, genesis } = Community::found(owner.public_key(), "V", vec![], NOW).unwrap(); + let mut fold = ControlFold::new(community.id, community.owner, FoldMode::Tracking); + fold.ingest(genesis.iter().map(|r| parse_edition(r).unwrap())); + + // Owner renames the community and the channel. + let meta_head = fold.head(&community.id.0).unwrap(); + let rename = build_edition_rumor( + owner.public_key(), + vsk::COMMUNITY_METADATA, + &community.id.0, + 2, + Some(&meta_head.hash()), + "{\"name\":\"Vector HQ\",\"relays\":[\"wss://new\"]}", + NOW / 1000, + None, + ); + fold.ingest([parse_edition(&rename).unwrap()]); + + community.apply_control(&fold); + assert_eq!(community.name, "Vector HQ"); + assert_eq!(community.relays, vec!["wss://new".to_string()]); + assert_eq!(community.channels.len(), 1, "definitions follow the fold"); + } +} diff --git a/crates/vector-core/src/concord/v2/community_list.rs b/crates/vector-core/src/concord/v2/community_list.rs new file mode 100644 index 00000000..53d009c0 --- /dev/null +++ b/crates/vector-core/src/concord/v2/community_list.rs @@ -0,0 +1,367 @@ +//! CORD-02 §8: the Community List — a member's memberships, synced across +//! devices and clients as one self-encrypted kind 13302 replaceable. +//! +//! Two snapshots per membership solve opposite problems: `seed` holds the +//! *earliest* epoch you ever held (the anchor for full-history backfill) and +//! only ever moves backward on merge; `current` holds the *latest* (a fresh +//! device reconstructs instantly and just pages) and only ever moves forward. +//! Tombstones are permanent — liveness is derived, never deletion. + +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::invite::{ChannelGrant, CommunityInvite, InviteError}; +use super::{kind, Epoch, COMMUNITY_LIST_MAX_MEMBERSHIPS, NIP44_MAX_PLAINTEXT}; + +/// Join material: the bundle's *membership* subset — never the icon (a +/// rehydrating device folds it from the Control Plane) and never the link +/// fields (expiry and attribution belong to the invite, not the membership). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JoinMaterial { + pub community_id: String, + pub owner: String, + pub owner_salt: String, + pub community_root: String, + pub root_epoch: u64, + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub relays: Vec, + #[serde(default)] + pub name: String, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl JoinMaterial { + /// Extract the membership subset from a validated bundle. + pub fn from_bundle(bundle: &CommunityInvite) -> Self { + JoinMaterial { + community_id: bundle.community_id.clone(), + owner: bundle.owner.clone(), + owner_salt: bundle.owner_salt.clone(), + community_root: bundle.community_root.clone(), + root_epoch: bundle.root_epoch, + channels: bundle.channels.clone(), + relays: bundle.relays.clone(), + name: bundle.name.clone(), + extra: Default::default(), + } + } + + /// The canonical bytes an epoch tie breaks on — a total order, so a + /// same-epoch rename can't leave two devices flapping. + fn canonical(&self) -> String { + serde_json::to_string(self).unwrap_or_default() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommunityListEntry { + pub community_id: String, + /// The earliest epoch held: only ever moves BACKward on merge. + pub seed: JoinMaterial, + /// The freshest snapshot: replaced on every Refounding or rename. + pub current: JoinMaterial, + /// Unix ms; tiebreaks against a tombstone (newest wins). + pub added_at: u64, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ListTombstone { + pub community_id: String, + pub removed_at: u64, +} + +/// The kind 13302 document: every Community you're in and every one you've +/// left. A tombstoned entry stays *in* the document, or merges would depend +/// on gossip order. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct CommunityList { + #[serde(default)] + pub entries: Vec, + #[serde(default)] + pub tombstones: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +/// Merge direction for one snapshot slot. +fn merge_snapshot(mine: &mut JoinMaterial, theirs: &JoinMaterial, keep_lower_epoch: bool) { + let replace = match theirs.root_epoch.cmp(&mine.root_epoch) { + std::cmp::Ordering::Less => keep_lower_epoch, + std::cmp::Ordering::Greater => !keep_lower_epoch, + // Epoch tie: the lexicographically lowest canonical bytes win. + std::cmp::Ordering::Equal => theirs.canonical() < mine.canonical(), + }; + if replace { + *mine = theirs.clone(); + } +} + +impl CommunityList { + /// Whether a membership is live: the newest of `added_at` and + /// `removed_at` wins, so a re-join legitimately resurrects while a + /// backfill can never re-add a tombstoned id. + pub fn is_live(&self, community_id: &str) -> bool { + let Some(entry) = self.entries.iter().find(|e| e.community_id == community_id) else { + return false; + }; + match self.tombstones.iter().find(|t| t.community_id == community_id) { + Some(tomb) => entry.added_at > tomb.removed_at, + None => true, + } + } + + pub fn live_entries(&self) -> impl Iterator { + self.entries.iter().filter(|e| self.is_live(&e.community_id)) + } + + /// Record a (re-)join. Seed keeps the earliest epoch, current the + /// freshest; `added_at` moves forward only. + pub fn add_membership(&mut self, material: JoinMaterial, added_at_ms: u64) { + match self.entries.iter_mut().find(|e| e.community_id == material.community_id) { + Some(entry) => { + merge_snapshot(&mut entry.seed, &material, true); + merge_snapshot(&mut entry.current, &material, false); + entry.added_at = entry.added_at.max(added_at_ms); + } + None => self.entries.push(CommunityListEntry { + community_id: material.community_id.clone(), + seed: material.clone(), + current: material, + added_at: added_at_ms, + extra: Default::default(), + }), + } + self.entries.sort_by(|a, b| a.community_id.cmp(&b.community_id)); + } + + /// Record a leave. Permanent: pruning would let a long-offline device + /// resurrect a Community you left. + pub fn tombstone(&mut self, community_id: &str, removed_at_ms: u64) { + match self.tombstones.iter_mut().find(|t| t.community_id == community_id) { + Some(tomb) => tomb.removed_at = tomb.removed_at.max(removed_at_ms), + None => self.tombstones.push(ListTombstone { + community_id: community_id.to_string(), + removed_at: removed_at_ms, + }), + } + self.tombstones.sort_by(|a, b| a.community_id.cmp(&b.community_id)); + } + + /// Merge another device's copy. Commutative, associative, idempotent — + /// two clients serving one npub converge without coordination. + pub fn merge(&mut self, other: &CommunityList) { + for theirs in &other.entries { + match self.entries.iter_mut().find(|e| e.community_id == theirs.community_id) { + Some(mine) => { + merge_snapshot(&mut mine.seed, &theirs.seed, true); + merge_snapshot(&mut mine.current, &theirs.current, false); + mine.added_at = mine.added_at.max(theirs.added_at); + } + None => self.entries.push(theirs.clone()), + } + } + for tomb in &other.tombstones { + self.tombstone(&tomb.community_id, tomb.removed_at); + } + self.entries.sort_by(|a, b| a.community_id.cmp(&b.community_id)); + } + + /// The publish gate: the membership cap bounds the common case, the byte + /// cap is the law — a client MUST verify the serialized List fits (join + /// material carrying private-channel keys can overflow well below 50). + pub fn validate_for_publish(&self) -> Result { + if self.entries.len() > COMMUNITY_LIST_MAX_MEMBERSHIPS { + return Err(InviteError::Malformed(format!( + "community list holds {} memberships (cap {COMMUNITY_LIST_MAX_MEMBERSHIPS})", + self.entries.len() + ))); + } + let json = serde_json::to_string(self).map_err(|e| InviteError::Malformed(e.to_string()))?; + if json.len() > NIP44_MAX_PLAINTEXT { + return Err(InviteError::Malformed("community list exceeds the NIP-44 plaintext cap".into())); + } + Ok(json) + } + + pub fn seed_epoch(&self, community_id: &str) -> Option { + self.entries + .iter() + .find(|e| e.community_id == community_id) + .map(|e| Epoch(e.seed.root_epoch)) + } + + pub fn current_epoch(&self, community_id: &str) -> Option { + self.entries + .iter() + .find(|e| e.community_id == community_id) + .map(|e| Epoch(e.current.root_epoch)) + } +} + +/// Publish form: kind 13302, replaceable, signed by the real key, +/// NIP-44-encrypted to self. +pub fn build_community_list_event( + keys: &Keys, + list: &CommunityList, + created_at_secs: u64, +) -> Result { + let json = list.validate_for_publish()?; + let ct = nostr_sdk::nips::nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + &json, + nostr_sdk::nips::nip44::Version::V2, + ) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + EventBuilder::new(Kind::Custom(kind::COMMUNITY_LIST), ct) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .sign_with_keys(keys) + .map_err(|e| InviteError::Crypto(e.to_string())) +} + +pub fn open_community_list_event(keys: &Keys, event: &Event) -> Result { + let json = nostr_sdk::nips::nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + serde_json::from_str(&json).map_err(|e| InviteError::Malformed(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn material(cid: &str, epoch: u64, name: &str) -> JoinMaterial { + JoinMaterial { + community_id: cid.into(), + owner: "owner".into(), + owner_salt: "salt".into(), + community_root: format!("root-{epoch}"), + root_epoch: epoch, + channels: vec![], + relays: vec!["wss://a".into()], + name: name.into(), + extra: Default::default(), + } + } + + #[test] + fn seed_moves_backward_current_moves_forward() { + let mut list = CommunityList::default(); + list.add_membership(material("c1", 3, "V"), 1_000); + // A refounding hands epoch 4: current advances, seed stays. + list.add_membership(material("c1", 4, "V"), 2_000); + assert_eq!(list.seed_epoch("c1"), Some(Epoch(3))); + assert_eq!(list.current_epoch("c1"), Some(Epoch(4))); + // A backfilled older epoch: seed rewinds, current stays. + list.add_membership(material("c1", 1, "V"), 500); + assert_eq!(list.seed_epoch("c1"), Some(Epoch(1))); + assert_eq!(list.current_epoch("c1"), Some(Epoch(4))); + assert_eq!(list.entries[0].added_at, 2_000, "added_at moves forward only"); + } + + #[test] + fn same_epoch_tie_breaks_on_canonical_bytes() { + let a = material("c1", 2, "Alpha"); + let b = material("c1", 2, "Beta"); + let winner = if a.canonical() < b.canonical() { "Alpha" } else { "Beta" }; + + // Both merge orders land every device on the same current. + let mut dev1 = CommunityList::default(); + dev1.add_membership(a.clone(), 1); + dev1.add_membership(b.clone(), 2); + let mut dev2 = CommunityList::default(); + dev2.add_membership(b, 1); + dev2.add_membership(a, 2); + assert_eq!(dev1.entries[0].current.name, winner); + assert_eq!(dev2.entries[0].current.name, winner); + } + + #[test] + fn tombstones_are_permanent_and_rejoin_resurrects() { + let mut list = CommunityList::default(); + list.add_membership(material("c1", 0, "V"), 1_000); + assert!(list.is_live("c1")); + + list.tombstone("c1", 2_000); + assert!(!list.is_live("c1")); + assert_eq!(list.entries.len(), 1, "a tombstoned entry stays in the document"); + + // A backfill replaying the old add can never re-add it. + list.add_membership(material("c1", 0, "V"), 1_000); + assert!(!list.is_live("c1")); + + // A genuine re-join (newer added_at) resurrects. + list.add_membership(material("c1", 0, "V"), 3_000); + assert!(list.is_live("c1")); + } + + #[test] + fn device_merge_converges_regardless_of_order() { + let mut a = CommunityList::default(); + a.add_membership(material("c1", 2, "V"), 1_000); + a.add_membership(material("c2", 0, "W"), 1_500); + let mut b = CommunityList::default(); + b.add_membership(material("c1", 5, "V-renamed"), 3_000); + b.tombstone("c2", 2_000); + + let mut ab = a.clone(); + ab.merge(&b); + let mut ba = b.clone(); + ba.merge(&a); + assert_eq!(ab, ba, "merge is commutative"); + assert_eq!(ab.seed_epoch("c1"), Some(Epoch(2))); + assert_eq!(ab.current_epoch("c1"), Some(Epoch(5))); + assert!(!ab.is_live("c2")); + // Idempotent. + let snapshot = ab.clone(); + ab.merge(&snapshot.clone()); + assert_eq!(ab, snapshot); + } + + #[test] + fn publish_gates_membership_and_byte_caps() { + let mut list = CommunityList::default(); + for i in 0..COMMUNITY_LIST_MAX_MEMBERSHIPS { + list.add_membership(material(&format!("c{i:03}"), 0, "V"), 1); + } + assert!(list.validate_for_publish().is_ok()); + list.add_membership(material("c-one-more", 0, "V"), 1); + assert!(list.validate_for_publish().is_err(), "51st membership refused"); + + // The byte cap binds below the count cap when entries are heavy. + let mut heavy = CommunityList::default(); + let mut m = material("c-heavy", 0, "V"); + m.channels = (0..200) + .map(|i| ChannelGrant { + id: crate::simd::hex::bytes_to_hex_32(&[i as u8; 32]), + key: Some(crate::simd::hex::bytes_to_hex_32(&[i as u8; 32])), + epoch: 1, + name: "x".repeat(60), + }) + .collect(); + for i in 0..3 { + let mut mi = m.clone(); + mi.community_id = format!("c-heavy-{i}"); + heavy.add_membership(mi, 1); + } + assert!(heavy.validate_for_publish().is_err(), "the byte cap is the law"); + } + + #[test] + fn event_roundtrip_and_unknown_fields_survive() { + let keys = Keys::generate(); + let mut list = CommunityList::default(); + list.add_membership(material("c1", 0, "V"), 1_000); + list.extra.insert("future_field".into(), serde_json::json!(42)); + let event = build_community_list_event(&keys, &list, 1_722_400_000).unwrap(); + assert_eq!(event.kind.as_u16(), kind::COMMUNITY_LIST); + let back = open_community_list_event(&keys, &event).unwrap(); + assert_eq!(back, list); + assert_eq!(back.extra["future_field"], 42); + assert!(open_community_list_event(&Keys::generate(), &event).is_err()); + } +} diff --git a/crates/vector-core/src/concord/v2/control.rs b/crates/vector-core/src/concord/v2/control.rs new file mode 100644 index 00000000..c3c80db0 --- /dev/null +++ b/crates/vector-core/src/concord/v2/control.rs @@ -0,0 +1,1126 @@ +//! CORD-04: the Control Plane fold — convergent, owner-rooted, refuse-downgrade. +//! +//! Clients fold every edition they hold into current state per entity, taking +//! the highest version whose chain is intact, judging each edition's signer +//! against the roster settled by the chain behind it. The fold is a +//! deterministic function of the accumulated candidate pool, so two clients +//! holding the same editions land on the same heads regardless of arrival +//! order; a persistent accepted floor refuses downgrades (a replayed stale +//! Grant or lifted Ban is rejected). + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use nostr_sdk::prelude::PublicKey; +use serde::{Deserialize, Serialize}; + +use super::derive::{banlist_locator, grant_locator, invite_links_locator, verify_owner}; +use super::edition::{Edition, EditionError}; +use super::roster::{Grant, Rank, Role, Roster}; +use super::{ + perm, vsk, ChannelId, CommunityId, OwnerSalt, RoleId, DESCRIPTION_MAX_BYTES, NAME_MAX_BYTES, + RELAYS_RECOMMENDED_MAX, +}; + +// ============================================================================ +// Entity payloads +// ============================================================================ + +/// An encrypted-blob pointer: images never touch a media server in plaintext +/// (CORD-02 §6). Fetch, decrypt with `key`/`nonce`, verify `hash`; a swapped +/// blob fails closed. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ImageRef { + pub url: String, + pub key: String, + pub nonce: String, + pub hash: String, +} + +/// Community metadata (vsk 0), gated by `MANAGE_METADATA`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommunityMetadata { + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + #[serde(default)] + pub relays: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub banner: Option, + /// Client-extensible, folded atomically with the entity. Round-trip + /// discipline: preserve what you don't understand. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, + /// Additive protocol fields ride unknown-field round-tripping. + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl CommunityMetadata { + pub fn validate(&self) -> Result<(), String> { + if self.name.len() > NAME_MAX_BYTES { + return Err(format!("community name exceeds {NAME_MAX_BYTES} bytes")); + } + if let Some(d) = &self.description { + if d.len() > DESCRIPTION_MAX_BYTES { + return Err(format!("description exceeds {DESCRIPTION_MAX_BYTES} bytes")); + } + } + Ok(()) + } + + /// The relay set a client actually connects: truncated to the recommended + /// cap, so an entity MUST stay usable when trimmed (CORD-02 §6). + pub fn effective_relays(&self) -> &[String] { + &self.relays[..self.relays.len().min(RELAYS_RECOMMENDED_MAX)] + } +} + +/// Channel metadata (vsk 2), gated by `MANAGE_CHANNELS`. The `channel_id` is +/// the edition's `eid`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChannelMetadata { + pub name: String, + pub private: bool, + /// Terminal: the id is never reused and no later edition resurrects it. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub deleted: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub custom: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl ChannelMetadata { + pub fn validate(&self) -> Result<(), String> { + if self.name.len() > NAME_MAX_BYTES { + return Err(format!("channel name exceeds {NAME_MAX_BYTES} bytes")); + } + Ok(()) + } +} + +fn parse_pubkey_list(content: &str) -> Option> { + let hexes: Vec = serde_json::from_str(content).ok()?; + hexes + .iter() + .map(|h| { + crate::simd::hex::hex_to_bytes_32_checked(h) + .and_then(|b| PublicKey::from_slice(&b).ok()) + }) + .collect() +} + +// ============================================================================ +// The fold +// ============================================================================ + +/// How a fold treats a chain that starts on a dangling `prev` (CORD-04 §1, +/// "Folding across a Refounding"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FoldMode { + /// A client that held the prior chain: an unresolvable `prev` is a gap — + /// fail closed for that entity, suspend, refetch. + Tracking, + /// A fresh joiner at a new epoch: accept the highest authority-verified + /// head as baseline despite the dangling `prev` (there is nothing behind + /// it to verify; signature + current-authority is the whole test). + FreshJoin, +} + +#[derive(Debug, Clone)] +struct AcceptedChain { + /// Version → accepted edition, ascending; contiguous from its first key. + editions: BTreeMap, + /// Version → edition hash, mirror of `editions`. + hashes: BTreeMap, +} + +impl AcceptedChain { + fn head(&self) -> Option<&Edition> { + self.editions.values().next_back() + } + + fn head_version(&self) -> u64 { + self.editions.keys().next_back().copied().unwrap_or(0) + } +} + +/// The folded Control Plane of one Community. +pub struct ControlFold { + community_id: CommunityId, + owner: PublicKey, + mode: FoldMode, + /// Every candidate ever ingested: eid → version → candidates (deduped by + /// edition hash). The fold is a deterministic function of this pool. + pool: HashMap<[u8; 32], BTreeMap>>, + /// The accepted floor — refuse-downgrade lives here. + accepted: HashMap<[u8; 32], AcceptedChain>, + /// Derived caches, rebuilt on every fold. + roster: Roster, + banned: HashSet, + dissolved: bool, +} + +impl ControlFold { + /// `owner` MUST already be verified against the `community_id` commitment + /// (`derive::verify_owner`) — the fold roots every chain at it. + pub fn new(community_id: CommunityId, owner: PublicKey, mode: FoldMode) -> Self { + ControlFold { + community_id, + owner, + mode, + pool: HashMap::new(), + accepted: HashMap::new(), + roster: Roster::new(owner), + banned: HashSet::new(), + dissolved: false, + } + } + + /// Verify the owner commitment and construct. The entry point for a + /// bundle-holding joiner. + pub fn verified( + community_id: CommunityId, + owner: PublicKey, + salt: &OwnerSalt, + mode: FoldMode, + ) -> Result { + if !verify_owner(&community_id, &owner.to_bytes(), salt) { + return Err("owner/salt do not reproduce the community_id".into()); + } + Ok(Self::new(community_id, owner, mode)) + } + + // --- ingest --- + + /// Add seal-verified editions to the pool and refold. Returns the number + /// of entities whose head changed. + pub fn ingest(&mut self, editions: impl IntoIterator) -> usize { + for ed in editions { + if ed.vsk == vsk::DISSOLVED { + // Wrong plane: the tombstone lives at the dissolved address, + // ingested via `ingest_dissolution`. + continue; + } + let versions = self.pool.entry(ed.eid).or_default(); + let slot = versions.entry(ed.version).or_default(); + if !slot.iter().any(|e| e.hash() == ed.hash()) { + slot.push(ed); + } + } + self.refold() + } + + /// Honor an owner-signed dissolution tombstone (CORD-02 §9). Anything + /// else at the coordinate is noise. Terminal: there is no un-dissolve. + pub fn ingest_dissolution(&mut self, ed: &Edition) -> bool { + if ed.vsk == vsk::DISSOLVED && ed.author == self.owner { + self.dissolved = true; + } + self.dissolved + } + + // --- accessors --- + + pub fn community_id(&self) -> &CommunityId { + &self.community_id + } + + pub fn owner(&self) -> &PublicKey { + &self.owner + } + + pub fn is_dissolved(&self) -> bool { + self.dissolved + } + + pub fn roster(&self) -> &Roster { + &self.roster + } + + /// Every honest client drops **every** event from a banned npub. + pub fn is_banned(&self, pk: &PublicKey) -> bool { + self.banned.contains(pk) + } + + pub fn banlist(&self) -> &HashSet { + &self.banned + } + + /// The folded Community metadata head, if any. + pub fn metadata(&self) -> Option { + let head = self.accepted.get(&self.community_id.0)?.head()?; + serde_json::from_str(&head.content).ok() + } + + /// Every defined Channel (deleted ones included — the flag is terminal). + pub fn channels(&self) -> Vec<(ChannelId, ChannelMetadata)> { + let mut out: Vec<(ChannelId, ChannelMetadata)> = self + .accepted + .values() + .filter_map(|chain| { + let head = chain.head()?; + if head.vsk != vsk::CHANNEL_METADATA { + return None; + } + let meta: ChannelMetadata = serde_json::from_str(&head.content).ok()?; + Some((ChannelId(head.eid), meta)) + }) + .collect(); + out.sort_by_key(|(id, _)| id.0); + out + } + + pub fn channel(&self, id: &ChannelId) -> Option { + let head = self.accepted.get(&id.0)?.head()?; + if head.vsk != vsk::CHANNEL_METADATA { + return None; + } + serde_json::from_str(&head.content).ok() + } + + /// The aggregate active public-invite set: every creator's Registry head, + /// honored only while its author holds `CREATE_INVITE` (CORD-05 §5). + pub fn registry_active_set(&self) -> Vec { + let mut set: Vec = self + .accepted + .values() + .filter_map(|chain| { + let head = chain.head()?; + if head.vsk != vsk::INVITE_REGISTRY { + return None; + } + if self.roster.permissions(&head.author) & perm::CREATE_INVITE == 0 { + return None; + } + parse_pubkey_list(&head.content) + }) + .flatten() + .collect(); + set.sort(); + set.dedup(); + set + } + + /// Non-empty active set = a live link exists = the Community is Public. + pub fn is_public(&self) -> bool { + !self.registry_active_set().is_empty() + } + + /// The current head edition of an entity, for publishing the next version. + pub fn head(&self, eid: &[u8; 32]) -> Option<&Edition> { + self.accepted.get(eid)?.head() + } + + /// The accepted hash at `(eid, version)` — what a `vac` citation pins. + pub fn accepted_hash(&self, eid: &[u8; 32], version: u64) -> Option<[u8; 32]> { + self.accepted.get(eid)?.hashes.get(&version).copied() + } + + /// Whether a Guestbook Kick is honored (CORD-02 §5 / CORD-04 §6): banlist + /// drop, citation sync floor (owner exempt), `KICK` bit, strict outrank. + pub fn may_kick( + &self, + actor: &PublicKey, + citation: Option<&super::edition::Citation>, + target: &PublicKey, + ) -> bool { + if self.banned.contains(actor) { + return false; + } + if *actor != self.owner { + let Some(c) = citation else { return false }; + match self.accepted_hash(&c.grant_eid, c.grant_version) { + Some(h) if h == c.grant_hash => {} + _ => return false, + } + } + self.roster.can_act_on(actor, perm::KICK, target) + } + + // --- the fold itself --- + + fn refold(&mut self) -> usize { + if self.dissolved { + return 0; // Death wins every race: nothing new is honored. + } + // Fixpoint: roster/banlist feed authorization, authorization feeds the + // roster. Each pass is a deterministic function of (pool, previous + // pass), starting from the accepted floor; the dependency depth is the + // delegation depth, so a small bound converges. + let mut changed_total: HashSet<[u8; 32]> = HashSet::new(); + for _ in 0..8 { + let mut changed_this_pass = false; + let eids: Vec<[u8; 32]> = self.pool.keys().copied().collect(); + for eid in eids { + let proposal = self.walk_entity(&eid); + let adopt = match (self.accepted.get(&eid), &proposal) { + (_, None) => false, + (None, Some(_)) => true, + (Some(old), Some(new)) => Self::chain_supersedes(&self.roster, old, new), + }; + if adopt { + let new_chain = proposal.expect("checked"); + let old_head = self.accepted.get(&eid).map(|c| c.head_version()); + let differs = self + .accepted + .get(&eid) + .map(|c| c.hashes != new_chain.hashes) + .unwrap_or(true); + if differs { + self.accepted.insert(eid, new_chain); + changed_this_pass = true; + changed_total.insert(eid); + let _ = old_head; + } + } + } + self.rebuild_roster(); + if !changed_this_pass { + break; + } + } + changed_total.len() + } + + /// Refuse-downgrade with a deterministic same-version arbiter: a higher + /// head wins outright; equal heads compare at the earliest divergence by + /// authority, then the lower rumor id. + fn chain_supersedes(roster: &Roster, old: &AcceptedChain, new: &AcceptedChain) -> bool { + let (ov, nv) = (old.head_version(), new.head_version()); + if nv != ov { + return nv > ov; + } + for (version, new_hash) in &new.hashes { + match old.hashes.get(version) { + Some(old_hash) if old_hash == new_hash => continue, + Some(_) => { + let old_ed = &old.editions[version]; + let new_ed = &new.editions[version]; + return Self::candidate_beats(roster, new_ed, old_ed); + } + // Different starting floors (e.g. a compaction baseline vs a + // full chain): keep what we hold. + None => return false, + } + } + false + } + + /// The same-version tiebreak (CORD-04 §1): authority first, then the + /// lower rumor id — never the author-settable timestamp. + fn candidate_beats(roster: &Roster, a: &Edition, b: &Edition) -> bool { + let (ra, rb) = (roster.rank(&a.author), roster.rank(&b.author)); + if ra.outranks(&rb) { + return true; + } + if rb.outranks(&ra) { + return false; + } + a.rumor_id.as_bytes() < b.rumor_id.as_bytes() + } + + /// Recompute one entity's best intact chain from the pool: the highest + /// version reachable, tie-broken at each step by authority then lower id. + fn walk_entity(&self, eid: &[u8; 32]) -> Option { + let versions = self.pool.get(eid)?; + let first_version = *versions.keys().next()?; + + // Anchor: version 1 (prev absent), or — fresh join after a compaction + // — the pool's lowest version accepted baseline-style on a dangling + // prev. A tracking client refuses the dangling start (a gap). + let mut starts: Vec<(&Edition, u64)> = Vec::new(); + if let Some(v1) = versions.get(&1) { + starts.extend(v1.iter().filter(|e| e.prev.is_none()).map(|e| (e, 1u64))); + } + if starts.is_empty() && self.mode == FoldMode::FreshJoin { + if let Some(cands) = versions.get(&first_version) { + starts.extend(cands.iter().map(|e| (e, first_version))); + } + } + // If we already accepted a floor for this entity, its own start is + // always a valid anchor (it may have been a baseline). + if let Some(existing) = self.accepted.get(eid) { + if let Some((v, ed)) = existing.editions.iter().next() { + if !starts.iter().any(|(e, _)| e.hash() == ed.hash()) { + starts.push((ed, *v)); + } + } + } + + let mut best: Option = None; + for (start, start_version) in starts { + if !self.authorize(start, None) { + continue; + } + let mut chain = AcceptedChain { editions: BTreeMap::new(), hashes: BTreeMap::new() }; + chain.editions.insert(start_version, start.clone()); + chain.hashes.insert(start_version, start.hash()); + self.extend_chain(versions, &mut chain, start_version); + best = match best { + None => Some(chain), + Some(current) => { + if Self::chain_supersedes(&self.roster, ¤t, &chain) { + Some(chain) + } else { + Some(current) + } + } + }; + } + best + } + + /// Greedy forward walk: at each next version pick the deterministic winner + /// among chain-intact, authorized candidates. Greedy is convergent here + /// because honest children only ever cite the winner's hash — a losing + /// same-version sibling has no valid descendants to out-reach it. + fn extend_chain( + &self, + versions: &BTreeMap>, + chain: &mut AcceptedChain, + mut at: u64, + ) { + loop { + let head_hash = chain.hashes[&at]; + let prior = chain.editions.get(&at).cloned(); + let next = at + 1; + let Some(candidates) = versions.get(&next) else { break }; + let mut eligible: Vec<&Edition> = candidates + .iter() + .filter(|e| e.prev == Some(head_hash) && self.authorize(e, prior.as_ref())) + .collect(); + if eligible.is_empty() { + break; + } + eligible.sort_by(|a, b| { + if Self::candidate_beats(&self.roster, a, b) { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Greater + } + }); + let winner = eligible[0].clone(); + chain.hashes.insert(next, winner.hash()); + chain.editions.insert(next, winner); + at = next; + } + } + + /// CORD-04 §5, judged against the *current* fixpoint state: seal-verified + /// actor (already done upstream), banlist drop, citation sync floor, the + /// required bit, and strict outranking per entity type. + fn authorize(&self, ed: &Edition, prior: Option<&Edition>) -> bool { + let author = ed.author; + if self.banned.contains(&author) { + return false; + } + + // Citation: absent when the owner acts; required from everyone else, + // and it must resolve (block-until-synced) before the action is + // honored. A hash mismatch parks exactly like an unsynced one. + if author != self.owner { + let Some(citation) = &ed.citation else { return false }; + match self.accepted_hash(&citation.grant_eid, citation.grant_version) { + Some(h) if h == citation.grant_hash => {} + _ => return false, // parked: the pool retains it for later folds + } + } + + match ed.vsk { + vsk::COMMUNITY_METADATA => { + if ed.eid != self.community_id.0 { + return false; + } + if self.roster.permissions(&author) & perm::MANAGE_METADATA == 0 { + return false; + } + serde_json::from_str::(&ed.content) + .map(|m| m.validate().is_ok()) + .unwrap_or(false) + } + vsk::ROLE => { + let Ok(role) = serde_json::from_str::(&ed.content) else { return false }; + if role.role_id != ed.eid || role.validate().is_err() { + return false; + } + if !self.roster.may_place_role(&author, role.position) { + return false; + } + // Editing an existing Role: the actor must also outrank what + // it currently is, or a junior could rewrite a senior Role. + if let Some(prev) = prior { + if let Ok(existing) = serde_json::from_str::(&prev.content) { + if !self.roster.rank(&author).outranks(&Rank::Position(existing.position)) { + return false; + } + } + } + true + } + vsk::CHANNEL_METADATA => { + let Ok(meta) = serde_json::from_str::(&ed.content) else { + return false; + }; + if meta.validate().is_err() { + return false; + } + if self.roster.permissions(&author) & perm::MANAGE_CHANNELS == 0 { + return false; + } + // Deletion is terminal: nothing follows a deleted state. + if let Some(prev) = prior { + if let Ok(existing) = serde_json::from_str::(&prev.content) { + if existing.deleted { + return false; + } + } + } + true + } + vsk::GRANT => { + let Ok(grant) = serde_json::from_str::(&ed.content) else { return false }; + let Ok(member) = PublicKey::from_slice(&grant.member) else { return false }; + if ed.eid != grant_locator(&self.community_id, &grant.member) { + return false; + } + let role_ids: Vec = grant.role_ids.iter().map(|r| RoleId(*r)).collect(); + self.roster.may_grant(&author, &member, &role_ids) + } + vsk::BANLIST => { + if ed.eid != banlist_locator(&self.community_id) { + return false; + } + if self.roster.permissions(&author) & perm::BAN == 0 { + return false; + } + let Some(new_list) = parse_pubkey_list(&ed.content) else { return false }; + // The owner is unbannable, and every newly added target must + // be strictly outranked. + let prev_list: HashSet = prior + .and_then(|p| parse_pubkey_list(&p.content)) + .map(|v| v.into_iter().collect()) + .unwrap_or_default(); + let actor_rank = self.roster.rank(&author); + new_list.iter().all(|target| { + *target != self.owner + && (prev_list.contains(target) || actor_rank.outranks(&self.roster.rank(target))) + }) + } + vsk::INVITE_REGISTRY => { + // Coordinate bound to the creator: nobody forges entries into + // anyone else's Registry. + if ed.eid != invite_links_locator(&self.community_id, &author.to_bytes()) { + return false; + } + if self.roster.permissions(&author) & perm::CREATE_INVITE == 0 { + return false; + } + parse_pubkey_list(&ed.content).is_some() + } + _ => false, + } + } + + fn rebuild_roster(&mut self) { + let mut roster = Roster::new(self.owner); + // Roles first (BTreeMap order via sorted eids keeps the cap + // deterministic), then Grants. + let mut role_heads: Vec<&Edition> = Vec::new(); + let mut grant_heads: Vec<&Edition> = Vec::new(); + let mut banlist_head: Option<&Edition> = None; + for chain in self.accepted.values() { + let Some(head) = chain.head() else { continue }; + match head.vsk { + vsk::ROLE => role_heads.push(head), + vsk::GRANT => grant_heads.push(head), + vsk::BANLIST => banlist_head = Some(head), + _ => {} + } + } + role_heads.sort_by_key(|e| e.eid); + for head in role_heads { + if let Ok(role) = serde_json::from_str::(&head.content) { + roster.insert_role(role); + } + } + for head in grant_heads { + if let Ok(grant) = serde_json::from_str::(&head.content) { + if let Ok(member) = PublicKey::from_slice(&grant.member) { + roster.apply_grant(member, grant.role_ids.iter().map(|r| RoleId(*r)).collect()); + } + } + } + self.banned = banlist_head + .and_then(|h| parse_pubkey_list(&h.content)) + .map(|v| v.into_iter().collect()) + .unwrap_or_default(); + self.roster = roster; + } +} + +/// Convenience: parse + fold errors surfaced together for transports. +pub fn parse_and_note(rumor: &nostr_sdk::prelude::UnsignedEvent) -> Result { + super::edition::parse_edition(rumor) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::concord::v2::edition::{build_edition_rumor, parse_edition, Citation}; + use crate::concord::v2::roster::RoleScope; + use nostr_sdk::prelude::Keys; + + struct TestCtx { + cid: CommunityId, + owner: Keys, + fold: ControlFold, + } + + fn ctx() -> TestCtx { + let owner = Keys::generate(); + let salt = OwnerSalt([0x33; 32]); + let cid = crate::concord::v2::derive::community_id(&owner.public_key().to_bytes(), &salt); + let fold = ControlFold::verified(cid, owner.public_key(), &salt, FoldMode::Tracking).unwrap(); + TestCtx { cid, owner, fold } + } + + fn edition( + author: &Keys, + entity_vsk: u8, + eid: &[u8; 32], + version: u64, + prev: Option<&[u8; 32]>, + content: &str, + citation: Option<&Citation>, + ) -> Edition { + let rumor = build_edition_rumor(author.public_key(), entity_vsk, eid, version, prev, content, 1_700_000_000, citation); + parse_edition(&rumor).unwrap() + } + + fn role_json(id: u8, position: u32, permissions: u64) -> String { + serde_json::to_string(&Role { + role_id: [id; 32], + name: "role".into(), + position, + permissions, + scope: RoleScope::Server, + color: 0, + }) + .unwrap() + } + + fn grant_json(member: &Keys, roles: &[u8]) -> String { + serde_json::to_string(&Grant { + member: member.public_key().to_bytes(), + role_ids: roles.iter().map(|i| [*i; 32]).collect(), + }) + .unwrap() + } + + fn metadata_json(name: &str) -> String { + format!("{{\"name\":\"{name}\",\"relays\":[\"wss://a\"]}}") + } + + /// Owner founds: metadata v1 + #general v1 (genesis, CORD-02 §1). + fn genesis(t: &mut TestCtx) -> ChannelId { + let chan = ChannelId([0x77; 32]); + let meta = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 1, None, &metadata_json("Vector"), None); + let general = edition( + &t.owner, + vsk::CHANNEL_METADATA, + &chan.0, + 1, + None, + "{\"name\":\"general\",\"private\":false}", + None, + ); + t.fold.ingest([meta, general]); + chan + } + + #[test] + fn genesis_folds() { + let mut t = ctx(); + let chan = genesis(&mut t); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + assert_eq!(t.fold.channel(&chan).unwrap().name, "general"); + assert!(!t.fold.is_public(), "no live links at genesis: Private"); + } + + #[test] + fn owner_verification_is_enforced() { + let owner = Keys::generate(); + let salt = OwnerSalt([0x33; 32]); + let cid = crate::concord::v2::derive::community_id(&owner.public_key().to_bytes(), &salt); + let impostor = Keys::generate(); + assert!(ControlFold::verified(cid, impostor.public_key(), &salt, FoldMode::Tracking).is_err()); + } + + #[test] + fn version_chain_folds_to_highest_intact() { + let mut t = ctx(); + genesis(&mut t); + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let v2 = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Renamed"), None); + let v2_hash = v2.hash(); + let v3 = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 3, Some(&v2_hash), &metadata_json("Renamed again"), None); + // Arrival order must not matter. + t.fold.ingest([v3.clone()]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector", "v3 dangles until v2 arrives"); + t.fold.ingest([v2]); + assert_eq!(t.fold.metadata().unwrap().name, "Renamed again"); + } + + #[test] + fn refuse_downgrade_ignores_replayed_stale_state() { + let mut t = ctx(); + genesis(&mut t); + let v1 = t.fold.head(&t.cid.0).unwrap().clone(); + let v2 = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1.hash()), &metadata_json("Renamed"), None); + t.fold.ingest([v2]); + assert_eq!(t.fold.metadata().unwrap().name, "Renamed"); + // A relay replaying v1 must not move the head back. + t.fold.ingest([v1]); + assert_eq!(t.fold.metadata().unwrap().name, "Renamed"); + } + + #[test] + fn unauthorized_editor_is_dropped() { + let mut t = ctx(); + genesis(&mut t); + let rando = Keys::generate(); + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + // Perfectly shaped, correctly chained, validly signed — and dropped: + // the signer maps to no qualifying rank. + let citation = Citation { grant_eid: [0; 32], grant_version: 1, grant_hash: [0; 32] }; + let forged = edition(&rando, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Owned"), Some(&citation)); + t.fold.ingest([forged]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + /// The full delegation flow: owner mints a Role, Grants it, and the + /// grantee acts under a resolving citation. + fn delegate_admin(t: &mut TestCtx, admin: &Keys, position: u32, permissions: u64) -> Citation { + let role_ed = edition(&t.owner, vsk::ROLE, &[0x01; 32], 1, None, &role_json(0x01, position, permissions), None); + let grant_eid = grant_locator(&t.cid, &admin.public_key().to_bytes()); + let grant_ed = edition(&t.owner, vsk::GRANT, &grant_eid, 1, None, &grant_json(admin, &[0x01]), None); + let grant_hash = grant_ed.hash(); + t.fold.ingest([role_ed, grant_ed]); + Citation { grant_eid, grant_version: 1, grant_hash } + } + + #[test] + fn delegated_admin_can_act_with_citation() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let vac = delegate_admin(&mut t, &admin, 1, perm::MANAGE_METADATA); + assert_eq!(t.fold.roster().rank(&admin.public_key()), Rank::Position(1)); + + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("ByAdmin"), Some(&vac)); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "ByAdmin"); + } + + #[test] + fn action_without_citation_is_dropped_unless_owner() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + delegate_admin(&mut t, &admin, 1, perm::MANAGE_METADATA); + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("NoVac"), None); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + #[test] + fn citation_parks_until_the_grant_syncs() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + + // Build the grant material but DON'T ingest it yet. + let role_ed = edition(&t.owner, vsk::ROLE, &[0x01; 32], 1, None, &role_json(0x01, 1, perm::MANAGE_METADATA), None); + let grant_eid = grant_locator(&t.cid, &admin.public_key().to_bytes()); + let grant_ed = edition(&t.owner, vsk::GRANT, &grant_eid, 1, None, &grant_json(&admin, &[0x01]), None); + let vac = Citation { grant_eid, grant_version: 1, grant_hash: grant_ed.hash() }; + + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Parked"), Some(&vac)); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector", "block-until-synced"); + + // The grant arrives: the parked action unparks on the next fold. + t.fold.ingest([role_ed, grant_ed]); + assert_eq!(t.fold.metadata().unwrap().name, "Parked"); + } + + #[test] + fn forged_citation_hash_never_resolves() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let real_vac = delegate_admin(&mut t, &admin, 1, perm::MANAGE_METADATA); + let forged = Citation { grant_hash: [0xEE; 32], ..real_vac }; + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Forged"), Some(&forged)); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + #[test] + fn demotion_is_never_grandfathered() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let vac = delegate_admin(&mut t, &admin, 1, perm::MANAGE_METADATA); + + // Owner revokes (grant v2, empty roles). + let grant_eid = vac.grant_eid; + let g1_hash = t.fold.head(&grant_eid).unwrap().hash(); + let revoke = edition(&t.owner, vsk::GRANT, &grant_eid, 2, Some(&g1_hash), &grant_json(&admin, &[]), None); + t.fold.ingest([revoke]); + + // The demoted admin's action citing the OLD (once-valid) grant is + // dropped: rank resolves against the current refuse-downgrade roster. + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Stale"), Some(&vac)); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + #[test] + fn same_version_tie_breaks_by_authority_then_lower_id() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let vac = delegate_admin(&mut t, &admin, 1, perm::MANAGE_METADATA); + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + + // Owner and admin race the same version: authority (owner) wins, + // regardless of ingest order. + let by_admin = edition(&admin, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("AdminWins"), Some(&vac)); + let by_owner = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("OwnerWins"), None); + t.fold.ingest([by_admin.clone()]); + t.fold.ingest([by_owner.clone()]); + assert_eq!(t.fold.metadata().unwrap().name, "OwnerWins"); + + let mut t2 = ctx(); + // Rebuild the same community in the other ingest order. (Fresh keys + // per ctx, so rebuild the delegation too.) + genesis(&mut t2); + let admin2 = Keys::generate(); + let vac2 = delegate_admin(&mut t2, &admin2, 1, perm::MANAGE_METADATA); + let v1h2 = t2.fold.head(&t2.cid.0).unwrap().hash(); + let a = edition(&t2.owner, vsk::COMMUNITY_METADATA, &t2.cid.0, 2, Some(&v1h2), &metadata_json("OwnerWins"), None); + let b = edition(&admin2, vsk::COMMUNITY_METADATA, &t2.cid.0, 2, Some(&v1h2), &metadata_json("AdminWins"), Some(&vac2)); + t2.fold.ingest([a]); + t2.fold.ingest([b]); + assert_eq!(t2.fold.metadata().unwrap().name, "OwnerWins"); + } + + #[test] + fn equal_authority_tie_breaks_by_lower_rumor_id() { + let mut t = ctx(); + genesis(&mut t); + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let a = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("A"), None); + let b = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("B"), None); + let expect = if a.rumor_id.as_bytes() < b.rumor_id.as_bytes() { "A" } else { "B" }; + // Both orders converge on the lower id. + t.fold.ingest([a.clone(), b.clone()]); + assert_eq!(t.fold.metadata().unwrap().name, expect); + // Same community, reverse arrival order. + let mut fold = ControlFold::new(t.cid, t.owner.public_key(), FoldMode::Tracking); + let meta1 = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 1, None, &metadata_json("Vector"), None); + fold.ingest([meta1]); + fold.ingest([b, a]); + assert_eq!(fold.metadata().unwrap().name, expect); + } + + #[test] + fn no_self_promotion_and_no_position_zero() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let vac = delegate_admin(&mut t, &admin, 2, perm::MANAGE_ROLES); + // A role at the actor's own position (or above) is refused. + let peer_role = edition(&admin, vsk::ROLE, &[0x05; 32], 1, None, &role_json(0x05, 2, perm::BAN), Some(&vac)); + let above_role = edition(&admin, vsk::ROLE, &[0x06; 32], 1, None, &role_json(0x06, 1, perm::BAN), Some(&vac)); + t.fold.ingest([peer_role, above_role]); + assert!(t.fold.roster().roles.get(&RoleId([0x05; 32])).is_none()); + assert!(t.fold.roster().roles.get(&RoleId([0x06; 32])).is_none()); + // Below: honored. + let below = edition(&admin, vsk::ROLE, &[0x07; 32], 1, None, &role_json(0x07, 3, perm::BAN), Some(&vac)); + t.fold.ingest([below]); + assert!(t.fold.roster().roles.get(&RoleId([0x07; 32])).is_some()); + // Position 0 is unmintable, the owner included. + let zero = edition(&t.owner, vsk::ROLE, &[0x08; 32], 1, None, &role_json(0x08, 0, 0), None); + t.fold.ingest([zero]); + assert!(t.fold.roster().roles.get(&RoleId([0x08; 32])).is_none()); + } + + #[test] + fn banlist_gating_and_banned_author_drop() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let troll = Keys::generate(); + let vac = delegate_admin(&mut t, &admin, 1, perm::BAN | perm::MANAGE_METADATA); + + let ban_eid = banlist_locator(&t.cid); + let ban = edition( + &admin, + vsk::BANLIST, + &ban_eid, + 1, + None, + &format!("[\"{}\"]", troll.public_key().to_hex()), + Some(&vac), + ); + t.fold.ingest([ban]); + assert!(t.fold.is_banned(&troll.public_key())); + + // A banned npub's editions are dropped entirely — even well-formed + // ones with a stolen-looking citation. + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let from_troll = edition(&troll, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("Troll"), Some(&vac)); + t.fold.ingest([from_troll]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + #[test] + fn banning_the_owner_or_a_peer_is_refused() { + let mut t = ctx(); + genesis(&mut t); + let admin = Keys::generate(); + let peer = Keys::generate(); + let vac_a = delegate_admin(&mut t, &admin, 1, perm::BAN); + // Give peer the same role (same rank). + let peer_grant_eid = grant_locator(&t.cid, &peer.public_key().to_bytes()); + let peer_grant = edition(&t.owner, vsk::GRANT, &peer_grant_eid, 1, None, &grant_json(&peer, &[0x01]), None); + t.fold.ingest([peer_grant]); + + let ban_eid = banlist_locator(&t.cid); + // Owner ban: refused outright. + let ban_owner = edition(&admin, vsk::BANLIST, &ban_eid, 1, None, &format!("[\"{}\"]", t.owner.public_key().to_hex()), Some(&vac_a)); + // Peer ban: equal cannot act on equal. + let ban_peer = edition(&admin, vsk::BANLIST, &ban_eid, 1, None, &format!("[\"{}\"]", peer.public_key().to_hex()), Some(&vac_a)); + t.fold.ingest([ban_owner, ban_peer]); + assert!(t.fold.banlist().is_empty()); + } + + #[test] + fn channel_deletion_is_terminal() { + let mut t = ctx(); + let chan = genesis(&mut t); + let v1_hash = t.fold.head(&chan.0).unwrap().hash(); + let delete = edition(&t.owner, vsk::CHANNEL_METADATA, &chan.0, 2, Some(&v1_hash), "{\"name\":\"general\",\"private\":false,\"deleted\":true}", None); + let d_hash = delete.hash(); + t.fold.ingest([delete]); + assert!(t.fold.channel(&chan).unwrap().deleted); + // Even the owner cannot resurrect a deleted Channel. + let resurrect = edition(&t.owner, vsk::CHANNEL_METADATA, &chan.0, 3, Some(&d_hash), "{\"name\":\"general\",\"private\":false}", None); + t.fold.ingest([resurrect]); + assert!(t.fold.channel(&chan).unwrap().deleted); + } + + #[test] + fn registry_binds_to_its_creator_and_gates_public() { + let mut t = ctx(); + genesis(&mut t); + let creator = Keys::generate(); + let vac = delegate_admin(&mut t, &creator, 1, perm::CREATE_INVITE); + let link_signer = Keys::generate().public_key(); + + // Registry at someone ELSE's coordinate: refused. + let other_eid = invite_links_locator(&t.cid, &Keys::generate().public_key().to_bytes()); + let forged = edition(&creator, vsk::INVITE_REGISTRY, &other_eid, 1, None, &format!("[\"{}\"]", link_signer.to_hex()), Some(&vac)); + t.fold.ingest([forged]); + assert!(!t.fold.is_public()); + + // Own coordinate: honored; the Community flips Public. + let own_eid = invite_links_locator(&t.cid, &creator.public_key().to_bytes()); + let registry = edition(&creator, vsk::INVITE_REGISTRY, &own_eid, 1, None, &format!("[\"{}\"]", link_signer.to_hex()), Some(&vac)); + let r_hash = registry.hash(); + t.fold.ingest([registry]); + assert!(t.fold.is_public()); + assert_eq!(t.fold.registry_active_set(), vec![link_signer]); + + // Retiring the last live link empties the set: Private again. + let empty = edition(&creator, vsk::INVITE_REGISTRY, &own_eid, 2, Some(&r_hash), "[]", Some(&vac)); + t.fold.ingest([empty]); + assert!(!t.fold.is_public()); + } + + #[test] + fn fresh_join_accepts_a_compaction_baseline_tracking_fails_closed() { + let mut t = ctx(); + genesis(&mut t); + // Simulate a post-Refounding head: metadata v5 whose prev cites an + // edition that no longer exists. + let head = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 5, Some(&[0xAA; 32]), &metadata_json("Refounded"), None); + + // Tracking client that held v1: the dangling prev is a gap — fail + // closed for that entity, keep the old state. + t.fold.ingest([head.clone()]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + + // Fresh joiner from nothing: signature + current-authority is the + // whole test; the head is the baseline. + let mut fresh = ControlFold::new(t.cid, t.owner.public_key(), FoldMode::FreshJoin); + fresh.ingest([head]); + assert_eq!(fresh.metadata().unwrap().name, "Refounded"); + assert_eq!(fresh.head(&t.cid.0).unwrap().version, 5); + + // And the baseline extends normally afterward. + let h5 = fresh.head(&t.cid.0).unwrap().hash(); + let v6 = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 6, Some(&h5), &metadata_json("Onward"), None); + fresh.ingest([v6]); + assert_eq!(fresh.metadata().unwrap().name, "Onward"); + } + + #[test] + fn dissolution_is_terminal_and_owner_only() { + let mut t = ctx(); + genesis(&mut t); + let impostor = Keys::generate(); + let fake = parse_edition(&crate::concord::v2::edition::build_dissolved_rumor(impostor.public_key(), 1_725_000_000)).unwrap(); + assert!(!t.fold.ingest_dissolution(&fake), "an impostor's tombstone is noise"); + + let real = parse_edition(&crate::concord::v2::edition::build_dissolved_rumor(t.owner.public_key(), 1_725_000_000)).unwrap(); + assert!(t.fold.ingest_dissolution(&real)); + assert!(t.fold.is_dissolved()); + + // Nothing new is honored post-seal. + let v1_hash = t.fold.head(&t.cid.0).unwrap().hash(); + let rename = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 2, Some(&v1_hash), &metadata_json("PostMortem"), None); + t.fold.ingest([rename]); + assert_eq!(t.fold.metadata().unwrap().name, "Vector"); + } + + #[test] + fn metadata_custom_fields_round_trip() { + let json = "{\"name\":\"V\",\"relays\":[],\"custom\":{\"rules\":\"Be excellent.\",\"vector/theme\":\"dark\"},\"future_field\":42}"; + let meta: CommunityMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(meta.custom.as_ref().unwrap()["rules"], "Be excellent."); + // Unknown top-level fields survive a round trip. + let back = serde_json::to_value(&meta).unwrap(); + assert_eq!(back["future_field"], 42); + assert_eq!(back["custom"]["vector/theme"], "dark"); + } + + #[test] + fn oversized_names_are_dropped_at_fold() { + let mut t = ctx(); + let long_name = "x".repeat(NAME_MAX_BYTES + 1); + let meta = edition(&t.owner, vsk::COMMUNITY_METADATA, &t.cid.0, 1, None, &metadata_json(&long_name), None); + t.fold.ingest([meta]); + assert!(t.fold.metadata().is_none()); + } +} diff --git a/crates/vector-core/src/concord/v2/db.rs b/crates/vector-core/src/concord/v2/db.rs new file mode 100644 index 00000000..689b561b --- /dev/null +++ b/crates/vector-core/src/concord/v2/db.rs @@ -0,0 +1,450 @@ +//! Persistence for Concord v2 local state (the `concord2_*` tables). +//! +//! Stores the secrets this account *holds* — the community root and +//! private-channel keys — plus channel definitions and the signed Control +//! Plane edition seals (verbatim JSON: rebuilds the fold at boot and re-wraps +//! heads signature-intact in a compaction). The DB is already account-scoped +//! (`account_dir(npub)/vector.db`), so there is no npub column. At-rest +//! encryption mirrors v1: with Local Encryption on, every secret blob and +//! identifying text field wraps under the account's ENCRYPTION_KEY. + +use nostr_sdk::prelude::PublicKey; +use rusqlite::{params, OptionalExtension}; + +use super::community::{Channel, Community}; +use super::{ChannelId, ChannelKey, CommunityId, CommunityRoot, Epoch, OwnerSalt}; + +fn enc_key(k: &[u8; 32]) -> Result, String> { + crate::crypto::maybe_encrypt_blob(k) +} + +fn dec_key(stored: &[u8]) -> Result<[u8; 32], String> { + let plain = crate::crypto::maybe_decrypt_blob(stored); + plain + .as_slice() + .try_into() + .map_err(|_| format!("expected 32-byte key, got {} bytes", plain.len())) +} + +fn enc_txt(s: &str) -> Result { + crate::crypto::maybe_encrypt_text(s) +} + +fn dec_txt(s: &str) -> String { + crate::crypto::maybe_decrypt_text(s) +} + +fn hex32(s: &str) -> Result<[u8; 32], String> { + crate::simd::hex::hex_to_bytes_32_checked(s) + .ok_or_else(|| format!("corrupt or wrong-length 64-char hex id ({} chars)", s.len())) +} + +fn now_secs() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Persist a Community and all its channels (upsert). +pub fn save_community(community: &Community) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + let relays_json = serde_json::to_string(&community.relays).map_err(|e| e.to_string())?; + let community_id = community.id.to_hex(); + let description = match &community.description { + Some(d) => Some(enc_txt(d)?), + None => None, + }; + conn.execute( + "INSERT INTO concord2_communities (community_id, owner, owner_salt, root, root_epoch, name, description, relays, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(community_id) DO UPDATE SET + owner = ?2, owner_salt = ?3, root = ?4, root_epoch = ?5, name = ?6, description = ?7, relays = ?8", + params![ + community_id, + enc_txt(&community.owner.to_hex())?, + enc_txt(&crate::simd::hex::bytes_to_hex_32(&community.owner_salt.0))?, + enc_key(community.root.as_bytes())?, + community.root_epoch.0 as i64, + enc_txt(&community.name)?, + description, + enc_txt(&relays_json)?, + now_secs(), + ], + ) + .map_err(|e| format!("save concord2 community: {e}"))?; + + for chan in &community.channels { + let key_blob = match &chan.key { + Some(k) => Some(enc_key(k.as_bytes())?), + None => None, + }; + conn.execute( + "INSERT INTO concord2_channels (channel_id, community_id, name, private, deleted, channel_key, epoch) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + ON CONFLICT(channel_id) DO UPDATE SET + name = ?3, private = ?4, deleted = ?5, channel_key = ?6, epoch = ?7", + params![ + chan.id.to_hex(), + community_id, + enc_txt(&chan.name)?, + chan.private as i64, + chan.deleted as i64, + key_blob, + chan.epoch.0 as i64, + ], + ) + .map_err(|e| format!("save concord2 channel: {e}"))?; + } + Ok(()) +} + +fn load_channels( + conn: &rusqlite::Connection, + community_id: &str, +) -> Result, String> { + let mut stmt = conn + .prepare("SELECT channel_id, name, private, deleted, channel_key, epoch FROM concord2_channels WHERE community_id = ?1") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, Option>>(4)?, + row.get::<_, i64>(5)?, + )) + }) + .map_err(|e| e.to_string())?; + let mut channels = Vec::new(); + for row in rows { + let (id_hex, name, private, deleted, key_blob, epoch) = row.map_err(|e| e.to_string())?; + let key = match key_blob { + Some(blob) => Some(ChannelKey(dec_key(&blob)?)), + None => None, + }; + channels.push(Channel { + id: ChannelId(hex32(&id_hex)?), + name: dec_txt(&name), + private: private != 0, + deleted: deleted != 0, + key, + epoch: Epoch(epoch as u64), + }); + } + channels.sort_by_key(|c| c.id.0); + Ok(channels) +} + +pub fn load_community(id: &CommunityId) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let community_id = id.to_hex(); + let row = conn + .query_row( + "SELECT owner, owner_salt, root, root_epoch, name, description, relays FROM concord2_communities WHERE community_id = ?1", + params![community_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Vec>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, Option>(5)?, + row.get::<_, String>(6)?, + )) + }, + ) + .optional() + .map_err(|e| e.to_string())?; + let Some((owner, salt, root, root_epoch, name, description, relays)) = row else { + return Ok(None); + }; + let owner = PublicKey::from_slice(&hex32(&dec_txt(&owner))?).map_err(|e| e.to_string())?; + let relays: Vec = serde_json::from_str(&dec_txt(&relays)).unwrap_or_default(); + Ok(Some(Community { + id: *id, + owner, + owner_salt: OwnerSalt(hex32(&dec_txt(&salt))?), + root: CommunityRoot(dec_key(&root)?), + root_epoch: Epoch(root_epoch as u64), + name: dec_txt(&name), + description: description.map(|d| dec_txt(&d)), + relays, + channels: load_channels(&conn, &community_id)?, + })) +} + +pub fn list_community_ids() -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT community_id FROM concord2_communities") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map([], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut ids = Vec::new(); + for row in rows { + ids.push(CommunityId(hex32(&row.map_err(|e| e.to_string())?)?)); + } + Ok(ids) +} + +pub fn list_communities() -> Result, String> { + let mut out = Vec::new(); + for id in list_community_ids()? { + if let Some(c) = load_community(&id)? { + out.push(c); + } + } + Ok(out) +} + +/// Resolve a channel hex id to its owning v2 community hex id — the routing +/// probe the Tauri commands use to branch v1/v2. +pub fn community_id_for_channel(channel_id: &str) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + conn.query_row( + "SELECT community_id FROM concord2_channels WHERE channel_id = ?1", + params![channel_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(|e| e.to_string()) +} + +pub fn community_created_at_ms(id: &CommunityId) -> Option { + let conn = crate::db::get_db_connection_guard_static().ok()?; + conn.query_row( + "SELECT created_at FROM concord2_communities WHERE community_id = ?1", + params![id.to_hex()], + |row| row.get::<_, i64>(0), + ) + .optional() + .ok() + .flatten() + .map(|secs| (secs as u64).saturating_mul(1000)) +} + +pub fn set_dissolved(id: &CommunityId) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "UPDATE concord2_communities SET dissolved = 1 WHERE community_id = ?1", + params![id.to_hex()], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +pub fn is_dissolved(id: &CommunityId) -> bool { + let Ok(conn) = crate::db::get_db_connection_guard_static() else { + return false; + }; + conn.query_row( + "SELECT dissolved FROM concord2_communities WHERE community_id = ?1", + params![id.to_hex()], + |row| row.get::<_, i64>(0), + ) + .optional() + .unwrap_or(None) + .map(|d| d != 0) + .unwrap_or(false) +} + +/// Delete a community and every scoped row (channels, editions). Chat/event +/// rows are handled by the caller's teardown. +pub fn delete_community(id: &CommunityId) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + let hex = id.to_hex(); + conn.execute("DELETE FROM concord2_channels WHERE community_id = ?1", params![hex]) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM concord2_editions WHERE community_id = ?1", params![hex]) + .map_err(|e| e.to_string())?; + conn.execute("DELETE FROM concord2_communities WHERE community_id = ?1", params![hex]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +/// Persist one edition's signed plaintext-seal JSON (INSERT OR IGNORE: an +/// edition is immutable, keyed by coordinate + version). +pub fn save_edition_seal( + community_id: &CommunityId, + eid: &[u8; 32], + version: u64, + seal_json: &str, +) -> Result<(), String> { + let conn = crate::db::get_write_connection_guard_static()?; + conn.execute( + "INSERT OR IGNORE INTO concord2_editions (community_id, eid, version, seal_json) VALUES (?1, ?2, ?3, ?4)", + params![ + community_id.to_hex(), + crate::simd::hex::bytes_to_hex_32(eid), + version as i64, + enc_txt(seal_json)?, + ], + ) + .map_err(|e| format!("save concord2 edition: {e}"))?; + Ok(()) +} + +/// Load every persisted edition seal for a community (fold rebuild at boot). +pub fn load_edition_seals(community_id: &CommunityId) -> Result, String> { + let conn = crate::db::get_db_connection_guard_static()?; + let mut stmt = conn + .prepare("SELECT seal_json FROM concord2_editions WHERE community_id = ?1 ORDER BY eid, version") + .map_err(|e| e.to_string())?; + let rows = stmt + .query_map(params![community_id.to_hex()], |row| row.get::<_, String>(0)) + .map_err(|e| e.to_string())?; + let mut out = Vec::new(); + for row in rows { + out.push(dec_txt(&row.map_err(|e| e.to_string())?)); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::Keys; + + static TEST_COUNTER: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(5000); + + fn make_test_npub(n: u32) -> String { + const BECH32: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l"; + let mut payload = vec![b'q'; 58]; + let mut x = n as u64; + let mut i = 58; + while x > 0 && i > 0 { + i -= 1; + payload[i] = BECH32[(x as usize) % 32]; + x /= 32; + } + format!("npub1{}", std::str::from_utf8(&payload).unwrap()) + } + + fn init_test_db() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>) { + let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner()); + crate::db::close_database(); + let tmp = tempfile::tempdir().unwrap(); + let n = TEST_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let account = make_test_npub(n); + std::fs::create_dir_all(tmp.path().join(&account)).unwrap(); + crate::db::set_app_data_dir(tmp.path().to_path_buf()); + crate::db::set_current_account(account.clone()).unwrap(); + crate::db::init_database(&account).unwrap(); + (tmp, guard) + } + + fn test_community() -> Community { + let owner = Keys::generate().public_key(); + let salt = OwnerSalt([0x33; 32]); + let id = super::super::derive::community_id(&owner.to_bytes(), &salt); + Community { + id, + owner, + owner_salt: salt, + root: CommunityRoot([0x44; 32]), + root_epoch: Epoch(0), + name: "Vector".into(), + description: Some("Private messaging.".into()), + relays: vec!["wss://a".into(), "wss://b".into()], + channels: vec![ + Channel { + id: ChannelId([0x77; 32]), + name: "general".into(), + private: false, + deleted: false, + key: None, + epoch: Epoch(0), + }, + Channel { + id: ChannelId([0x99; 32]), + name: "testers".into(), + private: true, + deleted: false, + key: Some(ChannelKey([0x42; 32])), + epoch: Epoch(1), + }, + ], + } + } + + #[test] + fn community_round_trips_with_keys_and_channels() { + let (_tmp, _guard) = init_test_db(); + let community = test_community(); + save_community(&community).unwrap(); + + let loaded = load_community(&community.id).unwrap().unwrap(); + assert_eq!(loaded.id, community.id); + assert_eq!(loaded.owner, community.owner); + assert_eq!(loaded.owner_salt, community.owner_salt); + assert_eq!(loaded.root, community.root); + assert_eq!(loaded.name, "Vector"); + assert_eq!(loaded.description.as_deref(), Some("Private messaging.")); + assert_eq!(loaded.relays, community.relays); + assert_eq!(loaded.channels.len(), 2); + let testers = loaded.channels.iter().find(|c| c.name == "testers").unwrap(); + assert_eq!(testers.key, Some(ChannelKey([0x42; 32]))); + assert_eq!(testers.epoch, Epoch(1)); + assert!(testers.private); + + assert_eq!(list_community_ids().unwrap(), vec![community.id]); + assert_eq!( + community_id_for_channel(&ChannelId([0x77; 32]).to_hex()).unwrap(), + Some(community.id.to_hex()) + ); + assert_eq!(community_id_for_channel(&"f".repeat(64)).unwrap(), None); + } + + #[test] + fn upsert_follows_rotation_and_renames() { + let (_tmp, _guard) = init_test_db(); + let mut community = test_community(); + save_community(&community).unwrap(); + + // A refounding rotates the root; a control fold renames. + community.root = CommunityRoot([0x55; 32]); + community.root_epoch = Epoch(1); + community.name = "Vector HQ".into(); + save_community(&community).unwrap(); + + let loaded = load_community(&community.id).unwrap().unwrap(); + assert_eq!(loaded.root, CommunityRoot([0x55; 32])); + assert_eq!(loaded.root_epoch, Epoch(1)); + assert_eq!(loaded.name, "Vector HQ"); + assert_eq!(loaded.channels.len(), 2, "channels upsert, never duplicate"); + } + + #[test] + fn dissolved_flag_and_delete() { + let (_tmp, _guard) = init_test_db(); + let community = test_community(); + save_community(&community).unwrap(); + assert!(!is_dissolved(&community.id)); + set_dissolved(&community.id).unwrap(); + assert!(is_dissolved(&community.id)); + + delete_community(&community.id).unwrap(); + assert!(load_community(&community.id).unwrap().is_none()); + assert_eq!(community_id_for_channel(&ChannelId([0x77; 32]).to_hex()).unwrap(), None); + } + + #[test] + fn edition_seals_persist_immutably() { + let (_tmp, _guard) = init_test_db(); + let community = test_community(); + save_community(&community).unwrap(); + + save_edition_seal(&community.id, &[0x01; 32], 1, "{\"seal\":1}").unwrap(); + save_edition_seal(&community.id, &[0x01; 32], 2, "{\"seal\":2}").unwrap(); + // An edition is immutable: a replay at the same coordinate+version is ignored. + save_edition_seal(&community.id, &[0x01; 32], 1, "{\"seal\":TAMPERED}").unwrap(); + + let seals = load_edition_seals(&community.id).unwrap(); + assert_eq!(seals, vec!["{\"seal\":1}".to_string(), "{\"seal\":2}".to_string()]); + } +} diff --git a/crates/vector-core/src/concord/v2/derive.rs b/crates/vector-core/src/concord/v2/derive.rs new file mode 100644 index 00000000..c3591af2 --- /dev/null +++ b/crates/vector-core/src/concord/v2/derive.rs @@ -0,0 +1,460 @@ +//! Appendix A derivations (CORD-02) — FROZEN. +//! +//! Everything Concord v2 addresses on the wire derives from a Community +//! secret through the shapes below. Changing any labeled byte re-addresses +//! every prior event and forces a migration; the golden vectors in the test +//! module are the spec. +//! +//! Construction (A.1): `HKDF-SHA256(ikm=secret, salt=∅, info, L=32)` where +//! `info = utf8(label) || 0x00 || id[32] || epoch_be[8]` — the `id` always +//! present (all-zeroes where a label has no meaningful id), the epoch the only +//! omittable field, and the `scalar_normalize` retry counter (A.3) appended +//! after whatever fields are present. + +use hkdf::Hkdf; +use nostr_sdk::nips::nip44::v2::ConversationKey; +use nostr_sdk::prelude::{Keys, PublicKey, SecretKey}; +use sha2::{Digest, Sha256}; + +use super::{ChannelId, ChannelKey, CommunityId, CommunityRoot, Epoch, OwnerSalt, ZERO_ID}; + +// Labels (A.6). Part of the wire format — append new ones, never edit or +// reuse an existing one. +const LABEL_CHANNEL: &str = "concord/channel"; +const LABEL_CONTROL: &str = "concord/control"; +const LABEL_REKEY_PSEUDONYM: &str = "concord/rekey-pseudonym"; +const LABEL_BASE_REKEY_PSEUDONYM: &str = "concord/base-rekey-pseudonym"; +const LABEL_RECIPIENT_PSEUDONYM: &str = "concord/recipient-pseudonym"; +const LABEL_GUESTBOOK: &str = "concord/guestbook"; +const LABEL_DISSOLVED: &str = "concord/dissolved"; +const LABEL_GRANT: &str = "concord/grant"; +const LABEL_BANLIST: &str = "concord/banlist"; +const LABEL_INVITE_LINKS: &str = "concord/invite-links"; +const LABEL_INVITE_KEY: &str = "concord/invite-key"; + +// Domain prefixes for the two plain-SHA-256 commitments (A.4, A.5). +const DOMAIN_COMMUNITY_ID: &[u8] = b"concord/community"; +const DOMAIN_EPOCH_COMMITMENT: &[u8] = b"concord/epoch-key-commitment"; + +/// A plane's derived keypair (A.2): the x-only pubkey is the on-wire Stream +/// address (`authors` filter), the secret signs its wraps, and the NIP-44 +/// self-ECDH conversation key encrypts them. +#[derive(Clone)] +pub struct GroupKey { + keys: Keys, + conv: ConversationKey, +} + +impl GroupKey { + fn from_secret(sk: SecretKey) -> Self { + let keys = Keys::new(sk); + let conv = ConversationKey::derive(keys.secret_key(), &keys.public_key()) + .expect("self-ECDH of a valid keypair is infallible"); + GroupKey { keys, conv } + } + + /// The Stream address: where this plane's events live. + pub fn public_key(&self) -> PublicKey { + self.keys.public_key() + } + + /// The signing keys for this plane's wraps. + pub fn keys(&self) -> &Keys { + &self.keys + } + + /// The NIP-44 self-ECDH conversation key (encrypts the wrap and the + /// encrypted seal's rumor layer). + pub fn conversation_key(&self) -> &ConversationKey { + &self.conv + } +} + +impl core::fmt::Debug for GroupKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + write!(f, "GroupKey({})", self.keys.public_key()) + } +} + +/// Scope of a rekey (CORD-06 §1): a specific Private Channel, or the base +/// `community_root` (the all-zero sentinel — never collides with a Channel +/// id, which is random). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RekeyScope { + Channel(ChannelId), + CommunityRoot, +} + +impl RekeyScope { + pub fn id32(&self) -> [u8; 32] { + match self { + RekeyScope::Channel(c) => c.0, + RekeyScope::CommunityRoot => ZERO_ID, + } + } + + pub fn from_id32(id: [u8; 32]) -> Self { + if id == ZERO_ID { + RekeyScope::CommunityRoot + } else { + RekeyScope::Channel(ChannelId(id)) + } + } +} + +/// Build the frozen `info` bytes (A.1). `counter` is the scalar-normalize +/// retry byte (A.3), appended only when non-zero retries occur. +fn build_info(label: &str, id32: &[u8; 32], epoch: Option, counter: Option) -> Vec { + let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8 + 1); + info.extend_from_slice(label.as_bytes()); + info.push(0x00); + info.extend_from_slice(id32); + if let Some(e) = epoch { + info.extend_from_slice(&e.0.to_be_bytes()); + } + if let Some(c) = counter { + info.push(c); + } + info +} + +/// HKDF-SHA256, empty salt, 32-byte output. RFC 5869 with no salt pads to the +/// block, identical to a zero-length salt; expand of 32 bytes never fails. +fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(info, &mut okm) + .expect("HKDF expand of 32 bytes is infallible"); + okm +} + +/// A.2 + A.3: derive a plane's `GroupKey`, normalizing the seed into a valid +/// secp256k1 scalar by appending an incrementing counter byte to `info` on the +/// ~2⁻¹²⁸-rare reject, deterministic across implementations. +fn group_key(label: &str, ikm: &[u8], id32: &[u8; 32], epoch: Option) -> GroupKey { + let mut counter: u8 = 0; + loop { + let info = build_info(label, id32, epoch, (counter > 0).then_some(counter)); + let seed = hkdf32(ikm, &info); + if let Ok(sk) = SecretKey::from_slice(&seed) { + return GroupKey::from_secret(sk); + } + counter = counter + .checked_add(1) + .expect("secp256k1 scalar rejection 256 times running is impossible"); + } +} + +// ============================================================================ +// Identity commitments (A.4, A.5) +// ============================================================================ + +/// A.4: the self-certifying Community identity — +/// `sha256("concord/community" || owner_xonly || owner_salt)`. Forging a +/// different owner onto an existing id is a second-preimage on SHA-256. +pub fn community_id(owner_xonly: &[u8; 32], salt: &OwnerSalt) -> CommunityId { + let mut h = Sha256::new(); + h.update(DOMAIN_COMMUNITY_ID); + h.update(owner_xonly); + h.update(salt.0); + CommunityId(h.finalize().into()) +} + +/// Verify a claimed `(owner, salt)` pair against a `community_id`. +pub fn verify_owner(id: &CommunityId, owner_xonly: &[u8; 32], salt: &OwnerSalt) -> bool { + community_id(owner_xonly, salt) == *id +} + +/// A.5: the epoch-key continuity commitment a rekey's `prevcommit` tag carries +/// (CORD-06 §2) — proves a rotation extends the very key the receiver holds. +pub fn epoch_key_commitment(prev_epoch: Epoch, prev_key: &[u8; 32]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update(DOMAIN_EPOCH_COMMITMENT); + h.update(prev_epoch.0.to_be_bytes()); + h.update(prev_key); + h.finalize().into() +} + +// ============================================================================ +// Plane addresses (group keys) +// ============================================================================ + +/// The Control Plane's group key (CORD-02 §5). +pub fn control_key(root: &CommunityRoot, id: &CommunityId, epoch: Epoch) -> GroupKey { + group_key(LABEL_CONTROL, root.as_bytes(), &id.0, Some(epoch)) +} + +/// The Guestbook Plane's group key (CORD-02 §5). +pub fn guestbook_key(root: &CommunityRoot, id: &CommunityId, epoch: Epoch) -> GroupKey { + group_key(LABEL_GUESTBOOK, root.as_bytes(), &id.0, Some(epoch)) +} + +/// A Public Channel's group key: derived from the `community_root` at the +/// root's epoch, so it needs no delivery and rotates with the base (CORD-03). +pub fn public_channel_key(root: &CommunityRoot, channel: &ChannelId, root_epoch: Epoch) -> GroupKey { + group_key(LABEL_CHANNEL, root.as_bytes(), &channel.0, Some(root_epoch)) +} + +/// A Private Channel's group key: its own independent secret and epoch. +pub fn private_channel_key(key: &ChannelKey, channel: &ChannelId, epoch: Epoch) -> GroupKey { + group_key(LABEL_CHANNEL, key.as_bytes(), &channel.0, Some(epoch)) +} + +/// A Private Channel rekey address for the epoch it *introduces*, keyed by the +/// prior `community_root` every member holds (CORD-06 §2). +pub fn rekey_address(prior_root: &CommunityRoot, channel: &ChannelId, new_epoch: Epoch) -> GroupKey { + group_key(LABEL_REKEY_PSEUDONYM, prior_root.as_bytes(), &channel.0, Some(new_epoch)) +} + +/// A base-rotation rekey address, keyed by the prior root (CORD-06 §2). +pub fn base_rekey_address(prior_root: &CommunityRoot, id: &CommunityId, new_epoch: Epoch) -> GroupKey { + group_key(LABEL_BASE_REKEY_PSEUDONYM, prior_root.as_bytes(), &id.0, Some(new_epoch)) +} + +/// The dissolution tombstone address (CORD-02 §9): derived from the +/// `community_id` alone — no key, no epoch — so every member past or present +/// resolves the same grave and a Refounding can never strand it. +pub fn dissolved_address(id: &CommunityId) -> GroupKey { + group_key(LABEL_DISSOLVED, &id.0, &ZERO_ID, None) +} + +// ============================================================================ +// Coordinates (plain 32-byte hkdf outputs — edition `eid`s and locators) +// ============================================================================ + +/// A member's Grant coordinate (CORD-04): bound to the `community_id`, never a +/// key or epoch, so it survives every Refounding. +pub fn grant_locator(id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] { + hkdf32(&id.0, &build_info(LABEL_GRANT, member_xonly, None, None)) +} + +/// The Banlist coordinate (CORD-04 §4). +pub fn banlist_locator(id: &CommunityId) -> [u8; 32] { + hkdf32(&id.0, &build_info(LABEL_BANLIST, &ZERO_ID, None, None)) +} + +/// A creator's invite Registry coordinate (CORD-05 §5): bound to the creator, +/// so each creator owns exactly their own list. +pub fn invite_links_locator(id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] { + hkdf32(&id.0, &build_info(LABEL_INVITE_LINKS, creator_xonly, None, None)) +} + +/// The public-invite bundle decrypt key, derived from the link's 16-byte +/// off-network unlock token (CORD-05 §2) — the only thing the token derives. +pub fn invite_bundle_key(token: &[u8; 16]) -> [u8; 32] { + hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO_ID, None, None)) +} + +/// A rekey blob's per-recipient locator (CORD-06 §2): +/// `hkdf(rotator_xonly || recipient_xonly, "concord/recipient-pseudonym", scope_id, new_epoch)`. +/// Public inputs by design — a NIP-46 bunker finds its blob with no raw-key +/// access, and only key-holding members ever see the list to search it. +pub fn recipient_locator( + rotator_xonly: &[u8; 32], + recipient_xonly: &[u8; 32], + scope: RekeyScope, + new_epoch: Epoch, +) -> [u8; 32] { + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(rotator_xonly); + ikm[32..].copy_from_slice(recipient_xonly); + hkdf32(&ikm, &build_info(LABEL_RECIPIENT_PSEUDONYM, &scope.id32(), Some(new_epoch), None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn hex32(b: &[u8; 32]) -> String { + crate::simd::hex::bytes_to_hex_32(b) + } + + fn test_id() -> [u8; 32] { + let mut id = [0u8; 32]; + for (i, b) in id.iter_mut().enumerate() { + *b = (255 - i) as u8; + } + id + } + + fn test_key() -> [u8; 32] { + let mut k = [0u8; 32]; + for (i, b) in k.iter_mut().enumerate() { + *b = i as u8; + } + k + } + + // --- Golden vectors --- + // Produced by an INDEPENDENT implementation (Python hashlib/hmac, RFC 5869) + // over these exact bytes, so a match proves the construction cross- + // implementation, not merely self-consistent. If any assertion here ever + // changes, the wire format changed — a conscious, re-labeled decision. + + // ikm = 0x00..0x1f, label "concord/channel", id = 0xff,0xfe,.., epoch 0 / 1 / big-endian probe. + const GOLDEN_CHANNEL_SEED_EPOCH0: &str = + "1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b"; + const GOLDEN_CHANNEL_SEED_EPOCH1: &str = + "4019ac9c2e15aba177749da7a0cfa59bacbac064bb76658628d9e23683717cad"; + const GOLDEN_CHANNEL_SEED_EPOCH_BE: &str = + "016fc386d9d4137c99908870a269ee51e52306a2562b749f5eddcce5e657f7ee"; + // ikm = [7;32], label "concord/control", id = [0x11;32], epoch 1. + const GOLDEN_CONTROL_SEED: &str = + "e64e120fa7d2103c036fb21530d4dac3c5ee172d5656fc10781ab6fb43b8a49f"; + // ikm = [0x11;32] (community id), member = [0x22;32], no epoch. + const GOLDEN_GRANT_LOCATOR: &str = + "f25f8fabe256fc32c922a83df441b277ca88498c9169746ce3133eb1fb166a79"; + // ikm = [0x11;32], id = zeros, no epoch. + const GOLDEN_BANLIST_LOCATOR: &str = + "ace9cb9651e5d98827fa13783b150fb7c07ac9598c5632353cd5fe6ed2c0b4e2"; + // ikm = [0x11;32], creator = [0x22;32], no epoch. + const GOLDEN_INVITE_LINKS_LOCATOR: &str = + "9e9af9144a43ab8da9eb6abfd8b5d4d7e9d31a30024db4ac6c7488030f2eba67"; + // ikm = token [5;16], id = zeros, no epoch. + const GOLDEN_INVITE_BUNDLE_KEY: &str = + "d2b67ec14b1bcdbceab051b4cf4165f33f8e305b9fa10741dd8065b06c4f5988"; + // ikm = [0xAA;32] || [0xBB;32], scope = test_id channel, epoch 3. + const GOLDEN_RECIPIENT_LOCATOR: &str = + "e717fe03605f26174ef5df87cb39ef31bd4fefad1d713e41adbfbf3fb8c7458c"; + // sha256("concord/community" || [0x22;32] || [0x33;32]). + const GOLDEN_COMMUNITY_ID: &str = + "0f244078c710a20430f7cce317cc3a2b0a99614348ac5a3df2dea67741abe378"; + // sha256("concord/epoch-key-commitment" || be64(2) || [7;32]). + const GOLDEN_EPOCH_COMMITMENT: &str = + "550c4dfe037bc9d45b768ce5e0a4a0aae740f508bfe80d455a16d8cc19597876"; + // ikm = [0x11;32] (community id), label "concord/dissolved", id zeros, no epoch. + const GOLDEN_DISSOLVED_SEED: &str = + "37d70cd43a168e1bea76f38368f705123e83ee7f50560eb3ab2044b51902c977"; + + #[test] + fn channel_seed_goldens_pin_layout_and_endianness() { + let seed0 = hkdf32(&test_key(), &build_info(LABEL_CHANNEL, &test_id(), Some(Epoch(0)), None)); + let seed1 = hkdf32(&test_key(), &build_info(LABEL_CHANNEL, &test_id(), Some(Epoch(1)), None)); + let seed_be = hkdf32( + &test_key(), + &build_info(LABEL_CHANNEL, &test_id(), Some(Epoch(0x0102030405060708)), None), + ); + assert_eq!(hex32(&seed0), GOLDEN_CHANNEL_SEED_EPOCH0); + assert_eq!(hex32(&seed1), GOLDEN_CHANNEL_SEED_EPOCH1); + // A multi-byte epoch pins big-endian serialization explicitly. + assert_eq!(hex32(&seed_be), GOLDEN_CHANNEL_SEED_EPOCH_BE); + } + + #[test] + fn control_seed_golden() { + let seed = hkdf32(&[7u8; 32], &build_info(LABEL_CONTROL, &[0x11u8; 32], Some(Epoch(1)), None)); + assert_eq!(hex32(&seed), GOLDEN_CONTROL_SEED); + // The seed is the group key's secret when it's a valid scalar (the + // overwhelmingly common case) — pin that the public API agrees. + let gk = control_key(&CommunityRoot([7u8; 32]), &CommunityId([0x11u8; 32]), Epoch(1)); + assert_eq!(gk.keys().secret_key().to_secret_hex(), GOLDEN_CONTROL_SEED); + } + + #[test] + fn coordinate_goldens() { + let cid = CommunityId([0x11u8; 32]); + assert_eq!(hex32(&grant_locator(&cid, &[0x22u8; 32])), GOLDEN_GRANT_LOCATOR); + assert_eq!(hex32(&banlist_locator(&cid)), GOLDEN_BANLIST_LOCATOR); + assert_eq!(hex32(&invite_links_locator(&cid, &[0x22u8; 32])), GOLDEN_INVITE_LINKS_LOCATOR); + assert_eq!(hex32(&invite_bundle_key(&[5u8; 16])), GOLDEN_INVITE_BUNDLE_KEY); + } + + #[test] + fn recipient_locator_golden_and_scope_bound() { + let loc = recipient_locator(&[0xAA; 32], &[0xBB; 32], RekeyScope::Channel(ChannelId(test_id())), Epoch(3)); + assert_eq!(hex32(&loc), GOLDEN_RECIPIENT_LOCATOR); + // Base-rotation scope must not collide with a channel scope. + let base = recipient_locator(&[0xAA; 32], &[0xBB; 32], RekeyScope::CommunityRoot, Epoch(3)); + assert_ne!(loc, base); + // Direction matters: rotator||recipient is not recipient||rotator. + let swapped = recipient_locator(&[0xBB; 32], &[0xAA; 32], RekeyScope::Channel(ChannelId(test_id())), Epoch(3)); + assert_ne!(loc, swapped); + } + + #[test] + fn community_id_golden_and_self_certifies() { + let owner = [0x22u8; 32]; + let salt = OwnerSalt([0x33u8; 32]); + let id = community_id(&owner, &salt); + assert_eq!(id.to_hex(), GOLDEN_COMMUNITY_ID); + assert!(verify_owner(&id, &owner, &salt)); + // A forged owner or salt fails the commitment. + assert!(!verify_owner(&id, &[0x23u8; 32], &salt)); + assert!(!verify_owner(&id, &owner, &OwnerSalt([0x34u8; 32]))); + } + + #[test] + fn epoch_commitment_golden() { + assert_eq!(hex32(&epoch_key_commitment(Epoch(2), &[7u8; 32])), GOLDEN_EPOCH_COMMITMENT); + // Epoch and key both bind. + assert_ne!(epoch_key_commitment(Epoch(3), &[7u8; 32]), epoch_key_commitment(Epoch(2), &[7u8; 32])); + assert_ne!(epoch_key_commitment(Epoch(2), &[8u8; 32]), epoch_key_commitment(Epoch(2), &[7u8; 32])); + } + + #[test] + fn dissolved_seed_golden_and_keyless() { + let seed = hkdf32(&[0x11u8; 32], &build_info(LABEL_DISSOLVED, &ZERO_ID, None, None)); + assert_eq!(hex32(&seed), GOLDEN_DISSOLVED_SEED); + let gk = dissolved_address(&CommunityId([0x11u8; 32])); + assert_eq!(gk.keys().secret_key().to_secret_hex(), GOLDEN_DISSOLVED_SEED); + } + + #[test] + fn labels_domain_separate_shared_ikm() { + // Control, guestbook, and base-rekey all key off the root with the + // community id — separation rests entirely on the label. + let root = CommunityRoot([7u8; 32]); + let cid = CommunityId([0x11u8; 32]); + let control = control_key(&root, &cid, Epoch(1)).public_key(); + let guestbook = guestbook_key(&root, &cid, Epoch(1)).public_key(); + let base_rekey = base_rekey_address(&root, &cid, Epoch(1)).public_key(); + assert_ne!(control, guestbook); + assert_ne!(control, base_rekey); + assert_ne!(guestbook, base_rekey); + // Grant / banlist / invite-links share the community-id IKM. + assert_ne!(grant_locator(&cid, &[0x22; 32]), invite_links_locator(&cid, &[0x22; 32])); + assert_ne!(grant_locator(&cid, &ZERO_ID), banlist_locator(&cid)); + } + + #[test] + fn epoch_rotates_every_address() { + let root = CommunityRoot([7u8; 32]); + let cid = CommunityId([0x11u8; 32]); + let chan = ChannelId(test_id()); + assert_ne!( + control_key(&root, &cid, Epoch(0)).public_key(), + control_key(&root, &cid, Epoch(1)).public_key() + ); + assert_ne!( + public_channel_key(&root, &chan, Epoch(0)).public_key(), + public_channel_key(&root, &chan, Epoch(1)).public_key() + ); + } + + #[test] + fn public_and_private_channel_keying_share_one_derivation() { + // A Public Channel is "a Channel whose key derives from the root" — + // same label, same layout, only the secret differs (CORD-03 §1). + let secret = test_key(); + let chan = ChannelId(test_id()); + let as_root = public_channel_key(&CommunityRoot(secret), &chan, Epoch(0)); + let as_key = private_channel_key(&ChannelKey(secret), &chan, Epoch(0)); + assert_eq!(as_root.public_key(), as_key.public_key()); + } + + #[test] + fn group_key_signs_and_self_decrypts() { + // The conv key is the self-ECDH of the group secret — encrypt/decrypt + // roundtrip through the nip44 string API used by the stream layer. + let gk = control_key(&CommunityRoot([7u8; 32]), &CommunityId([0x11u8; 32]), Epoch(0)); + let ct = nostr_sdk::nips::nip44::encrypt( + gk.keys().secret_key(), + &gk.public_key(), + "concord", + nostr_sdk::nips::nip44::Version::V2, + ) + .unwrap(); + let pt = nostr_sdk::nips::nip44::decrypt(gk.keys().secret_key(), &gk.public_key(), &ct).unwrap(); + assert_eq!(pt, "concord"); + } +} diff --git a/crates/vector-core/src/concord/v2/edition.rs b/crates/vector-core/src/concord/v2/edition.rs new file mode 100644 index 00000000..eefbb3c9 --- /dev/null +++ b/crates/vector-core/src/concord/v2/edition.rs @@ -0,0 +1,330 @@ +//! CORD-04 §1: Control Plane editions. +//! +//! Every authority action is a per-entity **edition**: a kind 3308 rumor whose +//! tags carry the machinery (`vsk` entity type, `eid` coordinate, `ev` +//! version, `ep` prev-hash chain link, `vac` authority citation) and whose +//! `content` is the entity's new state. Editions ride plaintext seals so a +//! compaction re-wraps them signature-intact (CORD-06 §3); the fold rules +//! live in `control.rs`. + +use nostr_sdk::prelude::*; +use sha2::{Digest, Sha256}; + +use super::{kind, vsk, ChannelId, CommunityId, RoleId}; + +/// The frozen edition-hash domain label. The name is historical (the hash +/// construction predates the CORD numbering) and is pinned by the spec — +/// changing a byte forks every chain. +const EDITION_HASH_LABEL: &[u8] = b"vector-community/v1/edition"; + +/// Edition tag names. +pub const TAG_VSK: &str = "vsk"; +pub const TAG_EID: &str = "eid"; +pub const TAG_EV: &str = "ev"; +pub const TAG_EP: &str = "ep"; +pub const TAG_VAC: &str = "vac"; + +/// The exact Grant edition an actor claims their rank under (CORD-04 §5) — +/// pinned by coordinate, version, AND content hash. A sync floor, never the +/// verdict: the verifier blocks until it holds at least this Grant, then +/// judges against its *current* refuse-downgrade roster. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Citation { + pub grant_eid: [u8; 32], + pub grant_version: u64, + pub grant_hash: [u8; 32], +} + +#[derive(Debug)] +pub enum EditionError { + NotAnEdition(u16), + MissingTag(&'static str), + Malformed(String), +} + +impl std::fmt::Display for EditionError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EditionError::NotAnEdition(k) => write!(f, "kind {k} is not a control edition"), + EditionError::MissingTag(t) => write!(f, "edition missing tag {t}"), + EditionError::Malformed(e) => write!(f, "malformed edition: {e}"), + } + } +} + +impl std::error::Error for EditionError {} + +/// One parsed, seal-verified edition. `content` is the exact wire bytes — the +/// hash preimage — never a re-serialization. +#[derive(Debug, Clone)] +pub struct Edition { + pub vsk: u8, + pub eid: [u8; 32], + pub version: u64, + pub prev: Option<[u8; 32]>, + pub content: String, + /// The seal-verified actor. + pub author: PublicKey, + /// The rumor id — the deterministic same-version tiebreak (lower wins). + pub rumor_id: EventId, + pub created_at: Timestamp, + pub citation: Option, +} + +impl Edition { + /// The edition's identity: what the next edition's `ep` cites (CORD-04 §1). + /// Length-prefixed, domain-separated, fixed-width — distinct inputs can + /// never collide, and `content` hashes as its exact wire bytes so a + /// compaction re-wrap preserves the value. + pub fn hash(&self) -> [u8; 32] { + edition_hash(&self.eid, self.version, self.prev.as_ref(), self.content.as_bytes()) + } +} + +/// The frozen preimage (CORD-04 §1). +pub fn edition_hash(eid: &[u8; 32], version: u64, prev: Option<&[u8; 32]>, content: &[u8]) -> [u8; 32] { + let mut h = Sha256::new(); + h.update((EDITION_HASH_LABEL.len() as u64).to_be_bytes()); + h.update(EDITION_HASH_LABEL); + h.update(eid); + h.update(version.to_be_bytes()); + match prev { + Some(p) => { + h.update([0x01]); + h.update(p); + } + None => { + h.update([0x00]); + h.update([0u8; 32]); + } + } + h.update((content.len() as u64).to_be_bytes()); + h.update(content); + h.finalize().into() +} + +/// Deterministic entity coordinates (CORD-04 §1). All bind to the +/// `community_id`, never a key or epoch, so they survive every Refounding. +pub fn metadata_eid(id: &CommunityId) -> [u8; 32] { + id.0 +} + +pub fn role_eid(role: &RoleId) -> [u8; 32] { + role.0 +} + +pub fn channel_eid(channel: &ChannelId) -> [u8; 32] { + channel.0 +} + +/// Build the kind 3308 edition rumor. `citation` is absent when the owner +/// acts — supreme needs no citation. +pub fn build_edition_rumor( + author: PublicKey, + entity_vsk: u8, + eid: &[u8; 32], + version: u64, + prev: Option<&[u8; 32]>, + content: &str, + created_at_secs: u64, + citation: Option<&Citation>, +) -> UnsignedEvent { + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_VSK.into()), [entity_vsk.to_string()]), + Tag::custom(TagKind::Custom(TAG_EID.into()), [crate::simd::hex::bytes_to_hex_32(eid)]), + Tag::custom(TagKind::Custom(TAG_EV.into()), [version.to_string()]), + ]; + if let Some(p) = prev { + tags.push(Tag::custom(TagKind::Custom(TAG_EP.into()), [crate::simd::hex::bytes_to_hex_32(p)])); + } + if let Some(c) = citation { + tags.push(Tag::custom( + TagKind::Custom(TAG_VAC.into()), + [ + crate::simd::hex::bytes_to_hex_32(&c.grant_eid), + c.grant_version.to_string(), + crate::simd::hex::bytes_to_hex_32(&c.grant_hash), + ], + )); + } + let mut rumor = EventBuilder::new(Kind::Custom(kind::CONTROL_EDITION), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .build(author); + rumor.ensure_id(); + rumor +} + +/// Build the chainless Dissolution tombstone (CORD-02 §9): no `ev`, no `ep`, +/// no `vac` — presence of one valid owner-signed edition *is* the state. +pub fn build_dissolved_rumor(owner: PublicKey, created_at_secs: u64) -> UnsignedEvent { + let mut rumor = EventBuilder::new(Kind::Custom(kind::CONTROL_EDITION), "") + .tags([ + Tag::custom(TagKind::Custom(TAG_VSK.into()), [vsk::DISSOLVED.to_string()]), + Tag::custom(TagKind::Custom(TAG_EID.into()), [crate::simd::hex::bytes_to_hex_32(&super::ZERO_ID)]), + ]) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .build(owner); + rumor.ensure_id(); + rumor +} + +fn tag_values<'a>(tags: &'a Tags, name: &str) -> Option> { + tags.iter() + .find(|t| t.kind() == TagKind::Custom(name.into())) + .map(|t| t.as_slice().iter().skip(1).map(|s| s.as_str()).collect()) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tag_values(tags, name).and_then(|v| v.first().copied()) +} + +fn hex32(s: &str) -> Result<[u8; 32], EditionError> { + crate::simd::hex::hex_to_bytes_32_checked(s) + .ok_or_else(|| EditionError::Malformed(format!("bad 32-byte hex ({} chars)", s.len()))) +} + +/// Parse a seal-verified kind 3308 rumor into an [`Edition`]. The rumor MUST +/// come out of [`super::stream::open`] (authorship already verified). +pub fn parse_edition(rumor: &UnsignedEvent) -> Result { + if rumor.kind.as_u16() != kind::CONTROL_EDITION { + return Err(EditionError::NotAnEdition(rumor.kind.as_u16())); + } + let mut rumor = rumor.clone(); + rumor.ensure_id(); + + let vsk_val: u8 = tag_value(&rumor.tags, TAG_VSK) + .ok_or(EditionError::MissingTag(TAG_VSK))? + .parse() + .map_err(|_| EditionError::Malformed("vsk not a u8".into()))?; + let eid = hex32(tag_value(&rumor.tags, TAG_EID).ok_or(EditionError::MissingTag(TAG_EID))?)?; + + // The dissolved tombstone is chainless and exempt from version discipline. + let version: u64 = if vsk_val == vsk::DISSOLVED { + 0 + } else { + tag_value(&rumor.tags, TAG_EV) + .ok_or(EditionError::MissingTag(TAG_EV))? + .parse() + .map_err(|_| EditionError::Malformed("ev not a u64".into()))? + }; + if vsk_val != vsk::DISSOLVED && version == 0 { + return Err(EditionError::Malformed("version starts at 1".into())); + } + + let prev = match tag_value(&rumor.tags, TAG_EP) { + Some(p) => Some(hex32(p)?), + None => None, + }; + + let citation = match tag_values(&rumor.tags, TAG_VAC) { + Some(parts) if parts.len() >= 3 => Some(Citation { + grant_eid: hex32(parts[0])?, + grant_version: parts[1] + .parse() + .map_err(|_| EditionError::Malformed("vac version not a u64".into()))?, + grant_hash: hex32(parts[2])?, + }), + Some(_) => return Err(EditionError::Malformed("vac tag needs 3 values".into())), + None => None, + }; + + Ok(Edition { + vsk: vsk_val, + eid, + version, + prev, + content: rumor.content.clone(), + author: rumor.pubkey, + rumor_id: rumor.id.expect("ensured"), + created_at: rumor.created_at, + citation, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Independent Python (hashlib) over the frozen preimage: + // len64(label)||label || eid=[0x11;32] || be64(4) || 0x01||prev=[0x22;32] || len64(content)||content=b"{\"name\":\"x\"}" + const GOLDEN_EDITION_HASH_CHAINED: &str = + "8c038656615a33561ad71efbec67849436c0d01e45d3bbffd6a9e1b696864934"; + // Same but version 1, no prev (0x00 || zeros). + const GOLDEN_EDITION_HASH_FIRST: &str = + "2d0f62c1f046b38e61c5c2ff3e67aadcb1bbd429eda822b916c09b1a7f8ac87c"; + + #[test] + fn edition_hash_goldens() { + let h = edition_hash(&[0x11; 32], 4, Some(&[0x22; 32]), b"{\"name\":\"x\"}"); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&h), GOLDEN_EDITION_HASH_CHAINED); + let h1 = edition_hash(&[0x11; 32], 1, None, b"{\"name\":\"x\"}"); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&h1), GOLDEN_EDITION_HASH_FIRST); + } + + #[test] + fn edition_hash_binds_every_field() { + let base = edition_hash(&[0x11; 32], 4, Some(&[0x22; 32]), b"c"); + assert_ne!(base, edition_hash(&[0x12; 32], 4, Some(&[0x22; 32]), b"c")); + assert_ne!(base, edition_hash(&[0x11; 32], 5, Some(&[0x22; 32]), b"c")); + assert_ne!(base, edition_hash(&[0x11; 32], 4, Some(&[0x23; 32]), b"c")); + assert_ne!(base, edition_hash(&[0x11; 32], 4, None, b"c")); + assert_ne!(base, edition_hash(&[0x11; 32], 4, Some(&[0x22; 32]), b"d")); + } + + #[test] + fn build_parse_roundtrip() { + let keys = Keys::generate(); + let citation = Citation { grant_eid: [0xAB; 32], grant_version: 2, grant_hash: [0xCD; 32] }; + let rumor = build_edition_rumor( + keys.public_key(), + super::super::vsk::ROLE, + &[0x11; 32], + 4, + Some(&[0x22; 32]), + "{\"name\":\"x\"}", + 1_686_840_217, + Some(&citation), + ); + let ed = parse_edition(&rumor).unwrap(); + assert_eq!(ed.vsk, super::super::vsk::ROLE); + assert_eq!(ed.eid, [0x11; 32]); + assert_eq!(ed.version, 4); + assert_eq!(ed.prev, Some([0x22; 32])); + assert_eq!(ed.content, "{\"name\":\"x\"}"); + assert_eq!(ed.author, keys.public_key()); + assert_eq!(ed.citation, Some(citation)); + // The parsed edition's hash matches the direct construction. + assert_eq!(ed.hash(), edition_hash(&[0x11; 32], 4, Some(&[0x22; 32]), b"{\"name\":\"x\"}")); + } + + #[test] + fn first_edition_has_no_prev_and_version_zero_is_rejected() { + let keys = Keys::generate(); + let rumor = build_edition_rumor(keys.public_key(), 0, &[0x11; 32], 1, None, "{}", 1, None); + let ed = parse_edition(&rumor).unwrap(); + assert_eq!(ed.prev, None); + + let bad = build_edition_rumor(keys.public_key(), 0, &[0x11; 32], 0, None, "{}", 1, None); + assert!(parse_edition(&bad).is_err(), "versions climb from 1"); + } + + #[test] + fn dissolved_tombstone_is_chainless() { + let keys = Keys::generate(); + let rumor = build_dissolved_rumor(keys.public_key(), 1_725_000_000); + let ed = parse_edition(&rumor).unwrap(); + assert_eq!(ed.vsk, super::super::vsk::DISSOLVED); + assert_eq!(ed.version, 0); + assert_eq!(ed.prev, None); + assert_eq!(ed.citation, None); + assert_eq!(ed.content, ""); + } + + #[test] + fn non_edition_kind_is_rejected() { + let keys = Keys::generate(); + let rumor = EventBuilder::new(Kind::Custom(9), "hi").build(keys.public_key()); + assert!(matches!(parse_edition(&rumor), Err(EditionError::NotAnEdition(9)))); + } +} diff --git a/crates/vector-core/src/concord/v2/guestbook.rs b/crates/vector-core/src/concord/v2/guestbook.rs new file mode 100644 index 00000000..fc5065b0 --- /dev/null +++ b/crates/vector-core/src/concord/v2/guestbook.rs @@ -0,0 +1,470 @@ +//! CORD-02 §5: the Guestbook Plane — membership motion, coalesced flat. +//! +//! Self-signed Joins and Leaves, authorized Kicks, refounder snapshots; never +//! messages, never authority. Off-consensus: nothing in Control or Chat +//! depends on it, so it loads last and lags without harm. The coalesce keeps +//! one final state per npub (latest wins by millisecond time, ties by lower +//! rumor id), merges observably-present authors forward, and subtracts the +//! Banlist: the Complete Memberlist, deterministic when synced, self-healing +//! when not. + +use std::collections::{HashMap, HashSet}; + +use nostr_sdk::prelude::*; + +use super::edition::Citation; +use super::stream::TAG_MS; +use super::{kind, split_ms, MAX_FUTURE_SKEW_MS, SNAPSHOT_CHUNK_MEMBERS}; + +/// Optional Join attribution echoed from an invite bundle (CORD-05 §1) — what +/// makes per-link usage counters possible. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InviteAttribution { + pub creator_hex: String, + pub label: String, +} + +/// A parsed Guestbook rumor. +#[derive(Debug, Clone)] +pub enum GuestbookEvent { + Join { attribution: Option }, + Leave, + Kick { target: PublicKey, citation: Option }, + /// One chunk of a refounder snapshot: `chunk` is (i, n), 1-based. + Snapshot { snapshot_id: String, chunk: (u32, u32), members: Vec }, +} + +/// One npub's coalesced final state. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MemberState { + Present, + Departed, +} + +#[derive(Debug, Clone)] +struct EntryState { + state: MemberState, + ms: u64, + rumor_id: EventId, + /// A snapshot seed is *secondhand* — the refounder's attestation, not the + /// member's own word; any self-signed entry or authorized Kick strictly + /// newer supersedes it. + secondhand: bool, +} + +/// The Guestbook coalesce plus forward-only observation. +#[derive(Debug, Default)] +pub struct GuestbookFold { + entries: HashMap, + /// Latest observed activity per author (any valid decrypted event + /// anywhere in the Community). + observed: HashMap, +} + +impl GuestbookFold { + pub fn new() -> Self { + Self::default() + } + + /// The strict fold gate (CORD-02 §5): an entry dated over an hour ahead + /// of the receiver's clock is dropped outright. + fn future_gate(ms: u64, now_ms: u64) -> bool { + ms <= now_ms.saturating_add(MAX_FUTURE_SKEW_MS) + } + + fn coalesce(&mut self, who: PublicKey, state: MemberState, ms: u64, rumor_id: EventId, secondhand: bool) { + let incoming = EntryState { state, ms, rumor_id, secondhand }; + match self.entries.get(&who) { + None => { + self.entries.insert(who, incoming); + } + Some(held) => { + let wins = match incoming.ms.cmp(&held.ms) { + std::cmp::Ordering::Greater => true, + std::cmp::Ordering::Less => false, + // Tie: firsthand beats secondhand (a member's own word over + // the refounder's attestation), then the lower rumor id + // (the inner event's, never the wrap's, which differs per + // re-wrap). Per-npub, so an author only ever grinds ties + // against themselves. + std::cmp::Ordering::Equal => { + if incoming.secondhand != held.secondhand { + held.secondhand + } else { + incoming.rumor_id.as_bytes() < held.rumor_id.as_bytes() + } + } + }; + if wins { + self.entries.insert(who, incoming); + } + } + } + } + + /// Apply a self-signed Join or Leave. `author` is seal-verified; `ms` is + /// the rumor's reconstructed millisecond time (a malformed `ms` tag means + /// the caller already dropped the entry). + pub fn apply_join_leave(&mut self, author: PublicKey, join: bool, ms: u64, rumor_id: EventId, now_ms: u64) { + if !Self::future_gate(ms, now_ms) { + return; + } + let state = if join { MemberState::Present } else { MemberState::Departed }; + self.coalesce(author, state, ms, rumor_id, false); + } + + /// Apply a Kick. `authorized` is the caller's `ControlFold::may_kick` + /// verdict — an unauthorized Kick is dropped, degrading the removal, never + /// breaking it. + pub fn apply_kick(&mut self, target: PublicKey, authorized: bool, ms: u64, rumor_id: EventId, now_ms: u64) { + if !authorized || !Self::future_gate(ms, now_ms) { + return; + } + self.coalesce(target, MemberState::Departed, ms, rumor_id, false); + } + + /// Apply one snapshot chunk (CORD-02 §5). Honored only from the npub + /// whose Refounding minted the epoch — the caller verifies `author` + /// before calling. Chunks are independently useful; a partial snapshot + /// seeds whoever arrived and the rest heal by observation. + pub fn apply_snapshot_chunk(&mut self, members: &[PublicKey], ms: u64, rumor_id: EventId, now_ms: u64) { + if !Self::future_gate(ms, now_ms) { + return; + } + for member in members { + // Secondhand: merely seeds state at the snapshot's timestamp; a + // firsthand entry newer (or tying) supersedes it in the coalesce. + self.coalesce(*member, MemberState::Present, ms, rumor_id, true); + } + } + + /// Record observed presence: every valid decrypted event names its real + /// author, and an author seen publishing is observably present — counted + /// *forward only* (an author re-enters on activity newer than their + /// latest Leave or Kick; departed history can never resurrect them). + pub fn observe(&mut self, author: PublicKey, ms: u64, now_ms: u64) { + if !Self::future_gate(ms, now_ms) { + return; + } + let slot = self.observed.entry(author).or_insert(0); + if ms > *slot { + *slot = ms; + } + } + + /// One npub's coalesced state, observation merged. + pub fn state(&self, who: &PublicKey) -> Option { + let entry = self.entries.get(who); + let observed = self.observed.get(who).copied(); + match (entry, observed) { + (None, None) => None, + (None, Some(_)) => Some(MemberState::Present), + (Some(e), None) => Some(e.state), + (Some(e), Some(obs_ms)) => { + if e.state == MemberState::Departed && obs_ms > e.ms { + Some(MemberState::Present) + } else { + Some(e.state) + } + } + } + } + + /// The Complete Memberlist: coalesced Guestbook, merged with observed + /// authors, minus the Banlist. Sorted for determinism. + pub fn members(&self, banlist: &HashSet) -> Vec { + let mut all: HashSet = self.entries.keys().copied().collect(); + all.extend(self.observed.keys().copied()); + let mut out: Vec = all + .into_iter() + .filter(|pk| !banlist.contains(pk)) + .filter(|pk| self.state(pk) == Some(MemberState::Present)) + .collect(); + out.sort(); + out + } +} + +// ============================================================================ +// Rumor build & parse +// ============================================================================ + +/// Build a Join (or Leave) rumor. Attribution rides Joins only (CORD-05 §1). +pub fn build_join_leave( + author: PublicKey, + join: bool, + ms: u64, + attribution: Option<&InviteAttribution>, +) -> UnsignedEvent { + let mut tags = Vec::new(); + if join { + if let Some(a) = attribution { + tags.push(Tag::custom( + TagKind::Custom("invite".into()), + [a.creator_hex.clone(), a.label.clone()], + )); + } + } + super::stream::build_plane_rumor(author, kind::JOIN_LEAVE, if join { "join" } else { "leave" }, ms, tags) +} + +/// Build a Kick rumor: names its target, cites the Grant it acts under. +pub fn build_kick(admin: PublicKey, target: &PublicKey, citation: &Citation, ms: u64) -> UnsignedEvent { + let tags = vec![ + Tag::public_key(*target), + Tag::custom( + TagKind::Custom(super::edition::TAG_VAC.into()), + [ + crate::simd::hex::bytes_to_hex_32(&citation.grant_eid), + citation.grant_version.to_string(), + crate::simd::hex::bytes_to_hex_32(&citation.grant_hash), + ], + ), + ]; + super::stream::build_plane_rumor(admin, kind::KICK, "", ms, tags) +} + +/// Build a snapshot as its chunk rumors: present members only, 400 per event, +/// one snapshot id and one timestamp across all `n` chunks. +pub fn build_snapshot(refounder: PublicKey, members: &[PublicKey], snapshot_id: &str, ms: u64) -> Vec { + let chunks: Vec<&[PublicKey]> = members.chunks(SNAPSHOT_CHUNK_MEMBERS).collect(); + let n = chunks.len().max(1); + let (secs, remainder) = split_ms(ms); + chunks + .iter() + .enumerate() + .map(|(i, chunk)| { + let hexes: Vec = chunk.iter().map(|m| m.to_hex()).collect(); + let content = serde_json::to_string(&hexes).expect("string array"); + let tags = vec![ + Tag::custom(TagKind::Custom(TAG_MS.into()), [remainder.to_string()]), + Tag::custom( + TagKind::Custom("snap".into()), + [snapshot_id.to_string(), (i + 1).to_string(), n.to_string()], + ), + ]; + let mut rumor = EventBuilder::new(Kind::Custom(kind::SNAPSHOT), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(secs)) + .build(refounder); + rumor.ensure_id(); + rumor + }) + .collect() +} + +fn tag_parts<'a>(tags: &'a Tags, name: &str) -> Option> { + tags.iter() + .find(|t| t.kind() == TagKind::Custom(name.into())) + .map(|t| t.as_slice().iter().skip(1).map(|s| s.as_str()).collect()) +} + +/// Parse a Guestbook rumor (kinds 3306 / 3309 / 3312). +pub fn parse(rumor: &UnsignedEvent) -> Option { + match rumor.kind.as_u16() { + kind::JOIN_LEAVE => match rumor.content.as_str() { + "join" => { + let attribution = tag_parts(&rumor.tags, "invite").and_then(|p| { + Some(InviteAttribution { + creator_hex: p.first()?.to_string(), + label: p.get(1).unwrap_or(&"").to_string(), + }) + }); + Some(GuestbookEvent::Join { attribution }) + } + "leave" => Some(GuestbookEvent::Leave), + _ => None, + }, + kind::KICK => { + let target_hex = tag_parts(&rumor.tags, "p")?.first()?.to_string(); + let target = PublicKey::from_hex(&target_hex).ok()?; + let citation = tag_parts(&rumor.tags, super::edition::TAG_VAC).and_then(|p| { + Some(Citation { + grant_eid: crate::simd::hex::hex_to_bytes_32_checked(p.first()?)?, + grant_version: p.get(1)?.parse().ok()?, + grant_hash: crate::simd::hex::hex_to_bytes_32_checked(p.get(2)?)?, + }) + }); + Some(GuestbookEvent::Kick { target, citation }) + } + kind::SNAPSHOT => { + let snap = tag_parts(&rumor.tags, "snap")?; + let snapshot_id = snap.first()?.to_string(); + let chunk = (snap.get(1)?.parse().ok()?, snap.get(2)?.parse().ok()?); + let hexes: Vec = serde_json::from_str(&rumor.content).ok()?; + let members: Vec = hexes.iter().filter_map(|h| PublicKey::from_hex(h).ok()).collect(); + Some(GuestbookEvent::Snapshot { snapshot_id, chunk, members }) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: u64 = 1_722_500_000_000; + + fn pk() -> PublicKey { + Keys::generate().public_key() + } + + fn rid(seed: u8) -> EventId { + EventId::from_slice(&[seed; 32]).unwrap() + } + + #[test] + fn latest_state_wins_per_npub() { + let mut g = GuestbookFold::new(); + let alice = pk(); + g.apply_join_leave(alice, true, 1_000, rid(1), NOW); + g.apply_join_leave(alice, false, 2_000, rid(2), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed)); + // An older join arriving late never resurrects. + g.apply_join_leave(alice, true, 1_500, rid(3), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed)); + // A genuinely newer re-join does. + g.apply_join_leave(alice, true, 3_000, rid(4), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Present)); + } + + #[test] + fn ties_break_by_lower_rumor_id() { + let mut g = GuestbookFold::new(); + let alice = pk(); + g.apply_join_leave(alice, true, 1_000, rid(9), NOW); + g.apply_join_leave(alice, false, 1_000, rid(1), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed), "lower id wins the tie"); + // Order independence. + let mut g2 = GuestbookFold::new(); + g2.apply_join_leave(alice, false, 1_000, rid(1), NOW); + g2.apply_join_leave(alice, true, 1_000, rid(9), NOW); + assert_eq!(g2.state(&alice), Some(MemberState::Departed)); + } + + #[test] + fn far_future_entries_are_dropped_outright() { + let mut g = GuestbookFold::new(); + let alice = pk(); + // A forged "latest forever" squat from the year 3000. + g.apply_join_leave(alice, false, NOW + MAX_FUTURE_SKEW_MS + 1, rid(1), NOW); + assert_eq!(g.state(&alice), None); + // Within the hour of skew: accepted. + g.apply_join_leave(alice, true, NOW + MAX_FUTURE_SKEW_MS, rid(2), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Present)); + } + + #[test] + fn kick_departs_only_when_authorized() { + let mut g = GuestbookFold::new(); + let target = pk(); + g.apply_join_leave(target, true, 1_000, rid(1), NOW); + g.apply_kick(target, false, 2_000, rid(2), NOW); + assert_eq!(g.state(&target), Some(MemberState::Present), "unauthorized Kick is dropped"); + g.apply_kick(target, true, 2_000, rid(2), NOW); + assert_eq!(g.state(&target), Some(MemberState::Departed)); + } + + #[test] + fn snapshot_seeds_and_firsthand_supersedes() { + let mut g = GuestbookFold::new(); + let alice = pk(); + let bob = pk(); + g.apply_snapshot_chunk(&[alice, bob], 5_000, rid(1), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Present)); + + // A member's own Leave strictly newer than the seed supersedes it. + g.apply_join_leave(alice, false, 5_001, rid(2), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed)); + + // A stale snapshot never overrides newer firsthand state. + g.apply_snapshot_chunk(&[alice], 5_000, rid(3), NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed)); + + // An omitted member publishes a fresh Join: unsuppressable heal. + let omitted = pk(); + g.apply_join_leave(omitted, true, 6_000, rid(4), NOW); + assert_eq!(g.state(&omitted), Some(MemberState::Present)); + } + + #[test] + fn observation_counts_forward_only() { + let mut g = GuestbookFold::new(); + let alice = pk(); + g.apply_join_leave(alice, false, 5_000, rid(1), NOW); + // Old history can never resurrect a departed member. + g.observe(alice, 4_000, NOW); + assert_eq!(g.state(&alice), Some(MemberState::Departed)); + // Activity newer than the departure re-enters them. + g.observe(alice, 6_000, NOW); + assert_eq!(g.state(&alice), Some(MemberState::Present)); + // An author never Guestbooked but seen publishing is present. + let lurker = pk(); + g.observe(lurker, 1_000, NOW); + assert_eq!(g.state(&lurker), Some(MemberState::Present)); + } + + #[test] + fn memberlist_subtracts_banlist_and_sorts() { + let mut g = GuestbookFold::new(); + let a = pk(); + let b = pk(); + let banned = pk(); + g.apply_join_leave(a, true, 1, rid(1), NOW); + g.apply_join_leave(b, true, 2, rid(2), NOW); + g.apply_join_leave(banned, true, 3, rid(3), NOW); + let banlist: HashSet = [banned].into_iter().collect(); + let members = g.members(&banlist); + assert_eq!(members.len(), 2); + assert!(!members.contains(&banned)); + let mut expect = vec![a, b]; + expect.sort(); + assert_eq!(members, expect); + } + + #[test] + fn rumor_build_parse_roundtrip() { + let author = pk(); + let join = build_join_leave(author, true, NOW, Some(&InviteAttribution { creator_hex: "aa".into(), label: "Reddit".into() })); + match parse(&join).unwrap() { + GuestbookEvent::Join { attribution } => { + let a = attribution.unwrap(); + assert_eq!(a.label, "Reddit"); + } + _ => panic!("expected join"), + } + let leave = build_join_leave(author, false, NOW, None); + assert!(matches!(parse(&leave).unwrap(), GuestbookEvent::Leave)); + + let target = pk(); + let citation = Citation { grant_eid: [1; 32], grant_version: 3, grant_hash: [2; 32] }; + let kick = build_kick(author, &target, &citation, NOW); + match parse(&kick).unwrap() { + GuestbookEvent::Kick { target: t, citation: c } => { + assert_eq!(t, target); + assert_eq!(c, Some(citation)); + } + _ => panic!("expected kick"), + } + } + + #[test] + fn snapshot_chunks_share_id_and_timestamp() { + let refounder = pk(); + let members: Vec = (0..900).map(|_| pk()).collect(); + let chunks = build_snapshot(refounder, &members, "snap-1", NOW); + assert_eq!(chunks.len(), 3, "900 members chunk at 400"); + let ts = chunks[0].created_at; + for (i, chunk) in chunks.iter().enumerate() { + assert_eq!(chunk.created_at, ts); + match parse(chunk).unwrap() { + GuestbookEvent::Snapshot { snapshot_id, chunk: (ci, cn), members: m } => { + assert_eq!(snapshot_id, "snap-1"); + assert_eq!((ci as usize, cn as usize), (i + 1, 3)); + assert_eq!(m.len(), if i < 2 { 400 } else { 100 }); + } + _ => panic!("expected snapshot"), + } + } + } +} diff --git a/crates/vector-core/src/concord/v2/invite.rs b/crates/vector-core/src/concord/v2/invite.rs new file mode 100644 index 00000000..566553f8 --- /dev/null +++ b/crates/vector-core/src/concord/v2/invite.rs @@ -0,0 +1,835 @@ +//! CORD-05: Invites — the bundle, the link, the Registry, Direct Invites. +//! +//! An invite delivers the keys that make a member: as a shareable URL whose +//! keys live in a token-encrypted bundle on relays (revocable before use), or +//! the same bundle giftwrapped straight to an npub as a Direct Invite (a key +//! handoff, unrevocable once landed, never flips the Community Public). + +use nostr_sdk::nips::nip19::{FromBech32, Nip19Coordinate, ToBech32}; +use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; +use nostr_sdk::nips::nip59::UnwrappedGift; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::control::ImageRef; +use super::derive::{invite_bundle_key, verify_owner}; +use super::{ + kind, vsk, ChannelId, ChannelKey, CommunityId, CommunityRoot, Epoch, OwnerSalt, + FRAGMENT_MAX_RELAYS, INVITE_MAX_CHANNELS, NIP44_MAX_PLAINTEXT, RELAYS_RECOMMENDED_MAX, +}; + +// ============================================================================ +// The bundle (§1) +// ============================================================================ + +/// One granted Channel inside a bundle: Public Channels carry no key (they +/// derive from the root); Private ones deliver theirs. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ChannelGrant { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub key: Option, + pub epoch: u64, + pub name: String, +} + +impl ChannelGrant { + pub fn channel_id(&self) -> Option { + ChannelId::from_hex(&self.id) + } + + pub fn channel_key(&self) -> Option { + crate::simd::hex::hex_to_bytes_32_checked(self.key.as_deref()?).map(ChannelKey) + } +} + +/// The CommunityInvite bundle (CORD-05 §1). The inviter's identity is +/// irrelevant to trust: the `community_id` self-certifies the owner, so a +/// bundle can't smuggle a false owner or a fake key for a real Community. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct CommunityInvite { + pub community_id: String, + pub owner: String, + pub owner_salt: String, + pub community_root: String, + pub root_epoch: u64, + #[serde(default)] + pub channels: Vec, + #[serde(default)] + pub relays: Vec, + #[serde(default)] + pub name: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, + /// Unix ms; past it the preview still renders, joining refuses. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub creator_npub: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug)] +pub enum InviteError { + /// `owner`/`owner_salt` fail to reproduce the `community_id`. + OwnerMismatch, + /// Bundle bounds exceeded (hostile-allocation guard). + TooManyChannels(usize), + Expired, + Malformed(String), + Crypto(String), +} + +impl std::fmt::Display for InviteError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + InviteError::OwnerMismatch => write!(f, "bundle owner/salt do not reproduce the community_id"), + InviteError::TooManyChannels(n) => write!(f, "bundle carries {n} channels (cap {INVITE_MAX_CHANNELS})"), + InviteError::Expired => write!(f, "invite expired"), + InviteError::Malformed(e) => write!(f, "malformed invite: {e}"), + InviteError::Crypto(e) => write!(f, "invite crypto: {e}"), + } + } +} + +impl std::error::Error for InviteError {} + +impl CommunityInvite { + /// Validate an attacker-crafted bundle *before allocating on it*: the + /// self-certifying owner check, the channel-count bound, the relay trim. + /// Expiry is separate (`is_expired`) — a parked invite still previews. + pub fn validate(&mut self) -> Result<(), InviteError> { + if self.channels.len() > INVITE_MAX_CHANNELS { + return Err(InviteError::TooManyChannels(self.channels.len())); + } + let id = CommunityId::from_hex(&self.community_id) + .ok_or_else(|| InviteError::Malformed("community_id not 32-byte hex".into()))?; + let owner = crate::simd::hex::hex_to_bytes_32_checked(&self.owner) + .ok_or_else(|| InviteError::Malformed("owner not 32-byte hex".into()))?; + let salt = crate::simd::hex::hex_to_bytes_32_checked(&self.owner_salt) + .map(OwnerSalt) + .ok_or_else(|| InviteError::Malformed("owner_salt not 32-byte hex".into()))?; + if !verify_owner(&id, &owner, &salt) { + return Err(InviteError::OwnerMismatch); + } + self.relays.truncate(RELAYS_RECOMMENDED_MAX); + Ok(()) + } + + pub fn is_expired(&self, now_ms: u64) -> bool { + self.expires_at.map(|e| now_ms > e).unwrap_or(false) + } + + pub fn community_id_typed(&self) -> Option { + CommunityId::from_hex(&self.community_id) + } + + pub fn community_root_typed(&self) -> Option { + crate::simd::hex::hex_to_bytes_32_checked(&self.community_root).map(CommunityRoot) + } + + pub fn root_epoch_typed(&self) -> Epoch { + Epoch(self.root_epoch) + } +} + +// ============================================================================ +// The relay dictionary + fragment codec (§3) +// ============================================================================ + +/// The format/dictionary generation byte. A client MAY reject any lower value +/// as a legacy link rather than decode it against the wrong dictionary. +pub const FRAGMENT_VERSION: u8 = 4; + +/// The stock set: four primaries selected by one flag, so the common invite +/// carries zero relay bytes. +pub const STOCK_RELAYS: [&str; 4] = [ + "wss://jskitty.com/nostr", + "wss://asia.vectorapp.io/nostr", + "wss://relay.ditto.pub", + "wss://relay.dreamith.to", +]; + +const FLAG_STOCK_SET: u8 = 0x01; + +/// Dictionary id → relay URL (generation 4). +pub fn dictionary_relay(id: u8) -> Option<&'static str> { + STOCK_RELAYS.get(id.checked_sub(1)? as usize).copied() +} + +fn relay_dictionary_id(url: &str) -> Option { + STOCK_RELAYS.iter().position(|r| *r == url).map(|i| (i + 1) as u8) +} + +/// The decoded `#fragment`: the unlock token plus bootstrap relays. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Fragment { + pub token: [u8; 16], + pub relays: Vec, +} + +/// Encode the fragment: `[version][flags][relays?][token:16]`, base64url, no +/// padding. Passing exactly the stock set (or nothing) sets the stock flag +/// and zero relay bytes. +pub fn encode_fragment(token: &[u8; 16], relays: &[String]) -> Result { + let mut bytes = vec![FRAGMENT_VERSION]; + let is_stock = relays.is_empty() || relays.iter().map(String::as_str).eq(STOCK_RELAYS); + if is_stock { + bytes.push(FLAG_STOCK_SET); + } else { + if relays.len() > FRAGMENT_MAX_RELAYS { + return Err(InviteError::Malformed(format!( + "fragment carries at most {FRAGMENT_MAX_RELAYS} bootstrap relays" + ))); + } + bytes.push(0x00); + bytes.push(relays.len() as u8); + for relay in relays { + if let Some(id) = relay_dictionary_id(relay) { + bytes.push(id); + } else if let Some(host) = relay.strip_prefix("wss://") { + if host.len() > 255 { + return Err(InviteError::Malformed("relay host too long".into())); + } + bytes.push(0x00); + bytes.push(host.len() as u8); + bytes.extend_from_slice(host.as_bytes()); + } else { + if relay.len() > 255 { + return Err(InviteError::Malformed("relay URL too long".into())); + } + bytes.push(0xFF); + bytes.push(relay.len() as u8); + bytes.extend_from_slice(relay.as_bytes()); + } + } + } + bytes.extend_from_slice(token); + Ok(base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes)) +} + +/// Decode a fragment, bounding every read (a hostile link is parsed input). +pub fn decode_fragment(fragment: &str) -> Result { + let bytes = base64_simd::URL_SAFE_NO_PAD + .decode_to_vec(fragment.as_bytes()) + .map_err(|e| InviteError::Malformed(format!("fragment base64url: {e}")))?; + let mut at = 0usize; + let mut next = |n: usize| -> Result<&[u8], InviteError> { + let slice = bytes + .get(at..at + n) + .ok_or_else(|| InviteError::Malformed("fragment truncated".into()))?; + at += n; + Ok(slice) + }; + + let version = next(1)?[0]; + if version != FRAGMENT_VERSION { + return Err(InviteError::Malformed(format!( + "fragment version {version} (this dictionary generation is {FRAGMENT_VERSION})" + ))); + } + let flags = next(1)?[0]; + let relays = if flags & FLAG_STOCK_SET != 0 { + STOCK_RELAYS.iter().map(|s| s.to_string()).collect() + } else { + let count = next(1)?[0] as usize; + if count > FRAGMENT_MAX_RELAYS { + return Err(InviteError::Malformed("too many bootstrap relays".into())); + } + let mut relays = Vec::with_capacity(count); + for _ in 0..count { + let lead = next(1)?[0]; + match lead { + 0x00 => { + let len = next(1)?[0] as usize; + let host = std::str::from_utf8(next(len)?) + .map_err(|_| InviteError::Malformed("relay host not UTF-8".into()))?; + relays.push(format!("wss://{host}")); + } + 0xFF => { + let len = next(1)?[0] as usize; + let url = std::str::from_utf8(next(len)?) + .map_err(|_| InviteError::Malformed("relay URL not UTF-8".into()))?; + relays.push(url.to_string()); + } + id => { + relays.push( + dictionary_relay(id) + .ok_or_else(|| InviteError::Malformed(format!("unknown dictionary relay {id}")))? + .to_string(), + ); + } + } + } + relays + }; + let token: [u8; 16] = next(16)? + .try_into() + .expect("sliced exactly 16"); + if at != bytes.len() { + return Err(InviteError::Malformed("trailing fragment bytes".into())); + } + Ok(Fragment { token, relays }) +} + +// ============================================================================ +// The link (§2) +// ============================================================================ + +/// The parsed protocol content of an invite URL: the bundle's coordinate and +/// the decoded fragment. The base is interchangeable; only these are protocol. +#[derive(Debug, Clone)] +pub struct InviteLink { + /// The per-link signer's pubkey — the bundle's addressable author. + pub link_signer: PublicKey, + pub fragment: Fragment, +} + +/// The bundle's addressable coordinate: `(33301, link_signer, "")` — the +/// per-link pubkey alone makes it unique, no identifier bytes, no relay +/// entries (relays travel compactly in the fragment). +pub fn bundle_coordinate(link_signer: PublicKey) -> Nip19Coordinate { + Nip19Coordinate::new( + Coordinate::new(Kind::Custom(kind::PUBLIC_INVITE), link_signer), + Vec::::new(), + ) +} + +/// Mint the shareable URL: `$BASE/invite/#`. +pub fn mint_link( + base: &str, + link_signer: PublicKey, + token: &[u8; 16], + relays: &[String], +) -> Result { + let naddr = bundle_coordinate(link_signer) + .to_bech32() + .map_err(|e| InviteError::Malformed(e.to_string()))?; + let fragment = encode_fragment(token, relays)?; + Ok(format!("{}/invite/{naddr}#{fragment}", base.trim_end_matches('/'))) +} + +/// Parse an invite URL (any base). Returns the coordinate author and the +/// decoded fragment. +pub fn parse_link(url: &str) -> Result { + let (path, fragment) = url + .split_once('#') + .ok_or_else(|| InviteError::Malformed("no #fragment".into()))?; + let naddr = path + .rsplit('/') + .next() + .filter(|s| !s.is_empty()) + .ok_or_else(|| InviteError::Malformed("no naddr in path".into()))?; + let coord = Nip19Coordinate::from_bech32(naddr) + .map_err(|e| InviteError::Malformed(format!("naddr: {e}")))?; + if coord.kind != Kind::Custom(kind::PUBLIC_INVITE) || !coord.identifier.is_empty() { + return Err(InviteError::Malformed("naddr is not an invite coordinate".into())); + } + Ok(InviteLink { + link_signer: coord.public_key, + fragment: decode_fragment(fragment)?, + }) +} + +// ============================================================================ +// The bundle event (§2) — kind 33301, bare on relays +// ============================================================================ + +fn bundle_conversation_key(token: &[u8; 16]) -> ConversationKey { + ConversationKey::new(invite_bundle_key(token)) +} + +/// Publish form of a live bundle: addressable, authored by the link signer, +/// empty `d`, `vsk` 6. +pub fn build_bundle_event( + link_signer: &Keys, + bundle: &CommunityInvite, + token: &[u8; 16], + created_at_secs: u64, +) -> Result { + let json = serde_json::to_string(bundle).map_err(|e| InviteError::Malformed(e.to_string()))?; + if json.len() > NIP44_MAX_PLAINTEXT { + return Err(InviteError::Malformed("bundle exceeds the NIP-44 plaintext cap".into())); + } + let ct = encrypt_to_bytes(&bundle_conversation_key(token), json.as_bytes()) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + EventBuilder::new(Kind::Custom(kind::PUBLIC_INVITE), base64_simd::STANDARD.encode_to_string(&ct)) + .tags([ + Tag::identifier(""), + Tag::custom(TagKind::Custom("vsk".into()), [vsk::INVITE_LIVE.to_string()]), + ]) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .sign_with_keys(link_signer) + .map_err(|e| InviteError::Crypto(e.to_string())) +} + +/// The revocation tombstone: same coordinate, `vsk` 9, exactly as durable as +/// the bundle it replaces. Retiring the last live link flips Private. +pub fn build_revocation_event(link_signer: &Keys, created_at_secs: u64) -> Result { + EventBuilder::new(Kind::Custom(kind::PUBLIC_INVITE), "") + .tags([ + Tag::identifier(""), + Tag::custom(TagKind::Custom("vsk".into()), [vsk::INVITE_REVOKED.to_string()]), + ]) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .sign_with_keys(link_signer) + .map_err(|e| InviteError::Crypto(e.to_string())) +} + +/// What a fetcher finds at a link's coordinate. +#[derive(Debug, Clone)] +pub enum FetchedBundle { + Live(CommunityInvite), + Revoked, +} + +/// Open a fetched coordinate event: verify the signer matches the link (a +/// squatter is a different coordinate, but verify anyway), read the `vsk` +/// marker, decrypt and validate a live bundle. +pub fn open_bundle_event( + event: &Event, + expected_signer: &PublicKey, + token: &[u8; 16], +) -> Result { + if event.kind != Kind::Custom(kind::PUBLIC_INVITE) || event.pubkey != *expected_signer { + return Err(InviteError::Malformed("not this link's bundle event".into())); + } + if event.verify().is_err() { + return Err(InviteError::Malformed("bundle signature invalid".into())); + } + let marker = event + .tags + .iter() + .find(|t| t.kind() == TagKind::Custom("vsk".into())) + .and_then(|t| t.content()) + .ok_or_else(|| InviteError::Malformed("no vsk marker".into()))?; + if marker == vsk::INVITE_REVOKED.to_string() { + return Ok(FetchedBundle::Revoked); + } + if marker != vsk::INVITE_LIVE.to_string() { + return Err(InviteError::Malformed(format!("unknown invite marker vsk {marker}"))); + } + let ct = base64_simd::STANDARD + .decode_to_vec(event.content.as_bytes()) + .map_err(|e| InviteError::Malformed(format!("bundle base64: {e}")))?; + let json = decrypt_to_bytes(&bundle_conversation_key(token), &ct) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + let mut bundle: CommunityInvite = + serde_json::from_slice(&json).map_err(|e| InviteError::Malformed(e.to_string()))?; + bundle.validate()?; + Ok(FetchedBundle::Live(bundle)) +} + +// ============================================================================ +// Direct invites (§6) — kind 3313 in a standard NIP-59 giftwrap +// ============================================================================ + +/// Build a Direct Invite: the bundle giftwrapped straight to an npub — the +/// classic NIP-59 shape (ephemeral wrap author, recipient `p`, kind 13 seal), +/// plus the outer `["k","3313"]` that makes invites indexable, and a NIP-40 +/// expiration matching the bundle's. +pub async fn build_direct_invite( + inviter: &T, + recipient: &PublicKey, + bundle: &CommunityInvite, +) -> Result { + let json = serde_json::to_string(bundle).map_err(|e| InviteError::Malformed(e.to_string()))?; + let inviter_pk = inviter + .get_public_key() + .await + .map_err(|e| InviteError::Crypto(e.to_string()))?; + let rumor = EventBuilder::new(Kind::Custom(kind::DIRECT_INVITE), json).build(inviter_pk); + let mut extra = vec![Tag::custom(TagKind::k(), [kind::DIRECT_INVITE.to_string()])]; + if let Some(expires_ms) = bundle.expires_at { + extra.push(Tag::expiration(Timestamp::from_secs(expires_ms / 1000))); + } + EventBuilder::gift_wrap(inviter, recipient, rumor, extra) + .await + .map_err(|e| InviteError::Crypto(e.to_string())) +} + +/// A received Direct Invite: the seal-verified inviter plus the validated +/// bundle. Nothing is fetched, joined, or announced on receipt — acceptance +/// is the user's, later. +#[derive(Debug, Clone)] +pub struct DirectInvite { + pub inviter: PublicKey, + pub bundle: CommunityInvite, +} + +/// Unwrap a giftwrap into a Direct Invite. The outer `k` tag is an unsigned +/// hint and never authority: an invite is whatever unwraps to a kind 3313 +/// rumor, so this accepts untagged wraps all the same. +pub async fn open_direct_invite( + recipient: &T, + wrap: &Event, +) -> Result { + let unwrapped = UnwrappedGift::from_gift_wrap(recipient, wrap) + .await + .map_err(|e| InviteError::Crypto(e.to_string()))?; + if unwrapped.rumor.kind != Kind::Custom(kind::DIRECT_INVITE) { + return Err(InviteError::Malformed(format!( + "rumor kind {} is not a direct invite", + unwrapped.rumor.kind + ))); + } + // The rumor must claim the seal's verified author. + if unwrapped.rumor.pubkey != unwrapped.sender { + return Err(InviteError::Malformed("rumor author differs from seal signer".into())); + } + let mut bundle: CommunityInvite = serde_json::from_str(&unwrapped.rumor.content) + .map_err(|e| InviteError::Malformed(e.to_string()))?; + bundle.validate()?; + Ok(DirectInvite { inviter: unwrapped.sender, bundle }) +} + +/// The indexed lookup for exactly one's own invites: +/// `{"kinds":[1059], "#p":[me], "#k":["3313"]}`. +pub fn direct_invite_filter(me: &PublicKey) -> Filter { + Filter::new() + .kind(Kind::GiftWrap) + .pubkey(*me) + .custom_tags(SingleLetterTag::lowercase(Alphabet::K), [kind::DIRECT_INVITE.to_string()]) +} + +// ============================================================================ +// The Invite List (§4) — kind 13303, self-encrypted bookkeeping +// ============================================================================ + +/// One minted link: immutable once minted; the token is the merge key. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InviteListEntry { + pub token: String, + /// The link_signer secret — refreshing or retiring the bundle needs it. + pub signer_sk: String, + pub community_id: String, + pub url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option, + pub created_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct InviteTombstone { + pub token: String, + pub community_id: String, +} + +/// The creator's private bookkeeping, NIP-44-encrypted to self. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +pub struct InviteList { + #[serde(default)] + pub entries: Vec, + #[serde(default)] + pub tombstones: Vec, + #[serde(flatten)] + pub extra: serde_json::Map, +} + +impl InviteList { + /// Merge two device copies without coordination: entries union by token + /// (immutable once minted), tombstones union, a tombstone beats its entry + /// terminally — a stale device can never resurrect a revoked link. + pub fn merge(&mut self, other: &InviteList) { + for entry in &other.entries { + if !self.entries.iter().any(|e| e.token == entry.token) { + self.entries.push(entry.clone()); + } + } + for tomb in &other.tombstones { + if !self.tombstones.iter().any(|t| t.token == tomb.token) { + self.tombstones.push(tomb.clone()); + } + } + self.entries.sort_by(|a, b| a.token.cmp(&b.token)); + self.tombstones.sort_by(|a, b| a.token.cmp(&b.token)); + } + + pub fn is_revoked(&self, token: &str) -> bool { + self.tombstones.iter().any(|t| t.token == token) + } + + /// Entries not tombstoned (the links still live somewhere). + pub fn live_entries(&self) -> impl Iterator { + self.entries.iter().filter(|e| !self.is_revoked(&e.token)) + } +} + +/// Publish form: kind 13303, replaceable, signed by the real key, encrypted +/// to self. +pub fn build_invite_list_event(keys: &Keys, list: &InviteList, created_at_secs: u64) -> Result { + let json = serde_json::to_string(list).map_err(|e| InviteError::Malformed(e.to_string()))?; + if json.len() > NIP44_MAX_PLAINTEXT { + return Err(InviteError::Malformed("invite list exceeds the NIP-44 plaintext cap".into())); + } + let ct = nostr_sdk::nips::nip44::encrypt(keys.secret_key(), &keys.public_key(), &json, nostr_sdk::nips::nip44::Version::V2) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + EventBuilder::new(Kind::Custom(kind::INVITE_LIST), ct) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .sign_with_keys(keys) + .map_err(|e| InviteError::Crypto(e.to_string())) +} + +pub fn open_invite_list_event(keys: &Keys, event: &Event) -> Result { + let json = nostr_sdk::nips::nip44::decrypt(keys.secret_key(), &keys.public_key(), &event.content) + .map_err(|e| InviteError::Crypto(e.to_string()))?; + serde_json::from_str(&json).map_err(|e| InviteError::Malformed(e.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::concord::v2::derive; + + fn owner_bundle() -> (Keys, CommunityInvite) { + let owner = Keys::generate(); + let salt = OwnerSalt([0x33; 32]); + let cid = derive::community_id(&owner.public_key().to_bytes(), &salt); + let bundle = CommunityInvite { + community_id: cid.to_hex(), + owner: owner.public_key().to_hex(), + owner_salt: crate::simd::hex::bytes_to_hex_32(&salt.0), + community_root: crate::simd::hex::bytes_to_hex_32(&[0x44; 32]), + root_epoch: 0, + channels: vec![ChannelGrant { + id: crate::simd::hex::bytes_to_hex_32(&[0x77; 32]), + key: None, + epoch: 0, + name: "general".into(), + }], + relays: vec!["wss://jskitty.com/nostr".into()], + name: "Vector".into(), + icon: None, + expires_at: None, + creator_npub: None, + label: None, + extra: Default::default(), + }; + (owner, bundle) + } + + #[test] + fn bundle_self_certifies_the_owner() { + let (_, mut bundle) = owner_bundle(); + assert!(bundle.validate().is_ok()); + // A smuggled owner fails the commitment. + let mut forged = bundle.clone(); + forged.owner = Keys::generate().public_key().to_hex(); + assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch))); + } + + #[test] + fn bundle_bounds_are_enforced_before_allocation() { + let (_, mut bundle) = owner_bundle(); + bundle.channels = (0..=INVITE_MAX_CHANNELS) + .map(|_| bundle.channels[0].clone()) + .collect(); + assert!(matches!(bundle.validate(), Err(InviteError::TooManyChannels(_)))); + + let (_, mut bundle) = owner_bundle(); + bundle.relays = (0..10).map(|i| format!("wss://r{i}")).collect(); + bundle.validate().unwrap(); + assert_eq!(bundle.relays.len(), RELAYS_RECOMMENDED_MAX, "relay list trimmed to the cap"); + } + + #[test] + fn expiry_refuses_joining_not_previewing() { + let (_, mut bundle) = owner_bundle(); + bundle.expires_at = Some(1_000); + bundle.validate().unwrap(); // still previews + assert!(bundle.is_expired(1_001)); + assert!(!bundle.is_expired(999)); + } + + #[test] + fn fragment_stock_set_is_two_bytes_plus_token() { + let token = [5u8; 16]; + let stock: Vec = STOCK_RELAYS.iter().map(|s| s.to_string()).collect(); + let frag = encode_fragment(&token, &stock).unwrap(); + // [version][flags][token:16] = 18 bytes → 24 base64url chars. + assert_eq!(frag.len(), 24); + let decoded = decode_fragment(&frag).unwrap(); + assert_eq!(decoded.token, token); + assert_eq!(decoded.relays, stock); + // Empty relay list also selects the stock set. + assert_eq!(encode_fragment(&token, &[]).unwrap(), frag); + } + + #[test] + fn fragment_dictionary_and_literals_roundtrip() { + let token = [9u8; 16]; + let relays = vec![ + "wss://relay.ditto.pub".to_string(), // dictionary id 3 + "wss://my.own.relay".to_string(), // wss-implied literal + "ws://localhost:7777".to_string(), // verbatim literal + ]; + let frag = encode_fragment(&token, &relays).unwrap(); + let decoded = decode_fragment(&frag).unwrap(); + assert_eq!(decoded.token, token); + assert_eq!(decoded.relays, relays); + } + + #[test] + fn fragment_rejects_hostile_input() { + let token = [1u8; 16]; + // More than 3 bootstrap relays refused at encode. + let many: Vec = (0..4).map(|i| format!("wss://r{i}")).collect(); + assert!(encode_fragment(&token, &many).is_err()); + // Truncated fragment. + let frag = encode_fragment(&token, &[]).unwrap(); + assert!(decode_fragment(&frag[..frag.len() - 4]).is_err()); + // Legacy/wrong version byte. + let mut bytes = base64_simd::URL_SAFE_NO_PAD.decode_to_vec(frag.as_bytes()).unwrap(); + bytes[0] = 3; + let legacy = base64_simd::URL_SAFE_NO_PAD.encode_to_string(&bytes); + assert!(decode_fragment(&legacy).is_err()); + // Unknown dictionary id. + let unknown = { + let mut b = vec![FRAGMENT_VERSION, 0x00, 1, 200]; + b.extend_from_slice(&token); + base64_simd::URL_SAFE_NO_PAD.encode_to_string(&b) + }; + assert!(decode_fragment(&unknown).is_err()); + } + + #[test] + fn link_mints_and_parses_on_any_base() { + let signer = Keys::generate(); + let token = [7u8; 16]; + let url = mint_link("https://vectorapp.io", signer.public_key(), &token, &[]).unwrap(); + assert!(url.starts_with("https://vectorapp.io/invite/naddr1")); + let parsed = parse_link(&url).unwrap(); + assert_eq!(parsed.link_signer, signer.public_key()); + assert_eq!(parsed.fragment.token, token); + // The same naddr+fragment opens on any redirect base. + let other_base = url.replace("https://vectorapp.io", "https://armada.soapbox.pub"); + assert_eq!(parse_link(&other_base).unwrap().link_signer, signer.public_key()); + } + + #[test] + fn bundle_event_roundtrip_and_revocation() { + let (_, bundle) = owner_bundle(); + let signer = Keys::generate(); + let token = [7u8; 16]; + let event = build_bundle_event(&signer, &bundle, &token, 1_719_800_000).unwrap(); + assert_eq!(event.kind.as_u16(), kind::PUBLIC_INVITE); + + match open_bundle_event(&event, &signer.public_key(), &token).unwrap() { + FetchedBundle::Live(got) => assert_eq!(got.name, "Vector"), + FetchedBundle::Revoked => panic!("expected live"), + } + + // The wrong token cannot open it. + assert!(open_bundle_event(&event, &signer.public_key(), &[8u8; 16]).is_err()); + // A squatter's event is a different author: refused. + assert!(open_bundle_event(&event, &Keys::generate().public_key(), &token).is_err()); + + // Revocation replaces the bundle with a grave. + let tomb = build_revocation_event(&signer, 1_722_400_000).unwrap(); + assert!(matches!( + open_bundle_event(&tomb, &signer.public_key(), &token).unwrap(), + FetchedBundle::Revoked + )); + } + + #[tokio::test] + async fn direct_invite_roundtrip() { + let (_, mut bundle) = owner_bundle(); + bundle.expires_at = Some(1_735_689_600_000); + let inviter = Keys::generate(); + let recipient = Keys::generate(); + let wrap = build_direct_invite(&inviter, &recipient.public_key(), &bundle).await.unwrap(); + + // The classic NIP-59 shape: ephemeral author, recipient p, k hint, + // NIP-40 expiration matching the bundle. + assert_eq!(wrap.kind, Kind::GiftWrap); + assert_ne!(wrap.pubkey, inviter.public_key(), "wrap author is ephemeral"); + let k = wrap.tags.iter().find(|t| t.kind() == TagKind::k()).and_then(|t| t.content()); + assert_eq!(k, Some("3313")); + let exp = wrap.tags.iter().find(|t| t.kind() == TagKind::Expiration).and_then(|t| t.content()); + assert_eq!(exp, Some("1735689600")); + + let opened = open_direct_invite(&recipient, &wrap).await.unwrap(); + assert_eq!(opened.inviter, inviter.public_key(), "seal proves who invited"); + assert_eq!(opened.bundle.name, "Vector"); + + // The wrong recipient cannot open it. + assert!(open_direct_invite(&Keys::generate(), &wrap).await.is_err()); + } + + #[tokio::test] + async fn direct_invite_refuses_a_non_invite_rumor() { + let inviter = Keys::generate(); + let recipient = Keys::generate(); + let rumor = EventBuilder::new(Kind::Custom(9), "not an invite").build(inviter.public_key()); + let wrap = EventBuilder::gift_wrap(&inviter, &recipient.public_key(), rumor, []) + .await + .unwrap(); + assert!(open_direct_invite(&recipient, &wrap).await.is_err()); + } + + #[test] + fn invite_list_merges_and_tombstones_terminally() { + let entry = |token: &str| InviteListEntry { + token: token.into(), + signer_sk: "aa".into(), + community_id: "bb".into(), + url: "https://x/invite/n#f".into(), + label: Some("Reddit".into()), + created_at: 1, + expires_at: None, + extra: Default::default(), + }; + let mut device_a = InviteList { + entries: vec![entry("t1"), entry("t2")], + tombstones: vec![], + extra: Default::default(), + }; + let device_b = InviteList { + entries: vec![entry("t2"), entry("t3")], + tombstones: vec![InviteTombstone { token: "t1".into(), community_id: "bb".into() }], + extra: Default::default(), + }; + device_a.merge(&device_b); + assert_eq!(device_a.entries.len(), 3, "entries union by token"); + assert!(device_a.is_revoked("t1"), "a stale device can never resurrect a revoked link"); + let live: Vec<&str> = device_a.live_entries().map(|e| e.token.as_str()).collect(); + assert_eq!(live, vec!["t2", "t3"]); + // Merge is idempotent. + let snapshot = device_a.clone(); + device_a.merge(&snapshot.clone()); + assert_eq!(device_a, snapshot); + } + + #[test] + fn invite_list_event_roundtrip() { + let keys = Keys::generate(); + let list = InviteList { + entries: vec![InviteListEntry { + token: "t1".into(), + signer_sk: "sk".into(), + community_id: "cid".into(), + url: "https://x/invite/n#f".into(), + label: None, + created_at: 1, + expires_at: Some(2), + extra: Default::default(), + }], + tombstones: vec![], + extra: Default::default(), + }; + let event = build_invite_list_event(&keys, &list, 1_722_400_000).unwrap(); + assert_eq!(event.kind.as_u16(), kind::INVITE_LIST); + let back = open_invite_list_event(&keys, &event).unwrap(); + assert_eq!(back, list); + // Another key cannot read it. + assert!(open_invite_list_event(&Keys::generate(), &event).is_err()); + } +} diff --git a/crates/vector-core/src/concord/v2/mod.rs b/crates/vector-core/src/concord/v2/mod.rs new file mode 100644 index 00000000..2485ee55 --- /dev/null +++ b/crates/vector-core/src/concord/v2/mod.rs @@ -0,0 +1,321 @@ +//! Concord v2 — the CORD protocol (see the CORD-01..06 documents). +//! +//! This module is the pure protocol core: identities, frozen derivations, the +//! stream envelope, Control Plane editions and their convergent fold, the +//! Guestbook coalesce, invites, and rekeys/refoundings. It is network-free and +//! DB-free; transports and persistence layer on top. +//! +//! Wire universe: every plane is a Private Stream (CORD-01) — kind 1059 wraps +//! signed by a derived stream key and fetched by `authors` filter. This is a +//! different address space from v1's `#z`-tag pseudonyms; the two protocols +//! never collide on the wire. + +pub mod community; +pub mod community_list; +pub mod control; +pub mod db; +pub mod derive; +pub mod edition; +pub mod guestbook; +pub mod invite; +pub mod rekey; +pub mod roster; +pub mod service; +pub mod stream; + +use serde::{Deserialize, Serialize}; +use zeroize::{Zeroize, ZeroizeOnDrop}; + +// ============================================================================ +// Identities & keys +// ============================================================================ + +/// A Community's permanent identity: a self-certifying SHA-256 commitment to +/// the owner's key (CORD-02 §1). Never on the wire itself — every coordinate +/// derives from it one-way — but it travels inside invites so any member can +/// verify who founded the Community. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct CommunityId(pub [u8; 32]); + +impl CommunityId { + pub fn to_hex(&self) -> String { + crate::simd::hex::bytes_to_hex_32(&self.0) + } + + pub fn from_hex(hex: &str) -> Option { + crate::simd::hex::hex_to_bytes_32_checked(hex).map(Self) + } +} + +/// The salt minted alongside a Community so one owner can run many (CORD-02 +/// §1). Not secret — it travels inside invites for owner verification. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OwnerSalt(pub [u8; 32]); + +/// The Community's private access key: holding the current root *is* +/// membership (CORD-02 §2). Deliberately independent of the `community_id` so +/// access rotates while identity stays fixed. +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct CommunityRoot(pub [u8; 32]); + +impl CommunityRoot { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +// Deliberately no Debug: the root must never reach a log line. +impl core::fmt::Debug for CommunityRoot { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("CommunityRoot()") + } +} + +/// A Channel's stable identity within a Community: random 32 bytes, doubling +/// as its ChannelMetadata edition coordinate (CORD-03 §2). Never reused, even +/// after deletion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct ChannelId(pub [u8; 32]); + +impl ChannelId { + pub fn to_hex(&self) -> String { + crate::simd::hex::bytes_to_hex_32(&self.0) + } + + pub fn from_hex(hex: &str) -> Option { + crate::simd::hex::hex_to_bytes_32_checked(hex).map(Self) + } +} + +/// A Private Channel's independent 32-byte symmetric secret (CORD-03 §1). +#[derive(Clone, PartialEq, Eq, Zeroize, ZeroizeOnDrop)] +pub struct ChannelKey(pub [u8; 32]); + +impl ChannelKey { + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } +} + +impl core::fmt::Debug for ChannelKey { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("ChannelKey()") + } +} + +/// The read-access clock: bumps only on a Rekey (CORD-02 §3). Public Channels +/// ride the root's epoch; Private Channels carry their own, monotonic and +/// never resetting across public/private conversions (CORD-03 §2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default)] +pub struct Epoch(pub u64); + +/// A role's stable identity: random 32 bytes minted at creation (CORD-04 §2). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct RoleId(pub [u8; 32]); + +impl RoleId { + pub fn to_hex(&self) -> String { + crate::simd::hex::bytes_to_hex_32(&self.0) + } + + pub fn from_hex(hex: &str) -> Option { + crate::simd::hex::hex_to_bytes_32_checked(hex).map(Self) + } +} + +/// The all-zero 32-byte sentinel: the rekey scope id for a base rotation +/// (CORD-06 §1) and the `id` for zero-id derivations (CORD-02 A.6). +pub const ZERO_ID: [u8; 32] = [0u8; 32]; + +// ============================================================================ +// Kind registry (CORD-02 Appendix B) — FROZEN +// ============================================================================ + +/// Wire kinds. A retired number is never reused; its meaning is burned. +pub mod kind { + /// Durable gift wrap — every durable plane event. + pub const WRAP: u16 = 1059; + /// Ephemeral gift wrap — relays MUST NOT store (typing indicator only). + pub const WRAP_EPHEMERAL: u16 = 21059; + /// Encrypted seal (Chat, Guestbook, rekey planes). + pub const SEAL_ENCRYPTED: u16 = 20013; + /// Plaintext seal (Control Plane only — survives compaction re-wraps). + pub const SEAL_PLAINTEXT: u16 = 20014; + /// Standard NIP-59 seal (Direct Invites only). + pub const SEAL_NIP59: u16 = 13; + + /// Chat message (NIP-C7 shape). + pub const MESSAGE: u16 = 9; + /// Reaction (NIP-25 shape). + pub const REACTION: u16 = 7; + /// Delete (NIP-09 shape). + pub const DELETE: u16 = 5; + /// Edit. + pub const EDIT: u16 = 3302; + /// Rekey blobs (CORD-06). + pub const REKEY: u16 = 3303; + /// Guestbook Join / Leave. + pub const JOIN_LEAVE: u16 = 3306; + /// Control edition (sub-kinded by `vsk`). + pub const CONTROL_EDITION: u16 = 3308; + /// Guestbook Kick. + pub const KICK: u16 = 3309; + /// WebXDC peer signal. + pub const WEBXDC: u16 = 3310; + /// Guestbook snapshot (refounder-signed, chunked). + pub const SNAPSHOT: u16 = 3312; + /// Direct invite (standard NIP-59 wrap, `k`-tagged). + pub const DIRECT_INVITE: u16 = 3313; + /// Typing indicator (ephemeral). + pub const TYPING: u16 = 23311; + /// Public invite bundle (addressable, bare on relays). + pub const PUBLIC_INVITE: u16 = 33301; + /// Community List (replaceable, self-encrypted). + pub const COMMUNITY_LIST: u16 = 13302; + /// Invite List (replaceable, self-encrypted). + pub const INVITE_LIST: u16 = 13303; +} + +/// Control-edition sub-kinds (`vsk` tag). +pub mod vsk { + pub const COMMUNITY_METADATA: u8 = 0; + pub const ROLE: u8 = 1; + pub const CHANNEL_METADATA: u8 = 2; + pub const GRANT: u8 = 3; + pub const BANLIST: u8 = 4; + /// Public invite bundle marker: live. + pub const INVITE_LIVE: u8 = 6; + /// Invite-link registry. + pub const INVITE_REGISTRY: u8 = 8; + /// Public invite bundle marker: revocation tombstone. + pub const INVITE_REVOKED: u8 = 9; + /// Dissolved tombstone (chainless, exempt from version discipline). + pub const DISSOLVED: u8 = 10; +} + +// ============================================================================ +// Permission bits (CORD-04 §3) — FROZEN +// ============================================================================ + +/// Permission bits. Positions are frozen: a new permission claims the next +/// free bit, a retired one is burned, never renumbered or reused. There is no +/// all-powerful bit. +pub mod perm { + pub const MANAGE_ROLES: u64 = 1 << 0; + pub const MANAGE_CHANNELS: u64 = 1 << 1; + pub const MANAGE_METADATA: u64 = 1 << 2; + pub const KICK: u64 = 1 << 3; + pub const BAN: u64 = 1 << 4; + pub const MANAGE_MESSAGES: u64 = 1 << 5; + pub const CREATE_INVITE: u64 = 1 << 6; + // 1 << 7 retired (was MANAGE_INVITES) + pub const VIEW_AUDIT_LOG: u64 = 1 << 8; + pub const MENTION_EVERYONE: u64 = 1 << 9; + // 1 << 10..=12 reserved (MANAGE_EMOJI, PIN_MESSAGES, MANAGE_EVENTS) +} + +// ============================================================================ +// Protocol caps & constants +// ============================================================================ + +/// NIP-44 hard plaintext cap — enforced at every nesting layer ourselves; +/// libraries are lenient and a lenient publisher mints events strict readers +/// cannot decrypt (CORD-02 Appendix B). +pub const NIP44_MAX_PLAINTEXT: usize = 65_535; + +/// Uniform name cap: Community, Channel, and Role names alike (UTF-8 bytes). +pub const NAME_MAX_BYTES: usize = 64; + +/// Community description cap (UTF-8 bytes). +pub const DESCRIPTION_MAX_BYTES: usize = 10_000; + +/// Recommended relay-set ceiling; a longer set MAY be truncated (CORD-02 §6). +pub const RELAYS_RECOMMENDED_MAX: usize = 5; + +/// The Community List membership cap (CORD-02 §8) — a protocol constant, not +/// client taste; the byte cap (`NIP44_MAX_PLAINTEXT`) is the law. +pub const COMMUNITY_LIST_MAX_MEMBERSHIPS: usize = 50; + +/// Guestbook snapshot chunk size: present members per kind 3312 event. +pub const SNAPSHOT_CHUNK_MEMBERS: usize = 400; + +/// Rekey blob fan-out: recipients per kind 3303 event (CORD-06 §1). +pub const REKEY_RECIPIENTS_PER_EVENT: usize = 120; + +/// Bundle bound: a hostile invite is an allocation vector, so channels are +/// capped before any allocation (CORD-05 §1). +pub const INVITE_MAX_CHANNELS: usize = 256; + +/// Bootstrap relays a link fragment may carry (CORD-05 §3). +pub const FRAGMENT_MAX_RELAYS: usize = 3; + +/// Community role ceiling: fold the 100 lowest `role_id`s, ignore the rest. +pub const MAX_ROLES: usize = 100; + +/// A member holds at most 64 roles. +pub const MAX_ROLES_PER_MEMBER: usize = 64; + +/// Practical banlist ceiling before the NIP-44 envelope refuses the edit. +pub const BANLIST_PRACTICAL_MAX: usize = 500; + +/// Guestbook fold rule: entries dated more than one hour ahead of the +/// receiver's clock are dropped outright (CORD-02 §5). +pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000; + +// ============================================================================ +// Millisecond ordering (CORD-02 §4) +// ============================================================================ + +/// Split epoch-milliseconds the way every Concord rumor carries time: +/// `created_at` seconds plus an `["ms", 0..999]` remainder tag. +pub fn split_ms(ms: u64) -> (u64, u16) { + (ms / 1000, (ms % 1000) as u16) +} + +/// Reconstruct the true millisecond time from `created_at` and a parsed `ms` +/// remainder. Returns `None` for a malformed remainder (outside 0..=999) — +/// malformed is *dropped, never interpreted*, or the excess would smuggle +/// arbitrary "future" past the clock check (CORD-02 §5). +pub fn combine_ms(created_at_secs: u64, ms_remainder: u64) -> Option { + if ms_remainder > 999 { + return None; + } + created_at_secs.checked_mul(1000)?.checked_add(ms_remainder) +} + +/// The current wall clock in epoch milliseconds. +pub fn now_ms() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn split_and_combine_roundtrip() { + let ms = 1_686_840_217_417u64; + let (secs, rem) = split_ms(ms); + assert_eq!(secs, 1_686_840_217); + assert_eq!(rem, 417); + assert_eq!(combine_ms(secs, rem as u64), Some(ms)); + } + + #[test] + fn malformed_ms_remainder_is_dropped_not_interpreted() { + assert_eq!(combine_ms(1_686_840_217, 1000), None); + assert_eq!(combine_ms(1_686_840_217, u64::MAX), None); + assert_eq!(combine_ms(1_686_840_217, 999), Some(1_686_840_217_999)); + } + + #[test] + fn keys_never_debug_print() { + let root = CommunityRoot([0xAA; 32]); + let ck = ChannelKey([0xBB; 32]); + assert_eq!(format!("{root:?}"), "CommunityRoot()"); + assert_eq!(format!("{ck:?}"), "ChannelKey()"); + } +} diff --git a/crates/vector-core/src/concord/v2/rekey.rs b/crates/vector-core/src/concord/v2/rekey.rs new file mode 100644 index 00000000..1ab5e199 --- /dev/null +++ b/crates/vector-core/src/concord/v2/rekey.rs @@ -0,0 +1,540 @@ +//! CORD-06: Rekeys and Refoundings — asynchronous key rotation for +//! post-removal secrecy. +//! +//! A rotation delivers the fresh key as per-recipient blobs (120 per kind +//! 3303 event) at an address derived from the *prior* secret. A removed +//! member receives no blob — that absence is the entire secrecy mechanism; +//! `prevcommit` is a convergence check keeping honest members on one shared +//! chain, and authority is verified by the seal (holding a key is never +//! authority). + +use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; +use nostr_sdk::prelude::*; +use serde::{Deserialize, Serialize}; + +use super::control::ControlFold; +use super::derive::{epoch_key_commitment, recipient_locator, RekeyScope}; +use super::edition::Citation; +use super::stream::TAG_MS; +use super::{kind, perm, split_ms, Epoch, REKEY_RECIPIENTS_PER_EVENT, ZERO_ID}; + +/// One located, wrapped key inside a rotation event's content array. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RekeyBlob { + /// Where its recipient finds it (`derive::recipient_locator`, hex). + pub locator: String, + /// NIP-44 ciphertext under the Rotator↔recipient pairwise key, base64. + pub wrapped: String, +} + +/// One parsed kind 3303 chunk. A rotation to many recipients spans several, +/// correlated by the Rotator at one `new_epoch` and `prevcommit`. +#[derive(Debug, Clone)] +pub struct RotationChunk { + /// The seal-verified Rotator. + pub rotator: PublicKey, + pub scope: RekeyScope, + pub new_epoch: Epoch, + pub prev_epoch: Epoch, + pub prevcommit: [u8; 32], + /// (i, n), 1-based. + pub chunk: (u32, u32), + pub blobs: Vec, + pub citation: Option, +} + +/// The wrapped plaintext is fixed-width, 72 bytes: `scope_id ‖ epoch_be ‖ +/// new_key`. Scope and epoch live *inside* the ciphertext so a blob minted +/// for one channel can never be replayed against another. +const WRAPPED_LEN: usize = 32 + 8 + 32; + +#[derive(Debug)] +pub enum RekeyError { + Malformed(String), + Crypto(String), +} + +impl std::fmt::Display for RekeyError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + RekeyError::Malformed(e) => write!(f, "malformed rekey: {e}"), + RekeyError::Crypto(e) => write!(f, "rekey crypto: {e}"), + } + } +} + +impl std::error::Error for RekeyError {} + +/// Build a rotation as its chunk rumors: every recipient's blob, 120 per +/// event, all chunks carrying identical continuity fields. The state being +/// rotated is acquired in full before the first publish, so a mid-flight +/// failure never leaves half a rotation as the only copy. +#[allow(clippy::too_many_arguments)] +pub fn build_rotation( + rotator: &Keys, + scope: RekeyScope, + prev_epoch: Epoch, + prev_key: &[u8; 32], + new_epoch: Epoch, + new_key: &[u8; 32], + recipients: &[PublicKey], + citation: Option<&Citation>, + unix_ms: u64, +) -> Result, RekeyError> { + let prevcommit = epoch_key_commitment(prev_epoch, prev_key); + let scope_id = scope.id32(); + + let mut plaintext = [0u8; WRAPPED_LEN]; + plaintext[..32].copy_from_slice(&scope_id); + plaintext[32..40].copy_from_slice(&new_epoch.0.to_be_bytes()); + plaintext[40..].copy_from_slice(new_key); + + let mut blobs = Vec::with_capacity(recipients.len()); + for recipient in recipients { + let conv = ConversationKey::derive(rotator.secret_key(), recipient) + .map_err(|e| RekeyError::Crypto(e.to_string()))?; + let ct = encrypt_to_bytes(&conv, &plaintext).map_err(|e| RekeyError::Crypto(e.to_string()))?; + let locator = recipient_locator( + &rotator.public_key().to_bytes(), + &recipient.to_bytes(), + scope, + new_epoch, + ); + blobs.push(RekeyBlob { + locator: crate::simd::hex::bytes_to_hex_32(&locator), + wrapped: base64_simd::STANDARD.encode_to_string(&ct), + }); + } + + let chunks: Vec<&[RekeyBlob]> = if blobs.is_empty() { + vec![&[]] + } else { + blobs.chunks(REKEY_RECIPIENTS_PER_EVENT).collect() + }; + let n = chunks.len(); + let (secs, remainder) = split_ms(unix_ms); + + chunks + .iter() + .enumerate() + .map(|(i, chunk)| { + let content = serde_json::to_string(chunk).map_err(|e| RekeyError::Malformed(e.to_string()))?; + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_MS.into()), [remainder.to_string()]), + Tag::custom(TagKind::Custom("scope".into()), [crate::simd::hex::bytes_to_hex_32(&scope_id)]), + Tag::custom(TagKind::Custom("newepoch".into()), [new_epoch.0.to_string()]), + Tag::custom(TagKind::Custom("prevepoch".into()), [prev_epoch.0.to_string()]), + Tag::custom(TagKind::Custom("prevcommit".into()), [crate::simd::hex::bytes_to_hex_32(&prevcommit)]), + Tag::custom(TagKind::Custom("chunk".into()), [(i + 1).to_string(), n.to_string()]), + ]; + if let Some(c) = citation { + tags.push(Tag::custom( + TagKind::Custom(super::edition::TAG_VAC.into()), + [ + crate::simd::hex::bytes_to_hex_32(&c.grant_eid), + c.grant_version.to_string(), + crate::simd::hex::bytes_to_hex_32(&c.grant_hash), + ], + )); + } + let mut rumor = EventBuilder::new(Kind::Custom(kind::REKEY), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(secs)) + .build(rotator.public_key()); + rumor.ensure_id(); + Ok(rumor) + }) + .collect() +} + +fn tag_parts<'a>(tags: &'a Tags, name: &str) -> Option> { + tags.iter() + .find(|t| t.kind() == TagKind::Custom(name.into())) + .map(|t| t.as_slice().iter().skip(1).map(|s| s.as_str()).collect()) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tag_parts(tags, name).and_then(|p| p.first().copied()) +} + +/// Parse a seal-verified kind 3303 rumor into a [`RotationChunk`]. +pub fn parse_rotation(rumor: &UnsignedEvent) -> Result { + if rumor.kind.as_u16() != kind::REKEY { + return Err(RekeyError::Malformed(format!("kind {} is not a rekey", rumor.kind))); + } + let scope_hex = tag_value(&rumor.tags, "scope").ok_or_else(|| RekeyError::Malformed("no scope".into()))?; + let scope_id = crate::simd::hex::hex_to_bytes_32_checked(scope_hex) + .ok_or_else(|| RekeyError::Malformed("scope not 32-byte hex".into()))?; + let new_epoch: u64 = tag_value(&rumor.tags, "newepoch") + .and_then(|v| v.parse().ok()) + .ok_or_else(|| RekeyError::Malformed("bad newepoch".into()))?; + let prev_epoch: u64 = tag_value(&rumor.tags, "prevepoch") + .and_then(|v| v.parse().ok()) + .ok_or_else(|| RekeyError::Malformed("bad prevepoch".into()))?; + let prevcommit = tag_value(&rumor.tags, "prevcommit") + .and_then(crate::simd::hex::hex_to_bytes_32_checked) + .ok_or_else(|| RekeyError::Malformed("bad prevcommit".into()))?; + let chunk_parts = tag_parts(&rumor.tags, "chunk").ok_or_else(|| RekeyError::Malformed("no chunk".into()))?; + let chunk: (u32, u32) = ( + chunk_parts.first().and_then(|v| v.parse().ok()).ok_or_else(|| RekeyError::Malformed("bad chunk i".into()))?, + chunk_parts.get(1).and_then(|v| v.parse().ok()).ok_or_else(|| RekeyError::Malformed("bad chunk n".into()))?, + ); + if chunk.0 == 0 || chunk.1 == 0 || chunk.0 > chunk.1 { + return Err(RekeyError::Malformed("chunk indices are 1-based i ≤ n".into())); + } + let blobs: Vec = + serde_json::from_str(&rumor.content).map_err(|e| RekeyError::Malformed(e.to_string()))?; + let citation = tag_parts(&rumor.tags, super::edition::TAG_VAC).and_then(|p| { + Some(Citation { + grant_eid: crate::simd::hex::hex_to_bytes_32_checked(p.first()?)?, + grant_version: p.get(1)?.parse().ok()?, + grant_hash: crate::simd::hex::hex_to_bytes_32_checked(p.get(2)?)?, + }) + }); + Ok(RotationChunk { + rotator: rumor.pubkey, + scope: RekeyScope::from_id32(scope_id), + new_epoch: Epoch(new_epoch), + prev_epoch: Epoch(prev_epoch), + prevcommit, + chunk, + blobs, + citation, + }) +} + +/// Continuity verdict (CORD-06 §2) for the key the receiver currently holds. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Continuity { + /// The rotation extends the very key you hold. + Extends, + /// A mismatch with a higher `prevepoch`: you missed a rotation — fetch + /// the gap first. + MissedRotation, + /// Any other mismatch: a fork or garbage. Reject. + Fork, +} + +pub fn verify_continuity(chunk: &RotationChunk, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity { + if chunk.prev_epoch == held_epoch && chunk.prevcommit == epoch_key_commitment(held_epoch, held_key) { + Continuity::Extends + } else if chunk.prev_epoch > held_epoch { + Continuity::MissedRotation + } else { + Continuity::Fork + } +} + +/// The receiver's verdict over the chunks of one rotation (correlate by +/// Rotator + `new_epoch` + `prevcommit` before calling). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RekeyOutcome { + /// Your blob was found, decrypted, and its inner scope/epoch verified + /// against the tags: shift to the new epoch with this key. + NewKey([u8; 32]), + /// All `n` chunks are held and none contains your locator: removed. + Removed, + /// A missing chunk is never a removal — keep recovering. + Incomplete, +} + +/// Search a rotation's chunks for my blob. Chunks with inconsistent +/// continuity fields are rejected (all chunks of one rotation carry +/// identical ones). +pub fn find_my_key(chunks: &[RotationChunk], my_keys: &Keys) -> Result { + let Some(first) = chunks.first() else { + return Ok(RekeyOutcome::Incomplete); + }; + let n = first.chunk.1; + for c in chunks { + if c.rotator != first.rotator + || c.new_epoch != first.new_epoch + || c.prev_epoch != first.prev_epoch + || c.prevcommit != first.prevcommit + || c.scope != first.scope + || c.chunk.1 != n + { + return Err(RekeyError::Malformed("mixed chunks from different rotations".into())); + } + } + + let my_locator = crate::simd::hex::bytes_to_hex_32(&recipient_locator( + &first.rotator.to_bytes(), + &my_keys.public_key().to_bytes(), + first.scope, + first.new_epoch, + )); + + for c in chunks { + for blob in &c.blobs { + if blob.locator != my_locator { + continue; + } + // One ECDH either side can compute — a NIP-46 bunker opens its + // blob with a single nip44_decrypt. + let conv = ConversationKey::derive(my_keys.secret_key(), &first.rotator) + .map_err(|e| RekeyError::Crypto(e.to_string()))?; + let ct = base64_simd::STANDARD + .decode_to_vec(blob.wrapped.as_bytes()) + .map_err(|e| RekeyError::Malformed(format!("blob base64: {e}")))?; + let pt = decrypt_to_bytes(&conv, &ct).map_err(|e| RekeyError::Crypto(e.to_string()))?; + if pt.len() != WRAPPED_LEN { + return Err(RekeyError::Malformed(format!("wrapped plaintext is {} bytes, not 72", pt.len()))); + } + // Verify the inner scope and epoch against the tags before + // accepting the key — what makes a blob unspliceable. + let inner_scope: [u8; 32] = pt[..32].try_into().expect("sized"); + let inner_epoch = u64::from_be_bytes(pt[32..40].try_into().expect("sized")); + if inner_scope != first.scope.id32() || inner_epoch != first.new_epoch.0 { + return Err(RekeyError::Malformed("blob scope/epoch do not match the event".into())); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&pt[40..]); + return Ok(RekeyOutcome::NewKey(key)); + } + } + + let held: std::collections::HashSet = chunks.iter().map(|c| c.chunk.0).collect(); + if (1..=n).all(|i| held.contains(&i)) { + Ok(RekeyOutcome::Removed) + } else { + Ok(RekeyOutcome::Incomplete) + } +} + +/// Rotation authority (CORD-06 §Authority): a single-channel Rekey requires +/// `MANAGE_CHANNELS`, a Refounding `BAN`; the Rotator cites their Grant like +/// any authority action and must strictly outrank every removed target. +/// Holding a key is never authority. +pub fn may_rotate( + control: &ControlFold, + rotator: &PublicKey, + citation: Option<&Citation>, + scope: RekeyScope, + removed: &[PublicKey], +) -> bool { + if control.is_banned(rotator) { + return false; + } + if rotator != control.owner() { + let Some(c) = citation else { return false }; + match control.accepted_hash(&c.grant_eid, c.grant_version) { + Some(h) if h == c.grant_hash => {} + _ => return false, + } + } + let bit = match scope { + RekeyScope::Channel(_) => perm::MANAGE_CHANNELS, + RekeyScope::CommunityRoot => perm::BAN, + }; + if control.roster().permissions(rotator) & bit == 0 { + return false; + } + let rank = control.roster().rank(rotator); + removed.iter().all(|t| rank.outranks(&control.roster().rank(t))) +} + +/// Two rotations racing to the same epoch converge deterministically: among +/// authorized candidates at the same continuity point, the lexicographically +/// lowest new key wins — every client computes the same winner. +pub fn rotation_race_winner(candidate_keys: &[[u8; 32]]) -> Option<[u8; 32]> { + candidate_keys.iter().min().copied() +} + +/// The same-epoch heal is **down-only**: a held epoch re-converges solely to +/// a strictly lower sibling, so a flaky fetch returning only the higher one +/// can never re-fork a settled epoch. +pub fn should_reconverge(held_key: &[u8; 32], sibling_key: &[u8; 32]) -> bool { + sibling_key < held_key +} + +/// The all-zero scope hex — a base rotation's `scope` tag value. +pub fn community_root_scope_hex() -> String { + crate::simd::hex::bytes_to_hex_32(&ZERO_ID) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::concord::v2::control::FoldMode; + use crate::concord::v2::derive::grant_locator; + use crate::concord::v2::edition::{build_edition_rumor, parse_edition}; + use crate::concord::v2::roster::{Grant, Role, RoleScope}; + use crate::concord::v2::{vsk, ChannelId, CommunityId, OwnerSalt}; + + fn chunk_set(recipients: &[Keys], rotator: &Keys, scope: RekeyScope) -> (Vec, [u8; 32]) { + let new_key = [0x55u8; 32]; + let pks: Vec = recipients.iter().map(|k| k.public_key()).collect(); + let rumors = build_rotation( + rotator, + scope, + Epoch(2), + &[0x11; 32], + Epoch(3), + &new_key, + &pks, + None, + 1_722_500_000_123, + ) + .unwrap(); + (rumors.iter().map(|r| parse_rotation(r).unwrap()).collect(), new_key) + } + + #[test] + fn recipient_finds_and_decrypts_their_blob() { + let rotator = Keys::generate(); + let members: Vec = (0..5).map(|_| Keys::generate()).collect(); + let scope = RekeyScope::Channel(ChannelId([0x77; 32])); + let (chunks, new_key) = chunk_set(&members, &rotator, scope); + assert_eq!(chunks.len(), 1); + for member in &members { + assert_eq!(find_my_key(&chunks, member).unwrap(), RekeyOutcome::NewKey(new_key)); + } + } + + #[test] + fn removed_member_sees_removal_only_with_all_chunks() { + let rotator = Keys::generate(); + // 130 recipients → 2 chunks. + let members: Vec = (0..130).map(|_| Keys::generate()).collect(); + let removed = Keys::generate(); + let (chunks, _) = chunk_set(&members, &rotator, RekeyScope::CommunityRoot); + assert_eq!(chunks.len(), 2, "recipients chunk at 120 per event"); + + // Holding only one chunk: a missing chunk is never a removal. + assert_eq!(find_my_key(&chunks[..1], &removed).unwrap(), RekeyOutcome::Incomplete); + // Holding all n and no locator: removed. + assert_eq!(find_my_key(&chunks, &removed).unwrap(), RekeyOutcome::Removed); + // A member found in chunk 2 still resolves. + assert!(matches!(find_my_key(&chunks, &members[125]).unwrap(), RekeyOutcome::NewKey(_))); + } + + #[test] + fn blob_binds_scope_and_epoch_inside_the_ciphertext() { + let rotator = Keys::generate(); + let member = Keys::generate(); + let scope_a = RekeyScope::Channel(ChannelId([0xAA; 32])); + let scope_b = RekeyScope::Channel(ChannelId([0xBB; 32])); + let (chunks_a, _) = chunk_set(&[member.clone()], &rotator, scope_a); + + // Splice chunk A's blobs under channel B's tags (attacker re-tags the + // event): the locator no longer matches — and even if the locator is + // recomputed, the inner scope check fails. + let mut spliced = chunks_a[0].clone(); + spliced.scope = scope_b; + // Recompute the locator the victim would search under scope B. + let loc_b = crate::simd::hex::bytes_to_hex_32(&recipient_locator( + &rotator.public_key().to_bytes(), + &member.public_key().to_bytes(), + scope_b, + spliced.new_epoch, + )); + spliced.blobs[0].locator = loc_b; + assert!(find_my_key(&[spliced], &member).is_err(), "inner scope mismatch is rejected"); + } + + #[test] + fn continuity_extends_missed_or_fork() { + let rotator = Keys::generate(); + let member = Keys::generate(); + let (chunks, _) = chunk_set(&[member], &rotator, RekeyScope::CommunityRoot); + let c = &chunks[0]; + // Holding epoch 2 with the exact key: extends. + assert_eq!(verify_continuity(c, Epoch(2), &[0x11; 32]), Continuity::Extends); + // Holding an older epoch: you missed a rotation, fetch the gap. + assert_eq!(verify_continuity(c, Epoch(1), &[0x10; 32]), Continuity::MissedRotation); + // Same epoch, different key: a fork — reject. + assert_eq!(verify_continuity(c, Epoch(2), &[0x99; 32]), Continuity::Fork); + } + + #[test] + fn mixed_rotations_are_rejected() { + let rotator = Keys::generate(); + let member = Keys::generate(); + let (a, _) = chunk_set(&[member.clone()], &rotator, RekeyScope::CommunityRoot); + let (b, _) = chunk_set(&[member.clone()], &Keys::generate(), RekeyScope::CommunityRoot); + let mixed = vec![a[0].clone(), b[0].clone()]; + assert!(find_my_key(&mixed, &member).is_err()); + } + + #[test] + fn race_converges_to_lowest_key_and_heals_down_only() { + let a = [0x02u8; 32]; + let b = [0x01u8; 32]; + assert_eq!(rotation_race_winner(&[a, b]), Some(b)); + // Holding the loser: re-converge down. + assert!(should_reconverge(&a, &b)); + // Holding the winner: a higher sibling can never re-fork. + assert!(!should_reconverge(&b, &a)); + assert!(!should_reconverge(&b, &b)); + } + + #[test] + fn rotation_authority_is_verified_not_possessed() { + let owner = Keys::generate(); + let salt = OwnerSalt([0x33; 32]); + let cid = crate::concord::v2::derive::community_id(&owner.public_key().to_bytes(), &salt); + let mut fold = ControlFold::new(cid, owner.public_key(), FoldMode::Tracking); + + let admin = Keys::generate(); + let removed_member = Keys::generate(); + let role = serde_json::to_string(&Role { + role_id: [1; 32], + name: "mod".into(), + position: 1, + permissions: perm::MANAGE_CHANNELS, + scope: RoleScope::Server, + color: 0, + }) + .unwrap(); + let grant_eid = grant_locator(&cid, &admin.public_key().to_bytes()); + let grant = serde_json::to_string(&Grant { member: admin.public_key().to_bytes(), role_ids: vec![[1; 32]] }).unwrap(); + let role_ed = parse_edition(&build_edition_rumor(owner.public_key(), vsk::ROLE, &[1; 32], 1, None, &role, 1, None)).unwrap(); + let grant_ed = parse_edition(&build_edition_rumor(owner.public_key(), vsk::GRANT, &grant_eid, 1, None, &grant, 1, None)).unwrap(); + let vac = Citation { grant_eid, grant_version: 1, grant_hash: grant_ed.hash() }; + fold.ingest([role_ed, grant_ed]); + + let chan = RekeyScope::Channel(ChannelId([0x77; 32])); + // The admin can rekey a channel against a plain member... + assert!(may_rotate(&fold, &admin.public_key(), Some(&vac), chan, &[removed_member.public_key()])); + // ...but not refound (no BAN bit)... + assert!(!may_rotate(&fold, &admin.public_key(), Some(&vac), RekeyScope::CommunityRoot, &[removed_member.public_key()])); + // ...never against the owner (rank)... + assert!(!may_rotate(&fold, &admin.public_key(), Some(&vac), chan, &[owner.public_key()])); + // ...and never without a resolving citation. + assert!(!may_rotate(&fold, &admin.public_key(), None, chan, &[removed_member.public_key()])); + // A removed member holding the prior root constructs a perfect + // rotation — and every honest member drops it (roleless). + let squatter = Keys::generate(); + assert!(!may_rotate(&fold, &squatter.public_key(), Some(&vac), chan, &[])); + // The owner needs no citation, and BAN-refounds freely. + assert!(may_rotate(&fold, &owner.public_key(), None, RekeyScope::CommunityRoot, &[removed_member.public_key()])); + } + + #[test] + fn build_parse_roundtrip_preserves_fields() { + let rotator = Keys::generate(); + let member = Keys::generate(); + let vac = Citation { grant_eid: [1; 32], grant_version: 2, grant_hash: [3; 32] }; + let rumors = build_rotation( + &rotator, + RekeyScope::CommunityRoot, + Epoch(4), + &[0x11; 32], + Epoch(5), + &[0x22; 32], + &[member.public_key()], + Some(&vac), + 1_722_500_000_123, + ) + .unwrap(); + let c = parse_rotation(&rumors[0]).unwrap(); + assert_eq!(c.rotator, rotator.public_key()); + assert_eq!(c.scope, RekeyScope::CommunityRoot); + assert_eq!((c.prev_epoch, c.new_epoch), (Epoch(4), Epoch(5))); + assert_eq!(c.prevcommit, epoch_key_commitment(Epoch(4), &[0x11; 32])); + assert_eq!(c.chunk, (1, 1)); + assert_eq!(c.citation, Some(vac)); + assert_eq!(c.blobs.len(), 1); + } +} diff --git a/crates/vector-core/src/concord/v2/roster.rs b/crates/vector-core/src/concord/v2/roster.rs new file mode 100644 index 00000000..84ac5dc9 --- /dev/null +++ b/crates/vector-core/src/concord/v2/roster.rs @@ -0,0 +1,466 @@ +//! CORD-04: the owner-rooted Roster — Roles, Grants, permissions, position. +//! +//! Authority is *rejection, not prevention*: anyone can publish an action; +//! everyone else drops the ones that don't map to a qualifying rank. Rank is +//! owner-rooted — the owner is proven by the `community_id` itself, occupies +//! position 0, and is supreme; every Role and Grant must trace to them. + +use std::collections::{BTreeMap, HashMap}; + +use nostr_sdk::prelude::PublicKey; +use serde::de::Error as _; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use super::{perm, ChannelId, RoleId, MAX_ROLES, MAX_ROLES_PER_MEMBER, NAME_MAX_BYTES}; + +/// A Role's reach: server-wide, or one Channel. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "lowercase")] +pub enum RoleScope { + Server, + Channel { + #[serde(with = "hex32_serde")] + channel_id: [u8; 32], + }, +} + +/// A Role: a named bundle of permission bits at a position (vsk 1). It mints +/// no key — granting it hands *rank*, never a secret. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Role { + #[serde(with = "hex32_serde")] + pub role_id: [u8; 32], + pub name: String, + /// Lower is higher; 0 is the owner and never mintable. + pub position: u32, + /// Rides the wire as a decimal string (a JSON number is a float in + /// JavaScript and corrupts past 2^53); a reader accepts either form. + #[serde(with = "perm_string")] + pub permissions: u64, + pub scope: RoleScope, + /// Cosmetic badge tint; 0 = theme default. + #[serde(default)] + pub color: u32, +} + +impl Role { + pub fn validate(&self) -> Result<(), String> { + if self.name.len() > NAME_MAX_BYTES { + return Err(format!("role name exceeds {NAME_MAX_BYTES} bytes")); + } + if self.position == 0 { + return Err("position 0 is the owner's and never mintable".into()); + } + Ok(()) + } +} + +/// A Grant: a member's npub mapped to their Roles (vsk 3). Empty `role_ids` +/// is a revoke. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Grant { + #[serde(with = "hex32_serde")] + pub member: [u8; 32], + #[serde(with = "hex32_vec_serde")] + pub role_ids: Vec<[u8; 32]>, +} + +/// A member's authority rank. Ordering: `Owner` above every position, lower +/// position above higher, any position above `Roleless`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Rank { + Owner, + Position(u32), + Roleless, +} + +impl Rank { + /// Strictly outranks — equal cannot act on equal (CORD-04 §3). + pub fn outranks(&self, other: &Rank) -> bool { + match (self, other) { + (Rank::Owner, Rank::Owner) => false, + (Rank::Owner, _) => true, + (_, Rank::Owner) => false, + (Rank::Position(a), Rank::Position(b)) => a < b, + (Rank::Position(_), Rank::Roleless) => true, + (Rank::Roleless, _) => false, + } + } +} + +/// The folded Roster: the owner plus every honored Role and Grant. +#[derive(Debug, Clone)] +pub struct Roster { + pub owner: PublicKey, + /// Keyed (and capped) by `role_id`: a Community carries at most 100 + /// Roles — fold the 100 lowest ids, ignore the rest, deterministically. + pub roles: BTreeMap, + pub grants: HashMap>, +} + +impl Roster { + pub fn new(owner: PublicKey) -> Self { + Roster { owner, roles: BTreeMap::new(), grants: HashMap::new() } + } + + /// Insert a Role under the deterministic 100-lowest-ids cap. + pub fn insert_role(&mut self, role: Role) { + self.roles.insert(RoleId(role.role_id), role); + while self.roles.len() > MAX_ROLES { + // BTreeMap orders by id: evict the highest. + let last = *self.roles.keys().next_back().expect("non-empty"); + self.roles.remove(&last); + } + } + + /// Apply a Grant, truncating to the 64-role member cap deterministically + /// (lowest role ids kept). + pub fn apply_grant(&mut self, member: PublicKey, mut role_ids: Vec) { + role_ids.sort(); + role_ids.dedup(); + role_ids.truncate(MAX_ROLES_PER_MEMBER); + if role_ids.is_empty() { + self.grants.remove(&member); + } else { + self.grants.insert(member, role_ids); + } + } + + fn member_roles(&self, member: &PublicKey) -> impl Iterator { + self.grants + .get(member) + .into_iter() + .flatten() + .filter_map(|rid| self.roles.get(rid)) + } + + /// A member's rank: the lowest position among their Roles; the owner is + /// position 0 by identity, a roleless member effectively last. + pub fn rank(&self, member: &PublicKey) -> Rank { + if *member == self.owner { + return Rank::Owner; + } + self.member_roles(member) + .map(|r| r.position) + .min() + .map(Rank::Position) + .unwrap_or(Rank::Roleless) + } + + /// Effective *server-wide* permissions: the union of server-scoped Roles' + /// bits. The owner is supreme (all bits). + pub fn permissions(&self, member: &PublicKey) -> u64 { + if *member == self.owner { + return u64::MAX; + } + self.member_roles(member) + .filter(|r| matches!(r.scope, RoleScope::Server)) + .fold(0u64, |acc, r| acc | r.permissions) + } + + /// Effective permissions *within one Channel*: server-wide bits plus + /// channel-scoped Roles matching it. + pub fn permissions_in_channel(&self, member: &PublicKey, channel: &ChannelId) -> u64 { + if *member == self.owner { + return u64::MAX; + } + self.member_roles(member) + .filter(|r| match r.scope { + RoleScope::Server => true, + RoleScope::Channel { channel_id } => channel_id == channel.0, + }) + .fold(0u64, |acc, r| acc | r.permissions) + } + + /// The one hard rule (CORD-04 §3): the actor holds the required bit AND + /// strictly outranks the target. + pub fn can_act_on(&self, actor: &PublicKey, required_bit: u64, target: &PublicKey) -> bool { + self.permissions(actor) & required_bit != 0 && self.rank(actor).outranks(&self.rank(target)) + } + + /// Whether `actor` may publish a Role edition claiming `position`: holds + /// `MANAGE_ROLES` and the position sits strictly *below* them — nobody + /// promotes toward the top, and the top itself is unmintable. Binds the + /// owner too: position 0 is refused even from position 0. + pub fn may_place_role(&self, actor: &PublicKey, position: u32) -> bool { + if self.permissions(actor) & perm::MANAGE_ROLES == 0 || position == 0 { + return false; + } + self.rank(actor).outranks(&Rank::Position(position)) + } + + /// Whether `actor` may hand out this exact role set to `member`: outranks + /// every Role handed out AND the target member (a revoke included). + pub fn may_grant(&self, actor: &PublicKey, member: &PublicKey, role_ids: &[RoleId]) -> bool { + if self.permissions(actor) & perm::MANAGE_ROLES == 0 { + return false; + } + let actor_rank = self.rank(actor); + if !actor_rank.outranks(&self.rank(member)) { + return false; + } + role_ids.iter().all(|rid| match self.roles.get(rid) { + Some(role) => actor_rank.outranks(&Rank::Position(role.position)), + // Granting a Role the fold doesn't hold can't be rank-checked. + None => false, + }) + } +} + +// --- serde helpers --- + +mod hex32_serde { + use super::*; + + pub fn serialize(v: &[u8; 32], s: S) -> Result { + s.serialize_str(&crate::simd::hex::bytes_to_hex_32(v)) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> { + let s = String::deserialize(d)?; + crate::simd::hex::hex_to_bytes_32_checked(&s) + .ok_or_else(|| D::Error::custom("expected 64-char lowercase hex")) + } +} + +mod hex32_vec_serde { + use super::*; + + pub fn serialize(v: &[[u8; 32]], s: S) -> Result { + use serde::ser::SerializeSeq; + let mut seq = s.serialize_seq(Some(v.len()))?; + for item in v { + seq.serialize_element(&crate::simd::hex::bytes_to_hex_32(item))?; + } + seq.end() + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let strings = Vec::::deserialize(d)?; + strings + .into_iter() + .map(|s| { + crate::simd::hex::hex_to_bytes_32_checked(&s) + .ok_or_else(|| D::Error::custom("expected 64-char lowercase hex")) + }) + .collect() + } +} + +/// Wire form of the permission field: always written as a decimal string, +/// read as either a string or a (legacy) number. +mod perm_string { + use super::*; + + pub fn serialize(v: &u64, s: S) -> Result { + s.serialize_str(&v.to_string()) + } + + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Either { + Str(String), + Num(u64), + } + match Either::deserialize(d)? { + Either::Str(s) => s.parse().map_err(|_| D::Error::custom("permissions not a decimal u64")), + Either::Num(n) => Ok(n), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::Keys; + + fn pk() -> PublicKey { + Keys::generate().public_key() + } + + fn role(id: u8, position: u32, permissions: u64) -> Role { + Role { + role_id: [id; 32], + name: "r".into(), + position, + permissions, + scope: RoleScope::Server, + color: 0, + } + } + + fn roster_with(owner: PublicKey, roles: Vec, grants: Vec<(PublicKey, Vec)>) -> Roster { + let mut r = Roster::new(owner); + for role in roles { + r.insert_role(role); + } + for (member, ids) in grants { + r.apply_grant(member, ids.into_iter().map(|i| RoleId([i; 32])).collect()); + } + r + } + + #[test] + fn role_wire_shape_permissions_as_decimal_string() { + let r = role(1, 2, perm::KICK | perm::MANAGE_MESSAGES); + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["permissions"], serde_json::json!("40")); + assert_eq!(json["scope"], serde_json::json!({"kind": "server"})); + // Reader accepts both forms. + let from_str: Role = serde_json::from_value(json).unwrap(); + assert_eq!(from_str.permissions, 40); + let legacy = serde_json::json!({ + "role_id": crate::simd::hex::bytes_to_hex_32(&[1; 32]), + "name": "r", "position": 2, "permissions": 40, + "scope": {"kind": "server"}, "color": 0 + }); + let from_num: Role = serde_json::from_value(legacy).unwrap(); + assert_eq!(from_num.permissions, 40); + // All 64 bits survive the string form exactly. + let full = role(1, 2, u64::MAX); + let round: Role = serde_json::from_str(&serde_json::to_string(&full).unwrap()).unwrap(); + assert_eq!(round.permissions, u64::MAX); + } + + #[test] + fn channel_scope_wire_shape() { + let r = Role { scope: RoleScope::Channel { channel_id: [9; 32] }, ..role(1, 2, 1) }; + let json = serde_json::to_value(&r).unwrap(); + assert_eq!(json["scope"]["kind"], "channel"); + let back: Role = serde_json::from_value(json).unwrap(); + assert_eq!(back.scope, RoleScope::Channel { channel_id: [9; 32] }); + } + + #[test] + fn rank_is_lowest_position_owner_supreme_roleless_last() { + let owner = pk(); + let admin = pk(); + let nobody = pk(); + let r = roster_with(owner, vec![role(1, 5, 0), role(2, 2, 0)], vec![(admin, vec![1, 2])]); + assert_eq!(r.rank(&owner), Rank::Owner); + assert_eq!(r.rank(&admin), Rank::Position(2)); + assert_eq!(r.rank(&nobody), Rank::Roleless); + assert!(r.rank(&owner).outranks(&r.rank(&admin))); + assert!(r.rank(&admin).outranks(&r.rank(&nobody))); + assert!(!r.rank(&nobody).outranks(&r.rank(&nobody)), "equal cannot act on equal"); + } + + #[test] + fn permissions_are_the_union_of_role_bits() { + let owner = pk(); + let member = pk(); + let r = roster_with( + owner, + vec![role(1, 3, perm::KICK), role(2, 4, perm::BAN)], + vec![(member, vec![1, 2])], + ); + assert_eq!(r.permissions(&member), perm::KICK | perm::BAN); + assert_eq!(r.permissions(&owner), u64::MAX, "owner is supreme"); + assert_eq!(r.permissions(&pk()), 0); + } + + #[test] + fn channel_scoped_roles_apply_only_in_their_channel() { + let owner = pk(); + let modder = pk(); + let chan = ChannelId([0x77; 32]); + let mut scoped = role(1, 3, perm::MANAGE_MESSAGES); + scoped.scope = RoleScope::Channel { channel_id: chan.0 }; + let r = roster_with(owner, vec![scoped], vec![(modder, vec![1])]); + assert_eq!(r.permissions(&modder), 0, "channel-scoped bits don't leak server-wide"); + assert_eq!(r.permissions_in_channel(&modder, &chan), perm::MANAGE_MESSAGES); + assert_eq!(r.permissions_in_channel(&modder, &ChannelId([0x78; 32])), 0); + } + + #[test] + fn equal_rank_cannot_act_on_equal() { + let owner = pk(); + let a = pk(); + let b = pk(); + let r = roster_with(owner, vec![role(1, 2, perm::BAN)], vec![(a, vec![1]), (b, vec![1])]); + assert!(!r.can_act_on(&a, perm::BAN, &b), "an admin cannot ban a peer admin"); + assert!(r.can_act_on(&owner, perm::BAN, &a)); + } + + #[test] + fn role_placement_rules() { + let owner = pk(); + let admin = pk(); + let r = roster_with(owner, vec![role(1, 2, perm::MANAGE_ROLES)], vec![(admin, vec![1])]); + // Below the actor: fine. + assert!(r.may_place_role(&admin, 3)); + // At or above the actor: refused — no self-promotion toward the top. + assert!(!r.may_place_role(&admin, 2)); + assert!(!r.may_place_role(&admin, 1)); + // Position 0 is not mintable, the owner included. + assert!(!r.may_place_role(&owner, 0)); + assert!(r.may_place_role(&owner, 1)); + // No MANAGE_ROLES bit → no placement at all. + assert!(!r.may_place_role(&pk(), 5)); + } + + #[test] + fn grant_rules_outrank_both_roles_and_target() { + let owner = pk(); + let admin = pk(); + let peer = pk(); + let member = pk(); + let r = roster_with( + owner, + vec![role(1, 2, perm::MANAGE_ROLES), role(2, 5, 0), role(3, 1, 0)], + vec![(admin, vec![1]), (peer, vec![1])], + ); + // Handing out a lower role to a roleless member: fine. + assert!(r.may_grant(&admin, &member, &[RoleId([2; 32])])); + // Handing out a role above the actor: refused. + assert!(!r.may_grant(&admin, &member, &[RoleId([3; 32])])); + // Acting on a peer of equal rank: refused (revoke included). + assert!(!r.may_grant(&admin, &peer, &[])); + // A role the fold doesn't hold can't be rank-checked: refused. + assert!(!r.may_grant(&admin, &member, &[RoleId([9; 32])])); + // The owner outranks everyone. + assert!(r.may_grant(&owner, &admin, &[RoleId([2; 32])])); + } + + #[test] + fn caps_are_deterministic() { + let owner = pk(); + let mut r = Roster::new(owner); + for i in 0..=(MAX_ROLES as u8) { + r.insert_role(role(i, 5, 0)); + } + assert_eq!(r.roles.len(), MAX_ROLES, "fold the 100 lowest role ids"); + assert!(r.roles.contains_key(&RoleId([0; 32]))); + assert!(!r.roles.contains_key(&RoleId([MAX_ROLES as u8; 32])), "highest id evicted"); + + let member = pk(); + let many: Vec = (0..70u8).map(|i| RoleId([i; 32])).collect(); + r.apply_grant(member, many); + assert_eq!(r.grants.get(&member).unwrap().len(), MAX_ROLES_PER_MEMBER); + } + + #[test] + fn grant_wire_roundtrip_and_empty_is_revoke() { + let g = Grant { member: [0xAA; 32], role_ids: vec![[1; 32], [2; 32]] }; + let json = serde_json::to_string(&g).unwrap(); + let back: Grant = serde_json::from_str(&json).unwrap(); + assert_eq!(back, g); + + let owner = pk(); + let member = pk(); + let mut r = roster_with(owner, vec![role(1, 2, 0)], vec![(member, vec![1])]); + assert_eq!(r.rank(&member), Rank::Position(2)); + r.apply_grant(member, vec![]); + assert_eq!(r.rank(&member), Rank::Roleless, "empty role_ids is a revoke"); + } + + #[test] + fn role_validation() { + assert!(role(1, 1, 0).validate().is_ok()); + assert!(role(1, 0, 0).validate().is_err(), "position 0 unmintable"); + let mut long = role(1, 1, 0); + long.name = "x".repeat(NAME_MAX_BYTES + 1); + assert!(long.validate().is_err()); + } +} diff --git a/crates/vector-core/src/concord/v2/service.rs b/crates/vector-core/src/concord/v2/service.rs new file mode 100644 index 00000000..b9f1535d --- /dev/null +++ b/crates/vector-core/src/concord/v2/service.rs @@ -0,0 +1,794 @@ +//! Concord v2 network service: creation, boot sync, channel paging, sending, +//! and the realtime authors-routed subscription. +//! +//! v2 planes live at derived stream *addresses* (`authors` filters on kind +//! 1059/21059 wraps), so routing is a pubkey→route map — a different seam +//! from v1's `z`-tag pseudonyms, and one that never collides with DM gift +//! wraps (those have random ephemeral authors and `p`-tag the recipient). +//! +//! Multi-account: every entry point captures a `SessionGuard` and re-checks +//! it across each network await before any STATE/DB write. + +use std::collections::HashMap; +use std::sync::LazyLock; +use std::time::Duration; + +use nostr_sdk::prelude::*; +use tokio::sync::{Mutex, RwLock}; + +use super::community::{self, Community}; +use super::control::{ControlFold, FoldMode}; +use super::db; +use super::derive::GroupKey; +use super::edition::parse_edition; +use super::stream::{self, Opened, SealForm}; +use super::{kind, now_ms, vsk, ChannelId, CommunityId, Epoch}; +use crate::state::SessionGuard; +use crate::types::Message; + +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +const PAGE_LIMIT: usize = 50; + +// ============================================================================ +// Routes & subscription +// ============================================================================ + +/// Where an inbound wrap belongs, resolved by its author (the plane address). +/// The `GroupKey` rides along so dispatch decrypts without a DB round-trip. +#[derive(Clone)] +enum Route { + /// Control and dissolution motion both funnel into a full community + /// re-sync, which re-derives its own keys. + Control { community: CommunityId }, + Chat { community: CommunityId, channel: ChannelId, epoch: Epoch, group: GroupKey }, + Guestbook { community: CommunityId, group: GroupKey }, + Dissolved { community: CommunityId }, +} + +static ROUTES: LazyLock>> = LazyLock::new(|| RwLock::new(HashMap::new())); +static SUB_ID: LazyLock>> = LazyLock::new(|| Mutex::new(None)); +static POOLWIDE_SUB_ID: LazyLock>> = LazyLock::new(|| Mutex::new(None)); + +/// Session-swap hygiene: drop every route and live subscription id. +pub async fn clear() { + ROUTES.write().await.clear(); + *SUB_ID.lock().await = None; + *POOLWIDE_SUB_ID.lock().await = None; +} + +/// Rebuild the pubkey→route map from persisted communities. Returns the +/// route authors (hex) and the union of community relays. +pub async fn rebuild_routes() -> (Vec, Vec) { + let communities = db::list_communities().unwrap_or_default(); + let mut map: HashMap = HashMap::new(); + let mut relays: Vec = Vec::new(); + for c in &communities { + let control = c.control_key(); + map.insert(control.public_key(), Route::Control { community: c.id }); + let guestbook = c.guestbook_key(); + map.insert(guestbook.public_key(), Route::Guestbook { community: c.id, group: guestbook }); + let dissolved = c.dissolved_key(); + map.insert(dissolved.public_key(), Route::Dissolved { community: c.id }); + for chan in c.channels.iter().filter(|ch| !ch.deleted) { + if let (Some(group), Some(epoch)) = (c.channel_key(&chan.id), c.channel_epoch(&chan.id)) { + map.insert( + group.public_key(), + Route::Chat { community: c.id, channel: chan.id, epoch, group }, + ); + } + } + for r in &c.relays { + if !relays.contains(r) { + relays.push(r.clone()); + } + } + } + let authors: Vec = map.keys().copied().collect(); + *ROUTES.write().await = map; + (authors, relays) +} + +/// (Re)open the live subscription: kind 1059/21059 by plane authors, on the +/// community relays (added GOSSIP|PING) plus pool-wide for Android parity. +pub async fn refresh_subscription(client: &Client) { + let (authors, relays) = rebuild_routes().await; + + // Serialize concurrent refreshers across the unsubscribe+subscribe. + let mut sub_guard = SUB_ID.lock().await; + if let Some(old) = sub_guard.take() { + client.unsubscribe(&old).await; + } + if authors.is_empty() { + if let Some(old) = POOLWIDE_SUB_ID.lock().await.take() { + client.unsubscribe(&old).await; + } + return; + } + + for r in &relays { + let _ = client.pool().add_relay(r.as_str(), crate::community_relay_options()).await; + } + client.connect().await; + + let filter = Filter::new() + .kinds([Kind::Custom(kind::WRAP), Kind::Custom(kind::WRAP_EPHEMERAL)]) + .authors(authors) + .limit(0); + + { + let mut pw = POOLWIDE_SUB_ID.lock().await; + if let Some(old) = pw.take() { + client.unsubscribe(&old).await; + } + if let Ok(out) = client.subscribe(filter.clone(), None).await { + *pw = Some(out.val); + } + } + if !relays.is_empty() { + if let Ok(out) = client.subscribe_to(relays, filter, None).await { + *sub_guard = Some(out.val); + } + } +} + +// ============================================================================ +// Signing & publishing +// ============================================================================ + +/// Seal (via the account's `NostrSigner` — local keys or bunker) and wrap a +/// rumor at a plane's address. +async fn seal_and_wrap( + client: &Client, + group: &GroupKey, + rumor: &UnsignedEvent, + form: SealForm, + wrap_kind: u16, +) -> Result { + let signer = client.signer().await.map_err(|e| format!("signer unavailable: {e}"))?; + let unsigned_seal = + stream::build_seal(group, rumor.pubkey, rumor, form).map_err(|e| e.to_string())?; + let seal = signer + .sign_event(unsigned_seal) + .await + .map_err(|e| format!("seal signing failed: {e}"))?; + stream::wrap_signed_seal(group, &seal, wrap_kind, Keys::generate().public_key()) + .map_err(|e| e.to_string()) +} + +async fn publish(client: &Client, relays: &[String], event: &Event) -> Result { + for r in relays { + let _ = client.pool().add_relay(r.as_str(), crate::community_relay_options()).await; + } + client.connect().await; + let out = if relays.is_empty() { + client.send_event(event).await.map_err(|e| e.to_string())? + } else { + client + .send_event_to(relays.iter().cloned(), event) + .await + .map_err(|e| e.to_string())? + }; + if out.success.is_empty() { + return Err("no relay accepted the event".into()); + } + Ok(event.id) +} + +/// Seal, wrap, and publish one chat rumor into a channel. Returns the outer +/// wrap id. +pub async fn publish_chat_rumor( + client: &Client, + community: &Community, + channel: &ChannelId, + rumor: &UnsignedEvent, + ephemeral_kind: bool, +) -> Result { + let group = community + .channel_key(channel) + .ok_or("channel key not held")?; + let wrap_kind = if ephemeral_kind { kind::WRAP_EPHEMERAL } else { kind::WRAP }; + let wrap = seal_and_wrap(client, &group, rumor, SealForm::Encrypted, wrap_kind).await?; + publish(client, &community.relays, &wrap).await +} + +// ============================================================================ +// Creation +// ============================================================================ + +/// Found and publish a v2 Community: mint keys + `#general`, persist locally +/// FIRST (fresh-random keys must never be lost to a publish failure), then +/// publish the genesis Control Plane. +pub async fn create_community(client: &Client, name: &str, relays: Vec) -> Result { + let session = SessionGuard::capture(); + let signer = client.signer().await.map_err(|e| format!("signer unavailable: {e}"))?; + let my_pk = signer.get_public_key().await.map_err(|e| e.to_string())?; + + let community::Founded { community, genesis } = + Community::found(my_pk, name, relays, now_ms())?; + + if !session.is_valid() { + return Err("account changed during creation".into()); + } + db::save_community(&community)?; + + let control = community.control_key(); + for rumor in &genesis { + let wrap = seal_and_wrap(client, &control, rumor, SealForm::Plaintext, kind::WRAP).await?; + publish(client, &community.relays, &wrap).await?; + if let Ok(ed) = parse_edition(rumor) { + if session.is_valid() { + // The seal survives inside the wrap; persist its JSON for the + // boot fold. Reconstruct it the same way the wrap carried it. + let seal = stream::build_seal(&control, rumor.pubkey, rumor, SealForm::Plaintext) + .map_err(|e| e.to_string())?; + let _ = db::save_edition_seal(&community.id, &ed.eid, ed.version, &seal.as_json()); + } + } + } + + if !session.is_valid() { + return Err("account changed during creation".into()); + } + sync_chats(&community).await; + refresh_subscription(client).await; + Ok(community) +} + +// ============================================================================ +// STATE / chat rows +// ============================================================================ + +/// Mirror every channel into STATE as a Community chat row + persist the slim +/// rows, so v2 channels load uniformly with DMs and v1 channels at startup. +pub async fn sync_chats(community: &Community) { + let session = SessionGuard::capture(); + let my_pk = crate::state::my_public_key(); + let is_owner = my_pk.map(|pk| pk == community.owner).unwrap_or(false); + let owner_npub = community.owner.to_bech32().ok(); + let created_at_ms = db::community_created_at_ms(&community.id); + let dissolved = db::is_dissolved(&community.id); + let slims = { + let mut state = crate::state::STATE.lock().await; + let mut slims = Vec::new(); + for ch in community.channels.iter().filter(|c| !c.deleted) { + let channel_id = ch.id.to_hex(); + state.upsert_community_chat( + &channel_id, + &community.name, + community.description.as_deref().unwrap_or(""), + &community.id.to_hex(), + is_owner, + false, + owner_npub.as_deref(), + created_at_ms, + dissolved, + ); + if let Some(chat) = state.chats.iter().find(|c| c.id == channel_id) { + slims.push(crate::db::chats::SlimChatDB::from_chat(chat, &state.interner)); + } + } + slims + }; + if !session.is_valid() { + return; + } + for slim in &slims { + let _ = crate::db::chats::save_slim_chat(slim); + } +} + +/// Local teardown: STATE chats + chat rows + concord2 rows. +pub async fn teardown_local(community: &Community) { + let channel_ids: Vec = community.channels.iter().map(|c| c.id.to_hex()).collect(); + { + let mut state = crate::state::STATE.lock().await; + state.chats.retain(|c| !channel_ids.contains(&c.id)); + } + for id in &channel_ids { + let _ = crate::db::chats::delete_chat(id); + } + let _ = db::delete_community(&community.id); +} + +// ============================================================================ +// Control fold +// ============================================================================ + +fn fold_from_seal_jsons(community: &Community, seals: &[String]) -> ControlFold { + // Persisted seals were verified on ingest; fresh joins are handled by the + // invite path (future pass), so Tracking is the boot default. + let mut fold = ControlFold::new(community.id, community.owner, FoldMode::Tracking); + let mut editions = Vec::new(); + for json in seals { + let Ok(seal) = Event::from_json(json) else { continue }; + if seal.verify().is_err() { + continue; + } + let Ok(mut rumor) = UnsignedEvent::from_json(seal.content.as_bytes()) else { continue }; + rumor.ensure_id(); + if rumor.pubkey != seal.pubkey { + continue; + } + if let Ok(ed) = parse_edition(&rumor) { + editions.push(ed); + } + } + fold.ingest(editions); + fold +} + +/// Fetch + fold the Control Plane and the dissolution coordinate for one +/// community, apply the result, and persist. Returns the refreshed Community. +pub async fn sync_community(client: &Client, mut community: Community) -> Result { + let session = SessionGuard::capture(); + let control = community.control_key(); + let dissolved_key = community.dissolved_key(); + + // Rebuild the fold from persisted editions, then extend it from relays. + let persisted = db::load_edition_seals(&community.id).unwrap_or_default(); + let mut fold = fold_from_seal_jsons(&community, &persisted); + + let filter = Filter::new() + .kind(Kind::Custom(kind::WRAP)) + .authors([control.public_key(), dissolved_key.public_key()]); + let events = fetch(client, &community.relays, filter).await?; + + if !session.is_valid() { + return Err("account changed during community sync".into()); + } + + for event in &events { + if event.pubkey == dissolved_key.public_key() { + if let Ok(opened) = stream::open(&dissolved_key, event) { + if let Ok(ed) = parse_edition(&opened.rumor) { + if ed.vsk == vsk::DISSOLVED && fold.ingest_dissolution(&ed) { + let _ = db::set_dissolved(&community.id); + } + } + } + continue; + } + if let Ok(opened) = stream::open(&control, event) { + if opened.seal_form != SealForm::Plaintext { + continue; + } + if let Ok(ed) = parse_edition(&opened.rumor) { + // Persist the signed seal verbatim (fold rebuild + compaction). + let seal_json = decrypt_seal_json(&control, event); + if let Some(json) = seal_json { + let _ = db::save_edition_seal(&community.id, &ed.eid, ed.version, &json); + } + fold.ingest([ed]); + } + } + } + + community.apply_control(&fold); + if !session.is_valid() { + return Err("account changed during community sync".into()); + } + db::save_community(&community)?; + sync_chats(&community).await; + Ok(community) +} + +/// The signed seal JSON inside a wrap (for verbatim persistence). +fn decrypt_seal_json(group: &GroupKey, wrap: &Event) -> Option { + use nostr_sdk::nips::nip44::v2::decrypt_to_bytes; + let ct = base64_simd::STANDARD.decode_to_vec(wrap.content.as_bytes()).ok()?; + let seal_bytes = decrypt_to_bytes(group.conversation_key(), &ct).ok()?; + String::from_utf8(seal_bytes).ok() +} + +// ============================================================================ +// Fetching & message ingest +// ============================================================================ + +async fn fetch(client: &Client, relays: &[String], filter: Filter) -> Result, String> { + for r in relays { + let _ = client.pool().add_relay(r.as_str(), crate::community_relay_options()).await; + } + client.connect().await; + let events = if relays.is_empty() { + client.fetch_events(filter, FETCH_TIMEOUT).await + } else { + client + .fetch_events_from(relays.iter().cloned(), filter, FETCH_TIMEOUT) + .await + } + .map_err(|e| format!("fetch failed: {e}"))?; + Ok(events.into_iter().collect()) +} + +/// Build a UI `Message` from an opened chat rumor. `None` for a malformed +/// `ms` tag (dropped, never interpreted). +fn message_from_opened(opened: &Opened, my_pk: &PublicKey) -> Option { + let at = opened.timestamp_ms()?; + let replied_to = opened + .rumor + .tags + .iter() + .find(|t| t.kind() == TagKind::q()) + .and_then(|t| t.content()) + .unwrap_or("") + .to_string(); + let emoji_tags: Vec = opened + .rumor + .tags + .iter() + .filter(|t| t.kind() == TagKind::Custom("emoji".into())) + .filter_map(|t| { + let s = t.as_slice(); + Some(crate::types::EmojiTag { shortcode: s.get(1)?.clone(), url: s.get(2)?.clone() }) + }) + .collect(); + Some(Message { + id: opened.rumor.id?.to_hex(), + content: opened.rumor.content.clone(), + replied_to, + at, + mine: opened.author == *my_pk, + npub: opened.author.to_bech32().ok(), + wrapper_event_id: Some(opened.wrap_id.to_hex()), + emoji_tags, + ..Default::default() + }) +} + +/// Ingest one opened kind-9 rumor into STATE + DB under its channel chat. +/// Returns the added message, or `None` on dedup/malformed. +async fn ingest_message(opened: &Opened, channel: &ChannelId, my_pk: &PublicKey) -> Option { + let msg = message_from_opened(opened, my_pk)?; + // Inner-id dedup mirrors the DM pipeline: known events load from the DB. + if crate::db::events::event_exists(&msg.id).unwrap_or(false) { + return None; + } + let chat_id = channel.to_hex(); + let added = { + let mut state = crate::state::STATE.lock().await; + state.ensure_community_chat(&chat_id); + state.add_message_to_chat(&chat_id, msg.clone()) + }; + if !added { + return None; + } + let _ = crate::db::events::save_message(&chat_id, &msg); + Some(msg) +} + +/// A dispatched inbound outcome the shell surfaces to the UI. +pub enum Inbound { + NewMessage { chat_id: String, message: Message }, + Typing { chat_id: String, npub: String, until: u64 }, + /// Handled internally (dedup, drop, self-echo, control re-sync spawn). + Handled, +} + +/// Route one wrap by its author. Returns `None` if the author is not a v2 +/// plane (the caller falls through to the DM pipeline). +pub async fn dispatch_event(session: &SessionGuard, client: &Client, event: &Event) -> Option { + let route = ROUTES.read().await.get(&event.pubkey).cloned()?; + // Once the author matched a v2 plane, the event is OURS: every early exit + // below is `Handled` (a drop), never `None` (which would leak a v2 wrap + // into the DM gift-wrap pipeline). + if !session.is_valid() { + return Some(Inbound::Handled); + } + let Some(my_pk) = crate::state::my_public_key() else { + return Some(Inbound::Handled); + }; + + match route { + Route::Chat { community, channel, epoch, group } => { + // Outer dedup before decryption (shared cross-transport ledger). + let outer = event.id.to_bytes(); + if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false) + || crate::db::wrappers::processed_wrapper_exists(&outer) + { + return Some(Inbound::Handled); + } + if db::is_dissolved(&community) { + return Some(Inbound::Handled); + } + let Ok(opened) = stream::open(&group, event) else { + return Some(Inbound::Handled); + }; + if stream::check_binding(&opened.rumor, &channel, epoch).is_err() { + return Some(Inbound::Handled); + } + match opened.rumor.kind.as_u16() { + kind::MESSAGE => match ingest_message(&opened, &channel, &my_pk).await { + Some(msg) => Some(Inbound::NewMessage { chat_id: channel.to_hex(), message: msg }), + None => Some(Inbound::Handled), + }, + kind::TYPING => { + if opened.author == my_pk { + return Some(Inbound::Handled); + } + match opened.author.to_bech32() { + Ok(npub) => Some(Inbound::Typing { + chat_id: channel.to_hex(), + npub, + until: (now_ms() / 1000) + 30, + }), + Err(_) => Some(Inbound::Handled), + } + } + // Reactions/edits/deletes land with the moderation pass. + _ => Some(Inbound::Handled), + } + } + Route::Control { community } | Route::Dissolved { community } => { + // Any control-plane motion triggers a full re-sync (small plane, + // convergence-driving, must stay complete). Spawned — a re-sync is + // seconds of network I/O and must not stall the notification loop. + let task_session = SessionGuard::capture(); + let client = client.clone(); + tokio::spawn(async move { + if !task_session.is_valid() { + return; + } + let Some(loaded) = db::load_community(&community).ok().flatten() else { return }; + let Ok(refreshed) = sync_community(&client, loaded).await else { return }; + if !task_session.is_valid() { + return; + } + refresh_subscription(&client).await; + crate::traits::emit_event( + "community_refreshed", + &serde_json::json!({ "community_id": refreshed.id.to_hex() }), + ); + }); + Some(Inbound::Handled) + } + Route::Guestbook { community, group } => { + // Observed membership only in this pass: record the author's + // presence motion into the ledger so replays skip early. + let outer = event.id.to_bytes(); + if crate::db::wrappers::processed_wrapper_exists(&outer) { + return Some(Inbound::Handled); + } + if stream::open(&group, event).is_ok() { + let _ = crate::db::wrappers::save_processed_wrapper( + &outer, + event.created_at.as_secs(), + crate::db::wrappers::TRANSPORT_CONCORD2, + ); + } + let _ = community; + Some(Inbound::Handled) + } + } +} + +// ============================================================================ +// Boot & paging +// ============================================================================ + +/// Boot sweep: control fold + latest channel page for every held community, +/// then the realtime subscription. +pub async fn boot_sync(client: &Client) -> Result<(), String> { + let session = SessionGuard::capture(); + let ids = db::list_community_ids()?; + for id in ids { + if !session.is_valid() { + return Err("account changed during boot sync".into()); + } + let Some(community) = db::load_community(&id)? else { continue }; + let community = match sync_community(client, community).await { + Ok(c) => c, + Err(e) => { + crate::log_warn!("[concord2] boot sync of {} failed: {}", id.to_hex(), e); + continue; + } + }; + for chan in community.channels.iter().filter(|c| !c.deleted) { + if !session.is_valid() { + return Err("account changed during boot sync".into()); + } + let _ = sync_channel_page_inner(client, &community, &chan.id, None, true).await; + } + } + refresh_subscription(client).await; + Ok(()) +} + +/// One page of channel history. `before_ms` pages backwards (scroll-up). +/// Returns (new_messages, reached_start, oldest_ms). +pub async fn sync_channel_page( + client: &Client, + channel_hex: &str, + before_ms: Option, +) -> Result<(u32, bool, Option), String> { + let community_hex = db::community_id_for_channel(channel_hex)?.ok_or("unknown v2 channel")?; + let id = CommunityId::from_hex(&community_hex).ok_or("bad community id")?; + let community = db::load_community(&id)?.ok_or("community not found")?; + let channel = ChannelId::from_hex(channel_hex).ok_or("bad channel id")?; + sync_channel_page_inner(client, &community, &channel, before_ms, false).await +} + +async fn sync_channel_page_inner( + client: &Client, + community: &Community, + channel: &ChannelId, + before_ms: Option, + emit_new: bool, +) -> Result<(u32, bool, Option), String> { + let session = SessionGuard::capture(); + let group = community.channel_key(channel).ok_or("channel key not held")?; + let epoch = community.channel_epoch(channel).ok_or("channel not found")?; + let my_pk = crate::state::my_public_key().ok_or("public key not set")?; + + let mut filter = Filter::new() + .kind(Kind::Custom(kind::WRAP)) + .author(group.public_key()) + .limit(PAGE_LIMIT); + if let Some(before) = before_ms { + // Wrap created_at is untweaked seconds (CORD-01), so second-floor works. + filter = filter.until(Timestamp::from_secs(before / 1000)); + } + let events = fetch(client, &community.relays, filter).await?; + if !session.is_valid() { + return Err("account changed during channel sync".into()); + } + + let fetched = events.len(); + let mut new_messages = 0u32; + let mut oldest: Option = None; + for event in &events { + oldest = Some(oldest.map_or(event.created_at.as_secs() * 1000, |o: u64| { + o.min(event.created_at.as_secs() * 1000) + })); + let outer = event.id.to_bytes(); + if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false) + || crate::db::wrappers::processed_wrapper_exists(&outer) + { + continue; + } + let Ok(opened) = stream::open(&group, event) else { continue }; + if stream::check_binding(&opened.rumor, channel, epoch).is_err() { + continue; + } + if opened.rumor.kind.as_u16() != kind::MESSAGE { + continue; + } + if !session.is_valid() { + return Err("account changed during channel sync".into()); + } + if let Some(msg) = ingest_message(&opened, channel, &my_pk).await { + new_messages += 1; + if emit_new { + crate::traits::emit_event( + "message_new", + &serde_json::json!({ "message": &msg, "chat_id": channel.to_hex() }), + ); + } + } + } + Ok((new_messages, fetched < PAGE_LIMIT, oldest)) +} + +// ============================================================================ +// Lifecycle +// ============================================================================ + +/// Leave: publish a self-signed Guestbook Leave, then tear down locally. +pub async fn leave_community(client: &Client, community_hex: &str) -> Result<(), String> { + let session = SessionGuard::capture(); + let id = CommunityId::from_hex(community_hex).ok_or("bad community id")?; + let community = db::load_community(&id)?.ok_or("community not found")?; + let my_pk = crate::state::my_public_key().ok_or("public key not set")?; + + let rumor = super::guestbook::build_join_leave(my_pk, false, now_ms(), None); + let guestbook = community.guestbook_key(); + if let Ok(wrap) = seal_and_wrap(client, &guestbook, &rumor, SealForm::Encrypted, kind::WRAP).await { + let _ = publish(client, &community.relays, &wrap).await; + } + + if !session.is_valid() { + return Err("account changed during leave".into()); + } + teardown_local(&community).await; + refresh_subscription(client).await; + Ok(()) +} + +/// Dissolve (owner only): publish the tombstone at the dissolution +/// coordinate, mark local state read-only. +pub async fn dissolve_community(client: &Client, community_hex: &str) -> Result<(), String> { + let session = SessionGuard::capture(); + let id = CommunityId::from_hex(community_hex).ok_or("bad community id")?; + let community = db::load_community(&id)?.ok_or("community not found")?; + let my_pk = crate::state::my_public_key().ok_or("public key not set")?; + if my_pk != community.owner { + return Err("only the owner can dissolve a community".into()); + } + + let rumor = super::edition::build_dissolved_rumor(my_pk, now_ms() / 1000); + let dissolved = community.dissolved_key(); + let wrap = seal_and_wrap(client, &dissolved, &rumor, SealForm::Plaintext, kind::WRAP).await?; + publish(client, &community.relays, &wrap).await?; + + if !session.is_valid() { + return Err("account changed during dissolution".into()); + } + db::set_dissolved(&id)?; + sync_chats(&community).await; + Ok(()) +} + +/// Publish a typing indicator (ephemeral wrap, nothing stored anywhere). +pub async fn send_typing(client: &Client, channel_hex: &str) -> Result<(), String> { + let community_hex = db::community_id_for_channel(channel_hex)?.ok_or("unknown v2 channel")?; + let id = CommunityId::from_hex(&community_hex).ok_or("bad community id")?; + let community = db::load_community(&id)?.ok_or("community not found")?; + let channel = ChannelId::from_hex(channel_hex).ok_or("bad channel id")?; + let epoch = community.channel_epoch(&channel).ok_or("channel not found")?; + let my_pk = crate::state::my_public_key().ok_or("public key not set")?; + let rumor = community::build_typing(my_pk, &channel, epoch, now_ms()); + publish_chat_rumor(client, &community, &channel, &rumor, true).await?; + Ok(()) +} + +/// Publish the next Community-metadata edition (rename and/or description). +/// Owner-only in this pass — delegated editors land with the roles/vac work. +pub async fn update_metadata( + client: &Client, + community_hex: &str, + name: Option<&str>, + description: Option<&str>, +) -> Result<(), String> { + let session = SessionGuard::capture(); + let id = CommunityId::from_hex(community_hex).ok_or("bad community id")?; + let mut community = db::load_community(&id)?.ok_or("community not found")?; + let my_pk = crate::state::my_public_key().ok_or("public key not set")?; + if my_pk != community.owner { + return Err("only the owner can edit metadata (for now)".into()); + } + if db::is_dissolved(&id) { + return Err("this community has been dissolved".into()); + } + + // Fold the persisted chain to find the current head — the next edition + // chains on its hash and round-trips fields it doesn't touch. + let persisted = db::load_edition_seals(&id)?; + let fold = fold_from_seal_jsons(&community, &persisted); + let head = fold.head(&id.0).ok_or("metadata head not folded yet")?.clone(); + let mut metadata: super::control::CommunityMetadata = + serde_json::from_str(&head.content).map_err(|e| format!("metadata head: {e}"))?; + if let Some(n) = name { + metadata.name = n.to_string(); + } + if let Some(d) = description { + // Empty string clears the description. + metadata.description = if d.is_empty() { None } else { Some(d.to_string()) }; + } + metadata.validate()?; + let content = serde_json::to_string(&metadata).map_err(|e| e.to_string())?; + + let rumor = super::edition::build_edition_rumor( + my_pk, + vsk::COMMUNITY_METADATA, + &id.0, + head.version + 1, + Some(&head.hash()), + &content, + now_ms() / 1000, + None, + ); + let control = community.control_key(); + let wrap = seal_and_wrap(client, &control, &rumor, SealForm::Plaintext, kind::WRAP).await?; + publish(client, &community.relays, &wrap).await?; + + if !session.is_valid() { + return Err("account changed during metadata update".into()); + } + if let Some(json) = decrypt_seal_json(&control, &wrap) { + let _ = db::save_edition_seal(&id, &id.0, head.version + 1, &json); + } + community.name = metadata.name.clone(); + community.description = metadata.description.clone(); + db::save_community(&community)?; + sync_chats(&community).await; + Ok(()) +} diff --git a/crates/vector-core/src/concord/v2/stream.rs b/crates/vector-core/src/concord/v2/stream.rs new file mode 100644 index 00000000..00064513 --- /dev/null +++ b/crates/vector-core/src/concord/v2/stream.rs @@ -0,0 +1,492 @@ +//! CORD-01: Private Streams — the wrap/seal/rumor envelope. +//! +//! A stream event is a kind 1059 wrap that *reverses* NIP-59: fixed author +//! (the derived stream key), ephemeral `p` tag, `created_at` untweaked, and +//! the wrap encrypted under the stream's NIP-44 *self*-conversation key, never +//! the `p`-tagged key. The seal inside is signed by the author's real key and +//! declares its content form by kind: 20013 encrypted (double-wrapped, can +//! never be lifted out as a standalone public event), 20014 plaintext (the +//! Control Plane only — a byte-verbatim rumor whose signature survives a +//! compaction re-wrap). + +use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes}; +use nostr_sdk::prelude::*; + +use super::derive::GroupKey; +use super::{combine_ms, kind, split_ms, ChannelId, Epoch, NIP44_MAX_PLAINTEXT}; + +/// Rumor tag names. +pub const TAG_CHANNEL: &str = "channel"; +pub const TAG_EPOCH: &str = "epoch"; +pub const TAG_MS: &str = "ms"; + +/// Which seal form a plane uses (CORD-02 §5). Fixed per protocol layer, never +/// a per-message choice; the seal's kind declares it so a reader never sniffs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SealForm { + /// Kind 20013 — the rumor NIP-44-encrypted inside the encrypted wrap. + /// Chat, Guestbook, and rekey planes MUST use this. + Encrypted, + /// Kind 20014 — the rumor's serialized JSON byte-verbatim. Control Plane + /// MUST use this (signatures must survive compaction re-encryption). + Plaintext, +} + +impl SealForm { + pub fn kind(&self) -> u16 { + match self { + SealForm::Encrypted => kind::SEAL_ENCRYPTED, + SealForm::Plaintext => kind::SEAL_PLAINTEXT, + } + } +} + +#[derive(Debug)] +pub enum StreamError { + /// A NIP-44 layer failed to encrypt/decrypt. + Crypto(String), + /// A layer exceeded the NIP-44 plaintext cap. + TooLarge(usize), + /// Signing failed. + Sign(String), + /// Malformed or unparseable layer. + Malformed(String), + /// The seal's signature does not verify. + BadSignature, + /// The seal's kind is not a seal kind. + NotASeal(u16), + /// The rumor's author differs from the seal's verified signer. + AuthorMismatch, + /// The rumor's channel/epoch binding doesn't match the opening key. + BindingMismatch, +} + +impl std::fmt::Display for StreamError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StreamError::Crypto(e) => write!(f, "stream crypto: {e}"), + StreamError::TooLarge(n) => write!(f, "layer exceeds NIP-44 plaintext cap: {n} bytes"), + StreamError::Sign(e) => write!(f, "stream sign: {e}"), + StreamError::Malformed(e) => write!(f, "malformed stream layer: {e}"), + StreamError::BadSignature => write!(f, "seal signature invalid"), + StreamError::NotASeal(k) => write!(f, "kind {k} is not a seal"), + StreamError::AuthorMismatch => write!(f, "rumor author differs from seal signer"), + StreamError::BindingMismatch => write!(f, "channel/epoch binding mismatch"), + } + } +} + +impl std::error::Error for StreamError {} + +/// A decrypted, signature-verified stream event. +#[derive(Debug, Clone)] +pub struct Opened { + /// The inner rumor. Its id is computed (stable across re-wraps). + pub rumor: UnsignedEvent, + /// The verified real author (the seal's signer; the rumor is checked to + /// agree — a keyholder can't attribute a rumor to someone else). + pub author: PublicKey, + /// Which seal form carried it. + pub seal_form: SealForm, + /// The outer wrap's event id — the transport dedup key. + pub wrap_id: EventId, + /// The seal's exact `content` bytes — for a plaintext seal this is the + /// byte-verbatim rumor JSON a compaction must carry forward unmodified. + pub seal_content: String, +} + +impl Opened { + /// The rumor's true millisecond time: `created_at * 1000 + ms`, `None` if + /// the `ms` tag is malformed (the caller drops the entry, CORD-02 §5). + pub fn timestamp_ms(&self) -> Option { + let remainder = match tag_value(&self.rumor.tags, TAG_MS) { + Some(v) => v.parse::().ok()?, + None => 0, + }; + combine_ms(self.rumor.created_at.as_secs(), remainder) + } +} + +fn cap(plaintext: &str) -> Result<&str, StreamError> { + if plaintext.len() > NIP44_MAX_PLAINTEXT { + return Err(StreamError::TooLarge(plaintext.len())); + } + Ok(plaintext) +} + +fn tag_value<'a>(tags: &'a Tags, name: &str) -> Option<&'a str> { + tags.iter() + .find(|t| t.kind() == TagKind::Custom(name.into())) + .and_then(|t| t.content()) +} + +/// Build an unsigned rumor carrying the CORD-03 §3 binding (`channel`, +/// `epoch`) plus the `ms` remainder, with `extra_tags` appended verbatim. +pub fn build_rumor( + author: PublicKey, + rumor_kind: u16, + content: &str, + channel: &ChannelId, + epoch: Epoch, + unix_ms: u64, + extra_tags: Vec, +) -> UnsignedEvent { + let (secs, remainder) = split_ms(unix_ms); + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel.to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), [remainder.to_string()]), + ]; + tags.extend(extra_tags); + let mut rumor = EventBuilder::new(Kind::Custom(rumor_kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(secs)) + .build(author); + rumor.ensure_id(); + rumor +} + +/// Build an unsigned rumor with no channel binding (Guestbook and rekey +/// rumors bind by plane address alone; they still carry `ms`). +pub fn build_plane_rumor( + author: PublicKey, + rumor_kind: u16, + content: &str, + unix_ms: u64, + extra_tags: Vec, +) -> UnsignedEvent { + let (secs, remainder) = split_ms(unix_ms); + let mut tags = vec![Tag::custom(TagKind::Custom(TAG_MS.into()), [remainder.to_string()])]; + tags.extend(extra_tags); + let mut rumor = EventBuilder::new(Kind::Custom(rumor_kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(secs)) + .build(author); + rumor.ensure_id(); + rumor +} + +/// Build the unsigned seal around a rumor — for callers signing through a +/// `NostrSigner` (local keys or a NIP-46 bunker) before [`wrap_signed_seal`]. +pub fn build_seal( + group: &GroupKey, + author: PublicKey, + rumor: &UnsignedEvent, + form: SealForm, +) -> Result { + let mut rumor = rumor.clone(); + rumor.ensure_id(); + let rumor_json = rumor.as_json(); + let seal_content = match form { + // Byte-verbatim: the seal's content IS the rumor's serialized JSON. + SealForm::Plaintext => cap(&rumor_json)?.to_string(), + SealForm::Encrypted => encrypt_to_bytes(group.conversation_key(), cap(&rumor_json)?.as_bytes()) + .map(|ct| base64_encode(&ct)) + .map_err(|e| StreamError::Crypto(e.to_string()))?, + }; + cap(&seal_content)?; + Ok(EventBuilder::new(Kind::Custom(form.kind()), seal_content) + .custom_created_at(rumor.created_at) + .build(author)) +} + +/// Seal a rumor with the author's local keys and wrap it at the plane's +/// address. `ephemeral_p` is the wrap's throwaway `p` key — the caller may +/// retain its secret to NIP-09-delete the wrap later (CORD-01 §Deletions). +pub fn wrap_rumor( + group: &GroupKey, + author_keys: &Keys, + rumor: &UnsignedEvent, + form: SealForm, + ephemeral_p: PublicKey, +) -> Result { + let seal = build_seal(group, author_keys.public_key(), rumor, form)? + .sign_with_keys(author_keys) + .map_err(|e| StreamError::Sign(e.to_string()))?; + wrap_signed_seal(group, &seal, kind::WRAP, ephemeral_p) +} + +/// Wrap an already-signed seal at the plane's address. This is the compaction +/// primitive (CORD-06 §3): a plaintext seal read from one epoch re-wraps under +/// another with its author signature intact, byte-verbatim. +pub fn wrap_signed_seal( + group: &GroupKey, + seal: &Event, + wrap_kind: u16, + ephemeral_p: PublicKey, +) -> Result { + let seal_json = seal.as_json(); + let content = encrypt_to_bytes(group.conversation_key(), cap(&seal_json)?.as_bytes()) + .map(|ct| base64_encode(&ct)) + .map_err(|e| StreamError::Crypto(e.to_string()))?; + EventBuilder::new(Kind::Custom(wrap_kind), cap(&content)?) + .tags([Tag::public_key(ephemeral_p)]) + .custom_created_at(seal.created_at) + .build(group.public_key()) + .sign_with_keys(group.keys()) + .map_err(|e| StreamError::Sign(e.to_string())) +} + +/// Open a stream event: decrypt the wrap under the plane's conversation key, +/// verify the seal's signature, extract the rumor, and check the rumor's +/// author agrees with the seal's signer. +pub fn open(group: &GroupKey, wrap: &Event) -> Result { + let seal_json = decrypt_to_bytes(group.conversation_key(), &base64_decode(&wrap.content)?) + .map_err(|e| StreamError::Crypto(e.to_string()))?; + let seal = Event::from_json(&seal_json).map_err(|e| StreamError::Malformed(e.to_string()))?; + if seal.verify().is_err() { + return Err(StreamError::BadSignature); + } + + let seal_kind = seal.kind.as_u16(); + let (form, rumor_json) = match seal_kind { + kind::SEAL_PLAINTEXT => (SealForm::Plaintext, seal.content.clone().into_bytes()), + kind::SEAL_ENCRYPTED => { + let pt = decrypt_to_bytes(group.conversation_key(), &base64_decode(&seal.content)?) + .map_err(|e| StreamError::Crypto(e.to_string()))?; + (SealForm::Encrypted, pt) + } + other => return Err(StreamError::NotASeal(other)), + }; + + let mut rumor = + UnsignedEvent::from_json(&rumor_json).map_err(|e| StreamError::Malformed(e.to_string()))?; + rumor.ensure_id(); + + // Authorship is the seal's verified signature; a rumor claiming another + // pubkey is a splice attempt by a keyholder. + if rumor.pubkey != seal.pubkey { + return Err(StreamError::AuthorMismatch); + } + + Ok(Opened { + author: seal.pubkey, + seal_form: form, + wrap_id: wrap.id, + seal_content: seal.content.clone(), + rumor, + }) +} + +/// CORD-03 §3 binding check: the rumor's committed `channel`/`epoch` tags must +/// strict-equal the Channel and epoch whose key decrypted the wrap, or a +/// keyholder could re-wrap a message into a context its author never chose. +pub fn check_binding(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result<(), StreamError> { + match tag_value(&rumor.tags, TAG_CHANNEL) { + Some(c) if c == channel.to_hex() => {} + _ => return Err(StreamError::BindingMismatch), + } + match tag_value(&rumor.tags, TAG_EPOCH) { + Some(e) if e == epoch.0.to_string() => {} + _ => return Err(StreamError::BindingMismatch), + } + Ok(()) +} + +fn base64_encode(bytes: &[u8]) -> String { + base64_simd::STANDARD.encode_to_string(bytes) +} + +fn base64_decode(s: &str) -> Result, StreamError> { + base64_simd::STANDARD + .decode_to_vec(s.as_bytes()) + .map_err(|e| StreamError::Malformed(format!("base64: {e}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::concord::v2::derive; + use crate::concord::v2::{CommunityId, CommunityRoot}; + + fn group() -> GroupKey { + derive::control_key(&CommunityRoot([7u8; 32]), &CommunityId([0x11u8; 32]), Epoch(0)) + } + + fn channel_group() -> GroupKey { + derive::public_channel_key(&CommunityRoot([7u8; 32]), &ChannelId([0x42u8; 32]), Epoch(0)) + } + + fn message_rumor(author: &Keys, ms: u64) -> UnsignedEvent { + build_rumor( + author.public_key(), + kind::MESSAGE, + "Hey chat!", + &ChannelId([0x42u8; 32]), + Epoch(0), + ms, + vec![], + ) + } + + #[test] + fn encrypted_seal_roundtrip() { + let author = Keys::generate(); + let g = channel_group(); + let rumor = message_rumor(&author, 1_686_840_217_417); + let wrap = wrap_rumor(&g, &author, &rumor, SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + + assert_eq!(wrap.kind.as_u16(), kind::WRAP); + assert_eq!(wrap.pubkey, g.public_key(), "wrap is authored by the stream key"); + // created_at is never tweaked (CORD-01) — it matches the rumor's seconds. + assert_eq!(wrap.created_at.as_secs(), 1_686_840_217); + + let opened = open(&g, &wrap).unwrap(); + assert_eq!(opened.author, author.public_key()); + assert_eq!(opened.seal_form, SealForm::Encrypted); + assert_eq!(opened.rumor.content, "Hey chat!"); + assert_eq!(opened.timestamp_ms(), Some(1_686_840_217_417)); + check_binding(&opened.rumor, &ChannelId([0x42u8; 32]), Epoch(0)).unwrap(); + } + + #[test] + fn plaintext_seal_roundtrip_is_byte_verbatim() { + let author = Keys::generate(); + let g = group(); + let mut rumor = build_plane_rumor(author.public_key(), kind::CONTROL_EDITION, "{}", 1_700_000_000_123, vec![]); + rumor.ensure_id(); + let wrap = wrap_rumor(&g, &author, &rumor, SealForm::Plaintext, Keys::generate().public_key()).unwrap(); + let opened = open(&g, &wrap).unwrap(); + assert_eq!(opened.seal_form, SealForm::Plaintext); + // The seal's content is the rumor's exact serialized JSON. + assert_eq!(opened.seal_content, rumor.as_json()); + assert_eq!(opened.rumor.id, rumor.id, "rumor id must be stable across the envelope"); + } + + #[test] + fn wrong_key_cannot_open() { + let author = Keys::generate(); + let g = channel_group(); + let wrap = wrap_rumor(&g, &author, &message_rumor(&author, 1), SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + let other = derive::public_channel_key(&CommunityRoot([8u8; 32]), &ChannelId([0x42u8; 32]), Epoch(0)); + assert!(open(&other, &wrap).is_err()); + // The next epoch's key is a different universe too. + let next = derive::public_channel_key(&CommunityRoot([7u8; 32]), &ChannelId([0x42u8; 32]), Epoch(1)); + assert!(open(&next, &wrap).is_err()); + } + + #[test] + fn author_mismatch_is_rejected() { + // A keyholder crafts a seal signed by themselves around a rumor + // claiming someone else's pubkey. + let mallory = Keys::generate(); + let victim = Keys::generate(); + let g = channel_group(); + let rumor = message_rumor(&victim, 1_686_840_217_417); // claims victim + let wrap = wrap_rumor(&g, &mallory, &rumor, SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + assert!(matches!(open(&g, &wrap), Err(StreamError::AuthorMismatch))); + } + + #[test] + fn tampered_seal_signature_is_rejected() { + let author = Keys::generate(); + let g = channel_group(); + let rumor = message_rumor(&author, 5); + // Build a seal whose signature is from a different event. + let fake_seal = EventBuilder::new(Kind::Custom(kind::SEAL_ENCRYPTED), "junk") + .build(author.public_key()); + let signed_other = EventBuilder::new(Kind::Custom(kind::SEAL_ENCRYPTED), "other") + .build(author.public_key()) + .sign_with_keys(&author) + .unwrap(); + // Graft the other event's sig onto this content. + let mut forged = serde_json::to_value(&fake_seal).unwrap(); + forged["sig"] = serde_json::json!(signed_other.sig.to_string()); + forged["id"] = serde_json::json!(signed_other.id.to_string()); + forged["pubkey"] = serde_json::json!(author.public_key().to_string()); + forged["created_at"] = serde_json::json!(1_686_840_217); + forged["kind"] = serde_json::json!(kind::SEAL_ENCRYPTED); + forged["tags"] = serde_json::json!([]); + let ct = encrypt_to_bytes(g.conversation_key(), forged.to_string().as_bytes()).unwrap(); + let wrap = EventBuilder::new(Kind::Custom(kind::WRAP), base64_encode(&ct)) + .tags([Tag::public_key(Keys::generate().public_key())]) + .build(g.public_key()) + .sign_with_keys(g.keys()) + .unwrap(); + assert!(matches!(open(&g, &wrap), Err(StreamError::BadSignature))); + let _ = rumor; + } + + #[test] + fn binding_mismatch_detects_splice() { + let author = Keys::generate(); + let g = channel_group(); + let wrap = wrap_rumor(&g, &author, &message_rumor(&author, 9), SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + let opened = open(&g, &wrap).unwrap(); + // Replayed against a different channel or epoch: strict-equal fails. + assert!(check_binding(&opened.rumor, &ChannelId([0x43u8; 32]), Epoch(0)).is_err()); + assert!(check_binding(&opened.rumor, &ChannelId([0x42u8; 32]), Epoch(1)).is_err()); + } + + #[test] + fn compaction_rewrap_preserves_signature_and_rumor_id() { + // Read a plaintext-sealed edition from epoch 0, re-wrap it under + // epoch 1's control key: same seal bytes, same author sig, same rumor. + let author = Keys::generate(); + let root = CommunityRoot([7u8; 32]); + let cid = CommunityId([0x11u8; 32]); + let g0 = derive::control_key(&root, &cid, Epoch(0)); + let g1 = derive::control_key(&CommunityRoot([9u8; 32]), &cid, Epoch(1)); + + let rumor = build_plane_rumor(author.public_key(), kind::CONTROL_EDITION, "{\"name\":\"x\"}", 1_700_000_000_000, vec![]); + let wrap0 = wrap_rumor(&g0, &author, &rumor, SealForm::Plaintext, Keys::generate().public_key()).unwrap(); + let opened0 = open(&g0, &wrap0).unwrap(); + + // The compactor holds the decrypted seal: reconstruct and re-wrap verbatim. + let seal_json = decrypt_to_bytes(g0.conversation_key(), &base64_decode(&wrap0.content).unwrap()).unwrap(); + let seal = Event::from_json(&seal_json).unwrap(); + let wrap1 = wrap_signed_seal(&g1, &seal, kind::WRAP, Keys::generate().public_key()).unwrap(); + + let opened1 = open(&g1, &wrap1).unwrap(); + assert_eq!(opened1.author, author.public_key()); + assert_eq!(opened1.rumor.id, opened0.rumor.id, "byte-verbatim re-wrap must preserve the rumor hash"); + assert_eq!(opened1.seal_content, opened0.seal_content); + } + + #[test] + fn ephemeral_wrap_uses_the_ephemeral_kind() { + let author = Keys::generate(); + let g = channel_group(); + let rumor = build_rumor(author.public_key(), kind::TYPING, "", &ChannelId([0x42u8; 32]), Epoch(0), 45, vec![]); + let seal_ct = encrypt_to_bytes(g.conversation_key(), rumor.as_json().as_bytes()).unwrap(); + let seal = EventBuilder::new(Kind::Custom(kind::SEAL_ENCRYPTED), base64_encode(&seal_ct)) + .custom_created_at(rumor.created_at) + .build(author.public_key()) + .sign_with_keys(&author) + .unwrap(); + let wrap = wrap_signed_seal(&g, &seal, kind::WRAP_EPHEMERAL, Keys::generate().public_key()).unwrap(); + assert_eq!(wrap.kind.as_u16(), kind::WRAP_EPHEMERAL); + let opened = open(&g, &wrap).unwrap(); + assert_eq!(opened.rumor.kind.as_u16(), kind::TYPING); + } + + #[test] + fn oversize_layer_is_refused_at_publish() { + let author = Keys::generate(); + let g = channel_group(); + let big = "x".repeat(NIP44_MAX_PLAINTEXT + 1); + let rumor = build_rumor(author.public_key(), kind::MESSAGE, &big, &ChannelId([0x42u8; 32]), Epoch(0), 1, vec![]); + assert!(matches!( + wrap_rumor(&g, &author, &rumor, SealForm::Encrypted, Keys::generate().public_key()), + Err(StreamError::TooLarge(_)) + )); + } + + #[test] + fn malformed_ms_tag_yields_no_timestamp() { + let author = Keys::generate(); + let g = channel_group(); + // A hostile ms tag (1000 would smuggle a future second). + let rumor = EventBuilder::new(Kind::Custom(kind::MESSAGE), "x") + .tags([ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [ChannelId([0x42u8; 32]).to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), ["1000".to_string()]), + ]) + .custom_created_at(Timestamp::from_secs(1_686_840_217)) + .build(author.public_key()); + let wrap = wrap_rumor(&g, &author, &rumor, SealForm::Encrypted, Keys::generate().public_key()).unwrap(); + let opened = open(&g, &wrap).unwrap(); + assert_eq!(opened.timestamp_ms(), None, "ms=1000 is malformed: dropped, never interpreted"); + } +} diff --git a/crates/vector-core/src/db/mod.rs b/crates/vector-core/src/db/mod.rs index 7993ea2f..80ded9d0 100644 --- a/crates/vector-core/src/db/mod.rs +++ b/crates/vector-core/src/db/mod.rs @@ -23,7 +23,8 @@ pub mod events; pub mod chats; pub mod wrappers; pub mod nip17_keys; -pub mod community; +// Concord v1 persistence lives with its protocol; aliased for the historical `db::community` path. +pub use crate::concord::v1::db as community; pub use settings::{ get_sql_setting, set_sql_setting, get_pkey, set_pkey, get_seed, set_seed, remove_setting, diff --git a/crates/vector-core/src/db/schema.rs b/crates/vector-core/src/db/schema.rs index 1fff3f79..9f82184a 100644 --- a/crates/vector-core/src/db/schema.rs +++ b/crates/vector-core/src/db/schema.rs @@ -729,5 +729,48 @@ pub fn run_migrations(conn: &mut rusqlite::Connection) -> Result<(), String> { Ok(()) })?; + // Migration 63: Concord v2 (CORD protocol) local state. Scoped under `concord2_*` + // so the two protocol generations never share rows. Per-account like everything + // else here; ids are hex; secrets (root, private-channel keys) are at-rest + // encrypted via the `enc_*` helpers in `concord/v2/db.rs`. Editions store the + // signed plaintext-seal JSON verbatim: enough to rebuild the Control fold at boot + // AND to re-wrap heads signature-intact in a future compaction (CORD-06). + run_atomic_migration(conn, 63, "Create Concord v2 tables", |tx| { + tx.execute_batch( + "CREATE TABLE IF NOT EXISTS concord2_communities ( + community_id TEXT PRIMARY KEY, + owner TEXT NOT NULL, + owner_salt TEXT NOT NULL, + root BLOB NOT NULL, + root_epoch INTEGER NOT NULL DEFAULT 0, + name TEXT NOT NULL, + description TEXT, + relays TEXT NOT NULL, + created_at INTEGER NOT NULL, + dissolved INTEGER NOT NULL DEFAULT 0 + ); + CREATE TABLE IF NOT EXISTS concord2_channels ( + channel_id TEXT PRIMARY KEY, + community_id TEXT NOT NULL, + name TEXT NOT NULL, + private INTEGER NOT NULL DEFAULT 0, + deleted INTEGER NOT NULL DEFAULT 0, + channel_key BLOB, + epoch INTEGER NOT NULL DEFAULT 0 + ); + CREATE INDEX IF NOT EXISTS idx_concord2_channels_community + ON concord2_channels(community_id); + CREATE TABLE IF NOT EXISTS concord2_editions ( + community_id TEXT NOT NULL, + eid TEXT NOT NULL, + version INTEGER NOT NULL, + seal_json TEXT NOT NULL, + PRIMARY KEY (community_id, eid, version) + );", + ) + .map_err(|e| format!("Failed to create concord2 tables: {}", e))?; + Ok(()) + })?; + Ok(()) } diff --git a/crates/vector-core/src/db/wrappers.rs b/crates/vector-core/src/db/wrappers.rs index 58c8c0f0..7e7fc9e8 100644 --- a/crates/vector-core/src/db/wrappers.rs +++ b/crates/vector-core/src/db/wrappers.rs @@ -6,6 +6,7 @@ use nostr_sdk::prelude::{EventId, Timestamp}; /// (cheaper than a per-row string, and the ledger can grow large). Never renumber an existing value. pub const TRANSPORT_NIP17: i64 = 0; pub const TRANSPORT_CONCORD: i64 = 1; +pub const TRANSPORT_CONCORD2: i64 = 2; /// Persist an outer-event id for cross-session dedup (INSERT OR IGNORE), tagged by `transport` /// so the ledger is shared across transports while negentropy stays NIP-17-scoped. diff --git a/crates/vector-core/src/lib.rs b/crates/vector-core/src/lib.rs index 270c28f3..4b919d07 100644 --- a/crates/vector-core/src/lib.rs +++ b/crates/vector-core/src/lib.rs @@ -166,8 +166,10 @@ pub mod deletion; // === SIMD Operations === pub mod simd; -// === Community protocol (GROUP_PROTOCOL.md) === -pub mod community; +// === Concord (communities) protocol, versioned === +pub mod concord; +// Back-compat alias: Concord v1 grew up as `community`; every consumer uses this path. +pub use concord::v1 as community; // === Event Handler === pub mod event_handler; diff --git a/src-tauri/src/account_manager.rs b/src-tauri/src/account_manager.rs index 187e9d34..bed77f8b 100644 --- a/src-tauri/src/account_manager.rs +++ b/src-tauri/src/account_manager.rs @@ -653,6 +653,8 @@ pub async fn reset_session() { // pseudonyms, so they MUST be cleared on swap or account A's keys/routes leak into B's session. // The state now lives in vector-core; the new account rebuilds it via refresh_community_subscription(). vector_core::community::realtime::clear().await; + // Concord v2 routes embed derived plane KEYS — same swap hygiene. + vector_core::concord::v2::service::clear().await; // Community per-channel sync state (account-scoped) — drop so account B doesn't inherit // A's history-start flags, paging cursors, or any stale in-flight claims. (Access-time // generation checks self-reset too; this is the explicit teardown.) diff --git a/src-tauri/src/commands/community.rs b/src-tauri/src/commands/community.rs index 28130a22..5c03ab0b 100644 --- a/src-tauri/src/commands/community.rs +++ b/src-tauri/src/commands/community.rs @@ -120,6 +120,7 @@ fn summarize(community: &vector_core::community::Community) -> CommunitySummary } /// List every Community the local user holds (owned or joined), for the chat list. +/// Combines both protocol generations: v1 (legacy) and v2 (CORD). #[tauri::command] pub async fn list_communities() -> Result, String> { let ids = vector_core::db::community::list_community_ids()?; @@ -129,9 +130,56 @@ pub async fn list_communities() -> Result, String> { out.push(summarize(&c)); } } + for c in vector_core::concord::v2::db::list_communities()? { + out.push(summarize_v2(&c)); + } Ok(out) } +/// The `CommunitySummary` for a Concord v2 community — same frontend shape, +/// with v2's self-certifying owner in place of v1's attestation. +fn summarize_v2(community: &vector_core::concord::v2::community::Community) -> CommunitySummary { + let is_owner = vector_core::my_public_key() + .map(|pk| pk == community.owner) + .unwrap_or(false); + CommunitySummary { + community_id: community.id.to_hex(), + name: community.name.clone(), + description: community.description.clone(), + is_owner, + has_icon: false, + channels: community + .channels + .iter() + .filter(|c| !c.deleted) + .map(|c| ChannelSummary { channel_id: c.id.to_hex(), name: c.name.clone() }) + .collect(), + owner_npub: community.owner.to_bech32().ok(), + dissolved: vector_core::concord::v2::db::is_dissolved(&community.id), + preloaded: false, + } +} + +/// Whether a community id belongs to the v2 (CORD) universe. +fn is_v2_community(community_id: &str) -> bool { + vector_core::concord::v2::CommunityId::from_hex(community_id) + .map(|id| { + vector_core::concord::v2::db::load_community(&id) + .ok() + .flatten() + .is_some() + }) + .unwrap_or(false) +} + +/// Whether a channel id belongs to a v2 community. +fn is_v2_channel(channel_id: &str) -> bool { + matches!( + vector_core::concord::v2::db::community_id_for_channel(channel_id), + Ok(Some(_)) + ) +} + /// A best-effort member entry: an observed participant (someone who has posted) + their /// most-recent activity. Membership is not authoritative (§ no roster); see community.rs. #[derive(serde::Serialize)] @@ -171,6 +219,19 @@ pub async fn unban_community_member(community_id: String, npub: String) -> Resul /// button on ownership + a type-to-confirm; this re-verifies authority cryptographically. #[tauri::command] pub async fn delete_community(community_id: String) -> Result<(), String> { + if is_v2_community(&community_id) { + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; + let community = vector_core::concord::v2::CommunityId::from_hex(&community_id) + .and_then(|id| vector_core::concord::v2::db::load_community(&id).ok().flatten()) + .ok_or("Community not found")?; + // An already-sealed husk skips the publish; this is just local cleanup. + if !vector_core::concord::v2::db::is_dissolved(&community.id) { + vector_core::concord::v2::service::dissolve_community(&client, &community_id).await?; + } + vector_core::concord::v2::service::teardown_local(&community).await; + vector_core::concord::v2::service::refresh_subscription(&client).await; + return Ok(()); + } let session = vector_core::state::SessionGuard::capture(); let id_bytes = hex_to_id32(&community_id)?; let community = vector_core::db::community::load_community(&CommunityId(id_bytes))? @@ -381,6 +442,11 @@ async fn set_member_banned(community_id: &str, npub: &str, banned: bool) -> Resu /// Fetch one Community's summary (for the overview/settings panel). #[tauri::command] pub async fn get_community(community_id: String) -> Result { + if let Some(id) = vector_core::concord::v2::CommunityId::from_hex(&community_id) { + if let Some(c) = vector_core::concord::v2::db::load_community(&id)? { + return Ok(summarize_v2(&c)); + } + } let id_bytes = hex_to_id32(&community_id)?; let community = vector_core::db::community::load_community(&CommunityId(id_bytes))? .ok_or("Community not found")?; @@ -392,6 +458,10 @@ pub async fn get_community(community_id: String) -> Result Result<(), String> { + if is_v2_community(&community_id) { + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; + return vector_core::concord::v2::service::leave_community(&client, &community_id).await; + } let session = vector_core::state::SessionGuard::capture(); let id_bytes = hex_to_id32(&community_id)?; // Capture the full community first (channel ids for chat-row teardown + a leave announce). @@ -509,16 +579,18 @@ pub(crate) async fn check_self_banned(community_id: &str) -> bool { // Create + send (the core lifecycle) // ============================================================================ -/// Create a new single-channel Community owned by the local user. Defaults the channel -/// to "general" and the relay set to the active trusted relays. Persists + publishes -/// metadata, surfaces the channel locally, and starts the subscription. Returns the -/// `(community_id, channel_id)` hex pair. +/// Create a new Community owned by the local user. Creation is **Concord v2 +/// only** — v1 communities remain readable/usable, but new ones are founded +/// on the CORD protocol. Genesis is the spec's fixed shape (metadata + one +/// public `#general`), so `channel_name` is accepted for API compatibility +/// but the founding channel is always `#general` (CORD-02 §1). #[tauri::command] pub async fn create_community( name: String, channel_name: Option, relays: Option>, ) -> Result { + let _ = channel_name; let relays = match relays { Some(r) if !r.is_empty() => r, _ => vector_core::state::active_trusted_relays() @@ -530,30 +602,16 @@ pub async fn create_community( if relays.is_empty() { return Err("No relays available to host the Community".to_string()); } - let channel_name = channel_name.unwrap_or_else(|| "general".to_string()); - let transport = LiveTransport::with_timeout(Duration::from_secs(12)); + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; let community = - service::create_community(&transport, &name, &channel_name, relays).await?; - - let channel_id = community.channels[0].id.to_hex(); - // Persist the channel chat(s) with display metadata so they load like any DM. - sync_community_chats(&community).await; - // record the join in the cross-device list so our other devices auto-join silently. - vector_core::community::list::add_membership(&community); - // Start receiving on the new channel. - crate::services::subscription_handler::refresh_community_subscription().await; + vector_core::concord::v2::service::create_community(&client, &name, relays).await?; - // Proven owner npub (verified attestation) so the frontend can stamp the crown - // immediately, rather than waiting for the next reload to re-derive it. - let community_id = community.id.to_hex(); - let owner_npub = community - .owner_attestation - .as_ref() - .and_then(|att| vector_core::community::owner::verify_owner_attestation(att, &community_id)) - .and_then(|pk| pk.to_bech32().ok()); - - Ok(CreatedCommunity { community_id, channel_id, owner_npub }) + Ok(CreatedCommunity { + community_id: community.id.to_hex(), + channel_id: community.channels[0].id.to_hex(), + owner_npub: community.owner.to_bech32().ok(), + }) } #[derive(serde::Serialize)] @@ -567,6 +625,12 @@ pub struct CreatedCommunity { /// dropped keystroke ping is harmless. `channel_id` is the channel hex id (the open-chat id the /// frontend already hands `start_typing`). Returns false if it isn't a known Community channel. pub(crate) async fn send_community_typing(channel_id: &str) -> bool { + if is_v2_channel(channel_id) { + let Some(client) = vector_core::state::nostr_client() else { return false }; + return vector_core::concord::v2::service::send_typing(&client, channel_id) + .await + .is_ok(); + } let session = vector_core::state::SessionGuard::capture(); let Ok(Some(community_id)) = vector_core::db::community::community_id_for_channel(channel_id) else { return false; @@ -593,6 +657,9 @@ pub async fn send_community_message( content: String, replied_to: Option, ) -> Result<(), String> { + if is_v2_channel(&channel_id) { + return send_community_message_v2(channel_id, content, replied_to).await; + } use vector_core::sending::SendCallback; use vector_core::Message; let reply = replied_to.filter(|r| !r.is_empty()); @@ -710,6 +777,126 @@ pub async fn send_community_message( } } +/// The Concord v2 send path: same pending → sent/failed lifecycle, built on +/// the CORD stream envelope (kind 9 rumor, encrypted seal, authors-addressed +/// wrap). The rumor id is known before the optimistic insert, so the relay +/// echo dedups by inner id exactly like v1 and DMs. +async fn send_community_message_v2( + channel_id: String, + content: String, + replied_to: Option, +) -> Result<(), String> { + use vector_core::concord::v2; + use vector_core::sending::SendCallback; + use vector_core::Message; + + let session = vector_core::state::SessionGuard::capture(); + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; + let author_pk = vector_core::my_public_key().ok_or("Public key not set")?; + let my_npub = author_pk.to_bech32().ok(); + let reply = replied_to.filter(|r| !r.is_empty()); + + let community_hex = v2::db::community_id_for_channel(&channel_id)? + .ok_or("Unknown Community channel")?; + let community_id = v2::CommunityId::from_hex(&community_hex).ok_or("bad community id")?; + let community = v2::db::load_community(&community_id)?.ok_or("Community not found")?; + if v2::db::is_dissolved(&community_id) { + return Err("This community has been dissolved".to_string()); + } + let channel = v2::ChannelId::from_hex(&channel_id).ok_or("bad channel id")?; + let epoch = community.channel_epoch(&channel).ok_or("Channel not found")?; + + let ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64; + + // NIP-30 custom emoji + the CORD reply `q` tag ride inside the signed rumor. + let emoji_tags = vector_core::emoji_packs::resolve_outbound_emoji_tags(&content); + let mut extra_tags: Vec = emoji_tags + .iter() + .map(|et| { + nostr_sdk::Tag::custom( + nostr_sdk::TagKind::Custom("emoji".into()), + [et.shortcode.clone(), et.url.clone()], + ) + }) + .collect(); + if let Some(parent) = reply.as_deref() { + extra_tags.push(nostr_sdk::Tag::custom( + nostr_sdk::TagKind::q(), + [parent.to_string()], + )); + } + let rumor = v2::stream::build_rumor( + author_pk, + v2::kind::MESSAGE, + &content, + &channel, + epoch, + ms, + extra_tags, + ); + let message_id = rumor.id.ok_or("rumor has no id")?.to_hex(); + + // 1. Optimistic message — renders instantly, keyed by its real id. + let callback = crate::message::sending::TauriSendCallback; + let pending_msg = Message { + id: message_id.clone(), + content: content.clone(), + at: ms, + pending: true, + mine: true, + npub: my_npub, + replied_to: reply.clone().unwrap_or_default(), + emoji_tags: emoji_tags.clone(), + ..Default::default() + }; + { + let mut state = vector_core::state::STATE.lock().await; + state.add_message_to_chat(&channel_id, pending_msg.clone()); + } + callback.on_pending(&channel_id, &pending_msg); + + // 2. Seal (signer-agnostic: local or bunker) + wrap + publish. + let publish_result = if session.is_valid() { + v2::service::publish_chat_rumor(&client, &community, &channel, &rumor, false).await + } else { + Err("account changed during send".to_string()) + }; + + match publish_result { + Ok(_wrap_id) => { + // Mark on own-send only while the session that sent it is live. + if !session.is_valid() { + return Ok(()); + } + let sent = { + let mut state = vector_core::state::STATE.lock().await; + state.update_message(&message_id, |m| m.set_pending(false)) + }; + if let Some((_cid, ref msg)) = sent { + callback.on_sent(&channel_id, &message_id, msg); + callback.on_persist(&channel_id, msg); + } + Ok(()) + } + Err(e) => { + let failed = { + let mut state = vector_core::state::STATE.lock().await; + state.update_message(&message_id, |m| { + m.set_failed(true); + m.set_pending(false); + }) + }; + if let Some((_cid, ref msg)) = failed { + callback.on_failed(&channel_id, &message_id, msg); + } + Err(e) + } + } +} + /// A Community attachment that's been encrypted and previewed locally but NOT yet uploaded. /// The upload is deferred into [`dispatch_community_attachment_message`] so the optimistic /// bubble (with the progress ring + cancel button) shows BEFORE the bytes hit the network — @@ -1188,6 +1375,14 @@ pub struct CommunitySyncResult { /// orderly and waste-free. Ingest dedups on inner id; re-persist is idempotent (INSERT OR REPLACE). #[tauri::command] pub async fn sync_community_channel(channel_id: String, before_ms: Option, reset_cursor: Option) -> Result { + if is_v2_channel(&channel_id) { + let _ = reset_cursor; + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; + let (new_messages, reached_start, oldest_ms) = + vector_core::concord::v2::service::sync_channel_page(&client, &channel_id, before_ms) + .await?; + return Ok(CommunitySyncResult { new_messages, reached_start, oldest_ms }); + } let is_older = before_ms.is_some(); // Anti-stampede: one in-flight fetch per channel per direction. Claimed FIRST so the cursor reset @@ -1884,6 +2079,16 @@ pub async fn sync_communities_boot() -> Result<(), String> { .buffer_unordered(BOOT_SYNC_WINDOW) .collect::>() .await; + + // Concord v2 boot sweep: control fold + latest page per channel, then the + // authors-routed realtime subscription. + if session.is_valid() { + if let Some(client) = vector_core::state::nostr_client() { + if let Err(e) = vector_core::concord::v2::service::boot_sync(&client).await { + vector_core::log_warn!("[concord2] boot sync failed: {}", e); + } + } + } Ok(()) } @@ -2512,6 +2717,16 @@ pub async fn update_community_metadata( name: Option, description: Option, ) -> Result<(), String> { + if is_v2_community(&community_id) { + let client = vector_core::state::nostr_client().ok_or("Not logged in")?; + return vector_core::concord::v2::service::update_metadata( + &client, + &community_id, + name.as_deref(), + description.as_deref(), + ) + .await; + } let session = vector_core::state::SessionGuard::capture(); let id_bytes = hex_to_id32(&community_id)?; let mut community = vector_core::db::community::load_community(&CommunityId(id_bytes))? diff --git a/src-tauri/src/commands/sync.rs b/src-tauri/src/commands/sync.rs index 235c8d74..d9c394b7 100644 --- a/src-tauri/src/commands/sync.rs +++ b/src-tauri/src/commands/sync.rs @@ -347,13 +347,17 @@ pub async fn fetch_messages( // teardown from older builds) renders as an un-deletable ghost — every community // command starts at load_community and errors "not found". Drop STATE + DB rows. { - let held: std::collections::HashSet = + let mut held: std::collections::HashSet = vector_core::db::community::list_community_ids() .unwrap_or_default() .into_iter() .filter_map(|id| vector_core::db::community::load_community(&id).ok().flatten()) .flat_map(|c| c.channels.iter().map(|ch| ch.id.to_hex()).collect::>()) .collect(); + // Concord v2 channels are Community chat rows too — never sweep them. + for c in vector_core::concord::v2::db::list_communities().unwrap_or_default() { + held.extend(c.channels.iter().map(|ch| ch.id.to_hex())); + } let orphans: Vec = state .chats .iter() diff --git a/src-tauri/src/services/subscription_handler.rs b/src-tauri/src/services/subscription_handler.rs index d5f3bb05..8ea21ecb 100644 --- a/src-tauri/src/services/subscription_handler.rs +++ b/src-tauri/src/services/subscription_handler.rs @@ -143,6 +143,52 @@ async fn handle_community_event( vector_core::community::realtime::dispatch_event(session, event, handler).await; } +/// Route a Concord v2 wrap (kind 1059/21059 authored by a known plane address) +/// and surface the outcome to the UI. Returns false when the author is not a +/// v2 plane — the caller falls through to the DM gift-wrap pipeline. +pub(crate) async fn handle_concord2_event( + session: &vector_core::state::SessionGuard, + event: &Event, +) -> bool { + use vector_core::concord::v2::service::{dispatch_event, Inbound}; + let Some(client) = vector_core::state::nostr_client() else { return false }; + let Some(outcome) = dispatch_event(session, &client, event).await else { + return false; + }; + match outcome { + Inbound::NewMessage { chat_id, message } => { + vector_core::emit_event( + "message_new", + &serde_json::json!({ "message": &message, "chat_id": &chat_id }), + ); + let now_ms = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + // Ping only for genuinely-live messages (parity with v1): a + // back-synced page pushes old events through here too. + if message.at.saturating_add(300_000) >= now_ms && !message.mine { + show_community_notification(&chat_id, &message).await; + } + if let Some(handle) = crate::TAURI_APP.get() { + let _ = crate::commands::messaging::update_unread_counter(handle.clone()).await; + } + } + Inbound::Typing { chat_id, npub, until } => { + let typers = { + let mut state = crate::STATE.lock().await; + state.update_typing_and_get_active(&chat_id, &npub, until) + }; + vector_core::emit_event( + "typing-update", + &serde_json::json!({ "conversation_id": chat_id, "typers": typers }), + ); + } + Inbound::Handled => {} + } + true +} + /// Routes "straggler" community events — ones a slower relay returned after a racing /// `LiveTransport::fetch` already handed the caller the fast relay's batch — back through the SAME /// realtime ingest path. So a historical message, control edition, or rekey that only a slow relay @@ -298,6 +344,9 @@ pub(crate) async fn start_subscriptions() -> Result { // Community (kind-3300) subscription — scoped to our channels' epoch pseudonyms. refresh_community_subscription().await; + // Concord v2 subscription — kind 1059/21059 by derived plane authors. + vector_core::concord::v2::service::refresh_subscription(&client).await; + // Self-sync subscription — our own replaceable settings lists (Community List + emoji list). Covers // boot, reconnect, AND instant cross-device in one open subscription. subscribe_self_sync().await; @@ -312,7 +361,15 @@ pub(crate) async fn start_subscriptions() -> Result { match notification { RelayPoolNotification::Event { event, subscription_id, .. } => { let k = event.kind.as_u16(); - if subscription_id == gift_sub_id { + // Concord v2 wraps share kind 1059 with DM gift wraps; the seam is the + // author — a v2 plane address is a route-map hit, a DM wrap's ephemeral + // author never is. Checked first so v2 events stream regardless of which + // subscription surfaced them. + if (k == 1059 || k == 21059) + && handle_concord2_event(&session, &event).await + { + // handled by the v2 pipeline + } else if subscription_id == gift_sub_id { // DMs/files/reactions/edits (via tauri_commit_prepared_event) super::handle_event(*event, true).await; } else if (3300..=3311).contains(&k) {