From 61289edb9a08a477f4c48cf37d27e374d984a83f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 13 Oct 2026 08:10:05 +0700 Subject: [PATCH] chore: update deps and clean up --- Cargo.lock | 807 ++------- Cargo.toml | 1 - crates/auto_update/Cargo.toml | 8 +- crates/auto_update/src/lib.rs | 221 +-- crates/auto_update/src/source.rs | 199 ++- crates/concord/Cargo.toml | 23 - crates/concord/src/cords/cord01.rs | 723 -------- crates/concord/src/cords/cord02/guestbook.rs | 822 --------- crates/concord/src/cords/cord02/list.rs | 1171 ------------ crates/concord/src/cords/cord02/mod.rs | 1242 ------------- crates/concord/src/cords/cord03.rs | 1108 ------------ crates/concord/src/cords/cord04/mod.rs | 555 ------ crates/concord/src/cords/cord04/pins.rs | 819 --------- crates/concord/src/cords/cord04/roles.rs | 1087 ------------ crates/concord/src/cords/cord05.rs | 977 ---------- crates/concord/src/cords/cord06.rs | 1665 ------------------ crates/concord/src/cords/mod.rs | 8 - crates/concord/src/cords/rumor.rs | 96 - crates/concord/src/lib.rs | 12 - crates/concord/src/state.rs | 631 ------- crates/concord/src/types/mod.rs | 98 -- crates/concord/src/utils/base64url.rs | 34 - crates/concord/src/utils/derive.rs | 407 ----- crates/concord/src/utils/mod.rs | 77 - rust-toolchain.toml | 2 +- 25 files changed, 330 insertions(+), 12463 deletions(-) delete mode 100644 crates/concord/Cargo.toml delete mode 100644 crates/concord/src/cords/cord01.rs delete mode 100644 crates/concord/src/cords/cord02/guestbook.rs delete mode 100644 crates/concord/src/cords/cord02/list.rs delete mode 100644 crates/concord/src/cords/cord02/mod.rs delete mode 100644 crates/concord/src/cords/cord03.rs delete mode 100644 crates/concord/src/cords/cord04/mod.rs delete mode 100644 crates/concord/src/cords/cord04/pins.rs delete mode 100644 crates/concord/src/cords/cord04/roles.rs delete mode 100644 crates/concord/src/cords/cord05.rs delete mode 100644 crates/concord/src/cords/cord06.rs delete mode 100644 crates/concord/src/cords/mod.rs delete mode 100644 crates/concord/src/cords/rumor.rs delete mode 100644 crates/concord/src/lib.rs delete mode 100644 crates/concord/src/state.rs delete mode 100644 crates/concord/src/types/mod.rs delete mode 100644 crates/concord/src/utils/base64url.rs delete mode 100644 crates/concord/src/utils/derive.rs delete mode 100644 crates/concord/src/utils/mod.rs diff --git a/Cargo.lock b/Cargo.lock index 630d79fc..ec65bf48 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -298,18 +298,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "async-compression" -version = "0.4.50" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee19bd99b43e3691acbad4e840420a4881cea6c0b66a208125a824f8fd53f5a1" -dependencies = [ - "compression-codecs", - "compression-core", - "futures-io", - "pin-project-lite", -] - [[package]] name = "async-executor" version = "1.14.0" @@ -401,7 +389,7 @@ checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -436,7 +424,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -470,12 +458,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "atomic" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c59bdb34bc650a32731b31bd8f0829cc15d24a708ee31559e0bb34f2bc320cba" - [[package]] name = "atomic-destructor" version = "0.2.0" @@ -529,13 +511,13 @@ dependencies = [ name = "auto_update" version = "1.0.2" dependencies = [ + "futures", "gpui-pre", - "gpui-updater-core", - "instant", + "gpui-pre-reqwest-client", + "gpui-updater-pre", "log", "serde", "serde_json", - "ureq", ] [[package]] @@ -573,7 +555,7 @@ dependencies = [ "anyhow", "arrayvec", "log", - "nom 8.0.0", + "nom", "num-rational", "v_frame", ] @@ -589,9 +571,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.18.1" +version = "1.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" +checksum = "faac5829c2b74c28f830747e7818ccfb684261b5f48a1118b1e2a13d36dfab13" dependencies = [ "aws-lc-sys", "zeroize", @@ -599,9 +581,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.45.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" +checksum = "f1622d8446a2d4b2ce0c7eefc73dd43a99779028d5ee5c2dd8073a658ba8a2bc" dependencies = [ "cc", "cmake", @@ -643,26 +625,6 @@ version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" -[[package]] -name = "bindgen" -version = "0.72.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" -dependencies = [ - "bitflags 2.13.2", - "cexpr", - "clang-sys", - "itertools 0.13.0", - "log", - "prettyplease", - "proc-macro2", - "quote", - "regex", - "rustc-hash 2.1.3", - "shlex 1.3.0", - "syn 2.0.119", -] - [[package]] name = "bip39" version = "2.2.2" @@ -897,7 +859,7 @@ checksum = "6a1f896587b6f2c069c73d2f0913e2d590c3990285cd2f0b6aa02b786b4c679c" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -918,15 +880,6 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" -[[package]] -name = "bzip2" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c" -dependencies = [ - "libbz2-rs-sys", -] - [[package]] name = "calloop" version = "0.14.5" @@ -980,23 +933,14 @@ dependencies = [ [[package]] name = "cc" -version = "1.6.0" +version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex 2.0.1", -] - -[[package]] -name = "cexpr" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6fac387a98bb7c37292057cffc56d62ecb629900026402633ae9160df93a8766" -dependencies = [ - "nom 7.1.3", + "shlex", ] [[package]] @@ -1011,15 +955,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" -[[package]] -name = "cgl" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ced0551234e87afee12411d535648dd89d2e7f34c78b753395567aff3d447ff" -dependencies = [ - "libc", -] - [[package]] name = "chacha20" version = "0.9.1" @@ -1134,17 +1069,6 @@ dependencies = [ "inout 0.2.2", ] -[[package]] -name = "clang-sys" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" -dependencies = [ - "glob", - "libc", - "libloading", -] - [[package]] name = "cmake" version = "0.1.58" @@ -1261,43 +1185,6 @@ dependencies = [ "regex", ] -[[package]] -name = "compression-codecs" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98fc98460ba0ad5317075d3632b8dfc45d0be8c4a49347c2a38272019717614a" -dependencies = [ - "bzip2", - "compression-core", - "flate2", - "memchr", -] - -[[package]] -name = "compression-core" -version = "0.4.33" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" - -[[package]] -name = "concord" -version = "1.0.2" -dependencies = [ - "anyhow", - "base64 0.22.1", - "chacha20 0.9.1", - "data-encoding", - "hkdf", - "hmac 0.12.1", - "nostr", - "nostr-sdk", - "rand 0.10.3", - "serde", - "serde_json", - "sha2 0.10.9", - "smol", -] - [[package]] name = "concurrent-queue" version = "2.5.0" @@ -1532,19 +1419,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-graphics2" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4416167a69126e617f8d0a214af0e3c1dbdeffcb100ddf72dcd1a1ac9893c146" -dependencies = [ - "bitflags 2.13.2", - "block", - "cfg-if", - "core-foundation 0.10.1", - "libc", -] - [[package]] name = "core-text" version = "21.0.0" @@ -1557,20 +1431,6 @@ dependencies = [ "libc", ] -[[package]] -name = "core-video" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139679cc63eb9504bdbe37e37874b0247136177655f0008588781e90863afa62" -dependencies = [ - "block", - "core-foundation 0.10.1", - "core-graphics2", - "io-surface", - "libc", - "metal", -] - [[package]] name = "core_detect" version = "1.0.0" @@ -1803,7 +1663,6 @@ dependencies = [ "quote", "rustc_version", "syn 2.0.119", - "unicode-xid", ] [[package]] @@ -1864,7 +1723,6 @@ checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ "bitflags 2.13.2", "block2 0.6.2", - "libc", "objc2 0.6.5", ] @@ -1876,7 +1734,7 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -1936,6 +1794,12 @@ version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" +[[package]] +name = "ed25519-compact" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1454db4f2edb7f0e8fe0c5b375b0c978fc63244cc9f010d160e417eb10139aa8" + [[package]] name = "either" version = "1.19.0" @@ -1951,7 +1815,7 @@ dependencies = [ "cc", "memchr", "rustc_version", - "toml 1.1.7+spec-1.1.0", + "toml 1.1.8+spec-1.1.0", "vswhom", "winreg", ] @@ -2034,17 +1898,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" -[[package]] -name = "erased-serde" -version = "0.4.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" -dependencies = [ - "serde", - "serde_core", - "typeid", -] - [[package]] name = "errno" version = "0.3.14" @@ -2164,12 +2017,6 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" -[[package]] -name = "fixedbitset" -version = "0.5.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" - [[package]] name = "flatbuffers" version = "25.12.19" @@ -2318,7 +2165,7 @@ checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2388,19 +2235,6 @@ dependencies = [ "futures-sink", ] -[[package]] -name = "futures-concurrency" -version = "7.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "175cd8cca9e1d45b87f18ffa75088f2099e3c4fe5e2f83e42de112560bea8ea6" -dependencies = [ - "fixedbitset", - "futures-core", - "futures-lite", - "pin-project", - "smallvec", -] - [[package]] name = "futures-core" version = "0.3.34" @@ -2445,7 +2279,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -2584,12 +2418,6 @@ dependencies = [ "xml-rs", ] -[[package]] -name = "glob" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" - [[package]] name = "globset" version = "0.4.20" @@ -2672,9 +2500,9 @@ dependencies = [ [[package]] name = "gpui-base" -version = "0.7.1" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cf698677b253118459f552fe15513858c669d79a25837a94e480db2ae330050" +checksum = "2e9955b6f4b048fd50ca15fc8e225d80bb00ba9ebc8fecdddde5ad26dbef9270" dependencies = [ "aho-corasick", "anyhow", @@ -2713,25 +2541,22 @@ dependencies = [ [[package]] name = "gpui-pre" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360" +checksum = "34f61c1cfd36fc9c436af09308c963822ae27a45a84feb21a77ba5c9e051f1c0" dependencies = [ "accesskit", "anyhow", "async-channel", "async-task", "backtrace", - "bindgen", "bitflags 2.13.2", "chrono", - "core-video", "ctor", "derive_more", "embed-resource", "etagere", "futures", - "futures-concurrency", "getrandom 0.3.4", "gpui-pre-collections", "gpui-pre-http-client", @@ -2741,7 +2566,6 @@ dependencies = [ "gpui-pre-shared-string", "gpui-pre-sum-tree", "gpui-pre-util", - "gpui-pre-util-macros", "heapless 0.9.3", "image", "inventory", @@ -2755,8 +2579,7 @@ dependencies = [ "parking", "parking_lot", "pin-project", - "pollster 0.4.0", - "postage", + "pollster", "profiling", "rand 0.9.5", "raw-window-handle", @@ -2777,30 +2600,31 @@ dependencies = [ "usvg", "uuid", "waker-fn", - "web-time", "windows 0.62.2", - "zed-font-kit", "zed-scap", ] [[package]] name = "gpui-pre-apple" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfdd914311cd12894367c8b9908947bb0c27fd42f8b9a5ff7a0730551a83fa4c" +checksum = "b52f58893fe1a0836464428dfad2b5eaf42c9c303a9638f4c2aced5a69072a15" dependencies = [ "anyhow", "async-task", "block2 0.6.2", "cbindgen", "core-foundation 0.10.1", + "core-foundation-sys", "core-graphics 0.24.0", - "core-video", + "core-text", "derive_more", "dispatch2", "etagere", "foreign-types", + "futures", "gpui-pre", + "gpui-pre-collections", "gpui-pre-util", "image", "libc", @@ -2808,16 +2632,25 @@ dependencies = [ "mach2", "metal", "objc2 0.6.5", + "objc2-core-foundation", + "objc2-core-text", + "objc2-core-video", "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", + "objc2-security", "parking_lot", + "pathfinder_geometry", + "smallvec", + "thiserror 2.0.21", + "zed-font-kit", ] [[package]] name = "gpui-pre-collections" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2c060863025dc3b8e90febad2d1f4ffd23a5d0dcedba39d3f6b7820ea2fdd3a0" +checksum = "920c2f5b2f1a7813114d89f4841f26f6060d9c35d7c8a852a4205d1a767a7ce0" dependencies = [ "gpui-pre-util", "indexmap", @@ -2826,9 +2659,9 @@ dependencies = [ [[package]] name = "gpui-pre-derive-refineable" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc834f30f5f49791e214f1fde01a009c7de91c9cd0a9f31dabfed130ba39e6cf" +checksum = "947395f26228296c0c79fb889d3687b205e552c25bbcdece216fb2394ea9bcd1" dependencies = [ "proc-macro2", "quote", @@ -2837,14 +2670,12 @@ dependencies = [ [[package]] name = "gpui-pre-http-client" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d2935cf82333da3f41774e5757837756408f01356197b41c817ad2666034453" +checksum = "c56cdaa3f1a271162b7fede1d4557879eedb056e293cedbb82743600650b755e" dependencies = [ "anyhow", - "async-compression", "bytes", - "derive_more", "futures", "http", "http-body", @@ -2858,9 +2689,9 @@ dependencies = [ [[package]] name = "gpui-pre-http-client-tls" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bbac58d44e80c2bfdd736cacd334e5c9446d5847cd584193ee3c8634c729c2b" +checksum = "b7616a90ec92c9832bbc80ddab34e10fdf8d23520d5cbb0b432b73e4e9f6c812" dependencies = [ "log", "rustls", @@ -2870,9 +2701,9 @@ dependencies = [ [[package]] name = "gpui-pre-linux" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1131746fad5c87b5de74dc9c73105655b0c820a2dd4bb247586573e7cf5720e" +checksum = "e28213c3fde6f13e91ba0b74b90cbaadec9373e36a031eda98561d31059870f3" dependencies = [ "accesskit", "accesskit_unix", @@ -2915,9 +2746,9 @@ dependencies = [ [[package]] name = "gpui-pre-macos" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d756813ff1cd741a502af60fd18869e0677795936d7349642b41c49f43120c0" +checksum = "c7c988b7e49775cc8542dbe48d5a10e073d8e898a023ef3ff3d24f33363e9863" dependencies = [ "accesskit", "accesskit_macos", @@ -2927,8 +2758,6 @@ dependencies = [ "core-foundation 0.10.1", "core-foundation-sys", "core-graphics 0.24.0", - "core-text", - "core-video", "ctor", "dispatch2", "foreign-types", @@ -2944,26 +2773,22 @@ dependencies = [ "objc", "objc2 0.6.5", "objc2-app-kit 0.3.2", - "objc2-core-graphics", - "objc2-core-media", + "objc2-core-video", "objc2-foundation 0.3.2", - "objc2-screen-capture-kit", "objc2-user-notifications", "parking_lot", - "pathfinder_geometry", "raw-window-handle", "semver", "smallvec", "strum", "uuid", - "zed-font-kit", ] [[package]] name = "gpui-pre-macros" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2ad26ca759364694cb55487bebff4c1b7dd6accab49990ff3cda6f7a818a9f4" +checksum = "0bb80dac5e1f6b8cde1d2740389e77dfc0071bfcc81fba5526d5dc90f68171cc" dependencies = [ "heck 0.5.0", "proc-macro-crate", @@ -2972,22 +2797,11 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "gpui-pre-perf" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcb288a506a476b6cbf97e6ec1cc0118db43f0ca91087588afa1c2d8cd4f842" -dependencies = [ - "gpui-pre-collections", - "serde", - "serde_json", -] - [[package]] name = "gpui-pre-platform" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e200b7b78836f1a0fc70767d971768e88f29d8b91b697285c15fb8e373635a3e" +checksum = "bbdbdac421178313fed8929fe95b42634a266a735079fcf03ff231bc2ccb2c5e" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3000,9 +2814,9 @@ dependencies = [ [[package]] name = "gpui-pre-refineable" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4042345bb5bd3ecbc5d15b21ed128154f462a992058d7038cd397631f5462338" +checksum = "237703b3b219d11adb50dd33bb0fcca3d34fc001623b3143e72cb051d0fb3777" dependencies = [ "gpui-pre-derive-refineable", ] @@ -3015,7 +2829,6 @@ checksum = "05be23908e707966824f8c51a609904b7f30739e8d915e120a53c1b995ba964c" dependencies = [ "base64 0.22.1", "bytes", - "encoding_rs", "futures-core", "futures-util", "h2", @@ -3029,7 +2842,6 @@ dependencies = [ "js-sys", "log", "mime", - "mime_guess", "once_cell", "percent-encoding", "pin-project-lite", @@ -3059,9 +2871,9 @@ dependencies = [ [[package]] name = "gpui-pre-reqwest-client" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "894b8d8618c92cbd4da28a04dd3fcf3c0e0607074fa99416a864ac9b63b18aae" +checksum = "92c2e70dc8550d60324b1eb3f3d08b4ccf20a8b577a9a42732dd3bafb2363bc0" dependencies = [ "anyhow", "bytes", @@ -3077,9 +2889,9 @@ dependencies = [ [[package]] name = "gpui-pre-scheduler" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4acd73345bc3c09f1b98c1b21eb4459ad8336f89bb2edcef619e11c5a08456e" +checksum = "83f342555282a59ed8dd58dd7e0a920524fa8ef6aa8c8f30e2edb7019a8f72cb" dependencies = [ "async-task", "backtrace", @@ -3094,9 +2906,9 @@ dependencies = [ [[package]] name = "gpui-pre-shared-string" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "477529fd5aa90bff969785ee9a3938484d94aa5101cdf7b84719629c0345f98a" +checksum = "71f488bf4780735f6b2dad24f8db1e3d9e2593de0ec298e3574c788f571b81c4" dependencies = [ "schemars", "serde", @@ -3105,46 +2917,35 @@ dependencies = [ [[package]] name = "gpui-pre-sum-tree" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ec0ec176d576b82aed716cf47185856b54ea9528caaf9d01787024689bfd824" +checksum = "aadb3bd3da22617e2158b1842f5a9c29c1626c926ad0686163ced1a009963ff3" dependencies = [ "gpui-pre-ztracing", "heapless 0.9.3", - "log", "rayon", "tracing", ] [[package]] name = "gpui-pre-util" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1635ae7d0b05400bfa211ddef6c67dc8d9f223c463022c2b3bf2c1c14c6044f" +checksum = "a04ec9abd7a87ac05a221f4629403831c44c17123bc33cb5d271a6303259b340" dependencies = [ "anyhow", "log", "which", ] -[[package]] -name = "gpui-pre-util-macros" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01486fce7722e5d7d7e7bb71c719dfdcc6ed917112bdc7c56d0556fdb60d10ab" -dependencies = [ - "gpui-pre-perf", - "quote", - "syn 2.0.119", -] - [[package]] name = "gpui-pre-web" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "184776d0763e6889dd2842dcb1204c15055945f2bdfe926b5ef57bdbd1952a65" +checksum = "aedd55b99dc77e28df8202b0ea868aa5c90fda4aefff539ea21087d7c2ff4bfb" dependencies = [ "anyhow", + "async-channel", "console_error_panic_hook", "futures", "gpui-pre", @@ -3169,9 +2970,9 @@ dependencies = [ [[package]] name = "gpui-pre-wgpu" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439c5421cafe1a7cf68d0c0ae5cc82b26fad6b0e6345ac98d20acaf1f2463914" +checksum = "5b1cdb26c1503bf9662ee8fbc5ab0b44b2deeee4b5c12dc67491ba6fb5fc544b" dependencies = [ "anyhow", "bytemuck", @@ -3196,9 +2997,9 @@ dependencies = [ [[package]] name = "gpui-pre-windows" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c" +checksum = "513d4f0fc7d596d3bdef2a73a524ffb83dabc95bf484f2855a698e7f431290a9" dependencies = [ "accesskit", "accesskit_windows", @@ -3224,42 +3025,32 @@ dependencies = [ "windows-registry 0.6.1", ] -[[package]] -name = "gpui-pre-zlog" -version = "0.3.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3bbe5b98630ef4bfed5a7105ec1062e78d571912bc491388f07ea22f2457c18b" -dependencies = [ - "anyhow", - "chrono", - "gpui-pre-collections", - "log", -] - [[package]] name = "gpui-pre-ztracing" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f806400f6bda1fdd9fc627e158c0e44fb0c4b7767582a3621efcf92f4a850db7" +checksum = "f17bfd6bd1f77ba5c58181fa6f82fc7a63015a1c58bef94a76e5acb9f8e42572" dependencies = [ - "gpui-pre-zlog", "gpui-pre-ztracing-macro", "tracing", - "tracing-subscriber", ] [[package]] name = "gpui-pre-ztracing-macro" -version = "0.3.8" +version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" +checksum = "4b0d73e1bc02eef886a5947f41b8512708792e9e5d3053d181843311a487f96f" [[package]] name = "gpui-updater-core" -version = "0.1.0" -source = "git+https://github.com/AprilNEA/gpui-updater#a622818f581eb8663a3a19d65f18b962f665946c" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79f73dd68bf12bc2c800192c340a3c577dc38e59255650e81b1678a6339c584" dependencies = [ + "base64 0.22.1", + "ed25519-compact", "minisign-verify", + "roxmltree 0.20.0", "semver", "serde", "serde_json", @@ -3269,6 +3060,17 @@ dependencies = [ "ureq", ] +[[package]] +name = "gpui-updater-pre" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11ccca59c85b01241ea8a899cf49bad9e24fb8bbb02669e689eaa88daa76de7" +dependencies = [ + "futures", + "gpui-pre", + "gpui-updater-core", +] + [[package]] name = "gpui_tokio" version = "0.1.0" @@ -3853,18 +3655,6 @@ dependencies = [ "rustversion", ] -[[package]] -name = "io-surface" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "554b8c5d64ec09a3a520fe58e4d48a73e00ff32899cdcbe32a4877afd4968b8e" -dependencies = [ - "cgl", - "core-foundation 0.10.1", - "core-foundation-sys", - "leaky-cow", -] - [[package]] name = "ipnet" version = "2.12.2" @@ -4028,33 +3818,12 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" -[[package]] -name = "leak" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd100e01f1154f2908dfa7d02219aeab25d0b9c7fa955164192e3245255a0c73" - -[[package]] -name = "leaky-cow" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40a8225d44241fd324a8af2806ba635fc7c8a7e9a7de4d5cf3ef54e71f5926fc" -dependencies = [ - "leak", -] - [[package]] name = "lebe" version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" -[[package]] -name = "libbz2-rs-sys" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" - [[package]] name = "libc" version = "0.2.190" @@ -4128,9 +3897,9 @@ checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" +checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70" [[package]] name = "lmdb-master-sys" @@ -4159,7 +3928,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" dependencies = [ "serde_core", - "value-bag", ] [[package]] @@ -4270,12 +4038,9 @@ dependencies = [ [[package]] name = "mach2" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" -dependencies = [ - "libc", -] +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" [[package]] name = "malloc_buf" @@ -4397,12 +4162,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "minisign-verify" version = "0.2.5" @@ -4575,16 +4334,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "nom" -version = "7.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" -dependencies = [ - "memchr", - "minimal-lexical", -] - [[package]] name = "nom" version = "8.0.0" @@ -4603,7 +4352,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.45.5" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "aes", "base64 0.22.1", @@ -4629,7 +4378,7 @@ dependencies = [ [[package]] name = "nostr-blossom" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "base64 0.22.1", "bitcoin_hashes 1.2.0", @@ -4642,7 +4391,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "async-utility", "futures-core", @@ -4656,7 +4405,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4665,7 +4414,7 @@ dependencies = [ [[package]] name = "nostr-gossip" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "nostr", "opaquerr 0.2.0", @@ -4674,7 +4423,7 @@ dependencies = [ [[package]] name = "nostr-gossip-memory" version = "0.45.1" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "indexmap", "lru", @@ -4686,7 +4435,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.45.3" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "async-utility", "flatbuffers", @@ -4701,7 +4450,7 @@ dependencies = [ [[package]] name = "nostr-memory" version = "0.45.2" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "btreecap", "nostr", @@ -4712,7 +4461,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.45.4" -source = "git+https://github.com/rust-nostr/nostr#463f0c2d48e1d3c9e295cc231715d8a3855c65d3" +source = "git+https://github.com/rust-nostr/nostr#9791c3f61fd416e50cb7c6d883226f2ddefaa653" dependencies = [ "async-utility", "async-wsocket", @@ -4975,28 +4724,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-core-audio" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" -dependencies = [ - "dispatch2", - "objc2 0.6.5", - "objc2-core-audio-types", - "objc2-core-foundation", -] - -[[package]] -name = "objc2-core-audio-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" -dependencies = [ - "bitflags 2.13.2", - "objc2 0.6.5", -] - [[package]] name = "objc2-core-data" version = "0.2.2" @@ -5066,31 +4793,6 @@ dependencies = [ "objc2-foundation 0.3.2", ] -[[package]] -name = "objc2-core-location" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ca347214e24bc973fc025fd0d36ebb179ff30536ed1f80252706db19ee452009" -dependencies = [ - "objc2 0.6.5", - "objc2-foundation 0.3.2", -] - -[[package]] -name = "objc2-core-media" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" -dependencies = [ - "bitflags 2.13.2", - "dispatch2", - "objc2 0.6.5", - "objc2-core-audio", - "objc2-core-audio-types", - "objc2-core-foundation", - "objc2-core-video", -] - [[package]] name = "objc2-core-text" version = "0.3.2" @@ -5114,6 +4816,7 @@ dependencies = [ "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -5209,17 +4912,12 @@ dependencies = [ ] [[package]] -name = "objc2-screen-capture-kit" +name = "objc2-security" version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74b7c5390f477482f001bc354d6571a70db7e4f8d5288e860c45521fbce11394" +checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a" dependencies = [ - "block2 0.6.2", - "dispatch2", - "objc2 0.6.5", - "objc2-core-graphics", - "objc2-core-media", - "objc2-foundation 0.3.2", + "objc2-core-foundation", ] [[package]] @@ -5231,7 +4929,6 @@ dependencies = [ "bitflags 2.13.2", "block2 0.6.2", "objc2 0.6.5", - "objc2-core-location", "objc2-foundation 0.3.2", ] @@ -5657,12 +5354,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "pollster" -version = "0.2.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5da3b0203fd7ee5720aa0b5e790b591aa5d3f41c3ed2c34a3a393382198af2f7" - [[package]] name = "pollster" version = "0.4.0" @@ -5716,23 +5407,6 @@ dependencies = [ "portable-atomic", ] -[[package]] -name = "postage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af3fb618632874fb76937c2361a7f22afd393c982a2165595407edc75b06d3c1" -dependencies = [ - "atomic", - "crossbeam-queue", - "futures", - "log", - "parking_lot", - "pin-project", - "pollster 0.2.5", - "static_assertions", - "thiserror 1.0.69", -] - [[package]] name = "potential_utf" version = "0.1.6" @@ -5769,23 +5443,13 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.119", -] - [[package]] name = "proc-macro-crate" version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.16+spec-1.1.0", + "toml_edit 0.25.17+spec-1.1.0", ] [[package]] @@ -6205,7 +5869,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6378,7 +6042,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d84e8ba78bd384263e5922f084cbe1b081c3b7e69add59c8fb097b879ba968a" dependencies = [ "globset", - "sha2 0.11.0", + "sha2 0.11.1", "walkdir", ] @@ -6568,9 +6232,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e" dependencies = [ "dyn-clone", "indexmap", @@ -6582,14 +6246,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.2" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +checksum = "e1c3a92094fa7d61aa124645844facb6b554dfc797136d0f5fd1f890e2bffc69" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6636,7 +6300,7 @@ dependencies = [ "cfg-if", "pbkdf2 0.13.0", "salsa20", - "sha2 0.11.0", + "sha2 0.11.1", ] [[package]] @@ -6741,7 +6405,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6752,24 +6416,16 @@ checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", -] - -[[package]] -name = "serde_fmt" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e497af288b3b95d067a23a4f749f2861121ffcb2f6d8379310dcda040c345ed" -dependencies = [ - "serde_core", + "syn 3.0.9", ] [[package]] name = "serde_json" -version = "1.0.151" +version = "1.0.154" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" dependencies = [ + "foldhash 0.2.0", "indexmap", "itoa", "memchr", @@ -6786,7 +6442,7 @@ checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -6866,9 +6522,9 @@ dependencies = [ [[package]] name = "sha2" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19" dependencies = [ "cfg-if", "cpufeatures 0.3.1", @@ -6884,12 +6540,6 @@ dependencies = [ "lazy_static", ] -[[package]] -name = "shlex" -version = "1.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" - [[package]] name = "shlex" version = "2.0.1" @@ -6989,9 +6639,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.16.2" +version = "1.16.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" [[package]] name = "smol" @@ -7164,85 +6814,6 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" -[[package]] -name = "sval" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b81b254da21fe1fcc4e3a74fe39b46e25e3a863078f8b71c954d47f84889dbc6" - -[[package]] -name = "sval_buffer" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50be352d2822ffafb59e3e2ddac9d5ee60f2eeadbb7b5a2a951b9f3651e87a6f" -dependencies = [ - "sval", - "sval_ref", - "zerocopy", -] - -[[package]] -name = "sval_dynamic" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b048ca293b998d9a45659159f94a64063791e74cdc670164943dbb434405573d" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_fmt" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6b5888e40f80568733217f27b7317b845f463400ced36c424b1a804730e53b2" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_json" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17664d6bb6b74947afaab9d7c991caa9bf5638d4dee16fcbef637f440796049" -dependencies = [ - "itoa", - "ryu", - "sval", -] - -[[package]] -name = "sval_nested" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07c059969ca5ca163ea7fef6c9661758973d17691aba92abdcf5c428f4ec122c" -dependencies = [ - "sval", - "sval_buffer", - "sval_ref", -] - -[[package]] -name = "sval_ref" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42d6b29ff568c85c87561807f51d2adfff4b6016c6363133f7cd1652a12548f3" -dependencies = [ - "sval", -] - -[[package]] -name = "sval_serde" -version = "2.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f33ec9edc42b12764d5c90ca0a1d84189c6bde81ed27507f1e661c6e4e05853" -dependencies = [ - "serde_core", - "sval", - "sval_nested", -] - [[package]] name = "svg_fmt" version = "0.4.5" @@ -7283,9 +6854,9 @@ dependencies = [ [[package]] name = "syn" -version = "3.0.6" +version = "3.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" dependencies = [ "proc-macro2", "quote", @@ -7318,7 +6889,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -7484,7 +7055,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -7623,7 +7194,7 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -7678,9 +7249,9 @@ dependencies = [ [[package]] name = "tokio-util" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b" dependencies = [ "bytes", "futures-core", @@ -7704,9 +7275,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.1.7+spec-1.1.0" +version = "1.1.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7874cd34dd99040fc24e30923aa64a60a81ff3ddfe2b8129ccc84ba24949deed" +checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d" dependencies = [ "indexmap", "serde_core", @@ -7751,9 +7322,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.16+spec-1.1.0" +version = "0.25.17+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88f38f3af8bf71841c67a990f4e2b52143b2d0a1a994c72513ff4c14977fc722" +checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" dependencies = [ "indexmap", "toml_datetime 1.1.2+spec-1.1.0", @@ -7763,9 +7334,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.1.4+spec-1.1.0" +version = "1.1.5+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bed810ee007d8e7f9cf20b4774e7198142fa27186e0f805871da7f3c39eaf333" +checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" dependencies = [ "winnow 1.0.4", ] @@ -7930,12 +7501,6 @@ dependencies = [ "utf-8", ] -[[package]] -name = "typeid" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" - [[package]] name = "typenum" version = "1.20.1" @@ -7998,9 +7563,9 @@ checksum = "65dd75ab2d9d379dbe86d4059e91d8b5a6e7054865eaba1ddbfc7a817ff96392" [[package]] name = "unicode-ident" -version = "1.0.26" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" [[package]] name = "unicode-linebreak" @@ -8047,12 +7612,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" -[[package]] -name = "unicode-xid" -version = "0.2.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" - [[package]] name = "universal-hash" version = "0.5.1" @@ -8172,9 +7731,9 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "uuid" -version = "1.27.0" +version = "1.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97277d36b9c3ace13e58fa6e753f8b0bbbf302a18dd193240d70f9e29681059a" +checksum = "7cc1186384beb7dd8eedea376413fd654937285ea6c9cfbb928dc3043ea4b606" dependencies = [ "getrandom 0.4.3", "js-sys", @@ -8200,42 +7759,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "value-bag" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2799ffb329a792ecfd902b71306c8a815a6ef1c0470fa9953a6aa4d4cecbe511" -dependencies = [ - "value-bag-serde1", - "value-bag-sval2", -] - -[[package]] -name = "value-bag-serde1" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0941feceafbe7a8f59ea1096d45b97002884a41306315ad797b3684b63a81d8c" -dependencies = [ - "erased-serde", - "serde_core", - "serde_fmt", -] - -[[package]] -name = "value-bag-sval2" -version = "1.14.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839752af8179287d27eb2b94164641b1ede9e60ab7424163388dc21ebd0508cd" -dependencies = [ - "sval", - "sval_buffer", - "sval_dynamic", - "sval_fmt", - "sval_json", - "sval_ref", - "sval_serde", -] - [[package]] name = "version_check" version = "0.9.5" @@ -8345,7 +7868,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "wasm-bindgen-shared", ] @@ -9576,7 +9099,7 @@ checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -9648,7 +9171,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zbus_names", "zvariant", "zvariant_utils", @@ -9755,18 +9278,18 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.61" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879fb705ce98c32e41ebdb970fbe1204f8492423b314c6ab0354c3e7b5542866" +checksum = "86502bf56ac7c77571a32e2647bb2a15894565e981fb2a48d7bde2d91c965a9d" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.61" +version = "0.8.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "708882a28301d604fa039cc7727607a98d04c4b86dc76ec9cc683f805d709759" +checksum = "5457206954b06561e2608c7e19cf58b1926586d999c246eebe4502f7e2039d1a" dependencies = [ "proc-macro2", "quote", @@ -9790,7 +9313,7 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "synstructure", ] @@ -9844,7 +9367,7 @@ checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", ] [[package]] @@ -9908,7 +9431,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 3.0.6", + "syn 3.0.9", "zvariant_utils", ] @@ -9921,6 +9444,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 3.0.6", + "syn 3.0.9", "winnow 1.0.4", ] diff --git a/Cargo.toml b/Cargo.toml index dafaa015..7e99b5ba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,7 +57,6 @@ smol = "2" webbrowser = "1.0.4" tracing-subscriber = { version = "0.3.18", features = ["fmt"] } instant = "0.1" -ureq = { version = "3", default-features = false, features = ["rustls", "platform-verifier", "json"] } [profile.release] strip = true diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index e58b814f..90d9004c 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -6,10 +6,12 @@ publish.workspace = true [dependencies] gpui.workspace = true -instant.workspace = true log.workspace = true serde.workspace = true serde_json.workspace = true -ureq.workspace = true +futures.workspace = true -gpui-updater-core = { git = "https://github.com/AprilNEA/gpui-updater" } +gpui-updater-pre = "0.2" + +[dev-dependencies] +reqwest_client.workspace = true diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 1f6e0997..3959bc6b 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -1,11 +1,9 @@ #![cfg(not(target_arch = "wasm32"))] -use std::sync::Arc; -use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::time::Duration; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; -use gpui_updater_core::{EngineConfig, Release, UpdateEngine, UpdateStatus, Verification, Version}; -use instant::Duration; +use gpui_updater_pre::{EngineConfig, UpdateStatus, Updater, Verification, Version}; use crate::source::{AssetFilter, GiteaSource, asset_filter_for}; @@ -66,13 +64,10 @@ struct GlobalAutoUpdater(Entity); impl Global for GlobalAutoUpdater {} pub struct AutoUpdater { - /// The blocking engine, driven on the background executor. - engine: Arc>, + updater: Entity, status: UpdateStatus, - /// The newer release found by the last successful check, if any. - available: Option, - /// The in-flight check or download, if any. - task: Option>, + /// The pending revert of an `Errored` status back to `Idle`, if any. + error_revert: Option>, } impl AutoUpdater { @@ -87,12 +82,21 @@ impl AutoUpdater { } fn new(version: Version, filter: AssetFilter, cx: &mut Context) -> Self { - let entity = cx.entity().downgrade(); - let source = GiteaSource::new(GITEA_API_BASE, GITEA_REPO_OWNER, GITEA_REPO_NAME, filter); - let config = EngineConfig::new(version.clone()).verification(Verification::Checksum); - let engine = Arc::new(UpdateEngine::new(source, config)); + let source = GiteaSource::new( + GITEA_API_BASE, + GITEA_REPO_OWNER, + GITEA_REPO_NAME, + filter, + cx.http_client(), + ); + let config = EngineConfig::new(version).verification(Verification::Checksum); + let updater = cx.new(|cx| Updater::new(source, config, cx)); + + cx.observe(&updater, |this, _, cx| this.observe_status(cx)) + .detach(); // Schedule an auto-check after a 2-minute delay + let entity = cx.entity().downgrade(); cx.defer(move |cx| { cx.spawn(async move |cx| { cx.background_executor().timer(AUTO_CHECK_DELAY).await; @@ -102,13 +106,41 @@ impl AutoUpdater { }); Self { - engine, + updater, status: UpdateStatus::Idle, - available: None, - task: None, + error_revert: None, } } + fn observe_status(&mut self, cx: &mut Context) { + let status = self.updater.read(cx).status().clone(); + self.status = status.clone(); + + self.error_revert = if matches!(status, UpdateStatus::Errored(_)) { + Some(cx.spawn(async move |this, cx| { + cx.background_executor().timer(ERROR_DISPLAY_DURATION).await; + this.update(cx, |this, cx| { + if matches!(this.status, UpdateStatus::Errored(_)) { + this.status = UpdateStatus::Idle; + cx.notify(); + } + }) + .ok(); + })) + } else { + None + }; + + // The adapter stops at `Available`; continue into the download. + if matches!(status, UpdateStatus::Available(_)) { + self.updater.update(cx, |updater, cx| { + updater.download_and_install(cx); + }); + } + + cx.notify(); + } + /// Whether the running version is the newest release, so the status line can be hidden. pub fn up_to_date(&self) -> bool { matches!(self.status, UpdateStatus::Idle | UpdateStatus::UpToDate) @@ -143,136 +175,9 @@ impl AutoUpdater { /// Check the release host for a newer version, then download and install it. pub fn check(&mut self, cx: &mut Context) { - if self.status.is_busy() { - return; - } - self.set_status(UpdateStatus::Checking, cx); - - let engine = self.engine.clone(); - - self.task = Some(cx.spawn(async move |this, cx| { - let result = cx - .background_executor() - .spawn(async move { engine.check() }) - .await; - - this.update(cx, |this, cx| { - this.task = None; - match result { - Ok(Some(release)) => { - log::info!("Update {} is available", release.version); - let version = release.version.clone(); - this.available = Some(release); - this.set_status(UpdateStatus::Available(version), cx); - this.download_and_install(cx); - } - Ok(None) => this.set_status(UpdateStatus::UpToDate, cx), - Err(error) => { - log::warn!("Update check failed: {error}"); - this.set_status(UpdateStatus::Errored(error.to_string()), cx); - } - } - }) - .ok(); - })); - } - - /// Download the available update, verify it, and swap it into place. - fn download_and_install(&mut self, cx: &mut Context) { - if self.status.is_busy() { - return; - } - let Some(release) = self.available.clone() else { - return; - }; - - let engine = self.engine.clone(); - self.set_status( - UpdateStatus::Downloading { - downloaded: 0, - total: None, - }, - cx, - ); - - self.task = Some(cx.spawn(async move |this, cx| { - let downloaded = Arc::new(AtomicU64::new(0)); - let total = Arc::new(AtomicU64::new(0)); // 0 = unknown - let done = Arc::new(AtomicBool::new(false)); - - let download_task = { - let (engine, release) = (engine.clone(), release.clone()); - let (downloaded, total, done) = (downloaded.clone(), total.clone(), done.clone()); - cx.background_executor().spawn(async move { - let result = engine.download(&release, |got, expected| { - downloaded.store(got, Ordering::Relaxed); - total.store(expected.unwrap_or(0), Ordering::Relaxed); - }); - done.store(true, Ordering::Relaxed); - result - }) - }; - - loop { - let got = downloaded.load(Ordering::Relaxed); - let total = total.load(Ordering::Relaxed); - this.update(cx, |this, cx| { - this.set_status( - UpdateStatus::Downloading { - downloaded: got, - total: (total != 0).then_some(total), - }, - cx, - ); - }) - .ok(); - if done.load(Ordering::Relaxed) { - break; - } - cx.background_executor() - .timer(Duration::from_millis(120)) - .await; - } - - let artifact = match download_task.await { - Ok(artifact) => artifact, - Err(error) => { - log::warn!("Update download failed: {error}"); - this.update(cx, |this, cx| { - this.task = None; - this.set_status(UpdateStatus::Errored(error.to_string()), cx); - }) - .ok(); - return; - } - }; - - let _ = this.update(cx, |this, cx| this.set_status(UpdateStatus::Installing, cx)); - - let installed = { - let engine = engine.clone(); - cx.background_executor() - .spawn(async move { engine.install(&artifact) }) - .await - }; - - this.update(cx, |this, cx| { - this.task = None; - match installed { - Ok(installed) => { - if let Some(path) = installed.restart_path { - cx.set_restart_path(path); - } - let version = release.version.clone(); - this.set_status(UpdateStatus::Staged(version), cx); - } - Err(error) => { - this.set_status(UpdateStatus::Errored(error.to_string()), cx); - } - } - }) - .ok(); - })); + self.updater.update(cx, |updater, cx| { + updater.check(cx); + }); } /// Relaunch into the staged update. @@ -281,24 +186,8 @@ impl AutoUpdater { log::warn!("Ignoring restart request: no update is staged"); return; } - cx.restart(); - } - - fn set_status(&mut self, status: UpdateStatus, cx: &mut Context) { - let errored = matches!(status, UpdateStatus::Errored(_)); - self.status = status; - - if errored { - cx.spawn(async move |this, cx| { - cx.background_executor().timer(ERROR_DISPLAY_DURATION).await; - this.update(cx, |this, cx| { - this.set_status(UpdateStatus::Idle, cx); - }) - .ok(); - }) - .detach(); - } - - cx.notify(); + self.updater.update(cx, |updater, cx| { + updater.restart(cx); + }); } } diff --git a/crates/auto_update/src/source.rs b/crates/auto_update/src/source.rs index 318b01e7..89d3a5fe 100644 --- a/crates/auto_update/src/source.rs +++ b/crates/auto_update/src/source.rs @@ -1,8 +1,18 @@ -use gpui_updater_core::{Asset, Error, Release, Result, UpdateSource, parse_tag}; +use std::sync::Arc; +use std::time::Duration; + +use futures::AsyncReadExt; +use futures::executor::block_on; +use gpui::http_client::{AsyncBody, HttpClient, HttpRequestExt, RedirectPolicy, Request}; +use gpui_updater_pre::{Asset, Error, Release, Result, UpdateSource, parse_tag}; use serde::Deserialize; +use serde::de::DeserializeOwned; const CHECKSUMS_ASSET: &str = "SHA256SUMS"; const RELEASE_PAGE_SIZE: usize = 20; +const USER_AGENT: &str = concat!("coop-updater/", env!("CARGO_PKG_VERSION")); +/// Deadline for the complete metadata request, including its body. +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); /// Which published artifact belongs to a target platform. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -39,12 +49,38 @@ pub fn asset_filter_for(os: &str, arch: &str) -> Option { Some(AssetFilter { extension, arch }) } +/// A release as returned by the Gitea API. +#[derive(Debug, Deserialize)] +struct GiteaRelease { + tag_name: String, + #[serde(default)] + name: Option, + #[serde(default)] + body: Option, + #[serde(default)] + draft: bool, + #[serde(default)] + prerelease: bool, + #[serde(default)] + assets: Vec, +} + +/// A release asset as returned by the Gitea API. +#[derive(Debug, Deserialize)] +struct GiteaAsset { + name: String, + browser_download_url: String, + #[serde(default)] + size: u64, +} + /// Reads releases from a Gitea repository's Releases. pub struct GiteaSource { api_base: String, owner: String, repo: String, filter: AssetFilter, + http_client: Arc, } impl GiteaSource { @@ -55,12 +91,14 @@ impl GiteaSource { owner: impl Into, repo: impl Into, filter: AssetFilter, + http_client: Arc, ) -> Self { Self { api_base: api_base.into().trim_end_matches('/').to_string(), owner: owner.into(), repo: repo.into(), filter, + http_client, } } @@ -70,11 +108,49 @@ impl GiteaSource { self.api_base, self.owner, self.repo ) } + + async fn get_bytes(&self, url: &str) -> Result> { + 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(&self, url: &str) -> Result { + 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 { + let body = self.get_bytes(url).await?; + String::from_utf8(body).map_err(|error| Error::Parse(error.to_string())) + } } impl UpdateSource for GiteaSource { fn fetch_latest(&self) -> Result { - let releases: Vec = http::get_json(&self.releases_url())?; + let releases: Vec = block_on(self.get_json(&self.releases_url()))?; let release = newest_published(&releases) .ok_or_else(|| Error::Parse("repository has no published releases".to_string()))?; @@ -92,26 +168,27 @@ impl UpdateSource for GiteaSource { .assets .iter() .find(|candidate| candidate.name.eq_ignore_ascii_case(CHECKSUMS_ASSET)) - .map(|sums| http::get_string(&sums.browser_download_url)) + .map(|sums| block_on(self.get_string(&sums.browser_download_url))) .transpose()? .and_then(|sums| sha256_for(&sums, &asset.name)); - Ok(Release { - version: parse_tag(&release.tag_name)?, - notes: release - .body - .clone() - .filter(|body| !body.trim().is_empty()) - .or_else(|| release.name.clone()), - asset: Asset { - name: asset.name.clone(), - url: asset.browser_download_url.clone(), - size: asset.size, - }, - signature: None, - signature_url: None, - sha256, - }) + let notes = release + .body + .clone() + .filter(|body| !body.trim().is_empty()) + .or_else(|| release.name.clone()); + + let mut update = Release::new( + parse_tag(&release.tag_name)?, + Asset::new(&asset.name, &asset.browser_download_url, asset.size), + ); + if let Some(notes) = notes { + update = update.with_notes(notes); + } + if let Some(sha256) = sha256 { + update = update.with_sha256(sha256); + } + Ok(update) } } @@ -139,83 +216,9 @@ fn sha256_for(sums: &str, asset_name: &str) -> Option { }) } -/// A release as returned by the Gitea API. -#[derive(Debug, Deserialize)] -struct GiteaRelease { - tag_name: String, - #[serde(default)] - name: Option, - #[serde(default)] - body: Option, - #[serde(default)] - draft: bool, - #[serde(default)] - prerelease: bool, - #[serde(default)] - assets: Vec, -} - -/// 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> { - 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(url: &str) -> Result { - serde_json::from_slice(&get_bytes(url)?).map_err(|error| Error::Parse(error.to_string())) - } - - pub(super) fn get_string(url: &str) -> Result { - String::from_utf8(get_bytes(url)?).map_err(|error| Error::Parse(error.to_string())) - } -} - #[cfg(test)] mod tests { - use gpui_updater_core::Version; + use gpui_updater_pre::Version; use super::*; @@ -344,7 +347,13 @@ abcdef macos-arm64-artifacts/Coop_1.0.1_aarch64.dmg fn live_release_source_resolves_the_running_platform() { let filter = asset_filter_for(std::env::consts::OS, std::env::consts::ARCH) .expect("this platform should be supported"); - let source = GiteaSource::new("https://git.reya.info/api/v1", "reya", "coop", filter); + let source = GiteaSource::new( + "https://git.reya.info/api/v1", + "reya", + "coop", + filter, + Arc::new(reqwest_client::ReqwestClient::new()), + ); let release = source .fetch_latest() diff --git a/crates/concord/Cargo.toml b/crates/concord/Cargo.toml deleted file mode 100644 index d7ac779c..00000000 --- a/crates/concord/Cargo.toml +++ /dev/null @@ -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 diff --git a/crates/concord/src/cords/cord01.rs b/crates/concord/src/cords/cord01.rs deleted file mode 100644 index ad7f96ae..00000000 --- a/crates/concord/src/cords/cord01.rs +++ /dev/null @@ -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 { - 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, - 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, - 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 { - let seconds = rumor.created_at.as_secs().saturating_mul(1000); - let mut tag: Option> = 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 { - 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 { - check_plaintext_cap(plaintext.len())?; - Ok(BASE64.encode(&encrypt(conversation, plaintext)?)) -} - -pub fn open_bytes(conversation: &ConversationKey, content: &str) -> Result, 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(signer: &S, plaintext: &str) -> Result -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(signer: &S, content: &str) -> Result -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( - rumor: &UnsignedEvent, - form: SealForm, - group: &GroupKey, - author: &S, -) -> Result -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 { - 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 { - 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 { - 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, 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 { - 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, StreamError> { - let mut found: Option = 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 { - 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::::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); - } -} diff --git a/crates/concord/src/cords/cord02/guestbook.rs b/crates/concord/src/cords/cord02/guestbook.rs deleted file mode 100644 index d430d88f..00000000 --- a/crates/concord/src/cords/cord02/guestbook.rs +++ /dev/null @@ -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, - }, - Snapshot { - refounder: PublicKey, - members: Vec, - 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 { - 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 = chunk.iter().map(PublicKey::to_hex).collect(); - let content = format!( - "[{}]", - hex.iter() - .map(|member| format!("\"{member}\"")) - .collect::>() - .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( - 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, - can_kick: impl Fn(&PublicKey, &PublicKey, Option<&AuthorityCitation>) -> bool, -) -> BTreeMap { - let mut states: BTreeMap, 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, - observed: &BTreeMap, - granted: &BTreeSet, - banned: &BTreeSet, - banned_at: &BTreeMap, -) -> BTreeSet { - 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, 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 { - 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, GuestbookError> { - let entries: Vec = - 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 { - 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 { - 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 { - 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 = (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 = 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) - } -} diff --git a/crates/concord/src/cords/cord02/list.rs b/crates/concord/src/cords/cord02/list.rs deleted file mode 100644 index 16066018..00000000 --- a/crates/concord/src/cords/cord02/list.rs +++ /dev/null @@ -1,1171 +0,0 @@ -use std::collections::btree_map::Entry; -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use data_encoding::HEXLOWER; -use nostr_sdk::prelude::*; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::cord01::{self, NIP44_MAX_PLAINTEXT}; -use crate::cord05::{ChannelGrant, CommunityInvite}; -use crate::utils::{base64_to_hex32, base64url, canonical, hex32_to_base64, union}; -use crate::{ChannelId, CommunityId, Epoch, Extra, decode_hex_32}; - -pub const KIND_COMMUNITY_LIST: u16 = 33302; -pub const MAX_MEMBERSHIPS: usize = 50; - -#[derive(Debug)] -pub enum ListError { - Kind(u16), - Crypto(String), - Json(String), - Encoding(String), - Fragment(String), - TooManyMemberships(usize), - Oversize(usize), -} - -impl fmt::Display for ListError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - ListError::Kind(kind) => write!(f, "not a community list kind: {kind}"), - ListError::Crypto(error) => write!(f, "crypto: {error}"), - ListError::Json(error) => write!(f, "json: {error}"), - ListError::Encoding(error) => write!(f, "encoding: {error}"), - ListError::Fragment(error) => write!(f, "fragment: {error}"), - ListError::TooManyMemberships(count) => { - write!( - f, - "list carries {count} memberships (cap {MAX_MEMBERSHIPS})" - ) - } - ListError::Oversize(len) => { - write!(f, "list is {len} bytes (cap {NIP44_MAX_PLAINTEXT})") - } - } - } -} - -impl std::error::Error for ListError {} - -impl From for ListError { - fn from(error: cord01::StreamError) -> Self { - ListError::Crypto(error.to_string()) - } -} - -#[derive(Debug, Clone, PartialEq)] -pub struct JoinMaterial { - pub community_id: CommunityId, - pub owner: PublicKey, - pub owner_salt: String, - pub community_root: String, - pub root_epoch: Epoch, - pub control_pk: Option, - /// Present only when the holder is staff. - pub control_root: Option, - pub channels: Vec, - pub relays: Vec, - pub name: String, - pub extra: Extra, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CommunityListEntry { - pub community_id: CommunityId, - pub seed: JoinMaterial, - pub current: JoinMaterial, - pub added_at: u64, - pub extra: Extra, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct Tombstone { - pub community_id: CommunityId, - pub removed_at: u64, - pub extra: Extra, -} - -#[derive(Debug, Clone, PartialEq)] -pub struct CommunityList { - /// How many fragments this List has. Every fragment declares it. - pub frags: u64, - pub entries: Vec, - pub tombstones: Vec, - pub extra: Extra, -} - -impl Default for CommunityList { - fn default() -> Self { - Self { - frags: 1, - entries: Vec::new(), - tombstones: Vec::new(), - extra: Extra::default(), - } - } -} - -impl CommunityList { - pub fn is_live(&self, community_id: &CommunityId) -> bool { - let added = self - .entries - .iter() - .find(|entry| entry.community_id == *community_id) - .map(|entry| entry.added_at); - - match added { - None => false, - Some(added) => self - .tombstones - .iter() - .find(|tombstone| tombstone.community_id == *community_id) - .is_none_or(|tombstone| added > tombstone.removed_at), - } - } - - pub fn is_complete(&self, held: I) -> bool - where - I: IntoIterator, - { - let held: BTreeSet = held.into_iter().collect(); - (0..self.frags).all(|index| held.contains(&index)) - } - - pub fn joined(&self, entry: CommunityListEntry) -> CommunityList { - merge( - self.clone(), - CommunityList { - entries: vec![entry], - ..Default::default() - }, - ) - } - - pub fn tombstoned(&self, community_id: CommunityId, removed_at: u64) -> CommunityList { - merge( - self.clone(), - CommunityList { - tombstones: vec![Tombstone { - community_id, - removed_at, - extra: Extra::default(), - }], - ..Default::default() - }, - ) - } - - pub fn fits(&self) -> Result<(), ListError> { - if self.entries.len() > MAX_MEMBERSHIPS { - return Err(ListError::TooManyMemberships(self.entries.len())); - } - - let json = serde_json::to_string(self).map_err(json_error)?; - - if json.len() > NIP44_MAX_PLAINTEXT { - return Err(ListError::Oversize(json.len())); - } - - Ok(()) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct RetainedRoot { - pub epoch: Epoch, - pub key: [u8; 32], - pub control_pk: Option, - /// The npub whose Refounding minted this epoch. - pub refounder: Option, - /// The epoch-seconds the superseding rotation published. - pub retired_at: Option, -} - -impl JoinMaterial { - /// The npub whose Refounding minted `root_epoch`, when the document names one. - pub fn refounder(&self) -> Option { - self.extra.get("refounder").and_then(named_public_key) - } - - /// The prior root epochs the entry retains, with what the document knows of each. - pub fn held_roots(&self) -> Vec { - let Some(roots) = self - .extra - .get("held_roots") - .and_then(|roots| roots.as_array()) - else { - return Vec::new(); - }; - - let mut retained = Vec::with_capacity(roots.len()); - - for root in roots { - let Some(fields) = root.as_object() else { - continue; - }; - - let Some(epoch) = fields.get("epoch").and_then(|epoch| epoch.as_u64()) else { - continue; - }; - - let Some(key) = fields - .get("key") - .and_then(|key| key.as_str()) - .and_then(|key| decode_hex_32(&key.to_ascii_lowercase()).ok()) - else { - continue; - }; - - retained.push(RetainedRoot { - epoch: Epoch(epoch), - key, - control_pk: fields.get("control_pk").and_then(named_public_key), - refounder: fields.get("refounder").and_then(named_public_key), - retired_at: fields.get("retired_at").and_then(|at| at.as_u64()), - }); - } - - retained - } -} - -/// A hex npub the document names, or nothing when the field is not one. -fn named_public_key(value: &serde_json::Value) -> Option { - PublicKey::from_hex(value.as_str()?).ok() -} - -pub fn join_material(invite: &CommunityInvite, control_root: Option<&[u8; 32]>) -> JoinMaterial { - JoinMaterial { - community_id: invite.community_id, - owner: invite.owner, - owner_salt: invite.owner_salt.clone(), - community_root: invite.community_root.clone(), - root_epoch: invite.root_epoch, - control_pk: invite.control_pk, - control_root: control_root.map(|key| HEXLOWER.encode(key)), - channels: invite.channels.clone(), - relays: invite.relays.clone(), - name: invite.name.clone(), - extra: Extra::default(), - } -} - -pub fn merge(held: CommunityList, incoming: CommunityList) -> CommunityList { - let mut entries: BTreeMap = BTreeMap::new(); - - for mut entry in held.entries.into_iter().chain(incoming.entries) { - normalize(&mut entry); - - match entries.entry(entry.community_id) { - Entry::Vacant(slot) => { - slot.insert(entry); - } - Entry::Occupied(mut slot) => merge_entry(slot.get_mut(), entry), - } - } - - let mut tombstones: BTreeMap = BTreeMap::new(); - - for tombstone in held.tombstones.into_iter().chain(incoming.tombstones) { - match tombstones.entry(tombstone.community_id) { - Entry::Vacant(slot) => { - slot.insert(tombstone); - } - Entry::Occupied(mut slot) => { - let held = slot.get_mut(); - held.removed_at = held.removed_at.max(tombstone.removed_at); - union(&mut held.extra, tombstone.extra); - } - } - } - - let mut extra = held.extra; - union(&mut extra, incoming.extra); - - CommunityList { - frags: held.frags.max(incoming.frags), - entries: entries.into_values().collect(), - tombstones: tombstones.into_values().collect(), - extra, - } -} - -pub async fn build_list_event( - signer: &S, - list: &CommunityList, - fragment: u64, - at_secs: u64, -) -> Result -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(signer, &json).await?; - - EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), content) - .tag(Tag::identifier(fragment.to_string())) - .custom_created_at(Timestamp::from_secs(at_secs)) - .finalize_async(signer) - .await - .map_err(crypto_error) -} - -pub async fn parse_list_event(signer: &S, event: &Event) -> Result -where - S: AsyncGetPublicKey + AsyncNip44 + ?Sized, -{ - if event.kind.as_u16() != KIND_COMMUNITY_LIST { - return Err(ListError::Kind(event.kind.as_u16())); - } - - fragment_index(event)?; - - let json = cord01::open_to_self(signer, &event.content).await?; - - serde_json::from_str(&json).map_err(json_error) -} - -pub fn fragment_index(event: &Event) -> Result { - let value = event - .tags - .identifier() - .ok_or_else(|| ListError::Fragment("missing d tag".to_owned()))?; - - value - .parse() - .map_err(|_| ListError::Fragment(format!("d tag is not a fragment index: {value}"))) -} - -fn decode_base64(value: &str, field: &str) -> Result<[u8; 32], ListError> { - base64url::decode_32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) -} - -fn decode_community_id(value: &str) -> Result { - Ok(CommunityId::from_bytes(decode_base64( - value, - "community_id", - )?)) -} - -fn decode_public_key(value: &str, field: &str) -> Result { - PublicKey::from_slice(&decode_base64(value, field)?) - .map_err(|error| ListError::Encoding(format!("{field}: {error}"))) -} - -fn encode_hex_32(value: &str, field: &str) -> Result { - hex32_to_base64(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) -} - -fn decode_hex_32_base64(value: &str, field: &str) -> Result { - base64_to_hex32(value).map_err(|error| ListError::Encoding(format!("{field}: {error}"))) -} - -#[derive(Debug, Serialize, Deserialize)] -struct WireList { - #[serde(default = "one_fragment")] - frags: u64, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - entries: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - tombstones: Vec, - #[serde(flatten)] - extra: Extra, -} - -fn one_fragment() -> u64 { - 1 -} - -#[derive(Debug, Serialize, Deserialize)] -struct WireEntry { - community_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - seed: Option, - current: WireSnapshot, - added_at: u64, - #[serde(flatten)] - extra: Extra, -} - -#[derive(Debug, Serialize, Deserialize)] -struct WireTombstone { - community_id: String, - removed_at: u64, - #[serde(flatten)] - extra: Extra, -} - -#[derive(Debug, Serialize, Deserialize)] -struct WireSnapshot { - owner: String, - owner_salt: String, - community_root: String, - root_epoch: Epoch, - #[serde(default, skip_serializing_if = "Option::is_none")] - control_pk: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - control_root: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - channels: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - relays: Vec, - name: String, - #[serde(flatten)] - extra: Extra, -} - -#[derive(Debug, Serialize, Deserialize)] -struct WireChannel { - id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - key: Option, - epoch: Epoch, - #[serde(default)] - name: String, - #[serde(flatten)] - extra: Extra, -} - -impl WireList { - fn encode(list: &CommunityList) -> Result { - // §8: a retired entry is not written — the tombstone alone carries the - // state, since a membership is live only while an entry outranks its - // removal. The entry stays in the in-memory document. - let mut entries = Vec::with_capacity(list.entries.len()); - for entry in &list.entries { - if list.is_live(&entry.community_id) { - entries.push(WireEntry::encode(entry)?); - } - } - - let mut tombstones = Vec::with_capacity(list.tombstones.len()); - for tombstone in &list.tombstones { - tombstones.push(WireTombstone::encode(tombstone)?); - } - - Ok(Self { - frags: list.frags, - entries, - tombstones, - extra: list.extra.clone(), - }) - } - - fn decode(self) -> Result { - let mut entries = Vec::with_capacity(self.entries.len()); - for entry in self.entries { - entries.push(entry.decode()?); - } - - let mut tombstones = Vec::with_capacity(self.tombstones.len()); - for tombstone in self.tombstones { - tombstones.push(tombstone.decode()?); - } - - Ok(CommunityList { - frags: self.frags.max(1), - entries, - tombstones, - extra: self.extra, - }) - } -} - -impl WireEntry { - fn encode(entry: &CommunityListEntry) -> Result { - let current = WireSnapshot::encode(&entry.current)?; - - let mut seed = entry.seed.clone(); - normalize_snapshot(&mut seed, &entry.current); - - let seed = if seed == entry.current { - None - } else { - Some(WireSnapshot::encode(&seed)?) - }; - - Ok(Self { - community_id: base64url::encode(entry.community_id.as_bytes()), - seed, - current, - added_at: entry.added_at, - extra: entry.extra.clone(), - }) - } - - fn decode(self) -> Result { - let community_id = decode_community_id(&self.community_id)?; - let current = self.current.decode(community_id)?; - let seed = match self.seed { - Some(seed) => seed.decode(community_id)?, - None => current.clone(), - }; - - let mut entry = CommunityListEntry { - community_id, - seed, - current, - added_at: self.added_at, - extra: self.extra, - }; - normalize(&mut entry); - - Ok(entry) - } -} - -impl WireTombstone { - fn encode(tombstone: &Tombstone) -> Result { - Ok(Self { - community_id: base64url::encode(tombstone.community_id.as_bytes()), - removed_at: tombstone.removed_at, - extra: tombstone.extra.clone(), - }) - } - - fn decode(self) -> Result { - Ok(Tombstone { - community_id: decode_community_id(&self.community_id)?, - removed_at: self.removed_at, - extra: self.extra, - }) - } -} - -impl WireSnapshot { - fn encode(material: &JoinMaterial) -> Result { - let mut channels = Vec::with_capacity(material.channels.len()); - for channel in &material.channels { - channels.push(WireChannel::encode(channel)?); - } - - Ok(Self { - owner: base64url::encode(&material.owner.to_bytes()), - owner_salt: encode_hex_32(&material.owner_salt, "owner_salt")?, - community_root: encode_hex_32(&material.community_root, "community_root")?, - root_epoch: material.root_epoch, - control_pk: material - .control_pk - .map(|key| base64url::encode(&key.to_bytes())), - control_root: match &material.control_root { - Some(root) => Some(encode_hex_32(root, "control_root")?), - None => None, - }, - channels, - relays: material.relays.clone(), - name: material.name.clone(), - extra: material.extra.clone(), - }) - } - - fn decode(self, community_id: CommunityId) -> Result { - let mut channels = Vec::with_capacity(self.channels.len()); - for channel in self.channels { - channels.push(channel.decode()?); - } - - Ok(JoinMaterial { - community_id, - owner: decode_public_key(&self.owner, "owner")?, - owner_salt: decode_hex_32_base64(&self.owner_salt, "owner_salt")?, - community_root: decode_hex_32_base64(&self.community_root, "community_root")?, - root_epoch: self.root_epoch, - control_pk: match self.control_pk { - Some(value) => Some(decode_public_key(&value, "control_pk")?), - None => None, - }, - control_root: match self.control_root { - Some(value) => Some(decode_hex_32_base64(&value, "control_root")?), - None => None, - }, - channels, - relays: self.relays, - name: self.name, - extra: self.extra, - }) - } -} - -impl WireChannel { - fn encode(grant: &ChannelGrant) -> Result { - Ok(Self { - id: base64url::encode(grant.id.as_bytes()), - key: match &grant.key { - Some(key) => Some(encode_hex_32(key, "channel key")?), - None => None, - }, - epoch: grant.epoch, - name: grant.name.clone(), - extra: grant.extra.clone(), - }) - } - - fn decode(self) -> Result { - Ok(ChannelGrant { - id: ChannelId::from_bytes(decode_base64(&self.id, "channel id")?), - key: match self.key { - Some(value) => Some(decode_hex_32_base64(&value, "channel key")?), - None => None, - }, - epoch: self.epoch, - name: self.name, - extra: self.extra, - }) - } -} - -impl Serialize for CommunityList { - fn serialize(&self, serializer: S) -> Result { - WireList::encode(self) - .map_err(serde::ser::Error::custom)? - .serialize(serializer) - } -} - -impl<'de> Deserialize<'de> for CommunityList { - fn deserialize>(deserializer: D) -> Result { - WireList::deserialize(deserializer)? - .decode() - .map_err(serde::de::Error::custom) - } -} - -impl Serialize for JoinMaterial { - fn serialize(&self, serializer: S) -> Result { - WireSnapshot::encode(self) - .map_err(serde::ser::Error::custom)? - .serialize(serializer) - } -} - -#[derive(Clone, Copy, PartialEq, Eq)] -enum Snapshot { - Seed, - Current, -} - -fn merge_entry(held: &mut CommunityListEntry, incoming: CommunityListEntry) { - held.added_at = held.added_at.max(incoming.added_at); - held.seed = pick(&held.seed, &incoming.seed, Snapshot::Seed).clone(); - held.current = pick(&held.current, &incoming.current, Snapshot::Current).clone(); - union(&mut held.extra, incoming.extra); - normalize(held); -} - -fn pick<'a>( - held: &'a JoinMaterial, - incoming: &'a JoinMaterial, - which: Snapshot, -) -> &'a JoinMaterial { - let preferred = match which { - Snapshot::Seed => incoming.root_epoch < held.root_epoch, - Snapshot::Current => incoming.root_epoch > held.root_epoch, - }; - - if preferred { - return incoming; - } - - if incoming.root_epoch == held.root_epoch && canonical(incoming) < canonical(held) { - return incoming; - } - - held -} - -fn normalize(entry: &mut CommunityListEntry) { - entry.seed.community_id = entry.community_id; - entry.current.community_id = entry.community_id; - normalize_snapshot(&mut entry.seed, &entry.current); -} - -fn normalize_snapshot(seed: &mut JoinMaterial, current: &JoinMaterial) { - seed.community_id = current.community_id; - seed.name = current.name.clone(); - seed.relays = current.relays.clone(); - - for seed_channel in &mut seed.channels { - if let Some(current_channel) = current - .channels - .iter() - .find(|channel| channel.id == seed_channel.id) - { - seed_channel.name = current_channel.name.clone(); - } - } -} - -fn json_error(error: serde_json::Error) -> ListError { - ListError::Json(error.to_string()) -} - -fn crypto_error(error: impl fmt::Display) -> ListError { - ListError::Crypto(error.to_string()) -} - -#[cfg(test)] -mod tests { - use serde_json::{Value, json}; - - use super::*; - - fn id(byte: u8) -> CommunityId { - CommunityId::from_bytes([byte; 32]) - } - - fn material( - community_id: CommunityId, - owner: PublicKey, - name: &str, - epoch: u64, - ) -> JoinMaterial { - JoinMaterial { - community_id, - owner, - owner_salt: "33".repeat(32), - community_root: "44".repeat(32), - root_epoch: Epoch(epoch), - control_pk: None, - control_root: None, - channels: vec![], - relays: vec!["wss://relay.example".to_owned()], - name: name.to_owned(), - extra: Extra::default(), - } - } - - fn entry( - community_id: CommunityId, - seed: JoinMaterial, - current: JoinMaterial, - added_at: u64, - ) -> CommunityListEntry { - CommunityListEntry { - community_id, - seed, - current, - added_at, - extra: Extra::default(), - } - } - - fn list(entries: Vec) -> CommunityList { - CommunityList { - entries, - ..Default::default() - } - } - - fn removal(community_id: CommunityId, removed_at: u64) -> CommunityList { - CommunityList { - tombstones: vec![Tombstone { - community_id, - removed_at, - extra: Extra::default(), - }], - ..Default::default() - } - } - - /// A peer's refounder and retained roots ride in the unknown-field map — - /// they are extensions, not §8 named fields — so the round trip has to keep - /// them, and the accessors have to read them out of it. - #[test] - fn the_wire_round_trips_a_refounder_and_the_retained_roots() { - let owner = Keys::generate().public_key(); - let refounder = Keys::generate().public_key(); - let mut current = material(id(0x11), owner, "Room", 3); - - current - .extra - .insert("refounder".to_owned(), Value::String(refounder.to_hex())); - current.extra.insert( - "held_roots".to_owned(), - json!([ - { - "epoch": 2, - "key": "77".repeat(32), - "refounder": refounder.to_hex(), - "retired_at": 1_700_000_000, - }, - { "epoch": 1, "key": "88".repeat(32) }, - { "epoch": 4 }, - ]), - ); - - let wire = serde_json::to_string(&WireSnapshot::encode(¤t).expect("encodes")) - .expect("serializes"); - let decoded = serde_json::from_str::(&wire) - .expect("parses") - .decode(id(0x11)) - .expect("decodes"); - - assert_eq!(decoded.refounder(), Some(refounder)); - assert_eq!( - decoded.held_roots(), - vec![ - RetainedRoot { - epoch: Epoch(2), - key: [0x77; 32], - control_pk: None, - refounder: Some(refounder), - retired_at: Some(1_700_000_000), - }, - RetainedRoot { - epoch: Epoch(1), - key: [0x88; 32], - control_pk: None, - refounder: None, - retired_at: None, - }, - ], - "a root without a key is not a root" - ); - } - - #[test] - fn merge_keeps_the_earlier_seed_and_the_later_current_either_way_round() { - let owner = Keys::generate().public_key(); - let older = material(id(0x11), owner, "Room", 1); - let newer = material(id(0x11), owner, "Room", 3); - - let a = list(vec![entry(id(0x11), older.clone(), newer.clone(), 5_000)]); - let b = list(vec![entry(id(0x11), newer, older, 5_000)]); - - for merged in [merge(a.clone(), b.clone()), merge(b, a)] { - let merged = merged.entries.first().expect("one membership"); - assert_eq!( - merged.seed.root_epoch, - Epoch(1), - "seed anchors the earliest epoch held" - ); - assert_eq!(merged.current.root_epoch, Epoch(3)); - assert_eq!(merged.added_at, 5_000); - } - - // An epoch tie breaks on the whole snapshot's bytes, and does so for both - // orders, so two devices never flap competing republishes. - let alpha = material(id(0x11), owner, "Alpha", 2); - let beta = material(id(0x11), owner, "Beta", 2); - let a = list(vec![entry(id(0x11), alpha.clone(), alpha, 1)]); - let b = list(vec![entry(id(0x11), beta.clone(), beta, 1)]); - - let first = merge(a.clone(), b.clone()); - assert_eq!(first, merge(b, a)); - assert_eq!(first.entries[0].current.name, "Alpha"); - } - - #[test] - fn a_tombstone_is_terminal_and_the_entry_it_retires_is_never_written() { - let owner = Keys::generate().public_key(); - let joined = entry( - id(0x11), - material(id(0x11), owner, "Room", 0), - material(id(0x11), owner, "Room", 0), - 5_000, - ); - - let left = merge(list(vec![joined.clone()]), removal(id(0x11), 6_000)); - assert!(!left.is_live(&id(0x11))); - assert_eq!( - left.entries.len(), - 1, - "a retired entry stays in the document" - ); - - // A stale device re-merging the entry cannot resurrect it. - assert!(!merge(left.clone(), list(vec![joined.clone()])).is_live(&id(0x11))); - - // The tombstone alone is written, so the entry's key material leaves the wire. - let written = serde_json::to_string(&left).expect("writes"); - assert!( - !written.contains("\"entries\""), - "a retired entry is not written" - ); - let reparsed: CommunityList = serde_json::from_str(&written).expect("parses"); - assert_eq!(reparsed.tombstones.len(), 1); - assert!(!reparsed.is_live(&id(0x11))); - - // A re-join genuinely newer than the removal does, and is written again. - let rejoined = merge( - reparsed, - list(vec![entry( - id(0x11), - material(id(0x11), owner, "Room", 0), - material(id(0x11), owner, "Room", 0), - 7_000, - )]), - ); - assert!(rejoined.is_live(&id(0x11))); - assert!( - serde_json::to_string(&rejoined) - .expect("writes") - .contains("\"entries\"") - ); - - // And the older removal is not re-applied on top of it. - assert!(merge(rejoined, removal(id(0x11), 6_000)).is_live(&id(0x11))); - } - - #[test] - fn frags_disagreement_resolves_to_the_larger_value_and_completeness_is_by_index() { - let two = CommunityList { - frags: 2, - ..Default::default() - }; - let three = CommunityList { - frags: 3, - ..Default::default() - }; - - assert_eq!( - merge(two.clone(), three.clone()).frags, - 3, - "the larger fragment count wins" - ); - assert_eq!(merge(three.clone(), two).frags, 3); - - assert!(!three.is_complete([0, 1])); - assert!(three.is_complete([0, 1, 2])); - assert!(three.is_complete([2, 1, 0]), "order does not matter"); - assert!( - three.is_complete([0, 1, 2, 7]), - "indices at or above frags are out of range" - ); - } - - #[test] - fn a_second_device_reconstructs_membership_from_the_list() { - let me = Keys::generate(); - let owner = Keys::generate().public_key(); - let mine = CommunityList { - entries: vec![ - entry( - id(0x11), - material(id(0x11), owner, "Room", 1), - material(id(0x11), owner, "Room", 4), - AT, - ), - entry( - id(0x22), - material(id(0x22), owner, "Other", 0), - material(id(0x22), owner, "Other", 0), - AT + 1, - ), - ], - tombstones: vec![Tombstone { - community_id: id(0x33), - removed_at: AT, - extra: Extra::default(), - }], - ..Default::default() - }; - - let event = smol::block_on(build_list_event(&me, &mine, 1, AT_SECS)).expect("builds"); - assert_eq!(event.kind, Kind::Custom(KIND_COMMUNITY_LIST)); - assert_eq!(fragment_index(&event).expect("a fragment index"), 1); - assert_eq!( - smol::block_on(parse_list_event(&me, &event)).expect("parses"), - mine - ); - - // Only the member's own keys open it, and an unreadable list is "no news". - let stranger = Keys::generate(); - assert!(smol::block_on(parse_list_event(&stranger, &event)).is_err()); - - // A fragment with no `d` tag is not a fragment at all. - let untagged = EventBuilder::new(Kind::Custom(KIND_COMMUNITY_LIST), event.content.clone()) - .finalize(&me) - .expect("signs"); - assert!(matches!( - smol::block_on(parse_list_event(&me, &untagged)), - Err(ListError::Fragment(_)) - )); - - // Unknown fields survive the round trip, so a republish cannot wipe them - // — including on a channel, where a dropped field destroys key material. - let mut held = mine.clone(); - held.extra - .insert("future".to_owned(), serde_json::json!({"deep": [1, 2]})); - held.entries[0] - .current - .extra - .insert("held_roots".to_owned(), serde_json::json!([{"epoch": 1}])); - held.entries[0].current.channels = vec![ChannelGrant { - id: ChannelId::from_bytes([0x9c; 32]), - key: Some("55".repeat(32)), - epoch: Epoch(2), - name: "staff".to_owned(), - extra: Extra::default(), - }]; - held.entries[0].current.channels[0] - .extra - .insert("read_key".to_owned(), serde_json::json!("aa".repeat(32))); - let rebuilt = smol::block_on(parse_list_event( - &me, - &smol::block_on(build_list_event(&me, &held, 0, AT_SECS)).expect("builds"), - )) - .expect("parses"); - assert_eq!(rebuilt, held); - - // The write gate refuses an over-cap or oversized List before publishing. - let crowded = list( - (0..=MAX_MEMBERSHIPS) - .map(|index| { - let community_id = CommunityId::from_bytes([index as u8; 32]); - entry( - community_id, - material(community_id, owner, "Room", 0), - material(community_id, owner, "Room", 0), - AT, - ) - }) - .collect(), - ); - assert!(matches!( - smol::block_on(build_list_event(&me, &crowded, 0, AT_SECS)), - Err(ListError::TooManyMemberships(n)) if n == MAX_MEMBERSHIPS + 1 - )); - - let oversized = list(vec![entry( - id(0x11), - material(id(0x11), owner, "Room", 0), - material(id(0x11), owner, &"x".repeat(NIP44_MAX_PLAINTEXT), 0), - AT, - )]); - assert!(matches!(oversized.fits(), Err(ListError::Oversize(_)))); - } - - /// The worked example in `examples.md` §6.2, verbatim. Five of its base64url - /// values leave non-zero trailing bits, so a strict decoder rejects them. - const EXAMPLE: &str = r#"{ - "frags": 2, - "entries": [ - { - "community_id": "PxpVK3nQ7sB1yTfWm4dLxZ0aRcE9uHgKjNvOpQrStUv", - "current": { - "owner": "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9", - "owner_salt": "qhEwR9tYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI0oP", - "community_root": "d70Xa1QwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U", - "root_epoch": 3, - "control_pk": "DU8vB4nM6qW1eR3tY5uI7oP9aS0dF2gH4jK6lZ8xC0v", - "channels": [ - { "id": "Ch1dQwErTyUiOpAsDfGhJkLzXcVbNm2Qw4Er6Ty8U0i", - "key": "K3yAsDfGhJkLzXcVbNm1Qw2Er3Ty4Ui5Op6As7Df8Gh", - "epoch": 2, "name": "staff" } - ], - "relays": ["wss://relay.example.com"], - "name": "Example Community" - }, - "added_at": 1719800000000 - } - ], - "tombstones": [ - { "community_id": "u9RfLmWx3PqZtYvBnKjHgFdSaQwErTyUiOp2C4E6G8I", "removed_at": 1722400000000 } - ] - }"#; - - #[test] - fn the_spec_example_parses_and_the_writer_canonicalizes_it() { - let parsed: CommunityList = serde_json::from_str(EXAMPLE).expect("the spec example parses"); - - assert_eq!(parsed.frags, 2); - assert_eq!(parsed.tombstones.len(), 1); - - let entry = parsed.entries.first().expect("one membership"); - assert_eq!(entry.current.root_epoch, Epoch(3)); - assert_eq!(entry.current.name, "Example Community"); - assert_eq!(entry.current.relays, ["wss://relay.example.com"]); - assert!( - entry.current.control_root.is_none(), - "a non-staff snapshot holds no control_root" - ); - assert_eq!( - entry.seed, entry.current, - "an absent seed reads as equal to current" - ); - - let channel = entry.current.channels.first().expect("a private channel"); - assert_eq!(channel.name, "staff"); - assert_eq!(channel.epoch, Epoch(2)); - assert!(channel.key.is_some()); - - let written = serde_json::to_string(&parsed).expect("writes"); - assert!(written.contains("\"frags\":2")); - assert!( - !written.contains("\"seed\""), - "a seed equal to current is omitted" - ); - assert!( - !written.contains("\"current\":{\"community_id\""), - "an embedded snapshot omits community_id and inherits the entry's" - ); - - // Every writer emits the canonical spelling, so a non-canonical input is - // stabilized here and two devices converge on identical bytes. - let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); - let owner = value["entries"][0]["current"]["owner"] - .as_str() - .expect("an owner"); - assert_eq!(owner, base64url::encode(&entry.current.owner.to_bytes())); - assert_ne!(owner, "nC7hQ2eRtYuIoPaSdFgHjKlZxCvBnM1qW3eR5tY7uI9"); - - // Reading its own output is a fixed point. - let again: CommunityList = serde_json::from_str(&written).expect("parses"); - assert_eq!(again, parsed); - assert_eq!(serde_json::to_string(&again).expect("writes"), written); - } - - #[test] - fn a_rename_rewrites_the_seed_cosmetics_and_collapses_the_snapshot() { - let owner = Keys::generate().public_key(); - let channel = ChannelId::from_bytes([0x9c; 32]); - - let mut current = material(id(0x11), owner, "New name", 5); - current.channels = vec![ChannelGrant { - id: channel, - key: Some("55".repeat(32)), - epoch: Epoch(4), - name: "new channel".to_owned(), - extra: Extra::default(), - }]; - - let mut seed = material(id(0x11), owner, "Old name", 1); - seed.relays = vec!["wss://stale.example".to_owned()]; - seed.channels = vec![ChannelGrant { - id: channel, - key: Some("55".repeat(32)), - epoch: Epoch(2), - name: "old channel".to_owned(), - extra: Extra::default(), - }]; - - let written = - serde_json::to_string(&list(vec![entry(id(0x11), seed, current.clone(), AT)])) - .expect("writes"); - let value: serde_json::Value = serde_json::from_str(&written).expect("valid"); - let written_seed = &value["entries"][0]["seed"]; - assert_eq!(written_seed["name"], "New name"); - assert_eq!(written_seed["relays"][0], "wss://relay.example"); - assert_eq!(written_seed["channels"][0]["name"], "new channel"); - assert_eq!( - written_seed["root_epoch"].as_u64(), - Some(1), - "the rewrite touches no key material" - ); - - // A seed that differs from current only in cosmetics is the same bytes - // after the rewrite, so it is omitted entirely. - let mut stale = material(id(0x11), owner, "Old name", 5); - stale.channels = current.channels.clone(); - let collapsed = serde_json::to_string(&list(vec![entry(id(0x11), stale, current, AT)])) - .expect("writes"); - assert!(!collapsed.contains("\"seed\"")); - } - - const AT: u64 = 1_719_800_000_000; - const AT_SECS: u64 = AT / 1000; -} diff --git a/crates/concord/src/cords/cord02/mod.rs b/crates/concord/src/cords/cord02/mod.rs deleted file mode 100644 index 65a43ba8..00000000 --- a/crates/concord/src/cords/cord02/mod.rs +++ /dev/null @@ -1,1242 +0,0 @@ -pub mod guestbook; -pub mod list; - -use std::collections::{BTreeMap, BTreeSet}; - -use anyhow::{Result, bail}; -use nostr_sdk::prelude::{ - AsyncGetPublicKey, AsyncSignEvent, Event, PublicKey, Timestamp, UnsignedEvent, -}; -use serde::{Deserialize, Serialize}; - -use crate::cord01::{KIND_WRAP, SealForm, build_seal, open_wrap_at, wrap_seal_with}; -use crate::cord04::roles::{ - AuthorityEdition, CommunityRoles, Grant, MAX_BANLIST, Permissions, Role, Roster, citation_ok, - fold_roster, -}; -use crate::cord04::{ - AuthorityCitation, EditionFields, EditionMeta, EntityHead, Floors, ParsedEdition, - build_edition, fold_head, parse_edition, vsk, -}; -use crate::derive::{ - banlist_locator, community_id_of, control_group_key, control_signer_group_key, grant_locator, - invite_links_locator, pins_locator, verify_community_id, -}; -use crate::{ChannelId, CommunityId, Epoch, Extra, GroupKey, random_32}; - -pub const MAX_NAME_BYTES: usize = 64; -pub const MAX_DESCRIPTION_BYTES: usize = 10_000; -pub const MAX_RELAYS: usize = 5; -pub const MAX_REGISTRY_LINKS: usize = 64; - -pub const GENERAL_CHANNEL: &str = "general"; -pub const ROOT_EPOCH: Epoch = Epoch(0); - -const GENESIS_VERSION: u64 = 1; - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct ImageRef { - pub url: String, - pub key: String, - pub nonce: String, - pub hash: String, - #[serde(flatten)] - pub extra: Extra, -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct CommunityMetadata { - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub description: Option, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relays: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub icon: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub banner: Option, - /// CORD-08's disappearing-messages timer, in seconds. - #[serde( - default, - deserialize_with = "timer_seconds", - skip_serializing_if = "Option::is_none" - )] - pub message_expiration: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom: Option, - #[serde(flatten)] - pub extra: Extra, -} - -/// CORD-08 §1: absent, `0` and malformed all mean off. -fn timer_seconds<'de, D>(deserializer: D) -> Result, D::Error> -where - D: serde::Deserializer<'de>, -{ - let value = Option::::deserialize(deserializer)?; - - Ok(value - .and_then(|value| value.as_u64()) - .filter(|seconds| *seconds > 0)) -} - -#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] -pub struct ChannelMetadata { - pub name: String, - pub private: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub voice: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub deleted: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub custom: Option, - #[serde(flatten)] - pub extra: Extra, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CommunityIdentity { - pub community_id: CommunityId, - pub owner: PublicKey, - pub owner_salt: [u8; 32], -} - -impl CommunityIdentity { - pub fn verify(&self) -> bool { - verify_community_id(&self.community_id, &self.owner.to_bytes(), &self.owner_salt) - } -} - -#[derive(Debug, Clone)] -pub struct CommunityGenesis { - pub identity: CommunityIdentity, - pub community_root: [u8; 32], - pub control_root: [u8; 32], - pub channel_id: ChannelId, - pub wraps: Vec, -} - -pub async fn genesis( - owner: &S, - metadata: &CommunityMetadata, - at_secs: u64, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let metadata_content = encode_metadata(metadata)?; - let owner_salt = random_32()?; - let owner_key = owner - .get_public_key_async() - .await - .map_err(|error| anyhow::anyhow!("signer: {error}"))?; - - let identity = CommunityIdentity { - community_id: community_id_of(&owner_key.to_bytes(), &owner_salt), - owner: owner_key, - owner_salt, - }; - - let community_root = random_32()?; - let control_root = random_32()?; - let channel_id = ChannelId::from_bytes(random_32()?); - - let read = control_group_key(&community_root, &identity.community_id, ROOT_EPOCH)?; - let signer = control_signer_group_key(&control_root, &identity.community_id, ROOT_EPOCH)?; - - let channel_content = serde_json::to_string(&ChannelMetadata { - name: GENERAL_CHANNEL.to_owned(), - private: false, - ..ChannelMetadata::default() - })?; - - let editions = [ - build_edition(EditionFields { - author: identity.owner, - subkind: vsk::COMMUNITY_METADATA, - entity: *identity.community_id.as_bytes(), - version: GENESIS_VERSION, - prev: None, - citation: None, - content: &metadata_content, - at_secs, - }), - build_edition(EditionFields { - author: identity.owner, - subkind: vsk::CHANNEL_METADATA, - entity: *channel_id.as_bytes(), - version: GENESIS_VERSION, - prev: None, - citation: None, - content: &channel_content, - at_secs, - }), - ]; - - let mut wraps = Vec::with_capacity(editions.len()); - - for edition in &editions { - wraps.push(seal_edition(edition, owner, &read, &signer, at_secs).await?); - } - - Ok(CommunityGenesis { - identity, - community_root, - control_root, - channel_id, - wraps, - }) -} - -/// Opens a Control Plane wrap from its reading key alone. -pub fn open_edition( - wrap: &Event, - read: &GroupKey, - address: &PublicKey, - verify_wrap_signature: bool, -) -> Result { - let opened = open_wrap_at(wrap, address, read.conversation(), verify_wrap_signature)?; - - if opened.seal_form != SealForm::Plaintext { - bail!("control editions require a plaintext seal"); - } - - Ok(parse_edition(&opened.rumor)?) -} - -pub struct ControlWriter { - pub author: PublicKey, - pub read: GroupKey, - pub signer: GroupKey, -} - -pub struct Edition<'a> { - pub subkind: &'a str, - pub entity: [u8; 32], - pub content: &'a str, - /// The head this edition supersedes; `None` starts the chain. - pub head: Option<&'a EntityHead>, - pub citation: Option, -} - -impl ControlWriter { - pub async fn publish( - &self, - keys: &S, - edition: Edition<'_>, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - let rumor = build_edition(EditionFields { - author: self.author, - subkind: edition.subkind, - entity: edition.entity, - version: edition - .head - .map_or(GENESIS_VERSION, |head| head.version + 1), - prev: edition.head.map(|head| head.self_hash), - citation: edition.citation, - content: edition.content, - at_secs, - }); - - let parsed = parse_edition(&rumor)?; - let wrap = seal_edition(&rumor, keys, &self.read, &self.signer, at_secs).await?; - - Ok((wrap, EntityHead::from(&parsed))) - } - - pub async fn set_community_metadata( - &self, - keys: &S, - community_id: &CommunityId, - metadata: &CommunityMetadata, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - let content = encode_metadata(metadata)?; - - self.publish( - keys, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - pub async fn set_channel_metadata( - &self, - keys: &S, - channel: &ChannelId, - metadata: &ChannelMetadata, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - if metadata.name.len() > MAX_NAME_BYTES { - bail!("channel name exceeds {MAX_NAME_BYTES} bytes"); - } - - let content = serde_json::to_string(metadata)?; - - self.publish( - keys, - Edition { - subkind: vsk::CHANNEL_METADATA, - entity: *channel.as_bytes(), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - pub async fn set_role( - &self, - keys: &S, - role: &Role, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - if role.name.len() > MAX_NAME_BYTES { - bail!("role name exceeds {MAX_NAME_BYTES} bytes"); - } - - let content = role.to_content()?; - - self.publish( - keys, - Edition { - subkind: vsk::ROLE, - entity: *role.role_id.as_bytes(), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - pub async fn set_grant( - &self, - keys: &S, - community_id: &CommunityId, - grant: &Grant, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - let content = grant.to_content()?; - - self.publish( - keys, - Edition { - subkind: vsk::GRANT, - entity: grant_locator(community_id, &grant.member.to_bytes()), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - pub async fn set_banlist( - &self, - keys: &S, - community_id: &CommunityId, - banned: &BTreeSet, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - if banned.len() > MAX_BANLIST { - bail!("banlist exceeds {MAX_BANLIST} entries"); - } - - let entries: Vec = banned.iter().map(PublicKey::to_hex).collect(); - let content = serde_json::to_string(&entries)?; - - self.publish( - keys, - Edition { - subkind: vsk::BANLIST, - entity: banlist_locator(community_id), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - #[allow(clippy::too_many_arguments)] - pub async fn set_registry( - &self, - keys: &S, - community_id: &CommunityId, - creator: &PublicKey, - links: &[PublicKey], - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - let entries: Vec = links - .iter() - .take(MAX_REGISTRY_LINKS) - .map(PublicKey::to_hex) - .collect(); - let content = serde_json::to_string(&entries)?; - - self.publish( - keys, - Edition { - subkind: vsk::INVITE_LINKS, - entity: invite_links_locator(community_id, &creator.to_bytes()), - content: &content, - head, - citation, - }, - at_secs, - ) - .await - } - - /// The whole Pin List, in whichever of CORD-04 §7's two forms the Channel calls for. - #[allow(clippy::too_many_arguments)] - pub async fn set_pin_list( - &self, - keys: &S, - community_id: &CommunityId, - channel: &ChannelId, - content: &str, - head: Option<&EntityHead>, - citation: Option, - at_secs: u64, - ) -> Result<(Event, EntityHead)> - where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, - { - self.publish( - keys, - Edition { - subkind: vsk::PINS, - entity: pins_locator(community_id, channel), - content, - head, - citation, - }, - at_secs, - ) - .await - } -} - -fn encode_metadata(metadata: &CommunityMetadata) -> Result { - if metadata.name.len() > MAX_NAME_BYTES { - bail!("community name exceeds {MAX_NAME_BYTES} bytes"); - } - - if metadata - .description - .as_ref() - .is_some_and(|description| description.len() > MAX_DESCRIPTION_BYTES) - { - bail!("community description exceeds {MAX_DESCRIPTION_BYTES} bytes"); - } - - let mut metadata = metadata.clone(); - metadata.relays.truncate(MAX_RELAYS); - metadata.message_expiration = metadata.message_expiration.filter(|seconds| *seconds > 0); - - Ok(serde_json::to_string(&metadata)?) -} - -#[derive(Debug, Clone, Default)] -pub struct ControlFold { - pub roles: CommunityRoles, - pub banned: BTreeSet, - pub community: Option, - pub channels: BTreeMap, - /// Each creator's live link-signer set. - pub registries: BTreeMap>, - /// Head content per `pins_locator`; the coordinate is one-way, so a fold cannot name its Channel. - pub pins: BTreeMap<[u8; 32], String>, - pub floors: Floors, - pub gapped: bool, -} - -impl ControlFold { - pub fn is_public(&self) -> bool { - self.registries.values().any(|links| !links.is_empty()) - } - - pub fn pin_content(&self, community_id: &CommunityId, channel: &ChannelId) -> Option<&str> { - self.pins - .get(&pins_locator(community_id, channel)) - .map(String::as_str) - } -} - -pub fn fold_control( - owner: &PublicKey, - community_id: &CommunityId, - editions: &[ParsedEdition], - floors: &Floors, - held_bans: &BTreeSet, -) -> ControlFold { - let authority: Vec = editions - .iter() - .filter_map(|edition| AuthorityEdition::parse(edition, community_id)) - .collect(); - - let roster = fold_roster(owner, community_id, &authority, floors, held_bans); - let metadata = fold_metadata(owner, community_id, editions, &roster, floors); - - let mut floors = roster.floors; - floors.extend(metadata.floors); - - ControlFold { - roles: roster.roles, - banned: roster.banned, - community: metadata.community, - channels: metadata.channels, - registries: metadata.registries, - pins: metadata.pins, - floors, - gapped: roster.gapped || metadata.gapped, - } -} - -#[derive(Debug, Default)] -struct MetadataFold { - community: Option, - channels: BTreeMap, - registries: BTreeMap>, - pins: BTreeMap<[u8; 32], String>, - floors: Floors, - gapped: bool, -} - -fn fold_metadata( - owner: &PublicKey, - community_id: &CommunityId, - editions: &[ParsedEdition], - roster: &Roster, - floors: &Floors, -) -> MetadataFold { - let judge = Judge { - owner, - community_id, - roster, - floors, - }; - let community_entity = *community_id.as_bytes(); - let mut community: Vec<&ParsedEdition> = Vec::new(); - let mut channels: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new(); - - for edition in editions { - match edition.subkind.as_str() { - // A channel at the community's own coordinate would corrupt the metadata chain's floor. - vsk::COMMUNITY_METADATA if edition.entity == community_entity => { - community.push(edition) - } - vsk::CHANNEL_METADATA if edition.entity != community_entity => { - channels.entry(edition.entity).or_default().push(edition); - } - _ => {} - } - } - - let mut fold = MetadataFold::default(); - - if let Some(head) = authorized_head( - &judge, - community_entity, - &community, - Permissions::MANAGE_METADATA, - &mut fold.gapped, - ) { - fold.community = serde_json::from_str::(&head.content) - .ok() - .map(|mut metadata| { - // Up to 5 relays is a recommendation, so a longer set is truncated, not refused. - metadata.relays.truncate(MAX_RELAYS); - metadata - }); - fold.floors.insert(head.entity, EntityHead::from(head)); - } - - for (entity, candidates) in &channels { - let Some(head) = authorized_head( - &judge, - *entity, - candidates, - Permissions::MANAGE_CHANNELS, - &mut fold.gapped, - ) else { - continue; - }; - - fold.floors.insert(*entity, EntityHead::from(head)); - - if let Ok(metadata) = serde_json::from_str::(&head.content) { - fold.channels - .insert(ChannelId::from_bytes(*entity), metadata); - } - } - - fold.registries = fold_registries(&judge, editions, &mut fold.floors, &mut fold.gapped); - fold.pins = fold_pins(&judge, editions, &mut fold.floors, &mut fold.gapped); - - fold -} - -/// A one-way coordinate leaves the `eid` unchecked; violating content reads as empty. -fn fold_pins( - judge: &Judge<'_>, - editions: &[ParsedEdition], - floors: &mut Floors, - gapped: &mut bool, -) -> BTreeMap<[u8; 32], String> { - let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new(); - - for edition in editions { - if edition.subkind == vsk::PINS { - candidates.entry(edition.entity).or_default().push(edition); - } - } - - let mut pins = BTreeMap::new(); - - for (entity, group) in &candidates { - let Some(head) = authorized_head(judge, *entity, group, Permissions::PIN_MESSAGES, gapped) - else { - continue; - }; - - floors.insert(*entity, EntityHead::from(head)); - pins.insert(*entity, head.content.clone()); - } - - pins -} - -fn fold_registries( - judge: &Judge<'_>, - editions: &[ParsedEdition], - floors: &mut Floors, - gapped: &mut bool, -) -> BTreeMap> { - let mut candidates: BTreeMap<[u8; 32], Vec<&ParsedEdition>> = BTreeMap::new(); - - for edition in editions { - if edition.subkind == vsk::INVITE_LINKS - && invite_links_locator(judge.community_id, &edition.author.to_bytes()) - == edition.entity - { - candidates.entry(edition.entity).or_default().push(edition); - } - } - - let mut registries = BTreeMap::new(); - - for (entity, group) in &candidates { - let Some(head) = authorized_head(judge, *entity, group, Permissions::CREATE_INVITE, gapped) - else { - continue; - }; - - floors.insert(*entity, EntityHead::from(head)); - - let Ok(links) = serde_json::from_str::>(&head.content) else { - continue; - }; - - registries.insert( - head.author, - links - .iter() - .filter_map(|link| PublicKey::from_hex(link).ok()) - .take(MAX_REGISTRY_LINKS) - .collect(), - ); - } - - registries -} - -struct Judge<'a> { - owner: &'a PublicKey, - community_id: &'a CommunityId, - roster: &'a Roster, - floors: &'a Floors, -} - -fn authorized_head<'a>( - judge: &Judge<'_>, - entity: [u8; 32], - candidates: &[&'a ParsedEdition], - permission: u64, - gapped: &mut bool, -) -> Option<&'a ParsedEdition> { - let authorized: Vec<&ParsedEdition> = candidates - .iter() - .copied() - .filter(|edition| { - // A banned npub's edits are dropped even while a grant naming them still carries the bit. - !judge.roster.banned.contains(&edition.author) - && judge - .roster - .roles - .is_authorized(&edition.author, judge.owner, permission) - && citation_ok( - judge.owner, - judge.community_id, - &edition.author, - edition.citation.as_ref(), - &judge.roster.floors, - ) - }) - .collect(); - - if authorized.is_empty() { - return None; - } - - let metas: Vec = authorized - .iter() - .map(|edition| EditionMeta::from(*edition)) - .collect(); - - let selection = fold_head(&metas, judge.floors.get(&entity)); - *gapped |= selection.gap; - - selection.head.map(|index| authorized[index]) -} - -async fn seal_edition( - edition: &UnsignedEvent, - owner: &S, - read: &GroupKey, - signer: &GroupKey, - at_secs: u64, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let seal = build_seal(edition, SealForm::Plaintext, read, owner).await?; - - let (wrap, _) = wrap_seal_with( - &seal, - read.conversation(), - signer.keys(), - KIND_WRAP, - Timestamp::from_secs(at_secs), - &[], - )?; - - Ok(wrap) -} - -#[cfg(test)] -mod tests { - use nostr_sdk::prelude::Keys; - - use super::*; - use crate::cord03::{self, build_message, seal_rumor}; - use crate::cord04::pins; - use crate::cord04::roles::{Grant, MAX_BANLIST, MAX_ROLES_PER_MEMBER, Role, RoleScope}; - use crate::derive::{channel_group_key, grant_locator}; - use crate::state::CommunityState; - use crate::{Extra, RoleId}; - - const AT: u64 = 1_700_000_000; - - fn holder(minted: &CommunityGenesis) -> (GroupKey, GroupKey) { - let community_id = minted.identity.community_id; - - ( - control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"), - control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH) - .expect("derives"), - ) - } - - fn open_all(wraps: &[Event], read: &GroupKey, address: &PublicKey) -> Vec { - wraps - .iter() - .map(|wrap| open_edition(wrap, read, address, true).expect("opens")) - .collect() - } - - fn metadata(name: &str) -> CommunityMetadata { - CommunityMetadata { - name: name.to_owned(), - ..CommunityMetadata::default() - } - } - - #[test] - fn metadata_and_channel_edits_reach_a_second_client() { - let owner = Keys::generate(); - let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); - let community_id = minted.identity.community_id; - let owner_pk = owner.public_key(); - let (read, signer) = holder(&minted); - - let genesis_editions = open_all(&minted.wraps, &read, &signer.pk()); - let roster = fold_control( - &owner_pk, - &community_id, - &genesis_editions, - &Floors::new(), - &BTreeSet::new(), - ); - assert_eq!( - roster.community.as_ref().map(|meta| meta.name.as_str()), - Some("coop") - ); - - let writer = ControlWriter { - author: owner_pk, - read: read.clone(), - signer: signer.clone(), - }; - let community_head = roster.floors.get(community_id.as_bytes()).expect("head"); - let channel_head = roster - .floors - .get(minted.channel_id.as_bytes()) - .expect("head"); - - let (community_wrap, _) = smol::block_on(writer.set_community_metadata( - &owner, - &community_id, - &CommunityMetadata { - relays: vec!["wss://relay.example".to_owned()], - ..metadata("coop two") - }, - Some(community_head), - None, - AT + 1, - )) - .expect("publishes"); - let (channel_wrap, _) = smol::block_on(writer.set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "lobby".to_owned(), - private: false, - ..ChannelMetadata::default() - }, - Some(channel_head), - None, - AT + 2, - )) - .expect("publishes"); - - let mut edited = genesis_editions.clone(); - edited.extend(open_all( - &[community_wrap, channel_wrap], - &read, - &signer.pk(), - )); - - let folded = fold_control( - &owner_pk, - &community_id, - &edited, - &Floors::new(), - &BTreeSet::new(), - ); - assert_eq!( - folded.community.as_ref().map(|meta| meta.name.as_str()), - Some("coop two") - ); - assert_eq!( - folded - .channels - .get(&minted.channel_id) - .map(|channel| channel.name.as_str()), - Some("lobby") - ); - - // A relay serving only the editions a client already folded past must not walk - // the community backwards. - let stale = fold_control( - &owner_pk, - &community_id, - &genesis_editions, - &folded.floors, - &BTreeSet::new(), - ); - assert!(stale.community.is_none()); - assert!(stale.channels.is_empty()); - - let mut state = - CommunityState::from_genesis(&minted, &genesis_editions, AT * 1_000).expect("projects"); - state.apply_fold(&folded); - assert_eq!(state.channels.len(), 1); - assert_eq!(state.channels[0].name, "lobby"); - assert_eq!(state.relays.len(), 1); - } - - #[test] - fn a_delegated_member_edits_metadata_only_under_its_own_grant() { - let owner = Keys::generate(); - let member = Keys::generate(); - let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); - let community_id = minted.identity.community_id; - let owner_pk = owner.public_key(); - let (read, signer) = holder(&minted); - - let writer = ControlWriter { - author: owner_pk, - read: read.clone(), - signer: signer.clone(), - }; - let role_id = RoleId::from_bytes([0x07; 32]); - let role = Role { - role_id, - name: "Mod".to_owned(), - position: 1, - permissions: Permissions(Permissions::MANAGE_METADATA), - scope: RoleScope::Server, - color: 0, - extra: Extra::default(), - }; - - let (role_wrap, _) = smol::block_on(writer.publish( - &owner, - Edition { - subkind: vsk::ROLE, - entity: *role_id.as_bytes(), - content: &role.to_content().expect("serializes"), - head: None, - citation: None, - }, - AT + 1, - )) - .expect("publishes"); - let (grant_wrap, _) = smol::block_on( - writer.publish( - &owner, - Edition { - subkind: vsk::GRANT, - entity: grant_locator(&community_id, &member.public_key().to_bytes()), - content: &Grant { - member: member.public_key(), - role_ids: vec![role_id], - control_wrap: None, - extra: Extra::default(), - } - .to_content() - .expect("serializes"), - head: None, - citation: None, - }, - AT + 2, - ), - ) - .expect("publishes"); - - let mut base = open_all(&minted.wraps, &read, &signer.pk()); - base.extend(open_all(&[role_wrap, grant_wrap], &read, &signer.pk())); - - let roster = fold_control( - &owner_pk, - &community_id, - &base, - &Floors::new(), - &BTreeSet::new(), - ); - assert!(roster.roles.is_staff(&member.public_key(), &owner_pk)); - - let grant = roster - .floors - .get(&grant_locator( - &community_id, - &member.public_key().to_bytes(), - )) - .expect("the member's grant folded"); - let head = roster.floors.get(community_id.as_bytes()).expect("head"); - - // The member seals with their own keys and wraps with the staff write key. - let member_writer = ControlWriter { - author: member.public_key(), - read, - signer: signer.clone(), - }; - let content = serde_json::to_string(&metadata("coop by mod")).expect("serializes"); - - let (uncited, _) = smol::block_on(member_writer.publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: None, - }, - AT + 3, - )) - .expect("publishes"); - let (cited, _) = smol::block_on(member_writer.publish( - &member, - Edition { - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - content: &content, - head: Some(head), - citation: Some(AuthorityCitation { - entity: grant.entity, - version: grant.version, - hash: grant.self_hash, - }), - }, - AT + 4, - )) - .expect("publishes"); - - // Uncited, the edit claims an authority the member never showed. - let mut forged = base.clone(); - forged.extend(open_all(&[uncited], &member_writer.read, &signer.pk())); - let folded = fold_control( - &owner_pk, - &community_id, - &forged, - &Floors::new(), - &BTreeSet::new(), - ); - assert_eq!( - folded.community.as_ref().map(|meta| meta.name.as_str()), - Some("coop") - ); - - let mut edited_editions = base; - edited_editions.extend(open_all(&[cited], &member_writer.read, &signer.pk())); - let folded = fold_control( - &owner_pk, - &community_id, - &edited_editions, - &Floors::new(), - &BTreeSet::new(), - ); - assert_eq!( - folded.community.as_ref().map(|meta| meta.name.as_str()), - Some("coop by mod") - ); - } - - #[test] - fn a_pin_list_folds_under_its_coordinate_for_a_second_client() { - let owner = Keys::generate(); - let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); - let community_id = minted.identity.community_id; - let owner_pk = owner.public_key(); - let (read, signer) = holder(&minted); - - let channel = minted.channel_id; - let group = - channel_group_key(&minted.community_root, &channel, ROOT_EPOCH).expect("derives"); - let author = Keys::generate(); - - let rumor = build_message( - author.public_key(), - &channel, - ROOT_EPOCH, - "pin me", - None, - AT * 1_000, - None, - ); - let (wrap, _) = smol::block_on(seal_rumor(&rumor, &group, &author, false)).expect("seals"); - let opened = cord03::open(&wrap, &group, &channel, ROOT_EPOCH) - .expect("opens") - .0; - let entry = pins::build_entry(&opened, &group, &channel).expect("pins"); - - let content = pins::publishable( - &pins::ReadPinList { - entries: vec![entry], - sealed: false, - }, - false, - &group, - ROOT_EPOCH, - ) - .expect("publishes"); - - let writer = ControlWriter { - author: owner_pk, - read: read.clone(), - signer: signer.clone(), - }; - let (pin_wrap, _) = smol::block_on(writer.set_pin_list( - &owner, - &community_id, - &channel, - &content, - None, - None, - AT + 1, - )) - .expect("publishes"); - - let mut editions = open_all(&minted.wraps, &read, &signer.pk()); - editions.extend(open_all(&[pin_wrap], &read, &signer.pk())); - - let folded = fold_control( - &owner_pk, - &community_id, - &editions, - &Floors::new(), - &BTreeSet::new(), - ); - - // The coordinate derives one-way, so the list is found by naming the Channel. - let found = pins::read_list( - folded - .pin_content(&community_id, &channel) - .expect("the list folds"), - |_| None, - ); - assert_eq!(found.entries.len(), 1); - assert_eq!( - pins::verify_entry(&found.entries[0], &channel) - .expect("verifies") - .content, - "pin me" - ); - - let other = ChannelId::from_bytes([0x77; 32]); - assert!(folded.pin_content(&community_id, &other).is_none()); - } - - #[test] - fn the_timer_is_never_guessed_and_the_write_caps_hold() { - let owner = Keys::generate(); - let minted = smol::block_on(genesis(&owner, &metadata("coop"), AT)).expect("mints"); - let community_id = minted.identity.community_id; - let owner_pk = owner.public_key(); - let (read, signer) = holder(&minted); - - let writer = ControlWriter { - author: owner_pk, - read, - signer, - }; - let fold = |metadata: &CommunityMetadata| { - fold_control( - &owner_pk, - &community_id, - &open_all( - &[smol::block_on(writer.set_community_metadata( - &owner, - &community_id, - metadata, - None, - None, - AT + 1, - )) - .expect("publishes") - .0], - &writer.read, - &writer.signer.pk(), - ), - &Floors::new(), - &BTreeSet::new(), - ) - .community - .expect("folds") - }; - - let mut timed = metadata("coop"); - timed.message_expiration = Some(2_592_000); - assert_eq!(fold(&timed).message_expiration, Some(2_592_000)); - - // Absent, zero and garbage all mean off, and garbage never poisons the rest. - assert_eq!(fold(&metadata("coop")).message_expiration, None); - - let mut off = metadata("coop"); - off.message_expiration = Some(0); - assert_eq!(fold(&off).message_expiration, None); - - let garbage = serde_json::json!({ - "name": "coop", - "message_expiration": "later", - }) - .to_string(); - let folded: CommunityMetadata = serde_json::from_str(&garbage).expect("parses"); - assert_eq!(folded.name, "coop"); - assert_eq!(folded.message_expiration, None); - - // The caps the folds apply also hold on the way out. - let banned: BTreeSet = (0..=MAX_BANLIST) - .map(|_| Keys::generate().public_key()) - .collect(); - assert!( - smol::block_on(writer.set_banlist(&owner, &community_id, &banned, None, None, AT + 2)) - .is_err() - ); - - let grant = Grant { - member: owner_pk, - role_ids: (0..=MAX_ROLES_PER_MEMBER) - .map(|index| RoleId::from_bytes([index as u8; 32])) - .collect(), - control_wrap: None, - extra: Extra::default(), - }; - assert!(grant.to_content().is_err()); - - assert!( - smol::block_on(writer.set_channel_metadata( - &owner, - &minted.channel_id, - &ChannelMetadata { - name: "x".repeat(MAX_NAME_BYTES + 1), - private: false, - ..ChannelMetadata::default() - }, - None, - None, - AT + 3, - )) - .is_err() - ); - } -} diff --git a/crates/concord/src/cords/cord03.rs b/crates/concord/src/cords/cord03.rs deleted file mode 100644 index aa84e7ac..00000000 --- a/crates/concord/src/cords/cord03.rs +++ /dev/null @@ -1,1108 +0,0 @@ -use std::cmp::Reverse; -use std::collections::BTreeMap; - -use anyhow::Result; -use nostr_sdk::prelude::*; - -use crate::cord01::{ - KIND_WRAP, KIND_WRAP_EPHEMERAL, OpenedStream, SealForm, TAG_CHANNEL, TAG_EPOCH, build_rumor_ms, - build_seal, channel_binding_tags, check_channel_binding, open_wrap, resolve_ms_strict, - unique_tag, wrap_seal, -}; -use crate::cord04::{AuthorityCitation, canonical_decimal, citation_tag}; -pub use crate::cords::rumor::RumorError as ChatError; -use crate::cords::rumor::{optional_citation, pubkey, tag, value}; -use crate::derive::channel_group_key; -use crate::{ChannelId, Epoch, GroupKey, decode_hex_32}; - -pub const KIND_MESSAGE: u16 = 9; -pub const KIND_COMMENT: u16 = 1111; -pub const KIND_REACTION: u16 = 7; -pub const KIND_DELETE: u16 = 5; -pub const KIND_EDIT: u16 = 3302; -pub const KIND_FILE: u16 = 15; -pub const KIND_TIMER_NOTICE: u16 = 1740; -pub const KIND_WEBXDC: u16 = 3310; -pub const KIND_TYPING: u16 = 23311; - -pub const ROW_KINDS: [u16; 4] = [KIND_MESSAGE, KIND_FILE, KIND_COMMENT, KIND_TIMER_NOTICE]; -pub const SIDE_KINDS: [u16; 3] = [KIND_DELETE, KIND_REACTION, KIND_EDIT]; - -const TAG_QUOTE: &str = "q"; -const TAG_TARGET: &str = "e"; -const TAG_TARGET_KIND: &str = "k"; -const TAG_ROOT: &str = "E"; -const TAG_ROOT_KIND: &str = "K"; -const TAG_ROOT_AUTHOR: &str = "P"; -const TAG_TARGET_AUTHOR: &str = "p"; -const TAG_EXPIRATION: &str = "expiration"; -const TAG_TIMER: &str = "timer"; - -/// A chat event another chat event refers to: a quote, a comment's parent, a reaction's target. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ReplyRef { - pub id: EventId, - pub author: Option, -} - -/// A reference that also names the referenced event's kind, which `K`/`k` must commit on the wire. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Target { - pub reply: ReplyRef, - pub kind: u16, -} - -#[derive(Debug, Clone)] -pub enum ChatAction { - Message { - reply_to: Option, - thread_root: Option, - }, - Reaction { - target: EventId, - emoji: String, - }, - Edit { - target: EventId, - content: String, - }, - Delete { - target: EventId, - target_kind: Option, - citation: Option, - }, - Typing, - Opaque, - TimerNotice { - seconds: u64, - }, -} - -#[derive(Debug, Clone)] -pub struct ChatRumor { - pub id: EventId, - pub author: PublicKey, - pub kind: Kind, - pub channel: ChannelId, - pub epoch: Epoch, - pub at_ms: u64, - pub content: String, - pub expiration: Option, - pub action: ChatAction, -} - -/// A channel's timeline row, with every edit, delete and reaction folded in. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChatMessage { - pub id: EventId, - pub author: PublicKey, - pub channel: ChannelId, - pub epoch: Epoch, - pub kind: Kind, - pub content: String, - pub reply_to: Option, - pub thread_root: Option, - pub at_ms: u64, - pub expiration: Option, - pub edited_at: Option, - pub deleted: bool, - pub reactions: BTreeMap, -} - -pub fn build_message( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - content: &str, - quote: Option<&ReplyRef>, - at_ms: u64, - timer: Option, -) -> UnsignedEvent { - let mut tags = channel_binding_tags(channel, epoch); - - if let Some(quote) = quote { - tags.push(reply_tag(TAG_QUOTE, quote)); - } - - tags.extend(expiration_tag(at_ms, timer)); - - build_rumor_ms(KIND_MESSAGE, author, content, tags, at_ms) -} - -#[allow(clippy::too_many_arguments)] -pub fn build_comment( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - content: &str, - parent: &Target, - root: Option<&Target>, - at_ms: u64, - timer: Option, -) -> UnsignedEvent { - let root = root.unwrap_or(parent); - let mut tags = channel_binding_tags(channel, epoch); - - tags.push(Tag::custom(TAG_ROOT_KIND, [root.kind.to_string()])); - tags.push(reply_tag(TAG_ROOT, &root.reply)); - - if let Some(root_author) = root.reply.author { - tags.push(Tag::custom(TAG_ROOT_AUTHOR, [root_author.to_hex()])); - } - - tags.push(Tag::custom(TAG_TARGET_KIND, [parent.kind.to_string()])); - tags.push(reply_tag(TAG_TARGET, &parent.reply)); - - if let Some(parent_author) = parent.reply.author { - tags.push(Tag::custom(TAG_TARGET_AUTHOR, [parent_author.to_hex()])); - } - - tags.extend(expiration_tag(at_ms, timer)); - - build_rumor_ms(KIND_COMMENT, author, content, tags, at_ms) -} - -pub fn build_reaction( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - target: &Target, - emoji: &str, - at_ms: u64, - timer: Option, -) -> UnsignedEvent { - let mut tags = channel_binding_tags(channel, epoch); - - tags.push(Tag::custom(TAG_TARGET, [target.reply.id.to_hex()])); - if let Some(target_author) = target.reply.author { - tags.push(Tag::custom(TAG_TARGET_AUTHOR, [target_author.to_hex()])); - } - tags.push(Tag::custom(TAG_TARGET_KIND, [target.kind.to_string()])); - - tags.extend(expiration_tag(at_ms, timer)); - - build_rumor_ms(KIND_REACTION, author, emoji, tags, at_ms) -} - -pub fn build_edit( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - target: EventId, - content: &str, - at_ms: u64, - timer: Option, -) -> UnsignedEvent { - let mut tags = channel_binding_tags(channel, epoch); - tags.push(Tag::custom(TAG_TARGET, [target.to_hex()])); - - tags.extend(expiration_tag(at_ms, timer)); - - build_rumor_ms(KIND_EDIT, author, content, tags, at_ms) -} - -/// CORD-08 §4: informational, gated by the roster rather than by the fold. -pub fn build_timer_notice( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - seconds: u64, - at_ms: u64, -) -> UnsignedEvent { - let mut tags = channel_binding_tags(channel, epoch); - tags.push(Tag::custom(TAG_TIMER, [seconds.to_string()])); - - build_rumor_ms(KIND_TIMER_NOTICE, author, "", tags, at_ms) -} - -/// Derived from the signed `created_at`, so a later metadata edit never reaches back. -fn expiration_tag(at_ms: u64, timer: Option) -> Option { - timer.map(|timer| Tag::custom(TAG_EXPIRATION, [(at_ms / 1000 + timer).to_string()])) -} - -pub fn build_delete( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - target: EventId, - target_kind: Option, - citation: Option<&AuthorityCitation>, - at_ms: u64, -) -> UnsignedEvent { - let mut tags = channel_binding_tags(channel, epoch); - tags.push(Tag::custom(TAG_TARGET, [target.to_hex()])); - - if let Some(target_kind) = target_kind { - tags.push(Tag::custom(TAG_TARGET_KIND, [target_kind.to_string()])); - } - - if let Some(citation) = citation { - tags.push(citation_tag(citation)); - } - - build_rumor_ms(KIND_DELETE, author, "", tags, at_ms) -} - -pub fn build_typing( - author: PublicKey, - channel: &ChannelId, - epoch: Epoch, - at_ms: u64, -) -> UnsignedEvent { - build_rumor_ms( - KIND_TYPING, - author, - "", - channel_binding_tags(channel, epoch), - at_ms, - ) -} - -/// `ephemeral` picks the 21059 wrap, which relays must not store. -pub async fn seal_rumor( - rumor: &UnsignedEvent, - group: &GroupKey, - author: &S, - ephemeral: bool, -) -> Result<(Event, Keys), ChatError> -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let kind = rumor.kind.as_u16(); - - if !is_chat_kind(kind) { - return Err(ChatError::UnknownKind(kind)); - } - - let seal = build_seal(rumor, SealForm::Encrypted, group, author).await?; - let wrap_kind = if ephemeral { - KIND_WRAP_EPHEMERAL - } else { - KIND_WRAP - }; - - // The wrap's copy is for relays; the inner one drives the local purge. - let expiration: Vec = rumor - .tags - .iter() - .filter(|tag| tag.as_slice().first().map(String::as_str) == Some(TAG_EXPIRATION)) - .cloned() - .collect(); - - Ok(wrap_seal( - &seal, - group, - wrap_kind, - rumor.created_at, - &expiration, - )?) -} - -/// The claimed channel and epoch must both be the ones that opened the wrap. -pub fn open( - wrap: &Event, - group: &GroupKey, - channel: &ChannelId, - epoch: Epoch, -) -> Result<(OpenedStream, ChatRumor), ChatError> { - let opened = open_wrap(wrap, group)?; - - if opened.seal_form != SealForm::Encrypted { - return Err(ChatError::NotEncryptedSealed); - } - - check_channel_binding(&opened.rumor, channel, epoch)?; - - let chat = typed(&opened.rumor, channel, epoch)?; - - Ok((opened, chat)) -} - -/// Rebuild a rumor from a locally-cached copy: the binding tags name its channel and epoch. -pub fn parse_rumor(rumor: &UnsignedEvent) -> Result { - let channel: ChannelId = unique_tag(rumor, TAG_CHANNEL)? - .ok_or(ChatError::MissingTag(TAG_CHANNEL))? - .parse() - .map_err(|_| ChatError::BadTag(TAG_CHANNEL))?; - - let epoch = unique_tag(rumor, TAG_EPOCH)? - .ok_or(ChatError::MissingTag(TAG_EPOCH)) - .and_then(|raw| canonical_decimal(&raw).ok_or(ChatError::BadTag(TAG_EPOCH)))?; - - typed(rumor, &channel, Epoch(epoch)) -} - -/// `secret` is the `community_root` for a public channel, its own key for a private one. -pub fn plane_keys( - held: &[(Epoch, [u8; 32])], - channel: &ChannelId, -) -> Result> { - held.iter() - .map(|(epoch, secret)| Ok((*epoch, channel_group_key(secret, channel, *epoch)?))) - .collect() -} - -pub fn fold( - rumors: &[ChatRumor], - now: Timestamp, - can_delete: impl Fn(&PublicKey, Option<&AuthorityCitation>, &PublicKey) -> bool, -) -> Vec { - let mut order: Vec = (0..rumors.len()).collect(); - order.sort_by_key(|&index| (rumors[index].at_ms, rumors[index].id)); - - let mut messages: Vec = Vec::new(); - let mut slot: BTreeMap = BTreeMap::new(); - - for index in order { - let rumor = &rumors[index]; - - if expired(rumor, now) { - continue; - } - - let (reply_to, thread_root) = match &rumor.action { - ChatAction::Message { - reply_to, - thread_root, - } => ( - reply_to.map(|reply| reply.id), - thread_root.map(|reply| reply.id), - ), - ChatAction::TimerNotice { .. } => (None, None), - _ => continue, - }; - - slot.insert(rumor.id, messages.len()); - messages.push(ChatMessage { - id: rumor.id, - author: rumor.author, - channel: rumor.channel, - epoch: rumor.epoch, - kind: rumor.kind, - content: rumor.content.clone(), - reply_to, - thread_root, - at_ms: rumor.at_ms, - expiration: rumor.expiration, - edited_at: None, - deleted: false, - reactions: BTreeMap::new(), - }); - } - - let mut mutations: Vec = (0..rumors.len()).collect(); - mutations.sort_by_key(|&index| (rumors[index].at_ms, Reverse(rumors[index].id))); - - for index in mutations { - let rumor = &rumors[index]; - - match &rumor.action { - ChatAction::Edit { target, content } => { - let Some(&slot) = slot.get(target) else { - continue; - }; - let message = &mut messages[slot]; - - if message.deleted || message.author != rumor.author { - continue; - } - - message.content = content.clone(); - message.edited_at = Some(rumor.at_ms); - } - ChatAction::Delete { - target, citation, .. - } => { - let Some(&slot) = slot.get(target) else { - continue; - }; - - let author = messages[slot].author; - - if author == rumor.author || can_delete(&rumor.author, citation.as_ref(), &author) { - messages[slot].deleted = true; - } - } - ChatAction::Reaction { target, emoji } => { - let Some(&slot) = slot.get(target) else { - continue; - }; - - messages[slot].reactions.insert(rumor.author, emoji.clone()); - } - ChatAction::Message { .. } - | ChatAction::Typing - | ChatAction::TimerNotice { .. } - | ChatAction::Opaque => {} - } - } - - messages.sort_by_key(|message| (Reverse(message.at_ms), message.id)); - - messages -} - -/// CORD-08 §3: an expired rumor is never displayed, whatever its ingest path. -pub fn expired(rumor: &ChatRumor, now: Timestamp) -> bool { - rumor.expiration.is_some_and(|expiration| expiration <= now) -} - -fn is_chat_kind(kind: u16) -> bool { - matches!( - kind, - KIND_MESSAGE - | KIND_COMMENT - | KIND_REACTION - | KIND_DELETE - | KIND_EDIT - | KIND_FILE - | KIND_TIMER_NOTICE - | KIND_WEBXDC - | KIND_TYPING - ) -} - -fn typed(rumor: &UnsignedEvent, channel: &ChannelId, epoch: Epoch) -> Result { - let expiration = expiration_of(rumor)?; - - if expiration.is_some() && matches!(rumor.kind.as_u16(), KIND_DELETE | KIND_TIMER_NOTICE) { - return Err(ChatError::ExemptExpiration); - } - - Ok(ChatRumor { - id: rumor.id.unwrap_or_else(|| rumor.compute_id()), - author: rumor.pubkey, - kind: rumor.kind, - channel: *channel, - epoch, - at_ms: resolve_ms_strict(rumor)?, - content: rumor.content.clone(), - expiration, - action: action_of(rumor)?, - }) -} - -fn action_of(rumor: &UnsignedEvent) -> Result { - let kind = rumor.kind.as_u16(); - - match kind { - KIND_MESSAGE | KIND_FILE => Ok(ChatAction::Message { - reply_to: optional_reply(rumor, TAG_QUOTE)?, - thread_root: None, - }), - KIND_COMMENT => Ok(ChatAction::Message { - reply_to: optional_reply(rumor, TAG_TARGET)?, - thread_root: optional_reply(rumor, TAG_ROOT)?, - }), - KIND_REACTION => Ok(ChatAction::Reaction { - target: required_id(rumor, TAG_TARGET)?, - emoji: rumor.content.clone(), - }), - KIND_EDIT => Ok(ChatAction::Edit { - target: required_id(rumor, TAG_TARGET)?, - content: rumor.content.clone(), - }), - KIND_DELETE => Ok(ChatAction::Delete { - target: required_id(rumor, TAG_TARGET)?, - target_kind: optional_kind(rumor, TAG_TARGET_KIND)?, - citation: optional_citation(rumor)?, - }), - KIND_TYPING => Ok(ChatAction::Typing), - KIND_TIMER_NOTICE => Ok(ChatAction::TimerNotice { - seconds: timer_of(rumor)?, - }), - KIND_WEBXDC => Ok(ChatAction::Opaque), - other => Err(ChatError::UnknownKind(other)), - } -} - -fn timer_of(rumor: &UnsignedEvent) -> Result { - let fields = tag(rumor, TAG_TIMER)?.ok_or(ChatError::MissingTag(TAG_TIMER))?; - - canonical_decimal(value(fields, TAG_TIMER)?).ok_or(ChatError::BadTag(TAG_TIMER)) -} - -fn optional_reply( - rumor: &UnsignedEvent, - name: &'static str, -) -> Result, ChatError> { - let Some(fields) = tag(rumor, name)? else { - return Ok(None); - }; - - // NIP-C7 `q` and NIP-22 `E`/`e` put a relay hint at index 2 and the referenced author at index 3. - let author = match fields.get(3).map(String::as_str) { - Some(hex) if !hex.is_empty() => Some(pubkey(hex, name)?), - _ => None, - }; - - Ok(Some(ReplyRef { - id: hex_id(fields, name)?, - author, - })) -} - -fn required_id(rumor: &UnsignedEvent, name: &'static str) -> Result { - let fields = tag(rumor, name)?.ok_or(ChatError::MissingTag(name))?; - hex_id(fields, name) -} - -fn optional_kind(rumor: &UnsignedEvent, name: &'static str) -> Result, ChatError> { - let Some(fields) = tag(rumor, name)? else { - return Ok(None); - }; - - let raw = value(fields, name)?; - let kind = canonical_decimal(raw).ok_or(ChatError::BadTag(name))?; - - u16::try_from(kind) - .map(Some) - .map_err(|_| ChatError::BadTag(name)) -} - -pub fn expiration_of(rumor: &UnsignedEvent) -> Result, ChatError> { - let Some(fields) = tag(rumor, TAG_EXPIRATION)? else { - return Ok(None); - }; - - let seconds = canonical_decimal(value(fields, TAG_EXPIRATION)?) - .ok_or(ChatError::BadTag(TAG_EXPIRATION))?; - - Ok(Some(Timestamp::from_secs(seconds))) -} - -fn reply_tag(name: &str, reply: &ReplyRef) -> Tag { - Tag::custom( - name, - [ - reply.id.to_hex(), - String::new(), - reply - .author - .map(|author| author.to_hex()) - .unwrap_or_default(), - ], - ) -} - -fn hex_id(fields: &[String], name: &'static str) -> Result { - let bytes = decode_hex_32(value(fields, name)?).map_err(|_| ChatError::BadTag(name))?; - - EventId::from_slice(&bytes).map_err(|_| ChatError::BadTag(name)) -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::cord01::StreamError; - - const SECRET: [u8; 32] = [0x2du8; 32]; - const AT: u64 = 1_700_000_000_417; - - /// Well past every timestamp these tests use. - fn now() -> Timestamp { - Timestamp::from_secs(2_000_000_000) - } - - fn channel() -> ChannelId { - ChannelId::from_bytes([0x9cu8; 32]) - } - - fn group() -> GroupKey { - channel_group_key(&SECRET, &channel(), Epoch(0)).expect("derives") - } - - fn sealed(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys) -> Event { - smol::block_on(seal_rumor(rumor, group, author, false)) - .expect("seals") - .0 - } - - fn read(rumor: &UnsignedEvent, group: &GroupKey, author: &Keys, epoch: Epoch) -> ChatRumor { - open(&sealed(rumor, group, author), group, &channel(), epoch) - .expect("opens") - .1 - } - - fn target(id: EventId, author: &Keys) -> Target { - Target { - reply: ReplyRef { - id, - author: Some(author.public_key()), - }, - kind: KIND_MESSAGE, - } - } - - #[test] - fn a_second_holder_folds_edits_reactions_and_a_self_delete() { - let alice = Keys::generate(); - let carol = Keys::generate(); - let group = group(); - - let message = build_message( - alice.public_key(), - &channel(), - Epoch(0), - "hello", - None, - AT, - None, - ); - let id = message.compute_id(); - - let rumors = vec![ - read(&message, &group, &alice, Epoch(0)), - read( - &build_reaction( - carol.public_key(), - &channel(), - Epoch(0), - &target(id, &alice), - "🔥", - AT + 1_000, - None, - ), - &group, - &carol, - Epoch(0), - ), - read( - &build_edit( - alice.public_key(), - &channel(), - Epoch(0), - id, - "hello (fixed)", - AT + 2_000, - None, - ), - &group, - &alice, - Epoch(0), - ), - read( - &build_delete( - alice.public_key(), - &channel(), - Epoch(0), - id, - Some(KIND_MESSAGE), - None, - AT + 3_000, - ), - &group, - &alice, - Epoch(0), - ), - ]; - - let folded = fold(&rumors, now(), |_, _, _| false); - - assert_eq!(folded.len(), 1); - assert_eq!(folded[0].id, id); - assert_eq!(folded[0].content, "hello (fixed)"); - assert_eq!(folded[0].edited_at, Some(AT + 2_000)); - assert_eq!( - folded[0].reactions.get(&carol.public_key()), - Some(&"🔥".to_owned()) - ); - assert!(folded[0].deleted); - } - - #[test] - fn an_edit_or_delete_from_another_author_is_ignored() { - let alice = Keys::generate(); - let bob = Keys::generate(); - let group = group(); - - let message = build_message( - alice.public_key(), - &channel(), - Epoch(0), - "hello", - None, - AT, - None, - ); - let id = message.compute_id(); - - let rumors = vec![ - read(&message, &group, &alice, Epoch(0)), - read( - &build_edit( - bob.public_key(), - &channel(), - Epoch(0), - id, - "mine now", - AT + 1_000, - None, - ), - &group, - &bob, - Epoch(0), - ), - read( - &build_delete( - bob.public_key(), - &channel(), - Epoch(0), - id, - Some(KIND_MESSAGE), - None, - AT + 2_000, - ), - &group, - &bob, - Epoch(0), - ), - ]; - - let folded = fold(&rumors, now(), |_, _, _| false); - - assert_eq!(folded.len(), 1); - assert_eq!(folded[0].content, "hello"); - assert_eq!(folded[0].edited_at, None); - assert!(!folded[0].deleted); - } - - #[test] - fn a_comment_carries_its_root_and_its_parent() { - let alice = Keys::generate(); - let bob = Keys::generate(); - let group = group(); - - let root = build_message( - alice.public_key(), - &channel(), - Epoch(0), - "root", - None, - AT, - None, - ); - let root_id = root.compute_id(); - let parent = build_message( - bob.public_key(), - &channel(), - Epoch(0), - "parent", - None, - AT + 1_000, - None, - ); - let parent_id = parent.compute_id(); - - let comment = build_comment( - alice.public_key(), - &channel(), - Epoch(0), - "deep", - &target(parent_id, &bob), - Some(&target(root_id, &alice)), - AT + 2_000, - None, - ); - - assert!(comment.tags.iter().any(|tag| tag.as_slice() == ["K", "9"])); - assert!( - comment - .tags - .iter() - .any(|tag| { tag.as_slice()[0] == "E" && tag.as_slice()[1] == root_id.to_hex() }) - ); - assert!( - comment - .tags - .iter() - .any(|tag| { tag.as_slice()[0] == "e" && tag.as_slice()[1] == parent_id.to_hex() }) - ); - - let rumor = read(&comment, &group, &alice, Epoch(0)); - let ChatAction::Message { - reply_to, - thread_root, - } = &rumor.action - else { - panic!("a comment is a message row") - }; - - assert_eq!(reply_to.map(|reply| reply.id), Some(parent_id)); - assert_eq!(thread_root.map(|root| root.id), Some(root_id)); - } - - #[test] - fn a_rumor_bound_to_another_channel_or_epoch_is_rejected() { - let alice = Keys::generate(); - let group = group(); - - let plain = build_message( - alice.public_key(), - &channel(), - Epoch(0), - "hello", - None, - AT, - None, - ); - assert!( - open( - &sealed(&plain, &group, &alice), - &group, - &channel(), - Epoch(0) - ) - .is_ok() - ); - - // The keyholder re-addresses their own rumor: the binding is judged - // against the plane whose key opened the wrap, never the rumor's claim. - let elsewhere = ChannelId::from_bytes([0xeeu8; 32]); - assert!(matches!( - open( - &sealed(&plain, &group, &alice), - &group, - &elsewhere, - Epoch(0) - ), - Err(ChatError::Stream(StreamError::ChannelMismatch)) - )); - - let stale = build_message( - alice.public_key(), - &channel(), - Epoch(1), - "stale", - None, - AT, - None, - ); - assert!(matches!( - open( - &sealed(&stale, &group, &alice), - &group, - &channel(), - Epoch(0) - ), - Err(ChatError::Stream(StreamError::EpochMismatch)) - )); - - // Chat is encrypted-seal only (CORD-02 §5), and a retired kind is not a - // chat rumor however well-formed it looks. - let seal = - smol::block_on(build_seal(&plain, SealForm::Plaintext, &group, &alice)).expect("seals"); - let (wrap, _) = wrap_seal( - &seal, - &group, - KIND_WRAP, - Timestamp::from_secs(AT / 1000), - &[], - ) - .expect("wraps"); - assert!(matches!( - open(&wrap, &group, &channel(), Epoch(0)), - Err(ChatError::NotEncryptedSealed) - )); - - let ghost = build_rumor_ms( - 3300, - alice.public_key(), - "v1 ghost", - channel_binding_tags(&channel(), Epoch(0)), - AT, - ); - assert!(matches!( - smol::block_on(seal_rumor(&ghost, &group, &alice, false)), - Err(ChatError::UnknownKind(3300)) - )); - - let mut tags = channel_binding_tags(&channel(), Epoch(0)); - tags.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)])); - tags.push(Tag::custom(TAG_TARGET, ["cd".repeat(32)])); - let ambiguous = build_rumor_ms(KIND_DELETE, alice.public_key(), "", tags, AT); - assert!(matches!( - open( - &sealed(&ambiguous, &group, &alice), - &group, - &channel(), - Epoch(0) - ), - Err(ChatError::DuplicateTag(TAG_TARGET)) - )); - } - - #[test] - fn a_moderator_delete_needs_the_roster_and_a_citation() { - let alice = Keys::generate(); - let moderator = Keys::generate(); - let peer = Keys::generate(); - let group = group(); - - let message = read( - &build_message( - alice.public_key(), - &channel(), - Epoch(0), - "hello", - None, - AT, - None, - ), - &group, - &alice, - Epoch(0), - ); - let id = message.id; - let citation = AuthorityCitation { - entity: [0x33; 32], - version: 1, - hash: [0x44; 32], - }; - - let delete = |author: &Keys, citation: Option<&AuthorityCitation>| { - read( - &build_delete( - author.public_key(), - &channel(), - Epoch(0), - id, - Some(KIND_MESSAGE), - citation, - AT + 1_000, - ), - &group, - author, - Epoch(0), - ) - }; - - let can_delete = - |actor: &PublicKey, citation: Option<&AuthorityCitation>, author: &PublicKey| { - actor != author && citation.is_some() && actor == &moderator.public_key() - }; - - let cited = vec![message.clone(), delete(&moderator, Some(&citation))]; - assert!(matches!( - &cited[1].action, - ChatAction::Delete { citation: Some(parsed), .. } if *parsed == citation - )); - assert!( - fold(&cited, now(), can_delete)[0].deleted, - "a cited moderator delete lands" - ); - - let uncited = vec![message.clone(), delete(&moderator, None)]; - assert!( - !fold(&uncited, now(), can_delete)[0].deleted, - "an uncited delete names no rank" - ); - - let peer_delete = vec![message.clone(), delete(&peer, Some(&citation))]; - assert!( - !fold(&peer_delete, now(), can_delete)[0].deleted, - "a peer's delete is not authority" - ); - - let own = vec![message.clone(), delete(&alice, None)]; - assert!( - fold(&own, now(), |_, _, _| false)[0].deleted, - "a self-delete never consults the predicate" - ); - } - - #[test] - fn a_timer_rides_durable_rumors_and_expiry_gates_the_fold() { - let alice = Keys::generate(); - let group = group(); - let expires = (AT / 1000 + 60).to_string(); - - // Computed from the signed `created_at`, and mirrored onto the wrap so - // relays drop the ciphertext too. - let message = build_message( - alice.public_key(), - &channel(), - Epoch(0), - "tick", - None, - AT, - Some(60), - ); - assert!( - message - .tags - .iter() - .any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()]) - ); - assert!( - sealed(&message, &group, &alice) - .tags - .iter() - .any(|tag| tag.as_slice() == [TAG_EXPIRATION, expires.as_str()]) - ); - - let live = read(&message, &group, &alice, Epoch(0)); - assert_eq!(live.expiration, Some(Timestamp::from_secs(AT / 1000 + 60))); - assert!(!expired(&live, Timestamp::from_secs(AT / 1000 + 59))); - assert!(expired(&live, Timestamp::from_secs(AT / 1000 + 60))); - assert_eq!( - fold( - std::slice::from_ref(&live), - Timestamp::from_secs(AT / 1000 + 59), - |_, _, _| false - ) - .len(), - 1 - ); - assert_eq!( - fold(&[live], Timestamp::from_secs(AT / 1000 + 60), |_, _, _| { - false - }) - .len(), - 0 - ); - - // A delete is a tombstone and a notice documents the policy, so neither - // may be erased by the policy it carries. - let mut expiring = channel_binding_tags(&channel(), Epoch(0)); - expiring.push(Tag::custom(TAG_EXPIRATION, ["1"])); - expiring.push(Tag::custom(TAG_TARGET, ["ab".repeat(32)])); - - for kind in [KIND_DELETE, KIND_TIMER_NOTICE] { - let rumor = build_rumor_ms(kind, alice.public_key(), "", expiring.clone(), AT); - assert!(matches!( - open( - &sealed(&rumor, &group, &alice), - &group, - &channel(), - Epoch(0) - ), - Err(ChatError::ExemptExpiration) - )); - } - - // A notice is a row of its own; whether its author may be believed - // about policy is the roster's call, not the fold's. - let notice = build_timer_notice(alice.public_key(), &channel(), Epoch(0), 3_600, AT); - let folded = fold( - &[read(¬ice, &group, &alice, Epoch(0))], - now(), - |_, _, _| false, - ); - assert_eq!(folded.len(), 1); - assert_eq!(folded[0].kind, Kind::Custom(KIND_TIMER_NOTICE)); - - let mut malformed = channel_binding_tags(&channel(), Epoch(0)); - malformed.push(Tag::custom(TAG_TIMER, ["060"])); - let rumor = build_rumor_ms(KIND_TIMER_NOTICE, alice.public_key(), "", malformed, AT); - assert!(matches!( - open( - &sealed(&rumor, &group, &alice), - &group, - &channel(), - Epoch(0) - ), - Err(ChatError::BadTag(TAG_TIMER)) - )); - } -} diff --git a/crates/concord/src/cords/cord04/mod.rs b/crates/concord/src/cords/cord04/mod.rs deleted file mode 100644 index b2af00a3..00000000 --- a/crates/concord/src/cords/cord04/mod.rs +++ /dev/null @@ -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, - 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, - pub content: &'a str, - pub at_secs: u64, -} - -fn signing_bytes( - entity: &[u8; 32], - version: u64, - prev: Option<&[u8; 32]>, - content: &[u8], -) -> Vec { - 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 { - 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 { - 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, - 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 = 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 { - 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, - 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 { - 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, 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, 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); - } -} diff --git a/crates/concord/src/cords/cord04/pins.rs b/crates/concord/src/cords/cord04/pins.rs deleted file mode 100644 index cdd18d6a..00000000 --- a/crates/concord/src/cords/cord04/pins.rs +++ /dev/null @@ -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 { - let bytes = decode_hex_lower::(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 { - let hkdf = Hkdf::::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, - mac: [u8; 32], -} - -fn decode_payload(payload: &str) -> Option { - 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 { - let decoded = decode_payload(payload)?; - MessageKeys::derive(conversation_key, &decoded.nonce) -} - -fn open_payload(payload: &str, keys: &MessageKeys) -> Option { - let decoded = decode_payload(payload)?; - - let mut mac = Hmac::::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 { - 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 { - 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, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub edit: Option, - #[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, - pub edited: Option, - pub entry: PinEntry, -} - -#[derive(Debug, Clone, Default)] -pub struct ReadPinList { - pub entries: Vec, - pub sealed: bool, -} - -pub fn build_entry( - opened: &OpenedStream, - group: &GroupKey, - channel: &ChannelId, -) -> Result { - 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 { - 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 { - 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 { - 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 { - 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, -} - -pub fn publishable( - read: &ReadPinList, - private: bool, - group: &GroupKey, - epoch: Epoch, -) -> Result { - 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 { - let content = encode_form(entries)?; - check_caps(entries.len(), &content)?; - - Ok(content) -} - -fn serialize_sealed( - entries: &[PinEntry], - group: &GroupKey, - epoch: Epoch, -) -> Result { - 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 { - 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) -> 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::(content) else { - return EMPTY; - }; - - if value.get("entries").is_some() { - return match serde_json::from_value::(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::(&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) -> 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() - ); - } - } -} diff --git a/crates/concord/src/cords/cord04/roles.rs b/crates/concord/src/cords/cord04/roles.rs deleted file mode 100644 index f44343c4..00000000 --- a/crates/concord/src/cords/cord04/roles.rs +++ /dev/null @@ -1,1087 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet, HashSet}; - -use anyhow::{Result, bail}; -use nostr_sdk::prelude::PublicKey; -use serde::{Deserialize, Deserializer, Serialize, Serializer}; - -use crate::cord04::{ - AuthorityCitation, EditionMeta, EntityHead, Floors, ParsedEdition, fold_head, vsk, -}; -use crate::derive::{banlist_locator, grant_locator}; -use crate::{ChannelId, CommunityId, Extra, RoleId, decode_hex_32}; - -pub const MAX_ROLES_PER_COMMUNITY: usize = 100; -pub const MAX_ROLES_PER_MEMBER: usize = 64; -pub const MAX_BANLIST: usize = 500; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, PartialOrd, Ord, Hash)] -pub struct Permissions(pub u64); - -impl Permissions { - pub const BAN: u64 = 1 << 4; - pub const CREATE_INVITE: u64 = 1 << 6; - pub const KICK: u64 = 1 << 3; - pub const MANAGE_CHANNELS: u64 = 1 << 1; - pub const MANAGE_MESSAGES: u64 = 1 << 5; - pub const MANAGE_METADATA: u64 = 1 << 2; - pub const MANAGE_ROLES: u64 = 1 << 0; - pub const MENTION_EVERYONE: u64 = 1 << 9; - pub const PIN_MESSAGES: u64 = 1 << 11; - pub const STAFF_MASK: u64 = Self::MANAGE_ROLES - | Self::MANAGE_CHANNELS - | Self::MANAGE_METADATA - | Self::BAN - | Self::CREATE_INVITE - | Self::PIN_MESSAGES; - pub const VIEW_AUDIT_LOG: u64 = 1 << 8; - - pub fn contains(self, bits: u64) -> bool { - self.0 & bits == bits - } - - pub fn union(self, other: Self) -> Self { - Self(self.0 | other.0) - } - - pub fn is_staff(self) -> bool { - self.0 & Self::STAFF_MASK != 0 - } -} - -impl Serialize for Permissions { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.0.to_string()) - } -} - -impl<'de> Deserialize<'de> for Permissions { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - #[serde(untagged)] - enum Raw { - String(String), - Number(u64), - } - - match Raw::deserialize(deserializer)? { - Raw::Number(bits) => Ok(Self(bits)), - Raw::String(bits) => { - if bits.is_empty() || !bits.bytes().all(|byte| byte.is_ascii_digit()) { - return Err(serde::de::Error::custom( - "permissions must be a decimal string", - )); - } - - bits.parse().map(Self).map_err(serde::de::Error::custom) - } - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case", tag = "kind", content = "channel_id")] -pub enum RoleScope { - Server, - Channel(ChannelId), -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Role { - pub role_id: RoleId, - pub name: String, - /// 0 belongs to the owner. - pub position: u32, - pub permissions: Permissions, - pub scope: RoleScope, - #[serde(default)] - pub color: u32, - #[serde(flatten)] - pub extra: Extra, -} - -impl Role { - fn parse(content: &str) -> Option { - serde_json::from_str(content).ok() - } - - pub fn to_content(&self) -> Result { - Ok(serde_json::to_string(self)?) - } -} - -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct Grant { - pub member: PublicKey, - /// Empty is a revoke. - #[serde(default)] - pub role_ids: Vec, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub control_wrap: Option, - #[serde(flatten)] - pub extra: Extra, -} - -impl Grant { - fn parse(content: &str) -> Option { - serde_json::from_str(content).ok() - } - - pub fn to_content(&self) -> Result { - if self.role_ids.len() > MAX_ROLES_PER_MEMBER { - bail!("grant exceeds {MAX_ROLES_PER_MEMBER} roles"); - } - - Ok(serde_json::to_string(self)?) - } -} - -fn parse_banlist(content: &str) -> Option> { - let entries: Vec = serde_json::from_str(content).ok()?; - let mut banned = Vec::with_capacity(entries.len()); - - for entry in &entries { - banned.push(PublicKey::from_slice(&decode_hex_32(entry).ok()?).ok()?); - } - - Some(banned) -} - -/// The graph aggregated from the folded Role and Grant editions; not a wire document. -#[derive(Debug, Clone, Default, PartialEq)] -pub struct CommunityRoles { - roles: BTreeMap, - grants: BTreeMap, -} - -impl CommunityRoles { - pub fn role(&self, role_id: &RoleId) -> Option<&Role> { - self.roles.get(role_id) - } - - pub fn roles(&self) -> impl Iterator { - self.roles.values() - } - - pub fn grants(&self) -> impl Iterator { - self.grants.values() - } - - pub fn is_empty(&self) -> bool { - self.roles.is_empty() && self.grants.is_empty() - } - - pub fn roles_of<'a>(&'a self, member: &PublicKey) -> impl Iterator + 'a { - self.grants - .get(member) - .into_iter() - .flat_map(|grant| grant.role_ids.iter()) - .filter_map(|role_id| self.roles.get(role_id)) - } - - pub fn effective_permissions(&self, member: &PublicKey) -> Permissions { - self.roles_of(member) - .fold(Permissions::default(), |total, role| { - total.union(role.permissions) - }) - } - - pub fn has_permission(&self, member: &PublicKey, bits: u64) -> bool { - self.effective_permissions(member).contains(bits) - } - - pub fn highest_position(&self, member: &PublicKey) -> Option { - self.roles_of(member).map(|role| role.position).min() - } - - pub fn is_authorized(&self, actor: &PublicKey, owner: &PublicKey, permission: u64) -> bool { - actor == owner || self.has_permission(actor, permission) - } - - /// Strictly outranks: equal cannot act on equal. - pub fn outranks(&self, actor: &PublicKey, owner: &PublicKey, target_position: u32) -> bool { - if actor == owner { - return true; - } - - match self.highest_position(actor) { - Some(position) => position < target_position, - None => false, - } - } - - pub fn can_act_on_position( - &self, - actor: &PublicKey, - owner: &PublicKey, - target_position: u32, - permission: u64, - ) -> bool { - if actor == owner { - return true; - } - - self.has_permission(actor, permission) && self.outranks(actor, owner, target_position) - } - - pub fn can_act_on_member( - &self, - actor: &PublicKey, - owner: &PublicKey, - target: &PublicKey, - permission: u64, - ) -> bool { - if target == owner { - return false; - } - - self.can_act_on_position( - actor, - owner, - self.highest_position(target).unwrap_or(u32::MAX), - permission, - ) - } - - pub fn is_staff(&self, member: &PublicKey, owner: &PublicKey) -> bool { - member == owner || self.effective_permissions(member).is_staff() - } - - fn cap_roles(&mut self) { - if self.roles.len() <= MAX_ROLES_PER_COMMUNITY { - return; - } - - let Some(threshold) = self.roles.keys().nth(MAX_ROLES_PER_COMMUNITY).copied() else { - return; - }; - - self.roles.split_off(&threshold); - - let roles = &self.roles; - self.grants.retain(|_, grant| { - grant.role_ids.retain(|role_id| roles.contains_key(role_id)); - !grant.role_ids.is_empty() - }); - } -} - -#[derive(Debug, Clone)] -pub enum AuthorityContent { - Role(Role), - Grant(Grant), - Banlist(Vec), -} - -#[derive(Debug, Clone)] -pub struct AuthorityEdition { - pub entity: [u8; 32], - pub meta: EditionMeta, - pub author: PublicKey, - pub citation: Option, - pub content: AuthorityContent, -} - -impl AuthorityEdition { - /// `None` for anything the fold should drop rather than repair. - pub fn parse(edition: &ParsedEdition, community_id: &CommunityId) -> Option { - let content = match edition.subkind.as_str() { - vsk::ROLE => { - let role = Role::parse(&edition.content)?; - - if *role.role_id.as_bytes() != edition.entity || role.position == 0 { - return None; - } - - AuthorityContent::Role(role) - } - vsk::GRANT => { - let mut grant = Grant::parse(&edition.content)?; - - if grant_locator(community_id, &grant.member.to_bytes()) != edition.entity { - return None; - } - - grant.role_ids.truncate(MAX_ROLES_PER_MEMBER); - - AuthorityContent::Grant(grant) - } - vsk::BANLIST => { - if edition.entity != banlist_locator(community_id) { - return None; - } - - AuthorityContent::Banlist(parse_banlist(&edition.content)?) - } - _ => return None, - }; - - Some(Self { - entity: edition.entity, - meta: EditionMeta::from(edition), - author: edition.author, - citation: edition.citation, - content, - }) - } - - pub fn head(&self) -> EntityHead { - EntityHead { - entity: self.entity, - version: self.meta.version, - self_hash: self.meta.self_hash, - rumor_id: self.meta.tiebreak_id, - } - } -} - -#[derive(Debug, Clone, Default)] -pub struct Roster { - pub roles: CommunityRoles, - pub banned: BTreeSet, - /// The role, grant and banlist heads this fold settled. - pub floors: Floors, - pub gapped: bool, -} - -pub fn fold_roster( - owner: &PublicKey, - community_id: &CommunityId, - editions: &[AuthorityEdition], - floors: &Floors, - held_bans: &BTreeSet, -) -> Roster { - let banlist = banlist_locator(community_id); - let mut role_candidates: BTreeMap<[u8; 32], Vec<&AuthorityEdition>> = BTreeMap::new(); - let mut grant_candidates: BTreeMap<[u8; 32], Vec<&AuthorityEdition>> = BTreeMap::new(); - let mut banlist_candidates: Vec<&AuthorityEdition> = Vec::new(); - - for edition in editions { - if edition.meta.version < floors.get(&edition.entity).map_or(0, |floor| floor.version) { - continue; - } - - match &edition.content { - AuthorityContent::Role(_) => { - role_candidates - .entry(edition.entity) - .or_default() - .push(edition); - } - AuthorityContent::Grant(_) => { - grant_candidates - .entry(edition.entity) - .or_default() - .push(edition); - } - AuthorityContent::Banlist(_) if edition.entity == banlist => { - banlist_candidates.push(edition); - } - AuthorityContent::Banlist(_) => {} - } - } - - for candidates in role_candidates - .values_mut() - .chain(grant_candidates.values_mut()) - { - rank(candidates); - } - - rank(&mut banlist_candidates); - - let mut gapped = false; - for (entity, candidates) in role_candidates.iter().chain(&grant_candidates) { - gapped |= entity_gapped(entity, candidates, floors); - } - gapped |= entity_gapped(&banlist, &banlist_candidates, floors); - - let preliminary = select_authorized( - owner, - community_id, - &role_candidates, - &grant_candidates, - &BTreeSet::new(), - ); - - let (banned, banlist_head, banlist_gapped) = fold_banlist( - owner, - community_id, - &banlist_candidates, - &preliminary.roles, - floors, - held_bans, - ); - gapped |= banlist_gapped; - - let mut selection = select_authorized( - owner, - community_id, - &role_candidates, - &grant_candidates, - &banned, - ); - selection.roles.cap_roles(); - - let mut floors = selection.floors; - if let Some(head) = banlist_head { - floors.insert(head.entity, head); - } - - Roster { - roles: selection.roles, - banned, - floors, - gapped, - } -} - -pub fn citation_ok( - owner: &PublicKey, - community_id: &CommunityId, - author: &PublicKey, - citation: Option<&AuthorityCitation>, - floors: &Floors, -) -> bool { - if author == owner { - return true; - } - - let Some(citation) = citation else { - return false; - }; - - let grant = grant_locator(community_id, &author.to_bytes()); - - if citation.entity != grant { - return false; - } - - match floors.get(&grant) { - Some(head) if head.version > citation.version => true, - Some(head) if head.version == citation.version => head.self_hash == citation.hash, - _ => false, - } -} - -fn rank(candidates: &mut Vec<&AuthorityEdition>) { - candidates.sort_by(|a, b| { - b.meta - .version - .cmp(&a.meta.version) - .then(a.meta.tiebreak_id.cmp(&b.meta.tiebreak_id)) - }); -} - -fn entity_gapped(entity: &[u8; 32], candidates: &[&AuthorityEdition], floors: &Floors) -> bool { - let metas: Vec = candidates.iter().map(|candidate| candidate.meta).collect(); - - fold_head(&metas, floors.get(entity)).gap -} - -#[derive(Debug, Default)] -struct Selection { - roles: CommunityRoles, - floors: Floors, -} - -fn select_authorized( - owner: &PublicKey, - community_id: &CommunityId, - role_candidates: &BTreeMap<[u8; 32], Vec<&AuthorityEdition>>, - grant_candidates: &BTreeMap<[u8; 32], Vec<&AuthorityEdition>>, - excluded: &BTreeSet, -) -> Selection { - let fixpoint = Fixpoint { - owner, - community_id, - excluded, - roles: role_candidates, - grants: grant_candidates, - }; - - let bound = 2 * (role_candidates.len() + grant_candidates.len()) + 8; - let mut accepted = Selection::default(); - - for _ in 0..bound { - let next = fixpoint.pass(&accepted); - - if next.roles == accepted.roles { - return next; - } - - accepted = next; - } - - accepted -} - -struct Fixpoint<'a> { - owner: &'a PublicKey, - community_id: &'a CommunityId, - excluded: &'a BTreeSet, - roles: &'a BTreeMap<[u8; 32], Vec<&'a AuthorityEdition>>, - grants: &'a BTreeMap<[u8; 32], Vec<&'a AuthorityEdition>>, -} - -impl Fixpoint<'_> { - fn pass(&self, accepted: &Selection) -> Selection { - let mut next = Selection::default(); - - for candidates in self.roles.values() { - self.select_role(candidates, accepted, &mut next); - } - - for candidates in self.grants.values() { - self.select_grant(candidates, accepted, &mut next); - } - - next - } - - fn select_role( - &self, - candidates: &[&AuthorityEdition], - accepted: &Selection, - next: &mut Selection, - ) { - let mut admissible: HashSet<[u8; 32]> = HashSet::new(); - let mut standing: Option = None; - let mut end = candidates.len(); - - while end > 0 { - let version = candidates[end - 1].meta.version; - let mut start = end; - - while start > 0 && candidates[start - 1].meta.version == version { - start -= 1; - } - - // One winner per version, so fork siblings cannot sidestep the gates. - for candidate in &candidates[start..end] { - let AuthorityContent::Role(role) = &candidate.content else { - continue; - }; - - if self.excluded.contains(&candidate.author) { - continue; - } - - if !accepted.roles.can_act_on_position( - &candidate.author, - self.owner, - role.position, - Permissions::MANAGE_ROLES, - ) { - continue; - } - - if let Some(previous) = standing - && !accepted.roles.can_act_on_position( - &candidate.author, - self.owner, - previous, - Permissions::MANAGE_ROLES, - ) - { - continue; - } - - // An unresolvable citation parks the edition; an absent one declared no floor to wait for. - if candidate.citation.is_some() - && !citation_ok( - self.owner, - self.community_id, - &candidate.author, - candidate.citation.as_ref(), - &accepted.floors, - ) - { - continue; - } - - admissible.insert(candidate.meta.self_hash); - standing = Some(role.position); - break; - } - - end = start; - } - - for candidate in candidates { - let AuthorityContent::Role(role) = &candidate.content else { - continue; - }; - - if !admissible.contains(&candidate.meta.self_hash) { - continue; - } - - next.roles.roles.insert(role.role_id, role.clone()); - next.floors.insert(candidate.entity, candidate.head()); - break; - } - } - - fn select_grant( - &self, - candidates: &[&AuthorityEdition], - accepted: &Selection, - next: &mut Selection, - ) { - for candidate in candidates { - let AuthorityContent::Grant(grant) = &candidate.content else { - continue; - }; - - if self.excluded.contains(&candidate.author) || self.excluded.contains(&grant.member) { - continue; - } - - let resolved: Vec<(&RoleId, u32)> = grant - .role_ids - .iter() - .filter_map(|role_id| { - accepted - .roles - .role(role_id) - .map(|role| (role_id, role.position)) - }) - .collect(); - - if resolved.is_empty() && !grant.role_ids.is_empty() { - continue; - } - - let cited = citation_ok( - self.owner, - self.community_id, - &candidate.author, - candidate.citation.as_ref(), - &accepted.floors, - ); - - if !cited && candidate.citation.is_some() { - continue; - } - - if !cited && resolved.is_empty() { - continue; - } - - let ranks_every_role = resolved.iter().all(|(_, position)| { - accepted.roles.can_act_on_position( - &candidate.author, - self.owner, - *position, - Permissions::MANAGE_ROLES, - ) - }); - - if !ranks_every_role - || !accepted.roles.can_act_on_member( - &candidate.author, - self.owner, - &grant.member, - Permissions::MANAGE_ROLES, - ) - { - continue; - } - - // A revoke still advances the floor; it just does not belong in the roster. - next.floors.insert(candidate.entity, candidate.head()); - - if !resolved.is_empty() { - let mut resolved_grant = grant.clone(); - resolved_grant.role_ids = resolved.iter().map(|(role_id, _)| **role_id).collect(); - next.roles.grants.insert(grant.member, resolved_grant); - } - - break; - } - } -} - -fn fold_banlist( - owner: &PublicKey, - community_id: &CommunityId, - candidates: &[&AuthorityEdition], - roster: &CommunityRoles, - floors: &Floors, - held_bans: &BTreeSet, -) -> (BTreeSet, Option, bool) { - let authorized: Vec<&AuthorityEdition> = candidates - .iter() - .copied() - .filter(|candidate| { - !held_bans.contains(&candidate.author) - && roster.is_authorized(&candidate.author, owner, Permissions::BAN) - && citation_ok( - owner, - community_id, - &candidate.author, - candidate.citation.as_ref(), - floors, - ) - }) - .collect(); - - if authorized.is_empty() { - return (held_bans.clone(), None, false); - } - - let entity = banlist_locator(community_id); - let metas: Vec = authorized.iter().map(|candidate| candidate.meta).collect(); - let selection = fold_head(&metas, floors.get(&entity)); - - let Some(index) = selection.head else { - return (held_bans.clone(), None, selection.gap); - }; - - let head = authorized[index]; - - let AuthorityContent::Banlist(entries) = &head.content else { - return (held_bans.clone(), None, selection.gap); - }; - - let banned: BTreeSet = entries - .iter() - .filter(|target| roster.can_act_on_member(&head.author, owner, target, Permissions::BAN)) - .take(MAX_BANLIST) - .copied() - .collect(); - - (banned, Some(head.head()), selection.gap) -} - -#[cfg(test)] -mod tests { - use nostr_sdk::prelude::Keys; - - use super::*; - use crate::cord04::{EditionFields, build_edition, parse_edition}; - - const COMMUNITY: [u8; 32] = [0xc0; 32]; - const AT: u64 = 1_700_000_000; - - fn community_id() -> CommunityId { - CommunityId::from_bytes(COMMUNITY) - } - - fn role(entity: [u8; 32], position: u32) -> Role { - Role { - role_id: RoleId::from_bytes(entity), - name: "Mod".to_owned(), - position, - permissions: Permissions(Permissions::MANAGE_ROLES | Permissions::MANAGE_METADATA), - scope: RoleScope::Server, - color: 0, - extra: Extra::default(), - } - } - - fn edition( - author: &PublicKey, - subkind: &str, - entity: [u8; 32], - content: &str, - version: u64, - citation: Option, - ) -> AuthorityEdition { - let rumor = build_edition(EditionFields { - author: *author, - subkind, - entity, - version, - prev: None, - citation, - content, - at_secs: AT, - }); - - AuthorityEdition::parse(&parse_edition(&rumor).expect("parses"), &community_id()) - .expect("recognizes") - } - - fn role_edition(author: &PublicKey, id: u8, position: u32, version: u64) -> AuthorityEdition { - let entity = [id; 32]; - let content = role(entity, position).to_content().expect("serializes"); - - edition(author, vsk::ROLE, entity, &content, version, None) - } - - fn grant_edition( - author: &PublicKey, - member: &PublicKey, - roles: &[u8], - version: u64, - citation: Option, - ) -> AuthorityEdition { - let content = Grant { - member: *member, - role_ids: roles - .iter() - .map(|id| RoleId::from_bytes([*id; 32])) - .collect(), - control_wrap: None, - extra: Extra::default(), - } - .to_content() - .expect("serializes"); - - edition( - author, - vsk::GRANT, - grant_locator(&community_id(), &member.to_bytes()), - &content, - version, - citation, - ) - } - - fn fold( - owner: &PublicKey, - editions: &[AuthorityEdition], - banned: &BTreeSet, - ) -> Roster { - fold_roster(owner, &community_id(), editions, &Floors::new(), banned) - } - - fn position(roster: &Roster, id: u8) -> Option { - roster - .roles - .role(&RoleId::from_bytes([id; 32])) - .map(|role| role.position) - } - - #[test] - fn permission_bits_are_frozen() { - assert_eq!(Permissions::MANAGE_ROLES, 1); - assert_eq!(Permissions::MANAGE_CHANNELS, 2); - assert_eq!(Permissions::MANAGE_METADATA, 4); - assert_eq!(Permissions::KICK, 8); - assert_eq!(Permissions::BAN, 16); - assert_eq!(Permissions::MANAGE_MESSAGES, 32); - assert_eq!(Permissions::CREATE_INVITE, 64); - assert_eq!(Permissions::VIEW_AUDIT_LOG, 256); - assert_eq!(Permissions::MENTION_EVERYONE, 512); - assert_eq!(Permissions::PIN_MESSAGES, 2048); - assert_eq!(Permissions::STAFF_MASK, 1 | 2 | 4 | 16 | 64 | 2048); - } - - #[test] - fn role_content_round_trips_with_the_permissions_as_a_decimal_string() { - let id = [0x01; 32]; - let content = role(id, 2).to_content().expect("serializes"); - - assert!( - content.contains("\"permissions\":\"5\""), - "permissions ride as a string: {content}" - ); - assert!( - content.contains("\"scope\":{\"kind\":\"server\"}"), - "{content}" - ); - assert_eq!(Role::parse(&content).expect("parses").position, 2); - - let legacy = content.replace("\"permissions\":\"5\"", "\"permissions\":5"); - assert_eq!( - Role::parse(&legacy).expect("parses").permissions, - Permissions(Permissions::MANAGE_ROLES | Permissions::MANAGE_METADATA) - ); - assert!(Role::parse(&content.replace("\"5\"", "\"+5\"")).is_none()); - - let scoped = Role { - scope: RoleScope::Channel(ChannelId::from_bytes([0x0a; 32])), - ..role(id, 2) - }; - assert!( - scoped - .to_content() - .expect("serializes") - .contains("\"scope\":{\"kind\":\"channel\",\"channel_id\":") - ); - } - - #[test] - fn authority_resolves_outward_from_the_owner_and_refuses_escalation() { - let owner = Keys::generate(); - let admin = Keys::generate(); - let member = Keys::generate(); - let stranger = Keys::generate(); - - let editions = vec![ - role_edition(&owner.public_key(), 0x01, 1, 1), - grant_edition(&owner.public_key(), &admin.public_key(), &[0x01], 1, None), - grant_edition(&owner.public_key(), &member.public_key(), &[0x01], 1, None), - ]; - let roster = fold(&owner.public_key(), &editions, &BTreeSet::new()); - - assert!( - roster - .roles - .is_staff(&admin.public_key(), &owner.public_key()) - ); - assert!( - roster - .roles - .is_staff(&member.public_key(), &owner.public_key()) - ); - assert!( - !roster - .roles - .is_staff(&stranger.public_key(), &owner.public_key()) - ); - assert!(roster.roles.is_authorized( - &owner.public_key(), - &owner.public_key(), - Permissions::MANAGE_METADATA - )); - - // Equal cannot act on equal, and a roleless member outranks nobody. - assert!(!roster.roles.can_act_on_position( - &admin.public_key(), - &owner.public_key(), - 1, - Permissions::MANAGE_ROLES - )); - assert!(roster.roles.can_act_on_position( - &admin.public_key(), - &owner.public_key(), - 2, - Permissions::MANAGE_ROLES - )); - assert!(!roster.roles.can_act_on_member( - &admin.public_key(), - &owner.public_key(), - &member.public_key(), - Permissions::BAN - )); - - let banned = fold( - &owner.public_key(), - &editions, - &BTreeSet::from([admin.public_key()]), - ); - assert!( - !banned - .roles - .is_staff(&admin.public_key(), &owner.public_key()) - ); - assert!( - banned - .roles - .is_staff(&member.public_key(), &owner.public_key()) - ); - - // An unauthorized higher version is dropped, not allowed to vanish the entity. - let roster = fold( - &owner.public_key(), - &[ - role_edition(&stranger.public_key(), 0x02, 9, 9), - role_edition(&owner.public_key(), 0x02, 3, 1), - ], - &BTreeSet::new(), - ); - assert_eq!(position(&roster, 0x02), Some(3)); - - // Nor may an admin republish a role out from under its holders. - let roster = fold( - &owner.public_key(), - &[ - role_edition(&admin.public_key(), 0x03, 9, 2), - role_edition(&owner.public_key(), 0x03, 1, 1), - ], - &BTreeSet::new(), - ); - assert_eq!(position(&roster, 0x03), Some(1)); - } - - #[test] - fn an_uncited_revocation_strips_nothing_while_a_cited_one_does() { - let owner = Keys::generate(); - let moderator = Keys::generate(); - let member = Keys::generate(); - - let editions = vec![ - role_edition(&owner.public_key(), 0x01, 5, 1), - role_edition(&owner.public_key(), 0x02, 9, 1), - grant_edition( - &owner.public_key(), - &moderator.public_key(), - &[0x01], - 1, - None, - ), - grant_edition(&owner.public_key(), &member.public_key(), &[0x02], 1, None), - ]; - - let roster = fold(&owner.public_key(), &editions, &BTreeSet::new()); - assert!(roster.roles.roles_of(&member.public_key()).next().is_some()); - assert!(roster.roles.can_act_on_member( - &moderator.public_key(), - &owner.public_key(), - &member.public_key(), - Permissions::MANAGE_ROLES - )); - - let revoke = |citation| { - grant_edition( - &moderator.public_key(), - &member.public_key(), - &[], - 2, - citation, - ) - }; - - // A revoke names no position to rank-check. Uncited it is only a stranger's - // word, and strips nothing. - let stripped = fold( - &owner.public_key(), - &[editions.clone(), vec![revoke(None)]].concat(), - &BTreeSet::new(), - ); - assert!( - stripped - .roles - .roles_of(&member.public_key()) - .next() - .is_some() - ); - - // Cited against the grant it acts under, the same revoke lands. - let head = roster - .floors - .get(&grant_locator( - &community_id(), - &moderator.public_key().to_bytes(), - )) - .expect("the moderator's own grant folded"); - let stripped = fold( - &owner.public_key(), - &[ - editions, - vec![revoke(Some(AuthorityCitation { - entity: head.entity, - version: head.version, - hash: head.self_hash, - }))], - ] - .concat(), - &BTreeSet::new(), - ); - assert!( - stripped - .roles - .roles_of(&member.public_key()) - .next() - .is_none() - ); - assert!( - stripped - .roles - .is_staff(&moderator.public_key(), &owner.public_key()) - ); - } -} diff --git a/crates/concord/src/cords/cord05.rs b/crates/concord/src/cords/cord05.rs deleted file mode 100644 index ed8791f8..00000000 --- a/crates/concord/src/cords/cord05.rs +++ /dev/null @@ -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 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, - 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, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub channels: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub relays: Vec, - pub name: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub icon: Option, - /// Unix **ms**: past it the preview still renders, joining refuses. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub creator_npub: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub label: Option, - #[serde(flatten)] - pub extra: Extra, -} - -impl CommunityInvite { - pub fn from_bundle_json(json: &str) -> Result { - 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), - Revoked, -} - -pub fn build_bundle_event( - link_signer: &Keys, - invite: &CommunityInvite, - bundle_key: &[u8; 32], -) -> Result { - 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 { - 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 { - 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 { - RELAY_DICT.iter().map(|relay| relay.to_string()).collect() -} - -pub fn encode_fragment(token: &[u8; TOKEN_LEN], relays: &[String]) -> Result { - 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), 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 { - 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 { - 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, - /// The bare naddr as it appeared in the link, for the fetch. - pub naddr: String, -} - -pub fn parse_link(input: &str) -> Result { - 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( - inviter: &S, - recipient: &PublicKey, - invite: &CommunityInvite, -) -> Result -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( - 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, - pub created_at: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub expires_at: Option, - #[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, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub tombstones: Vec, - #[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 = 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 = 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(keys: &S, list: &InviteList) -> Result -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(keys: &S, event: &Event) -> Result -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 { - Ok(cord01::seal_bytes( - &ConversationKey::new(*bundle_key), - json.as_bytes(), - )?) -} - -fn open_bundle(bundle_key: &[u8; 32], content: &str) -> Result { - 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 { - 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 { - 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 { - 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(_)) - )); - } -} diff --git a/crates/concord/src/cords/cord06.rs b/crates/concord/src/cords/cord06.rs deleted file mode 100644 index 83764b71..00000000 --- a/crates/concord/src/cords/cord06.rs +++ /dev/null @@ -1,1665 +0,0 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use anyhow::Result; -use data_encoding::{BASE64, HEXLOWER}; -use nostr_sdk::prelude::{ - AsyncGetPublicKey, AsyncNip44, AsyncSignEvent, Event, PublicKey, Tag, Timestamp, UnsignedEvent, -}; -use serde::{Deserialize, Serialize}; - -use crate::cord01::{self, KIND_SEAL_PLAINTEXT, OpenedStream, SealForm, StreamError}; -use crate::cord02::CommunityIdentity; -use crate::cord04::roles::CommunityRoles; -use crate::cord04::{ - AuthorityCitation, KIND_CONTROL, TAG_SUBKIND, canonical_decimal, citation_from, citation_tag, - vsk, -}; -use crate::derive::{ - base_rekey_group_key, channel_rekey_group_key, control_group_key, control_signer_group_key, - dissolved_group_key, epoch_key_commitment, recipient_locator, -}; -use crate::{ChannelId, CommunityId, Epoch, GroupKey, random_32}; - -pub const KIND_REKEY: u16 = 3303; -pub const MAX_REKEY_BLOBS_PER_EVENT: usize = 80; -pub const MAX_REKEY_BLOBS_RECEIVED: usize = 120; -pub const MAX_REKEY_EPOCH: u64 = 1 << 40; - -const TAG_SCOPE: &str = "scope"; -const TAG_NEW_EPOCH: &str = "newepoch"; -const TAG_PREV_EPOCH: &str = "prevepoch"; -const TAG_PREV_COMMIT: &str = "prevcommit"; -const TAG_CHUNK: &str = "chunk"; -const TAG_SEVER: &str = "sever"; -const TAG_EID: &str = "eid"; - -const CHANNEL_BLOB_LEN: usize = 72; -const MEMBER_BASE_BLOB_LEN: usize = 104; -const STAFF_BASE_BLOB_LEN: usize = 136; - -#[derive(Debug)] -pub enum RekeyError { - Stream(StreamError), - Crypto(String), - Json(String), - BadBlobLength(usize), - BadBaseBlobWidth(usize), - ControlPairMismatch, - MisplacedControlKey, - ScopeSplice, - EpochSplice, - NotARekey(u16), - NotADissolution, - BadTag(&'static str), - NonMonotonicEpoch, - EpochTooLarge(u64), - BadChunkIndex, - TooManyBlobs(usize), -} - -impl fmt::Display for RekeyError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - RekeyError::Stream(error) => write!(f, "stream: {error}"), - RekeyError::Crypto(error) => write!(f, "crypto: {error}"), - RekeyError::Json(error) => write!(f, "json: {error}"), - RekeyError::BadBlobLength(len) => { - write!(f, "rekey blob plaintext is {len} bytes, expected 72") - } - RekeyError::BadBaseBlobWidth(len) => write!( - f, - "base rekey blob plaintext is {len} bytes, expected 72, 104 or 136" - ), - RekeyError::ControlPairMismatch => { - write!( - f, - "base rekey blob control_root does not derive to its control_pk" - ) - } - RekeyError::MisplacedControlKey => { - write!(f, "rekey blob carries a control key at the wrong width") - } - RekeyError::ScopeSplice => write!(f, "rekey blob scope does not match its coordinate"), - RekeyError::EpochSplice => write!(f, "rekey blob epoch does not match its coordinate"), - RekeyError::NotARekey(kind) => write!(f, "kind {kind} is not a rekey"), - RekeyError::NotADissolution => write!(f, "not a dissolution tombstone"), - RekeyError::BadTag(name) => write!(f, "missing, repeated or malformed tag: {name}"), - RekeyError::NonMonotonicEpoch => write!(f, "a rotation must advance the epoch"), - RekeyError::EpochTooLarge(epoch) => write!(f, "epoch {epoch} out of range"), - RekeyError::BadChunkIndex => write!(f, "rekey chunk index out of range"), - RekeyError::TooManyBlobs(count) => { - write!(f, "rekey carries {count} blobs, over the cap") - } - } - } -} - -impl std::error::Error for RekeyError {} - -impl From for RekeyError { - fn from(error: StreamError) -> Self { - RekeyError::Stream(error) - } -} - -/// What a rotation rotates: one private channel, or the whole community base. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RekeyScope { - Channel(ChannelId), - Base, -} - -impl RekeyScope { - /// The all-zero sentinel addresses the base; a channel id is random. - pub fn id32(self) -> [u8; 32] { - match self { - RekeyScope::Channel(channel) => *channel.as_bytes(), - RekeyScope::Base => [0u8; 32], - } - } - - fn to_hex(self) -> String { - HEXLOWER.encode(&self.id32()) - } - - fn from_hex(raw: &str) -> Option { - let bytes = crate::decode_hex_32(raw).ok()?; - - if bytes == [0u8; 32] { - return Some(RekeyScope::Base); - } - - Some(RekeyScope::Channel(ChannelId::from_bytes(bytes))) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct RekeyBlob { - pub locator: String, - pub wrapped: String, -} - -/// The plaintext a blob delivered. The width declared which fields ride. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct KeyDelivery { - pub new_key: [u8; 32], - pub control_pk: Option<[u8; 32]>, - pub control_root: Option<[u8; 32]>, -} - -pub fn encode_blob_plaintext( - scope: RekeyScope, - epoch: Epoch, - new_key: &[u8; 32], - control_pk: Option<&[u8; 32]>, - control_root: Option<&[u8; 32]>, -) -> Result, RekeyError> { - if control_root.is_some() && control_pk.is_none() { - return Err(RekeyError::MisplacedControlKey); - } - - let mut bytes = Vec::with_capacity(STAFF_BASE_BLOB_LEN); - bytes.extend_from_slice(&scope.id32()); - bytes.extend_from_slice(&epoch.0.to_be_bytes()); - bytes.extend_from_slice(new_key); - - match (scope, control_pk) { - (RekeyScope::Base, Some(control_pk)) => { - bytes.extend_from_slice(control_pk); - - if let Some(control_root) = control_root { - bytes.extend_from_slice(control_root); - } - } - (RekeyScope::Base | RekeyScope::Channel(_), None) => {} - (RekeyScope::Channel(_), Some(_)) => return Err(RekeyError::MisplacedControlKey), - } - - Ok(bytes) -} - -pub fn parse_blob_plaintext( - bytes: &[u8], - scope: RekeyScope, - epoch: Epoch, - community_id: &CommunityId, -) -> Result { - if bytes.len() < CHANNEL_BLOB_LEN { - return Err(RekeyError::BadBlobLength(bytes.len())); - } - - if bytes[..32] != scope.id32() { - return Err(RekeyError::ScopeSplice); - } - - let mut epoch_be = [0u8; 8]; - epoch_be.copy_from_slice(&bytes[32..40]); - - if u64::from_be_bytes(epoch_be) != epoch.0 { - return Err(RekeyError::EpochSplice); - } - - let mut new_key = [0u8; 32]; - new_key.copy_from_slice(&bytes[40..CHANNEL_BLOB_LEN]); - - if let RekeyScope::Channel(_) = scope { - if bytes.len() != CHANNEL_BLOB_LEN { - return Err(RekeyError::BadBlobLength(bytes.len())); - } - - return Ok(KeyDelivery { - new_key, - control_pk: None, - control_root: None, - }); - } - - let width = bytes.len(); - - if width == CHANNEL_BLOB_LEN { - return Ok(KeyDelivery { - new_key, - control_pk: None, - control_root: None, - }); - } - - // Between the frozen forms is malformed; wider is a future form, kept below. - if (CHANNEL_BLOB_LEN + 1..MEMBER_BASE_BLOB_LEN).contains(&width) - || (MEMBER_BASE_BLOB_LEN + 1..STAFF_BASE_BLOB_LEN).contains(&width) - { - return Err(RekeyError::BadBaseBlobWidth(width)); - } - - let mut control_pk = [0u8; 32]; - control_pk.copy_from_slice(&bytes[CHANNEL_BLOB_LEN..MEMBER_BASE_BLOB_LEN]); - - if width == MEMBER_BASE_BLOB_LEN { - return Ok(KeyDelivery { - new_key, - control_pk: Some(control_pk), - control_root: None, - }); - } - - let mut control_root = [0u8; 32]; - control_root.copy_from_slice(&bytes[MEMBER_BASE_BLOB_LEN..STAFF_BASE_BLOB_LEN]); - - if control_signer_group_key(&control_root, community_id, epoch) - .map_err(crypto_error)? - .pk() - .to_bytes() - != control_pk - { - if width == STAFF_BASE_BLOB_LEN { - return Err(RekeyError::ControlPairMismatch); - } - - return Ok(KeyDelivery { - new_key, - control_pk: Some(control_pk), - control_root: None, - }); - } - - Ok(KeyDelivery { - new_key, - control_pk: Some(control_pk), - control_root: Some(control_root), - }) -} - -/// The rekey plane's address for a scope. -pub fn rekey_group( - scope: RekeyScope, - addressing_root: &[u8; 32], - community_id: &CommunityId, - new_epoch: Epoch, -) -> Result { - match scope { - RekeyScope::Channel(channel) => { - channel_rekey_group_key(addressing_root, &channel, new_epoch) - } - RekeyScope::Base => base_rekey_group_key(addressing_root, community_id, new_epoch), - } -} - -pub fn blob_locator( - rotator: &PublicKey, - recipient: &PublicKey, - scope: RekeyScope, - epoch: Epoch, -) -> String { - HEXLOWER.encode(&recipient_locator( - &rotator.to_bytes(), - &recipient.to_bytes(), - &scope.id32(), - epoch, - )) -} - -pub async fn build_blob( - rotator: &S, - recipient: &PublicKey, - scope: RekeyScope, - epoch: Epoch, - new_key: &[u8; 32], - control_pk: Option<&[u8; 32]>, - control_root: Option<&[u8; 32]>, -) -> Result -where - S: AsyncGetPublicKey + AsyncNip44 + ?Sized, -{ - let plaintext = encode_blob_plaintext(scope, epoch, new_key, control_pk, control_root)?; - let rotator_pk = rotator.get_public_key_async().await.map_err(crypto_error)?; - - let wrapped = rotator - .nip44_encrypt_async(recipient, &BASE64.encode(&plaintext)) - .await - .map_err(crypto_error)?; - - Ok(RekeyBlob { - locator: blob_locator(&rotator_pk, recipient, scope, epoch), - wrapped, - }) -} - -pub async fn open_blob( - recipient: &S, - rotator: &PublicKey, - scope: RekeyScope, - epoch: Epoch, - blob: &RekeyBlob, - community_id: &CommunityId, -) -> Result -where - S: AsyncNip44 + ?Sized, -{ - let text = recipient - .nip44_decrypt_async(rotator, &blob.wrapped) - .await - .map_err(crypto_error)?; - let plaintext = BASE64.decode(text.as_bytes()).map_err(crypto_error)?; - - parse_blob_plaintext(&plaintext, scope, epoch, community_id) -} - -pub fn find_my_blobs<'a>( - blobs: &'a [RekeyBlob], - rotator: &PublicKey, - me: &PublicKey, - scope: RekeyScope, - epoch: Epoch, -) -> impl Iterator { - let wanted = blob_locator(rotator, me, scope, epoch); - blobs.iter().filter(move |blob| blob.locator == wanted) -} - -#[derive(Debug, Clone)] -pub struct RekeyChunk { - pub rotator: PublicKey, - pub scope: RekeyScope, - pub new_epoch: Epoch, - pub prev_epoch: Epoch, - pub prev_commit: [u8; 32], - pub chunk: (u32, u32), - pub blobs: Vec, - pub citation: Option, - pub severed: bool, -} - -/// The key that groups the chunks of one rotation. -pub type RotationKey = ([u8; 32], [u8; 32], u64, [u8; 32]); - -impl RekeyChunk { - pub fn correlation(&self) -> RotationKey { - ( - self.rotator.to_bytes(), - self.scope.id32(), - self.new_epoch.0, - self.prev_commit, - ) - } -} - -#[derive(Debug, Clone)] -pub struct Rotation { - pub rotator: PublicKey, - pub scope: RekeyScope, - pub new_epoch: Epoch, - pub prev_epoch: Epoch, - pub prev_commit: [u8; 32], - pub blobs: Vec, - pub declared: u32, - pub held: BTreeSet, - pub severed: bool, - pub citation: Option, -} - -impl Rotation { - /// Every declared index held. A missing chunk is never a removal. - pub fn is_complete(&self) -> bool { - self.declared >= 1 && (1..=self.declared).all(|index| self.held.contains(&index)) - } - - pub fn continuity(&self, held_epoch: Epoch, held_key: &[u8; 32]) -> Continuity { - continuity(self.prev_epoch, &self.prev_commit, held_epoch, held_key) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Continuity { - Extends, - Gap, - Fork, -} - -pub fn collect_rotations(chunks: &[RekeyChunk]) -> Vec { - let mut by_key: BTreeMap = BTreeMap::new(); - - for chunk in chunks { - let rotation = by_key - .entry(chunk.correlation()) - .or_insert_with(|| Rotation { - rotator: chunk.rotator, - scope: chunk.scope, - new_epoch: chunk.new_epoch, - prev_epoch: chunk.prev_epoch, - prev_commit: chunk.prev_commit, - blobs: Vec::new(), - declared: chunk.chunk.1, - held: BTreeSet::new(), - severed: chunk.severed, - citation: chunk.citation, - }); - - rotation.severed |= chunk.severed; - rotation.held.insert(chunk.chunk.0); - - for blob in &chunk.blobs { - if !rotation.blobs.iter().any(|held| held == blob) { - rotation.blobs.push(blob.clone()); - } - } - } - - by_key.into_values().collect() -} - -/// `None` until every chunk is held: an incomplete set is never a removal. -pub fn am_i_removed(rotation: &Rotation, me: &PublicKey) -> Option { - if !rotation.is_complete() { - return None; - } - - Some( - find_my_blobs( - &rotation.blobs, - &rotation.rotator, - me, - rotation.scope, - rotation.new_epoch, - ) - .next() - .is_none(), - ) -} - -fn continuity( - prev_epoch: Epoch, - prev_commit: &[u8; 32], - held_epoch: Epoch, - held_key: &[u8; 32], -) -> Continuity { - if prev_epoch.0 == held_epoch.0 { - return if epoch_key_commitment(held_epoch, held_key) == *prev_commit { - Continuity::Extends - } else { - Continuity::Fork - }; - } - - if prev_epoch.0 > held_epoch.0 { - Continuity::Gap - } else { - Continuity::Fork - } -} - -pub fn fork_winner(held: Option<&[u8; 32]>, candidates: &[[u8; 32]]) -> Option { - let (index, winner) = candidates.iter().enumerate().min_by_key(|(_, key)| **key)?; - - match held { - Some(held) if winner >= held => None, - _ => Some(index), - } -} - -pub fn rekey_authorized( - roles: &CommunityRoles, - owner: &PublicKey, - rotator: &PublicKey, - permission: u64, - removed: &[PublicKey], -) -> bool { - roles.is_authorized(rotator, owner, permission) - && removed - .iter() - .all(|target| roles.can_act_on_member(rotator, owner, target, permission)) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Refounding { - pub epoch: Epoch, - pub new_root: [u8; 32], - pub new_control_root: [u8; 32], -} - -impl Refounding { - pub fn read(&self, community_id: &CommunityId) -> Result { - control_group_key(&self.new_root, community_id, self.epoch) - } - - pub fn signer(&self, community_id: &CommunityId) -> Result { - control_signer_group_key(&self.new_control_root, community_id, self.epoch) - } -} - -pub fn plan_refounding(epoch: Epoch) -> Result { - Ok(Refounding { - epoch, - new_root: random_32()?, - new_control_root: random_32()?, - }) -} - -/// The material one rotation delivers, by scope. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum RotationPlan { - /// A refounding: a fresh root, with the root that reads and signs under it. - Base(Refounding), - /// A channel: a fresh channel key, and nothing beside it. - Channel { epoch: Epoch, new_key: [u8; 32] }, -} - -impl RotationPlan { - pub fn epoch(&self) -> Epoch { - match self { - Self::Base(refounding) => refounding.epoch, - Self::Channel { epoch, .. } => *epoch, - } - } - - /// The secret the rotation delivers, which becomes the scope's new read key. - pub fn new_key(&self) -> [u8; 32] { - match self { - Self::Base(refounding) => refounding.new_root, - Self::Channel { new_key, .. } => *new_key, - } - } -} - -/// Mint what a rotation of `scope` delivers at `epoch`. -pub fn plan_rotation(scope: RekeyScope, epoch: Epoch) -> Result { - match scope { - RekeyScope::Base => Ok(RotationPlan::Base(plan_refounding(epoch)?)), - RekeyScope::Channel(_) => Ok(RotationPlan::Channel { - epoch, - new_key: random_32()?, - }), - } -} - -/// Carries the settled heads across a refounding. -pub fn compact( - seals: &[Event], - read: &GroupKey, - signer: &GroupKey, - at_secs: u64, -) -> Result, RekeyError> { - let at = Timestamp::from_secs(at_secs); - let mut wraps = Vec::with_capacity(seals.len()); - - for seal in seals { - wraps.push(cord01::rewrap_seal(seal, read, signer, at)?.0); - } - - Ok(wraps) -} - -#[allow(clippy::too_many_arguments)] -pub fn build_rekey_rumor( - rotator: PublicKey, - scope: RekeyScope, - new_epoch: Epoch, - prev_epoch: Epoch, - prev_commit: &[u8; 32], - blobs: &[RekeyBlob], - chunk: (u32, u32), - citation: Option<&AuthorityCitation>, - severed: bool, - at_secs: u64, -) -> Result { - if new_epoch.0 <= prev_epoch.0 { - return Err(RekeyError::NonMonotonicEpoch); - } - - if new_epoch.0 > MAX_REKEY_EPOCH { - return Err(RekeyError::EpochTooLarge(new_epoch.0)); - } - - if chunk.1 < 1 || chunk.0 < 1 || chunk.0 > chunk.1 { - return Err(RekeyError::BadChunkIndex); - } - - if blobs.len() > MAX_REKEY_BLOBS_PER_EVENT { - return Err(RekeyError::TooManyBlobs(blobs.len())); - } - - let content = serde_json::to_string(blobs).map_err(json_error)?; - - let mut tags = vec![ - Tag::custom(TAG_SCOPE, [scope.to_hex()]), - Tag::custom(TAG_NEW_EPOCH, [new_epoch.0.to_string()]), - Tag::custom(TAG_PREV_EPOCH, [prev_epoch.0.to_string()]), - Tag::custom(TAG_PREV_COMMIT, [HEXLOWER.encode(prev_commit)]), - Tag::custom(TAG_CHUNK, [chunk.0.to_string(), chunk.1.to_string()]), - ]; - - if let Some(citation) = citation { - tags.push(citation_tag(citation)); - } - - if severed { - tags.push(Tag::custom(TAG_SEVER, ["1"])); - } - - Ok(cord01::build_rumor_secs( - KIND_REKEY, rotator, &content, tags, at_secs, - )) -} - -#[allow(clippy::too_many_arguments)] -pub async fn build_rekey_chunks( - rotator: &S, - group: &GroupKey, - scope: RekeyScope, - new_epoch: Epoch, - prev_epoch: Epoch, - prev_commit: &[u8; 32], - blobs: &[RekeyBlob], - citation: Option<&AuthorityCitation>, - severed: bool, - at_secs: u64, -) -> Result, RekeyError> -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let rotator_key = rotator.get_public_key_async().await.map_err(crypto_error)?; - let mut groups: Vec<&[RekeyBlob]> = blobs.chunks(MAX_REKEY_BLOBS_PER_EVENT).collect(); - - if groups.is_empty() { - groups.push(&[]); - } - - let total = groups.len() as u32; - let mut chunks = Vec::with_capacity(groups.len()); - - for (index, group_blobs) in groups.into_iter().enumerate() { - let rumor = build_rekey_rumor( - rotator_key, - scope, - new_epoch, - prev_epoch, - prev_commit, - group_blobs, - (index as u32 + 1, total), - citation, - severed, - at_secs, - )?; - - let seal = cord01::build_seal(&rumor, SealForm::Encrypted, group, rotator).await?; - let (wrap, _) = cord01::wrap_seal( - &seal, - group, - cord01::KIND_WRAP, - Timestamp::from_secs(at_secs), - &[], - )?; - - chunks.push(wrap); - } - - Ok(chunks) -} - -pub fn parse_rekey_chunk(opened: &OpenedStream) -> Result { - if opened.seal_form != SealForm::Encrypted { - // A plaintext-sealed rekey would be a public artifact anyone could lift. - return Err(RekeyError::Stream(StreamError::BadSealKind( - KIND_SEAL_PLAINTEXT, - ))); - } - - let rumor = &opened.rumor; - - if rumor.kind.as_u16() != KIND_REKEY { - return Err(RekeyError::NotARekey(rumor.kind.as_u16())); - } - - let scope = tag(rumor, TAG_SCOPE)? - .and_then(|fields| fields.get(1)) - .and_then(|raw| RekeyScope::from_hex(raw)) - .ok_or(RekeyError::BadTag(TAG_SCOPE))?; - - let new_epoch = Epoch(decimal(rumor, TAG_NEW_EPOCH)?); - let prev_epoch = Epoch(decimal(rumor, TAG_PREV_EPOCH)?); - - if new_epoch.0 <= prev_epoch.0 { - return Err(RekeyError::NonMonotonicEpoch); - } - - if new_epoch.0 > MAX_REKEY_EPOCH { - return Err(RekeyError::EpochTooLarge(new_epoch.0)); - } - - let prev_commit = tag(rumor, TAG_PREV_COMMIT)? - .and_then(|fields| fields.get(1)) - .and_then(|raw| crate::decode_hex_32(raw).ok()) - .ok_or(RekeyError::BadTag(TAG_PREV_COMMIT))?; - - let blobs: Vec = - serde_json::from_str(&rumor.content).map_err(|_| RekeyError::BadTag("blobs"))?; - - if blobs.len() > MAX_REKEY_BLOBS_RECEIVED { - return Err(RekeyError::TooManyBlobs(blobs.len())); - } - - let severed = match tag(rumor, TAG_SEVER)? { - None => false, - Some(fields) if fields.get(1).map(String::as_str) == Some("1") => true, - Some(_) => return Err(RekeyError::BadTag(TAG_SEVER)), - }; - - Ok(RekeyChunk { - rotator: opened.author, - scope, - new_epoch, - prev_epoch, - prev_commit, - chunk: parse_chunk(rumor)?, - blobs, - citation: tag(rumor, crate::cord04::TAG_CITATION)?.and_then(citation_from), - severed, - }) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DissolvedTombstone { - pub owner: PublicKey, -} - -pub fn dissolved_tombstone_rumor( - owner: PublicKey, - community_id: &CommunityId, - at_secs: u64, -) -> UnsignedEvent { - cord01::build_rumor_secs( - KIND_CONTROL, - owner, - "", - vec![ - Tag::custom(TAG_SUBKIND, [vsk::DISSOLVED]), - Tag::custom(TAG_EID, [HEXLOWER.encode(community_id.as_bytes())]), - ], - at_secs, - ) -} - -pub async fn seal_dissolved( - rumor: &UnsignedEvent, - community_id: &CommunityId, - owner: &S, - at_secs: u64, -) -> Result -where - S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, -{ - let group = dissolved_group_key(community_id).map_err(crypto_error)?; - let seal = cord01::build_seal(rumor, SealForm::Plaintext, &group, owner).await?; - let (wrap, _) = cord01::wrap_seal( - &seal, - &group, - cord01::KIND_WRAP, - Timestamp::from_secs(at_secs), - &[], - )?; - - Ok(wrap) -} - -/// Proves the seal signature and the tombstone shape, but not the owner. -pub fn open_dissolved( - wrap: &Event, - community_id: &CommunityId, -) -> Result { - let group = dissolved_group_key(community_id).map_err(crypto_error)?; - let opened = cord01::open_wrap(wrap, &group)?; - - if !is_tombstone(&opened.rumor, community_id) { - return Err(RekeyError::NotADissolution); - } - - Ok(DissolvedTombstone { - owner: opened.author, - }) -} - -/// Fail-closed: an unverifiable or foreign-signed tombstone is not death. -pub fn verify_dissolved(wrap: &Event, identity: &CommunityIdentity) -> bool { - if !identity.verify() { - return false; - } - - matches!( - open_dissolved(wrap, &identity.community_id), - Ok(tombstone) if tombstone.owner == identity.owner - ) -} - -fn is_tombstone(rumor: &UnsignedEvent, community_id: &CommunityId) -> bool { - let eid = HEXLOWER.encode(community_id.as_bytes()); - - rumor.kind.as_u16() == KIND_CONTROL - && value(rumor, TAG_SUBKIND) == Some(vsk::DISSOLVED) - && value(rumor, TAG_EID) == Some(eid.as_str()) -} - -fn value<'a>(rumor: &'a UnsignedEvent, name: &'static str) -> Option<&'a str> { - tag(rumor, name) - .ok() - .flatten() - .and_then(|fields| fields.get(1)) - .map(String::as_str) -} - -fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, RekeyError> { - 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(RekeyError::BadTag(name)); - } - - found = Some(fields); - } - - Ok(found) -} - -fn decimal(rumor: &UnsignedEvent, name: &'static str) -> Result { - tag(rumor, name)? - .and_then(|fields| fields.get(1)) - .and_then(|raw| canonical_decimal(raw)) - .ok_or(RekeyError::BadTag(name)) -} - -fn parse_chunk(rumor: &UnsignedEvent) -> Result<(u32, u32), RekeyError> { - let fields = tag(rumor, TAG_CHUNK)?.ok_or(RekeyError::BadTag(TAG_CHUNK))?; - - let index = as_u32(fields.get(1), TAG_CHUNK)?; - let total = as_u32(fields.get(2), TAG_CHUNK)?; - - if total < 1 || index < 1 || index > total { - return Err(RekeyError::BadChunkIndex); - } - - Ok((index, total)) -} - -fn as_u32(raw: Option<&String>, name: &'static str) -> Result { - let value = raw - .and_then(|raw| canonical_decimal(raw)) - .ok_or(RekeyError::BadTag(name))?; - - u32::try_from(value).map_err(|_| RekeyError::BadTag(name)) -} - -fn json_error(error: serde_json::Error) -> RekeyError { - RekeyError::Json(error.to_string()) -} - -fn crypto_error(error: impl fmt::Display) -> RekeyError { - RekeyError::Crypto(error.to_string()) -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeSet; - - use nostr_sdk::prelude::Keys; - - use super::*; - use crate::cord01::KIND_WRAP; - use crate::cord02::{ - CommunityMetadata, ControlWriter, Edition, ROOT_EPOCH, fold_control, genesis, open_edition, - }; - use crate::cord04::roles::{Grant, Permissions, Role, RoleScope}; - use crate::cord04::{EditionFields, Floors, build_edition}; - use crate::derive::{community_id_of, grant_locator}; - use crate::{Extra, RoleId}; - - const AT: u64 = 1_700_000_000; - const ROOT: [u8; 32] = [0x55; 32]; - const PRIOR_KEY: [u8; 32] = [0xEE; 32]; - - fn channel() -> ChannelId { - ChannelId::from_bytes([0x42; 32]) - } - - fn community() -> CommunityId { - CommunityId::from_bytes([0x77; 32]) - } - - fn chunk_at( - rotator: &Keys, - scope: RekeyScope, - new_epoch: u64, - prev_epoch: u64, - prev_key: &[u8; 32], - blobs: Vec, - chunk: (u32, u32), - ) -> RekeyChunk { - RekeyChunk { - rotator: rotator.public_key(), - scope, - new_epoch: Epoch(new_epoch), - prev_epoch: Epoch(prev_epoch), - prev_commit: epoch_key_commitment(Epoch(prev_epoch), prev_key), - chunk, - blobs, - citation: None, - severed: false, - } - } - - #[test] - fn a_blob_binds_its_scope_and_the_width_declares_the_base_form() { - let rotator = Keys::generate(); - let recipient = Keys::generate(); - let community_id = community(); - let epoch = Epoch(3); - let key = [0xABu8; 32]; - let scope = RekeyScope::Channel(channel()); - - let open = |keys: &Keys, scope: RekeyScope, epoch: Epoch, blob: &RekeyBlob| { - smol::block_on(open_blob( - keys, - &rotator.public_key(), - scope, - epoch, - blob, - &community_id, - )) - }; - let blob = smol::block_on(build_blob( - &rotator, - &recipient.public_key(), - scope, - epoch, - &key, - None, - None, - )) - .expect("builds"); - - assert_eq!( - blob.locator, - blob_locator(&rotator.public_key(), &recipient.public_key(), scope, epoch) - ); - - let delivery = open(&recipient, scope, epoch, &blob).expect("opens"); - assert_eq!(delivery.new_key, key); - assert_eq!(delivery.control_pk, None); - - // The locator is a public lookup index, so an outsider computes it and - // still cannot open: the pairwise decrypt is the gate. - let outsider = Keys::generate(); - assert!(open(&outsider, scope, epoch, &blob).is_err()); - - assert!(matches!( - open(&recipient, RekeyScope::Base, epoch, &blob), - Err(RekeyError::ScopeSplice) - )); - assert!(matches!( - open(&recipient, scope, Epoch(4), &blob), - Err(RekeyError::EpochSplice) - )); - - let control_root = [0x5Cu8; 32]; - let control_pk = control_signer_group_key(&control_root, &community_id, epoch) - .expect("derives") - .pk() - .to_bytes(); - - let base = |pk: Option<&[u8; 32]>, root: Option<&[u8; 32]>| { - smol::block_on(build_blob( - &rotator, - &recipient.public_key(), - RekeyScope::Base, - epoch, - &key, - pk, - root, - )) - .expect("builds") - }; - - for (pk, root, expected_pk, expected_root) in [ - (None, None, None, None), - (Some(&control_pk), None, Some(control_pk), None), - ( - Some(&control_pk), - Some(&control_root), - Some(control_pk), - Some(control_root), - ), - ] { - let delivery = - open(&recipient, RekeyScope::Base, epoch, &base(pk, root)).expect("opens"); - - assert_eq!(delivery.new_key, key); - assert_eq!(delivery.control_pk, expected_pk); - assert_eq!(delivery.control_root, expected_root); - } - - // A staff secret that does not derive to the pk beside it refuses the whole - // blob, rather than adopting a control plane split from its readers. - let forged = encode_blob_plaintext( - RekeyScope::Base, - epoch, - &key, - Some(&control_pk), - Some(&[0x11; 32]), - ) - .expect("encodes"); - assert!(matches!( - parse_blob_plaintext(&forged, RekeyScope::Base, epoch, &community_id), - Err(RekeyError::ControlPairMismatch) - )); - - // A width between the defined forms fits no append-only extension. - let staff = encode_blob_plaintext( - RekeyScope::Base, - epoch, - &key, - Some(&control_pk), - Some(&control_root), - ) - .expect("encodes"); - - for width in [73usize, 105, 135] { - let mut bytes = staff.clone(); - bytes.truncate(width); - - assert!(matches!( - parse_blob_plaintext(&bytes, RekeyScope::Base, epoch, &community_id), - Err(RekeyError::BadBaseBlobWidth(len)) if len == width - )); - } - - assert!(matches!( - encode_blob_plaintext(RekeyScope::Base, epoch, &key, None, Some(&control_root)), - Err(RekeyError::MisplacedControlKey) - )); - } - - #[test] - fn a_rekey_round_trips_and_only_a_complete_rotation_concludes_a_removal() { - let rotator = Keys::generate(); - let me = Keys::generate(); - let other = Keys::generate(); - let scope = RekeyScope::Channel(channel()); - let epoch = Epoch(1); - let community_id = community(); - - let blob_for = |recipient: &Keys, key: [u8; 32]| { - smol::block_on(build_blob( - &rotator, - &recipient.public_key(), - scope, - epoch, - &key, - None, - None, - )) - .expect("builds") - }; - let mine = blob_for(&me, [0xAA; 32]); - let theirs = blob_for(&other, [0xBB; 32]); - - let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); - let prior_commit = epoch_key_commitment(Epoch(0), &PRIOR_KEY); - let chunks = smol::block_on(build_rekey_chunks( - &rotator, - &group, - scope, - epoch, - Epoch(0), - &prior_commit, - &[mine.clone(), theirs.clone()], - None, - false, - AT, - )) - .expect("builds"); - assert_eq!(chunks.len(), 1); - - let opened = cord01::open_wrap(&chunks[0], &group).expect("opens"); - let chunk = parse_rekey_chunk(&opened).expect("parses"); - assert_eq!( - chunk.rotator, - rotator.public_key(), - "the seal names the rotator" - ); - assert_eq!(chunk.scope, scope); - assert_eq!((chunk.new_epoch, chunk.prev_epoch), (epoch, Epoch(0))); - assert_eq!(chunk.prev_commit, prior_commit); - assert_eq!( - chunk.prev_commit, - epoch_key_commitment(Epoch(0), &PRIOR_KEY) - ); - assert_eq!(chunk.chunk, (1, 1)); - assert_eq!(chunk.blobs, vec![mine.clone(), theirs.clone()]); - - // One of two chunks held, and it lacks my blob: not answerable yet. - let first = chunk_at( - &rotator, - scope, - 1, - 0, - &PRIOR_KEY, - vec![theirs.clone()], - (1, 2), - ); - let rotations = collect_rotations(std::slice::from_ref(&first)); - assert!(!rotations[0].is_complete()); - assert_eq!(am_i_removed(&rotations[0], &me.public_key()), None); - - // The second arrives with my blob: complete, and I am retained. - let second = chunk_at( - &rotator, - scope, - 1, - 0, - &PRIOR_KEY, - vec![mine.clone()], - (2, 2), - ); - let rotations = collect_rotations(&[first, second]); - assert!(rotations[0].is_complete()); - assert_eq!(am_i_removed(&rotations[0], &me.public_key()), Some(false)); - - let located = find_my_blobs( - &rotations[0].blobs, - &rotator.public_key(), - &me.public_key(), - scope, - epoch, - ) - .next() - .expect("located"); - assert_eq!( - smol::block_on(open_blob( - &me, - &rotator.public_key(), - scope, - epoch, - located, - &community_id - )) - .expect("opens") - .new_key, - [0xAA; 32] - ); - - // A complete rotation carrying only someone else's blob is a removal. - let alone = chunk_at( - &rotator, - scope, - 1, - 0, - &PRIOR_KEY, - vec![theirs.clone()], - (1, 1), - ); - assert_eq!( - am_i_removed(&collect_rotations(&[alone])[0], &me.public_key()), - Some(true) - ); - - // Two chunks claiming one index union their blobs. Dropping the loser's - // blobs would delete its recipients from the union, and a recipient with no - // blob reads as removed. - let reclaim = chunk_at(&rotator, scope, 1, 0, &PRIOR_KEY, vec![theirs], (1, 1)); - let original = chunk_at(&rotator, scope, 1, 0, &PRIOR_KEY, vec![mine], (1, 1)); - - for order in [ - vec![original.clone(), reclaim.clone()], - vec![reclaim, original], - ] { - let rotations = collect_rotations(&order); - assert_eq!(rotations.len(), 1); - assert_eq!( - am_i_removed(&rotations[0], &me.public_key()), - Some(false), - "an index collision must never fabricate a removal" - ); - } - } - - #[test] - fn a_severed_rotation_is_not_laundered_by_an_unmarked_sibling() { - let rotator = Keys::generate(); - let scope = RekeyScope::Base; - let mut marked = chunk_at(&rotator, scope, 2, 1, &PRIOR_KEY, vec![], (1, 2)); - marked.severed = true; - let unmarked = chunk_at(&rotator, scope, 2, 1, &PRIOR_KEY, vec![], (2, 2)); - - for order in [ - vec![marked.clone(), unmarked.clone()], - vec![unmarked, marked], - ] { - let rotations = collect_rotations(&order); - assert_eq!(rotations.len(), 1); - assert!(rotations[0].severed); - assert!(rotations[0].is_complete()); - } - - // Two rotators racing one epoch, and one rotator over two scopes, never merge. - let other = Keys::generate(); - let rotations = collect_rotations(&[ - chunk_at(&rotator, RekeyScope::Base, 2, 1, &PRIOR_KEY, vec![], (1, 1)), - chunk_at(&other, RekeyScope::Base, 2, 1, &PRIOR_KEY, vec![], (1, 1)), - chunk_at( - &rotator, - RekeyScope::Channel(channel()), - 2, - 1, - &PRIOR_KEY, - vec![], - (1, 1), - ), - ]); - assert_eq!(rotations.len(), 3); - } - - #[test] - fn continuity_gaps_and_forks_and_the_winner_heals_only_downward() { - let rotator = Keys::generate(); - let held = [0x33u8; 32]; - - let extends = chunk_at(&rotator, RekeyScope::Base, 3, 2, &held, vec![], (1, 1)); - assert_eq!( - collect_rotations(&[extends])[0].continuity(Epoch(2), &held), - Continuity::Extends - ); - - let ahead = chunk_at(&rotator, RekeyScope::Base, 5, 4, &held, vec![], (1, 1)); - assert_eq!( - collect_rotations(&[ahead])[0].continuity(Epoch(2), &held), - Continuity::Gap - ); - - // The same epoch with a different prior key, and a rotation older than where - // I am, are both forks. - let forked = chunk_at( - &rotator, - RekeyScope::Base, - 3, - 2, - &[0x99; 32], - vec![], - (1, 1), - ); - assert_eq!( - collect_rotations(&[forked])[0].continuity(Epoch(2), &held), - Continuity::Fork - ); - - let stale = chunk_at(&rotator, RekeyScope::Base, 2, 1, &held, vec![], (1, 1)); - assert_eq!( - collect_rotations(&[stale])[0].continuity(Epoch(2), &held), - Continuity::Fork - ); - - // The lowest key wins, and only when it strictly lowers a key already held, - // so a settled epoch re-converges down and never re-forks upward. - let candidates = [[0x03u8; 32], [0x01u8; 32], [0x02u8; 32]]; - assert_eq!(fork_winner(None, &candidates), Some(1)); - assert_eq!(fork_winner(Some(&[0x05u8; 32]), &candidates), Some(1)); - assert_eq!(fork_winner(Some(&[0x00u8; 32]), &candidates), None); - assert_eq!(fork_winner(None, &[]), None); - } - - fn publish_authority( - writer: &ControlWriter, - owner: &Keys, - subkind: &'static str, - entity: [u8; 32], - content: String, - at_secs: u64, - ) -> Event { - smol::block_on(writer.publish( - owner, - Edition { - subkind, - entity, - content: &content, - head: None, - citation: None, - }, - at_secs, - )) - .expect("publishes") - .0 - } - - #[test] - fn a_rotation_needs_the_permission_and_must_strictly_outrank_every_target() { - let owner = Keys::generate(); - let minted = - smol::block_on(genesis(&owner, &CommunityMetadata::default(), AT)).expect("mints"); - let community_id = minted.identity.community_id; - let read = - control_group_key(&minted.community_root, &community_id, ROOT_EPOCH).expect("derives"); - let signer = control_signer_group_key(&minted.control_root, &community_id, ROOT_EPOCH) - .expect("derives"); - let writer = ControlWriter { - author: owner.public_key(), - read: read.clone(), - signer: signer.clone(), - }; - - let senior = Keys::generate(); - let junior = Keys::generate(); - let target = Keys::generate(); - let mut wraps = Vec::new(); - - for (index, position, member, permissions) in [ - ( - 0u64, - 1u32, - &senior, - Permissions::MANAGE_CHANNELS | Permissions::BAN, - ), - (1, 3, &junior, Permissions::MANAGE_CHANNELS), - ] { - let role_id = RoleId::from_bytes([index as u8 + 1; 32]); - let role = Role { - role_id, - name: "role".to_owned(), - position, - permissions: Permissions(permissions), - scope: RoleScope::Server, - color: 0, - extra: Extra::default(), - }; - let grant = Grant { - member: member.public_key(), - role_ids: vec![role_id], - control_wrap: None, - extra: Extra::default(), - }; - - wraps.push(publish_authority( - &writer, - &owner, - vsk::ROLE, - *role_id.as_bytes(), - role.to_content().expect("serializes"), - AT + index, - )); - wraps.push(publish_authority( - &writer, - &owner, - vsk::GRANT, - grant_locator(&community_id, &member.public_key().to_bytes()), - grant.to_content().expect("serializes"), - AT + 10 + index, - )); - } - - let editions: Vec<_> = minted - .wraps - .iter() - .chain(wraps.iter()) - .map(|wrap| open_edition(wrap, &read, &signer.pk(), true).expect("opens")) - .collect(); - - let roles = fold_control( - &owner.public_key(), - &community_id, - &editions, - &Floors::new(), - &BTreeSet::new(), - ) - .roles; - let owner_pk = owner.public_key(); - let authorized = |rotator: &PublicKey, permission: u64, removed: &[PublicKey]| { - rekey_authorized(&roles, &owner_pk, rotator, permission, removed) - }; - let targets = [target.public_key()]; - - // The owner needs no grant. - assert!(authorized(&owner_pk, Permissions::BAN, &targets)); - - // The senior may refound; the junior holds only MANAGE_CHANNELS. - assert!(authorized(&senior.public_key(), Permissions::BAN, &targets)); - assert!(authorized( - &junior.public_key(), - Permissions::MANAGE_CHANNELS, - &targets - )); - assert!(!authorized( - &junior.public_key(), - Permissions::BAN, - &targets - )); - - // Strictly outrank: equal or above cannot rotate the other out. - assert!(!authorized( - &junior.public_key(), - Permissions::MANAGE_CHANNELS, - &[senior.public_key()] - )); - - // Holding a key is never authority. - assert!(!authorized( - &target.public_key(), - Permissions::MANAGE_CHANNELS, - &[] - )); - } - - #[test] - fn a_full_send_chunk_stays_within_a_relay_event() { - let rotator = Keys::generate(); - let community_id = community(); - let epoch = Epoch(1); - let scope = RekeyScope::Base; - let group = rekey_group(scope, &ROOT, &community_id, epoch).expect("derives"); - - let blobs: Vec = (0..MAX_REKEY_BLOBS_PER_EVENT) - .map(|_| { - let member = Keys::generate(); - - smol::block_on(build_blob( - &rotator, - &member.public_key(), - scope, - epoch, - &[0xCD; 32], - None, - None, - )) - .expect("builds") - }) - .collect(); - - let chunks = smol::block_on(build_rekey_chunks( - &rotator, - &group, - scope, - epoch, - Epoch(0), - &PRIOR_KEY, - &blobs, - None, - false, - AT, - )) - .expect("builds"); - - assert_eq!(chunks.len(), 1, "a full send chunk is one event"); - assert!( - chunks[0].as_json().len() <= 65_536, - "a full chunk must fit a 64 KB relay event" - ); - - let mut over = blobs; - over.push(RekeyBlob { - locator: "aa".repeat(32), - wrapped: "x".to_owned(), - }); - - let chunks = smol::block_on(build_rekey_chunks( - &rotator, - &group, - scope, - epoch, - Epoch(0), - &PRIOR_KEY, - &over, - None, - false, - AT, - )) - .expect("builds"); - - assert_eq!(chunks.len(), 2, "one over the cap splits across two events"); - } - - #[test] - fn compaction_carries_a_settled_head_with_its_signature_intact() { - let owner = Keys::generate(); - let community_id = community(); - let prior_read = control_group_key(&[0x01; 32], &community_id, Epoch(0)).expect("derives"); - - let rumor = build_edition(EditionFields { - author: owner.public_key(), - subkind: vsk::COMMUNITY_METADATA, - entity: *community_id.as_bytes(), - version: 1, - prev: None, - citation: None, - content: "{}", - at_secs: AT, - }); - let seal = smol::block_on(cord01::build_seal( - &rumor, - SealForm::Plaintext, - &prior_read, - &owner, - )) - .expect("seals"); - - let refounding = plan_refounding(Epoch(1)).expect("plans"); - let read = refounding.read(&community_id).expect("derives"); - let signer = refounding.signer(&community_id).expect("derives"); - assert_ne!(refounding.new_root, refounding.new_control_root); - - let compacted = - compact(std::slice::from_ref(&seal), &read, &signer, AT + 1).expect("compacts"); - assert_eq!(compacted.len(), 1); - - let reopened = cord01::open_wrap_at(&compacted[0], &signer.pk(), read.conversation(), true) - .expect("opens"); - assert_eq!( - reopened.seal.sig, seal.sig, - "the refounder re-signs nothing: the original author's signature rides the new wrap" - ); - assert_eq!(reopened.rumor_id, rumor.id.expect("has an id")); - assert_eq!(reopened.author, owner.public_key()); - - // Only a plaintext seal can be carried forward. - let encrypted = smol::block_on(cord01::build_seal( - &rumor, - SealForm::Encrypted, - &prior_read, - &owner, - )) - .expect("seals"); - assert!(matches!( - compact(&[encrypted], &read, &signer, AT + 1), - Err(RekeyError::Stream(StreamError::NotRewrappable)) - )); - } - - #[test] - fn a_foreign_eid_tombstone_cannot_seal_a_community() { - let owner = Keys::generate(); - let salt = [0x33u8; 32]; - let community_id = community_id_of(&owner.public_key().to_bytes(), &salt); - let identity = CommunityIdentity { - community_id, - owner: owner.public_key(), - owner_salt: salt, - }; - - let rumor = dissolved_tombstone_rumor(owner.public_key(), &community_id, AT); - let wrap = - smol::block_on(seal_dissolved(&rumor, &community_id, &owner, AT)).expect("seals"); - - assert!(verify_dissolved(&wrap, &identity)); - assert_eq!( - open_dissolved(&wrap, &community_id).expect("opens").owner, - owner.public_key() - ); - - // Anyone holding the community id finds the address, but only the committed - // owner's signature counts. - let impostor = Keys::generate(); - let forged = smol::block_on(seal_dissolved( - &dissolved_tombstone_rumor(impostor.public_key(), &community_id, AT), - &community_id, - &impostor, - AT, - )) - .expect("seals"); - assert!(!verify_dissolved(&forged, &identity)); - - // The spec's all-zero `eid` is refused: it would let one owner's genuine - // tombstone be re-wrapped at another of their communities and kill it. - let zeroed = smol::block_on(seal_dissolved( - &cord01::build_rumor_secs( - KIND_CONTROL, - owner.public_key(), - "", - vec![ - Tag::custom(TAG_SUBKIND, [vsk::DISSOLVED]), - Tag::custom(TAG_EID, ["00".repeat(32)]), - ], - AT, - ), - &community_id, - &owner, - AT, - )) - .expect("seals"); - assert!(matches!( - open_dissolved(&zeroed, &community_id), - Err(RekeyError::NotADissolution) - )); - assert!(!verify_dissolved(&zeroed, &identity)); - - // One owner, two communities: lifting X's seal to Y's public address keeps - // the signed `eid` naming X, so Y is not sealed. - let other_salt = [0x44u8; 32]; - let other_id = community_id_of(&owner.public_key().to_bytes(), &other_salt); - let other_identity = CommunityIdentity { - community_id: other_id, - owner: owner.public_key(), - owner_salt: other_salt, - }; - let seal = cord01::open_wrap(&wrap, &dissolved_group_key(&community_id).expect("derives")) - .expect("opens") - .seal; - let replayed = cord01::wrap_seal( - &seal, - &dissolved_group_key(&other_id).expect("derives"), - KIND_WRAP, - Timestamp::from_secs(AT + 1), - &[], - ) - .expect("rewraps") - .0; - - assert!(matches!( - open_dissolved(&replayed, &other_id), - Err(RekeyError::NotADissolution) - )); - assert!(!verify_dissolved(&replayed, &other_identity)); - } -} diff --git a/crates/concord/src/cords/mod.rs b/crates/concord/src/cords/mod.rs deleted file mode 100644 index f83962af..00000000 --- a/crates/concord/src/cords/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -mod rumor; - -pub mod cord01; -pub mod cord02; -pub mod cord03; -pub mod cord04; -pub mod cord05; -pub mod cord06; diff --git a/crates/concord/src/cords/rumor.rs b/crates/concord/src/cords/rumor.rs deleted file mode 100644 index 95d74114..00000000 --- a/crates/concord/src/cords/rumor.rs +++ /dev/null @@ -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 for RumorError { - fn from(error: StreamError) -> Self { - RumorError::Stream(error) - } -} - -pub fn tag<'a>( - rumor: &'a UnsignedEvent, - name: &'static str, -) -> Result, 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 { - 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, RumorError> { - let Some(fields) = tag(rumor, TAG_CITATION)? else { - return Ok(None); - }; - - citation_from(fields) - .map(Some) - .ok_or(RumorError::BadTag(TAG_CITATION)) -} diff --git a/crates/concord/src/lib.rs b/crates/concord/src/lib.rs deleted file mode 100644 index 8b37af57..00000000 --- a/crates/concord/src/lib.rs +++ /dev/null @@ -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}; diff --git a/crates/concord/src/state.rs b/crates/concord/src/state.rs deleted file mode 100644 index aa3f459d..00000000 --- a/crates/concord/src/state.rs +++ /dev/null @@ -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, -} - -/// 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, - /// The publish time of the rotation that superseded this root. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub retired_at: Option, -} - -#[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, -} - -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, - /// The oldest wrap paged back to, so the next round resumes below it. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub oldest: Option, - /// 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, round: Option) -> Option { - match (held, round) { - (Some(held), Some(round)) => Some(held.max(round)), - (held, None) => held, - (None, round) => round, - } -} - -fn earlier(held: Option, round: Option) -> Option { - 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/`. -#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] -pub struct CommunityState { - pub id: CommunityId, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub name: Option, - 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, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub channels: Vec, - pub relays: Vec, - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub heads: Vec, - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - pub banned: BTreeSet, - /// Where each channel's history sync has reached. - #[serde( - default, - rename = "channel_cursors", - skip_serializing_if = "BTreeMap::is_empty" - )] - pub cursors: BTreeMap, - /// Root epochs the community has rotated past that this client still holds. - #[serde(default, skip_serializing_if = "Vec::is_empty")] - pub held_roots: Vec, - /// The epoch a channel rotation removed us at. - #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] - pub channel_cuts: BTreeMap, - /// The npubs whose rotation minted an epoch of this community we verified. - #[serde(default, skip_serializing_if = "BTreeSet::is_empty")] - pub refounders: BTreeSet, - /// The base epoch we were excluded at. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub removed_at: Option, - /// 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 { - 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 { - 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 { - 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 { - let Some(held) = self.channels.iter().find(|held| held.id == *channel) else { - return Vec::new(); - }; - - if held.private { - let mut keys: Vec = 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 { - 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)])); - } -} diff --git a/crates/concord/src/types/mod.rs b/crates/concord/src/types/mod.rs deleted file mode 100644 index 0b9741e3..00000000 --- a/crates/concord/src/types/mod.rs +++ /dev/null @@ -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; - -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 { - Ok(Self(decode_hex_32(value)?)) - } - } - - impl Serialize for $name { - fn serialize(&self, serializer: S) -> Result { - serializer.serialize_str(&self.to_hex()) - } - } - - impl<'de> Deserialize<'de> for $name { - fn deserialize>(deserializer: D) -> Result { - 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) - } -} diff --git a/crates/concord/src/utils/base64url.rs b/crates/concord/src/utils/base64url.rs deleted file mode 100644 index e70ffbfd..00000000 --- a/crates/concord/src/utils/base64url.rs +++ /dev/null @@ -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())) -} diff --git a/crates/concord/src/utils/derive.rs b/crates/concord/src/utils/derive.rs deleted file mode 100644 index 8ee78ad5..00000000 --- a/crates/concord/src/utils/derive.rs +++ /dev/null @@ -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) -> Vec { - 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::::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 { - 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) -> Result { - 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::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::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::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::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::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::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::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 - ); - } -} diff --git a/crates/concord/src/utils/mod.rs b/crates/concord/src/utils/mod.rs deleted file mode 100644 index 85e2b996..00000000 --- a/crates/concord/src/utils/mod.rs +++ /dev/null @@ -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(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 { - 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 { - Ok(HEXLOWER.encode(&base64url::decode_32(value)?)) -} - -/// Canonical JSON bytes: the total-order tie-break every content merge uses. -pub(crate) fn canonical(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); - } - } -} diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 74544e8d..84ba93a7 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,5 +1,5 @@ [toolchain] -channel = "1.96" +channel = "1.98" profile = "minimal" components = ["rustfmt", "clippy"] targets = [