chore: update deps and clean up
This commit is contained in:
Generated
+165
-642
File diff suppressed because it is too large
Load Diff
@@ -57,7 +57,6 @@ smol = "2"
|
|||||||
webbrowser = "1.0.4"
|
webbrowser = "1.0.4"
|
||||||
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
tracing-subscriber = { version = "0.3.18", features = ["fmt"] }
|
||||||
instant = "0.1"
|
instant = "0.1"
|
||||||
ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] }
|
|
||||||
|
|
||||||
[profile.release]
|
[profile.release]
|
||||||
strip = true
|
strip = true
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ publish.workspace = true
|
|||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
instant.workspace = true
|
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
serde_json.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
|
||||||
|
|||||||
+54
-165
@@ -1,11 +1,9 @@
|
|||||||
#![cfg(not(target_arch = "wasm32"))]
|
#![cfg(not(target_arch = "wasm32"))]
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::time::Duration;
|
||||||
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
|
|
||||||
|
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task};
|
||||||
use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version};
|
use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version};
|
||||||
use instant::Duration;
|
|
||||||
|
|
||||||
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
use crate::source::{AssetFilter, GiteaSource, asset_filter_for};
|
||||||
|
|
||||||
@@ -66,13 +64,10 @@ struct GlobalAutoUpdater(Entity<AutoUpdater>);
|
|||||||
impl Global for GlobalAutoUpdater {}
|
impl Global for GlobalAutoUpdater {}
|
||||||
|
|
||||||
pub struct AutoUpdater {
|
pub struct AutoUpdater {
|
||||||
/// The blocking engine, driven on the background executor.
|
updater: Entity<Updater>,
|
||||||
engine: Arc<UpdateEngine<GiteaSource>>,
|
|
||||||
status: UpdateStatus,
|
status: UpdateStatus,
|
||||||
/// The newer release found by the last successful check, if any.
|
/// The pending revert of an `Errored` status back to `Idle`, if any.
|
||||||
available: Option<Release>,
|
error_revert: Option<Task<()>>,
|
||||||
/// The in-flight check or download, if any.
|
|
||||||
task: Option<Task<()>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AutoUpdater {
|
impl AutoUpdater {
|
||||||
@@ -87,12 +82,21 @@ impl AutoUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
fn new(version: Version, filter: AssetFilter, cx: &mut Context<Self>) -> Self {
|
||||||
let entity = cx.entity().downgrade();
|
let source = GiteaSource::new(
|
||||||
let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter);
|
GITEA_API_BASE,
|
||||||
let config = EngineConfig::new(version.clone()).verification(Verification::Checksum);
|
GITEA_REPO_OWNER,
|
||||||
let engine = Arc::new(UpdateEngine::new(source, config));
|
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
|
// Schedule an auto-check after a 2-minute delay
|
||||||
|
let entity = cx.entity().downgrade();
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
cx.background_executor().timer(AUTO_CHECK_DELAY).await;
|
||||||
@@ -102,13 +106,41 @@ impl AutoUpdater {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
engine,
|
updater,
|
||||||
status: UpdateStatus::Idle,
|
status: UpdateStatus::Idle,
|
||||||
available: None,
|
error_revert: None,
|
||||||
task: 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.
|
/// Whether the running version is the newest release, so the status line can be hidden.
|
||||||
pub fn up_to_date(&self) -> bool {
|
pub fn up_to_date(&self) -> bool {
|
||||||
matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate)
|
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.
|
/// Check the release host for a newer version, then download and install it.
|
||||||
pub fn check(&mut self, cx: &mut Context<Self>) {
|
pub fn check(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.status.is_busy() {
|
self.updater.update(cx, |updater, cx| {
|
||||||
return;
|
updater.check(cx);
|
||||||
}
|
|
||||||
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();
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Relaunch into the staged update.
|
/// Relaunch into the staged update.
|
||||||
@@ -281,24 +186,8 @@ impl AutoUpdater {
|
|||||||
log::warn!("Ignoring restart request: no update is staged");
|
log::warn!("Ignoring restart request: no update is staged");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
cx.restart();
|
self.updater.update(cx, |updater, cx| {
|
||||||
}
|
updater.restart(cx);
|
||||||
|
});
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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::Deserialize;
|
||||||
|
use serde::de::DeserializeOwned;
|
||||||
|
|
||||||
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
const CHECKSUMS_ASSET: &str = "SHA256SUMS";
|
||||||
const RELEASE_PAGE_SIZE: usize = 20;
|
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.
|
/// Which published artifact belongs to a target platform.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[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 })
|
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.
|
/// Reads releases from a Gitea repository's Releases.
|
||||||
pub struct GiteaSource {
|
pub struct GiteaSource {
|
||||||
api_base: String,
|
api_base: String,
|
||||||
owner: String,
|
owner: String,
|
||||||
repo: String,
|
repo: String,
|
||||||
filter: AssetFilter,
|
filter: AssetFilter,
|
||||||
|
http_client: Arc<dyn HttpClient>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl GiteaSource {
|
impl GiteaSource {
|
||||||
@@ -55,12 +91,14 @@ impl GiteaSource {
|
|||||||
owner: impl Into<String>,
|
owner: impl Into<String>,
|
||||||
repo: impl Into<String>,
|
repo: impl Into<String>,
|
||||||
filter: AssetFilter,
|
filter: AssetFilter,
|
||||||
|
http_client: Arc<dyn HttpClient>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
api_base: api_base.into().trim_end_matches('/').to_string(),
|
api_base: api_base.into().trim_end_matches('/').to_string(),
|
||||||
owner: owner.into(),
|
owner: owner.into(),
|
||||||
repo: repo.into(),
|
repo: repo.into(),
|
||||||
filter,
|
filter,
|
||||||
|
http_client,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,11 +108,49 @@ impl GiteaSource {
|
|||||||
self.api_base, self.owner, self.repo
|
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 {
|
impl UpdateSource for GiteaSource {
|
||||||
fn fetch_latest(&self) -> Result<Release> {
|
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)
|
let release = newest_published(&releases)
|
||||||
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
.ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?;
|
||||||
|
|
||||||
@@ -92,26 +168,27 @@ impl UpdateSource for GiteaSource {
|
|||||||
.assets
|
.assets
|
||||||
.iter()
|
.iter()
|
||||||
.find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET))
|
.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()?
|
.transpose()?
|
||||||
.and_then(|sums| sha256_for(&sums, &asset.name));
|
.and_then(|sums| sha256_for(&sums, &asset.name));
|
||||||
|
|
||||||
Ok(Release {
|
let notes = release
|
||||||
version: parse_tag(&release.tag_name)?,
|
|
||||||
notes: release
|
|
||||||
.body
|
.body
|
||||||
.clone()
|
.clone()
|
||||||
.filter(|body| !body.trim().is_empty())
|
.filter(|body| !body.trim().is_empty())
|
||||||
.or_else(|| release.name.clone()),
|
.or_else(|| release.name.clone());
|
||||||
asset: Asset {
|
|
||||||
name: asset.name.clone(),
|
let mut update = Release::new(
|
||||||
url: asset.browser_download_url.clone(),
|
parse_tag(&release.tag_name)?,
|
||||||
size: asset.size,
|
Asset::new(&asset.name, &asset.browser_download_url, asset.size),
|
||||||
},
|
);
|
||||||
signature: None,
|
if let Some(notes) = notes {
|
||||||
signature_url: None,
|
update = update.with_notes(notes);
|
||||||
sha256,
|
}
|
||||||
})
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use gpui_updater_core::Version;
|
use gpui_updater_pre::Version;
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
@@ -344,7 +347,13 @@ abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg
|
|||||||
fn live_release_source_resolves_the_running_platform() {
|
fn live_release_source_resolves_the_running_platform() {
|
||||||
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH)
|
||||||
.expect("this platform should be supported");
|
.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
|
let release = source
|
||||||
.fetch_latest()
|
.fetch_latest()
|
||||||
|
|||||||
@@ -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
|
|
||||||
@@ -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
@@ -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(¤t) if editions[current].tiebreak_id <= edition.tiebreak_id => {}
|
|
||||||
_ => {
|
|
||||||
by_version.insert(edition.version, index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let Some((&lowest_version, &lowest_index)) = by_version.first_key_value() else {
|
|
||||||
return FoldResult::default();
|
|
||||||
};
|
|
||||||
|
|
||||||
let lowest = editions[lowest_index];
|
|
||||||
|
|
||||||
let anchored = if floor == 0 {
|
|
||||||
lowest_version == 1 && lowest.prev.is_none()
|
|
||||||
} else if lowest_version == floor {
|
|
||||||
floor_hash == Some(&lowest.self_hash)
|
|
||||||
} else if lowest_version == floor + 1 {
|
|
||||||
floor_hash.is_some() && lowest.prev.as_ref() == floor_hash
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut head = Some(lowest_index);
|
|
||||||
let mut gap = !anchored;
|
|
||||||
let mut previous_version = lowest_version;
|
|
||||||
let mut previous_hash = lowest.self_hash;
|
|
||||||
|
|
||||||
for (&version, &index) in by_version.range(lowest_version + 1..) {
|
|
||||||
let edition = editions[index];
|
|
||||||
|
|
||||||
if version == previous_version + 1 && edition.prev == Some(previous_hash) {
|
|
||||||
head = Some(index);
|
|
||||||
previous_version = version;
|
|
||||||
previous_hash = edition.self_hash;
|
|
||||||
} else {
|
|
||||||
gap = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
FoldResult {
|
|
||||||
head,
|
|
||||||
gap,
|
|
||||||
anchored,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The highest version overall, ignoring contiguity.
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -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
@@ -1,8 +0,0 @@
|
|||||||
mod rumor;
|
|
||||||
|
|
||||||
pub mod cord01;
|
|
||||||
pub mod cord02;
|
|
||||||
pub mod cord03;
|
|
||||||
pub mod cord04;
|
|
||||||
pub mod cord05;
|
|
||||||
pub mod cord06;
|
|
||||||
@@ -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))
|
|
||||||
}
|
|
||||||
@@ -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};
|
|
||||||
@@ -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)]));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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()))
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
[toolchain]
|
[toolchain]
|
||||||
channel = "1.96"
|
channel = "1.98"
|
||||||
profile = "minimal"
|
profile = "minimal"
|
||||||
components = ["rustfmt", "clippy"]
|
components = ["rustfmt", "clippy"]
|
||||||
targets = [
|
targets = [
|
||||||
|
|||||||
Reference in New Issue
Block a user