5 Commits
Author SHA1 Message Date
reya aa86fef16f chore: release version 2.0.0-alpha 2026-10-15 12:46:25 +07:00
reya bbc69c9861 chore: refactor the login flow 2026-10-13 11:09:37 +07:00
reya a249326c28 feat: add configurable panel background image 2026-10-13 10:55:58 +07:00
reya 61289edb9a chore: update deps and clean up 2026-10-13 08:10:05 +07:00
reya a365a8f584 feat: bind user metadata to per-view directories (#64)
Reviewed-on: #64
2026-10-12 11:14:03 +00:00
58 changed files with 1648 additions and 13114 deletions
Generated
+165 -642
View File
File diff suppressed because it is too large Load Diff
+1 -2
View File
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop", "web"]
default-members = ["desktop"]
[workspace.package]
version = "1.0.2"
version = "2.0.0-alpha"
edition = "2024"
publish = false
@@ -57,7 +57,6 @@ smol = "2"
webbrowser = "1.0.4"
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
instant = "0.1"
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
[profile.release]
strip = true
Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

+5 -3
View File
@@ -6,10 +6,12 @@ publish.workspace = true
[dependencies]
gpui.workspace = true
instant.workspace = true
log.workspace = true
serde.workspace = true
serde_json.workspace = true
ureq.workspace = true
futures.workspace = true
gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" }
gpui-updater-pre = "0.2"
[dev-dependencies]
reqwest_client.workspace = true
+55 -166
View File
@@ -1,11 +1,9 @@
#![cfg(not(target_arch = "wasm32"))]
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::time::Duration;
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
use instant::Duration;
use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version};
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
@@ -66,13 +64,10 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
impl Global for GlobalAutoUpdater {}
pub struct AutoUpdater {
/// The blocking engine, driven on the background executor.
engine: Arc<UpdateEngine<GiteaSource>>,
updater: Entity<Updater>,
status: UpdateStatus,
/// The newer release found by the last successful check, if any.
available: Option<Release>,
/// The in-flight check or download, if any.
task: Option<Task<()>>,
/// The pending revert of an `Errored` status back to `Idle`, if any.
error_revert: Option<Task<()>>,
}
impl AutoUpdater {
@@ -87,12 +82,21 @@ impl AutoUpdater {
}
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
let entity = cx.entity().downgrade();
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
let engine = Arc::new(UpdateEngine::new(source, config));
let source = GiteaSource::new(
GITEA_API_BASE,
GITEA_REPO_OWNER,
GITEA_REPO_NAME,
filter,
cx.http_client(),
);
let config = EngineConfig::new(version).verification(Verification::Checksum);
let updater = cx.new(|cx| Updater::new(source, config, cx));
cx.observe(&updater, |this, _, cx| this.observe_status(cx))
.detach();
// Schedule an auto-check after a 2-minute delay
let entity = cx.entity().downgrade();
cx.defer(move |cx| {
cx.spawn(async move |cx| {
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
@@ -102,13 +106,41 @@ impl AutoUpdater {
});
Self {
engine,
updater,
status: UpdateStatus::Idle,
available: None,
task: None,
error_revert: None,
}
}
fn observe_status(&mut self, cx: &mut Context<Self>) {
let status = self.updater.read(cx).status().clone();
self.status = status.clone();
self.error_revert = if matches!(status, UpdateStatus::Errored(_)) {
Some(cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |this, cx| {
if matches!(this.status, UpdateStatus::Errored(_)) {
this.status = UpdateStatus::Idle;
cx.notify();
}
})
.ok();
}))
} else {
None
};
// The adapter stops at `Available`; continue into the download.
if matches!(status, UpdateStatus::Available(_)) {
self.updater.update(cx, |updater, cx| {
updater.download_and_install(cx);
});
}
cx.notify();
}
/// Whether the running version is the newest release, so the status line can be hidden.
pub fn up_to_date(&self) -> bool {
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
@@ -143,136 +175,9 @@ impl AutoUpdater {
/// Check the release host for a newer version, then download and install it.
pub fn check(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
self.set_status(UpdateStatus::Checking, cx);
let engine = self.engine.clone();
self.task = Some(cx.spawn(async move |this, cx| {
let result = cx
.background_executor()
.spawn(async move { engine.check() })
.await;
this.update(cx, |this, cx| {
this.task = None;
match result {
Ok(Some(release)) => {
log::info!("Update {} is available", release.version);
let version = release.version.clone();
this.available = Some(release);
this.set_status(UpdateStatus::Available(version), cx);
this.download_and_install(cx);
}
Ok(None) => this.set_status(UpdateStatus::UpToDate, cx),
Err(error) => {
log::warn!("Update check failed: {error}");
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
}
/// Download the available update, verify it, and swap it into place.
fn download_and_install(&mut self, cx: &mut Context<Self>) {
if self.status.is_busy() {
return;
}
let Some(release) = self.available.clone() else {
return;
};
let engine = self.engine.clone();
self.set_status(
UpdateStatus::Downloading {
downloaded: 0,
total: None,
},
cx,
);
self.task = Some(cx.spawn(async move |this, cx| {
let downloaded = Arc::new(AtomicU64::new(0));
let total = Arc::new(AtomicU64::new(0)); // 0 = unknown
let done = Arc::new(AtomicBool::new(false));
let download_task = {
let (engine, release) = (engine.clone(), release.clone());
let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone());
cx.background_executor().spawn(async move {
let result = engine.download(&release, |got, expected| {
downloaded.store(got, Ordering::Relaxed);
total.store(expected.unwrap_or(0), Ordering::Relaxed);
});
done.store(true, Ordering::Relaxed);
result
})
};
loop {
let got = downloaded.load(Ordering::Relaxed);
let total = total.load(Ordering::Relaxed);
this.update(cx, |this, cx| {
this.set_status(
UpdateStatus::Downloading {
downloaded: got,
total: (total != 0).then_some(total),
},
cx,
);
})
.ok();
if done.load(Ordering::Relaxed) {
break;
}
cx.background_executor()
.timer(Duration::from_millis(120))
.await;
}
let artifact = match download_task.await {
Ok(artifact) => artifact,
Err(error) => {
log::warn!("Update download failed: {error}");
this.update(cx, |this, cx| {
this.task = None;
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
})
.ok();
return;
}
};
let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx));
let installed = {
let engine = engine.clone();
cx.background_executor()
.spawn(async move { engine.install(&artifact) })
.await
};
this.update(cx, |this, cx| {
this.task = None;
match installed {
Ok(installed) => {
if let Some(path) = installed.restart_path {
cx.set_restart_path(path);
}
let version = release.version.clone();
this.set_status(UpdateStatus::Staged(version), cx);
}
Err(error) => {
this.set_status(UpdateStatus::Errored(error.to_string()), cx);
}
}
})
.ok();
}));
self.updater.update(cx, |updater, cx| {
updater.check(cx);
});
}
/// Relaunch into the staged update.
@@ -281,24 +186,8 @@ impl AutoUpdater {
log::warn!("Ignoring restart request: no update is staged");
return;
}
cx.restart();
}
fn set_status(&mut self, status: UpdateStatus, cx: &mut Context<Self>) {
let errored = matches!(status, UpdateStatus::Errored(_));
self.status = status;
if errored {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(ERROR_DISPLAY_DURATION).await;
this.update(cx, |this, cx| {
this.set_status(UpdateStatus::Idle, cx);
})
.ok();
})
.detach();
}
cx.notify();
self.updater.update(cx, |updater, cx| {
updater.restart(cx);
});
}
}
+104 -95
View File
@@ -1,8 +1,18 @@
use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag};
use std::sync::Arc;
use std::time::Duration;
use futures::AsyncReadExt;
use futures::executor::block_on;
use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, RedirectPolicy, Request};
use gpui_updater_pre::{Asset, Error, Release, Result, UpdateSource, parse_tag};
use serde::Deserialize;
use serde::de::DeserializeOwned;
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
const RELEASE_PAGE_SIZE: usize = 20;
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
/// Deadline for the complete metadata request, including its body.
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
/// Which published artifact belongs to a target platform.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -39,12 +49,38 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option<AssetFilter> {
Some(AssetFilter { extension, arch })
}
/// A release as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
/// A release asset as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
/// Reads releases from a Gitea repository's Releases.
pub struct GiteaSource {
api_base: String,
owner: String,
repo: String,
filter: AssetFilter,
http_client: Arc<dyn HttpClient>,
}
impl GiteaSource {
@@ -55,12 +91,14 @@ impl GiteaSource {
owner: impl Into<String>,
repo: impl Into<String>,
filter: AssetFilter,
http_client: Arc<dyn HttpClient>,
) -> Self {
Self {
api_base: api_base.into().trim_end_matches('/').to_string(),
owner: owner.into(),
repo: repo.into(),
filter,
http_client,
}
}
@@ -70,11 +108,49 @@ impl GiteaSource {
self.api_base, self.owner, self.repo
)
}
async fn get_bytes(&self, url: &str) -> Result<Vec<u8>> {
let request = Request::get(url)
.header("User-Agent", USER_AGENT)
.timeout(REQUEST_TIMEOUT)
.follow_redirects(RedirectPolicy::FollowAll)
.body(AsyncBody::empty())
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
let mut response = self
.http_client
.send(request)
.await
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
let status = response.status();
if !status.is_success() {
return Err(Error::Http(format!("GET {url} -> {status}")));
}
let mut body = Vec::new();
response
.body_mut()
.read_to_end(&mut body)
.await
.map_err(|error| Error::Http(format!("GET {url}: {error}")))?;
Ok(body)
}
async fn get_json<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
let body = self.get_bytes(url).await?;
serde_json::from_slice(&body).map_err(|error| Error::Parse(error.to_string()))
}
async fn get_string(&self, url: &str) -> Result<String> {
let body = self.get_bytes(url).await?;
String::from_utf8(body).map_err(|error| Error::Parse(error.to_string()))
}
}
impl UpdateSource for GiteaSource {
fn fetch_latest(&self) -> Result<Release> {
let releases: Vec<GiteaRelease> = http::get_json(&self.releases_url())?;
let releases: Vec<GiteaRelease> = block_on(self.get_json(&self.releases_url()))?;
let release = newest_published(&releases)
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
@@ -92,26 +168,27 @@ impl UpdateSource for GiteaSource {
.assets
.iter()
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
.map(|sums| http::get_string(&sums.browser_download_url))
.map(|sums| block_on(self.get_string(&sums.browser_download_url)))
.transpose()?
.and_then(|sums| sha256_for(&sums, &asset.name));
Ok(Release {
version: parse_tag(&release.tag_name)?,
notes: release
.body
.clone()
.filter(|body| !body.trim().is_empty())
.or_else(|| release.name.clone()),
asset: Asset {
name: asset.name.clone(),
url: asset.browser_download_url.clone(),
size: asset.size,
},
signature: None,
signature_url: None,
sha256,
})
let notes = release
.body
.clone()
.filter(|body| !body.trim().is_empty())
.or_else(|| release.name.clone());
let mut update = Release::new(
parse_tag(&release.tag_name)?,
Asset::new(&asset.name, &asset.browser_download_url, asset.size),
);
if let Some(notes) = notes {
update = update.with_notes(notes);
}
if let Some(sha256) = sha256 {
update = update.with_sha256(sha256);
}
Ok(update)
}
}
@@ -139,83 +216,9 @@ fn sha256_for(sums: &str, asset_name: &str) -> Option<String> {
})
}
/// A release as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaRelease {
tag_name: String,
#[serde(default)]
name: Option<String>,
#[serde(default)]
body: Option<String>,
#[serde(default)]
draft: bool,
#[serde(default)]
prerelease: bool,
#[serde(default)]
assets: Vec<GiteaAsset>,
}
/// A release asset as returned by the Gitea API.
#[derive(Debug, Deserialize)]
struct GiteaAsset {
name: String,
browser_download_url: String,
#[serde(default)]
size: u64,
}
/// Blocking HTTP helpers for release metadata.
mod http {
use std::time::Duration;
use gpui_updater_core::{Error, Result};
use serde::de::DeserializeOwned;
use ureq::Agent;
use ureq::tls::{RootCerts, TlsConfig};
const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION"));
const CONNECT_TIMEOUT: Duration = Duration::from_secs(15);
const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
fn agent() -> Agent {
Agent::config_builder()
.user_agent(USER_AGENT)
.tls_config(
TlsConfig::builder()
.root_certs(RootCerts::PlatformVerifier)
.build(),
)
.timeout_resolve(Some(CONNECT_TIMEOUT))
.timeout_connect(Some(CONNECT_TIMEOUT))
.timeout_recv_response(Some(RESPONSE_TIMEOUT))
.build()
.into()
}
fn get_bytes(url: &str) -> Result<Vec<u8>> {
let mut response = agent().get(url).call().map_err(|error| match error {
ureq::Error::StatusCode(code) => Error::Http(format!("GET {url} -> {code}")),
other => Error::Http(other.to_string()),
})?;
response
.body_mut()
.read_to_vec()
.map_err(|error| Error::Http(format!("GET {url} -> {error}")))
}
pub(super) fn get_json<T: DeserializeOwned>(url: &str) -> Result<T> {
serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
pub(super) fn get_string(url: &str) -> Result<String> {
String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string()))
}
}
#[cfg(test)]
mod tests {
use gpui_updater_core::Version;
use gpui_updater_pre::Version;
use super::*;
@@ -344,7 +347,13 @@ abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
fn live_release_source_resolves_the_running_platform() {
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
.expect("this platform should be supported");
let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter);
let source = GiteaSource::new(
"https://git.reya.info/api/v1",
"reya",
"coop",
filter,
Arc::new(reqwest_client::ReqwestClient::new()),
);
let release = source
.fetch_latest()
+5 -3
View File
@@ -580,6 +580,7 @@ impl ChatRegistry {
let room: Room = room.into().organize(&public_key);
let room_id = room.id;
let entity = cx.new(|_| room);
entity.update(cx, |room, cx| room.ensure_directory(cx));
self.room_index.insert(room_id, entity.clone());
self.rooms.insert(0, entity);
@@ -593,10 +594,10 @@ impl ChatRegistry {
let id = room.read(cx).id;
if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) {
let entity = room.to_owned();
e.insert(entity.clone());
room.update(cx, |room, cx| room.ensure_directory(cx));
e.insert(room.clone());
self.rooms.insert(0, entity);
self.rooms.insert(0, room.clone());
cx.notify();
}
}
@@ -670,6 +671,7 @@ impl ChatRegistry {
} else {
let new_room_id = new_room.id;
let entity = cx.new(|_| new_room);
entity.update(cx, |room, cx| room.ensure_directory(cx));
self.room_index.insert(new_room_id, entity.clone());
self.rooms.push(entity);
+39 -14
View File
@@ -3,12 +3,12 @@ use std::hash::{Hash, Hasher};
use anyhow::{Error, anyhow};
use common::EventExt;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task};
use gpui::{App, AppContext, Context, Entity, EventEmitter, SharedString, Task};
use instant::Duration;
use itertools::Itertools;
use nip4e::DeviceRegistry;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use person::{Directory, Person};
use settings::{RoomConfig, SignerKind};
use state::{NostrRegistry, TIMEOUT, UniversalSigner};
@@ -88,6 +88,9 @@ pub struct Room {
/// Configuration
config: RoomConfig,
/// Person directory, attached when the room is registered
directory: Option<Entity<Directory>>,
}
impl Ord for Room {
@@ -136,6 +139,7 @@ impl From<&UnsignedEvent> for Room {
members,
kind: RoomKind::default(),
config: RoomConfig::new(),
directory: None,
}
}
}
@@ -236,6 +240,32 @@ impl Room {
self.members.len() > 2
}
/// The room's person directory, attached when the room is registered
pub fn directory(&self) -> Option<Entity<Directory>> {
self.directory.clone()
}
/// Attach the person directory, tracking the room's members
pub(crate) fn ensure_directory(&mut self, cx: &mut Context<Self>) {
if self.directory.is_some() {
return;
}
let directory = Directory::new(cx);
let members = self.members.clone();
directory.update(cx, |directory, cx| directory.track(members, cx));
self.directory = Some(directory);
}
/// The person for a room member
fn member(&self, public_key: &PublicKey, cx: &App) -> Person {
let Some(directory) = self.directory.as_ref() else {
return Person::new(*public_key, Metadata::default());
};
directory.read(cx).person(public_key, cx)
}
/// Gets the display name for the room
pub fn display_name(&self, cx: &App) -> SharedString {
if let Some(value) = self.subject.clone() {
@@ -267,19 +297,16 @@ impl Room {
///
/// Display member is always different from the current user.
pub fn display_member(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.members[0], cx)
self.member(&self.members[0], cx)
}
/// Merge the names of the first two members of the room.
fn merged_name(&self, cx: &App) -> SharedString {
let persons = PersonRegistry::global(cx);
if self.is_group() {
let profiles: Vec<Person> = self
.members
.iter()
.map(|public_key| persons.read(cx).get(public_key, cx))
.map(|public_key| self.member(public_key, cx))
.collect();
let mut name = profiles
@@ -404,9 +431,10 @@ impl Room {
let sender = nostr.read(cx).current_user()?;
// Construct a direct message rumor event
//
// WARNING: never sign and send this event to relays
let mut tags = self.conversation_tags(&replies, sender, cx);
tags.extend(PersonRegistry::emoji_tags(&content, cx));
tags.extend(self.member(&sender, cx).emoji_tags(&content));
let mut event = EventBuilder::new(kind, content)
.tags(tags)
@@ -446,8 +474,6 @@ impl Room {
// Build the `subject` + reply `e` tags + receiver `p` tags (excluding `sender`)
fn conversation_tags(&self, replies: &[EventId], sender: PublicKey, cx: &App) -> Vec<Tag> {
let persons = PersonRegistry::global(cx);
// Construct event's tags
let mut tags = vec![];
@@ -463,7 +489,7 @@ impl Room {
// Add all receiver tags (no intermediate allocation)
for public_key in self.members.iter().filter(|pk| *pk != &sender) {
let member = persons.read(cx).get(public_key, cx);
let member = self.member(public_key, cx);
tags.push(Tag::from(Nip01Tag::PublicKey {
public_key: member.public_key(),
relay_hint: member.messaging_relay_hint(),
@@ -486,15 +512,14 @@ impl Room {
let current_user = nostr.read(cx).current_user()?;
// Get sender's profile
let persons = PersonRegistry::global(cx);
let sender = persons.read(cx).get(&current_user, cx);
let sender = self.member(&current_user, cx);
// Get all members (excluding sender)
let receivers: Vec<Person> = self
.members
.iter()
.filter(|public_key| public_key != &&sender.public_key())
.map(|member| persons.read(cx).get(member, cx))
.map(|member| self.member(member, cx))
.collect();
Some(cx.background_spawn(async move {
+51 -24
View File
@@ -9,15 +9,15 @@ use common::{TimestampExt, emojis_from_tags};
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, AppContext, ClipboardItem, Context, Entity, EventEmitter, FocusHandle,
Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton,
ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
Focusable, ImageSource, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState,
MouseButton, ObjectFit, ParentElement, PathPromptOptions, Render, SharedString, SharedUri,
StatefulInteractiveElement, Styled, StyledImage, Subscription, SystemNotification,
SystemNotificationAction, Task, WeakEntity, Window, div, img, list, px, red, relative,
retain_all, svg, white,
};
use itertools::Itertools;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry};
use person::{Directory, Person};
use regex::Regex;
use settings::{AppSettings, SignerKind};
use smallvec::{SmallVec, smallvec};
@@ -73,6 +73,9 @@ pub struct ChatPanel {
/// Chat Room
room: WeakEntity<Room>,
/// Person directory shared with the room
directory: Entity<Directory>,
/// Message list state
list_state: ListState,
@@ -133,6 +136,13 @@ impl ChatPanel {
let messages = Vec::new();
let list_state = ListState::new(messages.len() + 1, ListAlignment::Bottom, px(1024.));
// Share the room's person directory
let directory = room
.read_with(cx, |room, _cx| room.directory())
.ok()
.flatten()
.unwrap_or_else(|| Directory::new(cx));
// Get room id and name
let (id, name) = room
.read_with(cx, |this, _cx| {
@@ -194,6 +204,7 @@ impl ChatPanel {
message_index: HashMap::new(),
reactions: BTreeMap::new(),
room,
directory,
list_state,
input,
subject_input,
@@ -832,8 +843,7 @@ impl ChatPanel {
}
fn profile(&self, public_key: &PublicKey, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(public_key, cx)
self.directory.read(cx).person(public_key, cx)
}
fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context<Self>) {
@@ -1085,7 +1095,7 @@ impl ChatPanel {
}
if let Some(message) = self.messages.get(index) {
let persons = PersonRegistry::global(cx);
let directory = self.directory.clone();
let show_author = self.is_group_start(index);
let text = self
.rendered_texts_by_id
@@ -1095,7 +1105,7 @@ impl ChatPanel {
&message.content,
&message.mentions,
&message.emojis,
&persons,
&directory,
cx,
)
})
@@ -1210,8 +1220,9 @@ impl ChatPanel {
items
}
fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement {
fn render_sent_reports(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
let reports = self.sent_reports(id);
let panel = cx.weak_entity();
let pending = reports.as_ref().is_some_and(|reports| reports.is_empty());
@@ -1241,28 +1252,29 @@ impl ChatPanel {
this.when(!pending, |this| {
this.on_click(move |_e, window, cx| {
let reports = reports.clone();
let panel = panel.clone();
window.open_dialog(cx, move |this, _window, cx| {
let items = panel
.update(cx, |panel, cx| {
reports
.iter()
.map(|report| panel.render_report(report, cx))
.collect::<Vec<_>>()
})
.unwrap_or_default();
this.title(SharedString::from("Sent Reports"))
.show_close(true)
.child(v_flex().gap_4().children({
let mut items = Vec::with_capacity(reports.len());
for report in reports.iter() {
items.push(Self::render_report(report, cx))
}
items
}))
.child(v_flex().gap_4().children(items))
});
})
})
})
}
fn render_report(report: &SendReport, cx: &App) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&report.receiver, cx);
fn render_report(&self, report: &SendReport, cx: &App) -> AnyElement {
let profile = self.directory.read(cx).person(&report.receiver, cx);
let name = profile.name();
let avatar = profile.avatar();
@@ -1363,6 +1375,7 @@ impl ChatPanel {
}),
)
})
.into_any_element()
}
fn render_actions(
@@ -1652,8 +1665,7 @@ impl ChatPanel {
fn render_reply(&self, id: &EventId, cx: &Context<Self>) -> impl IntoElement {
if let Some(text) = self.message(id) {
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&text.author, cx);
let profile = self.directory.read(cx).person(&text.author, cx);
div()
.w_full()
@@ -1740,7 +1752,7 @@ impl ChatPanel {
auto,
)
.menu_with_check_and_disabled(
"Decoupled Encryption Key",
"Encryption Key",
encryption,
Box::new(Command::ChangeSigner(SignerKind::Encryption)),
encryption,
@@ -1758,7 +1770,18 @@ impl ChatPanel {
}
fn render_emoji_menu(&self, _window: &Window, cx: &Context<Self>) -> impl IntoElement {
let emojis = PersonRegistry::current_emojis(cx);
let nostr = NostrRegistry::global(cx);
let current_user = nostr.read(cx).current_user();
let emojis = current_user
.map(|current_user| {
self.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec()
})
.unwrap_or_default();
Button::new("emoji")
.icon(IconName::Emoji)
@@ -1823,6 +1846,10 @@ impl Panel for ChatPanel {
}),
]
}
fn background(&self, cx: &App) -> Option<ImageSource> {
AppSettings::panel_background_source(cx)
}
}
impl EventEmitter<PanelEvent> for ChatPanel {}
+6 -3
View File
@@ -1,7 +1,7 @@
use chat::{Mention, nevent_refs};
use gpui::{App, Entity};
use nostr_sdk::prelude::Url;
use person::PersonRegistry;
use person::Directory;
use ui::markdown::InlineReplacement;
pub use ui::markdown::RenderedText;
@@ -9,7 +9,7 @@ pub fn rendered_text(
content: &str,
mentions: &[Mention],
emojis: &[(String, Url)],
persons: &Entity<PersonRegistry>,
persons: &Entity<Directory>,
cx: &App,
) -> RenderedText {
let mut replacements = mentions
@@ -17,7 +17,10 @@ pub fn rendered_text(
.map(|mention| {
InlineReplacement::new(
mention.range.clone(),
format!("@{}", persons.read(cx).get(&mention.public_key, cx).name()),
format!(
"@{}",
persons.read(cx).person(&mention.public_key, cx).name()
),
)
})
.collect::<Vec<_>>();
-23
View File
@@ -1,23 +0,0 @@
[package]
name = "concord"
version.workspace = true
edition.workspace = true
publish.workspace = true
[dependencies]
nostr.workspace = true
nostr-sdk.workspace = true
hkdf.workspace = true
sha2.workspace = true
chacha20.workspace = true
hmac.workspace = true
data-encoding.workspace = true
base64.workspace = true
rand.workspace = true
serde.workspace = true
serde_json.workspace = true
anyhow.workspace = true
[dev-dependencies]
smol.workspace = true
-723
View File
@@ -1,723 +0,0 @@
use std::fmt;
use data_encoding::BASE64;
use nostr::nips::nip44::v2::{ConversationKey, decrypt_to_bytes, encrypt_to_bytes_with_nonce};
use nostr_sdk::prelude::{
AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, EventBuilder, EventId, FinalizeEvent,
FinalizeEventAsync, Keys, Kind, PublicKey, Tag, Timestamp, UnsignedEvent,
};
use crate::derive::GroupKey;
use crate::{ChannelId, Epoch};
pub const KIND_WRAP: u16 = 1059;
pub const KIND_WRAP_EPHEMERAL: u16 = 21059;
pub const KIND_SEAL_ENCRYPTED: u16 = 20013;
pub const KIND_SEAL_PLAINTEXT: u16 = 20014;
pub const NIP44_MAX_PLAINTEXT: usize = 65_535;
const TAG_MS: &str = "ms";
pub(crate) const TAG_CHANNEL: &str = "channel";
pub(crate) const TAG_EPOCH: &str = "epoch";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SealForm {
Encrypted,
Plaintext,
}
impl SealForm {
pub fn kind(self) -> u16 {
match self {
SealForm::Encrypted => KIND_SEAL_ENCRYPTED,
SealForm::Plaintext => KIND_SEAL_PLAINTEXT,
}
}
fn from_kind(kind: u16) -> Option<Self> {
match kind {
KIND_SEAL_ENCRYPTED => Some(SealForm::Encrypted),
KIND_SEAL_PLAINTEXT => Some(SealForm::Plaintext),
_ => None,
}
}
}
#[derive(Debug)]
pub enum StreamError {
Sign(String),
Encrypt(String),
Decrypt(String),
Parse(String),
Oversize(usize),
BadWrapKind(u16),
WrongStream,
BadWrapSignature,
BadSealKind(u16),
BadSealSignature,
AuthorMismatch,
BadRumorId,
BadMs,
ChannelMismatch,
EpochMismatch,
MissingTag(&'static str),
DuplicateTag(&'static str),
NotRewrappable,
}
impl fmt::Display for StreamError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
StreamError::Sign(error) => write!(f, "sign: {error}"),
StreamError::Encrypt(error) => write!(f, "encrypt: {error}"),
StreamError::Decrypt(error) => write!(f, "decrypt: {error}"),
StreamError::Parse(error) => write!(f, "parse: {error}"),
StreamError::Oversize(len) => write!(f, "plaintext {len} bytes exceeds NIP-44 cap"),
StreamError::BadWrapKind(kind) => write!(f, "not a wrap kind: {kind}"),
StreamError::WrongStream => write!(f, "wrap author is not this stream"),
StreamError::BadWrapSignature => write!(f, "restricted wrap signature invalid"),
StreamError::BadSealKind(kind) => write!(f, "not a seal kind: {kind}"),
StreamError::BadSealSignature => write!(f, "seal signature invalid"),
StreamError::AuthorMismatch => write!(f, "rumor pubkey != seal pubkey"),
StreamError::BadRumorId => write!(f, "rumor id != computed hash"),
StreamError::BadMs => write!(f, "ms is not a canonical decimal in 0..=999"),
StreamError::ChannelMismatch => write!(f, "channel binding mismatch"),
StreamError::EpochMismatch => write!(f, "epoch binding mismatch"),
StreamError::MissingTag(name) => write!(f, "missing rumor tag: {name}"),
StreamError::DuplicateTag(name) => write!(f, "duplicate rumor tag: {name}"),
StreamError::NotRewrappable => write!(f, "only plaintext seals survive re-wrapping"),
}
}
}
impl std::error::Error for StreamError {}
#[derive(Debug, Clone)]
pub struct OpenedStream {
pub rumor_id: EventId,
pub author: PublicKey,
pub seal_form: SealForm,
pub seal: Event,
pub wrapper_id: EventId,
pub at_ms: u64,
pub rumor: UnsignedEvent,
}
pub fn split_ms(at_ms: u64) -> (u64, u16) {
(at_ms / 1000, (at_ms % 1000) as u16)
}
/// Build a rumor carrying a full epoch-ms time: seconds in `created_at`, the remainder as `["ms", 0..=999]`.
pub fn build_rumor_ms(
kind: u16,
author: PublicKey,
content: &str,
mut tags: Vec<Tag>,
at_ms: u64,
) -> UnsignedEvent {
let (seconds, offset) = split_ms(at_ms);
tags.push(Tag::custom(TAG_MS, [offset.to_string()]));
build_rumor_secs(kind, author, content, tags, seconds)
}
/// Build a rumor with a plain seconds timestamp and no `ms` tag.
pub fn build_rumor_secs(
kind: u16,
author: PublicKey,
content: &str,
tags: Vec<Tag>,
at_secs: u64,
) -> UnsignedEvent {
let mut rumor = UnsignedEvent::new(
author,
Timestamp::from_secs(at_secs),
Kind::Custom(kind),
tags,
content,
);
rumor.ensure_id();
rumor
}
pub fn resolve_ms_strict(rumor: &UnsignedEvent) -> Result<u64, StreamError> {
let seconds = rumor.created_at.as_secs().saturating_mul(1000);
let mut tag: Option<Option<String>> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) == Some(TAG_MS) {
tag = Some(fields.get(1).cloned());
break;
}
}
let Some(raw) = tag else {
return Ok(seconds);
};
let raw = raw.ok_or(StreamError::BadMs)?;
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return Err(StreamError::BadMs);
}
let offset: u64 = raw.parse().map_err(|_| StreamError::BadMs)?;
if offset > 999 || (raw.len() > 1 && raw.starts_with('0')) {
return Err(StreamError::BadMs);
}
Ok(seconds.saturating_add(offset))
}
pub fn seal_content(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
) -> Result<String, StreamError> {
let json = rumor.as_json();
check_plaintext_cap(json.len())?;
match form {
SealForm::Plaintext => Ok(json),
SealForm::Encrypted => seal_bytes(group.conversation(), json.as_bytes()),
}
}
pub fn seal_bytes(conversation: &ConversationKey, plaintext: &[u8]) -> Result<String, StreamError> {
check_plaintext_cap(plaintext.len())?;
Ok(BASE64.encode(&encrypt(conversation, plaintext)?))
}
pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result<Vec<u8>, StreamError> {
let payload = BASE64
.decode(content.as_bytes())
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
decrypt_to_bytes(conversation, &payload)
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
/// A member's own document (the Community List, the Invite List): NIP-44 to self.
pub async fn seal_to_self<S>(signer: &S, plaintext: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
check_plaintext_cap(plaintext.len())?;
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))?;
signer
.nip44_encrypt_async(&address, plaintext)
.await
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
pub async fn open_to_self<S>(signer: &S, content: &str) -> Result<String, StreamError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
let address = signer
.get_public_key_async()
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))?;
signer
.nip44_decrypt_async(&address, content)
.await
.map_err(|error| StreamError::Decrypt(error.to_string()))
}
pub async fn build_seal<S>(
rumor: &UnsignedEvent,
form: SealForm,
group: &GroupKey,
author: &S,
) -> Result<Event, StreamError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let content = seal_content(rumor, form, group)?;
EventBuilder::new(Kind::Custom(form.kind()), content)
.custom_created_at(rumor.created_at)
.finalize_async(author)
.await
.map_err(|error| StreamError::Sign(error.to_string()))
}
pub fn wrap_seal(
seal: &Event,
group: &GroupKey,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
wrap_seal_with(
seal,
group.conversation(),
group.keys(),
wrap_kind,
at,
extra,
)
}
pub fn wrap_seal_with(
seal: &Event,
conversation: &ConversationKey,
signer: &Keys,
wrap_kind: u16,
at: Timestamp,
extra: &[Tag],
) -> Result<(Event, Keys), StreamError> {
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
let json = seal.as_json();
check_plaintext_cap(json.len())?;
let content = BASE64.encode(&encrypt(conversation, json.as_bytes())?);
let ephemeral = Keys::generate();
let mut tags = vec![Tag::public_key(ephemeral.public_key())];
tags.extend_from_slice(extra);
let wrap = EventBuilder::new(Kind::Custom(wrap_kind), content)
.tags(tags)
.custom_created_at(at)
.finalize(signer)
.map_err(|error| StreamError::Sign(error.to_string()))?;
Ok((wrap, ephemeral))
}
pub fn rewrap_seal(
seal: &Event,
read: &GroupKey,
signer: &GroupKey,
at: Timestamp,
) -> Result<(Event, Keys), StreamError> {
if seal.kind.as_u16() != KIND_SEAL_PLAINTEXT {
return Err(StreamError::NotRewrappable);
}
wrap_seal_with(seal, read.conversation(), signer.keys(), KIND_WRAP, at, &[])
}
pub fn open_wrap(wrap: &Event, group: &GroupKey) -> Result<OpenedStream, StreamError> {
open_wrap_at(wrap, &group.pk(), group.conversation(), false)
}
pub fn open_wrap_at(
wrap: &Event,
address: &PublicKey,
conversation: &ConversationKey,
verify_wrap_signature: bool,
) -> Result<OpenedStream, StreamError> {
let wrap_kind = wrap.kind.as_u16();
if wrap_kind != KIND_WRAP && wrap_kind != KIND_WRAP_EPHEMERAL {
return Err(StreamError::BadWrapKind(wrap_kind));
}
if wrap.pubkey != *address {
return Err(StreamError::WrongStream);
}
if verify_wrap_signature && wrap.verify().is_err() {
return Err(StreamError::BadWrapSignature);
}
let seal: Event = Event::from_json(decode_content(conversation, &wrap.content)?)
.map_err(|error| StreamError::Parse(error.to_string()))?;
let seal_kind = seal.kind.as_u16();
let seal_form = SealForm::from_kind(seal_kind).ok_or(StreamError::BadSealKind(seal_kind))?;
seal.verify().map_err(|_| StreamError::BadSealSignature)?;
let rumor_json = match seal_form {
SealForm::Plaintext => seal.content.clone(),
SealForm::Encrypted => decode_content(conversation, &seal.content)?,
};
let mut rumor: UnsignedEvent = UnsignedEvent::from_json(rumor_json.as_bytes())
.map_err(|error| StreamError::Parse(error.to_string()))?;
if rumor.pubkey != seal.pubkey {
return Err(StreamError::AuthorMismatch);
}
let computed = rumor.compute_id();
if let Some(claimed) = rumor.id
&& claimed != computed
{
return Err(StreamError::BadRumorId);
}
rumor.id = Some(computed);
let at_ms = resolve_ms_strict(&rumor)?;
Ok(OpenedStream {
rumor_id: computed,
author: seal.pubkey,
seal_form,
seal,
wrapper_id: wrap.id,
at_ms,
rumor,
})
}
pub fn channel_binding_tags(channel: &ChannelId, epoch: Epoch) -> Vec<Tag> {
vec![
Tag::custom(TAG_CHANNEL, [channel.to_hex()]),
Tag::custom(TAG_EPOCH, [epoch.0.to_string()]),
]
}
pub fn check_channel_binding(
rumor: &UnsignedEvent,
channel: &ChannelId,
epoch: Epoch,
) -> Result<(), StreamError> {
match unique_tag(rumor, TAG_CHANNEL)? {
Some(value) if value == channel.to_hex() => {}
Some(_) => return Err(StreamError::ChannelMismatch),
None => return Err(StreamError::MissingTag(TAG_CHANNEL)),
}
match unique_tag(rumor, TAG_EPOCH)? {
Some(value) if value == epoch.0.to_string() => {}
Some(_) => return Err(StreamError::EpochMismatch),
None => return Err(StreamError::MissingTag(TAG_EPOCH)),
}
Ok(())
}
fn encrypt(conversation: &ConversationKey, plaintext: &[u8]) -> Result<Vec<u8>, StreamError> {
let mut nonce = [0u8; 32];
crate::fill_random(&mut nonce).map_err(|error| StreamError::Encrypt(error.to_string()))?;
encrypt_to_bytes_with_nonce(conversation, plaintext, nonce)
.map_err(|error| StreamError::Encrypt(error.to_string()))
}
fn decode_content(conversation: &ConversationKey, content: &str) -> Result<String, StreamError> {
let plaintext = open_bytes(conversation, content)?;
String::from_utf8(plaintext).map_err(|error| StreamError::Parse(error.to_string()))
}
fn check_plaintext_cap(len: usize) -> Result<(), StreamError> {
if len > NIP44_MAX_PLAINTEXT {
return Err(StreamError::Oversize(len));
}
Ok(())
}
pub(crate) fn unique_tag(
rumor: &UnsignedEvent,
name: &'static str,
) -> Result<Option<String>, StreamError> {
let mut found: Option<String> = None;
for tag in rumor.tags.iter() {
let fields = tag.as_slice();
if fields.len() >= 2 && fields[0] == name {
if found.is_some() {
return Err(StreamError::DuplicateTag(name));
}
found = Some(fields[1].clone());
}
}
Ok(found)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::derive::channel_group_key;
const SECRET: [u8; 32] = [0x07u8; 32];
const OTHER_SECRET: [u8; 32] = [0x08u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group(epoch: u64) -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(epoch)).expect("derives")
}
fn wrapper_p_tag(wrap: &Event) -> Option<String> {
wrap.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some("p"))
.and_then(|tag| tag.as_slice().get(1).cloned())
}
fn bound_rumor(content: &str, author: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(
9,
author,
content,
channel_binding_tags(&channel(), Epoch(0)),
at_ms,
)
}
fn sealed(rumor: &UnsignedEvent, form: SealForm, author: &Keys) -> Event {
smol::block_on(build_seal(rumor, form, &group(0), author)).expect("seals")
}
fn wrapped(seal: &Event, kind: u16, at_secs: u64) -> Event {
wrap_seal(seal, &group(0), kind, Timestamp::from_secs(at_secs), &[])
.expect("wraps")
.0
}
fn encrypted_wrap(content: &str, author: &Keys, at_ms: u64, kind: u16) -> Event {
let rumor = bound_rumor(content, author.public_key(), at_ms);
wrapped(
&sealed(&rumor, SealForm::Encrypted, author),
kind,
at_ms / 1000,
)
}
#[test]
fn both_seal_forms_round_trip() {
let author = Keys::generate();
let at_ms = 1_686_840_217_417;
let wrap = encrypted_wrap("Hey chat!", &author, at_ms, KIND_WRAP);
assert_eq!(wrap.kind, Kind::GiftWrap, "the durable wrap is kind 1059");
assert_eq!(wrap.pubkey, group(0).pk(), "the stream key signs the wrap");
let opened = open_wrap(&wrap, &group(0)).expect("opens");
assert_eq!(opened.author, author.public_key());
assert_eq!(opened.rumor.content, "Hey chat!");
assert_eq!(opened.rumor_id, opened.rumor.id.expect("id is set"));
assert_eq!(opened.wrapper_id, wrap.id);
assert_eq!(opened.at_ms, at_ms);
assert_eq!(opened.seal_form, SealForm::Encrypted);
check_channel_binding(&opened.rumor, &channel(), Epoch(0)).expect("binding holds");
// The wrap's `p` tag must identify neither the stream nor the author.
let p = wrapper_p_tag(&wrap).expect("the wrap carries a p tag");
assert_ne!(p, group(0).pk_hex());
assert_ne!(p, author.public_key().to_hex());
// Ephemeral actions ride the same structure at a kind relays must drop.
let typing = encrypted_wrap("typing", &author, 5_000, KIND_WRAP_EPHEMERAL);
assert_eq!(typing.kind.as_u16(), 21059);
assert_eq!(
open_wrap(&typing, &group(0)).expect("opens").rumor.content,
"typing"
);
// The plaintext form carries the rumor's bytes verbatim, which is what
// lets a compaction re-wrap the signed edition into a later epoch.
let edition = build_rumor_secs(
3308,
author.public_key(),
"an edition",
vec![],
1_700_000_000,
);
let seal = sealed(&edition, SealForm::Plaintext, &author);
assert_eq!(seal.content, edition.as_json(), "the rumor rides verbatim");
let opened = open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)).expect("opens");
assert_eq!(opened.seal_form, SealForm::Plaintext);
let (rewrapped, _) =
rewrap_seal(&opened.seal, &group(1), &group(1), Timestamp::from_secs(2))
.expect("rewraps");
let reopened = open_wrap(&rewrapped, &group(1)).expect("opens");
assert_eq!(reopened.rumor_id, opened.rumor_id, "the rumor id survives");
assert_eq!(reopened.author, author.public_key());
assert_eq!(
reopened.seal.sig, opened.seal.sig,
"the signature rides whole"
);
assert_ne!(reopened.wrapper_id, opened.wrapper_id);
assert!(matches!(
rewrap_seal(
&sealed(&edition, SealForm::Encrypted, &author),
&group(1),
&group(1),
Timestamp::from_secs(2)
),
Err(StreamError::NotRewrappable)
));
}
#[test]
fn hostile_wraps_are_dropped_in_order() {
let author = Keys::generate();
let impostor = Keys::generate();
// Kind and address are settled before any decryption is attempted.
let mut wrong_kind = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
wrong_kind.kind = Kind::Custom(1058);
assert!(matches!(
open_wrap(&wrong_kind, &group(0)),
Err(StreamError::BadWrapKind(1058))
));
let foreign = channel_group_key(&OTHER_SECRET, &channel(), Epoch(0)).expect("derives");
let wrap = encrypted_wrap("x", &author, 1_000, KIND_WRAP);
assert!(matches!(
open_wrap(&wrap, &foreign),
Err(StreamError::WrongStream)
));
// A flipped ciphertext byte fails the NIP-44 MAC.
let mut payload = BASE64
.decode(wrap.content.as_bytes())
.expect("content is base64");
payload[40] ^= 0x01;
let mut tampered = wrap.clone();
tampered.content = BASE64.encode(&payload);
assert!(matches!(
open_wrap(&tampered, &group(0)),
Err(StreamError::Decrypt(_))
));
// A seal claiming an author it holds no signature for.
let seal = sealed(
&bound_rumor("spoof", author.public_key(), 1_000),
SealForm::Encrypted,
&impostor,
);
let mut swapped: serde_json::Value = serde_json::from_str(&seal.as_json()).expect("json");
swapped["pubkey"] = serde_json::Value::String(author.public_key().to_hex());
let seal = Event::from_json(swapped.to_string()).expect("a swapped pubkey still parses");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadSealSignature)
));
// A seal that does not vouch for the rumor's author.
let seal = sealed(
&bound_rumor("spoof", impostor.public_key(), 1_000),
SealForm::Encrypted,
&author,
);
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::AuthorMismatch)
));
// A claimed id the rumor's own bytes do not hash to. The plaintext seal
// smuggles the forgery through verbatim.
let rumor = bound_rumor("real", author.public_key(), 1_000);
let mut forged: serde_json::Value = serde_json::from_str(&rumor.as_json()).expect("json");
forged["id"] = serde_json::Value::String("00".repeat(32));
let seal = EventBuilder::new(Kind::Custom(KIND_SEAL_PLAINTEXT), forged.to_string())
.custom_created_at(rumor.created_at)
.finalize(&author)
.expect("seals");
assert!(matches!(
open_wrap(&wrapped(&seal, KIND_WRAP, 1), &group(0)),
Err(StreamError::BadRumorId)
));
// Binding splices: another channel, another epoch, a duplicate or none.
let doubled = vec![channel_binding_tags(&channel(), Epoch(0)); 2].concat();
let rumor = bound_rumor("x", author.public_key(), 1_000);
assert!(matches!(
check_channel_binding(&rumor, &ChannelId::from_bytes([0xcdu8; 32]), Epoch(0)),
Err(StreamError::ChannelMismatch)
));
assert!(matches!(
check_channel_binding(&rumor, &channel(), Epoch(1)),
Err(StreamError::EpochMismatch)
));
let duplicate = build_rumor_ms(9, author.public_key(), "x", doubled, 1_000);
assert!(matches!(
check_channel_binding(&duplicate, &channel(), Epoch(0)),
Err(StreamError::DuplicateTag(_))
));
let unbound = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000);
assert!(matches!(
check_channel_binding(&unbound, &channel(), Epoch(0)),
Err(StreamError::MissingTag(_))
));
let oversize = build_rumor_ms(
9,
author.public_key(),
&"x".repeat(NIP44_MAX_PLAINTEXT + 1),
vec![],
1_000,
);
assert!(matches!(
seal_content(&oversize, SealForm::Encrypted, &group(0)),
Err(StreamError::Oversize(_))
));
}
#[test]
fn ms_is_a_drop_gate() {
let author = Keys::generate();
let absent = build_rumor_secs(9, author.public_key(), "x", vec![], 1_000);
assert_eq!(resolve_ms_strict(&absent).expect("resolves"), 1_000_000);
let highest = build_rumor_ms(9, author.public_key(), "x", vec![], 1_000_999);
assert_eq!(resolve_ms_strict(&highest).expect("resolves"), 1_000_999);
for malformed in ["1000", "007", "abc", "+5", ""] {
let rumor = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, [malformed.to_string()])],
1_000,
);
assert!(
matches!(resolve_ms_strict(&rumor), Err(StreamError::BadMs)),
"{malformed:?} must be malformed"
);
}
// Present but valueless is malformed, not an offset-0 default.
let valueless = build_rumor_secs(
9,
author.public_key(),
"x",
vec![Tag::custom(TAG_MS, Vec::<String>::new())],
1_000,
);
assert!(matches!(
resolve_ms_strict(&valueless),
Err(StreamError::BadMs)
));
// A valued duplicate takes the first, matching Armada.
let repeated = build_rumor_secs(
9,
author.public_key(),
"x",
vec![
Tag::custom(TAG_MS, ["1".to_string()]),
Tag::custom(TAG_MS, ["2".to_string()]),
],
1_000,
);
assert_eq!(resolve_ms_strict(&repeated).expect("resolves"), 1_000_001);
}
}
@@ -1,822 +0,0 @@
use std::cmp::Reverse;
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use crate::cord01::{
KIND_WRAP, OpenedStream, SealForm, build_rumor_ms, build_seal, open_wrap, wrap_seal,
};
use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag};
pub use crate::cords::rumor::RumorError as GuestbookError;
use crate::cords::rumor::{optional_citation, pubkey, required, value};
use crate::{GroupKey, decode_hex_32};
pub const KIND_JOIN_LEAVE: u16 = 3306;
pub const KIND_KICK: u16 = 3309;
pub const KIND_SNAPSHOT: u16 = 3312;
pub const MAX_SNAPSHOT_CHUNK: usize = 400;
pub const MAX_FUTURE_SKEW_MS: u64 = 60 * 60 * 1000;
const TAG_INVITE: &str = "invite";
const TAG_TARGET: &str = "p";
const TAG_SNAP: &str = "snap";
const TAG_CONTENT: &str = "content";
const CONTENT_JOIN: &str = "join";
const CONTENT_LEAVE: &str = "leave";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GuestbookEntry {
Join {
member: PublicKey,
at_ms: u64,
/// The `(creator, label)` an invite attributed the join to.
invited_by: Option<(String, String)>,
},
Leave {
member: PublicKey,
at_ms: u64,
},
Kick {
actor: PublicKey,
target: PublicKey,
at_ms: u64,
citation: Option<AuthorityCitation>,
},
Snapshot {
refounder: PublicKey,
members: Vec<PublicKey>,
snapshot_id: [u8; 32],
chunk: (u32, u32),
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GuestbookRumor {
pub id: EventId,
pub author: PublicKey,
pub kind: Kind,
pub at_ms: u64,
pub entry: GuestbookEntry,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MemberState {
Joined {
at_ms: u64,
invited_by: Option<(String, String)>,
},
Left {
at_ms: u64,
},
Kicked {
at_ms: u64,
actor: PublicKey,
},
}
pub fn build_join(
member: PublicKey,
invited_by: Option<(&str, &str)>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = Vec::new();
if let Some((creator, label)) = invited_by {
tags.push(Tag::custom(TAG_INVITE, [creator, label]));
}
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_JOIN, tags, at_ms)
}
pub fn build_leave(member: PublicKey, at_ms: u64) -> UnsignedEvent {
build_rumor_ms(KIND_JOIN_LEAVE, member, CONTENT_LEAVE, Vec::new(), at_ms)
}
pub fn build_kick(
actor: PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>,
at_ms: u64,
) -> UnsignedEvent {
let mut tags = vec![Tag::custom(TAG_TARGET, [target.to_hex()])];
if let Some(citation) = citation {
tags.push(citation_tag(citation));
}
build_rumor_ms(KIND_KICK, actor, "", tags, at_ms)
}
pub fn build_snapshot_chunks(
refounder: PublicKey,
members: &[PublicKey],
snapshot_id: [u8; 32],
at_ms: u64,
) -> Vec<UnsignedEvent> {
let chunks: Vec<&[PublicKey]> = members.chunks(MAX_SNAPSHOT_CHUNK).collect();
let total = chunks.len() as u32;
chunks
.iter()
.enumerate()
.map(|(index, chunk)| {
let hex: Vec<String> = chunk.iter().map(PublicKey::to_hex).collect();
let content = format!(
"[{}]",
hex.iter()
.map(|member| format!("\"{member}\""))
.collect::<Vec<_>>()
.join(",")
);
let tags = vec![Tag::custom(
TAG_SNAP,
[
HEXLOWER.encode(&snapshot_id),
(index as u32 + 1).to_string(),
total.to_string(),
],
)];
build_rumor_ms(KIND_SNAPSHOT, refounder, &content, tags, at_ms)
})
.collect()
}
pub async fn seal_rumor<S>(
rumor: &UnsignedEvent,
group: &GroupKey,
author: &S,
) -> Result<(Event, Keys), GuestbookError>
where
S: AsyncGetPublicKey + AsyncSignEvent + ?Sized,
{
let kind = rumor.kind.as_u16();
if !is_guestbook_kind(kind) {
return Err(GuestbookError::UnknownKind(kind));
}
let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?;
Ok(wrap_seal(&seal, group, KIND_WRAP, rumor.created_at, &[])?)
}
pub fn open(
wrap: &Event,
group: &GroupKey,
) -> Result<(OpenedStream, GuestbookRumor), GuestbookError> {
let opened = open_wrap(wrap, group)?;
if opened.seal_form != SealForm::Encrypted {
return Err(GuestbookError::NotEncryptedSealed);
}
let entry = entry_of(&opened)?;
let rumor = GuestbookRumor {
id: opened.rumor_id,
author: opened.author,
kind: opened.rumor.kind,
at_ms: opened.at_ms,
entry,
};
Ok((opened, rumor))
}
/// Coalesce the guestbook flat: one final state per npub, the latest entry
/// winning by millisecond time, ties broken by the lower rumor id.
///
/// `snapshot_authorities` are the npubs whose refounding is known to have minted an epoch this client reads.
/// A snapshot chunk is honored only from one of them, and an empty set honors no snapshot at all.
pub fn coalesce(
rumors: &[GuestbookRumor],
now_ms: u64,
snapshot_authorities: &BTreeSet<PublicKey>,
can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool,
) -> BTreeMap<PublicKey, MemberState> {
let mut states: BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)> = BTreeMap::new();
let horizon = now_ms.saturating_add(MAX_FUTURE_SKEW_MS);
for rumor in rumors {
if rumor.at_ms > horizon {
continue;
}
match &rumor.entry {
GuestbookEntry::Join {
member,
at_ms,
invited_by,
} => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: invited_by.clone(),
},
),
GuestbookEntry::Leave { member, at_ms } => offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Left { at_ms: *at_ms },
),
GuestbookEntry::Kick {
actor,
target,
at_ms,
citation,
} => {
if !can_kick(actor, target, citation.as_ref()) {
continue;
}
offer(
&mut states,
*target,
*at_ms,
rumor.id,
MemberState::Kicked {
at_ms: *at_ms,
actor: *actor,
},
);
}
GuestbookEntry::Snapshot {
refounder,
members,
at_ms,
..
} => {
if !snapshot_authorities.contains(refounder) {
continue;
}
for member in members {
offer(
&mut states,
*member,
*at_ms,
rumor.id,
MemberState::Joined {
at_ms: *at_ms,
invited_by: None,
},
);
}
}
}
}
states
.into_iter()
.map(|(member, (_, _, state))| (member, state))
.collect()
}
pub fn complete_memberlist(
coalesced: &BTreeMap<PublicKey, MemberState>,
observed: &BTreeMap<PublicKey, u64>,
granted: &BTreeSet<PublicKey>,
banned: &BTreeSet<PublicKey>,
banned_at: &BTreeMap<PublicKey, u64>,
) -> BTreeSet<PublicKey> {
let mut candidates: BTreeSet<&PublicKey> = coalesced.keys().collect();
candidates.extend(observed.keys());
candidates.extend(granted.iter());
let mut members = BTreeSet::new();
for member in candidates {
let mut inclusion = observed.get(member).copied();
if let Some(state) = coalesced.get(member) {
match state {
MemberState::Joined { at_ms, .. } => {
inclusion = Some(inclusion.map_or(*at_ms, |seen| seen.max(*at_ms)));
}
MemberState::Left { .. } | MemberState::Kicked { .. } => {}
}
}
if inclusion.is_none() && granted.contains(member) {
inclusion = Some(0);
}
let mut exclusion = match coalesced.get(member) {
Some(MemberState::Left { at_ms }) | Some(MemberState::Kicked { at_ms, .. }) => {
Some(*at_ms)
}
_ => None,
};
if banned.contains(member) {
exclusion = Some(match banned_at.get(member) {
Some(at_ms) => exclusion.map_or(*at_ms, |seen| seen.max(*at_ms)),
None => u64::MAX,
});
}
if let Some(inclusion) = inclusion
&& exclusion.is_none_or(|exclusion| inclusion > exclusion)
{
members.insert(*member);
}
}
members
}
fn offer(
states: &mut BTreeMap<PublicKey, (u64, Reverse<EventId>, MemberState)>,
member: PublicKey,
at_ms: u64,
id: EventId,
state: MemberState,
) {
let candidate = (at_ms, Reverse(id));
if let Some(existing) = states.get(&member)
&& (existing.0, existing.1) >= candidate
{
return;
}
states.insert(member, (at_ms, Reverse(id), state));
}
fn is_guestbook_kind(kind: u16) -> bool {
matches!(kind, KIND_JOIN_LEAVE | KIND_KICK | KIND_SNAPSHOT)
}
fn entry_of(opened: &OpenedStream) -> Result<GuestbookEntry, GuestbookError> {
let rumor = &opened.rumor;
let author = opened.author;
let at_ms = opened.at_ms;
match rumor.kind.as_u16() {
KIND_JOIN_LEAVE => match rumor.content.as_str() {
CONTENT_JOIN => Ok(GuestbookEntry::Join {
member: author,
at_ms,
invited_by: invite_of(rumor),
}),
CONTENT_LEAVE => Ok(GuestbookEntry::Leave {
member: author,
at_ms,
}),
_ => Err(GuestbookError::BadTag(TAG_CONTENT)),
},
KIND_KICK => Ok(GuestbookEntry::Kick {
actor: author,
target: tagged_pubkey(rumor, TAG_TARGET)?,
at_ms,
citation: optional_citation(rumor)?,
}),
KIND_SNAPSHOT => {
let (snapshot_id, chunk) = snapshot_of(rumor)?;
let members = members_of(&rumor.content)?;
Ok(GuestbookEntry::Snapshot {
refounder: author,
members,
snapshot_id,
chunk,
at_ms,
})
}
other => Err(GuestbookError::UnknownKind(other)),
}
}
fn invite_of(rumor: &UnsignedEvent) -> Option<(String, String)> {
rumor.tags.iter().find_map(|candidate| {
let fields = candidate.as_slice();
(fields.len() >= 3 && fields[0] == TAG_INVITE)
.then(|| (fields[1].clone(), fields[2].clone()))
})
}
fn members_of(content: &str) -> Result<Vec<PublicKey>, GuestbookError> {
let entries: Vec<String> =
serde_json::from_str(content).map_err(|_| GuestbookError::BadTag(TAG_CONTENT))?;
if entries.len() > MAX_SNAPSHOT_CHUNK {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
entries
.iter()
.map(|entry| pubkey(entry, TAG_CONTENT))
.collect()
}
fn snapshot_of(rumor: &UnsignedEvent) -> Result<([u8; 32], (u32, u32)), GuestbookError> {
let fields = required(rumor, TAG_SNAP)?;
if fields.len() != 4 {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
let snapshot_id = decode_hex_32(&fields[1]).map_err(|_| GuestbookError::BadTag(TAG_SNAP))?;
let index = decimal(&fields[2])?;
let total = decimal(&fields[3])?;
if index == 0 || index > total {
return Err(GuestbookError::BadTag(TAG_SNAP));
}
Ok((snapshot_id, (index, total)))
}
fn decimal(raw: &str) -> Result<u32, GuestbookError> {
canonical_decimal(raw)
.and_then(|value| u32::try_from(value).ok())
.ok_or(GuestbookError::BadTag(TAG_SNAP))
}
fn tagged_pubkey(rumor: &UnsignedEvent, name: &'static str) -> Result<PublicKey, GuestbookError> {
pubkey(value(required(rumor, name)?, name)?, name)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cord01::{StreamError, build_rumor_secs};
use crate::cord04::TAG_CITATION;
use crate::derive::guestbook_group_key;
use crate::{CommunityId, Epoch};
const ROOT: [u8; 32] = [0x5au8; 32];
const AT: u64 = 1_700_000_000_000;
fn community() -> CommunityId {
CommunityId::from_bytes([0x11u8; 32])
}
/// The refounders a fold is told about: a snapshot seeds members on theirs alone.
fn refounders(keys: &[&Keys]) -> BTreeSet<PublicKey> {
keys.iter().map(|keys| keys.public_key()).collect()
}
fn group() -> GroupKey {
guestbook_group_key(&ROOT, &community(), Epoch(0)).expect("derives")
}
fn citation() -> AuthorityCitation {
AuthorityCitation {
entity: [0x33u8; 32],
version: 1,
hash: [0x44u8; 32],
}
}
fn publish(rumor: &UnsignedEvent, author: &Keys) -> GuestbookRumor {
let wrap = smol::block_on(seal_rumor(rumor, &group(), author))
.expect("seals")
.0;
open(&wrap, &group()).expect("opens").1
}
#[test]
fn join_leave_kick_and_snapshot_converge_to_one_memberlist() {
let alice = Keys::generate();
let bob = Keys::generate();
let carol = Keys::generate();
let dave = Keys::generate();
let frank = Keys::generate();
let grace = Keys::generate();
let owner = Keys::generate();
let survivors: Vec<PublicKey> = (0..401).map(|_| Keys::generate().public_key()).collect();
let mut rumors = vec![
publish(
&build_join(
alice.public_key(),
Some((&"ab".repeat(32), "Reddit")),
AT + 1_000,
),
&alice,
),
publish(&build_join(bob.public_key(), None, AT + 2_000), &bob),
publish(&build_leave(bob.public_key(), AT + 3_000), &bob),
publish(&build_join(dave.public_key(), None, AT + 4_000), &dave),
publish(
&build_kick(
carol.public_key(),
&dave.public_key(),
Some(&citation()),
AT + 5_000,
),
&carol,
),
publish(&build_join(frank.public_key(), None, AT + 7_000), &frank),
];
let snapshot_id = "77".repeat(32);
let chunks =
build_snapshot_chunks(carol.public_key(), &survivors, [0x77u8; 32], AT + 6_000);
assert_eq!(chunks.len(), 2, "401 survivors chunk into two events");
for (index, chunk) in chunks.iter().enumerate() {
assert!(chunk.tags.iter().any(|tag| tag.as_slice()
== [
TAG_SNAP,
snapshot_id.as_str(),
&(index + 1).to_string(),
"2"
]));
rumors.push(publish(chunk, &carol));
}
let can_kick =
|actor: &PublicKey, target: &PublicKey, citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &carol.public_key() && target != &owner.public_key()
};
let states = coalesce(&rumors, AT + 8_000, &refounders(&[&carol]), can_kick);
assert_eq!(
states.get(&alice.public_key()),
Some(&MemberState::Joined {
at_ms: AT + 1_000,
invited_by: Some(("ab".repeat(32), "Reddit".to_owned())),
})
);
assert_eq!(
states.get(&bob.public_key()),
Some(&MemberState::Left { at_ms: AT + 3_000 })
);
assert_eq!(
states.get(&dave.public_key()),
Some(&MemberState::Kicked {
at_ms: AT + 5_000,
actor: carol.public_key(),
})
);
assert!(
survivors
.iter()
.all(|member| matches!(states.get(member), Some(MemberState::Joined { .. }))),
"every chunk seeds its own members"
);
let reversed: Vec<GuestbookRumor> = rumors.iter().rev().cloned().collect();
assert_eq!(
coalesce(&reversed, AT + 8_000, &refounders(&[&carol]), can_kick),
states,
"arrival order cannot change the fold"
);
let observed = BTreeMap::from([
(bob.public_key(), AT + 9_000),
(carol.public_key(), AT + 5_000),
]);
let granted = BTreeSet::from([grace.public_key()]);
let banned = BTreeSet::from([frank.public_key()]);
let banned_at = BTreeMap::from([(frank.public_key(), AT + 8_000)]);
let members = complete_memberlist(&states, &observed, &granted, &banned, &banned_at);
let mut expected = BTreeSet::from([
alice.public_key(),
bob.public_key(),
carol.public_key(),
grace.public_key(),
]);
expected.extend(survivors.iter().copied());
assert_eq!(members, expected);
assert!(
!members.contains(&dave.public_key()),
"a kicked member is out"
);
assert!(
!members.contains(&frank.public_key()),
"a ban wins over a later join"
);
}
#[test]
fn a_kick_or_snapshot_without_authority_is_dropped() {
let moderator = Keys::generate();
let outsider = Keys::generate();
let owner = Keys::generate();
let kicked = Keys::generate();
let uncited = Keys::generate();
let unranked = Keys::generate();
let refounder = Keys::generate();
let impostor = Keys::generate();
let seeded = Keys::generate();
let smuggled = Keys::generate();
let can_kick = |actor: &PublicKey,
target: &PublicKey,
citation: Option<&AuthorityCitation>| {
citation.is_some() && actor == &moderator.public_key() && target != &owner.public_key()
};
let rumors = vec![
publish(
&build_kick(
moderator.public_key(),
&kicked.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
publish(
&build_kick(moderator.public_key(), &uncited.public_key(), None, AT),
&moderator,
),
publish(
&build_kick(
outsider.public_key(),
&unranked.public_key(),
Some(&citation()),
AT,
),
&outsider,
),
publish(
&build_kick(
moderator.public_key(),
&owner.public_key(),
Some(&citation()),
AT,
),
&moderator,
),
];
let states = coalesce(&rumors, AT + 1_000, &BTreeSet::new(), can_kick);
assert_eq!(
states.get(&kicked.public_key()),
Some(&MemberState::Kicked {
at_ms: AT,
actor: moderator.public_key(),
})
);
assert!(
!states.contains_key(&uncited.public_key()),
"a kick cites the Grant it acts under"
);
assert!(
!states.contains_key(&unranked.public_key()),
"a kick needs KICK"
);
assert!(
!states.contains_key(&owner.public_key()),
"nobody kicks the owner"
);
let by_refounder = build_snapshot_chunks(
refounder.public_key(),
&[seeded.public_key()],
[0x77u8; 32],
AT,
)
.remove(0);
let by_impostor = build_snapshot_chunks(
impostor.public_key(),
&[smuggled.public_key()],
[0x88u8; 32],
AT,
)
.remove(0);
for authority in [BTreeSet::new(), refounders(&[&refounder])] {
let states = coalesce(
&[
publish(&by_refounder, &refounder),
publish(&by_impostor, &impostor),
],
AT + 1_000,
&authority,
|_, _, _| true,
);
assert_eq!(
states.contains_key(&seeded.public_key()),
!authority.is_empty(),
"only a known refounder seeds, and there is no owner fallback"
);
assert!(
!states.contains_key(&smuggled.public_key()),
"a foreign snapshot never seeds"
);
}
}
#[test]
fn a_future_entry_a_bad_ms_and_a_malformed_snapshot_are_dropped() {
let member = Keys::generate();
let moderator = Keys::generate();
let target = Keys::generate();
let future = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS + 1),
&member,
);
let horizon = publish(
&build_join(member.public_key(), None, AT + MAX_FUTURE_SKEW_MS),
&member,
);
assert!(
coalesce(&[future], AT, &BTreeSet::new(), |_, _, _| true).is_empty(),
"an entry more than an hour ahead is dropped"
);
assert_eq!(
coalesce(&[horizon], AT, &BTreeSet::new(), |_, _, _| true).len(),
1,
"the horizon itself is skew, not forgery"
);
let bad_ms = build_rumor_secs(
KIND_JOIN_LEAVE,
member.public_key(),
CONTENT_JOIN,
vec![Tag::custom("ms", ["1000"])],
AT / 1000,
);
assert!(matches!(
open(
&smol::block_on(seal_rumor(&bad_ms, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::Stream(StreamError::BadMs))
));
let bad_verb = build_rumor_ms(KIND_JOIN_LEAVE, member.public_key(), "maybe", vec![], AT);
assert!(matches!(
open(
&smol::block_on(seal_rumor(&bad_verb, &group(), &member))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_CONTENT))
));
let ambiguous = build_rumor_ms(
KIND_KICK,
moderator.public_key(),
"",
vec![
Tag::custom(TAG_TARGET, [target.public_key().to_hex()]),
citation_tag(&citation()),
citation_tag(&citation()),
],
AT,
);
assert!(matches!(
open(
&smol::block_on(seal_rumor(&ambiguous, &group(), &moderator))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::DuplicateTag(TAG_CITATION))
));
for fields in [
vec![snapshot_id(), "0".to_owned(), "2".to_owned()],
vec![snapshot_id(), "3".to_owned(), "2".to_owned()],
vec![snapshot_id(), "1".to_owned()],
] {
let rumor = build_rumor_ms(
KIND_SNAPSHOT,
moderator.public_key(),
"[]",
vec![Tag::custom(TAG_SNAP, fields)],
AT,
);
assert!(matches!(
open(
&smol::block_on(seal_rumor(&rumor, &group(), &moderator))
.expect("seals")
.0,
&group()
),
Err(GuestbookError::BadTag(TAG_SNAP))
));
}
}
fn snapshot_id() -> String {
"ab".repeat(32)
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-555
View File
@@ -1,555 +0,0 @@
pub mod pins;
pub mod roles;
use std::cmp::Reverse;
use std::collections::BTreeMap;
use std::fmt;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::{EventId, PublicKey, Tag, UnsignedEvent};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::cord01::build_rumor_secs;
use crate::decode_hex_32;
pub const KIND_CONTROL: u16 = 3308;
const EDITION_LABEL: &[u8] = b"vector-community/v1/edition";
/// Entity types an edition can address.
pub mod vsk {
pub const COMMUNITY_METADATA: &str = "0";
pub const ROLE: &str = "1";
pub const CHANNEL_METADATA: &str = "2";
pub const GRANT: &str = "3";
pub const BANLIST: &str = "4";
pub const INVITE_LIVE: &str = "6";
pub const INVITE_LINKS: &str = "8";
pub const INVITE_REVOKED: &str = "9";
pub const DISSOLVED: &str = "10";
pub const PINS: &str = "11";
}
pub const TAG_SUBKIND: &str = "vsk";
pub const TAG_CITATION: &str = "vac";
const TAG_ENTITY: &str = "eid";
const TAG_VERSION: &str = "ev";
const TAG_PREV: &str = "ep";
#[derive(Debug)]
pub enum EditionError {
BadKind(u16),
BadField(&'static str),
Duplicate(&'static str),
Missing(&'static str),
}
impl fmt::Display for EditionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
EditionError::BadKind(kind) => write!(f, "not an edition kind: {kind}"),
EditionError::BadField(name) => write!(f, "malformed edition field: {name}"),
EditionError::Duplicate(name) => write!(f, "duplicate edition field: {name}"),
EditionError::Missing(name) => write!(f, "missing edition field: {name}"),
}
}
}
impl std::error::Error for EditionError {}
/// A `vac`: the Grant edition an actor claims rank under, pinned by coordinate, version and hash.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct AuthorityCitation {
pub entity: [u8; 32],
pub version: u64,
pub hash: [u8; 32],
}
#[derive(Debug, Clone)]
pub struct ParsedEdition {
pub author: PublicKey,
pub subkind: String,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: String,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
pub struct EditionFields<'a> {
pub author: PublicKey,
pub subkind: &'a str,
pub entity: [u8; 32],
pub version: u64,
pub prev: Option<[u8; 32]>,
pub citation: Option<AuthorityCitation>,
pub content: &'a str,
pub at_secs: u64,
}
fn signing_bytes(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> Vec<u8> {
let mut bytes =
Vec::with_capacity(8 + EDITION_LABEL.len() + 32 + 8 + 1 + 32 + 8 + content.len());
bytes.extend_from_slice(&(EDITION_LABEL.len() as u64).to_be_bytes());
bytes.extend_from_slice(EDITION_LABEL);
bytes.extend_from_slice(entity);
bytes.extend_from_slice(&version.to_be_bytes());
match prev {
Some(prev) => {
bytes.push(1);
bytes.extend_from_slice(prev);
}
None => {
bytes.push(0);
bytes.extend_from_slice(&[0u8; 32]);
}
}
bytes.extend_from_slice(&(content.len() as u64).to_be_bytes());
bytes.extend_from_slice(content);
bytes
}
fn edition_hash(
entity: &[u8; 32],
version: u64,
prev: Option<&[u8; 32]>,
content: &[u8],
) -> [u8; 32] {
Sha256::digest(signing_bytes(entity, version, prev, content)).into()
}
pub fn citation_tag(citation: &AuthorityCitation) -> Tag {
Tag::custom(
TAG_CITATION,
[
HEXLOWER.encode(&citation.entity),
citation.version.to_string(),
HEXLOWER.encode(&citation.hash),
],
)
}
pub fn citation_from(fields: &[String]) -> Option<AuthorityCitation> {
if fields.len() != 4 {
return None;
}
Some(AuthorityCitation {
entity: hex32(&fields[1], TAG_CITATION).ok()?,
version: canonical_decimal(&fields[2])?,
hash: hex32(&fields[3], TAG_CITATION).ok()?,
})
}
pub fn build_edition(fields: EditionFields<'_>) -> UnsignedEvent {
let mut tags = vec![
Tag::custom(TAG_SUBKIND, [fields.subkind]),
Tag::custom(TAG_ENTITY, [HEXLOWER.encode(&fields.entity)]),
Tag::custom(TAG_VERSION, [fields.version.to_string()]),
];
if let Some(prev) = fields.prev {
tags.push(Tag::custom(TAG_PREV, [HEXLOWER.encode(&prev)]));
}
if let Some(citation) = fields.citation {
tags.push(citation_tag(&citation));
}
build_rumor_secs(
KIND_CONTROL,
fields.author,
fields.content,
tags,
fields.at_secs,
)
}
pub fn parse_edition(rumor: &UnsignedEvent) -> Result<ParsedEdition, EditionError> {
let kind = rumor.kind.as_u16();
if kind != KIND_CONTROL {
return Err(EditionError::BadKind(kind));
}
let subkind = value(rumor, TAG_SUBKIND)?
.ok_or(EditionError::Missing(TAG_SUBKIND))?
.to_owned();
if canonical_decimal(&subkind).is_none() {
return Err(EditionError::BadField(TAG_SUBKIND));
}
let entity = hex32(
value(rumor, TAG_ENTITY)?.ok_or(EditionError::Missing(TAG_ENTITY))?,
TAG_ENTITY,
)?;
let version =
canonical_decimal(value(rumor, TAG_VERSION)?.ok_or(EditionError::Missing(TAG_VERSION))?)
.ok_or(EditionError::BadField(TAG_VERSION))?;
let prev = match value(rumor, TAG_PREV)? {
Some(raw) => Some(hex32(raw, TAG_PREV)?),
None => None,
};
let citation = match fields(rumor, TAG_CITATION)? {
Some(fields) => Some(citation_from(fields).ok_or(EditionError::BadField(TAG_CITATION))?),
None => None,
};
let self_hash = edition_hash(&entity, version, prev.as_ref(), rumor.content.as_bytes());
Ok(ParsedEdition {
author: rumor.pubkey,
subkind,
entity,
version,
prev,
citation,
content: rumor.content.clone(),
self_hash,
rumor_id: rumor.id.unwrap_or_else(|| rumor.compute_id()),
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EditionMeta {
pub version: u64,
pub self_hash: [u8; 32],
pub prev: Option<[u8; 32]>,
pub tiebreak_id: EventId,
}
impl From<&ParsedEdition> for EditionMeta {
fn from(edition: &ParsedEdition) -> Self {
Self {
version: edition.version,
self_hash: edition.self_hash,
prev: edition.prev,
tiebreak_id: edition.rumor_id,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
struct FoldResult {
pub head: Option<usize>,
pub gap: bool,
pub anchored: bool,
}
/// The highest version whose chain is intact, given a held floor.
fn fold(editions: &[EditionMeta], floor: u64, floor_hash: Option<&[u8; 32]>) -> FoldResult {
let mut by_version: BTreeMap<u64, usize> = BTreeMap::new();
for (index, edition) in editions.iter().enumerate() {
if edition.version < floor {
continue;
}
match by_version.get(&edition.version) {
Some(&current) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
_ => {
by_version.insert(edition.version, index);
}
}
}
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
return FoldResult::default();
};
let lowest = editions[lowest_index];
let anchored = if floor == 0 {
lowest_version == 1 && lowest.prev.is_none()
} else if lowest_version == floor {
floor_hash == Some(&lowest.self_hash)
} else if lowest_version == floor + 1 {
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
} else {
false
};
let mut head = Some(lowest_index);
let mut gap = !anchored;
let mut previous_version = lowest_version;
let mut previous_hash = lowest.self_hash;
for (&version, &index) in by_version.range(lowest_version + 1..) {
let edition = editions[index];
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
head = Some(index);
previous_version = version;
previous_hash = edition.self_hash;
} else {
gap = true;
break;
}
}
FoldResult {
head,
gap,
anchored,
}
}
/// The highest version overall, ignoring contiguity.
fn bootstrap_head(editions: &[EditionMeta]) -> Option<usize> {
editions
.iter()
.enumerate()
.min_by_key(|(_, edition)| (Reverse(edition.version), edition.tiebreak_id))
.map(|(index, _)| index)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct HeadSelection {
pub head: Option<usize>,
pub gap: bool,
}
/// The head to prefer for one entity, given what this client already committed to.
pub fn fold_head(editions: &[EditionMeta], floor: Option<&EntityHead>) -> HeadSelection {
let Some(floor) = floor else {
return HeadSelection {
head: bootstrap_head(editions),
gap: false,
};
};
let anchored = fold(editions, floor.version, Some(&floor.self_hash));
if anchored.anchored {
return HeadSelection {
head: anchored.head,
gap: anchored.gap,
};
}
if anchored.head.is_none() && !anchored.gap {
return HeadSelection::default();
}
let fork = editions
.iter()
.enumerate()
.filter(|(_, edition)| edition.version == floor.version)
.min_by_key(|(_, edition)| edition.tiebreak_id);
let winner = match fork {
Some((_, edition))
if edition.self_hash != floor.self_hash && edition.tiebreak_id < floor.rumor_id =>
{
edition.self_hash
}
_ => {
return HeadSelection {
head: None,
gap: true,
};
}
};
let refolded = fold(editions, floor.version, Some(&winner));
if refolded.anchored {
HeadSelection {
head: refolded.head,
gap: refolded.gap,
}
} else {
HeadSelection {
head: None,
gap: true,
}
}
}
/// A committed head, and the refuse-downgrade floor a later fold is judged against.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityHead {
pub entity: [u8; 32],
pub version: u64,
pub self_hash: [u8; 32],
pub rumor_id: EventId,
}
impl From<&ParsedEdition> for EntityHead {
fn from(edition: &ParsedEdition) -> Self {
Self {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
}
}
}
/// Every entity's committed head, keyed by coordinate.
pub type Floors = BTreeMap<[u8; 32], EntityHead>;
pub(crate) fn canonical_decimal(raw: &str) -> Option<u64> {
if raw.is_empty() || !raw.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
if raw.len() > 1 && raw.starts_with('0') {
return None;
}
raw.parse().ok()
}
fn hex32(raw: &str, name: &'static str) -> Result<[u8; 32], EditionError> {
decode_hex_32(raw).map_err(|_| EditionError::BadField(name))
}
fn fields<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, EditionError> {
let mut found: Option<&[String]> = None;
for tag in rumor.tags.iter() {
let tag_fields = tag.as_slice();
if tag_fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(EditionError::Duplicate(name));
}
found = Some(tag_fields);
}
Ok(found)
}
fn value<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a str>, EditionError> {
match fields(rumor, name)? {
Some(fields) if fields.len() == 2 => Ok(Some(fields[1].as_str())),
Some(_) => Err(EditionError::BadField(name)),
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn meta(version: u64, prev: Option<[u8; 32]>, hash: u8, tiebreak: u8) -> EditionMeta {
EditionMeta {
version,
self_hash: [hash; 32],
prev,
tiebreak_id: EventId::from_byte_array([tiebreak; 32]),
}
}
fn head(version: u64, hash: u8, rumor: u8) -> EntityHead {
EntityHead {
entity: [0x11; 32],
version,
self_hash: [hash; 32],
rumor_id: EventId::from_byte_array([rumor; 32]),
}
}
#[test]
fn fold_picks_the_head_from_the_chain_and_the_floor() {
let chain = [
meta(1, None, 0xa1, 1),
meta(2, Some([0xa1; 32]), 0xa2, 2),
meta(3, Some([0xa2; 32]), 0xa3, 3),
];
let folded = fold(&chain, 0, None);
assert_eq!(folded.head, Some(2));
assert!(!folded.gap && folded.anchored);
// A missing link stops the walk at the last contiguous edition.
let gapped = fold(&[chain[0], chain[2]], 0, None);
assert_eq!(gapped.head, Some(0));
assert!(gapped.gap && gapped.anchored);
// Everything below the held floor is a stale relay, not a gap.
let stale = fold(&chain[..2], 3, Some(&[0xa3; 32]));
assert_eq!(stale.head, None);
assert!(!stale.gap && !stale.anchored);
// A fork at a version breaks on the lower inner rumor id, and the chain resumes.
let fork = [meta(1, None, 0xb1, 9), meta(1, None, 0xa1, 1)];
assert_eq!(
fold(&fork, 0, None).head,
Some(1),
"the lower rumor id wins"
);
let forked = [fork[0], fork[1], chain[1], chain[2]];
assert_eq!(fold(&forked, 0, None).head, Some(3));
// A re-wrap onto the head we hold is the legitimate case; one whose `prev` no
// longer resolves is a withholding.
let rewrapped = meta(5, Some([0x99; 32]), 0xc5, 5);
assert_eq!(
fold_head(&[rewrapped], Some(&head(4, 0x99, 4))).head,
Some(0)
);
let dangling = meta(5, Some([0x88; 32]), 0xc5, 5);
let refused = fold_head(&[dangling], Some(&head(4, 0x99, 4)));
assert_eq!(refused.head, None);
assert!(refused.gap);
// A bootstrap takes it anyway: a compaction would leave a joiner with nothing.
assert_eq!(bootstrap_head(&[dangling]), Some(0));
assert_eq!(fold_head(&[dangling], None).head, Some(0));
// A fork at the floor's own version converges to the lower rumor id when that is
// genuinely earlier than what we hold, and the chain above it re-anchors.
let forked = [
meta(2, Some([0xa1; 32]), 0xb2, 3),
meta(3, Some([0xb2; 32]), 0xb3, 4),
];
let converged = fold_head(&forked, Some(&head(2, 0xaa, 9)));
assert_eq!(converged.head, Some(1));
assert!(!converged.gap);
// A fork that is not earlier than the held head is refused.
assert_eq!(fold_head(&forked, Some(&head(2, 0xaa, 2))).head, None);
}
#[test]
fn edition_hash_matches_the_cross_client_vector() {
let entity = [0x11u8; 32];
assert_eq!(
HEXLOWER.encode(&edition_hash(&entity, 1, None, b"hello")),
"2daf42e65a6bc259a4c99fac6df754a5d3d92310607cf13e2a1e8c94d42f6303"
);
// The golden vector only exercises the absent-prev encoding, so pin the flag.
let bytes = signing_bytes(&entity, 1, Some(&entity), b"hello");
assert_eq!(bytes[8 + EDITION_LABEL.len() + 32 + 8], 1);
}
}
-819
View File
@@ -1,819 +0,0 @@
use std::fmt;
use chacha20::ChaCha20;
use chacha20::cipher::{KeyIvInit, StreamCipher};
use data_encoding::{BASE64, HEXLOWER};
use hkdf::Hkdf;
use hmac::{Hmac, Mac};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::cord01::{self, OpenedStream, SealForm, resolve_ms_strict};
use crate::cord03::{ChatAction, ChatRumor, KIND_COMMENT, KIND_EDIT, KIND_MESSAGE};
use crate::cord04::canonical_decimal;
use crate::{ChannelId, Epoch, Extra, GroupKey, decode_hex_lower};
pub const PIN_MAX_ENTRIES: usize = 25;
pub const PIN_MAX_CONTENT_BYTES: usize = 32_768;
/// The serialized disclosure: `chacha_key[32] || chacha_nonce[12] || hmac_key[32]`.
pub const MESSAGE_KEYS_BYTES: usize = 76;
const TAG_CHANNEL: &str = "channel";
const TAG_EPOCH: &str = "epoch";
const TAG_TARGET: &str = "e";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PinError {
NotEncryptedSeal,
BadPayload,
Unverifiable,
Unreadable,
TooManyEntries,
Oversize(usize),
Seal(String),
Encode(String),
}
impl fmt::Display for PinError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
PinError::NotEncryptedSeal => write!(f, "pin requires an encrypted seal"),
PinError::BadPayload => write!(f, "the seal payload does not open"),
PinError::Unverifiable => write!(f, "the entry would not verify"),
PinError::Unreadable => {
write!(f, "refusing to publish a pin list this client cannot read")
}
PinError::TooManyEntries => write!(f, "pin list exceeds {PIN_MAX_ENTRIES} entries"),
PinError::Oversize(len) => {
write!(
f,
"pin list content is {len} bytes (cap {PIN_MAX_CONTENT_BYTES})"
)
}
PinError::Seal(error) => write!(f, "seal: {error}"),
PinError::Encode(error) => write!(f, "encode: {error}"),
}
}
}
impl std::error::Error for PinError {}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct MessageKeys {
chacha_key: [u8; 32],
chacha_nonce: [u8; 12],
hmac_key: [u8; 32],
}
impl MessageKeys {
pub fn to_hex(&self) -> String {
let mut packed = [0u8; MESSAGE_KEYS_BYTES];
packed[0..32].copy_from_slice(&self.chacha_key);
packed[32..44].copy_from_slice(&self.chacha_nonce);
packed[44..76].copy_from_slice(&self.hmac_key);
HEXLOWER.encode(&packed)
}
pub fn from_hex(value: &str) -> Option<Self> {
let bytes = decode_hex_lower::<MESSAGE_KEYS_BYTES>(value).ok()?;
Some(Self {
chacha_key: bytes[0..32].try_into().ok()?,
chacha_nonce: bytes[32..44].try_into().ok()?,
hmac_key: bytes[44..76].try_into().ok()?,
})
}
fn derive(conversation_key: &[u8; 32], nonce: &[u8]) -> Option<Self> {
let hkdf = Hkdf::<Sha256>::from_prk(conversation_key).ok()?;
let mut key_material = [0u8; MESSAGE_KEYS_BYTES];
hkdf.expand(nonce, &mut key_material).ok()?;
Some(Self {
chacha_key: key_material[0..32].try_into().ok()?,
chacha_nonce: key_material[32..44].try_into().ok()?,
hmac_key: key_material[44..76].try_into().ok()?,
})
}
}
struct Payload {
nonce: [u8; 32],
ciphertext: Vec<u8>,
mac: [u8; 32],
}
fn decode_payload(payload: &str) -> Option<Payload> {
let data = BASE64.decode(payload.as_bytes()).ok()?;
if data.len() < 99 || data[0] != 2 {
return None;
}
let mac_at = data.len() - 32;
Some(Payload {
nonce: data[1..33].try_into().ok()?,
ciphertext: data[33..mac_at].to_vec(),
mac: data[mac_at..].try_into().ok()?,
})
}
fn disclose_keys(payload: &str, conversation_key: &[u8; 32]) -> Option<MessageKeys> {
let decoded = decode_payload(payload)?;
MessageKeys::derive(conversation_key, &decoded.nonce)
}
fn open_payload(payload: &str, keys: &MessageKeys) -> Option<String> {
let decoded = decode_payload(payload)?;
let mut mac = Hmac::<Sha256>::new_from_slice(&keys.hmac_key).ok()?;
mac.update(&decoded.nonce);
mac.update(&decoded.ciphertext);
mac.verify_slice(&decoded.mac).ok()?;
let mut padded = decoded.ciphertext;
let mut cipher = ChaCha20::new((&keys.chacha_key).into(), (&keys.chacha_nonce).into());
cipher.apply_keystream(&mut padded);
unpad(&padded)
}
fn unpad(padded: &[u8]) -> Option<String> {
let (len, prefix) = plaintext_length(padded)?;
let unpadded = padded.get(prefix..prefix.checked_add(len)?)?;
if len < 1 || padded.len() != prefix.checked_add(padded_len(len)?)? {
return None;
}
String::from_utf8(unpadded.to_vec()).ok()
}
fn plaintext_length(padded: &[u8]) -> Option<(usize, usize)> {
let short = u16::from_be_bytes(padded.get(..2)?.try_into().ok()?);
if short != 0 {
return Some((short as usize, 2));
}
let long = u32::from_be_bytes(padded.get(2..6)?.try_into().ok()?);
if long < 65_536 {
return None;
}
Some((long as usize, 6))
}
fn padded_len(len: usize) -> Option<usize> {
if len < 1 {
return None;
}
if len <= 32 {
return Some(32);
}
let next_power = 1usize.checked_shl(usize::BITS - (len - 1).leading_zeros())?;
let chunk = if next_power <= 256 {
32
} else {
next_power / 8
};
Some(chunk * ((len - 1) / chunk + 1))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEditBundle {
pub seal: Event,
pub keys: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct PinEntry {
pub seal: Event,
pub keys: String,
/// An unverifiable locator hint; a mismatch is expected and never fatal.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub wrap: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub edit: Option<PinEditBundle>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditedContent {
pub content: String,
pub at_ms: u64,
}
#[derive(Debug, Clone)]
pub struct VerifiedPin {
pub rumor_id: EventId,
pub author: PublicKey,
pub kind: u16,
pub content: String,
pub tags: Tags,
pub epoch: Epoch,
pub at_ms: u64,
pub created_at: u64,
pub wrap: Option<String>,
pub edited: Option<EditedContent>,
pub entry: PinEntry,
}
#[derive(Debug, Clone, Default)]
pub struct ReadPinList {
pub entries: Vec<PinEntry>,
pub sealed: bool,
}
pub fn build_entry(
opened: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> Result<PinEntry, PinError> {
let keys = disclosed_keys(opened, group)?;
let entry = PinEntry {
seal: opened.seal.clone(),
keys: keys.to_hex(),
wrap: Some(opened.wrapper_id.to_hex()),
edit: None,
extra: Extra::default(),
};
if verify_entry(&entry, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(entry)
}
pub fn build_edit_bundle(
edit: &OpenedStream,
group: &GroupKey,
original: &VerifiedPin,
channel: &ChannelId,
) -> Result<PinEditBundle, PinError> {
let bundle = PinEditBundle {
seal: edit.seal.clone(),
keys: disclosed_keys(edit, group)?.to_hex(),
};
if verify_edit_bundle(&bundle, &original.author, &original.rumor_id, channel).is_none() {
return Err(PinError::Unverifiable);
}
Ok(bundle)
}
pub fn with_proven_edit(
entry: &PinEntry,
edit: &OpenedStream,
group: &GroupKey,
channel: &ChannelId,
) -> PinEntry {
let Some(original) = verify_entry(entry, channel) else {
return entry.clone();
};
let Ok(bundle) = build_edit_bundle(edit, group, &original, channel) else {
return entry.clone();
};
let mut refreshed = entry.clone();
refreshed.edit = Some(bundle);
refreshed
}
fn disclosed_keys(opened: &OpenedStream, group: &GroupKey) -> Result<MessageKeys, PinError> {
if opened.seal_form != SealForm::Encrypted {
return Err(PinError::NotEncryptedSeal);
}
let conversation: [u8; 32] = group
.conversation()
.as_bytes()
.try_into()
.map_err(|_| PinError::BadPayload)?;
let keys = disclose_keys(&opened.seal.content, &conversation).ok_or(PinError::BadPayload)?;
if open_payload(&opened.seal.content, &keys).is_none() {
return Err(PinError::BadPayload);
}
Ok(keys)
}
pub fn verify_entry(entry: &PinEntry, channel: &ChannelId) -> Option<VerifiedPin> {
let seal = &entry.seal;
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&entry.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
// NIP-59's impersonation check: the renderer shows the rumor's fields.
if rumor.pubkey != seal.pubkey {
return None;
}
let kind = rumor.kind.as_u16();
if kind != KIND_MESSAGE && kind != KIND_COMMENT {
return None;
}
// CORD-01's binding, restated: a keyholder must not pin a message into another Channel's list.
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
let epoch = Epoch(canonical_decimal(tag_value(&rumor, TAG_EPOCH)?)?);
// Recomputed from the decrypted bytes; a claimed `id` is never trusted.
rumor.verify_id().ok()?;
let rumor_id = rumor.compute_id();
let edited = entry
.edit
.as_ref()
.and_then(|bundle| verify_edit_bundle(bundle, &rumor.pubkey, &rumor_id, channel));
Some(VerifiedPin {
author: rumor.pubkey,
content: edited
.as_ref()
.map_or_else(|| rumor.content.clone(), |edited| edited.content.clone()),
epoch,
at_ms: resolve_ms_strict(&rumor).ok()?,
created_at: rumor.created_at.as_secs(),
tags: rumor.tags.clone(),
wrap: entry.wrap.clone(),
edited,
entry: entry.clone(),
kind,
rumor_id,
})
}
fn verify_edit_bundle(
bundle: &PinEditBundle,
original_author: &PublicKey,
original_id: &EventId,
channel: &ChannelId,
) -> Option<EditedContent> {
let seal = &bundle.seal;
// Checkable before any crypto: nobody else may revise another member's words.
if seal.kind.as_u16() != cord01::KIND_SEAL_ENCRYPTED || seal.pubkey != *original_author {
return None;
}
if seal.verify().is_err() {
return None;
}
let keys = MessageKeys::from_hex(&bundle.keys)?;
let plaintext = open_payload(&seal.content, &keys)?;
let rumor = UnsignedEvent::from_json(&plaintext).ok()?;
if rumor.pubkey != seal.pubkey || rumor.kind.as_u16() != KIND_EDIT {
return None;
}
if tag_value(&rumor, TAG_CHANNEL)? != channel.to_hex() {
return None;
}
if tag_value(&rumor, TAG_TARGET)? != original_id.to_hex() {
return None;
}
rumor.verify_id().ok()?;
Some(EditedContent {
content: rumor.content.clone(),
at_ms: resolve_ms_strict(&rumor).ok()?,
})
}
fn tag_value<'a>(rumor: &'a UnsignedEvent, name: &str) -> Option<&'a str> {
rumor
.tags
.iter()
.find(|tag| tag.as_slice().first().map(String::as_str) == Some(name))
.and_then(|tag| tag.as_slice().get(1))
.map(String::as_str)
}
#[derive(Serialize, Deserialize)]
struct PlainForm {
entries: Vec<PinEntry>,
}
pub fn publishable(
read: &ReadPinList,
private: bool,
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if read.sealed {
return Err(PinError::Unreadable);
}
if private {
serialize_sealed(&read.entries, group, epoch)
} else {
serialize_public(&read.entries)
}
}
fn serialize_public(entries: &[PinEntry]) -> Result<String, PinError> {
let content = encode_form(entries)?;
check_caps(entries.len(), &content)?;
Ok(content)
}
fn serialize_sealed(
entries: &[PinEntry],
group: &GroupKey,
epoch: Epoch,
) -> Result<String, PinError> {
if entries.len() > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
let inner = encode_form(entries)?;
let sealed = cord01::seal_bytes(group.conversation(), inner.as_bytes())
.map_err(|error| PinError::Seal(error.to_string()))?;
let content = serde_json::json!({ "epoch": epoch.to_string(), "sealed": sealed }).to_string();
check_caps(entries.len(), &content)?;
Ok(content)
}
fn encode_form(entries: &[PinEntry]) -> Result<String, PinError> {
serde_json::to_string(&PlainForm {
entries: entries.to_vec(),
})
.map_err(|error| PinError::Encode(error.to_string()))
}
fn check_caps(count: usize, content: &str) -> Result<(), PinError> {
if count > PIN_MAX_ENTRIES {
return Err(PinError::TooManyEntries);
}
if content.len() > PIN_MAX_CONTENT_BYTES {
return Err(PinError::Oversize(content.len()));
}
Ok(())
}
pub fn read_list(content: &str, unseal: impl Fn(Epoch) -> Option<GroupKey>) -> ReadPinList {
const EMPTY: ReadPinList = ReadPinList {
entries: Vec::new(),
sealed: false,
};
if content.len() > PIN_MAX_CONTENT_BYTES {
return EMPTY;
}
let Ok(value) = serde_json::from_str::<serde_json::Value>(content) else {
return EMPTY;
};
if value.get("entries").is_some() {
return match serde_json::from_value::<PlainForm>(value) {
Ok(form) if form.entries.len() <= PIN_MAX_ENTRIES => ReadPinList {
entries: form.entries,
sealed: false,
},
_ => EMPTY,
};
}
let (Some(epoch), Some(sealed)) = (
value.get("epoch").and_then(serde_json::Value::as_str),
value.get("sealed").and_then(serde_json::Value::as_str),
) else {
return EMPTY;
};
let Some(epoch) = canonical_decimal(epoch) else {
return EMPTY;
};
let Some(group) = unseal(Epoch(epoch)) else {
return ReadPinList {
sealed: true,
..EMPTY
};
};
let Ok(inner) = cord01::open_bytes(group.conversation(), sealed) else {
return EMPTY;
};
let Ok(form) = serde_json::from_slice::<PlainForm>(&inner) else {
return EMPTY;
};
if form.entries.len() > PIN_MAX_ENTRIES {
return EMPTY;
}
ReadPinList {
entries: form.entries,
sealed: false,
}
}
pub fn killed_by(pin: &VerifiedPin, delete: &ChatRumor) -> bool {
delete.author == pin.author
&& matches!(&delete.action, ChatAction::Delete { target, .. } if *target == pin.rumor_id)
}
#[cfg(test)]
mod tests {
use nostr::nips::nip44::v2::{self, ConversationKey};
use super::*;
use crate::cord03::{ChatRumor, build_delete, build_edit, build_message, open, seal_rumor};
use crate::derive::channel_group_key;
const AT_MS: u64 = 1_700_000_000_000;
const SECRET: [u8; 32] = [0x21u8; 32];
fn channel() -> ChannelId {
ChannelId::from_bytes([0xabu8; 32])
}
fn group() -> GroupKey {
channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives")
}
fn conversation() -> ConversationKey {
*group().conversation()
}
/// A real message through the production seal/open pipeline, as a pinner sees it.
fn sealed_message(author: &Keys, text: &str, at_ms: u64) -> (OpenedStream, ChatRumor) {
let rumor = build_message(
author.public_key(),
&channel(),
Epoch(0),
text,
None,
at_ms,
None,
);
let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group(), author, false)).expect("seals");
open(&wrap, &group(), &channel(), Epoch(0)).expect("opens")
}
fn entry_for(author: &Keys, text: &str) -> (PinEntry, OpenedStream) {
let (opened, _) = sealed_message(author, text, AT_MS);
let entry = build_entry(&opened, &group(), &channel()).expect("builds");
(entry, opened)
}
fn some(entries: Vec<PinEntry>) -> ReadPinList {
ReadPinList {
entries,
sealed: false,
}
}
/// The load-bearing primitive: the reproduction must open what nostr's own
/// encryption produced, through the disclosure alone.
#[test]
fn a_disclosure_opens_its_message_and_nothing_else() {
let nonce = [0x5au8; 32];
let disclosure =
MessageKeys::derive(conversation().as_bytes().try_into().expect("32"), &nonce)
.expect("derives");
for text in ["a", "hello world", &"padding boundary ".repeat(40)] {
let raw = v2::encrypt_to_bytes_with_nonce(&conversation(), text.as_bytes(), nonce)
.expect("encrypts");
let payload = BASE64.encode(&raw);
assert_eq!(open_payload(&payload, &disclosure).as_deref(), Some(text));
}
// Another nonce discloses different keys, which open nothing else.
let other = v2::encrypt_to_bytes_with_nonce(&conversation(), b"second", [0x99u8; 32])
.expect("encrypts");
assert!(open_payload(&BASE64.encode(&other), &disclosure).is_none());
let hex = disclosure.to_hex();
assert_eq!(
MessageKeys::from_hex(&hex).map(|keys| keys.to_hex()),
Some(hex.clone())
);
assert!(MessageKeys::from_hex(&hex.to_uppercase()).is_none());
}
#[test]
fn a_built_entry_proves_its_author_and_cannot_cross_channels() {
let author = Keys::generate();
let (entry, opened) = entry_for(&author, "pin me");
let verified = verify_entry(&entry, &channel()).expect("verifies");
assert_eq!(verified.author, author.public_key());
assert_eq!(verified.content, "pin me");
assert_eq!(verified.rumor_id, opened.rumor_id);
assert_eq!(verified.at_ms, AT_MS);
assert_eq!(verified.epoch, Epoch(0));
// A keyholder must not be able to pin channel X's message into Y's list.
let foreign = ChannelId::from_bytes([0xcdu8; 32]);
assert!(verify_entry(&entry, &foreign).is_none());
// Tampered keys and a re-signed seal both fail.
let mut bad_keys = entry.clone();
bad_keys.keys = format!("00{}", &entry.keys[2..]);
assert!(verify_entry(&bad_keys, &channel()).is_none());
let mut forged = entry.clone();
forged.seal.pubkey = Keys::generate().public_key();
assert!(verify_entry(&forged, &channel()).is_none());
// A rumor carrying a claimed id that is not its own is refused.
let plaintext = cord01::open_bytes(&conversation(), &opened.seal.content).expect("opens");
let mut value: serde_json::Value = serde_json::from_slice(&plaintext).expect("json");
value["id"] = serde_json::Value::String("00".repeat(32));
let raw = v2::encrypt_to_bytes_with_nonce(
&conversation(),
value.to_string().as_bytes(),
[0x11u8; 32],
)
.expect("encrypts");
let content = BASE64.encode(&raw);
let seal = EventBuilder::new(Kind::Custom(cord01::KIND_SEAL_ENCRYPTED), &content)
.custom_created_at(opened.seal.created_at)
.finalize(&author)
.expect("signs");
let lying = PinEntry {
keys: disclose_keys(&content, conversation().as_bytes().try_into().expect("32"))
.expect("discloses")
.to_hex(),
seal,
wrap: None,
edit: None,
extra: Extra::default(),
};
assert!(verify_entry(&lying, &channel()).is_none());
}
#[test]
fn a_proven_edit_replaces_the_words_and_a_stranger_cannot_revise() {
let author = Keys::generate();
let (entry, original) = entry_for(&author, "teh typo");
let edit = build_edit(
author.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"the typo, fixed",
AT_MS + 5_000,
None,
);
let (wrap, _) = smol::block_on(seal_rumor(&edit, &group(), &author, false)).expect("seals");
let (edit_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let refreshed = with_proven_edit(&entry, &edit_opened, &group(), &channel());
let verified = verify_entry(&refreshed, &channel()).expect("verifies");
assert_eq!(verified.content, "the typo, fixed");
assert_eq!(verified.edited.expect("edited").at_ms, AT_MS + 5_000);
// A stranger's edit of the same message never attaches.
let stranger = Keys::generate();
let hijack = build_edit(
stranger.public_key(),
&channel(),
Epoch(0),
original.rumor_id,
"hijacked",
AT_MS + 6_000,
None,
);
let (wrap, _) =
smol::block_on(seal_rumor(&hijack, &group(), &stranger, false)).expect("seals");
let (hijack_opened, _) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
let unchanged = with_proven_edit(&entry, &hijack_opened, &group(), &channel());
assert!(unchanged.edit.is_none());
}
#[test]
fn both_list_forms_round_trip_and_obey_their_caps() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "hello");
let public =
publishable(&some(vec![entry.clone()]), false, &group(), Epoch(0)).expect("publishes");
let read = read_list(&public, |_| None);
assert!(!read.sealed);
assert_eq!(read.entries.len(), 1);
assert!(verify_entry(&read.entries[0], &channel()).is_some());
// A sealed list stays dark without its key, lights with it, and a wrong
// key reads empty rather than panicking.
let at_epoch_4 = channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives");
let sealed = publishable(&some(vec![entry.clone()]), true, &at_epoch_4, Epoch(4))
.expect("publishes");
let dark = read_list(&sealed, |_| None);
assert!(dark.sealed && dark.entries.is_empty());
let lit = read_list(&sealed, |epoch| {
(epoch == Epoch(4))
.then(|| channel_group_key(&SECRET, &channel(), Epoch(4)).expect("derives"))
});
assert!(!lit.sealed);
assert!(verify_entry(&lit.entries[0], &channel()).is_some());
assert!(read_list(&sealed, |_| Some(group())).entries.is_empty());
// 26 entries: the writer refuses, and a hand-built violating edition
// reads as empty rather than forking the chain.
let many = vec![entry; PIN_MAX_ENTRIES + 1];
assert_eq!(
publishable(&some(many.clone()), false, &group(), Epoch(0)),
Err(PinError::TooManyEntries)
);
let violating = serde_json::json!({ "entries": many }).to_string();
assert!(read_list(&violating, |_| None).entries.is_empty());
// Garbage never panics and never reads as a list.
for bad in [
"",
"not json",
"[]",
"42",
r#"{"entries": 7}"#,
r#"{"epoch":"04","sealed":"y"}"#,
] {
let read = read_list(bad, |_| None);
assert!(read.entries.is_empty() && !read.sealed, "{bad}");
}
}
#[test]
fn a_dark_list_is_never_reformed_and_only_the_author_kills_a_pin() {
let author = Keys::generate();
let (entry, _) = entry_for(&author, "delete me later");
let dark = ReadPinList {
entries: vec![entry.clone()],
sealed: true,
};
assert_eq!(
publishable(&dark, false, &group(), Epoch(0)),
Err(PinError::Unreadable)
);
let verified = verify_entry(&entry, &channel()).expect("verifies");
for author_keys in [&author, &Keys::generate()] {
let delete = build_delete(
author_keys.public_key(),
&channel(),
Epoch(0),
verified.rumor_id,
Some(KIND_MESSAGE),
None,
AT_MS + 1_000,
);
let (wrap, _) =
smol::block_on(seal_rumor(&delete, &group(), author_keys, false)).expect("seals");
let (_, rumor) = open(&wrap, &group(), &channel(), Epoch(0)).expect("opens");
assert_eq!(
killed_by(&verified, &rumor),
author_keys.public_key() == author.public_key()
);
}
}
}
File diff suppressed because it is too large Load Diff
-977
View File
@@ -1,977 +0,0 @@
use std::collections::BTreeMap;
use std::collections::btree_map::Entry;
use std::fmt;
use data_encoding::BASE64URL_NOPAD;
use nostr::nips::nip01::Coordinate;
use nostr::nips::nip19::{Nip19, Nip19Coordinate};
use nostr::nips::nip44::v2::ConversationKey;
use nostr::nips::nip59::{GiftWrapBuilder, UnwrappedGift};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord01::{self, NIP44_MAX_PLAINTEXT, StreamError};
use crate::cord02::{ImageRef, MAX_RELAYS};
use crate::cord04::{TAG_SUBKIND, vsk};
use crate::derive::{TOKEN_LEN, verify_community_id};
use crate::utils::{canonical, union};
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
pub const KIND_BUNDLE: u16 = 33301;
pub const KIND_INVITE_LIST: u16 = 13303;
pub const KIND_DIRECT_INVITE: u16 = 3313;
pub const FRAGMENT_VERSION: u8 = 4;
pub const MAX_BUNDLE_CHANNELS: usize = 256;
pub const MAX_BOOTSTRAP_RELAYS: usize = 3;
pub const MAX_BUNDLE_EPOCH: u64 = 1 << 40;
pub const MAX_INVITE_ENTRIES: usize = 64;
const FLAG_STOCK_SET: u8 = 0x01;
const INVITE_PATH: &str = "/invite/";
const TAG_IDENTIFIER: &str = "d";
const TAG_EXPIRATION: &str = "expiration";
const RELAY_DICT: [&str; 4] = [
"wss://jskitty.com/nostr",
"wss://asia.vectorapp.io/nostr",
"wss://relay.ditto.pub",
"wss://relay.dreamith.to",
];
#[derive(Debug)]
pub enum InviteError {
Stream(StreamError),
Json(String),
BadHex(&'static str),
TooManyChannels(usize),
TooManyInvites(usize),
Oversize(usize),
Kind(u16),
EpochTooLarge(u64),
OwnerMismatch,
BadFragment(&'static str),
BadVersion(u8),
BadLink(&'static str),
BadEvent(&'static str),
Crypto(String),
}
impl fmt::Display for InviteError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InviteError::Stream(error) => write!(f, "stream: {error}"),
InviteError::Json(error) => write!(f, "json: {error}"),
InviteError::BadHex(field) => write!(f, "{field} is not 32-byte lowercase hex"),
InviteError::TooManyChannels(count) => {
write!(
f,
"bundle carries {count} channels (cap {MAX_BUNDLE_CHANNELS})"
)
}
InviteError::TooManyInvites(count) => {
write!(
f,
"invite list carries {count} entries (cap {MAX_INVITE_ENTRIES})"
)
}
InviteError::Oversize(len) => {
write!(f, "invite list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})")
}
InviteError::Kind(kind) => write!(f, "not an invite list kind: {kind}"),
InviteError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"),
InviteError::OwnerMismatch => {
write!(f, "bundle owner does not reproduce its community_id")
}
InviteError::BadFragment(why) => write!(f, "bad invite fragment: {why}"),
InviteError::BadVersion(version) => {
write!(f, "unsupported invite fragment version {version}")
}
InviteError::BadLink(why) => write!(f, "bad invite link: {why}"),
InviteError::BadEvent(why) => write!(f, "bad invite bundle event: {why}"),
InviteError::Crypto(error) => write!(f, "crypto: {error}"),
}
}
}
impl std::error::Error for InviteError {}
impl From<StreamError> for InviteError {
fn from(error: StreamError) -> Self {
InviteError::Stream(error)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ChannelGrant {
pub id: ChannelId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
pub epoch: Epoch,
#[serde(default)]
pub name: String,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityInvite {
pub community_id: CommunityId,
pub owner: PublicKey,
pub owner_salt: String,
pub community_root: String,
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelGrant>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub relays: Vec<String>,
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub icon: Option<ImageRef>,
/// Unix **ms**: past it the preview still renders, joining refuses.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub creator_npub: Option<PublicKey>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
#[serde(flatten)]
pub extra: Extra,
}
impl CommunityInvite {
pub fn from_bundle_json(json: &str) -> Result<Self, InviteError> {
let mut invite: Self =
serde_json::from_str(json).map_err(|error| InviteError::Json(error.to_string()))?;
if invite.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(invite.channels.len()));
}
invite.relays.truncate(MAX_RELAYS);
invite.validate()?;
Ok(invite)
}
pub fn validate(&self) -> Result<(), InviteError> {
if self.channels.len() > MAX_BUNDLE_CHANNELS {
return Err(InviteError::TooManyChannels(self.channels.len()));
}
for epoch in std::iter::once(self.root_epoch).chain(self.channels.iter().map(|c| c.epoch)) {
if epoch.0 > MAX_BUNDLE_EPOCH {
return Err(InviteError::EpochTooLarge(epoch.0));
}
}
let owner_salt = hex32(&self.owner_salt, "owner_salt")?;
hex32(&self.community_root, "community_root")?;
for channel in &self.channels {
if let Some(key) = &channel.key {
hex32(key, "channel key")?;
}
}
if !verify_community_id(&self.community_id, &self.owner.to_bytes(), &owner_salt) {
return Err(InviteError::OwnerMismatch);
}
Ok(())
}
pub fn expired(&self, now_ms: u64) -> bool {
self.expires_at.is_some_and(|expires| now_ms > expires)
}
}
#[derive(Debug, Clone)]
pub enum BundleState {
Live(Box<CommunityInvite>),
Revoked,
}
pub fn build_bundle_event(
link_signer: &Keys,
invite: &CommunityInvite,
bundle_key: &[u8; 32],
) -> Result<Event, InviteError> {
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let content = seal_bundle(bundle_key, &json)?;
EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([empty_identifier(), subkind_tag(vsk::INVITE_LIVE)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn build_revocation(link_signer: &Keys) -> Result<Event, InviteError> {
EventBuilder::new(Kind::Custom(KIND_BUNDLE), "")
.tags([empty_identifier(), subkind_tag(vsk::INVITE_REVOKED)])
.finalize(link_signer)
.map_err(crypto_error)
}
pub fn parse_bundle_event(
event: &Event,
expected_signer: &PublicKey,
bundle_key: &[u8; 32],
) -> Result<BundleState, InviteError> {
if event.kind.as_u16() != KIND_BUNDLE {
return Err(InviteError::BadEvent("wrong kind"));
}
if event.pubkey != *expected_signer {
return Err(InviteError::BadEvent("author is not the link signer"));
}
if first_tag(event, TAG_IDENTIFIER).is_some_and(|identifier| !identifier.is_empty()) {
return Err(InviteError::BadEvent(
"bundle is not at the link's coordinate",
));
}
event
.verify()
.map_err(|_| InviteError::BadEvent("signature invalid"))?;
match first_tag(event, TAG_SUBKIND).as_deref() {
Some(vsk::INVITE_REVOKED) => return Ok(BundleState::Revoked),
Some(vsk::INVITE_LIVE) => {}
_ => return Err(InviteError::BadEvent("unknown or missing bundle marker")),
}
let json = open_bundle(bundle_key, &event.content)?;
Ok(BundleState::Live(Box::new(
CommunityInvite::from_bundle_json(&json)?,
)))
}
pub fn stock_relays() -> Vec<String> {
RELAY_DICT.iter().map(|relay| relay.to_string()).collect()
}
pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result<String, InviteError> {
let stock = relays == RELAY_DICT;
let mut bytes = Vec::with_capacity(2 + TOKEN_LEN + relays.len() * 8);
bytes.push(FRAGMENT_VERSION);
if stock {
bytes.push(FLAG_STOCK_SET);
} else {
bytes.push(0x00);
let bounded = &relays[..relays.len().min(MAX_BOOTSTRAP_RELAYS)];
bytes.push(bounded.len() as u8);
for relay in bounded {
match dict_id(relay) {
Some(id) => bytes.push(id),
None => {
let (lead, literal) = match relay.strip_prefix("wss://") {
Some(host) => (0x00, host),
None => (0xff, relay.as_str()),
};
if literal.len() > u8::MAX as usize {
return Err(InviteError::BadFragment("relay too long"));
}
bytes.extend_from_slice(&[lead, literal.len() as u8]);
bytes.extend_from_slice(literal.as_bytes());
}
}
}
}
bytes.extend_from_slice(token);
Ok(BASE64URL_NOPAD.encode(&bytes))
}
pub fn decode_fragment(fragment: &str) -> Result<([u8; TOKEN_LEN], Vec<String>), InviteError> {
let bytes = BASE64URL_NOPAD
.decode(fragment.trim().as_bytes())
.map_err(|_| InviteError::BadFragment("not base64url"))?;
let version = *bytes.first().ok_or(InviteError::BadFragment("truncated"))?;
if version != FRAGMENT_VERSION {
return Err(InviteError::BadVersion(version));
}
let flags = *bytes.get(1).ok_or(InviteError::BadFragment("truncated"))?;
let mut offset = 2;
let mut relays = Vec::new();
if flags & FLAG_STOCK_SET != 0 {
relays = stock_relays();
} else {
let count = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
if count > MAX_BOOTSTRAP_RELAYS {
return Err(InviteError::BadFragment("too many bootstrap relays"));
}
for _ in 0..count {
let lead = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))?;
offset += 1;
if (1..=254).contains(&lead) {
if let Some(url) = dict_url(lead) {
relays.push(url.to_string());
}
continue;
}
let len = *bytes
.get(offset)
.ok_or(InviteError::BadFragment("truncated"))? as usize;
offset += 1;
let end = offset
.checked_add(len)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
let text = std::str::from_utf8(raw)
.map_err(|_| InviteError::BadFragment("relay is not utf8"))?;
relays.push(match lead {
0x00 => format!("wss://{text}"),
0xff => text.to_string(),
_ => return Err(InviteError::BadFragment("unknown relay lead byte")),
});
offset = end;
}
}
let end = offset
.checked_add(TOKEN_LEN)
.ok_or(InviteError::BadFragment("truncated"))?;
let raw = bytes
.get(offset..end)
.ok_or(InviteError::BadFragment("truncated"))?;
if end != bytes.len() {
return Err(InviteError::BadFragment("trailing bytes"));
}
let mut token = [0u8; TOKEN_LEN];
token.copy_from_slice(raw);
Ok((token, relays))
}
pub fn bundle_naddr(link_signer: &PublicKey) -> Result<String, InviteError> {
let coordinate = Coordinate {
kind: Kind::Custom(KIND_BUNDLE),
public_key: *link_signer,
identifier: String::new(),
};
Nip19::Coordinate(Nip19Coordinate {
coordinate,
relays: Vec::new(),
})
.to_bech32()
.map_err(|_| InviteError::BadLink("invalid naddr"))
}
pub fn build_invite_url(
base: &str,
link_signer: &PublicKey,
token: &[u8; TOKEN_LEN],
relays: &[String],
) -> Result<String, InviteError> {
let naddr = bundle_naddr(link_signer)?;
let fragment = encode_fragment(token, relays)?;
Ok(format!(
"{}{INVITE_PATH}{naddr}#{fragment}",
base.trim_end_matches('/')
))
}
#[derive(Debug, Clone)]
pub struct ParsedInviteLink {
/// The bundle coordinate's author.
pub link_signer: PublicKey,
pub token: [u8; TOKEN_LEN],
pub bootstrap_relays: Vec<String>,
/// The bare naddr as it appeared in the link, for the fetch.
pub naddr: String,
}
pub fn parse_link(input: &str) -> Result<ParsedInviteLink, InviteError> {
let (locator, fragment) = input
.trim()
.split_once('#')
.ok_or(InviteError::BadLink("no fragment"))?;
if fragment.is_empty() {
return Err(InviteError::BadLink("empty fragment"));
}
let naddr = match locator.find(INVITE_PATH) {
Some(index) => locator[index + INVITE_PATH.len()..].trim_end_matches('/'),
None => locator.trim_start_matches("nostr:"),
};
let link_signer = signer_from_naddr(naddr)?;
let (token, bootstrap_relays) = decode_fragment(fragment)?;
Ok(ParsedInviteLink {
link_signer,
token,
bootstrap_relays,
naddr: naddr.to_string(),
})
}
pub async fn build_direct_invite<S>(
inviter: &S,
recipient: &PublicKey,
invite: &CommunityInvite,
) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44,
{
invite.validate()?;
let json = serde_json::to_string(invite).map_err(json_error)?;
let author = inviter.get_public_key_async().await.map_err(crypto_error)?;
let rumor = EventBuilder::new(Kind::Custom(KIND_DIRECT_INVITE), json).finalize_unsigned(author);
let mut tags = vec![Tag::custom("k", [KIND_DIRECT_INVITE.to_string()])];
if let Some(expires_at) = invite.expires_at {
tags.push(Tag::custom(
TAG_EXPIRATION,
[(expires_at / 1000).to_string()],
));
}
GiftWrapBuilder::new(*recipient, rumor)
.extra_tags(tags)
.finalize_async(inviter)
.await
.map_err(crypto_error)
}
/// The NIP-59 unwrap is `Sized`-bounded in the SDK, so this stays `Sized` too.
pub async fn unwrap_direct_invite<S>(
wrap: &Event,
recipient: &S,
) -> Result<(PublicKey, CommunityInvite), InviteError>
where
S: AsyncNip44,
{
let unwrapped = UnwrappedGift::from_gift_wrap_async(recipient, wrap)
.await
.map_err(crypto_error)?;
if unwrapped.rumor.kind.as_u16() != KIND_DIRECT_INVITE {
return Err(InviteError::BadEvent("rumor is not a direct invite"));
}
Ok((
unwrapped.sender,
CommunityInvite::from_bundle_json(&unwrapped.rumor.content)?,
))
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteEntry {
/// The link's unlock secret, and its merge key.
pub token: String,
/// The `link_signer` secret: refreshing or retiring the bundle needs it.
pub signer_sk: String,
pub community_id: CommunityId,
pub url: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub label: Option<String>,
pub created_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub expires_at: Option<u64>,
#[serde(flatten)]
pub extra: Extra,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct InviteTombstone {
pub token: String,
pub community_id: CommunityId,
#[serde(flatten)]
pub extra: Extra,
}
/// A creator's own link bookkeeping.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct InviteList {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub entries: Vec<InviteEntry>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tombstones: Vec<InviteTombstone>,
#[serde(flatten)]
pub extra: Extra,
}
impl InviteList {
/// A tombstone beats an entry terminally, so a stale device can never resurrect a revoked link.
pub fn is_live(&self, token: &str) -> bool {
self.entries.iter().any(|entry| entry.token == token)
&& !self
.tombstones
.iter()
.any(|tombstone| tombstone.token == token)
}
pub fn fits(&self) -> Result<(), InviteError> {
if self.entries.len() > MAX_INVITE_ENTRIES {
return Err(InviteError::TooManyInvites(self.entries.len()));
}
let json = serde_json::to_string(self).map_err(json_error)?;
if json.len() > NIP44_MAX_PLAINTEXT {
return Err(InviteError::Oversize(json.len()));
}
Ok(())
}
}
pub fn merge_invite_lists(held: InviteList, incoming: InviteList) -> InviteList {
let mut entries: BTreeMap<String, InviteEntry> = BTreeMap::new();
for entry in held.entries.into_iter().chain(incoming.entries) {
match entries.entry(entry.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(entry);
}
Entry::Occupied(mut slot) => {
let merged = merge_entry(slot.get(), &entry);
*slot.get_mut() = merged;
}
}
}
let mut tombstones: BTreeMap<String, InviteTombstone> = BTreeMap::new();
for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) {
match tombstones.entry(tombstone.token.clone()) {
Entry::Vacant(slot) => {
slot.insert(tombstone);
}
Entry::Occupied(mut slot) => {
if canonical(&tombstone) < canonical(slot.get()) {
*slot.get_mut() = tombstone;
}
}
}
}
let mut extra = held.extra;
union(&mut extra, incoming.extra);
InviteList {
entries: entries.into_values().collect(),
tombstones: tombstones.into_values().collect(),
extra,
}
}
pub async fn build_invite_list<S>(keys: &S, list: &InviteList) -> Result<Event, InviteError>
where
S: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + ?Sized,
{
list.fits()?;
let json = serde_json::to_string(list).map_err(json_error)?;
let content = cord01::seal_to_self(keys, &json).await?;
EventBuilder::new(Kind::Custom(KIND_INVITE_LIST), content)
.finalize_async(keys)
.await
.map_err(crypto_error)
}
pub async fn parse_invite_list<S>(keys: &S, event: &Event) -> Result<InviteList, InviteError>
where
S: AsyncGetPublicKey + AsyncNip44 + ?Sized,
{
if event.kind.as_u16() != KIND_INVITE_LIST {
return Err(InviteError::Kind(event.kind.as_u16()));
}
let json = cord01::open_to_self(keys, &event.content).await?;
serde_json::from_str(&json).map_err(json_error)
}
/// An entry is immutable once minted, so two copies should agree.
fn merge_entry(held: &InviteEntry, incoming: &InviteEntry) -> InviteEntry {
let (winner, loser) = if canonical(incoming) < canonical(held) {
(incoming, held)
} else {
(held, incoming)
};
let mut merged = winner.clone();
union(&mut merged.extra, loser.extra.clone());
merged
}
fn seal_bundle(bundle_key: &[u8; 32], json: &str) -> Result<String, InviteError> {
Ok(cord01::seal_bytes(
&ConversationKey::new(*bundle_key),
json.as_bytes(),
)?)
}
fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result<String, InviteError> {
let plaintext = cord01::open_bytes(&ConversationKey::new(*bundle_key), content)?;
String::from_utf8(plaintext).map_err(|_| InviteError::BadFragment("bundle is not utf8"))
}
fn signer_from_naddr(naddr: &str) -> Result<PublicKey, InviteError> {
match Nip19::from_bech32(naddr.trim_start_matches("nostr:")) {
Ok(Nip19::Coordinate(coordinate))
if coordinate.coordinate.kind.as_u16() == KIND_BUNDLE
&& coordinate.coordinate.identifier.is_empty() =>
{
Ok(coordinate.coordinate.public_key)
}
_ => Err(InviteError::BadLink(
"naddr is not an invite-bundle coordinate",
)),
}
}
fn hex32(value: &str, field: &'static str) -> Result<[u8; 32], InviteError> {
decode_hex_32(value).map_err(|_| InviteError::BadHex(field))
}
fn dict_id(relay: &str) -> Option<u8> {
RELAY_DICT
.iter()
.position(|known| *known == relay)
.map(|index| index as u8 + 1)
}
fn dict_url(id: u8) -> Option<&'static str> {
RELAY_DICT.get(id.checked_sub(1)? as usize).copied()
}
fn empty_identifier() -> Tag {
Tag::identifier("")
}
fn subkind_tag(value: &str) -> Tag {
Tag::custom(TAG_SUBKIND, [value])
}
fn first_tag(event: &Event, name: &str) -> Option<String> {
event.tags.iter().find_map(|tag| {
let fields = tag.as_slice();
(fields.len() >= 2 && fields[0] == name).then(|| fields[1].clone())
})
}
fn json_error(error: serde_json::Error) -> InviteError {
InviteError::Json(error.to_string())
}
fn crypto_error(error: impl fmt::Display) -> InviteError {
InviteError::Crypto(error.to_string())
}
#[cfg(test)]
mod tests {
use data_encoding::HEXLOWER;
use super::*;
use crate::derive::{community_id_of, invite_bundle_key};
const SALT: [u8; 32] = [0x33u8; 32];
fn bundle() -> CommunityInvite {
let owner = Keys::generate();
CommunityInvite {
community_id: community_id_of(&owner.public_key().to_bytes(), &SALT),
owner: owner.public_key(),
owner_salt: HEXLOWER.encode(&SALT),
community_root: "44".repeat(32),
root_epoch: Epoch(0),
control_pk: None,
channels: vec![ChannelGrant {
id: ChannelId::from_bytes([0x9cu8; 32]),
key: Some("55".repeat(32)),
epoch: Epoch(1),
name: "lounge".to_owned(),
extra: Extra::default(),
}],
relays: vec!["wss://relay.example".to_owned()],
name: "Test community".to_owned(),
icon: None,
expires_at: None,
creator_npub: None,
label: None,
extra: Extra::default(),
}
}
fn token16() -> [u8; TOKEN_LEN] {
std::array::from_fn(|i| i as u8)
}
#[test]
fn fragment_goldens_pin_the_wire_layout() {
let token = token16();
// [04 version][01 stock flag][token 00..0f]
let stock = encode_fragment(&token, &stock_relays()).expect("encodes");
assert_eq!(stock, "BAEAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&stock).expect("decodes"),
(token, stock_relays())
);
// [04][00 flags][02 count][02 dict-id][04 dict-id][token 00..0f]
let mixed = vec![RELAY_DICT[1].to_owned(), RELAY_DICT[3].to_owned()];
let encoded = encode_fragment(&token, &mixed).expect("encodes");
assert_eq!(encoded, "BAACAgQAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(decode_fragment(&encoded).expect("decodes"), (token, mixed));
// [04][00][01 count][ff verbatim lead][06 len]["ws://h"][token 00..0f]
let verbatim = vec!["ws://h".to_owned()];
let encoded = encode_fragment(&token, &verbatim).expect("encodes");
assert_eq!(encoded, "BAAB_wZ3czovL2gAAQIDBAUGBwgJCgsMDQ4P");
assert_eq!(
decode_fragment(&encoded).expect("decodes"),
(token, verbatim)
);
}
#[test]
fn a_fragment_is_strict_about_framing_and_counts() {
let token = token16();
for version in [3u8, 5] {
let mut bytes = vec![version, FLAG_STOCK_SET];
bytes.extend_from_slice(&token);
let encoded = BASE64URL_NOPAD.encode(&bytes);
assert!(
matches!(decode_fragment(&encoded), Err(InviteError::BadVersion(v)) if v == version),
"a legacy and a future version are both refused"
);
}
let mut trailing = vec![FRAGMENT_VERSION, FLAG_STOCK_SET];
trailing.extend_from_slice(&token);
trailing.push(0xff);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&trailing)),
Err(InviteError::BadFragment(_))
));
let mut over = vec![FRAGMENT_VERSION, 0x00, 0x04, 1, 2, 3, 4];
over.extend_from_slice(&token);
assert!(matches!(
decode_fragment(&BASE64URL_NOPAD.encode(&over)),
Err(InviteError::BadFragment(_))
));
// An unknown dictionary id is skipped, not fatal, so the dictionary can grow.
let mut unknown = vec![FRAGMENT_VERSION, 0x00, 0x01, 200];
unknown.extend_from_slice(&token);
let (decoded, relays) =
decode_fragment(&BASE64URL_NOPAD.encode(&unknown)).expect("decodes");
assert_eq!(decoded, token);
assert!(relays.is_empty());
}
#[test]
fn a_link_round_trips_and_refuses_a_non_invite() {
let link_signer = Keys::generate();
let token = token16();
let relays = vec!["wss://a.example".to_owned()];
let url = build_invite_url(
"https://vectorapp.io/",
&link_signer.public_key(),
&token,
&relays,
)
.expect("builds");
let parsed = parse_link(&url).expect("parses");
assert_eq!(parsed.link_signer, link_signer.public_key());
assert_eq!(parsed.token, token);
assert_eq!(parsed.bootstrap_relays, relays);
let fragment = url.split('#').nth(1).expect("carries a fragment");
let bare = format!("{}#{fragment}", parsed.naddr);
let reparsed = parse_link(&bare).expect("parses the domain-agnostic form");
assert_eq!(reparsed.link_signer, link_signer.public_key());
assert_eq!(reparsed.token, token);
assert!(
parse_link("https://x/invite/#frag").is_err(),
"the naddr is not optional"
);
}
#[test]
fn a_bundle_round_trips_while_a_revocation_reads_as_revoked() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[7u8; TOKEN_LEN]);
let event = build_bundle_event(&link_signer, &invite, &key).expect("builds");
assert_eq!(event.pubkey, link_signer.public_key());
match parse_bundle_event(&event, &link_signer.public_key(), &key).expect("parses") {
BundleState::Live(opened) => {
assert_eq!(opened.community_id, invite.community_id);
assert_eq!(opened.channels.len(), 1);
}
BundleState::Revoked => panic!("expected a live bundle"),
}
let revocation = build_revocation(&link_signer).expect("builds");
assert!(matches!(
parse_bundle_event(&revocation, &link_signer.public_key(), &key),
Ok(BundleState::Revoked)
));
// The token is the only way in, and a squatter is a different coordinate.
assert!(
parse_bundle_event(
&event,
&link_signer.public_key(),
&invite_bundle_key(&[8u8; TOKEN_LEN])
)
.is_err()
);
let squatter = Keys::generate();
assert!(matches!(
parse_bundle_event(&event, &squatter.public_key(), &key),
Err(InviteError::BadEvent(_))
));
}
#[test]
fn a_bundle_off_its_coordinate_or_off_its_owner_is_refused() {
let invite = bundle();
let link_signer = Keys::generate();
let key = invite_bundle_key(&[9u8; TOKEN_LEN]);
let json = serde_json::to_string(&invite).expect("serializes");
let content = seal_bundle(&key, &json).expect("seals");
// The fetch filters on the author, so the empty `d` is pinned here: a
// signature-valid event of the same author at another `d` is not the bundle.
let elsewhere = EventBuilder::new(Kind::Custom(KIND_BUNDLE), content)
.tags([Tag::identifier("elsewhere"), subkind_tag(vsk::INVITE_LIVE)])
.finalize(&link_signer)
.expect("signs");
assert!(matches!(
parse_bundle_event(&elsewhere, &link_signer.public_key(), &key),
Err(InviteError::BadEvent(_))
));
let mut forged = bundle();
forged.owner = Keys::generate().public_key();
assert!(matches!(forged.validate(), Err(InviteError::OwnerMismatch)));
assert!(matches!(
build_bundle_event(&link_signer, &forged, &key),
Err(InviteError::OwnerMismatch)
));
let mut malformed = bundle();
malformed.community_root = "not hex".to_owned();
assert!(matches!(malformed.validate(), Err(InviteError::BadHex(_))));
let mut crowded = bundle();
crowded.channels = (0..=MAX_BUNDLE_CHANNELS)
.map(|_| ChannelGrant {
id: ChannelId::from_bytes([0x01; 32]),
key: None,
epoch: Epoch(0),
name: String::new(),
extra: Extra::default(),
})
.collect();
assert!(matches!(
crowded.validate(),
Err(InviteError::TooManyChannels(n)) if n == MAX_BUNDLE_CHANNELS + 1
));
}
#[test]
fn a_direct_invite_round_trips_and_refuses_a_foreign_rumor() {
let inviter = Keys::generate();
let recipient = Keys::generate();
let invite = bundle();
let wrap = smol::block_on(build_direct_invite(
&inviter,
&recipient.public_key(),
&invite,
))
.expect("builds");
assert_eq!(wrap.kind, Kind::GiftWrap);
assert_ne!(
wrap.pubkey,
inviter.public_key(),
"the wrap author is ephemeral"
);
assert!(
wrap.tags.iter().any(|tag| tag.as_slice() == ["k", "3313"]),
"the k tag is what makes an invite indexable"
);
let (sender, opened) =
smol::block_on(unwrap_direct_invite(&wrap, &recipient)).expect("unwraps");
assert_eq!(sender, inviter.public_key());
assert_eq!(opened.community_id, invite.community_id);
// Somebody else's wrap is not ours to open...
let stranger = Keys::generate();
assert!(smol::block_on(unwrap_direct_invite(&wrap, &stranger)).is_err());
// ...and a wrap that opens to some other kind is not an invite.
let rumor = EventBuilder::new(Kind::Custom(crate::cord03::KIND_MESSAGE), "hello")
.finalize_unsigned(recipient.public_key());
let wrap = GiftWrapBuilder::new(recipient.public_key(), rumor)
.finalize(&recipient)
.expect("wraps");
assert!(matches!(
smol::block_on(unwrap_direct_invite(&wrap, &recipient)),
Err(InviteError::BadEvent(_))
));
}
}
File diff suppressed because it is too large Load Diff
-8
View File
@@ -1,8 +0,0 @@
mod rumor;
pub mod cord01;
pub mod cord02;
pub mod cord03;
pub mod cord04;
pub mod cord05;
pub mod cord06;
-96
View File
@@ -1,96 +0,0 @@
use std::fmt;
use nostr_sdk::prelude::*;
use crate::cord01::StreamError;
use crate::cord04::{AuthorityCitation, TAG_CITATION, citation_from};
use crate::decode_hex_32;
#[derive(Debug)]
pub enum RumorError {
Stream(StreamError),
NotEncryptedSealed,
UnknownKind(u16),
MissingTag(&'static str),
DuplicateTag(&'static str),
BadTag(&'static str),
/// Neither a delete nor a timer notice may be erased by the policy it carries.
ExemptExpiration,
}
impl fmt::Display for RumorError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RumorError::Stream(error) => write!(f, "stream: {error}"),
RumorError::NotEncryptedSealed => write!(f, "rumor must ride an encrypted seal"),
RumorError::UnknownKind(kind) => write!(f, "not a rumor kind: {kind}"),
RumorError::MissingTag(name) => write!(f, "missing tag: {name}"),
RumorError::DuplicateTag(name) => write!(f, "duplicate tag: {name}"),
RumorError::BadTag(name) => write!(f, "malformed tag: {name}"),
RumorError::ExemptExpiration => {
write!(f, "a delete or timer notice must not carry an expiration")
}
}
}
}
impl std::error::Error for RumorError {}
impl From<StreamError> for RumorError {
fn from(error: StreamError) -> Self {
RumorError::Stream(error)
}
}
pub fn tag<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<Option<&'a [String]>, RumorError> {
let mut found: Option<&[String]> = None;
for candidate in rumor.tags.iter() {
let fields = candidate.as_slice();
if fields.first().map(String::as_str) != Some(name) {
continue;
}
if found.is_some() {
return Err(RumorError::DuplicateTag(name));
}
found = Some(fields);
}
Ok(found)
}
pub fn required<'a>(
rumor: &'a UnsignedEvent,
name: &'static str,
) -> Result<&'a [String], RumorError> {
tag(rumor, name)?.ok_or(RumorError::MissingTag(name))
}
pub fn value<'a>(fields: &'a [String], name: &'static str) -> Result<&'a str, RumorError> {
fields
.get(1)
.map(String::as_str)
.ok_or(RumorError::BadTag(name))
}
pub fn pubkey(hex: &str, name: &'static str) -> Result<PublicKey, RumorError> {
let bytes = decode_hex_32(hex).map_err(|_| RumorError::BadTag(name))?;
PublicKey::from_slice(&bytes).map_err(|_| RumorError::BadTag(name))
}
pub fn optional_citation(rumor: &UnsignedEvent) -> Result<Option<AuthorityCitation>, RumorError> {
let Some(fields) = tag(rumor, TAG_CITATION)? else {
return Ok(None);
};
citation_from(fields)
.map(Some)
.ok_or(RumorError::BadTag(TAG_CITATION))
}
-12
View File
@@ -1,12 +0,0 @@
mod cords;
mod types;
mod utils;
pub mod state;
pub use cords::{cord01, cord02, cord03, cord04, cord05, cord06};
pub(crate) use types::Extra;
pub use types::{ChannelId, CommunityId, Epoch, RoleId};
pub use utils::decode_hex_32;
pub use utils::derive::{self, GroupKey};
pub(crate) use utils::{decode_hex_lower, fill_random, random_32};
-631
View File
@@ -1,631 +0,0 @@
use std::collections::{BTreeMap, BTreeSet};
use anyhow::Result;
use data_encoding::HEXLOWER;
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use crate::cord02::list::{CommunityListEntry, JoinMaterial};
use crate::cord02::{
ChannelMetadata, CommunityGenesis, CommunityMetadata, ControlFold, ROOT_EPOCH,
};
use crate::cord04::{EntityHead, Floors, ParsedEdition, vsk};
use crate::cord05::ChannelGrant;
use crate::derive::control_signer_group_key;
use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32};
/// The `concord/` namespace for locally-keyed documents.
pub const STATE_PREFIX: &str = "concord/";
/// A key epoch the client still holds, retained so history stays readable.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldKey {
pub epoch: Epoch,
pub key: [u8; 32],
/// The publish time of the rotation that superseded this key.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
/// A community root epoch the client still holds, retained for the same reason.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldRoot {
pub epoch: Epoch,
pub key: [u8; 32],
/// The epoch's Control Plane signer, when the rotation delivered one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_pk: Option<PublicKey>,
/// The publish time of the rotation that superseded this root.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub retired_at: Option<Timestamp>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelKeyRef {
pub id: ChannelId,
pub name: String,
pub private: bool,
pub epoch: Epoch,
/// The channel's read secret when the member was granted it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub key: Option<[u8; 32]>,
/// Keys this one superseded, retained so a rotation never blanks history.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub priors: Vec<HeldKey>,
}
impl ChannelKeyRef {
/// The write coordinate: only the current epoch is ever published under.
pub fn current(&self) -> Option<(Epoch, [u8; 32])> {
self.key.map(|key| (self.epoch, key))
}
}
/// How far a channel's history sync has reached, in wrap times.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ChannelCursor {
/// The newest wrap ingested, so a live subscription knows where to resume.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub newest: Option<Timestamp>,
/// The oldest wrap paged back to, so the next round resumes below it.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub oldest: Option<Timestamp>,
/// History verifiably swept to the bottom.
#[serde(default)]
pub exhausted: bool,
}
impl ChannelCursor {
pub fn merge(self, round: Self) -> Self {
Self {
newest: later(self.newest, round.newest),
oldest: earlier(self.oldest, round.oldest),
exhausted: self.exhausted || round.exhausted,
}
}
}
fn later(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.max(round)),
(held, None) => held,
(None, round) => round,
}
}
fn earlier(held: Option<Timestamp>, round: Option<Timestamp>) -> Option<Timestamp> {
match (held, round) {
(Some(held), Some(round)) => Some(held.min(round)),
(held, None) => held,
(None, round) => round,
}
}
/// One local document per community, keyed by `concord/<community_id>`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct CommunityState {
pub id: CommunityId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
pub owner: PublicKey,
pub owner_salt: [u8; 32],
pub community_root: [u8; 32],
pub root_epoch: Epoch,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub control_root: Option<[u8; 32]>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub control_pks: BTreeMap<u64, PublicKey>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub channels: Vec<ChannelKeyRef>,
pub relays: Vec<RelayUrl>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub heads: Vec<EntityHead>,
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub banned: BTreeSet<PublicKey>,
/// Where each channel's history sync has reached.
#[serde(
default,
rename = "channel_cursors",
skip_serializing_if = "BTreeMap::is_empty"
)]
pub cursors: BTreeMap<ChannelId, ChannelCursor>,
/// Root epochs the community has rotated past that this client still holds.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub held_roots: Vec<HeldRoot>,
/// The epoch a channel rotation removed us at.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub channel_cuts: BTreeMap<ChannelId, Epoch>,
/// The npubs whose rotation minted an epoch of this community we verified.
#[serde(default, skip_serializing_if = "BTreeSet::is_empty")]
pub refounders: BTreeSet<PublicKey>,
/// The base epoch we were excluded at.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub removed_at: Option<Epoch>,
/// A complete rotation ahead of our epoch predates our join and carries no blob.
#[serde(default)]
pub stranded: bool,
#[serde(default)]
pub dissolved: bool,
/// When this community joined the member's list, in milliseconds.
pub added_at_ms: u64,
}
impl CommunityState {
pub fn from_genesis(
genesis: &CommunityGenesis,
editions: &[ParsedEdition],
added_at_ms: u64,
) -> Result<Self> {
let mut channels = Vec::new();
let mut heads = Vec::with_capacity(editions.len());
let mut relays = Vec::new();
let mut name = None;
for edition in editions {
heads.push(EntityHead {
entity: edition.entity,
version: edition.version,
self_hash: edition.self_hash,
rumor_id: edition.rumor_id,
});
match edition.subkind.as_str() {
vsk::COMMUNITY_METADATA => {
let metadata: CommunityMetadata = serde_json::from_str(&edition.content)?;
relays.extend(
metadata
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok()),
);
name = label(&metadata.name);
}
vsk::CHANNEL_METADATA => {
let metadata: ChannelMetadata = serde_json::from_str(&edition.content)?;
channels.push(ChannelKeyRef {
id: ChannelId::from_bytes(edition.entity),
name: metadata.name,
private: metadata.private,
epoch: ROOT_EPOCH,
key: None,
priors: Vec::new(),
});
}
_ => {}
}
}
let control_pks = BTreeMap::from([(
ROOT_EPOCH.0,
control_signer_group_key(
&genesis.control_root,
&genesis.identity.community_id,
ROOT_EPOCH,
)?
.pk(),
)]);
Ok(Self {
id: genesis.identity.community_id,
name,
owner: genesis.identity.owner,
owner_salt: genesis.identity.owner_salt,
community_root: genesis.community_root,
root_epoch: ROOT_EPOCH,
control_root: Some(genesis.control_root),
control_pks,
channels,
relays,
heads,
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms,
})
}
pub fn from_join_material(material: &JoinMaterial, added_at_ms: u64) -> Result<Self> {
let control_pks = match material.control_pk {
Some(address) => BTreeMap::from([(material.root_epoch.0, address)]),
None => BTreeMap::new(),
};
let relays = material
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
let control_root = match &material.control_root {
Some(root) => Some(decode_hex_32(root)?),
None => None,
};
let mut channels = Vec::with_capacity(material.channels.len());
for grant in &material.channels {
let key = match &grant.key {
Some(key) => Some(decode_hex_32(key)?),
None => None,
};
channels.push(ChannelKeyRef {
id: grant.id,
name: grant.name.clone(),
private: key.is_some(),
epoch: grant.epoch,
key,
priors: Vec::new(),
});
}
Ok(Self {
id: material.community_id,
name: label(&material.name),
owner: material.owner,
owner_salt: decode_hex_32(&material.owner_salt)?,
community_root: decode_hex_32(&material.community_root)?,
root_epoch: material.root_epoch,
control_root,
control_pks,
channels,
relays,
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms,
})
}
pub fn identifier(&self) -> String {
state_identifier(&self.id)
}
/// Every root epoch we hold, the current one first.
pub fn roots(&self) -> Vec<HeldRoot> {
let mut roots = Vec::with_capacity(self.held_roots.len() + 1);
roots.push(HeldRoot {
epoch: self.root_epoch,
key: self.community_root,
control_pk: self.control_pks.get(&self.root_epoch.0).copied(),
retired_at: None,
});
roots.extend(self.held_roots.iter().copied());
roots
}
/// Every secret held for a channel, newest epoch first.
pub fn held_keys(&self, channel: &ChannelId) -> Vec<HeldKey> {
let Some(held) = self.channels.iter().find(|held| held.id == *channel) else {
return Vec::new();
};
if held.private {
let mut keys: Vec<HeldKey> = held
.key
.map(|key| HeldKey {
epoch: held.epoch,
key,
retired_at: None,
})
.into_iter()
.collect();
keys.extend(held.priors.iter().copied());
return keys;
}
self.roots()
.into_iter()
.map(|root| HeldKey {
epoch: root.epoch,
key: root.key,
retired_at: root.retired_at,
})
.collect()
}
/// Whether a channel rotation removed us at or after `epoch`.
pub fn channel_cut(&self, channel: &ChannelId, epoch: Epoch) -> bool {
self.channel_cuts
.get(channel)
.is_some_and(|cut| epoch <= *cut)
}
pub fn floors(&self) -> Floors {
self.heads
.iter()
.map(|head| (head.entity, head.clone()))
.collect()
}
pub fn apply_fold(&mut self, fold: &ControlFold) {
self.heads = fold.floors.values().cloned().collect();
self.banned = fold.banned.clone();
if let Some(community) = &fold.community {
self.relays = community
.relays
.iter()
.filter_map(|relay| RelayUrl::parse(relay).ok())
.collect();
if let Some(name) = label(&community.name) {
self.name = Some(name);
}
}
for (id, metadata) in &fold.channels {
if metadata.deleted.unwrap_or(false) {
self.channels.retain(|channel| channel.id != *id);
continue;
}
match self.channels.iter_mut().find(|channel| channel.id == *id) {
Some(channel) => {
channel.name = metadata.name.clone();
if !metadata.private {
channel.private = false;
}
}
None if !metadata.private => self.channels.push(ChannelKeyRef {
id: *id,
name: metadata.name.clone(),
private: false,
epoch: self.root_epoch,
key: None,
priors: Vec::new(),
}),
None => {}
}
}
}
}
pub fn list_entry(state: &CommunityState, name: &str) -> CommunityListEntry {
let material = JoinMaterial {
community_id: state.id,
owner: state.owner,
owner_salt: HEXLOWER.encode(&state.owner_salt),
community_root: HEXLOWER.encode(&state.community_root),
root_epoch: state.root_epoch,
control_pk: state.control_pks.get(&state.root_epoch.0).copied(),
control_root: state.control_root.map(|root| HEXLOWER.encode(&root)),
channels: state
.channels
.iter()
.map(|channel| ChannelGrant {
id: channel.id,
key: channel.key.map(|key| HEXLOWER.encode(&key)),
epoch: channel.epoch,
name: channel.name.clone(),
extra: Extra::default(),
})
.collect(),
relays: state.relays.iter().map(RelayUrl::to_string).collect(),
name: name.to_owned(),
extra: Extra::default(),
};
CommunityListEntry {
community_id: state.id,
seed: material.clone(),
current: material,
added_at: state.added_at_ms,
extra: Extra::default(),
}
}
fn label(name: &str) -> Option<String> {
let trimmed = name.trim();
(!trimmed.is_empty()).then(|| trimmed.to_owned())
}
/// The local document key a community's state is stored under.
pub fn state_identifier(id: &CommunityId) -> String {
format!("{STATE_PREFIX}{}", id.to_hex())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_cursor_merge_only_moves_forward_and_never_seals() {
let held = ChannelCursor {
newest: Some(Timestamp::from_secs(1_000)),
oldest: Some(Timestamp::from_secs(5_000)),
exhausted: false,
};
// An incomplete round reports nothing and moves neither bound.
assert_eq!(held.merge(ChannelCursor::default()), held);
let merged = held.merge(ChannelCursor {
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
});
assert_eq!(
merged,
ChannelCursor {
newest: Some(Timestamp::from_secs(2_000)),
oldest: Some(Timestamp::from_secs(3_000)),
exhausted: true,
}
);
// A later round that learned less cannot walk either bound back.
assert_eq!(
merged.merge(ChannelCursor {
newest: Some(Timestamp::from_secs(1_500)),
oldest: Some(Timestamp::from_secs(4_000)),
exhausted: false,
}),
merged
);
}
/// A cursor stored when the boundaries were milliseconds must not be read as
/// seconds. The key it was stored under is gone, so the document's counters
/// are ignored and the channel re-syncs rather than being sealed off by an
/// `exhausted` that outlived the bounds it was earned against.
#[test]
fn a_cursor_stored_in_the_old_unit_is_dropped_rather_than_reinterpreted() {
let channel = ChannelId::from_bytes([0x9c; 32]);
let cursor = ChannelCursor {
newest: Some(Timestamp::from_secs(1_700_000_000)),
exhausted: true,
..ChannelCursor::default()
};
let mut state = CommunityState {
id: CommunityId::from_bytes([0x42; 32]),
name: None,
owner: Keys::generate().public_key(),
owner_salt: [0x01; 32],
community_root: [0x02; 32],
root_epoch: Epoch(0),
control_root: None,
control_pks: BTreeMap::new(),
channels: Vec::new(),
relays: Vec::new(),
heads: Vec::new(),
banned: BTreeSet::new(),
cursors: BTreeMap::new(),
held_roots: Vec::new(),
channel_cuts: BTreeMap::new(),
refounders: BTreeSet::new(),
removed_at: None,
stranded: false,
dissolved: false,
added_at_ms: 7,
};
// The document a version that stored milliseconds wrote: its own key,
// and boundaries padded by a thousand.
let mut stored = serde_json::Map::new();
stored.insert(
channel.to_hex(),
serde_json::json!({
"newest_ms": 1_700_000_000_000u64,
"oldest_ms": 1_699_999_000_000u64,
"exhausted": true
}),
);
let mut legacy = serde_json::to_value(&state).expect("serializes");
legacy
.as_object_mut()
.expect("a document")
.insert("cursors".to_owned(), serde_json::Value::Object(stored));
let read: CommunityState = serde_json::from_value(legacy).expect("deserializes");
assert!(
read.cursors.is_empty(),
"a millisecond cursor is not a seconds cursor"
);
// A typed cursor still round-trips under the key it is written with.
state.cursors.insert(channel, cursor);
let document = serde_json::to_value(&state).expect("serializes");
let read: CommunityState = serde_json::from_value(document).expect("deserializes");
assert_eq!(read.cursors.get(&channel), Some(&cursor));
}
#[test]
fn from_join_material_materializes_a_subscribable_state_with_or_without_the_control_root() {
let owner = Keys::generate().public_key();
let control_pk = Keys::generate().public_key();
let staff = ChannelId::from_bytes([0x9c; 32]);
let general = ChannelId::from_bytes([0x9d; 32]);
let material = JoinMaterial {
community_id: CommunityId::from_bytes([0x42; 32]),
owner,
owner_salt: "01".repeat(32),
community_root: "02".repeat(32),
root_epoch: Epoch(3),
control_pk: Some(control_pk),
control_root: Some("03".repeat(32)),
channels: vec![
ChannelGrant {
id: staff,
key: Some("04".repeat(32)),
epoch: Epoch(2),
name: "staff".to_owned(),
extra: Extra::default(),
},
ChannelGrant {
id: general,
key: None,
epoch: Epoch(0),
name: "general".to_owned(),
extra: Extra::default(),
},
],
relays: vec!["wss://relay.example".to_owned()],
name: "Room".to_owned(),
extra: Extra::default(),
};
let state = CommunityState::from_join_material(&material, 7).expect("materializes");
assert_eq!(state.id, material.community_id);
assert_eq!(state.owner, owner);
assert_eq!(state.owner_salt, [0x01; 32]);
assert_eq!(state.community_root, [0x02; 32]);
assert_eq!(state.root_epoch, Epoch(3));
assert_eq!(state.control_root, Some([0x03; 32]));
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
assert!(
state.heads.is_empty(),
"the first control fold fills the heads"
);
assert!(state.banned.is_empty());
assert!(!state.dissolved);
assert_eq!(state.relays.len(), 1);
assert_eq!(state.added_at_ms, 7);
// A granted key lands on the channel and makes it private; a grant with
// no key is a public channel.
let granted = state
.channels
.iter()
.find(|c| c.id == staff)
.expect("staff");
assert!(granted.private);
assert_eq!(granted.key, Some([0x04; 32]));
assert_eq!(granted.epoch, Epoch(2));
assert_eq!(granted.name, "staff");
let public = state
.channels
.iter()
.find(|c| c.id == general)
.expect("general");
assert!(!public.private);
assert_eq!(public.key, None);
// A member who is not staff carries no control_root, but reading needs no
// secret: the address rides in the material either way.
let mut member = material.clone();
member.control_root = None;
let state = CommunityState::from_join_material(&member, 7).expect("materializes");
assert_eq!(state.control_root, None);
assert_eq!(state.control_pks, BTreeMap::from([(3, control_pk)]));
}
}
-98
View File
@@ -1,98 +0,0 @@
use std::fmt;
use std::str::FromStr;
use anyhow::Result;
use data_encoding::HEXLOWER;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::decode_hex_32;
/// Unknown fields a content struct does not model, so a republish cannot wipe them.
pub(crate) type Extra = serde_json::Map<String, serde_json::Value>;
macro_rules! hex_id {
($(#[$meta:meta])* $name:ident) => {
$(#[$meta])*
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct $name([u8; 32]);
impl $name {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_hex(&self) -> String {
HEXLOWER.encode(&self.0)
}
}
impl From<[u8; 32]> for $name {
fn from(bytes: [u8; 32]) -> Self {
Self(bytes)
}
}
impl fmt::Display for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.to_hex())
}
}
impl fmt::Debug for $name {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}({})", stringify!($name), self.to_hex())
}
}
impl FromStr for $name {
type Err = anyhow::Error;
fn from_str(value: &str) -> Result<Self> {
Ok(Self(decode_hex_32(value)?))
}
}
impl Serialize for $name {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_hex())
}
}
impl<'de> Deserialize<'de> for $name {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let value = String::deserialize(deserializer)?;
value.parse().map_err(serde::de::Error::custom)
}
}
};
}
hex_id! {
/// A self-certifying commitment to the owner's key, never on the wire.
CommunityId
}
hex_id! {
ChannelId
}
hex_id! {
/// Both a Role's entity coordinate and the field it repeats in its own content.
RoleId
}
/// A key-rotation counter; it bumps only on a Rekey that removes somebody.
#[derive(
Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Debug, Default, Serialize, Deserialize,
)]
pub struct Epoch(pub u64);
impl fmt::Display for Epoch {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
-34
View File
@@ -1,34 +0,0 @@
use anyhow::{Result, anyhow};
use base64::engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig};
use base64::{Engine as _, alphabet};
/// Unpadded base64url (RFC 4648 §5), 43 characters for 32 bytes: §8's value
/// encoding at any depth.
///
/// The reader tolerates non-zero trailing bits; the writer never emits them.
/// The spec's own worked example (`examples.md` §6.2) contains five such
/// values, and a reader cannot tell a mis-encoded named field from a correctly
/// encoded one, so the boundary is the writer's alone.
const BASE64URL: GeneralPurpose = GeneralPurpose::new(
&alphabet::URL_SAFE,
GeneralPurposeConfig::new()
.with_encode_padding(false)
.with_decode_padding_mode(DecodePaddingMode::RequireNone)
.with_decode_allow_trailing_bits(true),
);
pub(crate) fn encode(bytes: &[u8]) -> String {
BASE64URL.encode(bytes)
}
/// Decodes one 32-byte value, the width every §8 field has.
pub(crate) fn decode_32(value: &str) -> Result<[u8; 32]> {
let bytes = BASE64URL
.decode(value.trim())
.map_err(|error| anyhow!("invalid base64url: {error}"))?;
bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected 32 bytes, got {}", bytes.len()))
}
-407
View File
@@ -1,407 +0,0 @@
use anyhow::{Result, bail};
use hkdf::Hkdf;
use nostr::nips::nip44::v2::ConversationKey;
use nostr_sdk::prelude::{Keys, PublicKey, SecretKey};
use sha2::{Digest, Sha256};
use crate::{ChannelId, CommunityId, Epoch};
pub const TOKEN_LEN: usize = 16;
const LABEL_CHANNEL: &str = "concord/channel";
const LABEL_CONTROL: &str = "concord/control";
const LABEL_CONTROL_SIGNER: &str = "concord/control-signer";
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_PINS: &str = "concord/pins";
const LABEL_INVITE_LINKS: &str = "concord/invite-links";
const LABEL_INVITE_KEY: &str = "concord/invite-key";
const LABEL_COMMUNITY: &str = "concord/community";
const LABEL_EPOCH_COMMITMENT: &str = "concord/epoch-key-commitment";
const ZERO32: [u8; 32] = [0u8; 32];
fn build_info(label: &str, id32: &[u8; 32], epoch: Option<u64>) -> Vec<u8> {
let mut info = Vec::with_capacity(label.len() + 1 + 32 + 8);
info.extend_from_slice(label.as_bytes());
info.push(0x00);
info.extend_from_slice(id32);
if let Some(epoch) = epoch {
info.extend_from_slice(&epoch.to_be_bytes());
}
info
}
fn hkdf32(ikm: &[u8], info: &[u8]) -> [u8; 32] {
let mut okm = [0u8; 32];
Hkdf::<Sha256>::new(None, ikm)
.expand(info, &mut okm)
.expect("expanding HKDF to 32 bytes is below the 255*32 ceiling");
okm
}
fn hkdf_to_secret_key(ikm: &[u8], base_info: &[u8]) -> Result<SecretKey> {
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, base_info)) {
return Ok(secret_key);
}
for counter in 0u8..=u8::MAX {
let mut info = Vec::with_capacity(base_info.len() + 1);
info.extend_from_slice(base_info);
info.push(counter);
if let Ok(secret_key) = SecretKey::from_slice(&hkdf32(ikm, &info)) {
return Ok(secret_key);
}
}
bail!("seed stayed out of the secp256k1 scalar range across all 256 counters")
}
#[derive(Clone)]
pub struct GroupKey {
keys: Keys,
conversation: ConversationKey,
}
impl GroupKey {
fn derive(label: &str, secret: &[u8], id32: &[u8; 32], epoch: Option<u64>) -> Result<Self> {
let secret_key = hkdf_to_secret_key(secret, &build_info(label, id32, epoch))?;
let keys = Keys::new(secret_key);
let conversation = ConversationKey::derive(keys.secret_key(), &keys.public_key())?;
Ok(Self { keys, conversation })
}
pub fn pk(&self) -> PublicKey {
self.keys.public_key()
}
pub fn pk_hex(&self) -> String {
self.keys.public_key().to_hex()
}
pub fn keys(&self) -> &Keys {
&self.keys
}
pub fn conversation(&self) -> &ConversationKey {
&self.conversation
}
}
impl std::fmt::Debug for GroupKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GroupKey")
.field("pk", &self.pk_hex())
.finish()
}
}
/// `secret` is the `community_root` for a public channel.
pub fn channel_group_key(secret: &[u8; 32], channel: &ChannelId, epoch: Epoch) -> Result<GroupKey> {
GroupKey::derive(LABEL_CHANNEL, secret, channel.as_bytes(), Some(epoch.0))
}
/// The plane's read key: its conversation key encrypts the wraps for every member.
pub fn control_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// The plane's address and wrap signer, held only by staff; wraps still read under [`control_group_key`].
pub fn control_signer_group_key(
control_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_CONTROL_SIGNER,
control_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Member-writable, unlike the Control Plane: a join or a leave is each member's own word.
pub fn guestbook_group_key(
community_root: &[u8; 32],
community_id: &CommunityId,
epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_GUESTBOOK,
community_root,
community_id.as_bytes(),
Some(epoch.0),
)
}
/// Keyed by the prior `community_root`, so any retained member recovers any epoch's rekey.
pub fn channel_rekey_group_key(
prior_root: &[u8; 32],
channel: &ChannelId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_REKEY_PSEUDONYM,
prior_root,
channel.as_bytes(),
Some(new_epoch.0),
)
}
pub fn base_rekey_group_key(
prior_root: &[u8; 32],
community_id: &CommunityId,
new_epoch: Epoch,
) -> Result<GroupKey> {
GroupKey::derive(
LABEL_BASE_REKEY_PSEUDONYM,
prior_root,
community_id.as_bytes(),
Some(new_epoch.0),
)
}
pub fn dissolved_group_key(community_id: &CommunityId) -> Result<GroupKey> {
GroupKey::derive(LABEL_DISSOLVED, community_id.as_bytes(), &ZERO32, None)
}
pub fn community_id_of(owner_xonly: &[u8; 32], owner_salt: &[u8; 32]) -> CommunityId {
let mut hasher = Sha256::new();
hasher.update(LABEL_COMMUNITY.as_bytes());
hasher.update(owner_xonly);
hasher.update(owner_salt);
CommunityId::from_bytes(hasher.finalize().into())
}
pub fn verify_community_id(
community_id: &CommunityId,
owner_xonly: &[u8; 32],
owner_salt: &[u8; 32],
) -> bool {
community_id_of(owner_xonly, owner_salt) == *community_id
}
/// The continuity a rekey blob must satisfy against the key currently held.
pub fn epoch_key_commitment(previous_epoch: Epoch, previous_key: &[u8; 32]) -> [u8; 32] {
let mut hasher = Sha256::new();
hasher.update(LABEL_EPOCH_COMMITMENT.as_bytes());
hasher.update(previous_epoch.0.to_be_bytes());
hasher.update(previous_key);
hasher.finalize().into()
}
/// Bound to the `community_id`, so a member's Grant coordinate survives every refounding.
pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_GRANT, member_xonly, None),
)
}
pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_BANLIST, &ZERO32, None),
)
}
pub fn pins_locator(community_id: &CommunityId, channel: &ChannelId) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_PINS, channel.as_bytes(), None),
)
}
/// Bound to the creator, so each creator owns exactly their own registry.
pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] {
hkdf32(
community_id.as_bytes(),
&build_info(LABEL_INVITE_LINKS, creator_xonly, None),
)
}
/// Built from public inputs only, so a locator match proves nothing about authenticity
pub fn recipient_locator(
rotator_xonly: &[u8; 32],
recipient_xonly: &[u8; 32],
scope_id: &[u8; 32],
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_id, Some(new_epoch.0)),
)
}
/// The raw output is the NIP-44 conversation key (CORD-05 §2).
pub fn invite_bundle_key(token: &[u8; TOKEN_LEN]) -> [u8; 32] {
hkdf32(token, &build_info(LABEL_INVITE_KEY, &ZERO32, None))
}
#[cfg(test)]
mod tests {
use super::*;
const CHANNEL_E0_SEED: &str =
"1a99a5958bf9fcc5336e6e19db42aabf36ffbfa12f38a1d5fbde2ae383ed751b";
const CHANNEL_E0_PK: &str = "7a5c5dff759a63f1fc2779864487432bae3d1ea72c4ffabd39f4c1fdaf62097a";
const CHANNEL_EMULTI_PK: &str =
"f20c7d192cc87615d7341e86f38f85303f4708b40232d4fea521ab8217767391";
const CONTROL_E0_PK: &str = "c43df20bf4d6eeaea5149619662ffe9b211f31e11bb4a59f56b6e906f702d46f";
const CONTROL_SIGNER_E0_SEED: &str =
"c4a3e8354d95137132087356412b67b53e025d127d45de45cff9ecf45b0c24f6";
const CONTROL_SIGNER_E0_PK: &str =
"718aef388257f3fd9f1bfae5cf2cbd0594a2ffc31adb5c1fe22c502c046acaee";
const CONTROL_SIGNER_EMULTI_PK: &str =
"e27235cc13be2f9ad65648e01ff2b63402846469c8638b5386c625688194ec7d";
const GUESTBOOK_E0_PK: &str =
"ad09de582026fa7a052db18bb5827fa24c15e929d59aadcc91efb8508f5368ad";
const CHANNEL_REKEY_E1_PK: &str =
"7c55cdb957e9db2b4800d687b2a07d3f7066b1a35824a1e86ba871f55e87e8b5";
const BASE_REKEY_E1_PK: &str =
"fb2fa44fba66ba15595f784255a1cb569531db8784432ac0e4fe838498dd9dea";
const DISSOLVED_PK: &str = "4d3d55d88fdf9d9c2089651e5cbb0dfa93b6b9b10cdcb2319b0dce1a1398096a";
const GRANT_LOCATOR: &str = "fd2f88cc7f1eb8d7d862c91dc22afe700c358d1845158b3f353b769ce4898e35";
const BANLIST_LOCATOR: &str =
"88089214afae6d3c412fd817ada44d6df4d485a53565646471e74476397693c9";
const INVITE_LINKS_LOCATOR: &str =
"f4ae29994165767bac23e8dce630f81b926d2c8aa150e5cbf0bdf75865e8379a";
const RECIPIENT_LOCATOR: &str =
"342deb400e191f0f52c81f27600934552550beb85aa9bf169f02d0e7f826cf74";
const INVITE_KEY: &str = "94bf8b0d89e579ddaeccf8d9db3f5de5c86a1259c597f2560ff0120173bc5e1f";
const COMMUNITY_ID: &str = "2b790bd59df98bdc52092b74ebd6933a89ef8eaeecc9030861cbdeae7c814c46";
const EPOCH_COMMITMENT: &str =
"3e6d6a3c9973c16d1ca7c5602d36979927c55c21a7e2c840f883af3f047e80a4";
const PINS_LOCATOR: &str = "3b4529395a35c981ed409b588af3c4cd3081992958a485347356a173c3146c52";
const EPOCH_MULTI: u64 = 0x0102030405060708;
/// `0x00..0x1f` / `0xff..0xe0` / `0x11` x32 — the inputs every vector uses.
fn secret() -> [u8; 32] {
let mut key = [0u8; 32];
for (index, byte) in key.iter_mut().enumerate() {
*byte = index as u8;
}
key
}
fn id32() -> [u8; 32] {
let mut id = [0u8; 32];
for (index, byte) in id.iter_mut().enumerate() {
*byte = 255 - index as u8;
}
id
}
fn hex(bytes: &[u8]) -> String {
data_encoding::HEXLOWER.encode(bytes)
}
#[test]
fn golden_vectors() {
let secret = secret();
let id = id32();
let alt = [0x11u8; 32];
let community_id = CommunityId::from_bytes(id);
let channel = ChannelId::from_bytes(id);
let channel_e0 = channel_group_key(&secret, &channel, Epoch(0)).expect("derives");
assert_eq!(
hex(channel_e0.keys().secret_key().as_secret_bytes()),
CHANNEL_E0_SEED
);
assert_eq!(channel_e0.pk_hex(), CHANNEL_E0_PK);
assert_eq!(
channel_group_key(&secret, &channel, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CHANNEL_EMULTI_PK
);
assert_eq!(
control_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
CONTROL_E0_PK
);
let signer = control_signer_group_key(&secret, &community_id, Epoch(0)).expect("derives");
assert_eq!(
hex(signer.keys().secret_key().as_secret_bytes()),
CONTROL_SIGNER_E0_SEED
);
assert_eq!(signer.pk_hex(), CONTROL_SIGNER_E0_PK);
assert_eq!(
control_signer_group_key(&secret, &community_id, Epoch(EPOCH_MULTI))
.expect("derives")
.pk_hex(),
CONTROL_SIGNER_EMULTI_PK
);
assert_eq!(
guestbook_group_key(&secret, &community_id, Epoch(0))
.expect("derives")
.pk_hex(),
GUESTBOOK_E0_PK
);
assert_eq!(
channel_rekey_group_key(&secret, &channel, Epoch(1))
.expect("derives")
.pk_hex(),
CHANNEL_REKEY_E1_PK
);
assert_eq!(
base_rekey_group_key(&secret, &community_id, Epoch(1))
.expect("derives")
.pk_hex(),
BASE_REKEY_E1_PK
);
assert_eq!(
dissolved_group_key(&community_id)
.expect("derives")
.pk_hex(),
DISSOLVED_PK
);
assert_eq!(hex(&grant_locator(&community_id, &alt)), GRANT_LOCATOR);
assert_eq!(hex(&banlist_locator(&community_id)), BANLIST_LOCATOR);
assert_eq!(
hex(&invite_links_locator(&community_id, &alt)),
INVITE_LINKS_LOCATOR
);
assert_eq!(hex(&pins_locator(&community_id, &channel)), PINS_LOCATOR);
assert_eq!(
hex(&recipient_locator(&secret, &alt, &id, Epoch(3))),
RECIPIENT_LOCATOR
);
assert_eq!(hex(&invite_bundle_key(&[0x07u8; TOKEN_LEN])), INVITE_KEY);
assert_eq!(hex(community_id_of(&secret, &alt).as_bytes()), COMMUNITY_ID);
assert_eq!(
hex(&epoch_key_commitment(Epoch(2), &secret)),
EPOCH_COMMITMENT
);
}
}
-77
View File
@@ -1,77 +0,0 @@
pub mod base64url;
pub mod derive;
use anyhow::{Result, anyhow, bail};
use data_encoding::HEXLOWER;
use rand::TryRng as _;
use rand::rngs::SysRng;
use serde::Serialize;
use crate::Extra;
/// Decode a 64-character lowercase-hex string into 32 bytes.
///
/// Uppercase and other non-canonical spellings are rejected.
pub fn decode_hex_32(value: &str) -> Result<[u8; 32]> {
decode_hex_lower::<32>(value)
}
pub(crate) fn decode_hex_lower<const N: usize>(value: &str) -> Result<[u8; N]> {
let bytes = HEXLOWER
.decode(value.as_bytes())
.map_err(|error| anyhow!("invalid hex: {error}"))?;
let decoded: [u8; N] = bytes
.as_slice()
.try_into()
.map_err(|_| anyhow!("expected {N} bytes, got {}", bytes.len()))?;
if HEXLOWER.encode(&decoded) != value {
bail!("hex must be lowercase and canonical");
}
Ok(decoded)
}
pub(crate) fn fill_random(bytes: &mut [u8]) -> Result<()> {
SysRng
.try_fill_bytes(bytes)
.map_err(|error| anyhow!("os rng: {error}"))
}
pub(crate) fn random_32() -> Result<[u8; 32]> {
let mut bytes = [0u8; 32];
fill_random(&mut bytes)?;
Ok(bytes)
}
/// Hex to unpadded base64url for one 32-byte §8 value.
pub(crate) fn hex32_to_base64(value: &str) -> Result<String> {
Ok(base64url::encode(&decode_hex_32(value)?))
}
/// Unpadded base64url to lowercase hex for one 32-byte §8 value.
pub(crate) fn base64_to_hex32(value: &str) -> Result<String> {
Ok(HEXLOWER.encode(&base64url::decode_32(value)?))
}
/// Canonical JSON bytes: the total-order tie-break every content merge uses.
pub(crate) fn canonical<T: Serialize>(value: &T) -> String {
serde_json::to_string(value).unwrap_or_default()
}
/// Unions an unknown-field map. Where both sides carry a key, the
/// lexicographically lowest canonical bytes win, so two devices converge
/// instead of flapping.
pub(crate) fn union(into: &mut Extra, other: Extra) {
for (key, value) in other {
let replace = match into.get(&key) {
Some(existing) => canonical(&value) < canonical(existing),
None => true,
};
if replace {
into.insert(key, value);
}
}
}
+2 -3
View File
@@ -6,7 +6,6 @@ use chat::Message;
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
@@ -234,9 +233,9 @@ impl Group {
&mut self,
content: &str,
replies: Vec<EventId>,
emojis: Vec<Tag>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let emojis = PersonRegistry::emoji_tags(content, cx);
self.dispatch(
move |id, prev| id.message(content, &replies, &emojis, prev),
cx,
@@ -247,9 +246,9 @@ impl Group {
&mut self,
target: EventId,
emoji: String,
emojis: Vec<Tag>,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
let emojis = PersonRegistry::emoji_tags(&emoji, cx);
self.dispatch(
move |id, prev| id.reaction(target, &emoji, &emojis, prev),
cx,
+66 -14
View File
@@ -5,13 +5,13 @@ use chat_ui::text::rendered_text;
use gpui::prelude::FluentBuilder;
use gpui::{
Action, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable,
FollowMode, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
FollowMode, ImageSource, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement,
PathPromptOptions, Render, SharedString, Styled, Subscription, Task, WeakEntity, Window, div,
list, px,
};
use nip29::{Group, GroupEvent, GroupKey, GroupsRegistry, Membership, Pin, Row};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use person::Directory;
use serde::Deserialize;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
@@ -308,6 +308,8 @@ pub struct GroupPanel {
id: SharedString,
focus_handle: FocusHandle,
group: WeakEntity<Group>,
/// Person directory for members, message authors, and activity subjects
directory: Entity<Directory>,
list_state: ListState,
head: Option<EventId>,
/// The message the composer is replying to, if any.
@@ -356,12 +358,14 @@ impl GroupPanel {
subscriptions.push(cx.subscribe(&group, |this, _group, event, cx| {
if !matches!(event, GroupEvent::Sent(_)) {
this.sync(cx);
this.track_participants(cx);
cx.notify();
}
}));
subscriptions.push(cx.observe(&group, |this, _group, cx| {
this.sync(cx);
this.track_participants(cx);
cx.notify();
}));
@@ -376,10 +380,11 @@ impl GroupPanel {
));
});
Self {
let mut this = Self {
id,
focus_handle: cx.focus_handle(),
group: group.downgrade(),
directory: Directory::new(cx),
list_state: ListState::new(count + 2, ListAlignment::Bottom, px(1024.)),
head,
reply_to: None,
@@ -389,7 +394,29 @@ impl GroupPanel {
uploading: false,
tasks: smallvec![],
_subscriptions: subscriptions,
}
};
this.track_participants(cx);
this
}
/// Track the keys rendered by this panel: members, message authors, and
/// activity subjects
fn track_participants(&mut self, cx: &mut Context<Self>) {
let Some(group) = self.group.upgrade() else {
return;
};
let group = group.read(cx);
let mut keys: Vec<PublicKey> = group.members().iter().copied().collect();
keys.extend(group.rows().iter().map(|row| match row {
Row::Message(message) => message.author,
Row::Activity(activity) => activity.subject(),
}));
self.directory
.update(cx, |directory, cx| directory.track(keys, cx));
}
fn item_count(&self, cx: &App) -> usize {
@@ -505,11 +532,11 @@ impl GroupPanel {
}
let replies: Vec<EventId> = self.reply_to.take().into_iter().collect();
let emojis = self.emoji_tags(&content, cx);
let Ok(send) = self
.group
.update(cx, |group, cx| group.send_message(&content, replies, cx))
else {
let Ok(send) = self.group.update(cx, |group, cx| {
group.send_message(&content, replies, emojis, cx)
}) else {
return;
};
@@ -586,6 +613,18 @@ impl GroupPanel {
.and_then(|group| Notice::of(group.read(cx)))
}
/// The signed-in user's custom emoji tags referenced in `content`
fn emoji_tags(&self, content: &str, cx: &App) -> Vec<Tag> {
let Some(current_user) = NostrRegistry::global(cx).read(cx).current_user() else {
return Vec::new();
};
self.directory
.read(cx)
.person(&current_user, cx)
.emoji_tags(content)
}
fn react(
&mut self,
group: WeakEntity<Group>,
@@ -594,7 +633,10 @@ impl GroupPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, cx)) else {
let emojis = self.emoji_tags(&emoji, cx);
let Ok(react) = group.update(cx, |group, cx| group.send_reaction(id, emoji, emojis, cx))
else {
return;
};
@@ -877,11 +919,13 @@ impl GroupPanel {
let message = match row {
Row::Message(message) => message,
Row::Activity(activity) => return message::render_activity(ix, activity, cx),
Row::Activity(activity) => {
return message::render_activity(ix, activity, &self.directory, cx);
}
};
let nostr = NostrRegistry::global(cx);
let persons = PersonRegistry::global(cx);
let directory = self.directory.clone();
let content = self
.rendered_texts_by_id
@@ -891,7 +935,7 @@ impl GroupPanel {
&message.content,
&message.mentions,
&message.emojis,
&persons,
&directory,
cx,
)
})
@@ -916,6 +960,7 @@ impl GroupPanel {
panel: cx.weak_entity(),
admin,
pinned,
persons: self.directory.clone(),
},
cx,
)
@@ -990,8 +1035,7 @@ impl GroupPanel {
let context = self.group.upgrade().and_then(|entity| {
entity.read_with(cx, |group, cx| {
let message = group.message(target)?;
let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, cx);
let author = self.directory.read(cx).person(&message.author, cx);
let preview = message.preview();
let line = preview.lines().next().unwrap_or_default().trim();
@@ -1128,6 +1172,10 @@ impl Panel for GroupPanel {
Vec::new()
}
fn background(&self, cx: &App) -> Option<ImageSource> {
AppSettings::panel_background_source(cx)
}
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
let Some(group) = self.group.upgrade() else {
return;
@@ -1148,6 +1196,10 @@ impl Panel for GroupPanel {
window.dispatch_action(Box::new(HideGroupMeta { relay, group: id }), cx);
}
}
fn on_removed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.set_active(false, window, cx);
}
}
impl EventEmitter<PanelEvent> for GroupPanel {}
+31 -10
View File
@@ -2,12 +2,12 @@ use chat::Message;
use common::TimestampExt;
use gpui::prelude::FluentBuilder;
use gpui::{
AnyElement, App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
AnyElement, App, Entity, InteractiveElement, IntoElement, ParentElement, SharedString, Styled,
WeakEntity, div, px,
};
use nip29::{Activity, Group, Reaction};
use nostr_sdk::prelude::{EventId, Kind};
use person::PersonRegistry;
use person::Directory;
use settings::AppSettings;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
@@ -25,6 +25,7 @@ pub(crate) struct Actions {
pub panel: WeakEntity<GroupPanel>,
pub admin: bool,
pub pinned: bool,
pub persons: Entity<Directory>,
}
pub(crate) fn render(
@@ -37,10 +38,10 @@ pub(crate) fn render(
cx: &App,
) -> AnyElement {
let nostr = NostrRegistry::global(cx);
let persons = PersonRegistry::global(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let author = persons.read(cx).get(&message.author, cx);
let persons = actions.persons.clone();
let author = persons.read(cx).person(&message.author, cx);
let reaction_groups = group_reactions(
reactions.iter().map(|reaction| {
@@ -66,6 +67,7 @@ pub(crate) fn render(
.when(!message.replies_to.is_empty(), |this| {
this.children(replies(
&message.replies_to,
&persons,
&actions.group,
&actions.panel,
cx,
@@ -162,7 +164,22 @@ fn react_menu(
)
});
let emojis = PersonRegistry::current_emojis(cx);
let emojis = panel
.upgrade()
.and_then(|panel| {
let current_user = NostrRegistry::global(cx).read(cx).current_user()?;
Some(
panel
.read(cx)
.directory
.read(cx)
.person(&current_user, cx)
.emojis()
.to_vec(),
)
})
.unwrap_or_default();
if emojis.is_empty() {
return menu;
}
@@ -185,9 +202,13 @@ fn react_menu(
})
}
pub(crate) fn render_activity(ix: usize, activity: &Activity, cx: &App) -> AnyElement {
let persons = PersonRegistry::global(cx);
let subject = persons.read(cx).get(&activity.subject(), cx);
pub(crate) fn render_activity(
ix: usize,
activity: &Activity,
persons: &Entity<Directory>,
cx: &App,
) -> AnyElement {
let subject = persons.read(cx).person(&activity.subject(), cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
h_flex()
@@ -262,6 +283,7 @@ fn action(activity: &Activity) -> String {
fn replies(
targets: &[EventId],
persons: &Entity<Directory>,
group: &WeakEntity<Group>,
panel: &WeakEntity<GroupPanel>,
cx: &App,
@@ -275,8 +297,7 @@ fn replies(
for (ix, id) in targets.iter().enumerate() {
let context = entity.read_with(cx, |group, cx| {
let message = group.message(*id)?;
let persons = PersonRegistry::global(cx);
let author = persons.read(cx).get(&message.author, cx);
let author = persons.read(cx).person(&message.author, cx);
Some((author.name(), message.preview()))
});
+13 -4
View File
@@ -11,7 +11,7 @@ use gpui::{
SharedString, Styled, Subscription, Task, Window, div, px, relative,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use person::Directory;
use settings::AppSettings;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, CLIENT_NAME, NostrRegistry, UniversalSigner};
@@ -63,6 +63,8 @@ pub struct DeviceRegistry {
pub(crate) pending_request: bool,
/// Whether an announcement has been made for this device
pub(crate) announcement_existed: Arc<AtomicBool>,
/// Person directory for requesters shown in notifications
directory: Entity<Directory>,
/// Relay notification listener
notification_listener: Option<Task<Result<(), Error>>>,
tasks: Vec<Task<Result<(), Error>>>,
@@ -122,6 +124,7 @@ impl DeviceRegistry {
signer,
pending_request: false,
announcement_existed: Arc::new(AtomicBool::new(false)),
directory: Directory::new(cx),
notification_listener: None,
tasks: vec![],
_subscriptions: subscriptions,
@@ -690,7 +693,11 @@ impl DeviceRegistry {
}
/// Build a notification for the encryption request.
fn notification(&self, event: Event, cx: &Context<Self>) -> Result<Notification, Error> {
fn notification(
&mut self,
event: Event,
cx: &mut Context<Self>,
) -> Result<Notification, Error> {
const MSG: &str = "You've requested an encryption key from another device. \
Approve to allow Coop to share with it. Before approving, \
compare the code with the one shown on the requesting \
@@ -705,8 +712,8 @@ impl DeviceRegistry {
.context("Request has no valid requester key")?;
let code = authorization_code(&requester);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(&requester, cx);
let directory = self.directory.clone();
directory.update(cx, |directory, cx| directory.track([requester], cx));
let code_label = code.clone();
let entity = cx.entity().downgrade();
@@ -719,6 +726,8 @@ impl DeviceRegistry {
.with_kind(NotificationKind::Info)
.title("Encryption Key Request")
.content(move |_this, _window, cx| {
let profile = directory.read(cx).person(&requester, cx);
v_flex()
.gap(px(theme::SPACE))
.text_style(TextStyle::Callout)
+234
View File
@@ -0,0 +1,234 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use anyhow::Error;
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, Entity, Subscription, Task};
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use crate::{Person, PersonEvent, PersonRegistry};
/// Directory of persons tracked by a view
#[derive(Debug)]
pub struct Directory {
persons: HashMap<PublicKey, Person>,
/// Keys awaiting their first update, guarded for render-time access
pending: RwLock<HashSet<PublicKey>>,
tasks: SmallVec<[Task<()>; 2]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
impl Directory {
/// Create a directory following the registry's events
pub fn new(cx: &mut App) -> Entity<Self> {
let registry = PersonRegistry::global(cx);
cx.new(|cx| {
let subscription = cx.subscribe(&registry, |this: &mut Self, _registry, event, cx| {
this.handle_event(event.clone(), cx);
});
Self {
persons: HashMap::new(),
pending: RwLock::new(HashSet::new()),
tasks: SmallVec::new(),
_subscriptions: smallvec![subscription],
}
})
}
/// Load known profiles from the database, request the rest
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>) {
/// Profiles and emoji lists loaded from the database
type Loaded = (Vec<Person>, Vec<(PublicKey, Vec<(String, Url)>)>);
let missing: Vec<PublicKey> = keys
.into_iter()
.filter(|key| {
!self.persons.contains_key(key)
&& !self.with_pending(|pending| pending.contains(key))
})
.collect();
if missing.is_empty() {
return;
}
// Accept events that arrive while the database query is running
self.with_pending(|pending| {
pending.extend(missing.iter().copied());
});
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let authors = missing.clone();
let task: Task<Result<Loaded, Error>> = cx.background_spawn(async move {
let mut metadata_events: Vec<Event> = client
.database()
.query(Filter::new().kind(Kind::Metadata).authors(authors.clone()))
.await?
.into_iter()
.collect();
// Keep only the newest event per author
metadata_events.sort_by_key(|event| Reverse(event.created_at));
let mut seen = HashSet::new();
let persons = metadata_events
.into_iter()
.filter(|event| seen.insert(event.pubkey))
.map(|event| {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
Person::new(event.pubkey, metadata)
})
.collect();
let mut emoji_events: Vec<Event> = client
.database()
.query(Filter::new().kind(Kind::Emojis).authors(authors))
.await?
.into_iter()
.collect();
// Keep only the newest event per author
emoji_events.sort_by_key(|event| Reverse(event.created_at));
let mut seen = HashSet::new();
let emojis = emoji_events
.into_iter()
.filter(|event| seen.insert(event.pubkey))
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
.collect();
Ok((persons, emojis))
});
self.tasks.push(cx.spawn(async move |this, cx| {
let loaded = task.await;
this.update(cx, |this, cx| {
let remaining = match loaded {
Ok((persons, emojis)) => {
let found: HashSet<PublicKey> =
persons.iter().map(Person::public_key).collect();
this.bulk_insert(persons, cx);
// Emojis apply on top of loaded or already received data
let mut updated = false;
for (public_key, emojis) in emojis {
if emojis.is_empty() {
continue;
}
let person = this
.persons
.entry(public_key)
.or_insert_with(|| Person::new(public_key, Metadata::default()));
person.set_emojis(emojis);
updated = true;
}
if updated {
cx.notify();
}
missing
.into_iter()
.filter(|key| !found.contains(key))
.collect()
}
Err(e) => {
log::warn!("Failed to load persons from the database: {e}");
missing
}
};
if !remaining.is_empty() {
let registry = PersonRegistry::global(cx);
registry.read(cx).fetch(remaining);
}
})
.ok();
}));
}
/// The person for a key, or a placeholder that queues a request
pub fn person(&self, public_key: &PublicKey, cx: &App) -> Person {
if let Some(person) = self.persons.get(public_key) {
return person.clone();
}
if self.with_pending(|pending| pending.insert(*public_key)) {
let registry = PersonRegistry::global(cx);
registry.read(cx).fetch([*public_key]);
}
Person::new(*public_key, Metadata::default())
}
/// Merge event data into the tracked person
fn handle_event(&mut self, event: PersonEvent, cx: &mut Context<Self>) {
let public_key = match &event {
PersonEvent::Metadata(person) => person.public_key(),
PersonEvent::Emojis { public_key, .. }
| PersonEvent::Announcement { public_key, .. }
| PersonEvent::MessagingRelays { public_key, .. } => *public_key,
};
if !self.persons.contains_key(&public_key)
&& !self.with_pending(|pending| pending.remove(&public_key))
{
return;
}
let person = self
.persons
.entry(public_key)
.or_insert_with(|| Person::new(public_key, Metadata::default()));
match event {
PersonEvent::Metadata(updated) => person.set_metadata(updated.metadata()),
PersonEvent::Emojis { emojis, .. } => person.set_emojis(emojis),
PersonEvent::Announcement { announcement, .. } => {
person.set_announcement(announcement);
}
PersonEvent::MessagingRelays { relays, .. } => person.set_messaging_relays(relays),
}
cx.notify();
}
/// Insert loaded persons, keeping data already received from relays
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
let mut inserted = false;
for person in persons {
let public_key = person.public_key();
if self.persons.contains_key(&public_key) {
continue;
}
self.persons.insert(public_key, person);
inserted = true;
}
if inserted {
cx.notify();
}
}
/// Run `then` with the pending set, tolerating poisoning: it is only a dedupe cache
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
let mut pending = match self.pending.write() {
Ok(pending) => pending,
Err(poisoned) => poisoned.into_inner(),
};
then(&mut pending)
}
}
+79 -186
View File
@@ -1,17 +1,18 @@
use std::cmp::Reverse;
use std::collections::{HashMap, HashSet};
use std::sync::RwLock;
use anyhow::{Error, anyhow};
use common::{EventExt, emojis_from_tags};
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use common::emojis_from_tags;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
use instant::Duration;
use nostr_sdk::prelude::*;
use smallvec::{SmallVec, smallvec};
use state::{Announcement, BOOTSTRAP_RELAYS, NostrRegistry, TIMEOUT};
mod directory;
mod person;
pub use directory::*;
pub use person::*;
pub fn init(cx: &mut App) {
@@ -22,6 +23,30 @@ struct GlobalPersonRegistry(Entity<PersonRegistry>);
impl Global for GlobalPersonRegistry {}
/// A change to a person's data
#[derive(Debug, Clone)]
pub enum PersonEvent {
/// Profile metadata
Metadata(Box<Person>),
/// Custom emoji list (NIP-30)
Emojis {
public_key: PublicKey,
emojis: Vec<(String, Url)>,
},
/// Profile encryption keys announcement (NIP-4e)
Announcement {
public_key: PublicKey,
announcement: Announcement,
},
/// Messaging relay list (NIP-65)
MessagingRelays {
public_key: PublicKey,
relays: Vec<RelayUrl>,
},
}
impl EventEmitter<PersonEvent> for PersonRegistry {}
#[derive(Debug, Clone)]
enum Dispatch {
Person(Person),
@@ -33,10 +58,8 @@ enum Dispatch {
/// Person Registry
#[derive(Debug)]
pub struct PersonRegistry {
/// Collection of all persons (user profiles)
persons: HashMap<PublicKey, Entity<Person>>,
/// Set of public keys that have been seen
seen: RwLock<HashSet<PublicKey>>,
/// Public keys with a metadata request in flight
in_flight: RwLock<HashSet<PublicKey>>,
sender: flume::Sender<PublicKey>,
tasks: SmallVec<[Task<()>; 4]>,
_subscriptions: SmallVec<[Subscription; 1]>,
@@ -57,7 +80,6 @@ impl PersonRegistry {
fn new(cx: &mut Context<Self>) -> Self {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let entity = cx.entity().downgrade();
// Channel for communication between nostr and gpui
let (tx, rx) = flume::bounded::<Dispatch>(100);
@@ -103,17 +125,8 @@ impl PersonRegistry {
}
});
// Load all user profiles from the database
cx.defer(move |cx| {
_ = entity.update(cx, |this, cx| {
this.load(cx);
this.load_emojis(cx);
});
});
Self {
persons: HashMap::new(),
seen: RwLock::new(HashSet::new()),
in_flight: RwLock::new(HashSet::new()),
sender: metadata_tx,
tasks,
_subscriptions: smallvec![subscription],
@@ -146,12 +159,6 @@ impl PersonRegistry {
log::warn!("Channel closed, dropping metadata event");
}
}
Kind::ContactList => {
let public_keys = event.extract_public_keys();
if let Err(e) = get_metadata(client, public_keys).await {
log::warn!("Failed to get metadata for contact list: {e}");
}
}
Kind::InboxRelays => {
_ = tx.send_async(Dispatch::Relays(event.into_owned())).await;
}
@@ -198,72 +205,6 @@ impl PersonRegistry {
}
}
/// Load all user profiles from the database
fn load(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
let task: Task<Result<Vec<Person>, Error>> = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
let persons = events
.into_iter()
.map(|event| {
let metadata = Metadata::from_json(event.content).unwrap_or_default();
Person::new(event.pubkey, metadata)
})
.collect();
Ok(persons)
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(persons) = task.await {
this.update(cx, |this, cx| {
this.bulk_insert(persons, cx);
})
.ok();
}
}));
}
/// Load cached emoji lists from the database
fn load_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let client = nostr.read(cx).client();
type EmojiLists = Vec<(PublicKey, Vec<(String, Url)>)>;
let task: Task<Result<EmojiLists, Error>> = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Emojis).limit(200);
let mut events: Vec<Event> =
client.database().query(filter).await?.into_iter().collect();
// Keep only the newest list per author
events.sort_by_key(|event| Reverse(event.created_at));
let mut seen = HashSet::new();
let lists = events
.into_iter()
.filter(|event| seen.insert(event.pubkey))
.map(|event| (event.pubkey, emojis_from_tags(&event.tags)))
.collect();
Ok(lists)
});
self.tasks.push(cx.spawn(async move |this, cx| {
if let Ok(lists) = task.await {
this.update(cx, |this, cx| {
for (public_key, emojis) in lists {
this.set_emojis_for(public_key, emojis, cx);
}
})
.ok();
}
}));
}
/// Subscribe to the signed-in user's emoji list
fn get_emojis(&mut self, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
@@ -283,130 +224,82 @@ impl PersonRegistry {
}
/// Set profile encryption keys announcement
fn set_announcement(&mut self, event: &Event, cx: &mut App) {
fn set_announcement(&mut self, event: &Event, cx: &mut Context<Self>) {
let Some(announcement) = Announcement::from_event(event) else {
log::warn!("Ignoring announcement with missing or invalid key tag");
return;
};
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| {
person.set_announcement(announcement);
cx.notify();
});
} else {
let person =
Person::new(event.pubkey, Metadata::default()).with_announcement(announcement);
self.insert(person, cx);
}
cx.emit(PersonEvent::Announcement {
public_key: event.pubkey,
announcement,
});
}
/// Set messaging relays for a person
fn set_messaging_relays(&mut self, event: &Event, cx: &mut App) {
let urls: Vec<RelayUrl> = nip17::extract_relay_list(event).collect();
if let Some(person) = self.persons.get(&event.pubkey) {
person.update(cx, |person, cx| {
person.set_messaging_relays(urls);
cx.notify();
});
} else {
let person = Person::new(event.pubkey, Metadata::default()).with_messaging_relays(urls);
self.insert(person, cx);
}
fn set_messaging_relays(&mut self, event: &Event, cx: &mut Context<Self>) {
cx.emit(PersonEvent::MessagingRelays {
public_key: event.pubkey,
relays: nip17::extract_relay_list(event).collect(),
});
}
/// Set custom emoji for a person from their emoji list event
fn set_emojis(&mut self, event: &Event, cx: &mut App) {
fn set_emojis(&mut self, event: &Event, cx: &mut Context<Self>) {
self.set_emojis_for(event.pubkey, emojis_from_tags(&event.tags), cx);
}
/// Set custom emoji for a person
fn set_emojis_for(&mut self, public_key: PublicKey, emojis: Vec<(String, Url)>, cx: &mut App) {
if let Some(person) = self.persons.get(&public_key) {
person.update(cx, |person, cx| {
person.set_emojis(emojis);
cx.notify();
});
} else {
let person = Person::new(public_key, Metadata::default()).with_emojis(emojis);
self.insert(person, cx);
}
fn set_emojis_for(
&mut self,
public_key: PublicKey,
emojis: Vec<(String, Url)>,
cx: &mut Context<Self>,
) {
cx.emit(PersonEvent::Emojis { public_key, emojis });
}
/// Insert batch of persons
fn bulk_insert(&mut self, persons: Vec<Person>, cx: &mut Context<Self>) {
for person in persons.into_iter() {
let public_key = person.public_key();
self.persons
.entry(public_key)
.or_insert_with(|| cx.new(|_| person));
}
cx.notify();
}
/// Insert or update a person
pub fn insert(&mut self, person: Person, cx: &mut App) {
/// Publish a person's profile metadata to every directory
pub fn insert(&mut self, person: Person, cx: &mut Context<Self>) {
let public_key = person.public_key();
self.release_in_flight(&public_key);
match self.persons.get(&public_key) {
Some(this) => {
this.update(cx, |this, cx| {
this.set_metadata(person.metadata());
cx.notify();
});
}
None => {
self.persons.insert(public_key, cx.new(|_| person));
}
}
cx.emit(PersonEvent::Metadata(Box::new(person)));
}
/// Get single person by public key
pub fn get(&self, public_key: &PublicKey, cx: &App) -> Person {
if let Some(person) = self.persons.get(public_key) {
return person.read(cx).clone();
}
/// Queue a batched metadata request for keys without one in flight
pub(crate) fn fetch<I>(&self, keys: I)
where
I: IntoIterator<Item = PublicKey>,
{
let mut queue = Vec::new();
{
let mut in_flight = match self.in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
let public_key = *public_key;
if self.seen.write().unwrap().insert(public_key) {
let sender = self.sender.clone();
// Spawn background task to request metadata
cx.background_spawn(async move {
if let Err(e) = sender.send_async(public_key).await {
log::warn!("Failed to send public key for metadata request: {e}");
for key in keys {
if in_flight.insert(key) {
queue.push(key);
}
})
.detach();
}
}
// Return a temporary profile with default metadata
Person::new(public_key, Metadata::default())
for public_key in queue {
if let Err(error) = self.sender.send(public_key) {
log::warn!("Failed to queue metadata request: {error}");
}
}
}
/// The signed-in user's custom emoji
pub fn current_emojis(cx: &App) -> Vec<(String, Url)> {
let registry = Self::global(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return Vec::new();
/// Allow future fetches for a key once its metadata has arrived
fn release_in_flight(&self, public_key: &PublicKey) {
let mut in_flight = match self.in_flight.write() {
Ok(in_flight) => in_flight,
Err(poisoned) => poisoned.into_inner(),
};
registry.read(cx).get(&public_key, cx).emojis().to_vec()
}
/// An `emoji` tag for every signed-in user's custom emoji referenced.
pub fn emoji_tags(content: &str, cx: &App) -> Vec<Tag> {
let registry = Self::global(cx);
let nostr = NostrRegistry::global(cx);
let Some(public_key) = nostr.read(cx).current_user() else {
return Vec::new();
};
registry.read(cx).get(&public_key, cx).emoji_tags(content)
in_flight.remove(public_key);
}
}
-21
View File
@@ -57,21 +57,6 @@ impl Person {
}
}
/// Build profile encryption keys announcement
pub(crate) fn with_announcement(mut self, announcement: Announcement) -> Self {
self.announcement = Some(announcement);
self
}
/// Build profile messaging relays
pub(crate) fn with_messaging_relays<I>(mut self, relays: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
self.messaging_relays = relays.into_iter().collect();
self
}
/// Get profile public key
pub fn public_key(&self) -> PublicKey {
self.public_key
@@ -92,12 +77,6 @@ impl Person {
self.emojis = emojis;
}
/// Build custom emoji from the person's emoji list
pub(crate) fn with_emojis(mut self, emojis: Vec<(String, Url)>) -> Self {
self.emojis = emojis;
self
}
/// An `emoji` tag for every custom emoji referenced as `:shortcode:` in `content`
pub fn emoji_tags(&self, content: &str) -> Vec<Tag> {
let mut tags = Vec::new();
+28 -5
View File
@@ -1,6 +1,8 @@
use std::path::PathBuf;
use anyhow::{Error, anyhow};
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Global, ImageSource, Subscription, Task};
use nostr_sdk::prelude::*;
use serde::{Deserialize, Serialize};
use smallvec::{SmallVec, smallvec};
@@ -12,6 +14,8 @@ pub fn init(cx: &mut App) {
const DEFAULT_FILE_SERVER: &str = "https://nostr.download/";
const LEGACY_FILE_SERVER: &str = "blossom.band";
/// Built-in image shown behind chat and group panels
pub const DEFAULT_BACKGROUND: &str = "brand/default-bg.jpg";
macro_rules! setting_accessors {
($(pub $field:ident: $type:ty),* $(,)?) => {
@@ -56,6 +60,8 @@ setting_accessors! {
pub screening: bool,
pub nip4e: bool,
pub file_server: Url,
pub panel_background: bool,
pub panel_background_path: Option<PathBuf>,
}
setting_getters! {
@@ -114,12 +120,8 @@ impl RoomConfig {
}
}
/// Settings
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
/// Which appearance to paint: follow the OS, or pin light or dark.
/// `alias` reads the pre-bezel `theme_mode` key so old config files keep
/// their pinned choice.
#[serde(default, alias = "theme_mode")]
pub appearance_mode: AppearanceMode,
@@ -132,6 +134,14 @@ pub struct Settings {
/// Enable decoupling encryption key
pub nip4e: bool,
/// Show an image behind chat and group panels
#[serde(default)]
pub panel_background: bool,
/// Custom image for the panel background; the built-in one when unset
#[serde(default)]
pub panel_background_path: Option<PathBuf>,
/// Trusted relays; Coop will automatically authenticate with these relays
pub trusted_relays: Vec<String>,
@@ -154,6 +164,8 @@ impl Default for Settings {
hide_avatar: false,
screening: true,
nip4e: false,
panel_background: true,
panel_background_path: None,
trusted_relays: vec![],
file_server: Url::parse(DEFAULT_FILE_SERVER).unwrap(),
pinned_rooms: vec![],
@@ -272,6 +284,17 @@ impl AppSettings {
self.inner.read(cx).nip4e
}
/// The image to show behind chat and group panels, when enabled
pub fn panel_background_source(cx: &App) -> Option<ImageSource> {
if !Self::get_panel_background(cx) {
return None;
}
Some(match Self::get_panel_background_path(cx) {
Some(path) => path.into(),
None => DEFAULT_BACKGROUND.into(),
})
}
/// Check if the chat room is pinned
pub fn pinned_room(&self, id: u64, cx: &App) -> bool {
self.inner.read(cx).pinned_rooms.contains(&id)
+5 -4
View File
@@ -5,12 +5,14 @@ pub const CLIENT_NAME: &str = "Coop";
pub const APP_ID: &str = "su.reya.coop";
/// Keyring name
pub(crate) const MASTER_KEYRING: &str = "Coop Master Key";
pub const USER_KEYRING: &str = "Coop User Credential";
pub const USER_KEYRING: &str = "Coop Safe Storage";
/// Default timeout for subscription
pub const TIMEOUT: u64 = 2;
/// Default timeout for Nostr Connect
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
/// Default delay for searching
pub const FIND_DELAY: u64 = 600;
@@ -31,9 +33,8 @@ pub(crate) const INDEXER_RELAYS: [&str; 2] =
pub(crate) const SEARCH_RELAYS: [&str; 2] = ["wss://antiprimal.net", "wss://search.nos.today"];
/// Default bootstrap relays
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
pub const BOOTSTRAP_RELAYS: [&str; 3] = [
"wss://relay.ditto.pub",
"wss://relay.primal.net",
"wss://relay.nostr.net",
"wss://profiles.nostr1.com",
];
+255 -88
View File
@@ -4,7 +4,7 @@ use anyhow::{Error, anyhow};
#[cfg(not(target_arch = "wasm32"))]
use browser_signer_proxy::prelude::*;
use common::config_dir;
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, TaskExt};
use gpui_tokio::Tokio;
use instant::Duration;
use nostr_connect::prelude::*;
@@ -53,6 +53,8 @@ pub enum StateEvent {
NoSigner,
/// The signer has changed
SignerChanged,
/// A stored credential needs a passphrase to unlock
PassphraseRequired,
/// An error occurred
Error(String),
}
@@ -75,16 +77,12 @@ impl StateEvent {
pub struct NostrRegistry {
/// Nostr client
client: Client,
/// Universal signer
signer: UniversalSigner,
/// Current user's public key
current_user: Option<PublicKey>,
/// Whether the initial credential check has concluded
ready: bool,
/// Whether the stored credential needs a passphrase to unlock
passphrase_required: bool,
/// Tasks for asynchronous operations
tasks: Vec<Task<Result<(), Error>>>,
}
@@ -126,6 +124,8 @@ impl NostrRegistry {
.gossip(NostrGossipMemory::unbounded())
.gossip_config(GossipConfig::default().no_background_refresh())
.connect_timeout(Duration::from_secs(10))
.verify_subscriptions(true)
.ban_relay_on_mismatch(true)
.sleep_when_idle(SleepWhenIdle::Enabled {
timeout: Duration::from_secs(600),
})
@@ -137,14 +137,13 @@ impl NostrRegistry {
this.connect_bootstrap_relays(cx);
if cfg!(target_arch = "wasm32") {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner);
} else if let Some(secret) = cli_key {
// Use CLI-provided key -- same path as get_user_credential
// Use CLI-provided key -- same path as restore_session
let keys = Keys::new(secret);
this.set_signer(keys, cx);
} else {
this.get_user_credential(cx);
this.restore_session(cx);
}
});
});
@@ -153,7 +152,7 @@ impl NostrRegistry {
client,
signer,
current_user: None,
ready: false,
passphrase_required: false,
tasks: vec![],
}
}
@@ -173,17 +172,8 @@ impl NostrRegistry {
self.current_user
}
fn mark_ready(&mut self, cx: &mut Context<Self>) {
if self.ready {
return;
}
self.ready = true;
cx.notify();
}
/// Update the signer
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: std::error::Error + Send + Sync + 'static,
@@ -196,7 +186,8 @@ impl NostrRegistry {
this.update(cx, |this, cx| {
this.signer.swap_inner(new_signer);
this.current_user = Some(public_key);
this.mark_ready(cx);
this.passphrase_required = false;
cx.emit(StateEvent::SignerChanged);
cx.notify();
})?;
@@ -247,93 +238,249 @@ impl NostrRegistry {
}));
}
/// Check the user's credential and set the signer if valid
fn get_user_credential(&mut self, cx: &mut Context<Self>) {
/// Restore the signer from the stored credential
fn restore_session(&mut self, cx: &mut Context<Self>) {
let user_keyring = cx.read_credentials(USER_KEYRING);
let master_keyring = self.get_master_key(cx);
self.tasks.push(cx.spawn(async move |this, cx| {
match user_keyring.await {
Ok(Some((_username, secret))) => {
let content = String::from_utf8(secret)?;
let result = async {
match user_keyring.await {
Ok(Some((_username, secret))) => {
let content = String::from_utf8(secret)?;
if content.starts_with("nsec1") {
let secret_key = SecretKey::parse(&content)?;
let keys = Keys::new(secret_key);
if content.starts_with("nsec1") {
let keys = Keys::new(SecretKey::parse(&content)?);
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
} else if content.starts_with("bunker://") {
// The master key used for Nostr Connect is embedded in
// the stored URI so reconnects reuse the same local key
let (base, keys) = extract_master_key(&content);
let uri = NostrConnectUri::parse(base)?;
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
cx.notify();
// Construct the nostr connect signer
let mut signer = NostrConnect::new(
uri,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
this.update(cx, |this, cx| {
this.set_signer(signer, cx);
})?;
} else if content.starts_with("ncryptsec1") {
this.update(cx, |this, cx| {
this.passphrase_required = true;
cx.emit(StateEvent::PassphraseRequired);
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.connect_proxy(cx);
})?;
} else {
this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner);
})?;
}
}
_ => {
this.update(cx, |_, cx| {
cx.emit(StateEvent::NoSigner);
})?;
} else if content.starts_with("bunker://") {
let keys = master_keyring.await;
let timeout = Duration::from_secs(30);
let uri = NostrConnectUri::parse(content)?;
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
this.update(cx, |this, cx| {
this.set_signer(signer, cx);
cx.notify();
})?;
} else if content == "proxy" {
#[cfg(not(target_arch = "wasm32"))]
this.update(cx, |this, cx| {
this.mark_ready(cx);
this.connect_proxy(cx);
})?;
} else {
this.update(cx, |this, cx| this.mark_ready(cx))?;
}
}
_ => {
this.update(cx, |this, cx| {
this.mark_ready(cx);
cx.emit(StateEvent::NoSigner);
})?;
}
Ok::<(), Error>(())
}
.await;
if let Err(e) = result {
this.update(cx, |_, cx| {
cx.emit(StateEvent::error(e.to_string()));
cx.emit(StateEvent::NoSigner);
})?;
}
Ok(())
}));
}
/// Get the master key that used for Nostr Connect
pub fn get_master_key(&self, cx: &App) -> Task<Keys> {
if cfg!(target_arch = "wasm32") {
return cx.background_spawn(async move { Keys::generate() });
}
/// Unlock the stored encrypted key with its passphrase
pub fn restore_with_passphrase(
&mut self,
password: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let password = password.to_owned();
let user_keyring = cx.read_credentials(USER_KEYRING);
let task = cx.read_credentials(MASTER_KEYRING);
cx.spawn(async move |this, cx| {
let content = user_keyring
.await?
.map(|(_username, secret)| String::from_utf8(secret))
.transpose()?
.ok_or_else(|| anyhow!("no stored credential; nothing to unlock"))?;
cx.spawn(async move |cx| {
let (keys, new_key) = match task.await {
Ok(Some((_user, secret))) => match SecretKey::from_slice(&secret) {
Ok(secret_key) => (Keys::new(secret_key), false),
_ => (Keys::generate(), true),
},
_ => (Keys::generate(), true),
};
if new_key {
let keys_clone = keys.clone();
let username = keys_clone.public_key().to_hex();
let password = keys_clone.secret_key().to_secret_bytes();
cx.update(|cx| {
let task = cx.write_credentials(MASTER_KEYRING, &username, &password);
cx.background_spawn(async move { task.await.ok() }).detach();
});
if !content.starts_with("ncryptsec1") {
return Err(anyhow!("stored credential is not passphrase-encrypted"));
}
keys
let decrypt_task = cx.background_spawn(async move {
let encrypted = EncryptedSecretKey::from_bech32(&content)?;
let secret = encrypted.decrypt(&password)?;
Ok::<_, Error>(Keys::new(secret))
});
let keys = decrypt_task.await?;
let public_key = keys.public_key();
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
Ok(public_key)
})
}
/// Import an `nsec1...` secret key, storing it NIP-49 encrypted with `password`
pub fn import_nsec(
&mut self,
nsec: &str,
password: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let keys = match SecretKey::parse(nsec.trim()) {
Ok(secret) => Keys::new(secret),
Err(e) => return Task::ready(Err(anyhow!(e))),
};
if password.is_empty() {
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
}
let password = password.to_owned();
cx.spawn(async move |this, cx| {
let job = cx.background_spawn(async move {
let encrypted =
EncryptedSecretKey::new(keys.secret_key(), &password, 16, KeySecurity::Medium)?;
let ncryptsec = encrypted.to_bech32()?;
Ok::<_, Error>((keys, ncryptsec))
});
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
write.await?;
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
Ok(public_key)
})
}
/// Import an NIP-49 encrypted secret key (`ncryptsec1...`), decrypting it with `password`
pub fn import_ncryptsec(
&mut self,
ncryptsec: &str,
password: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let ncryptsec = ncryptsec.trim().to_owned();
if password.is_empty() {
return Task::ready(Err(anyhow!("Passphrase must not be empty")));
}
let password = password.to_owned();
cx.spawn(async move |this, cx| {
let stored = ncryptsec.clone();
let decrypt_task = cx.background_spawn(async move {
let encrypted = EncryptedSecretKey::from_bech32(&ncryptsec)?;
let secret = encrypted.decrypt(&password)?;
Ok::<_, Error>(Keys::new(secret))
});
let keys = decrypt_task.await?;
let public_key = keys.public_key();
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), stored.as_bytes())
});
write.await?;
this.update(cx, |this, cx| {
this.set_signer(keys, cx);
})?;
Ok(public_key)
})
}
/// Import a `bunker://...` URI (NIP-46), connecting to the remote signer
pub fn import_bunker(
&mut self,
uri: &str,
cx: &mut Context<Self>,
) -> Task<Result<PublicKey, Error>> {
let uri_string = uri.trim().to_owned();
let connect_uri = match NostrConnectUri::parse(&uri_string) {
Ok(uri) => uri,
Err(e) => return Task::ready(Err(anyhow!(e))),
};
let keys = Keys::generate();
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
cx.spawn(async move |this, cx| {
// Construct the nostr connect signer
let mut signer = NostrConnect::new(
connect_uri,
keys,
Duration::from_secs(NOSTR_CONNECT_TIMEOUT),
None,
)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
// Verify the bunker responds before persisting the credential
let public_key = signer.get_public_key_async().await?;
write.await?;
this.update(cx, |this, cx| {
this.set_signer(signer, cx);
})?;
Ok(public_key)
})
}
/// Sign out and remove the stored credential
pub fn sign_out(&mut self, cx: &mut Context<Self>) {
self.current_user = None;
self.passphrase_required = false;
cx.delete_credentials(USER_KEYRING).detach_and_log_err(cx);
cx.emit(StateEvent::SignerChanged);
cx.emit(StateEvent::NoSigner);
cx.notify();
}
/// Start the browser proxy
#[cfg(not(target_arch = "wasm32"))]
pub fn connect_proxy(&mut self, cx: &mut Context<Self>) {
@@ -514,3 +661,23 @@ impl NostrRegistry {
})
}
}
// Extract the base URI and the master key from a bunker credential with `master=<nsec>`
fn extract_master_key(credential: &str) -> (&str, Keys) {
match credential.split_once("master=") {
Some((base, nsec)) => {
let keys = SecretKey::parse(nsec)
.map(Keys::new)
.unwrap_or_else(|_| Keys::generate());
(base.trim_end_matches(['?', '&']), keys)
}
None => (credential, Keys::generate()),
}
}
// Embed the master key in a bunker URI so reconnects reuse the same local key
fn with_master_key(uri: &str, keys: &Keys) -> String {
let separator = if uri.contains('?') { '&' } else { '?' };
let nsec = keys.secret_key().to_bech32().expect("infallible");
format!("{uri}{separator}master={nsec}")
}
+20 -43
View File
@@ -5,32 +5,9 @@ use std::pin::Pin;
use std::sync::{Arc, RwLock};
use nostr_connect::client::AuthUrlHandler;
use nostr_sdk::error::Error as SignerError;
use nostr_sdk::prelude::*;
#[derive(Debug)]
pub struct UniversalSignerError(Box<dyn Error + Send + Sync + 'static>);
impl fmt::Display for UniversalSignerError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
impl Error for UniversalSignerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
Some(&*self.0)
}
}
impl UniversalSignerError {
pub fn new<E>(err: E) -> Self
where
E: Error + Send + Sync + 'static,
{
UniversalSignerError(Box::new(err))
}
}
#[derive(Clone, Debug)]
pub struct UniversalSigner {
inner: Arc<RwLock<Arc<dyn InnerSigner>>>,
@@ -50,7 +27,7 @@ impl UniversalSigner {
}
/// Swap the inner signer in-place. All clones see the new signer.
pub(crate) fn swap_inner<T>(&self, new_signer: T)
pub fn swap_inner<T>(&self, new_signer: T)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
<T as AsyncGetPublicKey>::Error: Error + Send + Sync + 'static,
@@ -65,7 +42,7 @@ impl UniversalSigner {
&self,
public_key: &PublicKey,
content: &str,
) -> Result<String, UniversalSignerError> {
) -> Result<String, SignerError> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
inner.nip44_encrypt_async(public_key, content).await
}
@@ -75,7 +52,7 @@ impl UniversalSigner {
&self,
public_key: &PublicKey,
payload: &str,
) -> Result<String, UniversalSignerError> {
) -> Result<String, SignerError> {
let inner = self.inner.read().expect("RwLock poisoned").clone();
inner.nip44_decrypt_async(public_key, payload).await
}
@@ -84,21 +61,21 @@ impl UniversalSigner {
trait InnerSigner: fmt::Debug + Send + Sync + 'static {
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>>;
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>>;
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>>;
fn nip44_encrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
fn nip44_decrypt_async<'a>(
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>>;
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>>;
}
#[derive(Debug)]
@@ -113,22 +90,22 @@ where
{
fn get_public_key_async(
&self,
) -> Pin<Box<dyn Future<Output = Result<PublicKey, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<PublicKey, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncGetPublicKey::get_public_key_async(&self.0)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
fn sign_event_async(
&self,
unsigned: UnsignedEvent,
) -> Pin<Box<dyn Future<Output = Result<Event, UniversalSignerError>> + Send + '_>> {
) -> Pin<Box<dyn Future<Output = Result<Event, SignerError>> + Send + '_>> {
Box::pin(async move {
AsyncSignEvent::sign_event_async(&self.0, unsigned)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -136,11 +113,11 @@ where
&'a self,
public_key: &'a PublicKey,
content: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_encrypt_async(&self.0, public_key, content)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
@@ -148,17 +125,17 @@ where
&'a self,
public_key: &'a PublicKey,
payload: &'a str,
) -> Pin<Box<dyn Future<Output = Result<String, UniversalSignerError>> + Send + 'a>> {
) -> Pin<Box<dyn Future<Output = Result<String, SignerError>> + Send + 'a>> {
Box::pin(async move {
AsyncNip44::nip44_decrypt_async(&self.0, public_key, payload)
.await
.map_err(UniversalSignerError::new)
.map_err(SignerError::other)
})
}
}
impl AsyncGetPublicKey for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn get_public_key_async(
&self,
@@ -169,7 +146,7 @@ impl AsyncGetPublicKey for UniversalSigner {
}
impl AsyncSignEvent for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn sign_event_async(
&self,
@@ -181,7 +158,7 @@ impl AsyncSignEvent for UniversalSigner {
}
impl AsyncNip44 for UniversalSigner {
type Error = UniversalSignerError;
type Error = SignerError;
fn nip44_encrypt_async<'a>(
&'a self,
@@ -211,7 +188,7 @@ impl AuthUrlHandler for CoopAuthUrlHandler {
auth_url: Url,
) -> Pin<Box<dyn Future<Output = Result<(), nostr_connect::error::Error>> + Send + '_>> {
Box::pin(async move {
webbrowser::open(auth_url.as_str()).unwrap();
webbrowser::open(auth_url.as_str()).map_err(nostr_connect::error::Error::other)?;
Ok(())
})
}
+46 -24
View File
@@ -6,10 +6,10 @@ use std::sync::Arc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, AnyView, App, AppContext as _, Bounds, Context, Div, Empty, Entity,
GlobalElementId, InspectorElementId, InteractiveElement as _, IntoElement, LayoutId,
MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render, ScrollHandle,
SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement, Styled as _,
WeakEntity, Window, actions, div, px,
GlobalElementId, ImageSource, InspectorElementId, InteractiveElement as _, IntoElement,
LayoutId, MouseButton, MouseMoveEvent, MouseUpEvent, ParentElement as _, Pixels, Render,
ScrollHandle, SharedString, Stateful, StatefulInteractiveElement as _, Style, StyleRefinement,
Styled as _, WeakEntity, Window, actions, div, px,
};
pub use gpui_base::dock::{DockArea, DockLayout, DockPlacement};
use gpui_base::dock::{
@@ -24,7 +24,7 @@ use crate::resizable::{resize_handle, resize_handle_appearance};
use crate::tab::Tab;
use crate::tab::tab_bar::TabBar;
use crate::title_bar::{TRAFFIC_LIGHT_PADDING, title_bar_drag_handlers, window_controls};
use crate::{IconName, Selectable, Sizable, StyledExt, h_flex, v_flex};
use crate::{IconName, Selectable, Sizable, StyledExt, background, h_flex, v_flex};
mod panel;
pub use panel::*;
@@ -431,6 +431,11 @@ impl TabGroupSkin {
let tabs_count = group.panels().len();
let is_title_bar = self.is_title_bar_group(group, cx);
let wallpaper = group
.active_panel()
.and_then(|panel| panel_background(panel, cx))
.is_some();
let displayed = group.active_panel().map(|panel| panel.panel_id(cx));
let displayed_ix = displayed.and_then(|displayed| {
group
@@ -467,7 +472,7 @@ impl TabGroupSkin {
let bar = TabBar::new("tab-bar")
.track_scroll(&self.scroll_handle)
.h(TABBAR_HEIGHT)
.bg(cx.theme().bg)
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.when(needs_traffic_light_padding, |this| {
this.pl(px(TRAFFIC_LIGHT_PADDING))
})
@@ -569,7 +574,7 @@ impl TabGroupSkin {
h_flex()
.h(TABBAR_HEIGHT)
.w_full()
.bg(cx.theme().bg)
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.child(bar.flex_1())
.child(window_controls())
.into_any_element()
@@ -647,23 +652,31 @@ impl TabGroupSkin {
}
impl TabGroupRenderer for TabGroupSkin {
fn frame(&self, group: &TabGroupContext, _: &mut Window, _cx: &mut App) -> Stateful<Div> {
div().id("tab-panel").when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
fn frame(&self, group: &TabGroupContext, _: &mut Window, cx: &mut App) -> Stateful<Div> {
let wallpaper = group
.active_panel()
.and_then(|panel| panel_background(panel, cx));
div()
.id("tab-panel")
.relative()
.when_some(wallpaper, |this, source| this.child(background(source, cx)))
.when(!group.is_collapsed(), |this| {
this.on_action({
let group = TabGroupContext::clone(group);
move |_: &ToggleZoom, window, cx| group.toggle_zoom(window, cx)
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
.on_action({
let group = TabGroupContext::clone(group);
move |_: &ClosePanel, window, cx| {
let Some(panel) = group.active_panel() else {
return;
};
let panel = panel.panel_id(cx);
group.close(panel, window, cx);
}
})
})
}
fn render_tab_bar(
@@ -710,6 +723,11 @@ impl TabGroupRenderer for TabGroupSkin {
return Empty.into_any_element();
}
let wallpaper = group
.active_panel()
.and_then(|active| panel_background(active, cx))
.is_some();
v_flex()
.id("tab-content")
.group("")
@@ -718,7 +736,7 @@ impl TabGroupRenderer for TabGroupSkin {
.child(
div()
.size_full()
.bg(cx.theme().bg)
.when(!wallpaper, |this| this.bg(cx.theme().bg))
.overflow_hidden()
.child(panel.cached(StyleRefinement::default().v_flex().size_full())),
)
@@ -781,3 +799,7 @@ fn panel_title(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> AnyElem
None => SharedString::from(panel.panel_name(cx)).into_any_element(),
}
}
fn panel_background(panel: &Arc<dyn gpui_base::dock::PanelView>, cx: &App) -> Option<ImageSource> {
PanelHandle::of(panel).and_then(|handle| handle.panel().background(cx))
}
+25 -3
View File
@@ -2,8 +2,8 @@ use std::any::Any;
use std::sync::Arc;
use gpui::{
AnyElement, AnyView, App, Element, Entity, EventEmitter, FocusHandle, Focusable, Render,
SharedString, Window,
AnyElement, AnyView, App, Context, Element, Entity, EventEmitter, FocusHandle, Focusable,
ImageSource, Render, SharedString, Window,
};
use gpui_base::dock::{PanelId, PanelState};
@@ -39,6 +39,9 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
/// Set active state of the panel.
fn set_active(&self, _active: bool, _window: &mut Window, _cx: &mut App) {}
/// Called when the panel leaves the dock.
fn on_removed(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
/// Set zoomed state of the panel.
fn set_zoomed(&self, _zoomed: bool, _cx: &mut App) {}
@@ -46,6 +49,11 @@ pub trait Panel: EventEmitter<PanelEvent> + Render + Focusable {
fn toolbar_buttons(&self, _window: &Window, _cx: &App) -> Vec<Button> {
vec![]
}
/// An optional image drawn behind the panel and its tab bar.
fn background(&self, _cx: &App) -> Option<ImageSource> {
None
}
}
pub(crate) trait PanelView: 'static + Send + Sync {
@@ -54,8 +62,10 @@ pub(crate) trait PanelView: 'static + Send + Sync {
fn closable(&self, cx: &App) -> bool;
fn zoomable(&self, cx: &App) -> bool;
fn visible(&self, cx: &App) -> bool;
fn background(&self, cx: &App) -> Option<ImageSource>;
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App);
fn set_zoomed(&self, zoomed: bool, cx: &mut App);
fn on_removed(&self, window: &mut Window, cx: &mut App);
fn view(&self) -> AnyView;
fn focus_handle(&self, cx: &App) -> FocusHandle;
}
@@ -81,6 +91,10 @@ impl<T: Panel> PanelView for Entity<T> {
self.read(cx).visible(cx)
}
fn background(&self, cx: &App) -> Option<ImageSource> {
self.read(cx).background(cx)
}
fn set_active(&self, active: bool, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.set_active(active, window, cx);
@@ -93,6 +107,12 @@ impl<T: Panel> PanelView for Entity<T> {
})
}
fn on_removed(&self, window: &mut Window, cx: &mut App) {
self.update(cx, |this, cx| {
this.on_removed(window, cx);
})
}
fn view(&self) -> AnyView {
self.clone().into()
}
@@ -163,7 +183,9 @@ impl gpui_base::dock::PanelView for PanelHandle {
) {
}
fn on_removed(&self, _: &mut Window, _: &mut App) {}
fn on_removed(&self, window: &mut Window, cx: &mut App) {
self.panel.on_removed(window, cx);
}
fn view(&self) -> AnyView {
self.panel.view()
+17 -1
View File
@@ -1,4 +1,7 @@
use gpui::{App, Div, Pixels, Refineable, StyleRefinement, Styled, div, px};
use gpui::{
App, Div, ImageSource, ObjectFit, ParentElement, Pixels, Refineable, StyleRefinement, Styled,
StyledImage, div, img, px,
};
pub use gpui_base::component_traits::{Disableable, Selectable};
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius, surface_shadows};
@@ -17,6 +20,19 @@ pub fn divider(cx: &App) -> Div {
div().w_full().h_px().bg(cx.theme().border_faint)
}
/// Returns a full-bleed image with a wash for readability, to show behind panel content.
pub fn background<I>(image: I, cx: &App) -> Div
where
I: Into<ImageSource>,
{
let wash = if cx.theme().is_dark() { 0.5 } else { 0.2 };
div()
.absolute()
.inset_0()
.child(img(image).size_full().object_fit(ObjectFit::Cover))
.child(div().absolute().inset_0().bg(cx.theme().bg.opacity(wash)))
}
macro_rules! font_weight {
($fn:ident, $const:ident) => {
/// [docs](https://tailwindcss.com/docs/font-weight)
+158 -123
View File
@@ -1,4 +1,4 @@
use anyhow::{Error, anyhow};
use anyhow::Error;
use gpui::prelude::FluentBuilder;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
@@ -6,11 +6,11 @@ use gpui::{
};
use instant::Duration;
use nostr_connect::prelude::*;
use state::{CoopAuthUrlHandler, NostrRegistry, USER_KEYRING};
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, surface_radius};
use ui::button::{Button, ButtonVariants};
use ui::input::{Input, InputEvent, InputState};
use ui::{Disableable, StyledExt, WindowExtension, v_flex};
use ui::{Disableable, WindowExtension, v_flex};
pub fn open(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new(window, cx));
@@ -25,146 +25,157 @@ pub fn open(window: &mut Window, cx: &mut App) {
});
}
/// Open a passphrase-only dialog to unlock the stored encrypted key
pub fn open_unlock(window: &mut Window, cx: &mut App) {
let import = cx.new(|cx| ImportIdentity::new_unlock(window, cx));
window.open_dialog(cx, move |this, _window, _cx| {
this.width(px(450.))
.show_close(false)
.overlay_closable(false)
.keyboard(false)
.title("Unlock your identity")
.child(import.clone())
});
}
#[derive(Debug, PartialEq, Eq)]
enum Mode {
/// Import a credential (nsec, ncryptsec or bunker)
Import,
/// Unlock the stored encrypted key with its passphrase
Unlock,
}
#[derive(Debug)]
pub struct ImportIdentity {
mode: Mode,
/// Secret key input
key_input: Entity<InputState>,
/// Password input (if required)
/// Passphrase input
pass_input: Entity<InputState>,
/// Repeated passphrase input when encrypting a plaintext key
repass_input: Entity<InputState>,
/// Error message
error: Entity<Option<SharedString>>,
/// Whether the user is currently loading
loading: bool,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Option<Subscription>,
_subscriptions: Vec<Subscription>,
}
impl ImportIdentity {
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("nsec or bunker://"));
Self::build(Mode::Import, window, cx)
}
pub fn new_unlock(window: &mut Window, cx: &mut Context<Self>) -> Self {
Self::build(Mode::Unlock, window, cx)
}
fn build(mode: Mode, window: &mut Window, cx: &mut Context<Self>) -> Self {
let key_input = cx.new(|cx| InputState::new(window, cx).placeholder("secret or bunker://"));
let pass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let repass_input = cx.new(|cx| InputState::new(window, cx).masked(true));
let error = cx.new(|_| None);
let input_subscription =
cx.subscribe_in(&key_input, window, |this, _input, event, window, cx| {
let subscriptions = vec![
cx.subscribe_in(&key_input, window, |this, _input, event, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(window, cx);
this.login(cx);
};
});
}),
cx.subscribe_in(&pass_input, window, |this, _input, event, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(cx);
};
}),
cx.subscribe_in(&repass_input, window, |this, _input, event, _window, cx| {
if let InputEvent::PressEnter { .. } = event {
this.login(cx);
};
}),
];
Self {
mode,
key_input,
pass_input,
repass_input,
error,
loading: false,
tasks: vec![],
_subscription: Some(input_subscription),
_subscriptions: subscriptions,
}
}
fn login(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let value = self.key_input.read(cx).value();
let password = self.pass_input.read(cx).value();
fn login(&mut self, cx: &mut Context<Self>) {
let password = self.pass_input.read(cx).value().to_string();
let nostr = NostrRegistry::global(cx);
// Set loading state
if self.mode == Mode::Unlock {
if password.is_empty() {
self.set_error("Passphrase is required", cx);
return;
}
self.set_loading(true, cx);
let task = nostr.update(cx, |this, cx| this.restore_with_passphrase(&password, cx));
self.finish(task, cx);
return;
}
let value = self.key_input.read(cx).value().trim().to_string();
self.set_loading(true, cx);
if value.starts_with("ncryptsec1") {
self.ncryptsec(value, password, window, cx);
return;
}
if value.starts_with("bunker://") {
match NostrConnectUri::parse(value) {
Ok(uri) => {
self.bunker(uri, window, cx);
}
Err(e) => {
self.set_error(e.to_string(), cx);
}
}
let task = nostr.update(cx, |this, cx| this.import_bunker(&value, cx));
self.finish(task, cx);
return;
}
if let Ok(secret) = SecretKey::parse(&value) {
let keys = Keys::new(secret);
let nostr = NostrRegistry::global(cx);
if value.starts_with("ncryptsec1") {
if password.is_empty() {
self.set_error("Passphrase is required", cx);
return;
}
// Update the signer
nostr.update(cx, |this, cx| {
this.set_signer(keys, cx);
});
} else {
self.set_error("Invalid key", cx);
let task = nostr.update(cx, |this, cx| this.import_ncryptsec(&value, &password, cx));
self.finish(task, cx);
return;
}
// A plaintext key is encrypted with the passphrase before it is stored
if value.starts_with("nsec1") || SecretKey::parse(&value).is_ok() {
if password.is_empty() {
self.set_error("Passphrase is required", cx);
return;
}
let repass = self.repass_input.read(cx).value().to_string();
if password != repass {
self.set_error("Passphrases do not match", cx);
return;
}
let task = nostr.update(cx, |this, cx| this.import_nsec(&value, &password, cx));
self.finish(task, cx);
return;
}
self.set_error("Enter an nsec, ncryptsec or bunker://", cx);
}
fn ncryptsec<S>(&mut self, content: S, pwd: S, window: &mut Window, cx: &mut Context<Self>)
where
S: Into<String>,
{
let nostr = NostrRegistry::global(cx);
let content: String = content.into();
let password: String = pwd.into();
if password.is_empty() {
self.set_error("Password is required", cx);
return;
}
let Ok(enc) = EncryptedSecretKey::from_bech32(&content) else {
self.set_error("Secret Key is invalid", cx);
return;
};
// Decrypt in the background to ensure it doesn't block the UI
let task = cx.background_spawn(async move {
if let Ok(content) = enc.decrypt(&password) {
Ok(Keys::new(content))
} else {
Err(anyhow!("Invalid password"))
/// Run the import task, surfacing errors while success closes the dialog
/// through the `SignerChanged` event
fn finish(&mut self, task: Task<Result<PublicKey, Error>>, cx: &mut Context<Self>) {
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
match task.await {
Ok(keys) => {
nostr.update_in(cx, |this, window, cx| {
this.set_signer(keys, cx);
window.close_dialog(cx);
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.set_error(e.to_string(), cx);
})?;
}
}
Ok(())
}));
}
fn bunker(&mut self, uri: NostrConnectUri, window: &mut Window, cx: &mut Context<Self>) {
let nostr = NostrRegistry::global(cx);
let master_keys = nostr.read(cx).get_master_key(cx);
let password = uri.to_string();
let save = cx.write_credentials(USER_KEYRING, "bunker", password.as_bytes());
self.tasks.push(cx.spawn_in(window, async move |_this, cx| {
let keys = master_keys.await;
let timeout = Duration::from_secs(30);
// Construct the nostr connect signer
let mut signer = NostrConnect::new(uri, keys, timeout, None)?;
// Handle auth url with the default browser
signer.auth_url_handler(CoopAuthUrlHandler);
nostr.update(cx, |this, cx| {
this.set_signer(signer, cx);
cx.background_spawn(async move { save.await.ok() }).detach();
cx.notify();
});
Ok(())
}));
@@ -214,36 +225,62 @@ impl ImportIdentity {
impl Render for ImportIdentity {
fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context<Self>) -> impl IntoElement {
const BUNKER_WARN: &str = "Nostr Connect will usually take more time to get all your messages. Please keep your session open until you see all your messages.";
const KEY_WARN: &str = "Coop won't store your identity key on the local device. You need to re-login again in the next session. You can use Nostr Connect for persistent login.";
const KEY_WARN: &str = "Coop encrypts your key with your passphrase and stores it on this device. You only need the passphrase to sign in next time.";
let is_wasm = cfg!(target_arch = "wasm32");
let require_password = self.key_input.read(cx).value().starts_with("ncryptsec1");
let key_warning = self.key_input.read(cx).value().starts_with("nsec1") || require_password;
let bunker_warning = self.key_input.read(cx).value().starts_with("bunker://");
let is_unlock = self.mode == Mode::Unlock;
let value = self.key_input.read(cx).value().trim().to_string();
let require_password = value.starts_with("ncryptsec1");
let require_encrypt = value.starts_with("nsec1");
let key_warning = require_password || require_encrypt;
let bunker_warning = value.starts_with("bunker://");
v_flex()
.size_full()
.gap(px(theme::SPACE))
.gap_4()
.text_style(TextStyle::Callout)
.child(
v_flex()
.gap_1()
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
.when(require_password, |this| {
.gap(px(theme::SPACE))
.when(!is_unlock, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Decrypt Password:")
.child("Use secret key or bunker connection")
.child(Input::new(&self.key_input)),
)
})
.when(is_unlock || require_password, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child(if is_unlock {
"Passphrase to unlock your identity:"
} else {
"Decrypt Password:"
})
.child(Input::new(&self.pass_input)),
)
})
.when(require_encrypt, |this| {
this.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Passphrase to encrypt your key on this device:")
.child(Input::new(&self.pass_input)),
)
.child(
v_flex()
.gap_1()
.text_color(cx.theme().text_muted)
.child("Repeat Passphrase:")
.child(Input::new(&self.repass_input)),
)
})
.when(bunker_warning, |this| {
this.child(
div()
@@ -271,21 +308,19 @@ impl Render for ImportIdentity {
)
.child(
Button::new("login")
.label("Continue")
.label(if is_unlock { "Unlock" } else { "Continue" })
.primary()
.font_semibold()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, window, cx| {
this.login(window, cx);
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.login(cx);
})),
)
.when(!is_wasm, |this| {
.when(!is_wasm && !is_unlock, |this| {
this.child(
Button::new("proxy")
.label("Connect via Web Extension (Experimental)")
.ghost()
.loading(self.loading)
.disabled(self.loading)
.on_click(cx.listener(move |this, _ev, _window, cx| {
this.proxy(cx);
+10 -8
View File
@@ -10,7 +10,7 @@ use gpui::{
Window, div, px, uniform_list,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use person::Directory;
use settings::AppSettings;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
@@ -39,6 +39,8 @@ pub struct NewChat {
input: Entity<InputState>,
/// Current user's contacts
contacts: Option<Vec<PublicKey>>,
/// Person directory for contacts and selected members
directory: Entity<Directory>,
/// Public keys selected as members of the chat
members: Vec<PublicKey>,
/// Whether a NIP-05 address is being resolved
@@ -69,6 +71,7 @@ impl NewChat {
dock,
input,
contacts: None,
directory: Directory::new(cx),
members: Vec::new(),
resolving: false,
error: None,
@@ -112,6 +115,9 @@ impl NewChat {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
cx.notify();
})?;
@@ -133,7 +139,6 @@ impl NewChat {
return Vec::new();
};
let persons = PersonRegistry::global(cx);
let query = self.input.read(cx).value().trim().to_lowercase();
if query.is_empty() {
@@ -146,7 +151,7 @@ impl NewChat {
if public_key.to_hex().to_lowercase().contains(&query) {
return true;
}
let name = persons.read(cx).get(public_key, cx).name();
let name = self.directory.read(cx).person(public_key, cx).name();
name.to_lowercase().contains(&query)
})
.cloned()
@@ -318,7 +323,6 @@ impl NewChat {
contacts: &[PublicKey],
cx: &mut Context<Self>,
) -> Vec<AnyElement> {
let persons = PersonRegistry::global(cx);
let start = range.start;
contacts
@@ -327,7 +331,7 @@ impl NewChat {
.flatten()
.enumerate()
.map(|(offset, public_key)| {
let profile = persons.read(cx).get(public_key, cx);
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
Nav::new(start + offset)
@@ -350,13 +354,11 @@ impl NewChat {
}
fn render_members(&self, cx: &mut Context<Self>) -> impl IntoElement {
let persons = PersonRegistry::global(cx);
h_flex()
.flex_wrap()
.gap_1()
.children(self.members.iter().map(|public_key| {
let profile = persons.read(cx).get(public_key, cx);
let profile = self.directory.read(cx).person(public_key, cx);
let public_key = *public_key;
let id = SharedString::from(format!("m-{}", public_key.to_hex()));
+14 -8
View File
@@ -9,7 +9,7 @@ use gpui::{
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use person::{Directory, Person, PersonRegistry, shorten_pubkey};
use settings::AppSettings;
use state::{NostrRegistry, upload};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -34,6 +34,8 @@ struct ProfileDialog {
public_key: PublicKey,
/// Whether the profile belongs to the signed-in user
is_current_user: bool,
/// Person directory for the profile
directory: Entity<Directory>,
/// Whether the metadata inputs are shown
editing: bool,
/// User's name text input
@@ -74,9 +76,15 @@ impl ProfileDialog {
.placeholder("A short introduce about you.")
});
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| {
directory.track([public_key], cx);
});
Self {
public_key,
is_current_user,
directory,
editing: false,
name_input,
avatar_input,
@@ -90,8 +98,7 @@ impl ProfileDialog {
}
fn profile(&self, cx: &App) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
self.directory.read(cx).person(&self.public_key, cx)
}
fn set_profile(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -238,9 +245,8 @@ impl ProfileDialog {
}
fn save(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let persons = PersonRegistry::global(cx);
let public_key = self.public_key;
let old_metadata = persons.read(cx).get(&public_key, cx).metadata();
let old_metadata = self.directory.read(cx).person(&public_key, cx).metadata();
// Extract all new metadata fields
let avatar = self.avatar_input.read(cx).value();
@@ -274,9 +280,9 @@ impl ProfileDialog {
match task.await {
Ok(_) => {
this.update_in(cx, |this, window, cx| {
// Update the registry
persons.update(cx, |this, cx| {
this.insert(Person::new(public_key, new_metadata), cx);
// Publish the new metadata to every directory
PersonRegistry::global(cx).update(cx, |registry, cx| {
registry.insert(Person::new(public_key, new_metadata), cx);
});
this.set_updating(false, cx);
+11 -5
View File
@@ -9,7 +9,7 @@ use gpui::{
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::{Person, PersonRegistry, shorten_pubkey};
use person::{Directory, Person, shorten_pubkey};
use smallvec::{SmallVec, smallvec};
use state::{BOOTSTRAP_RELAYS, NostrAddress, NostrRegistry, TIMEOUT};
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
@@ -33,6 +33,8 @@ pub struct Screening {
last_active: Option<Timestamp>,
/// All mutual contacts of the person being screened.
mutual_contacts: Vec<PublicKey>,
/// Person directory for the person and their mutual contacts
directory: Entity<Directory>,
tasks: SmallVec<[Task<()>; 3]>,
_subscriptions: SmallVec<[Subscription; 1]>,
}
@@ -53,12 +55,16 @@ impl Screening {
this.verify_identifier(cx);
});
let directory = Directory::new(cx);
directory.update(cx, |directory, cx| directory.track([public_key], cx));
Self {
public_key,
verified: false,
followed: false,
last_active: None,
mutual_contacts: vec![],
directory,
tasks: smallvec![],
_subscriptions: subscriptions,
}
@@ -208,8 +214,7 @@ impl Screening {
}
fn profile(&self, cx: &Context<Self>) -> Person {
let persons = PersonRegistry::global(cx);
persons.read(cx).get(&self.public_key, cx)
self.directory.read(cx).person(&self.public_key, cx)
}
fn address(&self, cx: &Context<Self>) -> Option<Nip05Address> {
@@ -260,22 +265,23 @@ impl Screening {
fn mutual_contacts(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let contacts = self.mutual_contacts.clone();
let directory = self.directory.clone();
window.open_dialog(cx, move |this, _window, _cx| {
let contacts = contacts.clone();
let directory = directory.clone();
let total = contacts.len();
this.title("Mutual contacts").child(
v_flex().gap_1().pb_2().child(
uniform_list("contacts", total, move |range, _window, cx| {
let persons = PersonRegistry::global(cx);
let mut items = Vec::with_capacity(total);
for ix in range {
let Some(contact) = contacts.get(ix) else {
continue;
};
let profile = persons.read(cx).get(contact, cx);
let profile = directory.read(cx).person(contact, cx);
items.push(
h_flex()
+104 -3
View File
@@ -1,7 +1,7 @@
use gpui::http_client::Url;
use gpui::{
App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, Styled,
Window, div,
App, AppContext, Context, Entity, IntoElement, ParentElement, PathPromptOptions, Render,
SharedString, Styled, Window, div,
};
use settings::AppSettings;
use theme::{ActiveTheme, AppExt as _, AppearanceMode, TextStyle, Typeset as _};
@@ -11,7 +11,7 @@ use ui::input::{Input, InputState};
use ui::menu::{DropdownMenu, PopupMenuItem};
use ui::notification::Notification;
use ui::switch::Switch;
use ui::{IconName, Sizable, WindowExtension, h_flex, v_flex};
use ui::{Disableable, IconName, Sizable, WindowExtension, h_flex, v_flex};
pub fn init(window: &mut Window, cx: &mut App) -> Entity<Preferences> {
cx.new(|cx| Preferences::new(window, cx))
@@ -52,6 +52,32 @@ impl Preferences {
AppSettings::update_appearance_mode(mode, cx);
cx.set_appearance_mode(mode);
}
/// Ask the user for an image to show behind chat and group panels
fn choose_background(cx: &mut App) {
let task = cx.prompt_for_paths(PathPromptOptions {
files: true,
directories: false,
multiple: false,
prompt: None,
});
cx.spawn(async move |cx| {
let Ok(Ok(Some(mut paths))) = task.await else {
return;
};
let Some(path) = paths.pop() else {
return;
};
cx.update(|cx| {
AppSettings::update_panel_background_path(Some(path), cx);
cx.refresh_windows();
});
})
.detach();
}
}
impl Render for Preferences {
@@ -60,11 +86,19 @@ impl Render for Preferences {
const AVATAR: &str = "Hide all avatar pictures to improve performance.";
const MODE: &str = "Follow the OS, or always use light or dark.";
const NIP4E: &str = "Use a dedicated key to encrypt and decrypt messages.";
const BACKGROUND: &str = "Show an image behind chat and group panels.";
let screening = AppSettings::get_screening(cx);
let hide_avatar = AppSettings::get_hide_avatar(cx);
let nip4e = AppSettings::get_nip4e(cx);
let appearance_mode = AppSettings::get_appearance_mode(cx);
let panel_background = AppSettings::get_panel_background(cx);
let panel_background_path = AppSettings::get_panel_background_path(cx);
let background_label = match &panel_background_path {
Some(path) => path.display().to_string(),
None => String::from("Default Background"),
};
v_flex()
.gap_4()
@@ -132,6 +166,73 @@ impl Render for Preferences {
),
),
)
.child(
GroupBox::new()
.id("background")
.title("Background")
.fill()
.child(
Switch::new("panel-background")
.label("Panel background")
.description(BACKGROUND)
.checked(panel_background)
.on_click(move |_, _window, cx| {
AppSettings::update_panel_background(!panel_background, cx);
cx.refresh_windows();
}),
)
.child(
h_flex()
.gap_3()
.justify_between()
.child(
v_flex()
.flex_1()
.min_w_0()
.child(
div()
.text_style(TextStyle::Callout)
.child(SharedString::from("Image")),
)
.child(
div()
.text_style(TextStyle::Caption)
.text_color(cx.theme().text_muted)
.truncate()
.child(SharedString::from(background_label)),
),
)
.child(
h_flex()
.flex_shrink_0()
.gap_1()
.child(
Button::new("choose-background")
.label("Choose image")
.ghost_alt()
.small()
.disabled(!panel_background)
.on_click(|_, _window, cx| {
Self::choose_background(cx);
}),
)
.child(
Button::new("reset-background")
.label("Reset")
.ghost_alt()
.small()
.disabled(
!panel_background
|| panel_background_path.is_none(),
)
.on_click(|_, _window, cx| {
AppSettings::update_panel_background_path(None, cx);
cx.refresh_windows();
}),
),
),
),
)
.child(
GroupBox::new()
.id("experiments")
+27 -28
View File
@@ -21,7 +21,7 @@ use ui::notification::{Notification, NotificationKind};
use ui::{Root, WindowExtension, v_flex};
use crate::dialogs::restore::RestoreEncryption;
use crate::dialogs::{join_group, new_chat, new_group, profile, relays, settings};
use crate::dialogs::{import, join_group, new_chat, new_group, profile, relays, settings};
use crate::panels::{backup, browse, contact_list, greeter, inbox};
use crate::sidebar::Sidebar;
@@ -76,6 +76,7 @@ impl Workspace {
let sidebar = cx.new(|cx| Sidebar::new(window, dock.downgrade(), cx));
let left_sidebar = sidebar.clone();
let workspace = cx.entity().downgrade();
let mut subscriptions = smallvec![];
subscriptions.push(
@@ -87,10 +88,10 @@ impl Workspace {
subscriptions.push(
// Subscribe to the nostr events
cx.subscribe_in(&nostr, window, move |_this, _state, event, window, cx| {
if let StateEvent::SignerChanged = event {
window.close_all_dialogs(cx);
}
cx.subscribe_in(&nostr, window, move |_, _, event, window, cx| match event {
StateEvent::SignerChanged => window.close_all_dialogs(cx),
StateEvent::PassphraseRequired => import::open_unlock(window, cx),
_ => {}
}),
);
@@ -183,6 +184,27 @@ impl Workspace {
}),
);
App::on_action(cx, {
let workspace = workspace.clone();
move |action, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| {
this.show_group_meta(action, cx);
});
}
});
App::on_action(cx, move |action, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| {
this.hide_group_meta(action, cx);
});
});
cx.defer_in(window, move |this, window, cx| {
let sidebar = PanelHandle::new(left_sidebar);
let greeter = PanelHandle::new(greeter::init(window, cx));
@@ -198,29 +220,6 @@ impl Workspace {
});
});
// Listen app-wide, so a group panel can show or hide the sidebar without holding it.
let workspace = cx.entity().downgrade();
App::on_action(cx, {
let workspace = workspace.clone();
move |action: &nip29_ui::ShowGroupMeta, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| this.show_group_meta(action, cx));
}
});
App::on_action(cx, move |action: &nip29_ui::HideGroupMeta, cx| {
let Some(workspace) = workspace.upgrade() else {
return;
};
workspace.update(cx, |this, cx| this.hide_group_meta(action, cx));
});
Self {
dock,
sidebar,
+11 -3
View File
@@ -9,7 +9,7 @@ use gpui::{
};
use instant::Duration;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use person::Directory;
use smallvec::{SmallVec, smallvec};
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius, panel_radius};
@@ -41,6 +41,9 @@ pub struct ContactListPanel {
/// All contacts
contacts: HashSet<PublicKey>,
/// Person directory for the contacts
directory: Entity<Directory>,
/// Event subscriptions
_subscriptions: SmallVec<[Subscription; 1]>,
@@ -73,6 +76,7 @@ impl ContactListPanel {
input,
updating: false,
contacts: HashSet::new(),
directory: Directory::new(cx),
error: None,
_subscriptions: subscriptions,
tasks: vec![],
@@ -110,6 +114,9 @@ impl ContactListPanel {
// Update state
this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(public_keys.iter().copied(), cx)
});
this.contacts.extend(public_keys);
cx.notify();
})?;
@@ -123,6 +130,8 @@ impl ContactListPanel {
if let Ok(public_key) = PublicKey::parse(&value) {
if self.contacts.insert(public_key) {
self.directory
.update(cx, |directory, cx| directory.track([public_key], cx));
self.input.update(cx, |this, cx| {
this.set_value("", window, cx);
});
@@ -218,11 +227,10 @@ impl ContactListPanel {
}
fn render_list_items(&mut self, cx: &mut Context<Self>) -> Vec<impl IntoElement> {
let persons = PersonRegistry::global(cx);
let mut items = Vec::new();
for (ix, public_key) in self.contacts.iter().enumerate() {
let profile = persons.read(cx).get(public_key, cx);
let profile = self.directory.read(cx).person(public_key, cx);
items.push(
h_flex()
+14 -3
View File
@@ -8,7 +8,6 @@ use gpui::{
};
use nip29::Group;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use state::NostrRegistry;
use theme::{ActiveTheme, TextStyle, Typeset as _};
@@ -51,6 +50,19 @@ impl Sidebar {
pub(super) fn show_group(&mut self, group: &Entity<Group>, cx: &mut Context<Self>) {
self.group = Some(group.downgrade());
self.group_open = true;
let group = group.read(cx);
let mut keys: Vec<PublicKey> = group.members().iter().copied().collect();
keys.extend(
group
.admins()
.entries()
.iter()
.map(|(public_key, _)| *public_key),
);
self.directory
.update(cx, |directory, cx| directory.track(keys, cx));
cx.notify();
}
@@ -253,9 +265,8 @@ impl Sidebar {
cx: &mut Context<Self>,
) -> AnyElement {
let hide_avatar = AppSettings::get_hide_avatar(cx);
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let person = self.directory.read(cx).person(public_key, cx);
let group = group.clone();
let public_key = *public_key;
+1 -3
View File
@@ -5,7 +5,6 @@ use gpui::{
Stateful, Styled, div, px,
};
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use theme::TABBAR_HEIGHT;
use ui::avatar::Avatar;
use ui::button::{Button, ButtonVariants};
@@ -22,8 +21,7 @@ impl Sidebar {
cx: &mut Context<Self>,
) -> Stateful<Div> {
let updater = AutoUpdater::try_global(cx);
let persons = PersonRegistry::global(cx);
let profile = persons.read(cx).get(current_user, cx);
let profile = self.directory.read(cx).person(current_user, cx);
let avatar = profile.avatar();
let avatar_seed = profile.avatar_seed();
+3
View File
@@ -69,6 +69,9 @@ impl Sidebar {
match task.await {
Ok(contacts) => {
this.update(cx, |this, cx| {
this.directory.update(cx, |directory, cx| {
directory.track(contacts.iter().copied(), cx)
});
this.contacts = Some(contacts);
cx.notify();
})?;
+24 -8
View File
@@ -13,6 +13,7 @@ use gpui::{
use instant::Duration;
use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry};
use nostr_sdk::prelude::*;
use person::Directory;
use smallvec::{SmallVec, smallvec};
use state::{FIND_DELAY, NostrRegistry, StateEvent};
use theme::{ActiveTheme, TABBAR_HEIGHT, TextStyle, Typeset as _};
@@ -61,6 +62,8 @@ pub struct Sidebar {
contacts_open: bool,
/// The signed-in user's contacts, loaded when the contact picker opens.
contacts: Option<Vec<PublicKey>>,
/// Person directory for contacts, search hits, and group members
directory: Entity<Directory>,
/// Contacts selected in the contact picker.
selected_contacts: HashSet<PublicKey>,
/// Scroll state of the contact picker's list.
@@ -139,15 +142,27 @@ impl Sidebar {
);
subscriptions.push(
cx.subscribe_in(&nostr, window, |this, _, event, window, cx| {
if let StateEvent::NoSigner = event {
this.banner = onboarding::pick_banner();
if this.search_open {
this.toggle_search(window, cx);
cx.subscribe_in(
&nostr,
window,
|this, state, event, window, cx| match event {
StateEvent::NoSigner => {
this.banner = onboarding::pick_banner();
if this.search_open {
this.toggle_search(window, cx);
}
cx.notify();
}
cx.notify();
}
}),
StateEvent::SignerChanged => {
if let Some(current_user) = state.read(cx).current_user() {
this.directory.update(cx, |directory, cx| {
directory.track([current_user], cx);
});
}
}
_ => {}
},
),
);
subscriptions.push(cx.subscribe_in(
@@ -196,6 +211,7 @@ impl Sidebar {
chats_open: None,
contacts_open: false,
contacts: None,
directory: Directory::new(cx),
selected_contacts: HashSet::new(),
contacts_scroll: UniformListScrollHandle::new(),
requests_open: false,
+3 -6
View File
@@ -9,7 +9,6 @@ use gpui::{
};
use nip29::Group;
use nostr_sdk::prelude::*;
use person::PersonRegistry;
use settings::AppSettings;
use theme::{ActiveTheme, TextStyle, Typeset as _, button_radius};
use ui::avatar::Avatar;
@@ -196,8 +195,7 @@ impl Sidebar {
.into_any_element()
}
SidebarRow::Contact { public_key } => {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let person = self.directory.read(cx).person(public_key, cx);
let selected = self.selected_contacts.contains(public_key);
let sidebar = cx.entity().downgrade();
let public_key = public_key.to_owned();
@@ -224,8 +222,7 @@ impl Sidebar {
.into_any_element()
}
SidebarRow::Profile { public_key } => {
let persons = PersonRegistry::global(cx);
let person = persons.read(cx).get(public_key, cx);
let person = self.directory.read(cx).person(public_key, cx);
let public_key = public_key.to_owned();
Nav::new(index)
@@ -330,7 +327,7 @@ impl Sidebar {
.into_any_element()
}
SidebarRow::Message { hit } => {
let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx);
let person = self.directory.read(cx).person(&hit.author, cx);
let name = hit.room.read(cx).display_name(cx);
let detail = format!("{} · {}", name, hit.created_at.to_ago());
let dock = self.dock.clone();
+14 -1
View File
@@ -106,6 +106,14 @@ impl Sidebar {
.chain(groups.into_iter().map(SearchResult::Group))
.chain(messages.into_iter().map(SearchResult::Message))
.collect();
let authors = self.results.iter().filter_map(|result| match result {
SearchResult::Message(hit) => Some(hit.author),
_ => None,
});
self.directory
.update(cx, |directory, cx| directory.track(authors, cx));
cx.notify();
}
@@ -133,7 +141,7 @@ impl Sidebar {
this.update(cx, |this, cx| {
this.global_finding = false;
this.global_results = Some(match result {
let keys: Vec<PublicKey> = match result {
Ok(profiles) => {
// Relays may return the same profile more than once
let mut seen = HashSet::new();
@@ -146,7 +154,12 @@ impl Sidebar {
log::debug!("Global search failed: {error}");
Vec::new()
}
};
this.directory.update(cx, |directory, cx| {
directory.track(keys.iter().copied(), cx)
});
this.global_results = Some(keys);
cx.notify();
})
.ok();
+1 -1
View File
@@ -14,7 +14,7 @@ product-name = "Coop"
description = "Chat Freely, Stay Private on Nostr"
identifier = "su.reya.coop"
category = "SocialNetworking"
version = "1.0.2"
version = "2.0.0-alpha"
out-dir = "../dist"
before-packaging-command = "cargo build --release"
resources = ["Cargo.toml", "src"]
+1 -1
View File
@@ -1,5 +1,5 @@
[toolchain]
channel = "1.96"
channel = "1.98"
profile = "minimal"
components = ["rustfmt", "clippy"]
targets = [