diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index a7b0e9c3..b7f36406 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -64,7 +64,7 @@ jobs: # Download glslc from your server Write-Host "Downloading glslc.exe..." - Invoke-WebRequest -Uri "https://jskitty.cat/glslc.exe" -OutFile "glslc.exe" + Invoke-WebRequest -Uri "https://jskitty.com/glslc.exe" -OutFile "glslc.exe" # Place it in the vcpkg bin directory $vcpkgBin = "C:\vcpkg\installed\x64-windows\bin" diff --git a/crates/Cargo.lock b/crates/Cargo.lock index 803c40c7..9da6b5a5 100644 --- a/crates/Cargo.lock +++ b/crates/Cargo.lock @@ -490,12 +490,6 @@ dependencies = [ "generic-array", ] -[[package]] -name = "blurhash" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" - [[package]] name = "bstr" version = "1.12.1" @@ -639,15 +633,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "cobs" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" -dependencies = [ - "thiserror 2.0.18", -] - [[package]] name = "color_quant" version = "1.1.0" @@ -670,6 +655,15 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "concord-cli" +version = "0.1.0" +dependencies = [ + "nostr-sdk", + "tokio", + "vector-core", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -709,17 +703,6 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" -[[package]] -name = "core-models" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "657f625ff361906f779745d08375ae3cc9fef87a35fba5f22874cf773010daf4" -dependencies = [ - "hax-lib", - "pastey", - "rand 0.9.4", -] - [[package]] name = "cpufeatures" version = "0.2.17" @@ -738,25 +721,6 @@ dependencies = [ "cfg-if", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.12" @@ -1192,8 +1156,6 @@ dependencies = [ "ff", "generic-array", "group", - "hkdf", - "pem-rfc7468", "pkcs8", "rand_core 0.6.4", "sec1", @@ -1201,18 +1163,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "embedded-io" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" - -[[package]] -name = "embedded-io" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" - [[package]] name = "encoding_rs" version = "0.8.35" @@ -1693,43 +1643,6 @@ dependencies = [ "hashbrown 0.14.5", ] -[[package]] -name = "hax-lib" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "543f93241d32b3f00569201bfce9d7a93c92c6421b23c77864ac929dc947b9fc" -dependencies = [ - "hax-lib-macros", - "num-bigint", - "num-traits", -] - -[[package]] -name = "hax-lib-macros" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8755751e760b11021765bb04cb4a6c4e24742688d9f3aa14c2079638f537b0f" -dependencies = [ - "hax-lib-macros-types", - "proc-macro-error2", - "proc-macro2", - "quote", - "syn 2.0.117", -] - -[[package]] -name = "hax-lib-macros-types" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f177c9ae8ea456e2f71ff3c1ea47bf4464f772a05133fcbba56cd5ba169035a2" -dependencies = [ - "proc-macro2", - "quote", - "serde", - "serde_json", - "uuid", -] - [[package]] name = "heck" version = "0.5.0" @@ -1775,69 +1688,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" -[[package]] -name = "hpke-rs" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcd4b22e7fc3318a1674085f943a35794023ecfe8b24a1691d1d1e016f869c8" -dependencies = [ - "hpke-rs-crypto", - "hpke-rs-libcrux", - "hpke-rs-rust-crypto", - "libcrux-sha3", - "log", - "rand_core 0.9.5", - "serde", - "tls_codec", - "zeroize", -] - -[[package]] -name = "hpke-rs-crypto" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2dd92b7d7f0deaae59c152e01c01f5280ea92dfac82090e5c025879b32df9193" -dependencies = [ - "rand_core 0.9.5", -] - -[[package]] -name = "hpke-rs-libcrux" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd99129e6e5ab959fca63fe83aebbd1b5ff1107eeb549dca597b6d9484e51684" -dependencies = [ - "hpke-rs-crypto", - "libcrux-aead", - "libcrux-ecdh", - "libcrux-hkdf", - "libcrux-kem", - "libcrux-traits", - "rand 0.9.4", - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "hpke-rs-rust-crypto" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "019f9a15c71981dffb32882487c372d3e6e48557c1c1ac84f235cbded330a2ef" -dependencies = [ - "aes-gcm", - "chacha20poly1305", - "hkdf", - "hpke-rs-crypto", - "k256", - "p256", - "p384", - "rand 0.8.6", - "rand_chacha 0.3.1", - "rand_core 0.6.4", - "sha2", - "x25519-dalek", -] - [[package]] name = "http" version = "1.4.0" @@ -2252,25 +2102,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "k256" -version = "0.13.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6e3919bbaa2945715f0bb6d3934a173d1e9a59ac23767fbaaef277265a7411b" -dependencies = [ - "cfg-if", - "elliptic-curve", -] - -[[package]] -name = "kamadak-exif" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" -dependencies = [ - "mutate_once", -] - [[package]] name = "keccak" version = "0.1.6" @@ -2280,15 +2111,6 @@ dependencies = [ "cpufeatures", ] -[[package]] -name = "keyring-core" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d8286c3ab222af2fa9e57874fe419893d967f739c7bf1743f98a88678edbf08" -dependencies = [ - "log", -] - [[package]] name = "kqueue" version = "1.1.1" @@ -2330,222 +2152,6 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" -[[package]] -name = "libcrux-aead" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31ca5c9cb6a0f4dcf2bab1b85aa302537f40b801fc5efe10b5b76fbd677e8161" -dependencies = [ - "libcrux-aesgcm", - "libcrux-chacha20poly1305", - "libcrux-secrets", - "libcrux-traits", -] - -[[package]] -name = "libcrux-aesgcm" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95d897badc420310155f90ed1ea48872809c3446c94ebb116e8a810b66651623" -dependencies = [ - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-traits", -] - -[[package]] -name = "libcrux-chacha20poly1305" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6070c5d3991e208511daaf0efae2c747b14a8c136718a3a0a474a82cc0c45522" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-poly1305", - "libcrux-secrets", - "libcrux-traits", -] - -[[package]] -name = "libcrux-curve25519" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "552571ff92bcdf2992b61b600c74d2eaba2c42a14d478c1e9e29391c39db8761" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-secrets", - "libcrux-traits", -] - -[[package]] -name = "libcrux-ecdh" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1fceb737840ec67255068f6d90e9782ae17fad2337aeb7d7203d76560966216" -dependencies = [ - "libcrux-curve25519", - "libcrux-p256", - "rand 0.9.4", -] - -[[package]] -name = "libcrux-hacl-rs" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2637dc87d158e1f1b550fd9b226443e84153fded4de69028d897b534d16d22e6" -dependencies = [ - "libcrux-macros", -] - -[[package]] -name = "libcrux-hkdf" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "295d04515de24bb0f81e5c46d79949517b66ba6a4aaf24328764c6f999e01e36" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-hmac", - "libcrux-secrets", -] - -[[package]] -name = "libcrux-hmac" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d081af93c27d7cebc9a8cc4b3720cba5411186297f9adeddf853d994bba4e7b" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-sha2", -] - -[[package]] -name = "libcrux-intrinsics" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aa4779454e853d1de200cd12f19a8185aac47d99a5ec404cea3295c943d48f1" -dependencies = [ - "core-models", - "hax-lib", -] - -[[package]] -name = "libcrux-kem" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34adb7fdaddd04136e7b4b7368e680f0bca8f1392dfafbb7cb809148c6eb48c7" -dependencies = [ - "libcrux-curve25519", - "libcrux-ecdh", - "libcrux-ml-kem", - "libcrux-p256", - "libcrux-sha3", - "libcrux-traits", - "rand 0.9.4", -] - -[[package]] -name = "libcrux-macros" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd6aa2dcd5be681662001b81d493f1569c6d49a32361f470b0c955465cd0338" -dependencies = [ - "quote", - "syn 2.0.117", -] - -[[package]] -name = "libcrux-ml-kem" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a930ff130a63e9d89648d0e22203ca034995191cbfa606b9f3c151ba67306963" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-secrets", - "libcrux-sha3", - "libcrux-traits", - "rand 0.9.4", - "tls_codec", -] - -[[package]] -name = "libcrux-p256" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a3d3d7567b86434b34a98faf19ce5a4dd20f964e0d9a2d13f02792b4ad0109" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-secrets", - "libcrux-sha2", - "libcrux-traits", -] - -[[package]] -name = "libcrux-platform" -version = "0.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d9e21d7ed31a92ac539bd69a8c970b183ee883872d2d19ce27036e24cb8ecc4" -dependencies = [ - "libc", -] - -[[package]] -name = "libcrux-poly1305" -version = "0.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccfb6399682b2dee13b728c779ab5dcc51afbe982b63508ca524806994336134" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", -] - -[[package]] -name = "libcrux-secrets" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ce650f3041b44ba40d4263852347d007cd2cd9d1cc856a6f6c8b2e10c3fd40b" -dependencies = [ - "hax-lib", -] - -[[package]] -name = "libcrux-sha2" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9b200262e529493e459609895f3a02434eadb58897352236ebde491b5d6d87" -dependencies = [ - "libcrux-hacl-rs", - "libcrux-macros", - "libcrux-traits", -] - -[[package]] -name = "libcrux-sha3" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3dabce2795479bd7294f853f7966a678cadf7a26d3d29f61cf15f5123e7ba4f" -dependencies = [ - "hax-lib", - "libcrux-intrinsics", - "libcrux-platform", - "libcrux-traits", -] - -[[package]] -name = "libcrux-traits" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695ff2fb97627e4d57315a2fdfbfe50df1c80c6ef7d91ba34216169bd6f41c00" -dependencies = [ - "libcrux-secrets", - "rand 0.9.4", -] - [[package]] name = "libm" version = "0.2.16" @@ -2623,63 +2229,6 @@ dependencies = [ "regex-automata", ] -[[package]] -name = "mdk-core" -version = "0.6.0" -dependencies = [ - "blurhash", - "chacha20poly1305", - "hex", - "hkdf", - "image", - "kamadak-exif", - "mdk-storage-traits", - "nostr", - "openmls", - "openmls_basic_credential", - "openmls_rust_crypto", - "openmls_traits", - "serde", - "sha2", - "thiserror 2.0.18", - "tls_codec", - "tracing", -] - -[[package]] -name = "mdk-sqlite-storage" -version = "0.6.0" -dependencies = [ - "getrandom 0.3.4", - "hex", - "keyring-core", - "mdk-storage-traits", - "nostr", - "openmls", - "openmls_traits", - "refinery", - "rusqlite", - "serde", - "serde_json", - "thiserror 2.0.18", - "tokio", - "tracing", -] - -[[package]] -name = "mdk-storage-traits" -version = "0.6.0" -dependencies = [ - "nostr", - "openmls", - "openmls_traits", - "postcard", - "serde", - "serde_json", - "thiserror 2.0.18", - "zeroize", -] - [[package]] name = "memchr" version = "2.8.0" @@ -2751,12 +2300,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "mutate_once" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af" - [[package]] name = "negentropy" version = "0.5.0" @@ -3075,84 +2618,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" -[[package]] -name = "openmls" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dcb512bfe6a55777518853ea535c6241f069cb0e8984678c117151d2a1e7e903" -dependencies = [ - "log", - "openmls_traits", - "rayon", - "serde", - "serde_bytes", - "thiserror 2.0.18", - "tls_codec", - "zeroize", -] - -[[package]] -name = "openmls_basic_credential" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983e8be1457dd6f316f409292cec334af3b57b49a19deadc925c83c3c35e15b6" -dependencies = [ - "ed25519-dalek", - "openmls_traits", - "p256", - "rand 0.8.6", - "serde", - "tls_codec", -] - -[[package]] -name = "openmls_memory_storage" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a52c927ddb9940acb96d51aebd54b8b9c601c7119e6609622fb3f2cbe16abe3" -dependencies = [ - "log", - "openmls_traits", - "serde", - "serde_json", - "thiserror 2.0.18", -] - -[[package]] -name = "openmls_rust_crypto" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8bc087eeb4cf230327b156014df77464f62ee5fcd682c3292be11fbba8e7811" -dependencies = [ - "aes-gcm", - "chacha20poly1305", - "ed25519-dalek", - "hkdf", - "hmac", - "hpke-rs", - "hpke-rs-crypto", - "hpke-rs-rust-crypto", - "openmls_memory_storage", - "openmls_traits", - "p256", - "rand 0.8.6", - "rand_chacha 0.3.1", - "serde", - "sha2", - "thiserror 2.0.18", - "tls_codec", -] - -[[package]] -name = "openmls_traits" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f88ccdd53448dfdbfa5b8da8ba4e527c418fdb966418172bace2e3b41eedd56" -dependencies = [ - "serde", - "tls_codec", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -3451,18 +2916,6 @@ dependencies = [ "thiserror 1.0.69", ] -[[package]] -name = "postcard" -version = "1.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" -dependencies = [ - "cobs", - "embedded-io 0.4.0", - "embedded-io 0.6.1", - "serde", -] - [[package]] name = "potential_utf" version = "0.1.5" @@ -3733,26 +3186,6 @@ dependencies = [ "winapi", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "rdrand" version = "0.8.3" @@ -3811,49 +3244,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "refinery" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee5133e5b207e5703c2a4a9dc9bd8c8f2cc74c4ac04ca5510acaa907012c77ac" -dependencies = [ - "refinery-core", - "refinery-macros", -] - -[[package]] -name = "refinery-core" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "023a2a96d959c9b5b5da78e965bfdb1363b365bf5e84531a67d0eee827a702a3" -dependencies = [ - "async-trait", - "cfg-if", - "log", - "regex", - "rusqlite", - "serde", - "siphasher", - "thiserror 2.0.18", - "time", - "toml 0.8.23", - "url", - "walkdir", -] - -[[package]] -name = "refinery-macros" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56c2e960c8e47c7c5c30ad334afea8b5502da796a59e34d640d6239d876d924" -dependencies = [ - "proc-macro2", - "quote", - "refinery-core", - "regex", - "syn 2.0.117", -] - [[package]] name = "regex" version = "1.12.3" @@ -4276,16 +3666,6 @@ dependencies = [ "serde", ] -[[package]] -name = "serde_bytes" -version = "0.11.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d440709e79d88e51ac01c4b72fc6cb7314017bb7da9eeff678aa94c10e3ea8" -dependencies = [ - "serde", - "serde_core", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -4846,28 +4226,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" -[[package]] -name = "tls_codec" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" -dependencies = [ - "serde", - "tls_codec_derive", - "zeroize", -] - -[[package]] -name = "tls_codec_derive" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "tokio" version = "1.52.2" @@ -6269,17 +5627,6 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" -[[package]] -name = "uuid" -version = "1.23.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" -dependencies = [ - "getrandom 0.4.2", - "js-sys", - "wasm-bindgen", -] - [[package]] name = "valuable" version = "0.1.1" @@ -6322,23 +5669,19 @@ dependencies = [ "aes-gcm", "argon2", "arti-client", + "async-trait", "base64-simd", "bip39", "chacha20poly1305", "fast-thumbhash", "futures-util", - "hpke-rs-rust-crypto", + "hkdf", "image", "libc", - "mdk-core", - "mdk-sqlite-storage", - "mdk-storage-traits", "nostr", "nostr-blossom", "nostr-connect", "nostr-sdk", - "openmls_rust_crypto", - "openmls_traits", "rand 0.8.6", "reqwest", "rusqlite", diff --git a/crates/Cargo.toml b/crates/Cargo.toml index e9a43509..5a011a5f 100644 --- a/crates/Cargo.toml +++ b/crates/Cargo.toml @@ -1,18 +1,8 @@ [workspace] -members = ["vector-core", "vector-cli", "vector-agent"] +members = ["vector-core", "vector-cli", "vector-agent", "concord-cli"] resolver = "2" [patch.crates-io] # nostr SDK's SecretKey Drop uses non_secure_erase which the compiler can optimize away. # Patched to use zeroize (volatile writes) so secret key bytes are guaranteed cleared from the stack. nostr = { git = "https://github.com/VectorPrivacy/nostr.git", branch = "zeroize-secretkey" } - -# MDK fork branched from rev 136a9ee with `build_message_event_retained` -# added — exposes the kind-445 wrapper's ephemeral signing key so the -# sender can later publish a NIP-09 deletion against the wrapper. -# In-repo so collaborators get the patched source without an external -# checkout. Maintained as a thin diff on top of the pinned upstream rev. -[patch."https://github.com/marmot-protocol/mdk.git"] -mdk-core = { path = "../patches/mdk/crates/mdk-core" } -mdk-sqlite-storage = { path = "../patches/mdk/crates/mdk-sqlite-storage" } -mdk-storage-traits = { path = "../patches/mdk/crates/mdk-storage-traits" } diff --git a/crates/concord-cli/Cargo.toml b/crates/concord-cli/Cargo.toml new file mode 100644 index 00000000..e7b1bb6e --- /dev/null +++ b/crates/concord-cli/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "concord-cli" +version = "0.1.0" +edition = "2021" +description = "Diagnostic CLI to inspect Concord protocol state (epoch chains, rekeys, forks, keys) from a Vector account — local DB now, relay source next." + +[[bin]] +name = "concord" +path = "src/main.rs" + +[dependencies] +vector-core = { path = "../vector-core" } +tokio = { version = "1.49.0", features = ["full"] } +nostr-sdk = { version = "0.44.1", features = ["nip44", "nip59"] } diff --git a/crates/concord-cli/src/main.rs b/crates/concord-cli/src/main.rs new file mode 100644 index 00000000..69ba07fa --- /dev/null +++ b/crates/concord-cli/src/main.rs @@ -0,0 +1,576 @@ +//! `concord` — a diagnostic CLI for the Concord protocol: parse + render a community's epoch chains, +//! rekeys, edition heads, and (next increment) every known FORK, decrypted with a real account's keys — +//! so you can SEE the precise state instead of reverse-engineering obfuscated relay blobs. +//! +//! Source toggle: `--relay` (planned) reconstructs the chain by fetching + decrypting relay events under +//! every held server root; the DEFAULT reads the account's local decrypted DB. Both render identically. +//! +//! Usage: +//! VECTOR_NSEC=nsec1... [VECTOR_DATA_DIR=/path] [VECTOR_PASSWORD=...] concord [community_id_prefix] [--relay] +//! VECTOR_PASSWORD= VECTOR_DATA_DIR=/copy concord --invites [--from ] [--since-hours N] +//! +//! Auth: pass VECTOR_NSEC directly, OR omit it and pass VECTOR_PASSWORD — the key is then read from the +//! account DB and decrypted with the PIN (the sole `npub1…` subdir, or VECTOR_NPUB to disambiguate). +//! +//! `--invites` answers "is a direct invite actually on the network for this account?" — it fetches every +//! gift wrap addressed to me from my inbox relays (paged past the relay cap), unwraps each, and reports +//! which are COMMUNITY_INVITE_BUNDLE. +//! +//! Run against a COPY of the data dir — login writes a pkey row and purges the per-account mls store. + +use std::path::PathBuf; +use vector_core::community::SERVER_ROOT_SCOPE_HEX; +use vector_core::db::community as cdb; +use vector_core::{CoreConfig, VectorCore}; + +/// First 6 bytes of a 32-byte key as hex + ellipsis — enough to eyeball equality/divergence across accounts. +fn prefix(b: &[u8]) -> String { + let h: String = b.iter().take(6).map(|x| format!("{x:02x}")).collect(); + format!("{h}…") +} + +#[tokio::main] +async fn main() { + let args: Vec = std::env::args().collect(); + let relay = args.iter().any(|a| a == "--relay"); + let invites = args.iter().any(|a| a == "--invites"); + // `--from ` narrows the invite probe to one sender; `--since-hours N` widens the window + // (gift wraps backdate their outer timestamp up to ~2 days, so default generously). + let from = arg_value(&args, "--from"); + let since_arg = arg_value(&args, "--since-hours"); + let since_hours: u64 = since_arg.as_deref().and_then(|s| s.parse().ok()).unwrap_or(168); + // Positional community-id prefix: the first bare token that isn't a flag or a flag's value. + let consumed: Vec<&String> = [from.as_ref(), since_arg.as_ref()].into_iter().flatten().collect(); + let filter = args + .iter() + .skip(1) + .find(|a| !a.starts_with("--") && !consumed.contains(a)) + .cloned(); + + let mut nsec = std::env::var("VECTOR_NSEC").unwrap_or_default(); + let password = std::env::var("VECTOR_PASSWORD").ok(); + let data_dir = std::env::var("VECTOR_DATA_DIR").map(PathBuf::from).unwrap_or_else(|_| default_dir()); + + let core = VectorCore::init(CoreConfig { data_dir: data_dir.clone(), event_emitter: None }).unwrap_or_else(|e| { + eprintln!("init failed: {e}"); + std::process::exit(1); + }); + + // Password-from-DB login: no nsec given → read the encrypted pkey from the account DB and decrypt it + // with the PIN. Only the PIN is needed, never the raw nsec. The data dir MUST be a COPY — login writes + // a pkey row and purges the per-account mls store, so never point this at a live account dir. + if nsec.is_empty() { + let npub = resolve_account_npub(&data_dir).unwrap_or_else(|e| { + eprintln!("{e}"); + std::process::exit(1); + }); + vector_core::db::set_current_account(npub.clone()).and_then(|_| vector_core::db::init_database(&npub)).unwrap_or_else(|e| { + eprintln!("open db for {npub} failed: {e}"); + std::process::exit(1); + }); + let stored = vector_core::db::get_pkey().ok().flatten().unwrap_or_else(|| { + eprintln!("no stored key in {npub}/vector.db"); + std::process::exit(1); + }); + nsec = if stored.starts_with("nsec1") { + stored + } else { + let pin = password.clone().unwrap_or_else(|| { + eprintln!("account is encrypted — set VECTOR_PASSWORD="); + std::process::exit(1); + }); + vector_core::crypto::maybe_decrypt_inner(stored, Some(pin)).await.unwrap_or_else(|_| { + eprintln!("incorrect PIN (could not decrypt stored key)"); + std::process::exit(1); + }) + }; + } + + let acct = core.login(&nsec, password.as_deref()).await.unwrap_or_else(|e| { + eprintln!("login failed: {e}"); + std::process::exit(1); + }); + eprintln!("# account {} source={}", acct.npub, if relay { "local+relay" } else { "local" }); + + if invites { + probe_invites(since_hours, from.as_deref()).await; + std::process::exit(0); + } + + let ids = cdb::list_community_ids().unwrap_or_default(); + let mut shown = 0usize; + for id in ids { + let hex = id.to_hex(); + if let Some(f) = &filter { + if !hex.starts_with(f.as_str()) { + continue; + } + } + if let Ok(Some(c)) = cdb::load_community(&id) { + print_local(&c); + if relay { + print_relay(&c).await; + } + shown += 1; + } + } + if shown == 0 { + println!("(no matching communities)"); + } + // Exit hard: login may have spawned background relay tasks we don't need for a one-shot dump. + std::process::exit(0); +} + +fn print_local(c: &vector_core::community::Community) { + let hex = c.id.to_hex(); + let mode = match cdb::get_community_invite_registry(&hex) { + Ok(r) if !r.is_empty() => "PUBLIC", + _ => "private", + }; + println!("\n━━━━━━ {} ({}…) [{}]", c.name, &hex[..16], mode); + println!(" server-root: epoch {} key {}", c.server_root_epoch.0, prefix(c.server_root_key.as_bytes())); + + if let Ok(pending) = cdb::get_read_cut_pending(&hex) { + let target = cdb::get_read_cut_target_epoch(&hex).unwrap_or(0); + if pending || target != c.server_root_epoch.0 { + println!(" read-cut: pending={pending} target_epoch={target}"); + } + } + if let Ok(bl) = cdb::get_community_banlist(&hex) { + if !bl.is_empty() { + let who: Vec = bl.iter().map(|h| format!("{}…", &h[..h.len().min(10)])).collect(); + println!(" banlist: {}", who.join(", ")); + } + } + + // Base (server-root) epoch chain — one root per epoch; a future fork view shows siblings here. + if let Ok(mut roots) = cdb::held_epoch_keys(&hex, SERVER_ROOT_SCOPE_HEX) { + roots.sort_by_key(|(e, _)| e.0); + let chain: Vec = roots.iter().map(|(e, k)| format!("{}:{}", e.0, prefix(k))).collect(); + println!(" base chain: {}", chain.join(" → ")); + } + + for ch in &c.channels { + let chex = ch.id.to_hex(); + println!( + " ┌─ #{} ({}…) head epoch {} key {}", + ch.name, + &chex[..12], + ch.epoch.0, + prefix(ch.key.as_bytes()) + ); + if let Ok(mut ks) = cdb::held_epoch_keys(&hex, &chex) { + ks.sort_by_key(|(e, _)| e.0); + let chain: Vec = ks.iter().map(|(e, k)| format!("{}:{}", e.0, prefix(k))).collect(); + println!(" └─ epochs: {}", chain.join(" → ")); + } + } + + // Control-plane edition heads (refuse-downgrade floors): entity → epoch.version. + if let Ok(heads) = cdb::get_all_edition_heads_epoched(&hex) { + if !heads.is_empty() { + let mut hv: Vec<(&String, &(u64, u64, [u8; 32]))> = heads.iter().collect(); + hv.sort_by(|a, b| a.0.cmp(b.0)); + println!(" edition heads (entity → epoch.version):"); + for (entity, (epoch, version, _)) in hv { + let label = if entity == &hex { + "GroupRoot".to_string() + } else { + format!("{}…", &entity[..entity.len().min(12)]) + }; + println!(" {label:<16} {epoch}.{version}"); + } + } + } + + // Per-creator public invite links — the basis of the computed Public/Private mode. + if let Ok(sets) = cdb::get_invite_link_sets(&hex) { + for s in sets.iter().filter(|s| !s.locators.is_empty()) { + println!(" invite-links: {}… holds {}", &s.creator_hex[..s.creator_hex.len().min(10)], s.locators.len()); + } + } +} + +/// RELAY reconstruction: fetch the rekey events from the community's relays and decrypt each under EVERY +/// held server root, so we SEE every fork sibling at each epoch + whether THIS account is a recipient of +/// each (the exact inputs the convergence heal works from). Uses local keys to decrypt; events from relays. +async fn print_relay(c: &vector_core::community::Community) { + use vector_core::community::derive::{self, RekeyScope}; + use vector_core::community::rekey; + use vector_core::community::transport::{LiveTransport, Query, Transport}; + use vector_core::community::{Epoch, ServerRootKey}; + use vector_core::stored_event::event_kind::COMMUNITY_REKEY; + use std::collections::{BTreeMap, HashSet}; + + let hex = c.id.to_hex(); + println!("\n ── RELAY reconstruction ──"); + let Some(keys) = vector_core::state::MY_SECRET_KEY.to_keys() else { + println!(" (no local key; cannot decrypt)"); + return; + }; + let sk = keys.secret_key(); + let tx = LiveTransport::with_timeout(std::time::Duration::from_secs(15)); + + // Per-relay latency, split into the two costs that matter on a high-latency link, measured with a FRESH + // throwaway client per relay so the handshake is real (not hidden behind an already-open pool socket): + // • socket-connect = TCP + TLS + WebSocket upgrade (the cost paid on every cold start / reconnect) + // • in-tunnel fetch = REQ → response once the socket is up (what the warmed pool would show) + // A re-founding's coverage gate fetches over the shared pool, so a relay that's cheap in-tunnel but + // expensive to (re)connect can still blow the budget after a reconnect — hence reporting both. + { + use nostr_sdk::{Client, Filter, Kind}; + use std::time::{Duration, Instant}; + use vector_core::stored_event::event_kind::COMMUNITY_CONTROL; + println!(" ── per-relay latency (fresh connection) ──"); + for r in &c.relays { + let client = Client::default(); + let _ = client.add_relay(r.as_str()).await; + let t0 = Instant::now(); + client.try_connect(Duration::from_secs(15)).await; + let connect_ms = t0.elapsed().as_millis(); + let filter = Filter::new().kind(Kind::Custom(COMMUNITY_CONTROL)).limit(1); + let t1 = Instant::now(); + let res = client.fetch_events_from(vec![r.clone()], filter, Duration::from_secs(15)).await; + let fetch_ms = t1.elapsed().as_millis(); + let n = res.as_ref().map(|e| e.len()).unwrap_or(0); + println!(" {r}\n socket-connect {connect_ms:>6} ms in-tunnel fetch {fetch_ms:>6} ms ({n} ev)"); + let _ = client.shutdown().await; + } + } + + let roots = vector_core::db::community::held_epoch_keys(&hex, vector_core::community::SERVER_ROOT_SCOPE_HEX).unwrap_or_default(); + if roots.is_empty() { + println!(" (no held server roots)"); + return; + } + let cur_base = c.server_root_epoch.0; + + // BASE re-foundings: a base rekey to epoch e is addressed under the PRIOR root (e-1), so try each held + // root as a prior. ≥2 distinct delivered roots at one epoch = a re-founding fork (B2). + println!(" BASE (fork = ≥2 roots at one epoch):"); + let mut base: BTreeMap)>> = BTreeMap::new(); + for (pe, prior) in &roots { + let target = pe.0 + 1; + let z = derive::base_rekey_pseudonym(&ServerRootKey(*prior), &c.id, Epoch(target)).to_hex(); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags: vec![z], since: None, ..Default::default() }; + for ev in tx.fetch(&q, &c.relays).await.unwrap_or_default() { + if let Ok(p) = rekey::open_rekey_event(&ev, prior) { + if matches!(p.scope, RekeyScope::ServerRoot) && p.new_epoch.0 == target { + base.entry(target).or_default().push((p.rotator.to_bytes(), peek_key(sk, &p))); + } + } + } + } + if base.is_empty() { + println!(" (none found on relays)"); + } + for (epoch, sibs) in &base { + let distinct: HashSet<_> = sibs.iter().filter_map(|(_, m)| *m).collect(); + let tag = if distinct.len() >= 2 { " <<< FORK" } else { "" }; + let star = if *epoch == cur_base { " (current)" } else { "" }; + println!(" epoch {epoch}{star}: {} candidate(s), {} distinct root(s){tag}", sibs.len(), distinct.len()); + for (rot, mine) in sibs { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "NOT A RECIPIENT".into()); + println!(" rotator {} root→me {m}", hexpref(rot)); + } + // VERDICT: a correct client adopts the LOWEST root (deterministic B2 tiebreak). If my head root + // disagrees with this, that's the bug, in one glance. + if distinct.len() >= 2 { + if let Some(win) = distinct.iter().min() { + println!(" → verdict: converge to LOWEST root {} (B2 tiebreak)", prefix(win)); + } + } + } + + // CHANNEL rekeys: a re-founding rekeys the channel under the (shared) root current at publish; search + // EVERY held root. ≥2 distinct delivered keys at one epoch = the A-B2 channel fork. + for ch in &c.channels { + let head = ch.epoch.0; + println!(" #{} (fork = ≥2 keys at one epoch):", ch.name); + let mut by_epoch: BTreeMap)>> = BTreeMap::new(); + for (re, root) in &roots { + let z_tags: Vec = (1..=head + 1) + .map(|e| derive::rekey_pseudonym(&ServerRootKey(*root), &ch.id, Epoch(e)).to_hex()) + .collect(); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags, since: None, ..Default::default() }; + for ev in tx.fetch(&q, &c.relays).await.unwrap_or_default() { + if let Ok(p) = rekey::open_rekey_event(&ev, root) { + if matches!(p.scope, RekeyScope::Channel(id) if id == ch.id) { + by_epoch.entry(p.new_epoch.0).or_default().push((re.0, p.rotator.to_bytes(), peek_key(sk, &p))); + } + } + } + } + if by_epoch.is_empty() { + println!(" (no channel rekeys found on relays)"); + } + for (epoch, sibs) in &by_epoch { + let distinct: HashSet<_> = sibs.iter().filter_map(|(_, _, m)| *m).collect(); + let tag = if distinct.len() >= 2 { " <<< FORK (channel diverged)" } else { "" }; + let star = if *epoch == head { " (current head)" } else { "" }; + println!(" epoch {epoch}{star}: {} candidate(s), {} distinct key(s){tag}", sibs.len(), distinct.len()); + for (root_ep, rot, mine) in sibs { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "NOT A RECIPIENT".into()); + println!(" under root@{root_ep} rotator {} key→me {m}", hexpref(rot)); + } + // VERDICT: a correct client adopts the LOWEST key (A-B2 tiebreak). If my channel head disagrees, + // that's the bug. NOT A RECIPIENT on the winning sibling = I can't converge (a retain-set gap). + if distinct.len() >= 2 { + if let Some(win) = distinct.iter().min() { + println!(" → verdict: converge to LOWEST key {} (A-B2 tiebreak)", prefix(win)); + } + } + } + } + + // BY-AUTHOR census: a BROAD fetch of every rekey on the relays (no #z filter), grouped by who rotated. + // Each is tried under every held root; what opens shows that author's published rekeys, what DOESN'T is + // counted as OPAQUE (under a root I don't hold) — so "the winner never published a channel rekey" vs + // "published one under a root I dropped" is answerable in one glance. + { + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let q = Query { kinds: vec![COMMUNITY_REKEY], z_tags: vec![], since: Some(now.saturating_sub(48 * 3600)), ..Default::default() }; + let events = tx.fetch(&q, &c.relays).await.unwrap_or_default(); + println!("\n ── by-author census ({} rekey events on relays, last 48h) ──", events.len()); + let mut by_author: BTreeMap)>> = BTreeMap::new(); + let mut opaque: BTreeMap = BTreeMap::new(); + for ev in &events { + let mut opened = false; + for (re, root) in &roots { + if let Ok(p) = rekey::open_rekey_event(ev, root) { + let scope = match p.scope { + RekeyScope::ServerRoot => "base".to_string(), + RekeyScope::Channel(id) => format!("chan:{}", &id.to_hex()[..6]), + }; + let key: String = p.rotator.to_bytes().iter().map(|b| format!("{b:02x}")).collect(); + by_author.entry(key).or_default().push((scope, p.new_epoch.0, re.0, peek_key(sk, &p))); + opened = true; + break; + } + } + if !opened { + let z = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == "z").then(|| s[1].clone()) + }).unwrap_or_default(); + *opaque.entry(z.chars().take(12).collect()).or_default() += 1; + } + } + for (rot, mut items) in by_author { + items.sort_by_key(|(_, e, _, _)| *e); + println!(" rotator {}…", &rot[..rot.len().min(10)]); + for (scope, epoch, root_ep, mine) in items { + let m = mine.map(|k| prefix(&k)).unwrap_or_else(|| "not-to-me".into()); + println!(" {scope} epoch {epoch} under root@{root_ep} key→me {m}"); + } + } + if !opaque.is_empty() { + let total: usize = opaque.values().sum(); + println!(" {total} OPAQUE event(s) under roots I don't hold (publisher's root unknown to me):"); + for (z, n) in opaque { + println!(" #z {z}… ×{n}"); + } + } + } +} + +/// Open MY blob in a rekey (the key it delivers to this account), or None if I'm not a recipient. +fn peek_key(sk: &nostr_sdk::SecretKey, p: &vector_core::community::rekey::ParsedRekey) -> Option<[u8; 32]> { + use vector_core::community::{derive, rekey}; + let secret = rekey::rekey_pairwise_secret(sk, &p.rotator).ok()?; + let loc = derive::recipient_pseudonym(&secret, p.scope, p.new_epoch).to_hex(); + let blob = p.blobs.iter().find(|b| b.locator == loc)?; + rekey::open_rekey_blob(sk, &p.rotator, p.scope, p.new_epoch, blob).ok() +} + +/// Short hex of a pubkey/id (first 5 bytes). +fn hexpref(b: &[u8]) -> String { + let h: String = b.iter().take(5).map(|x| format!("{x:02x}")).collect(); + format!("{h}…") +} + +/// Value of a `--flag value` pair on the command line, if present. +fn arg_value(args: &[String], flag: &str) -> Option { + args.iter().position(|a| a == flag).and_then(|i| args.get(i + 1)).cloned() +} + +/// Resolve which account to open for password-from-DB login: `VECTOR_NPUB` if set, else the sole +/// `npub1…` subdir under the data dir. Errors (listing options) if it's ambiguous, so we never guess. +fn resolve_account_npub(data_dir: &std::path::Path) -> Result { + if let Ok(n) = std::env::var("VECTOR_NPUB") { + if !n.is_empty() { + return Ok(n); + } + } + let npubs: Vec = std::fs::read_dir(data_dir) + .map_err(|e| format!("cannot read {}: {e}", data_dir.display()))? + .filter_map(|e| e.ok()) + .filter(|e| e.path().is_dir()) + .filter_map(|e| e.file_name().into_string().ok()) + .filter(|n| n.starts_with("npub1")) + .collect(); + match npubs.len() { + 1 => Ok(npubs.into_iter().next().unwrap()), + 0 => Err(format!("no npub1… account dir under {}", data_dir.display())), + _ => Err(format!("multiple accounts under {} — set VECTOR_NPUB to one of:\n {}", data_dir.display(), npubs.join("\n "))), + } +} + +/// INVITE PROBE: is a direct community invite actually on the network for THIS account? +/// +/// Fetches every kind-1059 gift wrap addressed to me from my own inbox relays (kind 10050) plus the +/// trusted relays, per-relay so a "delivered to relay A, missing on relay B" split is visible, unwraps +/// each with my key, and reports which inner rumors are COMMUNITY_INVITE_BUNDLE (3304) — with sender, +/// community, channel count and the rumor's own timestamp (the real send time; the wrap backdates). +async fn probe_invites(since_hours: u64, from: Option<&str>) { + use nostr_sdk::{Filter, Kind, RelayUrl, Timestamp, ToBech32}; + use std::collections::{BTreeMap, HashSet}; + use std::time::Duration; + use vector_core::stored_event::event_kind::COMMUNITY_INVITE_BUNDLE; + + let Some(me) = vector_core::state::my_public_key() else { + println!("(no active pubkey)"); + return; + }; + let Some(keys) = vector_core::state::MY_SECRET_KEY.to_keys() else { + println!("(no local key; cannot unwrap)"); + return; + }; + let Some(client) = vector_core::state::nostr_client() else { + println!("(no nostr client)"); + return; + }; + + let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + let since = Timestamp::from(now.saturating_sub(since_hours.saturating_mul(3600))); + let from_pk = from.and_then(|s| { + nostr_sdk::PublicKey::parse(s).map_err(|e| eprintln!("bad --from {s}: {e}")).ok() + }); + + // Relay set: my published inbox relays (where invites are delivered) ∪ trusted relays. + let mut relays: Vec = vector_core::inbox_relays::trusted_relay_urls(); + { + let f = Filter::new().author(me).kind(Kind::Custom(10050)).limit(1); + if let Ok(evs) = client.fetch_events(f, Duration::from_secs(8)).await { + if let Some(ev) = evs.into_iter().next() { + for t in ev.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "relay" { + if let Ok(u) = RelayUrl::parse(&s[1]) { + if !relays.contains(&u) { + relays.push(u); + } + } + } + } + } + } + } + println!( + "\n ── invite probe ──\n me {}\n window last {since_hours}h relays {}{}", + me.to_bech32().unwrap_or_default(), + relays.len(), + from_pk.map(|p| format!(" from {}", &p.to_hex()[..16])).unwrap_or_default() + ); + + // Per-relay fetch so a delivery split is visible; union the gift wraps by id for unwrapping. + // Relays cap a single response (~500), so page backwards by `until` until the window is exhausted — + // otherwise a truncated relay could hide the very invite we're hunting (no silent caps). + let mut union: BTreeMap = BTreeMap::new(); + for r in &relays { + let _ = client.add_relay(r.as_str()).await; + let mut until: Option = None; + let mut got = 0usize; + let mut pages = 0u32; + loop { + let mut f = Filter::new().kind(Kind::GiftWrap).pubkey(me).since(since).limit(500); + if let Some(u) = until { + f = f.until(u); + } + let evs = client.fetch_events_from(vec![r.clone()], f, Duration::from_secs(12)).await.unwrap_or_default(); + let n = evs.len(); + // Oldest in this page seeds the next page's `until` (one second before, to avoid re-fetching it). + let oldest = evs.iter().map(|e| e.created_at).min(); + for ev in evs.into_iter() { + union.insert(ev.id, ev); + } + got += n; + pages += 1; + // Stop when the relay returns a short page (no more), or we'd loop forever, or we've paged deep. + match oldest { + Some(o) if n >= 500 && o > since && pages < 40 => until = Some(Timestamp::from(o.as_secs().saturating_sub(1))), + _ => break, + } + } + println!(" {r} → {got} gift wrap(s) ({pages} page(s))"); + } + println!(" unique gift wraps: {}", union.len()); + + // Unwrap each, tally inner kinds, detail every invite bundle. + let mut by_kind: BTreeMap = BTreeMap::new(); + let mut undecryptable = 0usize; + let mut invites_found: Vec<(nostr_sdk::PublicKey, u64, vector_core::community::invite::CommunityInvite)> = Vec::new(); + let mut seen_senders: HashSet = HashSet::new(); + for ev in union.values() { + match nostr_sdk::nips::nip59::UnwrappedGift::from_gift_wrap(&keys, ev).await { + Ok(g) => { + let k = g.rumor.kind.as_u16(); + *by_kind.entry(k).or_default() += 1; + seen_senders.insert(g.sender); + if k == COMMUNITY_INVITE_BUNDLE { + if let Some(inv) = vector_core::community::invite::parse_invite_rumor(g.rumor.kind, &g.rumor.content) { + if from_pk.map(|p| p == g.sender).unwrap_or(true) { + invites_found.push((g.sender, g.rumor.created_at.as_secs(), inv)); + } + } + } + } + Err(_) => undecryptable += 1, + } + } + + println!("\n inner-kind tally (unwrapped):"); + for (k, n) in &by_kind { + let label = match *k { + 14 => " (nip17 dm)", + 15 => " (nip17 file)", + COMMUNITY_INVITE_BUNDLE => " (COMMUNITY INVITE)", + _ => "", + }; + println!(" kind {k:<5} ×{n}{label}"); + } + if undecryptable > 0 { + println!(" ({undecryptable} wrap(s) not addressed to / not decryptable by me)"); + } + println!(" distinct senders seen: {}", seen_senders.len()); + + println!("\n ── community invites on network: {} ──", invites_found.len()); + if invites_found.is_empty() { + println!(" NONE. No COMMUNITY_INVITE_BUNDLE gift wrap for this account on these relays in-window."); + println!(" → the invite never reached these relays (sender-side / relay-mismatch), OR it is older than {since_hours}h."); + } + invites_found.sort_by_key(|(_, ts, _)| *ts); + for (sender, ts, inv) in &invites_found { + println!( + " • '{}' ({}…)\n from {}\n sent {} relays {} channels {}", + inv.name, + &inv.community_id[..inv.community_id.len().min(16)], + sender.to_bech32().unwrap_or_else(|_| sender.to_hex()), + ts, + inv.relays.len(), + inv.channels.len(), + ); + } +} + +fn default_dir() -> PathBuf { + #[cfg(target_os = "macos")] + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join("Library/Application Support/io.vectorapp/agent"); + } + #[cfg(target_os = "linux")] + if let Ok(home) = std::env::var("HOME") { + return PathBuf::from(home).join(".local/share/io.vectorapp/agent"); + } + PathBuf::from("/tmp/vector-data") +} diff --git a/crates/vector-agent/src/handler.rs b/crates/vector-agent/src/handler.rs index 1f5fa673..7e222d13 100644 --- a/crates/vector-agent/src/handler.rs +++ b/crates/vector-agent/src/handler.rs @@ -19,6 +19,13 @@ impl AgentEventHandler { let buffer = Arc::new(Mutex::new(Vec::new())); (Self { buffer: buffer.clone() }, buffer) } + + /// Build a handler that writes into an EXISTING buffer — used when re-attaching `listen()` + /// after an account swap, so the new session's events flow into the same buffer the MCP + /// `get_new_messages` tool already reads from. + pub fn with_buffer(buffer: Arc>>) -> Self { + Self { buffer } + } } impl InboundEventHandler for AgentEventHandler { @@ -45,16 +52,4 @@ impl InboundEventHandler for AgentEventHandler { buf.lock().await.push(entry); }); } - - fn on_group_message(&self, group_id: &str, msg: &Message) { - let entry = BufferedMessage { - chat_id: group_id.to_string(), - is_group: true, - message: msg.clone(), - }; - let buf = self.buffer.clone(); - tokio::spawn(async move { - buf.lock().await.push(entry); - }); - } } diff --git a/crates/vector-agent/src/main.rs b/crates/vector-agent/src/main.rs index 42e6417b..204e009a 100644 --- a/crates/vector-agent/src/main.rs +++ b/crates/vector-agent/src/main.rs @@ -56,19 +56,6 @@ async fn main() { // Wait for relay connections tokio::time::sleep(std::time::Duration::from_secs(2)).await; - // Publish keypackage so others can invite us to MLS groups. - // `use_cache=true` reuses an existing valid keypackage if present. - match core.publish_keypackage(true).await { - Ok(kp) => { - if kp.cached { - eprintln!("[vector-agent] KeyPackage ready (cached): device={}", kp.device_id); - } else { - eprintln!("[vector-agent] KeyPackage published: device={}", kp.device_id); - } - } - Err(e) => eprintln!("[vector-agent] KeyPackage publish failed (invites won't work): {}", e), - } - // Start background listener with event handler let (event_handler, message_buffer) = AgentEventHandler::new(); let listen_core = VectorCore; @@ -96,22 +83,22 @@ fn dirs_or_default() -> PathBuf { #[cfg(target_os = "macos")] { if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join("Library/Application Support/io.vectorapp/data"); + return PathBuf::from(home).join("Library/Application Support/io.vectorapp/agent"); } } #[cfg(target_os = "linux")] { if let Ok(data) = std::env::var("XDG_DATA_HOME") { - return PathBuf::from(data).join("io.vectorapp/data"); + return PathBuf::from(data).join("io.vectorapp/agent"); } if let Ok(home) = std::env::var("HOME") { - return PathBuf::from(home).join(".local/share/io.vectorapp/data"); + return PathBuf::from(home).join(".local/share/io.vectorapp/agent"); } } #[cfg(target_os = "windows")] { if let Ok(appdata) = std::env::var("APPDATA") { - return PathBuf::from(appdata).join("io.vectorapp/data"); + return PathBuf::from(appdata).join("io.vectorapp/agent"); } } PathBuf::from("/tmp/vector-data") diff --git a/crates/vector-agent/src/tools.rs b/crates/vector-agent/src/tools.rs index ff891654..510227df 100644 --- a/crates/vector-agent/src/tools.rs +++ b/crates/vector-agent/src/tools.rs @@ -32,14 +32,6 @@ pub struct SendFileRequest { pub file_path: String, } -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SendGroupMessageRequest { - #[schemars(description = "Group ID (64-char hex)")] - pub group_id: String, - #[schemars(description = "Message content")] - pub content: String, -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct GetMessagesRequest { #[schemars(description = "Chat ID (npub for DMs, group_id for groups)")] @@ -55,115 +47,115 @@ pub struct GetMessagesRequest { fn default_limit() -> usize { 50 } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct CreateGroupRequest { - #[schemars(description = "Group name")] - pub name: String, - #[schemars(description = "Members to invite: array of {npub, device_id} objects")] - #[serde(default)] - pub members: Vec, +pub struct NpubRequest { + #[schemars(description = "User's npub")] + pub npub: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct MemberDevice { - #[schemars(description = "Member's npub")] - pub npub: String, - #[schemars(description = "Member's device ID")] - pub device_id: String, +pub struct AddAccountRequest { + #[schemars(description = "Optional nsec to import. OMIT to generate a fresh, random identity (preferred for test accounts — keeps secret keys out of the conversation).")] + #[serde(default)] + pub nsec: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct GroupIdRequest { - #[schemars(description = "Group ID (64-char hex)")] - pub group_id: String, +pub struct SetNicknameRequest { + #[schemars(description = "User's npub")] + pub npub: String, + #[schemars(description = "Nickname to set")] + pub nickname: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct WelcomeIdRequest { - #[schemars(description = "Welcome event ID (from list_invites)")] - pub welcome_event_id: String, +pub struct SyncDmsRequest { + #[schemars(description = "Number of days to sync (e.g. 7 for last week). Omit for full history sync.")] + pub since_days: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct NpubGroupRequest { - #[schemars(description = "Group ID (64-char hex)")] - pub group_id: String, - #[schemars(description = "Member's npub")] - pub npub: String, +pub struct UpdateProfileRequest { + #[schemars(description = "Display name")] + pub name: String, + #[schemars(description = "Avatar URL")] + #[serde(default)] + pub avatar: String, + #[schemars(description = "Banner URL")] + #[serde(default)] + pub banner: String, + #[schemars(description = "About/bio text")] + #[serde(default)] + pub about: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct InviteMemberRequest { - #[schemars(description = "Group ID")] - pub group_id: String, - #[schemars(description = "Member's npub")] - pub npub: String, - #[schemars(description = "Member's device ID")] - pub device_id: String, +pub struct JoinCommunityRequest { + #[schemars(description = "Public invite URL (e.g. https://vectorapp.io/invite#...)")] + pub invite_url: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct InviteMembersRequest { - #[schemars(description = "Group ID")] - pub group_id: String, - #[schemars(description = "Members to invite: array of {npub, device_id} objects")] - pub members: Vec, +pub struct CommunityIdRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RemoveMemberRequest { - #[schemars(description = "Group ID")] - pub group_id: String, - #[schemars(description = "Member's npub to remove")] - pub npub: String, +pub struct RevokePublicInviteRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "Hex token of the invite link to revoke (from list_public_invites)")] + pub token: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct UpdateGroupRequest { - #[schemars(description = "Group ID")] - pub group_id: String, - #[schemars(description = "New group name (optional)")] - pub name: Option, - #[schemars(description = "New group description (optional)")] - pub description: Option, - #[schemars(description = "New admin npubs list (optional)")] - pub admin_npubs: Option>, +pub struct CreateCommunityRequest { + #[schemars(description = "Name for the new community")] + pub name: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct NpubRequest { - #[schemars(description = "User's npub")] +pub struct CommunityMemberRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "The target member's npub")] pub npub: String, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SetNicknameRequest { - #[schemars(description = "User's npub")] - pub npub: String, - #[schemars(description = "Nickname to set")] - pub nickname: String, +pub struct EditCommunityMetadataRequest { + #[schemars(description = "Community id (hex)")] + pub community_id: String, + #[schemars(description = "New name (omit to leave unchanged)")] + #[serde(default)] + pub name: Option, + #[schemars(description = "New description (empty string clears it; omit to leave unchanged)")] + #[serde(default)] + pub description: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct SyncDmsRequest { - #[schemars(description = "Number of days to sync (e.g. 7 for last week). Omit for full history sync.")] - pub since_days: Option, +pub struct SendCommunityMessageRequest { + #[schemars(description = "Channel id (the hex chat id of a Community channel)")] + pub channel_id: String, + #[schemars(description = "Message content")] + pub content: String, + #[schemars(description = "Optional inner id of a message to reply to")] + #[serde(default)] + pub replied_to: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct UpdateProfileRequest { - #[schemars(description = "Display name")] - pub name: String, - #[schemars(description = "Avatar URL")] - #[serde(default)] - pub avatar: String, - #[schemars(description = "Banner URL")] - #[serde(default)] - pub banner: String, - #[schemars(description = "About/bio text")] - #[serde(default)] - pub about: String, +pub struct SyncCommunityChannelRequest { + #[schemars(description = "Channel id to sync the latest page of")] + pub channel_id: String, + #[schemars(description = "Max messages to fetch (default 20)")] + #[serde(default = "default_page")] + pub limit: usize, } +fn default_page() -> usize { 20 } + // ============================================================================ // VectorAgent — MCP server with all tools // ============================================================================ @@ -196,6 +188,99 @@ impl VectorAgent { } } + // === Accounts (multi-account, GUI-parity) === + + /// Re-attach the background DM listener to the CURRENT session, writing into the shared buffer + /// that `get_new_messages` drains. Called after a swap once the new client is connected; the + /// prior listener ended when `swap_session` shut its client down. + fn respawn_listener(&self) { + let buffer = self.message_buffer.clone(); + tokio::spawn(async move { + let handler = Arc::new(crate::handler::AgentEventHandler::with_buffer(buffer)); + if let Err(e) = VectorCore.listen(handler).await { + eprintln!("[vector-agent] re-listen error: {}", e); + } + }); + } + + #[tool(description = "List the Vector accounts this agent holds locally (each a separate npub with its own store). The active account is flagged. Use swap_account to switch between them.")] + async fn list_accounts(&self) -> Result { + let current = self.core.my_npub(); + match vector_core::db::get_accounts() { + Ok(accts) => { + let list: Vec<_> = accts.into_iter().map(|npub| { + let active = current.as_deref() == Some(npub.as_str()); + serde_json::json!({ "npub": npub, "active": active }) + }).collect(); + let json = serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])), + } + } + + #[tool(description = "The npub of the currently active account.")] + async fn current_account(&self) -> Result { + match self.core.my_npub() { + Some(npub) => Ok(CallToolResult::success(vec![Content::text(npub)])), + None => Ok(CallToolResult::error(vec![Content::text("No active account")])), + } + } + + #[tool(description = "Add a Vector account and switch to it. With NO nsec, generates a fresh random identity (preferred for test accounts). With an nsec, imports it. Returns the new account's npub.")] + async fn add_account(&self, Parameters(req): Parameters) -> Result { + let nsec = match req.nsec.as_deref() { + Some(s) if !s.is_empty() => s.to_string(), + _ => match self.core.generate_nsec() { + Ok(n) => n, + Err(e) => return Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + }, + }; + self.core.swap_session().await; + { self.message_buffer.lock().await.clear(); } + match self.core.login(&nsec, None).await { + Ok(res) => { + self.respawn_listener(); + Ok(CallToolResult::success(vec![Content::text(format!("Added and switched to {}", res.npub))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + + #[tool(description = "Switch the active account to one already held locally (by npub). Loads that account's stored key — no secret key is passed or exposed. See list_accounts for options.")] + async fn swap_account(&self, Parameters(req): Parameters) -> Result { + let accts = vector_core::db::get_accounts().unwrap_or_default(); + if !accts.iter().any(|a| a == &req.npub) { + return Ok(CallToolResult::error(vec![Content::text( + format!("No local account {}. Use list_accounts or add_account first.", req.npub))])); + } + if self.core.my_npub().as_deref() == Some(req.npub.as_str()) { + return Ok(CallToolResult::success(vec![Content::text(format!("Already active: {}", req.npub))])); + } + self.core.swap_session().await; + // Open the target account's store to read its stored key, then bind it via the normal login path. + if let Err(e) = vector_core::db::set_current_account(req.npub.clone()) { + return Ok(CallToolResult::error(vec![Content::text(e)])); + } + if let Err(e) = vector_core::db::init_database(&req.npub) { + return Ok(CallToolResult::error(vec![Content::text(e)])); + } + let nsec = match vector_core::db::get_pkey() { + Ok(Some(n)) => n, + Ok(None) => return Ok(CallToolResult::error(vec![Content::text( + "That account has no stored key (encrypted or external signer) — can't swap to it headlessly.")])), + Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])), + }; + { self.message_buffer.lock().await.clear(); } + match self.core.login(&nsec, None).await { + Ok(res) => { + self.respawn_listener(); + Ok(CallToolResult::success(vec![Content::text(format!("Switched to {}", res.npub))])) + } + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } + } + // === Messaging === #[tool(description = "Send an encrypted direct message (NIP-17 gift-wrapped DM) to a Nostr user")] @@ -218,15 +303,7 @@ impl VectorAgent { } } - #[tool(description = "Send a message to an MLS-encrypted group chat")] - async fn send_group_message(&self, Parameters(req): Parameters) -> Result { - match self.core.send_group_message(&req.group_id, &req.content).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text("Group message sent")])), - Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), - } - } - - #[tool(description = "Get message history for a chat (DM or group). Returns messages in chronological order.")] + #[tool(description = "Get message history for a chat. Returns messages in chronological order.")] async fn get_messages(&self, Parameters(req): Parameters) -> Result { let msgs = self.core.get_messages(&req.chat_id, req.limit, req.offset).await; let json = serde_json::to_string_pretty(&msgs).unwrap_or_else(|_| "[]".into()); @@ -253,257 +330,285 @@ impl VectorAgent { Ok(CallToolResult::success(vec![Content::text(json)])) } - // === Groups === - - #[tool(description = "Create a new MLS-encrypted group chat. Returns the group_id. Members are optional — you can invite later.")] - async fn create_group(&self, Parameters(req): Parameters) -> Result { - let devices: Vec<(&str, &str)> = req.members.iter() - .map(|m| (m.npub.as_str(), m.device_id.as_str())) - .collect(); - match self.core.create_group(&req.name, &devices).await { - Ok(group_id) => Ok(CallToolResult::success(vec![Content::text( - format!("Group created: {}", group_id) + #[tool(description = "Sync DM history from relays using NIP-77 negentropy reconciliation. Fetches missed messages and populates chat history. Use since_days to limit scope (e.g. 7 for last week) or omit for full sync.")] + async fn sync_dms(&self, Parameters(req): Parameters) -> Result { + match self.core.sync_dms(req.since_days, &vector_core::NoOpEventHandler).await { + Ok((events, new)) => Ok(CallToolResult::success(vec![Content::text( + format!("DM sync complete: {} events processed, {} new messages", events, new) )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "List all MLS groups you are a member of")] - async fn list_groups(&self) -> Result { - match self.core.list_groups().await { - Ok(groups) => { - let json = serde_json::to_string_pretty(&groups).unwrap_or_else(|_| "[]".into()); + // === Profiles === + + #[tool(description = "Get a user's profile by npub (from local cache)")] + async fn get_profile(&self, Parameters(req): Parameters) -> Result { + match self.core.get_profile(&req.npub).await { + Some(profile) => { + let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); Ok(CallToolResult::success(vec![Content::text(json)])) } - Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + None => Ok(CallToolResult::error(vec![Content::text("Profile not found in cache. Use load_profile to fetch from relays.")])), } } - #[tool(description = "Get the members and admins of an MLS group")] - async fn get_group_members(&self, Parameters(req): Parameters) -> Result { - match self.core.get_group_members(&req.group_id) { - Ok((group_id, members, admins)) => { - let result = serde_json::json!({ - "group_id": group_id, - "members": members, - "admins": admins, - }); - Ok(CallToolResult::success(vec![Content::text( - serde_json::to_string_pretty(&result).unwrap() - )])) + #[tool(description = "Fetch a user's profile metadata from Nostr relays (updates local cache)")] + async fn load_profile(&self, Parameters(req): Parameters) -> Result { + if self.core.load_profile(&req.npub).await { + match self.core.get_profile(&req.npub).await { + Some(profile) => { + let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + None => Ok(CallToolResult::success(vec![Content::text("Profile fetched but not cached")])), } - Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to fetch profile from relays")])) } } - #[tool(description = "Invite a single member to an MLS group by npub (auto-fetches their latest keypackage). Prefer this over invite_member_with_device.")] - async fn invite(&self, Parameters(req): Parameters) -> Result { - match self.core.invite(&req.group_id, &req.npub).await { - Ok(device_id) => Ok(CallToolResult::success(vec![Content::text( - format!("Invited {} (device {})", &req.npub[..20.min(req.npub.len())], &device_id[..8.min(device_id.len())]) + #[tool(description = "Update the current user's profile (name, avatar URL, banner URL, about/bio)")] + async fn update_profile(&self, Parameters(req): Parameters) -> Result { + if self.core.update_profile(&req.name, &req.avatar, &req.banner, &req.about).await { + Ok(CallToolResult::success(vec![Content::text("Profile updated")])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to update profile")])) + } + } + + #[tool(description = "Block a user by npub")] + async fn block_user(&self, Parameters(req): Parameters) -> Result { + if self.core.block_user(&req.npub).await { + Ok(CallToolResult::success(vec![Content::text(format!("Blocked {}", &req.npub[..20.min(req.npub.len())]))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to block user")])) + } + } + + #[tool(description = "Unblock a user by npub")] + async fn unblock_user(&self, Parameters(req): Parameters) -> Result { + if self.core.unblock_user(&req.npub).await { + Ok(CallToolResult::success(vec![Content::text(format!("Unblocked {}", &req.npub[..20.min(req.npub.len())]))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to unblock user")])) + } + } + + #[tool(description = "Set a local nickname for a user (only visible to you)")] + async fn set_nickname(&self, Parameters(req): Parameters) -> Result { + if self.core.set_nickname(&req.npub, &req.nickname).await { + Ok(CallToolResult::success(vec![Content::text(format!("Nickname set to '{}'", req.nickname))])) + } else { + Ok(CallToolResult::error(vec![Content::text("Failed to set nickname")])) + } + } + + #[tool(description = "Get all blocked user profiles")] + async fn get_blocked_users(&self) -> Result { + let blocked = self.core.get_blocked_users().await; + let json = serde_json::to_string_pretty(&blocked).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + // === Communities === + + #[tool(description = "List all Vector Communities held locally (owned or joined), each with its channels and channel ids. Use a channel id as the chat_id for get_messages.")] + async fn list_communities(&self) -> Result { + let communities = self.core.list_communities().await; + let json = serde_json::to_string_pretty(&communities).unwrap_or_else(|_| "[]".into()); + Ok(CallToolResult::success(vec![Content::text(json)])) + } + + #[tool(description = "Create a new Vector Community (single 'general' channel) owned by this identity. Signs the owner attestation so the creator is the proven owner. Returns the community + channel ids.")] + async fn create_community(&self, Parameters(req): Parameters) -> Result { + match self.core.create_community(&req.name).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Fetch a user's published MLS keypackages from relays. Returns list of (device_id, created_at). Advanced — prefer `invite` which handles this automatically.")] - async fn fetch_keypackages(&self, Parameters(req): Parameters) -> Result { - match self.core.fetch_keypackages(&req.npub).await { - Ok(packages) => { - let json = serde_json::to_string_pretty(&packages.iter() - .map(|(id, ts)| serde_json::json!({"device_id": id, "created_at": ts})) - .collect::>() - ).unwrap_or_else(|_| "[]".into()); - Ok(CallToolResult::success(vec![Content::text(json)])) - } + #[tool(description = "Mint a shareable public invite link for a Community this identity owns. Returns the URL.")] + async fn create_public_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.create_public_invite(&req.community_id).await { + Ok(url) => Ok(CallToolResult::success(vec![Content::text(url)])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Invite a single member to an MLS group with explicit device_id. Advanced — prefer `invite` which auto-selects latest keypackage.")] - async fn invite_member(&self, Parameters(req): Parameters) -> Result { - match self.core.invite_member(&req.group_id, &req.npub, &req.device_id).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text( - format!("Invited {} to group", &req.npub[..20.min(req.npub.len())]) + #[tool(description = "Send a PRIVATE community invite: gift-wrap the invite bundle directly to an npub over a NIP-17 DM (NOT a shareable link). The recipient parks it pending consent (accept_pending_invite). Requires the create-invite permission; a banned npub can't be re-invited.")] + async fn send_private_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.invite_to_community(&req.community_id, &req.npub).await { + Ok(v) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()) )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Invite multiple members to an MLS group in a single commit")] - async fn invite_members(&self, Parameters(req): Parameters) -> Result { - let devices: Vec<(&str, &str)> = req.members.iter() - .map(|m| (m.npub.as_str(), m.device_id.as_str())) - .collect(); - match self.core.invite_members(&req.group_id, &devices).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text( - format!("Invited {} members to group", req.members.len()) + #[tool(description = "List the public invite links this account holds for a Community (each with its hex token, url, and expiry). Use a token with revoke_public_invite.")] + async fn list_public_invites(&self, Parameters(req): Parameters) -> Result { + match self.core.list_public_invites(&req.community_id) { + Ok(records) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&records).unwrap_or_else(|_| "[]".into()) )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Remove a member from an MLS group (admin only)")] - async fn remove_member(&self, Parameters(req): Parameters) -> Result { - match self.core.remove_member(&req.group_id, &req.npub).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text( - format!("Removed {} from group", &req.npub[..20.min(req.npub.len())]) - )])), + #[tool(description = "Revoke a public invite link by its hex token. Retiring the LAST active link flips the Community to Private, which re-keys (re-founds) to cut link-joined lurkers. Needs a local key when it triggers that rekey.")] + async fn revoke_public_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.revoke_public_invite(&req.community_id, &req.token).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Revoked.")])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Update MLS group metadata (name, description, or admin list)")] - async fn update_group(&self, Parameters(req): Parameters) -> Result { - let admin_refs: Option> = req.admin_npubs.as_ref() - .map(|v| v.iter().map(|s| s.as_str()).collect()); - match self.core.update_group( - &req.group_id, - req.name.as_deref(), - req.description.as_deref(), - admin_refs.as_deref(), - ).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text("Group updated")])), + #[tool(description = "Join a Vector Community from a public invite URL. Fetches the invite bundle, joins, and registers its channels as chats.")] + async fn join_community(&self, Parameters(req): Parameters) -> Result { + match self.core.join_community(&req.invite_url).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) + )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Leave an MLS group")] - async fn leave_group(&self, Parameters(req): Parameters) -> Result { - match self.core.leave_group(&req.group_id).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text("Left group")])), + #[tool(description = "List PRIVATE community invites received via gift-wrapped DM and parked awaiting consent (each: community_id, name, inviter_npub). Accept one with accept_pending_invite.")] + async fn list_pending_invites(&self) -> Result { + match self.core.list_pending_invites() { + Ok(list) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&list).unwrap_or_else(|_| "[]".into()) + )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Publish this device's MLS KeyPackage to relays. Required before anyone can invite you to MLS groups. Called automatically on agent startup — only use manually if you suspect your keypackage is missing or corrupted.")] - async fn publish_keypackage(&self) -> Result { - match self.core.publish_keypackage(false).await { - Ok(kp) => Ok(CallToolResult::success(vec![Content::text( - format!("KeyPackage published: device={}, ref={}", kp.device_id, kp.keypackage_ref) + #[tool(description = "Accept a parked PRIVATE community invite by community_id (consent-then-join for a gift-wrapped invite). Folds the latest control plane, registers channels, announces presence. See list_pending_invites.")] + async fn accept_pending_invite(&self, Parameters(req): Parameters) -> Result { + match self.core.accept_pending_invite(&req.community_id).await { + Ok(summary) => Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&summary).unwrap_or_else(|_| "{}".into()) )])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "List pending MLS group invites that you've received but not yet accepted. Each invite has a welcome_event_id used for accept/decline.")] - async fn list_invites(&self) -> Result { - match self.core.list_invites().await { - Ok(invites) => { - let json = serde_json::to_string_pretty(&invites).unwrap_or_else(|_| "[]".into()); - Ok(CallToolResult::success(vec![Content::text(json)])) - } + #[tool(description = "Send a text message to a Vector Community channel. Returns the message id.")] + async fn send_community_message(&self, Parameters(req): Parameters) -> Result { + match self.core.send_community_message(&req.channel_id, &req.content, req.replied_to.as_deref()).await { + Ok(id) => Ok(CallToolResult::success(vec![Content::text(format!("Sent (message id {id})"))])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Accept a pending MLS group invite by its welcome_event_id. Joins the group, syncs participants, and fetches recent messages.")] - async fn accept_invite(&self, Parameters(req): Parameters) -> Result { - match self.core.accept_invite(&req.welcome_event_id).await { - Ok(group_id) => Ok(CallToolResult::success(vec![Content::text( - format!("Joined group: {}", group_id) - )])), + #[tool(description = "Fetch the latest page of a Community channel from relays (messages, reactions, edits, deletes, presence). Returns the count of new messages. Then use get_messages to read them.")] + async fn sync_community_channel(&self, Parameters(req): Parameters) -> Result { + match self.core.sync_community_channel(&req.channel_id, req.limit).await { + Ok((n, warnings)) => { + // Surface non-fatal warnings (catch-up / control-fold / read-cut-resume errors) so the agent + // is never blind to "the sync ran but a re-founding couldn't be resumed." + let mut msg = format!("Synced: {n} new message(s)"); + if !warnings.is_empty() { + msg.push_str("\n⚠ warnings:"); + for w in &warnings { + msg.push_str(&format!("\n - {w}")); + } + } + Ok(CallToolResult::success(vec![Content::text(msg)])) + } Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Decline a pending MLS group invite by its welcome_event_id. Removes it without joining.")] - async fn decline_invite(&self, Parameters(req): Parameters) -> Result { - match self.core.decline_invite(&req.welcome_event_id).await { - Ok(()) => Ok(CallToolResult::success(vec![Content::text("Invite declined")])), + #[tool(description = "List the observed members of a Community (people who've posted or announced a join, minus those who left or are banned). Each is {npub, last_active}.")] + async fn get_community_members(&self, Parameters(req): Parameters) -> Result { + let members = self.core.get_community_members(&req.community_id).await; + Ok(CallToolResult::success(vec![Content::text( + serde_json::to_string_pretty(&members).unwrap_or_else(|_| "[]".into()) + )])) + } + + #[tool(description = "Leave a Community: announces a 'left' presence, then drops the held keys and local channels. A fresh invite is needed to rejoin.")] + async fn leave_community(&self, Parameters(req): Parameters) -> Result { + match self.core.leave_community(&req.community_id).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Left the community.")])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Sync all MLS groups from relays. Returns count of processed events and new messages.")] - async fn sync_groups(&self) -> Result { - match self.core.sync_groups().await { - Ok((processed, new)) => Ok(CallToolResult::success(vec![Content::text( - format!("Synced: {} events processed, {} new messages", processed, new) - )])), + #[tool(description = "My management capabilities in a Community (manage_metadata, kick, ban, manage_roles, etc.). Use to confirm a promotion/demotion landed. Sync the channel first so the roster is current.")] + async fn get_community_capabilities(&self, Parameters(req): Parameters) -> Result { + match self.core.community_capabilities(&req.community_id) { + Ok(v) => Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Sync DM history from relays using NIP-77 negentropy reconciliation. Fetches missed messages and populates chat history. Use since_days to limit scope (e.g. 7 for last week) or omit for full sync.")] - async fn sync_dms(&self, Parameters(req): Parameters) -> Result { - match self.core.sync_dms(req.since_days, &vector_core::NoOpEventHandler).await { - Ok((events, new)) => Ok(CallToolResult::success(vec![Content::text( - format!("DM sync complete: {} events processed, {} new messages", events, new) - )])), + #[tool(description = "The Community's roles: the owner npub and the list of admin npubs. Sync the channel first so the roster is current.")] + async fn get_community_roles(&self, Parameters(req): Parameters) -> Result { + match self.core.community_roles(&req.community_id) { + Ok(v) => Ok(CallToolResult::success(vec![Content::text(serde_json::to_string_pretty(&v).unwrap_or_else(|_| "{}".into()))])), Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - // === Profiles === - - #[tool(description = "Get a user's profile by npub (from local cache)")] - async fn get_profile(&self, Parameters(req): Parameters) -> Result { - match self.core.get_profile(&req.npub).await { - Some(profile) => { - let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); - Ok(CallToolResult::success(vec![Content::text(json)])) - } - None => Ok(CallToolResult::error(vec![Content::text("Profile not found in cache. Use load_profile to fetch from relays.")])), + #[tool(description = "Grant a member the Community @admin role. Requires manage_roles + outranking the role. Re-verified by every peer.")] + async fn grant_community_admin(&self, Parameters(req): Parameters) -> Result { + match self.core.grant_admin(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Granted @admin to {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Fetch a user's profile metadata from Nostr relays (updates local cache)")] - async fn load_profile(&self, Parameters(req): Parameters) -> Result { - if self.core.load_profile(&req.npub).await { - match self.core.get_profile(&req.npub).await { - Some(profile) => { - let json = serde_json::to_string_pretty(&profile).unwrap_or_else(|_| "{}".into()); - Ok(CallToolResult::success(vec![Content::text(json)])) - } - None => Ok(CallToolResult::success(vec![Content::text("Profile fetched but not cached")])), - } - } else { - Ok(CallToolResult::error(vec![Content::text("Failed to fetch profile from relays")])) + #[tool(description = "Revoke a member's Community @admin role.")] + async fn revoke_community_admin(&self, Parameters(req): Parameters) -> Result { + match self.core.revoke_admin(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Revoked @admin from {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Update the current user's profile (name, avatar URL, banner URL, about/bio)")] - async fn update_profile(&self, Parameters(req): Parameters) -> Result { - if self.core.update_profile(&req.name, &req.avatar, &req.banner, &req.about).await { - Ok(CallToolResult::success(vec![Content::text("Profile updated")])) - } else { - Ok(CallToolResult::error(vec![Content::text("Failed to update profile")])) + #[tool(description = "Kick a member (cooperative): they self-remove but can rejoin with a fresh invite. Requires the kick permission + outranking the target.")] + async fn kick_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.kick_member(&req.community_id, &req.npub).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Kicked {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Block a user by npub")] - async fn block_user(&self, Parameters(req): Parameters) -> Result { - if self.core.block_user(&req.npub).await { - Ok(CallToolResult::success(vec![Content::text(format!("Blocked {}", &req.npub[..20.min(req.npub.len())]))])) - } else { - Ok(CallToolResult::error(vec![Content::text("Failed to block user")])) + #[tool(description = "Ban a member: terminal removal (no rejoin). In a PRIVATE community this also re-keys to cut their read access (needs a local key). Requires the ban permission + outranking the target.")] + async fn ban_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.set_member_banned(&req.community_id, &req.npub, true).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Banned {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Unblock a user by npub")] - async fn unblock_user(&self, Parameters(req): Parameters) -> Result { - if self.core.unblock_user(&req.npub).await { - Ok(CallToolResult::success(vec![Content::text(format!("Unblocked {}", &req.npub[..20.min(req.npub.len())]))])) - } else { - Ok(CallToolResult::error(vec![Content::text("Failed to unblock user")])) + #[tool(description = "Unban a member (removes them from the banlist so they may rejoin).")] + async fn unban_community_member(&self, Parameters(req): Parameters) -> Result { + match self.core.set_member_banned(&req.community_id, &req.npub, false).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!("Unbanned {}", req.npub))])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Set a local nickname for a user (only visible to you)")] - async fn set_nickname(&self, Parameters(req): Parameters) -> Result { - if self.core.set_nickname(&req.npub, &req.nickname).await { - Ok(CallToolResult::success(vec![Content::text(format!("Nickname set to '{}'", req.nickname))])) - } else { - Ok(CallToolResult::error(vec![Content::text("Failed to set nickname")])) + #[tool(description = "DESTRUCTIVE, OWNER-ONLY, IRREVERSIBLE: dissolve (permanently delete) a Community. Publishes a terminal tombstone so no new messages or changes are EVER accepted by anyone (including you), and retires your own invite links. Cannot be undone. Already-sent messages aren't erased, but people can still delete their OWN past messages. Requires you to be the proven owner.")] + async fn delete_community(&self, Parameters(req): Parameters) -> Result { + match self.core.dissolve_community(&req.community_id).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Community dissolved (deleted). It is permanently sealed: no new activity will be accepted.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), } } - #[tool(description = "Get all blocked user profiles")] - async fn get_blocked_users(&self) -> Result { - let blocked = self.core.get_blocked_users().await; - let json = serde_json::to_string_pretty(&blocked).unwrap_or_else(|_| "[]".into()); - Ok(CallToolResult::success(vec![Content::text(json)])) + #[tool(description = "Edit a Community's name and/or description (requires the manage-metadata permission). Omit a field to leave it unchanged; an empty description clears it.")] + async fn edit_community_metadata(&self, Parameters(req): Parameters) -> Result { + match self.core.edit_community_metadata(&req.community_id, req.name.as_deref(), req.description.as_deref()).await { + Ok(()) => Ok(CallToolResult::success(vec![Content::text("Community metadata updated.")])), + Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])), + } } } diff --git a/crates/vector-core/Cargo.toml b/crates/vector-core/Cargo.toml index 4a230f53..622585a0 100644 --- a/crates/vector-core/Cargo.toml +++ b/crates/vector-core/Cargo.toml @@ -19,20 +19,11 @@ bip39 = { version = "2.2.2", features = ["rand"] } # Database rusqlite = { version = "0.32", features = ["bundled"] } -# MLS (Marmot/MDK) — exact same rev as src-tauri -mdk-core = { git = "https://github.com/marmot-protocol/mdk.git", rev = "136a9ee929580206ea0357d48d9766427918186d", features = ["mip04"] } -mdk-sqlite-storage = { git = "https://github.com/marmot-protocol/mdk.git", rev = "136a9ee929580206ea0357d48d9766427918186d" } -mdk-storage-traits = { git = "https://github.com/marmot-protocol/mdk.git", rev = "136a9ee929580206ea0357d48d9766427918186d" } - -# OpenMLS crypto pins — must match MDK's transitive deps (avoids curve25519-dalek conflict with iroh in src-tauri) -openmls_rust_crypto = "=0.5.0" -hpke-rs-rust-crypto = "=0.4.0" -# Needed to access engine.provider.storage() via OpenMlsProvider trait method -openmls_traits = { version = "0.5", default-features = false } - # Async tokio = { version = "1.49.0", features = ["sync", "time", "net", "io-util", "rt-multi-thread", "macros"] } futures-util = "0.3.31" +# Boxed, Send futures for the Community Transport trait so impls work in spawned tasks. +async-trait = "0.1" # Serialization serde = { version = "1.0", features = ["derive"] } @@ -44,7 +35,11 @@ aes-gcm = "0.10.3" chacha20poly1305 = "0.10.1" argon2 = "0.5.3" sha2 = "0.10.9" -zeroize = "1.8" +# HKDF-SHA256 for the Community protocol's frozen key-derivation convention +# (GROUP_PROTOCOL.md §14). Audited RustCrypto crate rather than a hand-rolled +# construction, since the derivation is wire-immutable. +hkdf = "0.12" +zeroize = { version = "1.8", features = ["derive"] } # HTTP reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json", "stream", "charset", "socks"] } @@ -63,10 +58,9 @@ rustls = { version = "0.23", default-features = false, features = ["ring", "logg # Tor (Arti) — VectorPrivacy fork with a single one-line patch on tor-dirmgr's # Cargo.toml relaxing its rusqlite constraint from `>=0.36` to `>=0.32` so we -# can share libsqlite3-sys with Vector's MDK-pinned rusqlite 0.32. The actual -# rusqlite API surface tor-dirmgr uses hasn't changed, so no source edits are -# needed. When MDK 0.7.1 lands and Vector can move to rusqlite 0.37, drop this -# fork and use stock arti-client. +# can share libsqlite3-sys with Vector's rusqlite 0.32. The actual rusqlite API +# surface tor-dirmgr uses hasn't changed, so no source edits are needed. Drop +# this fork if/when Vector moves to rusqlite 0.37+. # # Direct path-equivalent git deps (not [patch.crates-io]) so the entire arti # workspace resolves through one consistent source — patching only tor-dirmgr @@ -97,3 +91,7 @@ tor = ["dep:arti-client", "dep:tor-rtcompat", "dep:tokio-util", "dep:tor-circmgr [dev-dependencies] tempfile = "3" +# `test-util` (test-only — NOT in the production tokio features) enables tokio's virtual clock +# (`start_paused`) so timing-based tests (e.g. the inbox-relays debounce window) resolve +# deterministically instead of depending on wall-clock margins under parallel CPU load. +tokio = { version = "1.49.0", features = ["test-util"] } diff --git a/crates/vector-core/examples/concord_control_dump.rs b/crates/vector-core/examples/concord_control_dump.rs new file mode 100644 index 00000000..627e9c31 --- /dev/null +++ b/crates/vector-core/examples/concord_control_dump.rs @@ -0,0 +1,125 @@ +//! One-off diagnostic: fetch a Community's control plane live and print exactly what the fold sees — +//! the GroupRoot candidates (name + author + inner_id), what's gapped/quarantined, the folded grants, +//! and whether a given author resolves as MANAGE_METADATA-authorized. There are no Concord logs, so this +//! is the only window besides the raw DB. +//! +//! Usage: cargo run -p vector-core --example concord_control_dump -- [author_hex] +//! Run against a COPY of the live DB (sqlite3 live.db ".backup copy.db") so it never locks/mutates the real one. + +use nostr_sdk::prelude::*; +use std::time::Duration; +use vector_core::community::{roster, CommunityId}; + +fn hex32(s: &str) -> [u8; 32] { + let mut out = [0u8; 32]; + for i in 0..32 { + out[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); + } + out +} +fn hx(b: &[u8]) -> String { + b.iter().map(|x| format!("{:02x}", x)).collect() +} + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let args: Vec = std::env::args().collect(); + let app_dir = &args[1]; + let npub = &args[2]; + let cid_hex = &args[3]; + let author = args.get(4).cloned(); + + vector_core::db::set_app_data_dir(std::path::PathBuf::from(app_dir)); + vector_core::db::set_current_account(npub.clone()).unwrap(); + vector_core::db::init_database(npub).unwrap(); + + let cid = CommunityId(hex32(cid_hex)); + let community = vector_core::db::community::load_community(&cid).unwrap().expect("community not in DB"); + let owner_hex = community.owner_attestation.as_ref().and_then(|j| Event::from_json(j).ok()).map(|e| e.pubkey.to_hex()); + println!("community = {:?} epoch = {} relays = {:?}", community.name, community.server_root_epoch.0, community.relays); + println!("proven owner = {:?}", owner_hex); + + // Fetch the control plane at the current server-root epoch pseudonym, exactly as fetch_control_folded does. + let pseudonym = roster::control_pseudonym(&community.server_root_key, &community.id, community.server_root_epoch); + println!("control pseudonym = {}", pseudonym); + let client = Client::default(); + for r in &community.relays { + let _ = client.add_relay(r).await; + } + client.connect().await; + let filter = Filter::new() + .kind(Kind::Custom(3308)) + .custom_tags(SingleLetterTag::lowercase(Alphabet::Z), [pseudonym]); + let events = client.fetch_events_from(community.relays.clone(), filter, Duration::from_secs(20)).await.unwrap(); + println!("\nfetched {} raw control events from relays", events.len()); + let inners: Vec = events.iter().filter_map(|e| roster::open_control_edition(e, &community.server_root_key).ok()).collect(); + println!("opened {} inner editions", inners.len()); + + let floors = vector_core::db::community::get_all_edition_heads(cid_hex).unwrap(); + if let Some((v, h)) = floors.get(cid_hex) { + println!("\nGroupRoot persisted floor: v{} self_hash={}", v, hx(h)); + } else { + println!("\nGroupRoot persisted floor: NONE (bootstrapping)"); + } + + let folded = roster::fold_roster(&inners, &community.id, &floors); + + println!("\n=== GroupRoot candidates (what the consumer scans, version desc / inner_id asc) ==="); + if folded.root_candidates.is_empty() { + println!(" (EMPTY — GroupRoot was quarantined or no edition >= floor)"); + } + for c in &folded.root_candidates { + let auth = owner_hex.as_deref().map(|o| { + roster::authorize_delegation(&folded, Some(o)).is_authorized(&c.author.to_hex(), Some(o), vector_core::community::roles::Permissions::MANAGE_METADATA) + }); + println!( + " v{} name={:?} author={} inner_id={} self_hash={} AUTHORIZED={:?}", + c.head.version, c.meta.name, c.author.to_hex(), hx(&c.head.inner_id), hx(&c.head.self_hash), auth + ); + } + + println!("\n=== gapped / quarantined entities ==="); + for g in &folded.gapped_entities { + let tag = if *g == community.id.0 { " <- GroupRoot" } else { "" }; + println!(" {}{}", hx(g), tag); + } + + println!("\n=== folded grants (authority) ==="); + for (i, g) in folded.roles.grants.iter().enumerate() { + let who = folded.grant_authors.get(i).map(|a| a.to_hex()).unwrap_or_default(); + println!(" member={} roles={:?} (granted by {})", g.member, g.role_ids, who); + } + + if let (Some(a), Some(o)) = (author.as_ref(), owner_hex.as_deref()) { + let authd = roster::authorize_delegation(&folded, Some(o)); + let yes = authd.is_authorized(a, Some(o), vector_core::community::roles::Permissions::MANAGE_METADATA); + println!("\nauthor {} MANAGE_METADATA authorized in THIS fold = {}", a, yes); + } + + // === RE-ANCHOR COVERAGE (why privatize aborts) === + // The base rotation demands every version 1..=head of every tracked entity be re-fetchable. Compute + // expected (from the persisted heads) vs what the live fetch actually returns, and print the gap. + use std::collections::HashSet; + let mut expected: HashSet<(String, u64)> = HashSet::new(); + for (entity, (head_v, _)) in &floors { + for v in 1..=*head_v { + expected.insert((entity.clone(), v)); + } + } + let mut fetched: HashSet<(String, u64)> = HashSet::new(); + for inner in &inners { + if let Ok(p) = vector_core::community::edition::parse_edition_inner(inner) { + fetched.insert((hx(&p.entity_id), p.version)); + } + } + let mut missing: Vec<(String, u64)> = expected.difference(&fetched).cloned().collect(); + missing.sort(); + println!("\n=== RE-ANCHOR COVERAGE ==="); + println!("expected (entity,version) pairs = {}", expected.len()); + println!("fetched distinct (entity,version) = {}", fetched.len()); + println!("MISSING from the live fetch = {} (each one aborts the base rotation):", missing.len()); + for (e, v) in &missing { + println!(" {} v{}", e, v); + } +} diff --git a/crates/vector-core/examples/invite_probe.rs b/crates/vector-core/examples/invite_probe.rs new file mode 100644 index 00000000..50a9e846 --- /dev/null +++ b/crates/vector-core/examples/invite_probe.rs @@ -0,0 +1,67 @@ +//! Probe a public-invite token's bundle on the relays: is the bundle still present, and is there a +//! NIP-09 deletion referencing its coordinate? Used to verify that revoking a public invite actually +//! removes it from relays. +//! +//! Usage: cargo run -p vector-core --example invite_probe -- + +use nostr_sdk::prelude::*; +use std::time::Duration; +use vector_core::community::public_invite; + +fn hex32(s: &str) -> [u8; 32] { + let mut o = [0u8; 32]; + for i in 0..32 { + o[i] = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16).unwrap(); + } + o +} + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + let args: Vec = std::env::args().collect(); + let token = hex32(&args[1]); + let relays: Vec = args[2].split(',').map(|s| s.to_string()).collect(); + + let signer_pk = public_invite::signer_pubkey(&token); + let locator = public_invite::locator_hex(&token); + let coord_a = format!("30078:{}:{}", signer_pk.to_hex(), locator); + println!("token = {}", args[1]); + println!("signer = {}", signer_pk.to_hex()); + println!("locator = {}", locator); + println!("coord = {}", coord_a); + + let client = Client::default(); + for r in &relays { + let _ = client.add_relay(r).await; + } + client.connect().await; + + // 1) The bundle itself, at (kind 30078, author=signer, #d=locator). + let bundle_filter = Filter::new().kind(Kind::Custom(30078)).author(signer_pk).identifier(locator.clone()); + let bundles = client.fetch_events_from(relays.clone(), bundle_filter, Duration::from_secs(15)).await.unwrap(); + println!("\nEvents at the coordinate: {}", bundles.len()); + for e in bundles.iter() { + let vsk = e.tags.iter().find_map(|t| { let s = t.as_slice(); (s.len() >= 2 && s[0] == "vsk").then(|| s[1].clone()) }).unwrap_or_default(); + let kind = if e.content.is_empty() && vsk == "9" { "TOMBSTONE (revoked)" } else if vsk == "6" { "LIVE BUNDLE" } else { "?" }; + println!(" vsk={} content_len={} -> {} (id={})", vsk, e.content.len(), kind, e.id); + } + + // 2) Any NIP-09 deletion (kind 5) by the token-signer referencing this coordinate (`a` tag). + let del_filter = Filter::new().kind(Kind::Custom(5)).author(signer_pk); + let dels = client.fetch_events_from(relays.clone(), del_filter, Duration::from_secs(15)).await.unwrap(); + let matching: Vec<_> = dels + .iter() + .filter(|e| e.tags.iter().any(|t| { let s = t.as_slice(); s.len() >= 2 && s[0] == "a" && s[1] == coord_a })) + .collect(); + println!("\nDELETION (kind 5) events by the signer referencing this coord: {}", matching.len()); + for e in &matching { + println!(" id={} created_at={}", e.id, e.created_at.as_u64()); + } + + println!( + "\n=> bundle is {} on relays; matching deletion is {} present", + if bundles.is_empty() { "GONE" } else { "STILL PRESENT" }, + if matching.is_empty() { "NOT" } else { "" } + ); +} diff --git a/crates/vector-core/src/chat.rs b/crates/vector-core/src/chat.rs index de8eff0b..579fe213 100644 --- a/crates/vector-core/src/chat.rs +++ b/crates/vector-core/src/chat.rs @@ -78,9 +78,12 @@ impl Chat { Self::new(their_npub, ChatType::DirectMessage, vec![handle]) } - pub fn new_mls_group(group_id: String, participants: Vec, interner: &mut NpubInterner) -> Self { + + /// A Community channel chat (GROUP_PROTOCOL.md). `channel_id` is the channel's + /// stable random id (hex); `participants` are the members known so far. + pub fn new_community_channel(channel_id: String, participants: Vec, interner: &mut NpubInterner) -> Self { let handles: Vec = participants.iter().map(|p| interner.intern(p)).collect(); - Self::new(group_id, ChatType::MlsGroup, handles) + Self::new(channel_id, ChatType::Community, handles) } // ======================================================================== @@ -221,7 +224,8 @@ impl Chat { .find(|&&h| Some(h) != my_handle) .and_then(|&h| interner.resolve(h).map(|s| s.to_string())) } - ChatType::MlsGroup => None, + // Community channels have no single "other" participant. + ChatType::Community => None, } } @@ -230,7 +234,7 @@ impl Chat { && interner.lookup(npub).map_or(false, |h| self.participants.contains(&h)) } - pub fn is_mls_group(&self) -> bool { matches!(self.chat_type, ChatType::MlsGroup) } + pub fn is_community(&self) -> bool { matches!(self.chat_type, ChatType::Community) } pub fn has_participant(&self, npub: &str, interner: &NpubInterner) -> bool { interner.lookup(npub).map_or(false, |h| self.participants.contains(&h)) @@ -323,15 +327,25 @@ impl SerializableChat { #[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] pub enum ChatType { DirectMessage, - MlsGroup, + /// A Community channel (GROUP_PROTOCOL.md — the MLS successor). The chat `id` + /// is the channel's stable random id. + Community, } impl ChatType { + // Discriminant 1 was the removed MlsGroup variant; Community keeps 2 for + // on-disk compatibility. Legacy chat_type=1 rows are dropped at the DB load layer. pub fn to_i32(&self) -> i32 { - match self { ChatType::DirectMessage => 0, ChatType::MlsGroup => 1 } + match self { + ChatType::DirectMessage => 0, + ChatType::Community => 2, + } } pub fn from_i32(value: i32) -> Self { - match value { 1 => ChatType::MlsGroup, _ => ChatType::DirectMessage } + match value { + 2 => ChatType::Community, + _ => ChatType::DirectMessage, + } } } @@ -398,19 +412,19 @@ mod tests { } #[test] - fn new_mls_group_with_participants() { + fn new_community_channel_with_participants() { let mut interner = NpubInterner::new(); let participants = vec![ "npub1alice".to_string(), "npub1bob".to_string(), "npub1charlie".to_string(), ]; - let chat = Chat::new_mls_group("grp_abc".to_string(), participants, &mut interner); + let chat = Chat::new_community_channel("grp_abc".to_string(), participants, &mut interner); assert_eq!(chat.id, "grp_abc", "group chat id should match"); - assert_eq!(chat.chat_type, ChatType::MlsGroup, "should be MlsGroup type"); + assert_eq!(chat.chat_type, ChatType::Community, "should be Community type"); assert_eq!(chat.participants.len(), 3, "should have 3 participants"); - assert!(chat.is_mls_group(), "is_mls_group() should return true"); + assert!(chat.is_community(), "is_community() should return true"); } #[test] @@ -590,7 +604,7 @@ mod tests { fn to_serializable_and_back_via_to_chat() { let mut interner = NpubInterner::new(); let participants = vec!["npub1alice".to_string(), "npub1bob".to_string()]; - let mut chat = Chat::new_mls_group("grp_test".to_string(), participants.clone(), &mut interner); + let mut chat = Chat::new_community_channel("grp_test".to_string(), participants.clone(), &mut interner); chat.metadata.set_name("Test Group".to_string()); chat.muted = true; @@ -605,7 +619,7 @@ mod tests { // Serialize to SerializableChat let serializable = chat.to_serializable(&interner); assert_eq!(serializable.id, "grp_test", "serialized id should match"); - assert_eq!(serializable.chat_type, ChatType::MlsGroup, "serialized type should match"); + assert_eq!(serializable.chat_type, ChatType::Community, "serialized type should match"); assert_eq!(serializable.participants.len(), 2, "should have 2 participants"); assert_eq!(serializable.messages.len(), 2, "should have 2 messages"); assert!(serializable.muted, "muted should be preserved"); @@ -620,7 +634,7 @@ mod tests { let restored = serializable.to_chat(&mut interner2); assert_eq!(restored.id, "grp_test", "restored id should match"); - assert_eq!(restored.chat_type, ChatType::MlsGroup, "restored type should match"); + assert_eq!(restored.chat_type, ChatType::Community, "restored type should match"); assert_eq!(restored.participants.len(), 2, "restored participants count should match"); assert_eq!(restored.message_count(), 2, "restored message count should match"); assert!(restored.muted, "restored muted should be true"); @@ -650,7 +664,7 @@ mod tests { #[test] fn has_participant_check() { let mut interner = NpubInterner::new(); - let chat = Chat::new_mls_group( + let chat = Chat::new_community_channel( "grp1".to_string(), vec!["npub1alice".to_string(), "npub1bob".to_string()], &mut interner, @@ -688,7 +702,7 @@ mod tests { #[test] fn is_dm_with_returns_false_for_group() { let mut interner = NpubInterner::new(); - let chat = Chat::new_mls_group( + let chat = Chat::new_community_channel( "grp1".to_string(), vec!["npub1alice".to_string()], &mut interner, @@ -716,7 +730,7 @@ mod tests { #[test] fn get_other_participant_returns_none_for_group() { let mut interner = NpubInterner::new(); - let chat = Chat::new_mls_group( + let chat = Chat::new_community_channel( "grp1".to_string(), vec!["npub1alice".to_string(), "npub1bob".to_string()], &mut interner, @@ -785,7 +799,7 @@ mod tests { #[test] fn chat_type_i32_roundtrip() { assert_eq!(ChatType::from_i32(ChatType::DirectMessage.to_i32()), ChatType::DirectMessage); - assert_eq!(ChatType::from_i32(ChatType::MlsGroup.to_i32()), ChatType::MlsGroup); + assert_eq!(ChatType::from_i32(ChatType::Community.to_i32()), ChatType::Community); assert_eq!( ChatType::from_i32(999), ChatType::DirectMessage, "unknown i32 should default to DirectMessage" diff --git a/crates/vector-core/src/community/attachments.rs b/crates/vector-core/src/community/attachments.rs new file mode 100644 index 00000000..6604ea10 --- /dev/null +++ b/crates/vector-core/src/community/attachments.rs @@ -0,0 +1,318 @@ +//! Community message attachments (NIP-92 `imeta`). +//! +//! Unlike NIP-17 DMs (one media item per event), a Community message event carries its +//! caption in `content` plus one `imeta` tag per attachment — so a single message can +//! mix text and N files. Each `imeta` holds the per-file AES-GCM key+nonce (the NIP-17 +//! attachment technique: fresh random key per file), so the Blossom ciphertext is only +//! decryptable by members who can open the event. + +use std::path::Path; +use nostr_sdk::prelude::*; +use crate::types::{Attachment, ImageMetadata}; + +const IMETA: &str = "imeta"; + +/// Encode an [`Attachment`] as a NIP-92 `imeta` tag with Vector's encryption fields. +/// Entries are space-delimited `key value` strings (NIP-92 form); a value may contain +/// spaces (e.g. a filename) since only the first space delimits key from value. +pub fn attachment_to_imeta(att: &Attachment) -> Tag { + let mut fields: Vec = Vec::with_capacity(10); + fields.push(format!("url {}", att.url)); + fields.push(format!("m {}", crate::crypto::mime_from_extension(&att.extension))); + fields.push("encryption-algorithm aes-gcm".to_string()); + fields.push(format!("decryption-key {}", att.key)); + fields.push(format!("decryption-nonce {}", att.nonce)); + if att.size > 0 { + fields.push(format!("size {}", att.size)); + } + if let Some(h) = att.original_hash.as_deref().filter(|h| !h.is_empty()) { + fields.push(format!("ox {}", h)); + } + if !att.name.is_empty() { + fields.push(format!("name {}", att.name)); + } + if let Some(meta) = &att.img_meta { + if !meta.thumbhash.is_empty() { + fields.push(format!("thumb {}", meta.thumbhash)); + } + fields.push(format!("dim {}x{}", meta.width, meta.height)); + } + Tag::custom(TagKind::Custom(IMETA.into()), fields) +} + +/// Read a single `key value` field from an `imeta` tag's entries (value is everything +/// after the first space, so spaces in the value are preserved). +fn field<'a>(entries: &'a [String], key: &str) -> Option<&'a str> { + entries.iter().find_map(|e| { + e.strip_prefix(key) + .and_then(|rest| rest.strip_prefix(' ')) + }) +} + +/// Parse a single `imeta` tag into an [`Attachment`]. `None` if the tag isn't an `imeta` +/// or is missing the required url / decryption fields. `download_dir` computes the +/// (not-yet-downloaded) local target path, mirroring the DM file-attachment path. +pub fn attachment_from_imeta(tag: &Tag, download_dir: &Path) -> Option { + let entries = tag.as_slice(); + if entries.first().map(String::as_str) != Some(IMETA) { + return None; + } + let body = &entries[1..]; + + let url = field(body, "url")?.to_string(); + if url.is_empty() { + return None; + } + let key = field(body, "decryption-key")?.to_string(); + let nonce = field(body, "decryption-nonce")?.to_string(); + + let mime = field(body, "m").unwrap_or("application/octet-stream"); + let name = field(body, "name").map(crate::crypto::sanitize_filename).unwrap_or_default(); + // Prefer the filename's extension (accurate for .toml/.rs/etc. that MIME maps to + // octet-stream); fall back to the MIME-derived extension. + let extension = name + .rsplit('.') + .next() + .filter(|e| !e.is_empty() && *e != name) + .map(|e| e.to_lowercase()) + .unwrap_or_else(|| crate::crypto::extension_from_mime(mime)); + + let size = field(body, "size").and_then(|s| s.parse::().ok()).unwrap_or(0); + let original_hash = field(body, "ox").map(|s| s.to_string()).filter(|s| !s.is_empty()); + + let img_meta = { + let thumb = field(body, "thumb").map(|s| s.to_string()); + let dim = field(body, "dim").and_then(|s| { + let (w, h) = s.split_once('x')?; + Some((w.parse::().ok()?, h.parse::().ok()?)) + }); + match (thumb, dim) { + (Some(thumbhash), Some((width, height))) => Some(ImageMetadata { thumbhash, width, height }), + _ => None, + } + }; + + // Local path keyed on the original hash (dedup across messages) when present, else + // the nonce (unique per send). The basis is author-controlled, so require it to be a + // bounded hex string before joining it into a filesystem path — a hostile member can't + // smuggle `../` traversal into the persisted `path` (defense-in-depth: `open_attachment` + // also re-checks the path is inside the download dir). + let basis = original_hash.clone().unwrap_or_else(|| nonce.clone()); + if basis.is_empty() || basis.len() > 128 || !basis.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let path = download_dir.join(format!("{}.{}", basis, extension)); + let downloaded = path.exists(); + + Some(Attachment { + id: basis, + key, + nonce, + extension, + name, + url, + path: path.to_string_lossy().to_string(), + size, + img_meta, + downloading: false, + downloaded, + webxdc_topic: None, + group_id: None, // Community attachments use explicit key/nonce (NIP-17 technique). + original_hash, + scheme_version: None, + mls_filename: None, + }) +} + +/// Parse every `imeta` tag on an event into attachments, order preserved. +pub fn attachments_from_tags<'a>( + tags: impl Iterator, + download_dir: &Path, +) -> Vec { + tags.filter_map(|t| attachment_from_imeta(t, download_dir)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample(name: &str, ext: &str, with_img: bool) -> Attachment { + Attachment { + id: "h".into(), + key: "0".repeat(64), // 32-byte key + nonce: "1".repeat(32), // 16-byte (0xChat-compatible) nonce + extension: ext.into(), + name: name.into(), + url: "https://blossom.example/abc".into(), + path: String::new(), + size: 4096, + img_meta: with_img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 800, height: 600 }), + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("a".repeat(64)), + scheme_version: None, + mls_filename: None, + } + } + + #[test] + fn imeta_round_trip_preserves_crypto_and_meta() { + let dir = std::env::temp_dir(); + let att = sample("my report.png", "png", true); + let tag = attachment_to_imeta(&att); + let back = attachment_from_imeta(&tag, &dir).expect("parses"); + assert_eq!(back.url, att.url); + assert_eq!(back.key, att.key); + assert_eq!(back.nonce, att.nonce); + assert_eq!(back.size, att.size); + assert_eq!(back.original_hash, att.original_hash); + assert_eq!(back.name, "my report.png"); // space in filename survives + assert_eq!(back.extension, "png"); + assert_eq!(back.group_id, None); + let m = back.img_meta.expect("img meta"); + assert_eq!((m.width, m.height), (800, 600)); + assert_eq!(m.thumbhash, "TH"); + } + + #[test] + fn spoiler_and_renamed_filenames_survive_imeta() { + // Spoiler is detected receiver-side by a `SPOILER_` prefix on the attachment NAME, + // so the name (incl. that prefix, and spaces) must round-trip through imeta intact — + // this is what gives Community attachments spoiler/rename parity with DMs. + let dir = std::env::temp_dir(); + let spoiler = attachment_from_imeta(&attachment_to_imeta(&sample("SPOILER_big reveal.png", "png", true)), &dir).unwrap(); + assert_eq!(spoiler.name, "SPOILER_big reveal.png"); + assert!(spoiler.name.to_uppercase().starts_with("SPOILER_"), "spoiler prefix preserved"); + assert_eq!(spoiler.extension, "png"); + + let renamed = attachment_from_imeta(&attachment_to_imeta(&sample("Quarterly Report (final).pdf", "pdf", false)), &dir).unwrap(); + assert_eq!(renamed.name, "Quarterly Report (final).pdf"); + assert_eq!(renamed.extension, "pdf"); + } + + #[test] + fn field_key_match_requires_a_following_space_no_prefix_bleed() { + // `field(_, "m")` must NOT match a longer key like "mime ..." (shared prefix). The + // "key + ' '" requirement guards this; lock it so future imeta fields can't collide. + let entries = vec!["mime image/png".to_string(), "m image/jpeg".to_string()]; + assert_eq!(field(&entries, "m"), Some("image/jpeg")); + assert_eq!(field(&entries, "mime"), Some("image/png")); + assert_eq!(field(&["decryption-key-x abc".to_string()], "decryption-key"), None); + // A key present with no value (no following space) yields None, not a panic. + assert_eq!(field(&["url".to_string()], "url"), None); + } + + #[test] + fn multiple_imeta_tags_parse_in_order() { + let dir = std::env::temp_dir(); + let tags = vec![ + Tag::custom(TagKind::Custom("z".into()), ["pseudonym"]), + attachment_to_imeta(&sample("a.png", "png", false)), + Tag::custom(TagKind::Custom("ms".into()), ["12"]), + attachment_to_imeta(&sample("b.pdf", "pdf", false)), + ]; + let atts = attachments_from_tags(tags.iter(), &dir); + assert_eq!(atts.len(), 2); + assert_eq!(atts[0].name, "a.png"); + assert_eq!(atts[1].name, "b.pdf"); + assert_eq!(atts[1].extension, "pdf"); + } + + #[test] + fn non_imeta_and_incomplete_tags_are_skipped() { + let dir = std::env::temp_dir(); + let not_imeta = Tag::custom(TagKind::Custom("e".into()), ["abc"]); + assert!(attachment_from_imeta(¬_imeta, &dir).is_none()); + // imeta missing decryption fields → None. + let bad = Tag::custom(TagKind::Custom("imeta".into()), ["url https://x/y"]); + assert!(attachment_from_imeta(&bad, &dir).is_none()); + } + + #[test] + fn imeta_crypto_params_actually_decrypt_the_ciphertext() { + // End-to-end attachment crypto: encrypt a plaintext with the real params, carry the + // key/nonce via imeta, parse them back out, and confirm they decrypt the ciphertext. + // This is the receiver's download path in miniature (minus the Blossom fetch). + let dir = std::env::temp_dir(); + let plaintext = b"the quick brown fox jumps over 13 lazy dogs".to_vec(); + let params = crate::crypto::generate_encryption_params(); + let ciphertext = crate::crypto::encrypt_data(&plaintext, ¶ms).unwrap(); + + let att = Attachment { + id: "x".into(), + key: params.key.clone(), + nonce: params.nonce.clone(), + extension: "txt".into(), + name: "note.txt".into(), + url: "https://blossom.example/blob".into(), + path: String::new(), + size: ciphertext.len() as u64, + img_meta: None, + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("c".repeat(64)), + scheme_version: None, + mls_filename: None, + }; + let parsed = attachment_from_imeta(&attachment_to_imeta(&att), &dir).expect("parses"); + // The parsed key/nonce (straight off the imeta) must decrypt the ciphertext. + let decrypted = crate::crypto::decrypt_data(&ciphertext, &parsed.key, &parsed.nonce) + .expect("decrypts with imeta-carried params"); + assert_eq!(decrypted, plaintext, "round-trip plaintext matches"); + } + + #[test] + fn hostile_path_basis_is_rejected() { + // A channel member authors the imeta, so the path basis (`ox`, else `nonce`) is + // attacker-controlled. A non-hex / traversal basis must be refused, never joined + // into a filesystem path. + let dir = std::path::Path::new("/tmp/vector-test-dl"); + let traversal = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y", + "decryption-key 00", + "decryption-nonce 11", + "ox ../../../../etc/passwd", + ]); + assert!(attachment_from_imeta(&traversal, dir).is_none(), "traversal ox rejected"); + + // Falls back to nonce when ox absent — a non-hex nonce is likewise rejected. + let bad_nonce = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y", + "decryption-key 00", + "decryption-nonce ../evil", + ]); + assert!(attachment_from_imeta(&bad_nonce, dir).is_none(), "traversal nonce rejected"); + + // A legitimate hex basis still parses. + let good = Tag::custom(TagKind::Custom("imeta".into()), [ + "url https://x/y".to_string(), + "decryption-key 00".to_string(), + "decryption-nonce 11".to_string(), + format!("ox {}", "a".repeat(64)), + ]); + assert!(attachment_from_imeta(&good, dir).is_some(), "hex ox accepted"); + } + + #[test] + fn malformed_imeta_does_not_panic_and_drops_gracefully() { + let dir = std::env::temp_dir(); + // Garbage entries, duplicate keys, value-less keys, weird spacing — must not panic. + let junk = Tag::custom(TagKind::Custom("imeta".into()), [ + "url", // no value + "decryption-key", // no value + "random noise here", + " ", + "url https://x/legit", // a later valid url + ]); + // Missing decryption-key/nonce → None (not a panic). + assert!(attachment_from_imeta(&junk, &dir).is_none()); + + // Empty imeta (just the tag name) → None. + let empty = Tag::custom(TagKind::Custom("imeta".into()), Vec::::new()); + assert!(attachment_from_imeta(&empty, &dir).is_none()); + } +} diff --git a/crates/vector-core/src/community/cipher.rs b/crates/vector-core/src/community/cipher.rs new file mode 100644 index 00000000..d413bbae --- /dev/null +++ b/crates/vector-core/src/community/cipher.rs @@ -0,0 +1,47 @@ +//! Raw-key NIP-44 v2 sealing — the single symmetric-encryption primitive of the +//! Community protocol. The channel key (message plane) and the server-root key +//! (metadata plane) are both raw 32-byte `ConversationKey`s; ciphertext is +//! base64'd for carriage in an event's string `content` field. + +use nostr_sdk::nips::nip44::v2::{decrypt_to_bytes, encrypt_to_bytes, ConversationKey}; + +/// Encrypt `plaintext` under a raw 32-byte key, returning base64 for event content. +pub fn seal(key: &[u8; 32], plaintext: &[u8]) -> Result { + let ck = ConversationKey::new(*key); + let ciphertext = encrypt_to_bytes(&ck, plaintext).map_err(|e| e.to_string())?; + Ok(base64_simd::STANDARD.encode_to_string(&ciphertext)) +} + +/// Inverse of [`seal`]: base64-decode then NIP-44-decrypt under the raw key. A +/// wrong key or tampered payload fails the MAC and returns `Err`. +pub fn open(key: &[u8; 32], content_b64: &str) -> Result, String> { + let ciphertext = base64_simd::STANDARD + .decode_to_vec(content_b64.as_bytes()) + .map_err(|e| e.to_string())?; + let ck = ConversationKey::new(*key); + decrypt_to_bytes(&ck, &ciphertext).map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn round_trip() { + let key = [0x5au8; 32]; + let sealed = seal(&key, b"hello community").unwrap(); + assert_eq!(open(&key, &sealed).unwrap(), b"hello community"); + } + + #[test] + fn wrong_key_fails() { + let sealed = seal(&[1u8; 32], b"secret").unwrap(); + assert!(open(&[2u8; 32], &sealed).is_err()); + } + + #[test] + fn distinct_ciphertext_per_call() { + let key = [9u8; 32]; + assert_ne!(seal(&key, b"x").unwrap(), seal(&key, b"x").unwrap()); + } +} diff --git a/crates/vector-core/src/community/derive.rs b/crates/vector-core/src/community/derive.rs new file mode 100644 index 00000000..2817aaaa --- /dev/null +++ b/crates/vector-core/src/community/derive.rs @@ -0,0 +1,481 @@ +//! Key-derivation convention (GROUP_PROTOCOL.md) — FROZEN. +//! +//! Every HKDF use in the Community protocol funnels through here. Changing any +//! byte of the construction shifts every pseudonym and sub-key, orphaning all +//! prior events — a forced migration to avoid. The layout is +//! locked by the golden vectors in the test module; treat those as the spec. +//! +//! Construction: `HKDF-SHA256(IKM, salt=∅, info, L=32)`, where +//! `info = utf8(label) || 0x00 || id32 || epoch_be` — +//! - `label` : ASCII purpose string, no terminator +//! - `0x00` : single separator byte +//! - `id32` : raw 32-byte id (channel id, or scope id), never hex +//! - `epoch_be` : the epoch as u64 big-endian (8 bytes); omitted where noted + +use hkdf::Hkdf; +use sha2::Sha256; + +use super::{ChannelId, ChannelKey, CommunityId, Epoch, Pseudonym, ServerRootKey}; +use nostr_sdk::prelude::SecretKey; + +/// Purpose labels. These strings are part of the wire format — append new +/// ones, never edit or reuse an existing one. +const LABEL_CHANNEL_PSEUDONYM: &str = "vector-community/v1/channel-pseudonym"; +const LABEL_RECIPIENT_PSEUDONYM: &str = "vector-community/v1/recipient-pseudonym"; +const LABEL_REKEY_PSEUDONYM: &str = "vector-community/v1/rekey-pseudonym"; +const LABEL_BASE_REKEY_PSEUDONYM: &str = "vector-community/v1/base-rekey-pseudonym"; +const LABEL_PUBLIC_INVITE_KEY: &str = "vector-community/v1/public-invite-key"; +const LABEL_PUBLIC_INVITE_LOCATOR: &str = "vector-community/v1/public-invite-locator"; +const LABEL_PUBLIC_INVITE_SIGNER: &str = "vector-community/v1/public-invite-signer"; +const LABEL_BANLIST_LOCATOR: &str = "vector-community/v1/banlist-locator"; +const LABEL_GRANT_LOCATOR: &str = "vector-community/v1/grant-locator"; +const LABEL_INVITE_LINKS_LOCATOR: &str = "vector-community/v1/invite-links-locator"; +const LABEL_DISSOLVED_LOCATOR: &str = "vector-community/v1/dissolved-locator"; +const LABEL_DISSOLVED_PSEUDONYM: &str = "vector-community/v1/dissolved-pseudonym"; +const LABEL_DISSOLVED_ENVELOPE: &str = "vector-community/v1/dissolved-envelope-key"; + +/// Opaque coordinate for the banlist entity, HKDF-derived from the **community id** — a STABLE +/// logical id that survives a server-root rotation, so a re-anchored banlist binds the same coordinate +/// at every epoch (re-anchoring). Member-computable (members hold the community id from their +/// invite), outsider-opaque (the id is never on the wire — the relay sees only the rotating +/// `control_pseudonym`), and the content stays server-root-encrypted, so privacy is unchanged. +pub fn banlist_locator(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_BANLIST_LOCATOR.as_bytes()) +} + +/// Opaque coordinate for the owner-dissolution tombstone (vsk=10), HKDF-derived from the **community +/// id** — STABLE across a server-root rotation, exactly like `banlist_locator`. This rotation-stability is +/// load-bearing for dissolution: a fresh joiner after a re-founding derives only the NEW epoch root, but +/// can still compute this community-scoped coordinate and discover the tombstone, so a dissolved community +/// can never look "alive" to anyone who can derive the community id. Member-computable, outsider-opaque. +pub fn dissolved_locator(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_LOCATOR.as_bytes()) +} + +/// Rotation-stable relay `#z` for the dissolution tombstone — community-id-derived (NOT the per-epoch +/// `control_pseudonym`), so ANY client that can derive the community id finds the tombstone at the SAME +/// coordinate regardless of which epoch root it holds. This is what closes the post-rotation +/// discoverability split: a fresh joiner who only ever derives a later epoch's root still probes this +/// fixed coordinate and learns the community is dead. Outsider-opaque (community id is never on the wire). +pub fn dissolved_pseudonym(community_id: &CommunityId) -> String { + crate::simd::hex::bytes_to_hex_32(&hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_PSEUDONYM.as_bytes())) +} + +/// Rotation-stable envelope key for the dissolution tombstone — community-id-derived so the tombstone is +/// openable by any member or joiner at ANY epoch. The control plane is server-root-encrypted (per-epoch), +/// which a post-rotation joiner can't open for the publish-epoch; the tombstone carries no secret (content +/// is `{}`), so a community-id key is the right scope — member-computable, outsider-opaque, epoch-free. +pub fn dissolved_envelope_key(community_id: &CommunityId) -> [u8; 32] { + hkdf_sha256_32(&community_id.0, LABEL_DISSOLVED_ENVELOPE.as_bytes()) +} + +/// Opaque coordinate for a CREATOR's own invite-links entity (vsk=8) — the per-creator list of +/// active public-invite-link locators THEY published. Bound to the creator's x-only pubkey exactly like a +/// per-member grant (`grant_locator`), so a creator can only publish links at their own coordinate, and +/// members fold every creator's list into the aggregate active-set (`is_public` = aggregate non-empty). +/// Community-id-derived (stable across rotation, member-computable, outsider-opaque). There is no shared +/// registry — each creator owns only their own list (per-creator ownership). +pub fn invite_links_locator(community_id: &CommunityId, creator_xonly: &[u8; 32]) -> [u8; 32] { + let info = build_info(LABEL_INVITE_LINKS_LOCATOR, creator_xonly, None); + hkdf_sha256_32(&community_id.0, &info) +} + +/// Opaque coordinate for a member's Grant entity (vsk=3), HKDF-derived from the **community id** +/// bound to the member's x-only pubkey. Community-scoped (not server-root-scoped) so the coordinate is +/// STABLE across a base rotation — the keystone that lets a re-anchored grant fold under the new root +/// (re-anchoring): a new joiner holding only the new root still derives the same `entity_id`. +/// Member-computable, outsider-opaque (community id never on the wire), content still server-root- +/// encrypted — privacy unchanged. Roles need no locator (their `d`-tag is the role's random id). +pub fn grant_locator(community_id: &CommunityId, member_xonly: &[u8; 32]) -> [u8; 32] { + let info = build_info(LABEL_GRANT_LOCATOR, member_xonly, None); + hkdf_sha256_32(&community_id.0, &info) +} + +/// Scope of a per-recipient rekey blob. Disambiguates two blobs a single +/// sender delivers to the same recipient in one epoch (a server-root rotation and +/// a channel rekey), which would otherwise collide on the same tag. +#[derive(Debug, Clone, Copy)] +pub enum RekeyScope { + /// A specific channel being rekeyed. + Channel(ChannelId), + /// A server-wide root rotation — not channel-scoped, uses the all-zero sentinel. + ServerRoot, +} + +impl RekeyScope { + /// The 32-byte scope id this rekey binds: the channel id, or the all-zero server-root + /// sentinel. Used by `recipient_pseudonym`, the epoch-keys archive scope, and the blob binding. + pub fn id32(&self) -> [u8; 32] { + match self { + RekeyScope::Channel(c) => c.0, + RekeyScope::ServerRoot => [0u8; 32], + } + } +} + +/// Build the frozen `info` byte string. `epoch` is `None` for the no-epoch derivations +/// (the grant + invite-links locators and the public-invite sub-keys). +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(e) = epoch { + info.extend_from_slice(&e.0.to_be_bytes()); + } + info +} + +/// HKDF-SHA256 expand to 32 bytes with an empty salt. +/// +/// RFC 5869 with no salt uses HashLen zero bytes; the `hkdf` crate's `new(None, ..)` +/// does exactly that, and for HMAC-SHA256 a zero-length salt and a 32-zero-byte salt +/// produce an identical PRK (both pad to the 64-byte block), so this matches the +/// spec's "salt=∅". The expand never fails for L=32 (≤ 255·HashLen). +fn hkdf_sha256_32(ikm: &[u8; 32], info: &[u8]) -> [u8; 32] { + let hk = Hkdf::::new(None, ikm); + let mut okm = [0u8; 32]; + hk.expand(info, &mut okm) + .expect("HKDF expand of 32 bytes is infallible"); + okm +} + +/// Channel pseudonym: the value carried in the relay-filterable `z` tag. +/// Every member derives the same one from the shared channel secret, so it both +/// addresses and (by rotation) unlinks the channel's traffic. +pub fn channel_pseudonym(channel_key: &ChannelKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_CHANNEL_PSEUDONYM, &channel_id.0, Some(epoch)); + Pseudonym(hkdf_sha256_32(channel_key.as_bytes(), &info)) +} + +/// The relay-filterable address of a channel REKEY event for `(channel, epoch)`. Derived from +/// the **server-root key** (NOT the channel key) + the channel id + the epoch the rekey introduces. +/// Because the IKM is the server root — which every member always holds and which is stable across a +/// channel rotation — any member can compute this for ANY epoch directly, WITHOUT holding that epoch's +/// (or the prior epoch's) channel key. That is what makes epochs **independently recoverable**: a +/// member fetches the rekey for whichever epoch(s) they choose (latest only, or all, in parallel), +/// rather than chaining forward one key at a time. Distinct from the channel message pseudonym (channel +/// key IKM) and the control pseudonym (community-id binding) by IKM/id, and domain-separated by label. +pub fn rekey_pseudonym(server_root: &ServerRootKey, channel_id: &ChannelId, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_REKEY_PSEUDONYM, &channel_id.0, Some(epoch)); + Pseudonym(hkdf_sha256_32(server_root.as_bytes(), &info)) +} + +/// The relay-filterable address of a SERVER-ROOT (base) rekey for `(community, new_epoch)`. +/// Keyed by the **PRIOR** server-root key — the base layer has no stable key above it, so the prior +/// root is the handle every current member holds: a returning member derives this from the root they +/// currently hold, finds the base rekey, learns the rotator from its inner sig, and recovers the next +/// root (a short forward-walk; base rotations are rare). Binds the community id + epoch, and is +/// label-separated from the channel-rekey / channel-message / control pseudonyms. +pub fn base_rekey_pseudonym(prior_root: &ServerRootKey, community_id: &CommunityId, new_epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_BASE_REKEY_PSEUDONYM, &community_id.0, Some(new_epoch)); + Pseudonym(hkdf_sha256_32(prior_root.as_bytes(), &info)) +} + +/// Per-recipient rekey-blob tag. `IKM` is the pairwise sender↔recipient +/// ECDH secret (not the channel key), so only that pair can locate the blob and a +/// removed member cannot derive tags for pairs they are not in. +pub fn recipient_pseudonym(per_recipient_secret: &[u8; 32], scope: RekeyScope, epoch: Epoch) -> Pseudonym { + let info = build_info(LABEL_RECIPIENT_PSEUDONYM, &scope.id32(), Some(epoch)); + Pseudonym(hkdf_sha256_32(per_recipient_secret, &info)) +} + +/// Reduce HKDF output to a valid secp256k1 scalar with reject-and-retry (the reject +/// branch is ~2^-128 rare but kept deterministic via a counter byte appended to +/// `info`, so derivation stays reproducible cross-implementation). +fn hkdf_to_secret_key(ikm: &[u8; 32], base_info: Vec) -> SecretKey { + let mut counter: u8 = 0; + loop { + let info = if counter == 0 { + base_info.clone() + } else { + let mut extended = base_info.clone(); + extended.push(counter); + extended + }; + let okm = hkdf_sha256_32(ikm, &info); + if let Ok(sk) = SecretKey::from_slice(&okm) { + return sk; + } + counter = counter + .checked_add(1) + .expect("secp256k1 scalar rejection 256 times running is impossible"); + } +} + +/// Public-invite sub-keys, all derived from the URL fetch-token. The token +/// is the IKM and there is no channel/epoch context, so the frozen `info` uses the +/// all-zero id and no epoch — the token alone provides uniqueness. The three labels +/// domain-separate the decryption key, the relay locator (addressable `d`-tag), and the +/// bundle's signing key (so the owner can re-post under one coordinate to rotate, and +/// joiners reject an impostor squatting the locator). +pub fn public_invite_key(token: &[u8; 32]) -> [u8; 32] { + hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_KEY, &[0u8; 32], None)) +} + +pub fn public_invite_locator(token: &[u8; 32]) -> [u8; 32] { + hkdf_sha256_32(token, &build_info(LABEL_PUBLIC_INVITE_LOCATOR, &[0u8; 32], None)) +} + +pub fn public_invite_signer(token: &[u8; 32]) -> SecretKey { + hkdf_to_secret_key(token, build_info(LABEL_PUBLIC_INVITE_SIGNER, &[0u8; 32], None)) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fixed test inputs. The golden hex below was produced by an INDEPENDENT + // HKDF-SHA256 implementation (Python hmac+hashlib, RFC 5869) over these exact + // bytes, so a match proves the construction is correct cross-implementation, + // not merely self-consistent. If any of these assertions ever change, the wire + // format changed — that must be a conscious, versioned decision. + fn test_channel_key() -> ChannelKey { + // 0x00,0x01,..,0x1f + let mut k = [0u8; 32]; + for (i, b) in k.iter_mut().enumerate() { + *b = i as u8; + } + ChannelKey(k) + } + + fn test_channel_id() -> ChannelId { + // 0xff,0xfe,.. + let mut id = [0u8; 32]; + for (i, b) in id.iter_mut().enumerate() { + *b = (255 - i) as u8; + } + ChannelId(id) + } + + #[test] + fn channel_pseudonym_is_deterministic() { + let key = test_channel_key(); + let id = test_channel_id(); + let a = channel_pseudonym(&key, &id, Epoch(0)); + let b = channel_pseudonym(&key, &id, Epoch(0)); + assert_eq!(a, b, "same inputs must yield the same pseudonym"); + } + + #[test] + fn channel_pseudonym_golden_epoch0() { + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH0); + } + + #[test] + fn channel_pseudonym_golden_epoch1() { + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(1)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH1); + } + + // Independent (Python hmac+hashlib, RFC 5869) over IKM=0x11*32 (the COMMUNITY id), member=0x22*32, + // info = "vector-community/v1/grant-locator" ‖ 0x00 ‖ member. + const GOLDEN_GRANT_LOCATOR: &str = + "c18d4d5955ecdd258f44240019a493a01fc01d51b5f0b8f7679ae424f8d5bfcc"; + + #[test] + fn grant_locator_golden() { + let loc = grant_locator(&crate::community::CommunityId([0x11u8; 32]), &[0x22u8; 32]); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&loc), GOLDEN_GRANT_LOCATOR); + } + + #[test] + fn invite_links_locator_golden_and_domain_separated() { + let cid = crate::community::CommunityId([0x11u8; 32]); + let alice = [0x22u8; 32]; + let bob = [0x33u8; 32]; + // Frozen output (drift = a silent coordinate change → members lose a creator's links). + assert_eq!( + crate::simd::hex::bytes_to_hex_32(&invite_links_locator(&cid, &alice)), + "cf42937a815ec561da6b4ca5ddd0c361634b0d9744693b744d4f5b34ec209ec2" + ); + // Per-creator: each creator's list lives at a DISTINCT coordinate (no shared registry). + assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&cid, &bob)); + // Domain-separated from grant + banlist despite sharing the community-id IKM (distinct label). + assert_ne!(invite_links_locator(&cid, &alice), grant_locator(&cid, &alice)); + assert_ne!(invite_links_locator(&cid, &alice), banlist_locator(&cid)); + // Community-bound (a different community → a different coordinate). + assert_ne!(invite_links_locator(&cid, &alice), invite_links_locator(&crate::community::CommunityId([0x99u8; 32]), &alice)); + } + + #[test] + fn grant_locator_binds_member_and_community() { + let cid = crate::community::CommunityId([0x11u8; 32]); + // Deterministic for the same inputs. + assert_eq!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x22u8; 32])); + // The member pubkey is bound in: a different member → a different locator. + assert_ne!(grant_locator(&cid, &[0x22u8; 32]), grant_locator(&cid, &[0x23u8; 32])); + // A different COMMUNITY → a different locator (so coordinates don't collide across communities, + // and an outsider without the community id can't compute any). + assert_ne!( + grant_locator(&cid, &[0x22u8; 32]), + grant_locator(&crate::community::CommunityId([0x99u8; 32]), &[0x22u8; 32]) + ); + } + + #[test] + fn epoch_changes_the_pseudonym() { + let key = test_channel_key(); + let id = test_channel_id(); + assert_ne!( + channel_pseudonym(&key, &id, Epoch(0)), + channel_pseudonym(&key, &id, Epoch(1)), + "rotating the epoch must rotate the pseudonym (unlinkability)" + ); + } + + #[test] + fn different_channel_id_changes_the_pseudonym() { + let key = test_channel_key(); + let other = ChannelId([0x42u8; 32]); + assert_ne!( + channel_pseudonym(&key, &test_channel_id(), Epoch(0)), + channel_pseudonym(&key, &other, Epoch(0)), + ); + } + + #[test] + fn different_label_does_not_collide() { + // Channel pseudonym and recipient pseudonym share IKM-shape + id + epoch but + // differ only by label — domain separation must keep them distinct. + let secret = test_channel_key(); + let id = test_channel_id(); + let chan = channel_pseudonym(&secret, &id, Epoch(0)); + let recip = recipient_pseudonym(secret.as_bytes(), RekeyScope::Channel(id), Epoch(0)); + assert_ne!(chan.0, recip.0, "labels must domain-separate"); + } + + #[test] + fn recipient_pseudonym_golden() { + let secret = [7u8; 32]; + let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3)); + let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3)); + assert_eq!(chan.to_hex(), GOLDEN_RECIPIENT_CHANNEL_EPOCH3); + assert_eq!(root.to_hex(), GOLDEN_RECIPIENT_SERVERROOT_EPOCH3); + } + + #[test] + fn rekey_pseudonym_is_server_root_derived_and_distinct() { + let sr = ServerRootKey([0x07u8; 32]); + let chan = test_channel_id(); + // Deterministic + golden (regression pin for the channel-rekey address derivation). + let p = rekey_pseudonym(&sr, &chan, Epoch(1)); + assert_eq!(p, rekey_pseudonym(&sr, &chan, Epoch(1))); + assert_eq!(p.to_hex(), GOLDEN_REKEY_PSEUDONYM); + // Server-root-derived: a different root → different address (so a non-member can't compute it, + // and crucially a member needs ONLY the server root — not the channel key — to find it). + assert_ne!(p, rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &chan, Epoch(1))); + // Per-epoch + per-channel binding. + assert_ne!(p, rekey_pseudonym(&sr, &chan, Epoch(2))); + assert_ne!(p, rekey_pseudonym(&sr, &ChannelId([0x42u8; 32]), Epoch(1))); + // Domain-separated from the channel message pseudonym even with the same (id, epoch): the + // message pseudonym keys off the CHANNEL key, this off the SERVER ROOT + a different label. + let as_chan_key = channel_pseudonym(&ChannelKey(*sr.as_bytes()), &chan, Epoch(1)); + assert_ne!(p.0, as_chan_key.0, "label must domain-separate rekey-address from channel-message"); + // The subtle pairing: rekey vs control plane share IKM=server_root AND epoch — separation rests + // ENTIRELY on the label (and id namespace). Pin it so a future label edit can't collapse them. + let as_control = + crate::community::roster::control_pseudonym(&sr, &crate::community::CommunityId(chan.0), Epoch(1)); + assert_ne!(p.to_hex(), as_control, "label must domain-separate rekey-address from control-plane"); + } + + #[test] + fn base_rekey_pseudonym_is_prior_root_derived_and_distinct() { + let root = ServerRootKey([0x07u8; 32]); + let community = crate::community::CommunityId([0x09u8; 32]); + let p = base_rekey_pseudonym(&root, &community, Epoch(1)); + assert_eq!(p, base_rekey_pseudonym(&root, &community, Epoch(1))); + assert_eq!(p.to_hex(), GOLDEN_BASE_REKEY_PSEUDONYM); + // Keyed by the PRIOR root: a different root → different address (so a member needs the root they + // hold to find the next base rekey — the forward-walk handle). + assert_ne!(p, base_rekey_pseudonym(&ServerRootKey([0x08u8; 32]), &community, Epoch(1))); + // Per-epoch + per-community binding. + assert_ne!(p, base_rekey_pseudonym(&root, &community, Epoch(2))); + assert_ne!(p, base_rekey_pseudonym(&root, &crate::community::CommunityId([0x42u8; 32]), Epoch(1))); + // Distinct from the control pseudonym (same IKM=root + community id + epoch) by label. + let control = super::super::roster::control_pseudonym(&root, &community, Epoch(1)); + assert_ne!(p.to_hex(), control, "label must domain-separate base-rekey from control-plane"); + } + + #[test] + fn server_root_scope_sentinel_matches_rekey_scope() { + // The epoch-keys archive scopes the base key under `SERVER_ROOT_SCOPE_HEX`; it must equal the + // hex of `RekeyScope::ServerRoot`'s all-zero `id32`, so the storage layer and the recipient + // pseudonym name the same server-root scope. Pinning this stops the two from drifting apart. + assert_eq!( + crate::simd::hex::bytes_to_hex_32(&RekeyScope::ServerRoot.id32()), + crate::community::SERVER_ROOT_SCOPE_HEX + ); + } + + #[test] + fn recipient_scope_disambiguates() { + // Same sender, same recipient, same epoch, but a channel rekey vs a + // server-root rotation must land on different tags (no blob collision). + let secret = [7u8; 32]; + let chan = recipient_pseudonym(&secret, RekeyScope::Channel(test_channel_id()), Epoch(3)); + let root = recipient_pseudonym(&secret, RekeyScope::ServerRoot, Epoch(3)); + assert_ne!(chan.0, root.0); + } + + #[test] + fn channel_pseudonym_golden_multibyte_epoch_is_big_endian() { + // A multi-byte epoch pins big-endian serialization explicitly (epoch 0/1 + // alone could be satisfied by either order beyond the low byte). + let p = channel_pseudonym(&test_channel_key(), &test_channel_id(), Epoch(0x0102030405060708)); + assert_eq!(p.to_hex(), GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE); + } + + #[test] + fn public_invite_subkeys_golden() { + // Independent RFC-5869 HKDF over token=[5;32], each label, all-zero id, no epoch. + let token = [5u8; 32]; + assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_key(&token)), GOLDEN_PUBLIC_INVITE_KEY); + assert_eq!(crate::simd::hex::bytes_to_hex_32(&public_invite_locator(&token)), GOLDEN_PUBLIC_INVITE_LOCATOR); + assert_eq!(public_invite_signer(&token).to_secret_hex(), GOLDEN_PUBLIC_INVITE_SIGNER); + } + + #[test] + fn public_invite_subkeys_domain_separated_and_token_bound() { + let token = [5u8; 32]; + let other = [6u8; 32]; + // Three sub-keys from one token must all differ (domain separation). + assert_ne!(public_invite_key(&token), public_invite_locator(&token)); + assert_ne!( + public_invite_key(&token).to_vec(), + public_invite_signer(&token).as_secret_bytes().to_vec() + ); + // A different token yields different sub-keys (token-bound). + assert_ne!(public_invite_key(&token), public_invite_key(&other)); + assert_ne!(public_invite_locator(&token), public_invite_locator(&other)); + } + + // --- Golden vectors (independent Python HKDF-SHA256, RFC 5869) --- + const GOLDEN_PUBLIC_INVITE_KEY: &str = + "7f02a8a832a1744adf286676038446dc94762c2c8332650c9ad62a0c870e0751"; + const GOLDEN_PUBLIC_INVITE_LOCATOR: &str = + "33c098d6e4cddc2b8ee98ab6b5182186794c35f5b71391130a49ae3d88588c2c"; + const GOLDEN_PUBLIC_INVITE_SIGNER: &str = + "9154a3a7e4a03e94eaad2f76efeebd43e25ee9df4fbca12454edcee0ef666e8d"; + + // server_root = [7;32], channel id = test_channel_id (0xff,0xfe,..), epoch 1. + const GOLDEN_REKEY_PSEUDONYM: &str = + "3a848655f79a586510e1113131f078aa1ce0ff8dcb74374507e6af07ff49fd24"; + // prior_root = [7;32], community id = [9;32], epoch 1. + const GOLDEN_BASE_REKEY_PSEUDONYM: &str = + "23ced8fd6cad30a21ded43c96bd040311cf20bcfff935453dc0985b41ff660be"; + + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH0: &str = + "d55b9f5fad668887d41d46b7c08ba63725a39d7c86b602c7c36e2f2e0eff8c40"; + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH1: &str = + "050079d9899c85bebf5c73fd777cdd812132d262e3ceec83c847a056dea41293"; + // secret = [7;32], epoch 3; channel scope = test_channel_id, root scope = all-zero. + const GOLDEN_RECIPIENT_CHANNEL_EPOCH3: &str = + "971f69d6a948c79704f8077188cded86bd35c82960e88043ebb2c2c3d60a3b71"; + const GOLDEN_RECIPIENT_SERVERROOT_EPOCH3: &str = + "e50e5d803fd2edc310be8cd7354586d12fcb8e3f30162553be53da1a34a17c46"; + // channel key [0..31], epoch 0x0102030405060708 (proves u64 big-endian). + const GOLDEN_CHANNEL_PSEUDONYM_EPOCH_BE: &str = + "cec398094d17688cd127bc609d34fa067331427400b023d0c70ff77fafe17e0b"; +} diff --git a/crates/vector-core/src/community/edition.rs b/crates/vector-core/src/community/edition.rs new file mode 100644 index 00000000..67221071 --- /dev/null +++ b/crates/vector-core/src/community/edition.rs @@ -0,0 +1,348 @@ +//! Real-npub authority editions — the keyless model's authorship + version carrier. +//! +//! An authority change (a Grant, RoleMetadata, RoleOrder, Banlist, ...) is an **inner event signed +//! by the ACTOR's own npub**, carrying the entity id, a per-entity +//! `version`, and the previous edition's hash (`prev_hash`, see [`super::version`]). That inner event +//! lives inside the channel/server-root encryption (the outer wrapper is the usual ephemeral signer, +//! [`super::envelope`]), so authorship is **member-verifiable**: the inner Schnorr signature *is* the +//! proof of who acted, and members check that npub against the roster. +//! +//! This module is the wire encoding of one edition (build + verify + parse). It does NOT decide +//! authorization — the signature proves WHO acted; the roster (§roles) decides WHETHER they were +//! allowed, and [`super::version::fold`] decides which edition is current. + +use super::version; +use crate::stored_event::event_kind; +use nostr_sdk::prelude::*; + +const TAG_SUBKIND: &str = "vsk"; +const TAG_ENTITY: &str = "eid"; +const TAG_EVERSION: &str = "ev"; +const TAG_EPREV: &str = "ep"; +const TAG_VERSION: &str = "v"; +const PROTOCOL_VERSION: &str = "1"; +/// Authority citation tag: `["vac", , , ]`. +/// The "pinned proof" — the grant edition the actor claims their authority under. In the MVP it is a +/// COMPLETENESS floor: a verifier confirms it has synced that exact grant to ≥ the cited version (an +/// un-forked, complete view) before acting, and resolves the actor's actual rank against its current +/// (refuse-downgrade-protected) roster — so a since-demoted actor is dropped there. The full +/// "resolve rank AT the cited version" (block-until-synced re-fetch + a roster-wide snapshot version) is +/// the deferred refinement; today it pins the actor's own grant, not a whole-roster moment. Absent when +/// the OWNER acts (supreme, no grant to cite). +pub const TAG_AUTHORITY_CITATION: &str = "vac"; + +/// The pinned authority an actor claims for an action (mechanism a). Points at the actor's own +/// authorizing edition (their Grant — or a RoleMetadata for a role-position claim) by stable +/// coordinate + the exact version/hash, so the verifier resolves authority against that frozen point, +/// not its own possibly-lagging-or-ahead live roster. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuthorityCitation { + /// The authorizing edition's entity id (e.g. `grant_locator(community_id, actor)`). + pub entity_id: [u8; 32], + /// The version of that edition the actor claims authority under. + pub version: u64, + /// That edition's [`version::edition_hash`] — pins the exact content, not just the version number. + pub edition_hash: [u8; 32], +} + +impl AuthorityCitation { + /// The signed `vac` tag carrying this citation. + pub fn to_tag(&self) -> Tag { + Tag::custom( + TagKind::Custom(TAG_AUTHORITY_CITATION.into()), + [ + crate::simd::hex::bytes_to_hex_32(&self.entity_id), + self.version.to_string(), + crate::simd::hex::bytes_to_hex_32(&self.edition_hash), + ], + ) + } + + /// Extract the citation from an event's tags, or `None` if absent. A malformed `vac` (bad hex / + /// unparseable version) returns `None` — the verifier then treats the action as uncited (owner-only + /// or rejected), never trusting a corrupt citation. + pub fn from_tags(tags: &Tags) -> Option { + let s = tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 4 && s[0] == TAG_AUTHORITY_CITATION).then(|| (s[1].clone(), s[2].clone(), s[3].clone())) + })?; + let valid_hex = |h: &str| h.len() == 64 && h.bytes().all(|b| b.is_ascii_hexdigit()); + if !valid_hex(&s.0) || !valid_hex(&s.2) { + return None; + } + Some(AuthorityCitation { + entity_id: crate::simd::hex::hex_to_bytes_32(&s.0), + version: s.1.parse().ok()?, + edition_hash: crate::simd::hex::hex_to_bytes_32(&s.2), + }) + } +} + +/// Build the unsigned inner edition event. Sign it with the ACTOR's real identity keys — that +/// signature is the authorship proof. `entity_id` is the entity's 32-byte id, `prev_hash` is the +/// previous edition's [`version::edition_hash`] (`None` for the first edition), `content` is the +/// entity payload JSON, and `created_at_secs` is the authored time (the version-fold tiebreak). +pub fn build_edition_inner( + author: PublicKey, + vsk: &str, + entity_id: &[u8; 32], + version: u64, + prev_hash: Option<&[u8; 32]>, + content: &str, + created_at_secs: u64, + authority: Option<&AuthorityCitation>, +) -> UnsignedEvent { + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_SUBKIND.into()), [vsk.to_string()]), + Tag::custom(TagKind::Custom(TAG_ENTITY.into()), [crate::simd::hex::bytes_to_hex_32(entity_id)]), + Tag::custom(TagKind::Custom(TAG_EVERSION.into()), [version.to_string()]), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]; + if let Some(p) = prev_hash { + tags.push(Tag::custom(TagKind::Custom(TAG_EPREV.into()), [crate::simd::hex::bytes_to_hex_32(p)])); + } + // The pinned authority proof: absent when the OWNER signs (supreme), present for a delegated + // admin so verifiers resolve their rank at the cited grant version. Outside the version-chain + // self_hash (it's per-action metadata, not chain identity), but covered by the inner signature. + if let Some(a) = authority { + tags.push(a.to_tag()); + } + EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_at_secs)) + .build(author) +} + +/// A signature-verified, parsed edition. +#[derive(Clone, Debug)] +pub struct ParsedEdition { + /// The real npub that signed (and is thus accountable for) this edition. + pub author: PublicKey, + pub vsk: String, + pub entity_id: [u8; 32], + pub version: u64, + pub prev_hash: Option<[u8; 32]>, + pub content: String, + /// [`version::edition_hash`] of this edition — what the next edition's `prev_hash` must cite. + pub self_hash: [u8; 32], + pub created_at: u64, + pub inner_id: [u8; 32], + /// The pinned authority proof, if the actor cited one. `None` when the OWNER signs (supreme) + /// or a non-authority edition carries no citation. Verified separately against the roster (#3c). + pub authority: Option, +} + +#[derive(Debug, PartialEq, Eq)] +pub enum EditionError { + BadSignature, + MissingField(&'static str), + BadField(&'static str), +} + +fn decode_hash(hex: &str, field: &'static str) -> Result<[u8; 32], EditionError> { + if hex.len() != 64 || !hex.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(EditionError::BadField(field)); + } + Ok(crate::simd::hex::hex_to_bytes_32(hex)) +} + +/// Verify + parse an inner edition event. Checks the inner Schnorr signature (the real-npub +/// authorship proof) and extracts the edition fields, computing `self_hash` over the canonical +/// edition bytes. Does NOT check roster authorization — that is the caller's separate step. +pub fn parse_edition_inner(inner: &Event) -> Result { + inner.verify().map_err(|_| EditionError::BadSignature)?; + // Reject duplicate authority tags: the signature covers all of them, but if two clients picked a + // different duplicate they would compute a different `self_hash` for the same signed event and + // diverge on the chain. The map signed-event → canonical bytes must be total and unambiguous. + for name in [TAG_SUBKIND, TAG_ENTITY, TAG_EVERSION, TAG_EPREV, TAG_AUTHORITY_CITATION] { + let count = inner + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str() == name).unwrap_or(false)) + .count(); + if count > 1 { + return Err(EditionError::BadField("duplicate authority tag")); + } + } + let get = |name: &str| -> Option { + inner.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == name).then(|| s[1].clone()) + }) + }; + let vsk = get(TAG_SUBKIND).ok_or(EditionError::MissingField("vsk"))?; + let entity_id = decode_hash(&get(TAG_ENTITY).ok_or(EditionError::MissingField("eid"))?, "eid")?; + let version: u64 = get(TAG_EVERSION) + .ok_or(EditionError::MissingField("ev"))? + .parse() + .map_err(|_| EditionError::BadField("ev"))?; + let prev_hash = match get(TAG_EPREV) { + Some(h) => Some(decode_hash(&h, "ep")?), + None => None, + }; + let content = inner.content.clone(); + let self_hash = version::edition_hash(&entity_id, version, prev_hash.as_ref(), content.as_bytes()); + Ok(ParsedEdition { + author: inner.pubkey, + vsk, + entity_id, + version, + prev_hash, + content, + self_hash, + created_at: inner.created_at.as_secs(), + inner_id: inner.id.to_bytes(), + authority: AuthorityCitation::from_tags(&inner.tags), + }) +} + +impl ParsedEdition { + /// The [`version::Edition`] view used by [`version::fold`]. + pub fn to_fold_edition(&self) -> version::Edition { + version::Edition { + version: self.version, + prev_hash: self.prev_hash, + self_hash: self.self_hash, + created_at: self.created_at, + tiebreak_id: self.inner_id, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const VSK_GRANT: &str = "3"; + + fn eid() -> [u8; 32] { + [0x42; 32] + } + + #[test] + fn round_trips_authorship_version_and_chain_hash() { + let actor = Keys::generate(); + let prev = version::edition_hash(&eid(), 1, None, b"{}"); + let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 2, Some(&prev), "{\"role_ids\":[]}", 1_700_000_000, None) + .sign_with_keys(&actor) + .unwrap(); + + let parsed = parse_edition_inner(&inner).expect("valid edition parses"); + assert_eq!(parsed.author, actor.public_key(), "authorship = the real signer"); + assert_eq!(parsed.vsk, VSK_GRANT); + assert_eq!(parsed.entity_id, eid()); + assert_eq!(parsed.version, 2); + assert_eq!(parsed.prev_hash, Some(prev)); + assert_eq!(parsed.created_at, 1_700_000_000); + // self_hash matches the canonical recomputation (what the next edition will cite). + assert_eq!( + parsed.self_hash, + version::edition_hash(&eid(), 2, Some(&prev), b"{\"role_ids\":[]}") + ); + // Folds into a version::Edition cleanly. + let fe = parsed.to_fold_edition(); + assert_eq!(fe.version, 2); + assert_eq!(fe.prev_hash, Some(prev)); + } + + #[test] + fn authority_citation_round_trips_on_an_edition() { + // A delegated admin's edition carries the pinned authority citation; it survives sign→parse, + // covered by the inner signature, and does NOT alter the chain self_hash (per-action metadata). + let actor = Keys::generate(); + let cite = AuthorityCitation { entity_id: [0xab; 32], version: 7, edition_hash: [0xcd; 32] }; + let inner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, Some(&cite)) + .sign_with_keys(&actor) + .unwrap(); + let parsed = parse_edition_inner(&inner).unwrap(); + assert_eq!(parsed.authority.as_ref(), Some(&cite), "citation round-trips"); + // self_hash is over (entity, version, prev, content) only — the citation doesn't perturb it. + assert_eq!(parsed.self_hash, version::edition_hash(&eid(), 1, None, b"{}")); + + // An uncited edition (owner-signed) parses with authority == None. + let owner = build_edition_inner(actor.public_key(), VSK_GRANT, &eid(), 1, None, "{}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + assert_eq!(parse_edition_inner(&owner).unwrap().authority, None); + } + + #[test] + fn authority_citation_tag_layout_is_frozen() { + // FROZEN wire layout: the citation rides as a 4-element `vac` tag + // `["vac", , , ]`. A change here reshuffles how every + // verifier reads pinned authority, so pin the exact shape (not just a round-trip). + let cite = AuthorityCitation { entity_id: [0x11; 32], version: 9, edition_hash: [0x22; 32] }; + let tag = cite.to_tag(); + let s = tag.as_slice(); + assert_eq!(s.len(), 4, "vac is a 4-element tag"); + assert_eq!(s[0], TAG_AUTHORITY_CITATION); + assert_eq!(s[1], "11".repeat(32), "entity id is lowercase hex"); + assert_eq!(s[2], "9", "version is the decimal string"); + assert_eq!(s[3], "22".repeat(32), "edition hash is lowercase hex"); + } + + #[test] + fn genesis_edition_has_no_prev() { + let actor = Keys::generate(); + let inner = build_edition_inner(actor.public_key(), "1", &eid(), 1, None, "{}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + let parsed = parse_edition_inner(&inner).unwrap(); + assert_eq!(parsed.prev_hash, None, "first edition cites no predecessor"); + assert_eq!(parsed.version, 1); + } + + #[test] + fn tampered_content_fails_verification() { + // Re-sign integrity: flipping the content after signing breaks the inner Schnorr sig. + let actor = Keys::generate(); + let inner = build_edition_inner(actor.public_key(), "3", &eid(), 1, None, "{\"a\":1}", 100, None) + .sign_with_keys(&actor) + .unwrap(); + let mut json: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap(); + json["content"] = serde_json::Value::String("{\"a\":2}".into()); // tamper + let tampered: Event = serde_json::from_value(json).unwrap(); + assert!(matches!(parse_edition_inner(&tampered), Err(EditionError::BadSignature))); + } + + #[test] + fn missing_required_field_is_rejected_not_panicked() { + // An inner event lacking the entity-id tag is a parse error, never a panic. + let actor = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}") + .tags([Tag::custom(TagKind::Custom("vsk".into()), ["3".to_string()])]) + .sign_with_keys(&actor) + .unwrap(); + assert!(matches!(parse_edition_inner(&inner), Err(EditionError::MissingField("eid")))); + } + + #[test] + fn duplicate_authority_tag_is_rejected() { + // A duplicate of ANY of the 5 authority tags (vsk/eid/ev/ep/vac) makes signed-event → canonical + // bytes ambiguous (clients could pick different ones) → chain divergence, so it must be rejected. + // Parameterized across all 5 — a regression dropping any one from the dedup loop is caught. + let actor = Keys::generate(); + let hash = crate::simd::hex::bytes_to_hex_32(&[0xAB; 32]); + let base = || -> Vec { + vec![ + Tag::custom(TagKind::Custom("vsk".into()), ["1".to_string()]), + Tag::custom(TagKind::Custom("eid".into()), [crate::simd::hex::bytes_to_hex_32(&eid())]), + Tag::custom(TagKind::Custom("ev".into()), ["1".to_string()]), + Tag::custom(TagKind::Custom("ep".into()), [hash.clone()]), + Tag::custom(TagKind::Custom("vac".into()), [crate::simd::hex::bytes_to_hex_32(&eid()), "1".to_string(), hash.clone()]), + ] + }; + let build = |tags: Vec| EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_CONTROL), "{}") + .tags(tags).sign_with_keys(&actor).unwrap(); + assert!(parse_edition_inner(&build(base())).is_ok(), "a clean 5-tag base edition parses"); + for name in ["vsk", "eid", "ev", "ep", "vac"] { + let mut tags = base(); + let dup = tags.iter().find(|t| t.as_slice().first().map(|s| s == name).unwrap_or(false)).cloned().unwrap(); + tags.push(dup); + assert!( + matches!(parse_edition_inner(&build(tags)), Err(EditionError::BadField("duplicate authority tag"))), + "a duplicate `{name}` tag must be rejected" + ); + } + } +} diff --git a/crates/vector-core/src/community/envelope.rs b/crates/vector-core/src/community/envelope.rs new file mode 100644 index 00000000..3aefb2c7 --- /dev/null +++ b/crates/vector-core/src/community/envelope.rs @@ -0,0 +1,904 @@ +//! Message envelope (GROUP_PROTOCOL.md). +//! +//! A Community message is an inner Nostr event signed by the author's real key +//! (the intra-group authorship proof), NIP-44-v2-encrypted under the shared channel +//! key, and wrapped in an ephemeral-signed outer event tagged with the per-epoch +//! pseudonym. Single NIP-44 pass, O(1) broadcast — not gift wrap's per-recipient +//! double-wrap. +//! +//! `open_message` enforces the binding triad: inner Schnorr signature valid, and +//! inner `kind`/`channel`/`epoch` equal to the outer kind and to the *specific* +//! channel/epoch whose key decrypted the payload (strict equality, never a +//! membership test). That defeats insider replay/splice across type, channel, or +//! epoch — the threat that any member, holding the channel key, could otherwise lift +//! another member's signed content into a different context. + +use nostr_sdk::prelude::*; + +use super::cipher; +use super::derive::channel_pseudonym; +use super::{ChannelId, ChannelKey, Epoch}; +use crate::stored_event::event_kind; + +/// Outer protocol-version tag value (forward-compat hook #2). Checked before any +/// decryption so an unknown version is rejected gracefully. +const PROTOCOL_VERSION: &str = "1"; + +const TAG_VERSION: &str = "v"; +const TAG_CHANNEL: &str = "channel"; +const TAG_EPOCH: &str = "epoch"; +const TAG_MS: &str = "ms"; + +/// Errors from sealing or opening a Community message envelope. +#[derive(Debug)] +pub enum EnvelopeError { + Sign(String), + Encrypt(String), + Decrypt(String), + InnerParse(String), + /// Outer `v` tag is absent or names a version we don't speak. + BadVersion(Option), + KindMismatch { outer: u16, inner: u16 }, + /// Inner channel id ≠ the channel whose key decrypted this (cross-channel splice). + ChannelMismatch, + /// Inner epoch ≠ the epoch whose key decrypted this (cross-epoch splice/replay). + EpochMismatch, + /// Inner author signature failed to verify. + BadSignature, + MissingTag(&'static str), + /// A binding tag appears more than once — the inner event is ambiguous, so the + /// wire form isn't deterministic. Any channel-key holder can craft the inner + /// event, so we reject rather than trust first-match. + DuplicateTag(&'static str), + /// The outer `z` pseudonym matches none of the member's held epoch keys (an epoch we were never a + /// recipient of, or a foreign channel). Not ours to read — dropped, not an error condition. + NoHeldEpoch, +} + +impl std::fmt::Display for EnvelopeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvelopeError::Sign(e) => write!(f, "sign: {e}"), + EnvelopeError::Encrypt(e) => write!(f, "encrypt: {e}"), + EnvelopeError::Decrypt(e) => write!(f, "decrypt: {e}"), + EnvelopeError::InnerParse(e) => write!(f, "inner parse: {e}"), + EnvelopeError::BadVersion(v) => write!(f, "unsupported protocol version: {v:?}"), + EnvelopeError::KindMismatch { outer, inner } => { + write!(f, "kind mismatch: outer {outer} != inner {inner}") + } + EnvelopeError::ChannelMismatch => write!(f, "channel-binding mismatch (splice)"), + EnvelopeError::EpochMismatch => write!(f, "epoch-binding mismatch (splice/replay)"), + EnvelopeError::BadSignature => write!(f, "inner author signature invalid"), + EnvelopeError::MissingTag(t) => write!(f, "missing inner tag: {t}"), + EnvelopeError::DuplicateTag(t) => write!(f, "duplicate inner tag: {t}"), + EnvelopeError::NoHeldEpoch => write!(f, "no held epoch key for this pseudonym"), + } + } +} + +impl std::error::Error for EnvelopeError {} + +/// A successfully opened and fully-verified Community message. +#[derive(Debug, Clone)] +pub struct OpenedMessage { + /// Inner event id — the `message_id`, the dedup/display key. + pub message_id: EventId, + /// Verified real author. + pub author: PublicKey, + pub content: String, + pub channel_id: ChannelId, + pub epoch: Epoch, + /// Ordering timestamp (epoch ms) via the SHARED `rumor::resolve_message_timestamp` (ms convention + + /// clamp). Kept here because the transport sorts fetched events by it before they become + /// `Message`s; reply-ref + emoji parsing, by contrast, is solely `process_rumor`'s job off `tags`. + pub ms: Option, + /// Inner event's real send time (NOT randomized). + pub created_at: Timestamp, + /// Append-plane sub-kind: 3300 message, 3301 reaction, 3302 edit. + pub kind: u16, + /// File attachments (one per NIP-92 `imeta` tag). A Community message can carry a + /// caption (`content`) plus N attachments in the same event — see `attachments` module. + pub attachments: Vec, + /// The authority citation (`vac` tag), if the inner carried one — present on a non-owner + /// moderation-hide (3305) naming the grant the hider claims authority under. The hide consumer + /// (`apply_delete`) resolves it against the persisted grant head (version-pinned authority). + pub citation: Option, + /// The OUTER wire event id (the relay-addressable event that carried this inner). The + /// transport's dedup key — persisted as the inner's `wrapper_event_id` so a re-fetched + /// channel page skips it pre-decryption, exactly as a DM gift-wrap id does. + pub wrapper_id: EventId, + /// The verified inner event's raw tags — handed to the SHARED `process_rumor` content parser so + /// reply/emoji/ms parsing is identical across transports (the binding tags were already checked). + pub tags: Tags, +} + +/// Seal a plaintext message into an outer wire event. The outer event is signed by +/// a fresh one-time key (no persistent author↔channel linkage on the wire). +/// +/// This convenience form discards that key, so the resulting message is NOT +/// self-deletable. **The product send path should use +/// [`seal_message_with_ephemeral`] and RETAIN the key** (like Vector's +/// `nip17_wrap_keys` for DMs) so the sender can later NIP-09-delete their own +/// message. Ephemeral-on-the-wire and retained-locally are complementary: the +/// relay still sees only one-time keys (no corpus-wide deletion authority), while +/// the sender keeps a per-message key to delete just their own. +pub fn seal_message( + author_keys: &Keys, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, +) -> Result { + seal_message_with_ephemeral(&Keys::generate(), author_keys, channel_key, channel_id, epoch, content, ms) +} + +/// Like [`seal_message`] but the caller supplies (and may retain) the ephemeral +/// outer-signing key. Retaining it enables a later NIP-09 deletion of this exact +/// outer event (the deletion must be signed by the same key — deletable +/// messages), which is also how on-relay tests clean up after themselves. +pub fn seal_message_with_ephemeral( + ephemeral: &Keys, + author_keys: &Keys, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, +) -> Result { + // Local-keys convenience: build the inner event, sign it with the author's keys, and + // seal. Identical wire output to the signer path (golden-vector stable). + let inner = build_inner_event(author_keys.public_key(), channel_id, epoch, content, ms, None) + .sign_with_keys(author_keys) + .map_err(|e| EnvelopeError::Sign(e.to_string()))?; + seal_with_signed_inner(ephemeral, &inner, channel_key, channel_id, epoch) +} + +/// Build the inner authorship-proof event UNSIGNED, so the caller can sign it with +/// whatever the account uses — local keys OR a NIP-46 remote bunker (parity with the +/// DM send path, which signs through the active `NostrSigner`). `author` is the +/// identity pubkey the signer will sign as. +/// +/// `ms` is the full send time in epoch-milliseconds, split the way Vector's DMs do: +/// `created_at` carries the seconds, the `ms` tag carries only the sub-second offset +/// (0..999). The open side reconstructs `created_at*1000 + ms`. +pub fn build_inner_event( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + content: &str, + ms: u64, + reply_to: Option<&str>, +) -> UnsignedEvent { + build_inner_typed(author, channel_id, epoch, event_kind::COMMUNITY_MESSAGE, content, ms, reply_to, &[]) +} + +/// Like [`build_inner_event`] but for any append-plane sub-type — a reaction (3301) or +/// edit (3302) as well as a message (3300). The `reference` `e` tag points at the target: +/// the replied-to message (3300), the reacted-to message (3301), or the edited message +/// (3302). The inner kind is mirrored to the outer on seal, and the receiver enforces the +/// binding triad (kind/channel/epoch). +pub fn build_inner_typed( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + kind: u16, + content: &str, + ms: u64, + reference: Option<&str>, + emoji_tags: &[crate::types::EmojiTag], +) -> UnsignedEvent { + build_inner_full(author, channel_id, epoch, kind, content, ms, reference, emoji_tags, &[]) +} + +/// Like [`build_inner_typed`] but also carries a slice of EXTRA inner tags appended verbatim — +/// used for NIP-92 `imeta` attachment tags (a 3300 message mixing a caption with N files, via +/// `attachments::attachment_to_imeta`). They are added before signing, so the inner signature +/// covers them; readers pick out what they need by exact tag name. +pub fn build_inner_full( + author: PublicKey, + channel_id: &ChannelId, + epoch: Epoch, + kind: u16, + content: &str, + ms: u64, + reference: Option<&str>, + emoji_tags: &[crate::types::EmojiTag], + extra_tags: &[Tag], +) -> UnsignedEvent { + let created_secs = ms / 1000; + let ms_offset = ms % 1000; + let mut tags = vec![ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [channel_id.to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), [epoch.0.to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), [ms_offset.to_string()]), + ]; + // Target reference: an `e` tag marked "reply" (Vector's DM convention) — the + // replied-to / reacted-to / edited message's inner id. + if let Some(target) = reference.filter(|t| !t.is_empty()) { + tags.push(Tag::custom(TagKind::e(), [target.to_string(), String::new(), "reply".to_string()])); + } + // NIP-30 custom emoji: ["emoji", shortcode, url] for each `:shortcode:` used in the + // content (so custom-emoji messages + reactions render the image, parity with DMs). + for et in emoji_tags { + tags.push(Tag::custom(TagKind::Custom("emoji".into()), [et.shortcode.clone(), et.url.clone()])); + } + // Extra inner tags appended verbatim (NIP-92 `imeta` attachment tags). + tags.extend(extra_tags.iter().cloned()); + EventBuilder::new(Kind::Custom(kind), content) + .tags(tags) + .custom_created_at(Timestamp::from_secs(created_secs)) + .build(author) +} + +/// Seal an already-signed inner authorship event into the outer wire event. The inner +/// may have been signed by local keys or a remote bunker — this stage is signer-agnostic. +/// Defensively re-checks the binding (kind/channel/epoch) so a caller can never seal +/// an inner that the receiver would then reject as a splice. +pub fn seal_with_signed_inner( + ephemeral: &Keys, + inner: &Event, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, +) -> Result { + // Only the community append-plane sub-kinds (message/reaction/edit + cooperative delete + + // presence + cooperative kick) may be sealed. Rekey (3303) and InviteBundle (3304) have their own + // carriers, so the contiguous 3300..=3302 range is admitted alongside the explicit 3305/3306/3309. + let inner_kind = inner.kind.as_u16(); + let allowed = (event_kind::COMMUNITY_MESSAGE..=event_kind::COMMUNITY_EDIT).contains(&inner_kind) + || inner_kind == event_kind::COMMUNITY_DELETE + || inner_kind == event_kind::COMMUNITY_PRESENCE + || inner_kind == event_kind::COMMUNITY_KICK; + if !allowed { + return Err(EnvelopeError::KindMismatch { + outer: event_kind::COMMUNITY_MESSAGE, + inner: inner_kind, + }); + } + match unique_tag(inner, TAG_CHANNEL)? { + Some(c) if c == channel_id.to_hex() => {} + _ => return Err(EnvelopeError::ChannelMismatch), + } + match unique_tag(inner, TAG_EPOCH)? { + Some(e) if e == epoch.0.to_string() => {} + _ => return Err(EnvelopeError::EpochMismatch), + } + + // Single NIP-44 v2 pass under the raw channel key. + let content_b64 = cipher::seal(channel_key.as_bytes(), inner.as_json().as_bytes()) + .map_err(EnvelopeError::Encrypt)?; + + // Outer event: ephemeral signer (no author↔channel linkage on the wire), tagged + // with the per-epoch pseudonym (relay-filterable `z`) and the version. Outer kind + // mirrors the inner (the binding the receiver enforces). + let pseudonym = channel_pseudonym(channel_key, channel_id, epoch); + EventBuilder::new(Kind::Custom(inner_kind), content_b64) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [pseudonym.to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(ephemeral) + .map_err(|e| EnvelopeError::Sign(e.to_string())) +} + +/// Open and fully verify an outer wire event, given the channel key + the exact +/// channel/epoch coordinate that key belongs to. +pub fn open_message( + outer: &Event, + channel_key: &ChannelKey, + channel_id: &ChannelId, + epoch: Epoch, +) -> Result { + // Version-check BEFORE attempting decryption (hook #2). + match find_tag(outer, TAG_VERSION).as_deref() { + Some(PROTOCOL_VERSION) => {} + other => return Err(EnvelopeError::BadVersion(other.map(str::to_string))), + } + + let plaintext = cipher::open(channel_key.as_bytes(), &outer.content) + .map_err(EnvelopeError::Decrypt)?; + let json = String::from_utf8(plaintext).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?; + let inner = Event::from_json(&json).map_err(|e| EnvelopeError::InnerParse(e.to_string()))?; + + // Inner author signature (the authorship proof). + inner.verify().map_err(|_| EnvelopeError::BadSignature)?; + + // Binding triad — type, channel, epoch. + if inner.kind.as_u16() != outer.kind.as_u16() { + return Err(EnvelopeError::KindMismatch { + outer: outer.kind.as_u16(), + inner: inner.kind.as_u16(), + }); + } + let inner_channel = unique_tag(&inner, TAG_CHANNEL)?.ok_or(EnvelopeError::MissingTag(TAG_CHANNEL))?; + if inner_channel != channel_id.to_hex() { + return Err(EnvelopeError::ChannelMismatch); + } + let inner_epoch = unique_tag(&inner, TAG_EPOCH)?.ok_or(EnvelopeError::MissingTag(TAG_EPOCH))?; + if inner_epoch != epoch.0.to_string() { + return Err(EnvelopeError::EpochMismatch); + } + + // Reply-ref + emoji parsing is the SHARED parser's job (runs off `tags` in `process_rumor`), so it + // lives in ONE place. The transport keeps only: the ordering `ms` (used to sort fetched events + // before they're parsed — via the SAME shared resolver), NIP-92 `imeta` attachments, and the + // authority citation. + let ms = Some(crate::rumor::resolve_message_timestamp( + inner.created_at.as_secs(), + unique_tag(&inner, TAG_MS)?.as_deref(), + )); + let attachments = super::attachments::attachments_from_tags( + inner.tags.iter(), + &crate::db::get_download_dir(), + ); + Ok(OpenedMessage { + message_id: inner.id, + author: inner.pubkey, + content: inner.content.clone(), + channel_id: *channel_id, + epoch, + ms, + created_at: inner.created_at, + kind: inner.kind.as_u16(), + attachments, + citation: super::edition::AuthorityCitation::from_tags(&inner.tags), + wrapper_id: outer.id, + tags: inner.tags.clone(), + }) +} + +/// Open an outer wire event when the member may hold MULTIPLE epoch keys (post-rekey catch-up): select +/// the decryption key by the outer's `z` pseudonym tag — each epoch addresses a distinct pseudonym we +/// can recompute — then open under that exact epoch. `epoch_keys` is the member's retained `(epoch, key)` +/// set for this channel. A `z` matching no held epoch yields [`EnvelopeError::NoHeldEpoch`] (not ours to +/// read), keeping the per-event cost one pseudonym derivation per held epoch (a handful), no trial-decrypt. +pub fn open_message_multi( + outer: &Event, + channel_id: &ChannelId, + epoch_keys: &[(Epoch, ChannelKey)], +) -> Result { + let z = find_tag(outer, "z").ok_or(EnvelopeError::MissingTag("z"))?; + for (epoch, key) in epoch_keys { + if channel_pseudonym(key, channel_id, *epoch).to_hex() == z { + return open_message(outer, key, channel_id, *epoch); + } + } + Err(EnvelopeError::NoHeldEpoch) +} + +/// First value of the first tag named `name` (for outer routing tags like `v`, +/// which the ephemeral signer controls and which carry no binding weight). +fn find_tag(event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|t| { + let s = t.as_slice(); + (s.len() >= 2 && s[0] == name).then(|| s[1].clone()) + }) +} + +/// Value of the tag named `name`, requiring it to appear AT MOST ONCE. The inner +/// event is constructable by any channel-key holder, so a duplicated binding tag +/// would make first-match nondeterministic — reject it. +fn unique_tag(event: &Event, name: &'static str) -> Result, EnvelopeError> { + let mut found: Option = None; + for t in event.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == name { + if found.is_some() { + return Err(EnvelopeError::DuplicateTag(name)); + } + found = Some(s[1].clone()); + } + } + Ok(found) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn key() -> ChannelKey { + ChannelKey([0x11u8; 32]) + } + fn chan() -> ChannelId { + ChannelId([0xaau8; 32]) + } + + fn channel_tag() -> Tag { + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()]) + } + fn epoch0_tag() -> Tag { + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]) + } + + /// Seal an arbitrary inner event into a correctly-tagged outer (epoch 0). + fn wrap_inner(inner: &Event) -> Event { + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap() + } + + #[test] + fn round_trip() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "gm fren", 1_700_000_000_000) + .expect("seal"); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open"); + assert_eq!(opened.content, "gm fren"); + assert_eq!(opened.author, author.public_key()); + assert_eq!(opened.ms, Some(1_700_000_000_000)); + assert_eq!(opened.channel_id, chan()); + assert_eq!(opened.epoch, Epoch(0)); + } + + #[tokio::test] + async fn signer_path_matches_local_keys_path() { + // Parity with DMs: the inner event can be signed through the async + // `NostrSigner` (the same path a NIP-46 bunker uses) instead of local keys. + // `Keys` implements `NostrSigner`, so signing the unsigned inner via `.sign()` + // exercises that path; the sealed result must open identically. + let author = Keys::generate(); + let ephemeral = Keys::generate(); + + let unsigned = build_inner_event(author.public_key(), &chan(), Epoch(0), "via signer", 1_700_000_000_777, None); + let inner: Event = unsigned.sign(&author).await.expect("remote-style sign"); + let outer = seal_with_signed_inner(&ephemeral, &inner, &key(), &chan(), Epoch(0)).expect("seal"); + + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).expect("open"); + assert_eq!(opened.content, "via signer"); + assert_eq!(opened.author, author.public_key(), "authorship is the identity key, not ephemeral"); + assert_eq!(opened.ms, Some(1_700_000_000_777)); + // Retained ephemeral is the outer signer (so it's still self-deletable). + assert_eq!(outer.pubkey, ephemeral.public_key()); + } + + #[test] + fn reply_reference_round_trips() { + // A reply target (inner `e` tag) survives seal→open and surfaces on the parsed Message via the + // shared parser (reply parsing now lives in `process_rumor`, exercised end-to-end via build_message). + let author = Keys::generate(); + let target = "a".repeat(64); + let inner = build_inner_event(author.public_key(), &chan(), Epoch(0), "re: hi", 5, Some(&target)) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.replied_to, target); + + // No reply target → replied_to is empty. + let plain = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 6).unwrap(); + let opened2 = open_message(&plain, &key(), &chan(), Epoch(0)).unwrap(); + assert!(crate::community::inbound::build_message(&opened2, &Keys::generate().public_key()).replied_to.is_empty()); + } + + #[test] + fn custom_emoji_tags_round_trip() { + // NIP-30 `["emoji", shortcode, url]` tags survive seal→open and surface on the parsed Message + // (shared parser), so custom-emoji messages render the image — parity with DMs. + let author = Keys::generate(); + let tags = vec![crate::types::EmojiTag { + shortcode: "fire".into(), + url: "https://blossom/fire.png".into(), + }]; + let inner = build_inner_typed( + author.public_key(), &chan(), Epoch(0), + crate::stored_event::event_kind::COMMUNITY_MESSAGE, "gm :fire:", 1, None, &tags, + ) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.emoji_tags.len(), 1); + assert_eq!(msg.emoji_tags[0].shortcode, "fire"); + assert_eq!(msg.emoji_tags[0].url, "https://blossom/fire.png"); + } + + #[test] + fn far_future_inner_timestamp_is_clamped() { + // W3: the inner created_at isn't relay-clamped (only the outer is published), so a + // hostile member could stamp it absurdly far in the future to pin the message to + // the top forever. open_message clamps an implausible ms back to receipt time. + let author = Keys::generate(); + let far_future = 99_999_999_999_999u64; // ~year 5138 in epoch-ms + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "from the future", far_future).unwrap(); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert!(opened.ms.unwrap() < far_future, "far-future ordering ms must be clamped to ~now"); + } + + #[test] + fn duplicate_reply_tag_on_a_message_is_tolerated() { + // A message's reply pointer is cosmetic, so a duplicate `e` no longer drops the whole message — + // the content is preserved (open succeeds). The security-critical disambiguation moved to the + // SHARED parser, which rejects an ambiguous TARGET for reactions/edits/deletes + // (see `rumor::unique_event_ref`); that's covered by the rumor-level tests. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .tags([ + Tag::custom(TagKind::Custom(TAG_CHANNEL.into()), [chan().to_hex()]), + Tag::custom(TagKind::Custom(TAG_EPOCH.into()), ["0".to_string()]), + Tag::custom(TagKind::Custom(TAG_MS.into()), ["1".to_string()]), + Tag::custom(TagKind::e(), ["aa".repeat(32), String::new(), "reply".to_string()]), + Tag::custom(TagKind::e(), ["bb".repeat(32), String::new(), "reply".to_string()]), + ]) + .sign_with_keys(&author) + .unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + assert!(open_message(&outer, &key(), &chan(), Epoch(0)).is_ok(), + "a message must not be dropped over an ambiguous (cosmetic) reply pointer"); + } + + #[test] + fn multi_attachment_message_round_trips_caption_and_imeta() { + // The protocol's multi-attachment capability: ONE event carries a caption + // (content) plus N attachments (one imeta each), optionally replying. Verify the + // full seal → open path reconstructs the caption, the reply target, and every + // attachment's crypto + metadata in order. + use crate::types::{Attachment, ImageMetadata}; + let mk = |name: &str, ext: &str, img: bool| Attachment { + id: "x".into(), + key: "0".repeat(64), + nonce: format!("{:0<24}", name), + extension: ext.into(), + name: name.into(), + url: format!("https://blossom.example/{name}"), + path: String::new(), + size: 1234, + img_meta: img.then(|| ImageMetadata { thumbhash: "TH".into(), width: 64, height: 48 }), + downloading: false, + downloaded: false, + webxdc_topic: None, + group_id: None, + original_hash: Some("a".repeat(64)), + scheme_version: None, + mls_filename: None, + }; + let imetas = vec![ + super::super::attachments::attachment_to_imeta(&mk("photo.png", "png", true)), + super::super::attachments::attachment_to_imeta(&mk("notes.pdf", "pdf", false)), + ]; + let author = Keys::generate(); + let reply_target = "bb".repeat(32); + let inner = build_inner_full( + author.public_key(), &chan(), Epoch(0), + event_kind::COMMUNITY_MESSAGE, "look at these", 1_700_000_000_123, + Some(&reply_target), &[], &imetas, + ).sign_with_keys(&author).unwrap(); + let outer = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)).unwrap(); + + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.content, "look at these"); + // Reply ref is parsed by the shared parser; the built Message carries it. Attachments are the + // transport-specific imeta, still parsed at open. + let msg = crate::community::inbound::build_message(&opened, &Keys::generate().public_key()); + assert_eq!(msg.replied_to, reply_target); + assert_eq!(opened.attachments.len(), 2, "both attachments parse"); + assert_eq!(opened.attachments[0].name, "photo.png"); + assert_eq!(opened.attachments[0].key, "0".repeat(64)); + assert_eq!(opened.attachments[0].extension, "png"); + assert!(opened.attachments[0].img_meta.is_some(), "image carries thumbhash/dim"); + assert!(opened.attachments[0].group_id.is_none(), "Community attachment uses key/nonce, not MLS"); + assert_eq!(opened.attachments[1].name, "notes.pdf"); + assert_eq!(opened.attachments[1].extension, "pdf"); + assert!(opened.attachments[1].img_meta.is_none()); + // A caption-only message (no imeta) opens with zero attachments. + let plain = seal_message(&author, &key(), &chan(), Epoch(0), "just text", 1).unwrap(); + assert!(open_message(&plain, &key(), &chan(), Epoch(0)).unwrap().attachments.is_empty()); + } + + #[test] + fn seal_rejects_inner_bound_to_wrong_channel() { + // seal_with_signed_inner must refuse an inner whose binding doesn't match the + // coordinate being sealed under (can't produce an unopenable/spliced message). + let author = Keys::generate(); + let other = ChannelId([0xbbu8; 32]); + let inner = build_inner_event(author.public_key(), &other, Epoch(0), "x", 1, None) + .sign_with_keys(&author) + .unwrap(); + let err = seal_with_signed_inner(&Keys::generate(), &inner, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}"); + } + + #[test] + fn ms_splits_to_created_at_and_offset_and_reconstructs() { + // Full epoch-ms in → created_at(secs) + ms-offset(0..999) on the wire → + // exact full ms back out (lossless, and matches Vector's DM convention). + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "ts", 1_234_567).unwrap(); + // On the wire: created_at is seconds, the ms tag is only the 0..999 offset. + assert_eq!(outer_inner_created_at_secs(&outer), 1234); + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.ms, Some(1_234_567), "full ms reconstructed from secs*1000 + offset"); + assert_eq!(opened.created_at.as_secs(), 1234); + } + + /// Decrypt + read the inner event's created_at (test helper). + fn outer_inner_created_at_secs(outer: &Event) -> u64 { + let pt = cipher::open(key().as_bytes(), &outer.content).unwrap(); + let inner = Event::from_json(&String::from_utf8(pt).unwrap()).unwrap(); + inner.created_at.as_secs() + } + + #[test] + fn outer_signer_is_ephemeral_not_author() { + // The wire event must NOT be signed by the author's real key (no linkage). + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "hi", 1).unwrap(); + assert_ne!( + outer.pubkey, + author.public_key(), + "outer event must be ephemeral-signed, not author-signed" + ); + // ...but the recovered author (inner) IS the real key. + let opened = open_message(&outer, &key(), &chan(), Epoch(0)).unwrap(); + assert_eq!(opened.author, author.public_key()); + } + + #[test] + fn identical_plaintext_yields_distinct_ciphertext() { + let author = Keys::generate(); + let a = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap(); + let b = seal_message(&author, &key(), &chan(), Epoch(0), "same", 1).unwrap(); + assert_ne!(a.content, b.content, "per-message nonce must randomize ciphertext"); + } + + #[test] + fn wrong_key_is_rejected() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "secret", 1).unwrap(); + let wrong = ChannelKey([0x22u8; 32]); + let err = open_message(&outer, &wrong, &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}"); + } + + #[test] + fn cross_channel_splice_is_rejected() { + // Decrypt succeeds under a key we hold, but the inner channel tag names a + // different channel than the key's channel → strict-equality check fires. + let author = Keys::generate(); + // Seal for channel A. + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "for A", 1).unwrap(); + // Attempt to open as if it belonged to a DIFFERENT channel B, using the SAME + // key (simulating a member who re-published it under B's coordinate). + let chan_b = ChannelId([0xbbu8; 32]); + let err = open_message(&outer, &key(), &chan_b, Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::ChannelMismatch)), "got {err:?}"); + } + + #[test] + fn cross_epoch_splice_is_rejected() { + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "epoch 0", 1).unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(1)); + assert!(matches!(err, Err(EnvelopeError::EpochMismatch)), "got {err:?}"); + } + + #[test] + fn tampered_ciphertext_is_rejected() { + // Flip a byte of the base64 payload → NIP-44 MAC must fail on decrypt. + let author = Keys::generate(); + let mut outer = + seal_message(&author, &key(), &chan(), Epoch(0), "integrity", 1).unwrap(); + // Rebuild an outer event with a corrupted content (events are immutable, so + // re-sign a mutated copy with a fresh ephemeral key). + let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap(); + let mid = bytes.len() / 2; + bytes[mid] ^= 0xff; + let corrupted = base64_simd::STANDARD.encode_to_string(&bytes); + let ephemeral = Keys::generate(); + outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), corrupted) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&ephemeral) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::Decrypt(_))), "got {err:?}"); + } + + #[test] + fn missing_version_tag_is_rejected() { + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let ephemeral = Keys::generate(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .sign_with_keys(&ephemeral) + .unwrap(); + assert!(matches!( + open_message(&outer, &key(), &chan(), Epoch(0)), + Err(EnvelopeError::BadVersion(None)) + )); + } + + #[test] + fn two_members_exchange() { + // The e2e seed: Alice and Bob hold the same channel key; each can open the + // other's sealed message and recover the correct real author. + let alice = Keys::generate(); + let bob = Keys::generate(); + let shared = key(); + + let from_alice = seal_message(&alice, &shared, &chan(), Epoch(0), "yo bob", 10).unwrap(); + let seen_by_bob = open_message(&from_alice, &shared, &chan(), Epoch(0)).unwrap(); + assert_eq!(seen_by_bob.author, alice.public_key()); + assert_eq!(seen_by_bob.content, "yo bob"); + + let from_bob = seal_message(&bob, &shared, &chan(), Epoch(0), "hey alice", 11).unwrap(); + let seen_by_alice = open_message(&from_bob, &shared, &chan(), Epoch(0)).unwrap(); + assert_eq!(seen_by_alice.author, bob.public_key()); + assert_eq!(seen_by_alice.content, "hey alice"); + + // message_ids differ (distinct inner events). + assert_ne!(seen_by_bob.message_id, seen_by_alice.message_id); + } + + #[test] + fn unknown_version_is_rejected_before_decrypt() { + // Hand-build an outer event with a bogus version tag; must reject on version, + // never reaching decryption. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let ephemeral = Keys::generate(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["999".to_string()])]) + .sign_with_keys(&ephemeral) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "999"), "got {err:?}"); + } + + #[test] + fn inner_kind_mismatch_is_rejected() { + // A signed REACTION inner re-wrapped inside a MESSAGE outer → type splice. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_REACTION), "+") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!( + matches!(err, Err(EnvelopeError::KindMismatch { outer: 3300, inner: 3301 })), + "got {err:?}" + ); + } + + #[test] + fn forged_inner_signature_is_rejected() { + // Tamper the inner content after signing: id/sig no longer verify. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "real") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&inner.as_json()).unwrap(); + v["content"] = serde_json::Value::String("forged".into()); + let tampered = serde_json::to_string(&v).unwrap(); + let content = cipher::seal(key().as_bytes(), tampered.as_bytes()).unwrap(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadSignature)), "got {err:?}"); + } + + #[test] + fn missing_channel_tag_is_rejected() { + // Inner has a valid sig + matching kind + epoch, but no channel tag. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi") + .tags([epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::MissingTag(t)) if t == TAG_CHANNEL), "got {err:?}"); + } + + #[test] + fn duplicate_channel_tag_is_rejected() { + // Two channel tags → ambiguous inner; must reject (don't trust first-match). + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "hi") + .tags([channel_tag(), channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let outer = wrap_inner(&inner); + let err = open_message(&outer, &key(), &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::DuplicateTag(t)) if t == TAG_CHANNEL), "got {err:?}"); + } + + #[test] + fn version_is_checked_before_decryption() { + // Bogus version AND wrong key: must fail on version, NOT decrypt. This proves + // the ordering — a regression moving the version check after decrypt would + // instead surface a Decrypt error here (wrong key), so this catches it where + // the correct-key version test cannot. + let author = Keys::generate(); + let inner = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), "x") + .tags([channel_tag(), epoch0_tag()]) + .sign_with_keys(&author) + .unwrap(); + let content = cipher::seal(key().as_bytes(), inner.as_json().as_bytes()).unwrap(); + let outer = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), content) + .tags([Tag::custom(TagKind::Custom(TAG_VERSION.into()), ["7".to_string()])]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + let wrong_key = ChannelKey([0x99u8; 32]); + let err = open_message(&outer, &wrong_key, &chan(), Epoch(0)); + assert!(matches!(err, Err(EnvelopeError::BadVersion(Some(ref v))) if v == "7"), "got {err:?}"); + } + + #[test] + fn truncated_ciphertext_is_rejected() { + // Distinct from the bit-flip test: chop bytes off the payload → length/MAC fail. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "intact", 1).unwrap(); + let mut bytes = base64_simd::STANDARD.decode_to_vec(outer.content.as_bytes()).unwrap(); + bytes.truncate(bytes.len().saturating_sub(5)); + let truncated = base64_simd::STANDARD.encode_to_string(&bytes); + let mangled = EventBuilder::new(Kind::Custom(event_kind::COMMUNITY_MESSAGE), truncated) + .tags([ + Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::Z)), + [super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex()], + ), + Tag::custom(TagKind::Custom(TAG_VERSION.into()), [PROTOCOL_VERSION.to_string()]), + ]) + .sign_with_keys(&Keys::generate()) + .unwrap(); + assert!(matches!(open_message(&mangled, &key(), &chan(), Epoch(0)), Err(EnvelopeError::Decrypt(_)))); + } + + #[test] + fn seal_uses_matching_inner_and_outer_kind() { + // The binding triad requires inner.kind == outer.kind; seal must produce that. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap(); + assert_eq!(outer.kind.as_u16(), event_kind::COMMUNITY_MESSAGE); + // Decrypt the inner and confirm its kind mirrors the outer. + let plaintext = cipher::open(key().as_bytes(), &outer.content).unwrap(); + let inner = Event::from_json(&String::from_utf8(plaintext).unwrap()).unwrap(); + assert_eq!(inner.kind.as_u16(), outer.kind.as_u16()); + } + + #[test] + fn seal_tags_outer_with_correct_pseudonym_and_version() { + // the relay-filterable `z` tag must carry the correct epoch pseudonym, + // and `v` must be "1" — a regression here silently breaks relay querying. + let author = Keys::generate(); + let outer = seal_message(&author, &key(), &chan(), Epoch(0), "x", 1).unwrap(); + let expected = super::super::derive::channel_pseudonym(&key(), &chan(), Epoch(0)).to_hex(); + assert_eq!(find_tag(&outer, "z").as_deref(), Some(expected.as_str())); + assert_eq!(find_tag(&outer, TAG_VERSION).as_deref(), Some("1")); + } +} diff --git a/crates/vector-core/src/community/inbound.rs b/crates/vector-core/src/community/inbound.rs new file mode 100644 index 00000000..bbe95471 --- /dev/null +++ b/crates/vector-core/src/community/inbound.rs @@ -0,0 +1,1359 @@ +//! Inbound processing: turn a verified, opened Community message into a `Message` +//! in `STATE` under its channel chat (→ app state). Pure conversion +//! (`build_message`) is separated from the STATE mutation (`ingest_message`) so the +//! conversion is unit-testable without any global state. + +use nostr_sdk::prelude::{Event, PublicKey}; +use nostr_sdk::ToBech32; + +use super::envelope::{open_message_multi, OpenedMessage}; +use super::Channel; +use crate::state::ChatState; +use crate::stored_event::event_kind; +use crate::types::Message; + +/// Convert a verified [`OpenedMessage`] into a STATE `Message` via the SHARED content parser. +/// +/// A Concord 3300 normalizes to a text rumor and runs through `rumor::process_rumor` — the exact same +/// path a NIP-17 DM text message takes — so content, reply ref, emoji, ms (incl. the future-clamp), +/// and author-by-conversation-type are parsed in ONE place for every transport. The only Concord- +/// specific layering is attachments: Concord carries NIP-92 `imeta` (multi-file + caption), already +/// parsed in `open_message`, so they're set on top of the shared text result. +/// Build a normalized `(RumorEvent, RumorContext)` from an opened Concord inner so it can run through +/// the SHARED `rumor::process_rumor`. `kind` is the canonical content kind the sub-kind maps to +/// (3300→14, 3301→reaction, 3302→edit, 3305→deletion). The binding/banlist/authority checks already +/// happened at the transport layer; this is purely the bridge to the shared content parser. +fn concord_rumor( + opened: &OpenedMessage, + kind: nostr_sdk::Kind, + my_pubkey: &PublicKey, +) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) { + use crate::rumor::{ConversationType, RumorContext, RumorEvent}; + ( + RumorEvent { + id: opened.message_id, + kind, + content: opened.content.clone(), + tags: opened.tags.clone(), + created_at: opened.created_at, + pubkey: opened.author, + }, + RumorContext { + sender: opened.author, + is_mine: opened.author == *my_pubkey, + conversation_id: opened.channel_id.to_hex(), + conversation_type: ConversationType::Community, + }, + ) +} + +pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message { + use crate::rumor::{process_rumor, RumorProcessingResult}; + let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::PrivateDirectMessage, my_pubkey); + let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) { + Ok(RumorProcessingResult::TextMessage(m)) => m, + // A 3300 is always a caption/text message, so this never fires — but never drop a message on + // a parser quirk: fall back to the minimal direct fields. + _ => Message { + id: opened.message_id.to_hex(), + content: opened.content.clone(), + at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)), + mine: opened.author == *my_pubkey, + npub: opened.author.to_bech32().ok(), + ..Default::default() + }, + }; + // Transport-specific: Concord attachments are NIP-92 imeta (already parsed). Link the outer wire + // id for the shared dedup. The shared parser already set content/reply/emoji/ms/npub. + msg.attachments = opened.attachments.clone(); + msg.wrapper_event_id = Some(opened.wrapper_id.to_hex()); + msg +} + +/// Ingest a verified Community message into STATE under its channel chat, creating +/// the chat (as `ChatType::Community`) if absent. Returns the added `Message` (so the +/// caller can persist + emit it), or `None` if it was a duplicate (dedup on the inner +/// message id). +pub fn ingest_message( + state: &mut ChatState, + opened: &OpenedMessage, + my_pubkey: &PublicKey, +) -> Option { + let chat_id = opened.channel_id.to_hex(); + let msg = build_message(opened, my_pubkey); + // DB-level dedup: an inner id already in the events table is KNOWN — don't re-ingest into + // STATE or re-emit it. A boot/catch-up sweep re-fetches the whole channel page, but in-memory + // STATE only holds the per-chat hydration window, so it can't dedup the tail on its own. Without + // this, replayed sends (incl. our own) resurface as "new", re-firing reads/notifications. Mirrors + // the DM pipeline: outer dedup (wrapper-id cache) + inner dedup (events table). Known events live + // in the DB and load from there. + if crate::db::events::event_exists(&msg.id).unwrap_or(false) { + return None; + } + state.ensure_community_chat(&chat_id); + if state.add_message_to_chat(&chat_id, msg.clone()) { + Some(msg) + } else { + None + } +} + +/// The result of processing an inbound wire event: a brand-new message (3300), an update to +/// an existing message (a reaction 3301 / edit 3302 applied to its target), or a tombstone +/// (a delete 3305 that removed its target). New/Updated surface as a UI `message_new` / +/// `message_update`; Removed surfaces as a `message_removed`. +pub enum IncomingEvent { + NewMessage(Message), + Updated { target_id: String, message: Message }, + Removed { target_id: String }, + /// A join/leave presence announcement (kind 3306). `npub` is the announcing member; the + /// caller persists + surfaces it as a `MemberJoined`/`MemberLeft` system event. `event_id` + /// is the inner id (dedup key). `created_at` is the inner's authenticated timestamp (secs) so a + /// HISTORICALLY-synced join/leave lands at the right place in the timeline, not at ingest-time + /// "now". `invited_by`/`invited_label` carry attribution on an invite-join (who/which-link + /// brought them) — `None` for a plain join/leave. Not a message. + Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option, invited_label: Option }, + /// A cooperative kick (3309) targeting THE LOCAL USER, authorized (signer held `KICK` + outranked + /// us). The caller performs the self-removal teardown (wipe local chat data, RETAIN the held + /// epoch keys). A kick of ANOTHER member surfaces as `Presence { joined: false }` + /// instead, so it falls out of the observed member list without a dedicated arm. + Kicked { community_id: String }, + /// A voluntary leave-presence (3306, content "leave") whose inner author IS the local npub — i.e. a + /// leave I (or another of my devices) published. route to the same self-removal teardown as + /// `Kicked`/ban so a leave on device A tears the community down on device B too. Safe because the + /// presence inner is real-npub-signed (only my own devices can author a leave for my npub). The + /// teardown is idempotent, so the publishing device tearing down on its own echoed leave is a no-op. + SelfLeft { community_id: String }, +} + +/// Open a single incoming wire event against `channel`, verify the binding, and apply +/// it to STATE by sub-kind: a message is ingested, a reaction/edit is applied to its +/// target. Events that fail to open (wrong key, splice, forged sig, bad version) or that +/// dedup/target-miss are dropped (returns `None`). This is the per-event handler the +/// real-time subscription routes each arriving 3300/3301/3302 event through. +pub fn process_incoming( + state: &mut ChatState, + event: &Event, + channel: &Channel, + my_pubkey: &PublicKey, +) -> Option { + // Outer-event dedup, shared with DMs via the cross-transport ledger: a wire event we've already + // processed is either recorded as some inner's `wrapper_event_id` (row-creating sub-kinds) or in + // the `processed_wrappers` ledger (non-row sub-kinds). Skip it BEFORE decryption — the same role + // the wrapper-id cache plays for gift-wraps. The per-inner-id check in ingest_message is the + // backstop for the same inner re-published under a fresh wire event. + let outer_bytes = event.id.to_bytes(); + if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false) + || crate::db::wrappers::processed_wrapper_exists(&outer_bytes) + { + return None; + } + // binary seal: a dissolved community DROPS every subsequent event — control or message, any author + // (owner included), any claimed time. NO timestamp comparison: the seal is the flag, not a time. + // Already-persisted events stay (no retroactive purge); this only stops NEW events from landing. + // CARVE-OUT: own-message DELETIONS (3305) always pass — data ownership means anyone can scrub their own + // content from a dead community, and a delete only removes the author's OWN message (it can't inject, so + // it doesn't reopen the backdating attack the seal exists to stop). `apply_delete` restricts a dissolved + // community's deletes to SELF-deletes (moderation-hides are blocked). + if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE { + return None; + } + // Select the decryption key by the event's epoch pseudonym across ALL held epochs (post-rekey + // catch-up), so a message posted under an older epoch still opens. Falls back to the head epoch for + // send-built/test channels (read_epoch_keys). + let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) { + Ok(o) => o, + Err(e) => { + crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e); + return None; + } + }; + // Banlist (the "anti-memberlist"): drop EVERY event kind from a banned author — message, + // reaction, edit, delete, presence — so a banned member vanishes entirely, presence and all. + if channel.banned.contains(&opened.author) { + crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex()); + return None; + } + let outcome = match opened.kind { + k if k == event_kind::COMMUNITY_MESSAGE => { + ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage) + } + k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey), + k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey), + k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey), + k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey), + k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey), + _ => None, + }; + // Record the outer id in the shared ledger for NON-message sub-kinds, which have no inner row to + // carry a `wrapper_event_id` (messages are covered atomically by that column on save). These are + // idempotent on replay, so recording at process time is safe. Gives every sub-kind the same + // pre-decryption skip on a re-fetch that messages already get. + if let Some(ref evt) = outcome { + if !matches!(evt, IncomingEvent::NewMessage(_)) { + let _ = crate::db::wrappers::save_processed_wrapper( + &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD, + ); + } + } + outcome +} + +/// Interpret a presence announcement (3306). The inner author is the member; content "leave" +/// marks a departure, anything else (e.g. "join") an arrival. No STATE mutation here — the +/// caller turns this into a persisted system event (which is where dedup-by-id happens). +fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option { + // Content is "leave", plain "join", or an attributed-join JSON `{"by":"","l":"