diff --git a/Cargo.lock b/Cargo.lock index 4934e43c8f..df904c8b11 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -352,20 +352,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -398,12 +399,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -598,6 +593,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -610,7 +611,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "serde", "unicode-normalization", ] @@ -645,6 +646,21 @@ version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" +[[package]] +name = "bitcoin-consensus-encoding" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "207311705279250ba465076a1bac4b1ac982855fff73fc5f67e22158ac58cdc9" +dependencies = [ + "bitcoin-internals", +] + +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.4" @@ -658,7 +674,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" dependencies = [ "bitcoin-io", - "hex-conservative", + "hex-conservative 0.2.2", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals", + "hex-conservative 1.2.0", "serde", ] @@ -813,7 +841,7 @@ dependencies = [ "hex", "httparse", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rustls", "serde", @@ -846,7 +874,7 @@ dependencies = [ "clap", "deadpool-redis", "hex", - "nostr", + "nostr 0.44.7", "rustls", "serde_json", "sqlx", @@ -908,7 +936,7 @@ dependencies = [ "hex", "hmac 0.13.0", "jsonwebtoken", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -930,7 +958,7 @@ dependencies = [ "http-body-util", "k8s-openapi", "kube", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "rustls", "serde", @@ -957,7 +985,7 @@ dependencies = [ "dirs", "hex", "infer", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rustls", @@ -990,7 +1018,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.1", "serde", @@ -1013,7 +1041,7 @@ dependencies = [ "hex", "metrics", "metrics-util", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "serde", "serde_json", @@ -1037,7 +1065,7 @@ dependencies = [ "ignore", "image", "nix 0.31.3", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "rmcp", "rustls", @@ -1070,7 +1098,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1109,7 +1137,7 @@ dependencies = [ "clap", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1138,7 +1166,7 @@ dependencies = [ "chrono", "deadpool-redis", "futures-util", - "nostr", + "nostr 0.44.7", "redis", "serde", "serde_json", @@ -1164,7 +1192,7 @@ dependencies = [ "metrics", "metrics-exporter-prometheus", "minicbor", - "nostr", + "nostr 0.44.7", "p256", "proptest", "rand 0.10.1", @@ -1223,7 +1251,7 @@ dependencies = [ "metrics-exporter-prometheus", "metrics-util", "moka", - "nostr", + "nostr 0.44.7", "opentelemetry 0.32.0", "opentelemetry-otlp 0.32.0", "opentelemetry_sdk 0.32.1", @@ -1264,7 +1292,7 @@ dependencies = [ "hex", "hmac 0.13.0", "iroh", - "nostr", + "nostr 0.44.7", "postcard", "proptest", "redis", @@ -1282,7 +1310,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1313,7 +1341,7 @@ dependencies = [ "chrono", "futures-util", "hex", - "nostr", + "nostr 0.44.7", "rand 0.10.1", "reqwest 0.13.4", "rust-s3", @@ -1362,7 +1390,7 @@ dependencies = [ "evalexpr", "hex", "moka", - "nostr", + "nostr 0.44.7", "reqwest 0.13.4", "serde", "serde_json", @@ -1378,7 +1406,7 @@ name = "buzz-ws-client" version = "0.1.0" dependencies = [ "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "thiserror 2.0.18", "tokio", @@ -1826,7 +1854,7 @@ dependencies = [ "anyhow", "buzz-sdk", "futures-util", - "nostr", + "nostr 0.44.7", "serde_json", "tokio", "tokio-tungstenite 0.29.0", @@ -2077,43 +2105,16 @@ dependencies = [ "phf", ] -[[package]] -name = "csv" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde_core", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - [[package]] name = "ctor" -version = "0.6.3" +version = "1.0.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "424e0138278faeb2b401f174ad17e715c829512d74f3d1e81eb43365c2e0590e" +checksum = "914a755b7c2d4af2bdcff7ce1739e2db9a1b81a9b07123d8015786ae03c0980d" dependencies = [ - "ctor-proc-macro", - "dtor", + "link-section", + "linktime-proc-macro", ] -[[package]] -name = "ctor-proc-macro" -version = "0.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52560adf09603e58c9a7ee1fe1dcb95a16927b17c127f0ac02d6e768a0e25bc1" - [[package]] name = "ctr" version = "0.9.2" @@ -2564,21 +2565,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "dtor" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404d02eeb088a82cfd873006cb713fe411306c7d182c344905e101fb1167d301" -dependencies = [ - "dtor-proc-macro", -] - -[[package]] -name = "dtor-proc-macro" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f678cf4a922c215c63e0de95eb1ff08a958a81d47e485cf9da1e27bf6305cfa5" - [[package]] name = "dunce" version = "1.0.5" @@ -2840,6 +2826,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3147,8 +3143,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3226,7 +3222,7 @@ name = "git-credential-nostr" version = "0.1.0" dependencies = [ "base64 0.22.1", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3239,7 +3235,7 @@ dependencies = [ "chrono", "hex", "libc", - "nostr", + "nostr 0.44.7", "serde_json", "zeroize", ] @@ -3319,6 +3315,15 @@ dependencies = [ "tracing", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbag" version = "0.1.13" @@ -3377,6 +3382,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.5.0" @@ -3405,45 +3420,31 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] name = "hf-xet" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "430b33fa84f92796d4d263070b6c0d3ca219df7b9a0e1853ee431029b1612bcd" +checksum = "c237ef4fb0ce1962a5117f8bd8c74454b41629826a9df17d14a1840ca18f0754" dependencies = [ + "anyhow", "async-trait", "bytes", "http", "more-asserts", "serde", + "serde_json", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "uuid", "xet-client", @@ -3748,7 +3749,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4181,7 +4182,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.0", + "lru", "n0-error", "n0-future", "noq", @@ -4567,6 +4568,18 @@ dependencies = [ "bitflags 2.13.0", ] +[[package]] +name = "link-section" +version = "0.19.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39c29a617ce3df32c08497bdc1ab6e2376e0b17948ac166a2fbe5977c5954cd9" + +[[package]] +name = "linktime-proc-macro" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e57c38c1e860fd37c604281cdfb1dd2216977fd76a50f85ba2f388ef3219616" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -4652,12 +4665,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.0" @@ -4804,8 +4811,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "hex", "mesh-llm-client", @@ -4814,8 +4821,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -4825,13 +4832,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -4862,8 +4869,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -4878,8 +4885,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -4888,8 +4895,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "clap", @@ -4900,12 +4907,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -4913,8 +4917,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", "serde_json", @@ -4922,16 +4926,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "argon2", @@ -4949,7 +4982,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -4964,6 +4996,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -4976,6 +5009,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5023,8 +5057,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "argon2", "base64 0.22.1", @@ -5045,8 +5079,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "serde", @@ -5056,8 +5090,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-types", @@ -5070,8 +5104,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -5087,8 +5121,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5106,8 +5140,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "hex", @@ -5117,18 +5151,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5150,8 +5193,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5165,8 +5208,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5176,8 +5219,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "chrono", @@ -5185,8 +5228,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5199,8 +5246,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "hex", "serde", @@ -5210,13 +5257,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5227,6 +5274,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "anyhow", + "libloading", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "metrics" version = "0.24.6" @@ -5364,8 +5427,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -5375,14 +5438,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5393,14 +5456,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5413,16 +5476,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "model-artifact", @@ -5800,6 +5863,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -5840,9 +5904,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -5858,56 +5922,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.1", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.1", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6188,10 +6267,16 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "async-trait", "axum", @@ -7555,7 +7640,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.0", + "lru", "palette", "serde", "strum", @@ -8296,13 +8381,24 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.1", + "rand 0.8.6", + "secp256k1-sys 0.10.1", +] + [[package]] name = "secp256k1" version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2c3c81b43dc2d8877c216a3fccf76677ee1ebccd429566d3e67447290d0c42b2" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.1", "rand 0.9.4", "secp256k1-sys 0.11.0", ] @@ -8600,7 +8696,6 @@ dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.2.17", "digest 0.10.7", - "sha2-asm", ] [[package]] @@ -8614,15 +8709,6 @@ dependencies = [ "digest 0.11.3", ] -[[package]] -name = "sha2-asm" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b845214d6175804686b2bd482bcffe96651bb2d1200742b712003504a2dac1ab" -dependencies = [ - "cc", -] - [[package]] name = "sharded-slab" version = "0.1.7" @@ -8775,8 +8861,8 @@ checksum = "0c6f73aeb92d671e0cc4dca167e59b2deb6387c375391bc99ee743f326994a2b" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "blake3", @@ -8785,40 +8871,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "libloading", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "prost 0.14.3", "prost-build 0.14.3", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "libc", @@ -8831,8 +8918,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "ahash", "anyhow", @@ -8843,6 +8930,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto 0.31.0", "serde", @@ -8852,16 +8941,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", "serde_json", @@ -9763,6 +9861,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -9831,9 +9938,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -9841,7 +9948,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -9881,6 +9988,7 @@ version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dad543404f98bfc969aeb71994105c592acfc6c43323fddcd016bb208d1c65cb" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -9898,6 +10006,30 @@ dependencies = [ "tokio-util", ] +[[package]] +name = "tokio_with_wasm" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34e40fbbbd95441133fe9483f522db15dbfd26dc636164ebd8f2dd28759a6aa6" +dependencies = [ + "js-sys", + "tokio", + "tokio_with_wasm_proc", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "tokio_with_wasm_proc" +version = "0.8.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d01145a2c788d6aae4cd653afec1e8332534d7d783d01897cefcafe4428de992" +dependencies = [ + "quote", + "syn 2.0.117", +] + [[package]] name = "toml" version = "0.9.12+spec-1.1.0" @@ -10199,9 +10331,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -10378,6 +10510,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -11335,8 +11473,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] @@ -11397,20 +11535,18 @@ dependencies = [ [[package]] name = "xet-client" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1e496dcbe6a09017acdfaf48e1a646735e7ff5b2a49e2c7e081cca77a59bc8" +checksum = "c3b8da8cc70aa2e3c500c0400e012df82c656ab9fca47f9f939fffc5afd89aca" dependencies = [ "anyhow", "async-trait", "base64 0.22.1", "bytes", - "clap", "crc32fast", "futures", "http", "hyper", - "lazy_static", "more-asserts", "rand 0.10.1", "redb", @@ -11424,8 +11560,8 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-retry", + "tokio_with_wasm", "tracing", - "tracing-subscriber", "url", "urlencoding", "web-time", @@ -11435,24 +11571,21 @@ dependencies = [ [[package]] name = "xet-core-structures" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb838aa8eb67d730af301584cf003caad407487606058292a6750711b603fbee" +checksum = "73503c223783dccc864abde22115e09d12f190448a0baf58ab2c54bc709e2f99" dependencies = [ "async-trait", "base64 0.22.1", "blake3", "bytemuck", "bytes", - "clap", "countio", - "csv", "futures", "futures-util", "getrandom 0.4.3", "heapify", "itertools", - "lazy_static", "lz4_flex", "more-asserts", "rand 0.10.1", @@ -11460,7 +11593,6 @@ dependencies = [ "safe-transmute", "serde", "static_assertions", - "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", @@ -11472,32 +11604,31 @@ dependencies = [ [[package]] name = "xet-data" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67fd409bef621411a9d9013798540bb8036cb2678f03ab39af89a5e88034ed8c" +checksum = "c89052ec5dec2187cad30b86af92cc24fd61c4a57a795f1ff7ff5f38d49184eb" dependencies = [ "anyhow", "async-trait", "bytes", "chrono", - "clap", "gearhash", "http", "itertools", - "lazy_static", "more-asserts", "rand 0.10.1", "serde", "serde_json", - "sha2 0.10.9", + "sha2 0.11.0", "tempfile", "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "url", "uuid", - "walkdir", + "web-time", "xet-client", "xet-core-structures", "xet-runtime", @@ -11505,9 +11636,9 @@ dependencies = [ [[package]] name = "xet-runtime" -version = "1.5.2" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "15d8f121c33866f7648b737abe70d0e2dd9c0af4ffdd7219207531d0283aa63d" +checksum = "af5c60d5eed38ab4c576f4421bae835e7bd07631fb381705605529d2015c106b" dependencies = [ "anyhow", "async-trait", @@ -11521,7 +11652,6 @@ dependencies = [ "git-version", "humantime", "konst", - "lazy_static", "libc", "more-asserts", "oneshot", @@ -11535,9 +11665,11 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tokio-util", + "tokio_with_wasm", "tracing", "tracing-appender", "tracing-subscriber", + "web-time", "whoami", "winapi", ] diff --git a/crates/buzz-auth/src/foundation.rs b/crates/buzz-auth/src/foundation.rs index ebdfcae94c..c3d1df462f 100644 --- a/crates/buzz-auth/src/foundation.rs +++ b/crates/buzz-auth/src/foundation.rs @@ -979,6 +979,32 @@ impl VerifiedFederatedAssertion { pub const fn attested_event_author_pubkey(&self) -> Option { self.attested_event_author_pubkey } + + /// Transport and exact request, target, and context coordinates sealed by the verifier. + pub const fn request_binding(&self) -> (ProofTransport, &[u8; 32], &[u8; 32], &[u8; 32]) { + ( + self.transport, + &self.request_fingerprint, + &self.target_fingerprint, + &self.transport_context_fingerprint, + ) + } + + /// SHA-256 fingerprint of the exact verified assertion bytes. + pub const fn assertion_fingerprint(&self) -> &[u8; 32] { + &self.assertion_fingerprint + } + + /// Inclusive not-before and exclusive expiry bounds sealed by the verifier. + pub const fn time_bounds(&self) -> (DateTime, DateTime) { + (self.not_before, self.expires_at) + } + + #[cfg(test)] + pub(crate) fn with_test_attested_event_author(mut self, author: PublicKey) -> Self { + self.attested_event_author_pubkey = Some(author); + self + } } impl fmt::Debug for VerifiedFederatedAssertion { @@ -1487,6 +1513,9 @@ pub struct LocalBindingResolution(LocalBindingResolutionKind); #[derive(Clone)] enum LocalBindingResolutionKind { + BoundKey { + binding: ActiveLocalBinding, + }, Direct { assertion: VerifiedFederatedAssertion, binding: ActiveLocalBinding, @@ -1502,6 +1531,14 @@ enum LocalBindingResolutionKind { } impl LocalBindingResolution { + /// Bind a transport-verified event author to its authoritative active row. + /// + /// This assertion-free path is for operations whose exact signed request is + /// itself the proof. It never enrolls a key and never accepts delegation. + pub(crate) fn bound_key(binding: ActiveLocalBinding) -> Self { + Self(LocalBindingResolutionKind::BoundKey { binding }) + } + /// Bind an origin-sealed direct assertion to an authoritative storage row. pub fn direct(assertion: VerifiedFederatedAssertion, binding: ActiveLocalBinding) -> Self { Self(LocalBindingResolutionKind::Direct { assertion, binding }) @@ -1527,6 +1564,7 @@ impl LocalBindingResolution { impl fmt::Debug for LocalBindingResolution { fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { let name = match &self.0 { + LocalBindingResolutionKind::BoundKey { .. } => "BoundKey([REDACTED])", LocalBindingResolutionKind::Direct { .. } => "Direct([REDACTED])", LocalBindingResolutionKind::Enrollment { .. } => "Enrollment([REDACTED])", LocalBindingResolutionKind::Delegated { .. } => "Delegated([REDACTED])", @@ -1936,6 +1974,11 @@ pub struct PreparedAuthorization { } impl PreparedAuthorization { + /// Server-generated correlation identifier sealed into this preparation. + pub const fn correlation_id(&self) -> Uuid { + self.context.correlation_id() + } + /// Clone the exact dependency tuple that authoritative storage and the /// verifier cache must recheck after all intervening I/O. pub fn recheck_request(&self) -> PreparedAuthorizationRecheck { @@ -2110,6 +2153,33 @@ impl AuthorizationFinalizer { reason, verifier_stamp, ) = match resolution.0 { + LocalBindingResolutionKind::BoundKey { binding } => { + if binding.authorization_domain != input.authorization_domain + || binding.event_author_pubkey != input.proof.actor_pubkey + || input.proof.bound_assertion_fingerprint.is_some() + || input.proof.delegation_conditions_fingerprint.is_some() + { + return Err(AuthorizationError::BindingMismatch); + } + let expires_at = Self::effective_lease_upper_bound( + authoritative_now, + &[ + Some(input.proof.expires_at), + binding.expires_at, + Some(policy.expires_at), + ], + )?; + ( + None, + binding.binding_id, + binding.binding_version, + None, + None, + expires_at, + AuthorizationReason::ExistingBinding, + None, + ) + } LocalBindingResolutionKind::Direct { assertion, binding } => { if assertion.authorization_domain != input.authorization_domain || binding.authorization_domain != input.authorization_domain diff --git a/crates/buzz-auth/src/lib.rs b/crates/buzz-auth/src/lib.rs index b934e91ab2..0453bb32bb 100644 --- a/crates/buzz-auth/src/lib.rs +++ b/crates/buzz-auth/src/lib.rs @@ -60,7 +60,12 @@ pub use nip42::{ generate_challenge, verify_nip42_authorization_proof, verify_nip42_event, Nip42AuthorizationProofError, }; -pub use nip98::verify_nip98_event; +pub use nip98::{ + verify_nip42_moderation_command_proof, verify_nip98_event, verify_nip98_invite_claim_proof, + verify_nip98_moderation_command_proof, Nip42ModerationCommandCoordinates, + Nip98InviteClaimCoordinates, Nip98ModerationCommandCoordinates, VerifiedModerationCommandProof, + VerifiedNip98InviteClaimProof, +}; pub use nip98_replay::{ nip98_replay_key, nip98_replay_key_for_scope, Nip98ReplayGuard, DEFAULT_REPLAY_TTL_SECS, MAX_REPLAY_TTL_SECS, diff --git a/crates/buzz-auth/src/nip98.rs b/crates/buzz-auth/src/nip98.rs index 74ed8c2655..cf998fa3a6 100644 --- a/crates/buzz-auth/src/nip98.rs +++ b/crates/buzz-auth/src/nip98.rs @@ -23,11 +23,16 @@ //! `SHA-256(body) == hex(payload_tag)`. This prevents body-substitution attacks. //! 8. Return `event.pubkey` on success. +use chrono::{DateTime, Duration, Utc}; use nostr::{Alphabet, Event, Kind, SingleLetterTag, TagKind, Timestamp}; use sha2::{Digest, Sha256}; use url::Url; -use crate::error::AuthError; +use crate::{ + error::AuthError, ActiveLocalBinding, AuthorizationError, AuthorizationFinalizer, + AuthorizationInput, LocalAuthorizationPolicy, LocalBindingResolution, PreparedAuthorization, + ProofTransport, RouteCapability, VerifiedFederatedAssertion, VerifiedNostrProof, +}; const TIMESTAMP_TOLERANCE_SECS: u64 = 60; @@ -130,6 +135,649 @@ pub fn verify_nip98_event( Ok(event.pubkey) } +/// Exact server-derived coordinates for one body-bound invite claim. +/// +/// The request fingerprint intentionally excludes event id, signature, and +/// serialization so a freshly signed proof for the same semantic claim reaches +/// the same canonical admission receipt. The transport fingerprint still binds +/// the exact host, URL, method, and payload. +pub struct Nip98InviteClaimCoordinates { + authorization_domain: buzz_core::CommunityId, + canonical_url: Box, + canonical_host: Box, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + payload_sha256: [u8; 32], +} + +impl Nip98InviteClaimCoordinates { + /// Bind an exact POST request to one server-resolved invite target. + pub fn new( + authorization_domain: buzz_core::CommunityId, + target_fingerprint: [u8; 32], + expected_url: &str, + body: &[u8], + ) -> Result { + if authorization_domain.as_uuid().is_nil() || target_fingerprint == [0; 32] { + return Err(AuthError::Nip98Invalid( + "invalid invite claim coordinates".to_owned(), + )); + } + let (canonical_url, canonical_host) = strict_canonical_url(expected_url)?; + let payload_sha256: [u8; 32] = Sha256::digest(body).into(); + let request_fingerprint = framed_digest( + b"buzz:nip-fi:nip98-invite-request:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + &target_fingerprint, + b"POST", + canonical_url.as_bytes(), + &payload_sha256, + ], + ); + let transport_context_fingerprint = framed_digest( + b"buzz:nip-fi:nip98-invite-transport:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + canonical_host.as_bytes(), + b"POST", + canonical_url.as_bytes(), + &payload_sha256, + ], + ); + Ok(Self { + authorization_domain, + canonical_url: canonical_url.into(), + canonical_host: canonical_host.into(), + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + payload_sha256, + }) + } + + /// Exact request, protected target, and transport-context fingerprints. + pub const fn request_binding(&self) -> (&[u8; 32], &[u8; 32], &[u8; 32]) { + ( + &self.request_fingerprint, + &self.target_fingerprint, + &self.transport_context_fingerprint, + ) + } + + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> buzz_core::CommunityId { + self.authorization_domain + } +} + +impl std::fmt::Debug for Nip98InviteClaimCoordinates { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Nip98InviteClaimCoordinates([REDACTED])") + } +} + +/// Origin-sealed proof of the exact NIP-98 invite-claim purpose. +/// +/// This opaque type prevents a generic NIP-98 proof produced for another +/// route from crossing the canonical invite-admission boundary. +#[derive(Clone)] +pub struct VerifiedNip98InviteClaimProof { + proof: VerifiedNostrProof, +} + +/// Exact server-derived coordinates for one moderation command submitted over +/// the HTTP event bridge. +pub struct Nip98ModerationCommandCoordinates { + authorization_domain: buzz_core::CommunityId, + canonical_url: Box, + canonical_host: Box, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + payload_sha256: [u8; 32], + actor: nostr::PublicKey, + command_event_id: [u8; 32], + command_kind: u32, +} + +impl Nip98ModerationCommandCoordinates { + /// Bind one body-authenticated `/events` request to its decoded moderation + /// target. The target must come from the verified command, never a client + /// side channel. + pub fn new( + authorization_domain: buzz_core::CommunityId, + target_fingerprint: [u8; 32], + expected_url: &str, + body: &[u8], + command: &Event, + ) -> Result { + if authorization_domain.as_uuid().is_nil() || target_fingerprint == [0; 32] { + return Err(AuthError::Nip98Invalid( + "invalid moderation command coordinates".to_owned(), + )); + } + let (canonical_url, canonical_host) = strict_canonical_url(expected_url)?; + let payload_sha256: [u8; 32] = Sha256::digest(body).into(); + let command_kind = command.kind.as_u16() as u32; + if !buzz_core::kind::is_moderation_command_kind(command_kind) { + return Err(AuthError::Nip98Invalid( + "invalid moderation command kind".to_owned(), + )); + } + let command_event_id = command.id.to_bytes(); + let request_fingerprint = moderation_command_request_fingerprint( + authorization_domain, + command.pubkey, + command_event_id, + command_kind, + target_fingerprint, + ); + let transport_context_fingerprint = framed_digest( + b"buzz:nip-fi:nip98-moderation-transport:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + canonical_host.as_bytes(), + b"POST", + canonical_url.as_bytes(), + &payload_sha256, + ], + ); + Ok(Self { + authorization_domain, + canonical_url: canonical_url.into(), + canonical_host: canonical_host.into(), + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + payload_sha256, + actor: command.pubkey, + command_event_id, + command_kind, + }) + } +} + +impl std::fmt::Debug for Nip98ModerationCommandCoordinates { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Nip98ModerationCommandCoordinates([REDACTED])") + } +} + +/// Exact server-derived coordinates for a fresh signed moderation command +/// submitted on an authenticated WebSocket. +pub struct Nip42ModerationCommandCoordinates { + authorization_domain: buzz_core::CommunityId, + relay_url: Box, + event_id: [u8; 32], + actor: nostr::PublicKey, + command_kind: u32, + request_fingerprint: [u8; 32], + target_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], +} + +impl Nip42ModerationCommandCoordinates { + /// Bind a signed command to the tenant WebSocket origin and decoded target. + pub fn new( + authorization_domain: buzz_core::CommunityId, + target_fingerprint: [u8; 32], + relay_url: &str, + command: &Event, + ) -> Result { + if authorization_domain.as_uuid().is_nil() + || target_fingerprint == [0; 32] + || command.id.to_bytes() == [0; 32] + { + return Err(AuthError::Nip98Invalid( + "invalid moderation command coordinates".to_owned(), + )); + } + let relay_url = strict_websocket_origin(relay_url)?; + let command_kind = command.kind.as_u16() as u32; + if !buzz_core::kind::is_moderation_command_kind(command_kind) { + return Err(AuthError::Nip98Invalid( + "invalid moderation command kind".to_owned(), + )); + } + let event_id = command.id.to_bytes(); + let request_fingerprint = moderation_command_request_fingerprint( + authorization_domain, + command.pubkey, + event_id, + command_kind, + target_fingerprint, + ); + let transport_context_fingerprint = framed_digest( + b"buzz:nip-fi:nip42-moderation-transport:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + relay_url.as_bytes(), + &event_id, + ], + ); + Ok(Self { + authorization_domain, + relay_url: relay_url.into(), + event_id, + actor: command.pubkey, + command_kind, + request_fingerprint, + target_fingerprint, + transport_context_fingerprint, + }) + } +} + +impl std::fmt::Debug for Nip42ModerationCommandCoordinates { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("Nip42ModerationCommandCoordinates([REDACTED])") + } +} + +/// Purpose-sealed proof of an exact moderation command. +#[derive(Clone)] +pub struct VerifiedModerationCommandProof { + proof: VerifiedNostrProof, +} + +impl VerifiedModerationCommandProof { + /// Server-resolved authorization domain. + pub const fn authorization_domain(&self) -> buzz_core::CommunityId { + self.proof.authorization_domain() + } + + /// Exact verified command author. + pub const fn actor_pubkey(&self) -> nostr::PublicKey { + self.proof.actor_pubkey() + } + + /// Transport-neutral signed-command request identity. + pub const fn request_fingerprint(&self) -> &[u8; 32] { + self.proof.request_fingerprint() + } + + /// Server-decoded moderation target. + pub const fn target_fingerprint(&self) -> &[u8; 32] { + self.proof.target_fingerprint() + } + + /// Exact transport context, intentionally distinct across HTTP and WS. + pub const fn transport_context_fingerprint(&self) -> &[u8; 32] { + self.proof.transport_context_fingerprint() + } + + /// Exclusive proof expiry. + pub const fn expires_at(&self) -> DateTime { + self.proof.expires_at() + } + + /// Finalize this purpose seal against authoritative local state. + /// + /// The generic proof and assertion-free binding resolution never leave + /// this crate, and the capability is fixed here rather than selected by a + /// caller. + pub fn prepare_authorization( + self, + binding: ActiveLocalBinding, + policy: LocalAuthorizationPolicy, + authoritative_now: DateTime, + ) -> Result { + let domain = self.proof.authorization_domain(); + let input = AuthorizationInput::new( + domain, + uuid::Uuid::new_v4(), + self.proof, + RouteCapability::Moderation, + )?; + let resolution = LocalBindingResolution::bound_key(binding); + AuthorizationFinalizer::prepare(input, resolution, policy, authoritative_now) + } +} + +impl std::fmt::Debug for VerifiedModerationCommandProof { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("VerifiedModerationCommandProof([REDACTED])") + } +} + +/// Verify a strict, payload-bound NIP-98 proof for one decoded moderation +/// command. +pub fn verify_nip98_moderation_command_proof( + event_json: &str, + coordinates: &Nip98ModerationCommandCoordinates, + body: &[u8], + authoritative_now: DateTime, +) -> Result { + let event: Event = serde_json::from_str(event_json) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation proof JSON".to_owned()))?; + if event.kind != Kind::HttpAuth || !event.content.is_empty() { + return Err(AuthError::Nip98Invalid( + "invalid moderation proof kind or content".to_owned(), + )); + } + buzz_core::verify_event(&event) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation proof signature".to_owned()))?; + validate_moderation_proof_time(&event, authoritative_now, TIMESTAMP_TOLERANCE_SECS)?; + let signed_url = exact_tag( + &event, + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)), + "u", + )?; + let signed_method = exact_tag(&event, TagKind::Method, "method")?; + let signed_payload = exact_tag(&event, TagKind::Payload, "payload")?; + let (canonical_signed_url, signed_host) = strict_canonical_url(signed_url)?; + let payload_sha256: [u8; 32] = Sha256::digest(body).into(); + let command: Event = serde_json::from_slice(body) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation command body".to_owned()))?; + let command_kind = command.kind.as_u16() as u32; + if canonical_signed_url != coordinates.canonical_url.as_ref() + || signed_host != coordinates.canonical_host.as_ref() + || signed_method != "POST" + || payload_sha256 != coordinates.payload_sha256 + || signed_payload != hex::encode(payload_sha256) + || event.pubkey != coordinates.actor + || command.pubkey != coordinates.actor + || command.id.to_bytes() != coordinates.command_event_id + || command_kind != coordinates.command_kind + || !buzz_core::kind::is_moderation_command_kind(command_kind) + { + return Err(AuthError::Nip98Invalid( + "moderation proof request binding mismatch".to_owned(), + )); + } + buzz_core::verify_event(&command) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation command signature".to_owned()))?; + validate_moderation_proof_time(&command, authoritative_now, 120)?; + let expires_at = proof_expiry(&event, authoritative_now, TIMESTAMP_TOLERANCE_SECS)? + .min(proof_expiry(&command, authoritative_now, 120)?); + let proof = VerifiedNostrProof::from_verifier( + coordinates.authorization_domain, + event.pubkey, + ProofTransport::Nip98, + coordinates.request_fingerprint, + coordinates.target_fingerprint, + coordinates.transport_context_fingerprint, + None, + None, + expires_at, + ) + .ok_or_else(|| AuthError::Nip98Invalid("invalid moderation proof binding".to_owned()))?; + Ok(VerifiedModerationCommandProof { proof }) +} + +/// Verify a fresh signed moderation event as the operation-bound WebSocket +/// proof. Connection authentication alone is intentionally insufficient. +pub fn verify_nip42_moderation_command_proof( + command: &Event, + coordinates: &Nip42ModerationCommandCoordinates, + authoritative_now: DateTime, +) -> Result { + if !buzz_core::kind::is_moderation_command_kind(command.kind.as_u16() as u32) + || command.id.to_bytes() != coordinates.event_id + || command.pubkey != coordinates.actor + || command.kind.as_u16() as u32 != coordinates.command_kind + || coordinates.relay_url.is_empty() + { + return Err(AuthError::Nip98Invalid( + "moderation command binding mismatch".to_owned(), + )); + } + buzz_core::verify_event(command) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation command signature".to_owned()))?; + validate_moderation_proof_time(command, authoritative_now, 120)?; + let expires_at = proof_expiry(command, authoritative_now, 120)?; + let proof = VerifiedNostrProof::from_verifier( + coordinates.authorization_domain, + command.pubkey, + ProofTransport::Nip42, + coordinates.request_fingerprint, + coordinates.target_fingerprint, + coordinates.transport_context_fingerprint, + None, + None, + expires_at, + ) + .ok_or_else(|| AuthError::Nip98Invalid("invalid moderation proof binding".to_owned()))?; + Ok(VerifiedModerationCommandProof { proof }) +} + +fn moderation_command_request_fingerprint( + authorization_domain: buzz_core::CommunityId, + actor: nostr::PublicKey, + event_id: [u8; 32], + kind: u32, + target_fingerprint: [u8; 32], +) -> [u8; 32] { + framed_digest( + b"buzz:nip-fi:moderation-command-request:v1", + &[ + authorization_domain.as_uuid().as_bytes(), + actor.as_bytes(), + &event_id, + &kind.to_be_bytes(), + &target_fingerprint, + ], + ) +} + +fn validate_moderation_proof_time( + event: &Event, + authoritative_now: DateTime, + tolerance_secs: u64, +) -> Result<(), AuthError> { + let now = u64::try_from(authoritative_now.timestamp()) + .map_err(|_| AuthError::Nip98Invalid("invalid verifier time".to_owned()))?; + if now.abs_diff(event.created_at.as_secs()) > tolerance_secs { + return Err(AuthError::Nip98Invalid( + "moderation proof timestamp is stale".to_owned(), + )); + } + Ok(()) +} + +fn proof_expiry( + event: &Event, + authoritative_now: DateTime, + tolerance_secs: u64, +) -> Result, AuthError> { + let expiry = DateTime::from_timestamp( + i64::try_from(event.created_at.as_secs()) + .map_err(|_| AuthError::Nip98Invalid("invalid moderation proof time".to_owned()))?, + 0, + ) + .and_then(|created| created.checked_add_signed(Duration::seconds(tolerance_secs as i64))) + .ok_or_else(|| AuthError::Nip98Invalid("invalid moderation proof time".to_owned()))?; + if authoritative_now >= expiry { + return Err(AuthError::Nip98Invalid( + "moderation proof is expired".to_owned(), + )); + } + Ok(expiry) +} + +impl VerifiedNip98InviteClaimProof { + /// Exact actor proven by the signed invite-claim event. + pub const fn actor_pubkey(&self) -> nostr::PublicKey { + self.proof.actor_pubkey() + } + + /// Consume the purpose-sealed wrapper at the canonical database boundary. + pub fn into_verified_nostr_proof(self) -> VerifiedNostrProof { + self.proof + } +} + +impl std::fmt::Debug for VerifiedNip98InviteClaimProof { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("VerifiedNip98InviteClaimProof([REDACTED])") + } +} + +/// Verify one strict invite-claim proof and mint provider-free Nostr evidence. +/// +/// Exactly one URL, method, and payload tag is required. The signed event and +/// independently verified assertion must name the same actor, domain, target, +/// request, and transport context before a proof can be constructed. +pub fn verify_nip98_invite_claim_proof( + event_json: &str, + coordinates: &Nip98InviteClaimCoordinates, + body: &[u8], + assertion: &VerifiedFederatedAssertion, + authoritative_now: DateTime, +) -> Result { + let event: Event = serde_json::from_str(event_json) + .map_err(|_| AuthError::Nip98Invalid("invalid invite proof JSON".to_owned()))?; + if event.kind != Kind::HttpAuth || !event.content.is_empty() { + return Err(AuthError::Nip98Invalid( + "invalid invite proof kind or content".to_owned(), + )); + } + buzz_core::verify_event(&event) + .map_err(|_| AuthError::Nip98Invalid("invalid invite proof signature".to_owned()))?; + let now = u64::try_from(authoritative_now.timestamp()) + .map_err(|_| AuthError::Nip98Invalid("invalid verifier time".to_owned()))?; + if now.abs_diff(event.created_at.as_secs()) > TIMESTAMP_TOLERANCE_SECS { + return Err(AuthError::Nip98Invalid( + "invite proof timestamp is stale".to_owned(), + )); + } + + let signed_url = exact_tag( + &event, + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::U)), + "u", + )?; + let signed_method = exact_tag(&event, TagKind::Method, "method")?; + let signed_payload = exact_tag(&event, TagKind::Payload, "payload")?; + let (canonical_signed_url, signed_host) = strict_canonical_url(signed_url)?; + let payload_sha256: [u8; 32] = Sha256::digest(body).into(); + if canonical_signed_url != coordinates.canonical_url.as_ref() + || signed_host != coordinates.canonical_host.as_ref() + || signed_method != "POST" + || payload_sha256 != coordinates.payload_sha256 + || signed_payload != hex::encode(payload_sha256) + { + return Err(AuthError::Nip98Invalid( + "invite proof request binding mismatch".to_owned(), + )); + } + + let (transport, assertion_request, assertion_target, assertion_context) = + assertion.request_binding(); + let (request, target, context) = coordinates.request_binding(); + let (assertion_not_before, assertion_expires_at) = assertion.time_bounds(); + if assertion.authorization_domain() != coordinates.authorization_domain + || assertion.attested_event_author_pubkey() != Some(event.pubkey) + || transport != ProofTransport::Nip98 + || assertion_request != request + || assertion_target != target + || assertion_context != context + || authoritative_now < assertion_not_before + || authoritative_now >= assertion_expires_at + { + return Err(AuthError::Nip98Invalid( + "invite assertion binding mismatch".to_owned(), + )); + } + + let event_expires_at = DateTime::from_timestamp( + i64::try_from(event.created_at.as_secs()) + .map_err(|_| AuthError::Nip98Invalid("invalid invite proof time".to_owned()))?, + 0, + ) + .and_then(|created| { + created.checked_add_signed(Duration::seconds(TIMESTAMP_TOLERANCE_SECS as i64)) + }) + .ok_or_else(|| AuthError::Nip98Invalid("invalid invite proof time".to_owned()))?; + let expires_at = event_expires_at.min(assertion_expires_at); + if authoritative_now >= expires_at { + return Err(AuthError::Nip98Invalid( + "invite proof is expired".to_owned(), + )); + } + let proof = VerifiedNostrProof::from_verifier( + coordinates.authorization_domain, + event.pubkey, + ProofTransport::Nip98, + *request, + *target, + *context, + Some(*assertion.assertion_fingerprint()), + None, + expires_at, + ) + .ok_or_else(|| AuthError::Nip98Invalid("invalid invite proof binding".to_owned()))?; + Ok(VerifiedNip98InviteClaimProof { proof }) +} + +fn exact_tag<'a>(event: &'a Event, kind: TagKind<'_>, name: &str) -> Result<&'a str, AuthError> { + let mut matching = event.tags.iter().filter(|tag| tag.kind() == kind); + let tag = matching + .next() + .filter(|_| matching.next().is_none()) + .ok_or_else(|| AuthError::Nip98Invalid(format!("invalid {name} tag cardinality")))?; + tag.content() + .filter(|value| !value.is_empty()) + .ok_or_else(|| AuthError::Nip98Invalid(format!("invalid {name} tag content"))) +} + +fn strict_canonical_url(raw: &str) -> Result<(String, String), AuthError> { + let parsed = + Url::parse(raw).map_err(|_| AuthError::Nip98Invalid("invalid canonical URL".to_owned()))?; + if !matches!(parsed.scheme(), "http" | "https") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + || parsed.query().is_some() + { + return Err(AuthError::Nip98Invalid("invalid canonical URL".to_owned())); + } + if parsed.as_str() != raw { + return Err(AuthError::Nip98Invalid("non-canonical URL".to_owned())); + } + let port = parsed + .port_or_known_default() + .ok_or_else(|| AuthError::Nip98Invalid("invalid canonical URL".to_owned()))?; + let host = parsed + .host_str() + .ok_or_else(|| AuthError::Nip98Invalid("invalid canonical URL".to_owned()))?; + let authority = format!("{host}:{port}"); + Ok((parsed.to_string(), authority)) +} + +fn strict_websocket_origin(raw: &str) -> Result { + let parsed = Url::parse(raw) + .map_err(|_| AuthError::Nip98Invalid("invalid WebSocket origin".to_owned()))?; + if !matches!(parsed.scheme(), "ws" | "wss") + || parsed.host_str().is_none() + || !parsed.username().is_empty() + || parsed.password().is_some() + || parsed.fragment().is_some() + || parsed.query().is_some() + || parsed.path() != "/" + || parsed.as_str().trim_end_matches('/') != raw.trim_end_matches('/') + { + return Err(AuthError::Nip98Invalid( + "invalid WebSocket origin".to_owned(), + )); + } + Ok(raw.trim_end_matches('/').to_owned()) +} + +fn framed_digest(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update((domain.len() as u64).to_be_bytes()); + digest.update(domain); + for field in fields { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + digest.finalize().into() +} + /// Normalize a URL for comparison. /// /// - Lowercases scheme and host (already done by the `url` crate). @@ -155,7 +803,10 @@ fn normalize_url(raw: &str) -> String { #[cfg(test)] mod tests { use super::*; + use crate::foundation::FederatedPrincipal; + use buzz_core::CommunityId; use nostr::{EventBuilder, Keys, Kind, Timestamp}; + use uuid::Uuid; const TEST_URL: &str = "https://relay.example.com/api/tokens"; const TEST_METHOD: &str = "POST"; @@ -185,6 +836,31 @@ mod tests { serde_json::to_string(&event).expect("serialize") } + fn invite_assertion( + coordinates: &Nip98InviteClaimCoordinates, + actor: nostr::PublicKey, + now: DateTime, + ) -> VerifiedFederatedAssertion { + let (request, target, context) = coordinates.request_binding(); + VerifiedFederatedAssertion::from_verifier( + coordinates.authorization_domain(), + FederatedPrincipal::from_verified_parts( + "https://issuer.example".to_owned(), + "opaque-subject".to_owned(), + ) + .expect("test principal"), + ProofTransport::Nip98, + [7; 32], + *target, + *request, + *context, + now - Duration::seconds(1), + now + Duration::minutes(5), + ) + .expect("test assertion") + .with_test_attested_event_author(actor) + } + #[test] fn valid_event_returns_pubkey() { let keys = Keys::generate(); @@ -314,4 +990,192 @@ mod tests { let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None); assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok()); } + + #[test] + fn invite_claim_proof_requires_exact_non_substitutable_coordinates() { + let keys = Keys::generate(); + let now = Utc::now(); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let body = br#"{"code":"v2.opaque"}"#; + let coordinates = Nip98InviteClaimCoordinates::new( + domain, + [21; 32], + "https://relay.example.com/api/invites/claim", + body, + ) + .expect("invite coordinates"); + let assertion = invite_assertion(&coordinates, keys.public_key(), now); + let payload = hex::encode(Sha256::digest(body)); + let event = make_nip98_event( + &keys, + "https://relay.example.com/api/invites/claim", + "POST", + Some(&payload), + Some(Timestamp::from(now.timestamp() as u64)), + ); + + let proof = verify_nip98_invite_claim_proof(&event, &coordinates, body, &assertion, now) + .expect("exact invite proof"); + assert_eq!(proof.actor_pubkey(), keys.public_key()); + let proof = proof.into_verified_nostr_proof(); + assert_eq!(proof.authorization_domain(), domain); + assert_eq!(proof.transport(), ProofTransport::Nip98); + + let substituted_target = Nip98InviteClaimCoordinates::new( + domain, + [22; 32], + "https://relay.example.com/api/invites/claim", + body, + ) + .expect("substituted target coordinates"); + assert!(verify_nip98_invite_claim_proof( + &event, + &substituted_target, + body, + &assertion, + now, + ) + .is_err()); + + let wrong_method = make_nip98_event( + &keys, + "https://relay.example.com/api/invites/claim", + "PUT", + Some(&payload), + Some(Timestamp::from(now.timestamp() as u64)), + ); + assert!(verify_nip98_invite_claim_proof( + &wrong_method, + &coordinates, + body, + &assertion, + now, + ) + .is_err()); + let wrong_url = make_nip98_event( + &keys, + "https://relay.example.com/api/invites/claim/", + "POST", + Some(&payload), + Some(Timestamp::from(now.timestamp() as u64)), + ); + assert!( + verify_nip98_invite_claim_proof(&wrong_url, &coordinates, body, &assertion, now,) + .is_err() + ); + let malformed_duplicate = EventBuilder::new(Kind::HttpAuth, "") + .tags(vec![ + nostr::Tag::parse(["u", "https://relay.example.com/api/invites/claim"]) + .expect("u tag"), + nostr::Tag::parse(["u"]).expect("malformed duplicate u tag"), + nostr::Tag::parse(["method", "POST"]).expect("method tag"), + nostr::Tag::parse(["payload", payload.as_str()]).expect("payload tag"), + ]) + .custom_created_at(Timestamp::from(now.timestamp() as u64)) + .sign_with_keys(&keys) + .expect("sign malformed duplicate event"); + let malformed_duplicate = + serde_json::to_string(&malformed_duplicate).expect("serialize malformed event"); + assert!(verify_nip98_invite_claim_proof( + &malformed_duplicate, + &coordinates, + body, + &assertion, + now, + ) + .is_err()); + assert!(verify_nip98_invite_claim_proof( + &event, + &coordinates, + br#"{"code":"v2.other"}"#, + &assertion, + now, + ) + .is_err()); + } + + #[test] + fn moderation_proofs_are_exact_and_cross_transport_replay_stable() { + let keys = Keys::generate(); + let other_keys = Keys::generate(); + let now = Utc::now(); + let created_at = Timestamp::from(now.timestamp() as u64); + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let target = [41; 32]; + let target_hex = hex::encode(target); + let command = EventBuilder::new(Kind::from(9040_u16), "") + .tag(nostr::Tag::parse(["p", target_hex.as_str()]).expect("target tag")) + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("sign moderation command"); + let body = serde_json::to_vec(&command).expect("serialize moderation command"); + let url = "https://relay.example.com/events"; + let payload = hex::encode(Sha256::digest(&body)); + let http_coordinates = + Nip98ModerationCommandCoordinates::new(domain, target, url, &body, &command) + .expect("HTTP moderation coordinates"); + let http_event = make_nip98_event(&keys, url, "POST", Some(&payload), Some(created_at)); + let http_proof = + verify_nip98_moderation_command_proof(&http_event, &http_coordinates, &body, now) + .expect("exact HTTP moderation proof"); + + let ws_coordinates = Nip42ModerationCommandCoordinates::new( + domain, + target, + "wss://relay.example.com", + &command, + ) + .expect("WebSocket moderation coordinates"); + let ws_proof = verify_nip42_moderation_command_proof(&command, &ws_coordinates, now) + .expect("exact WebSocket moderation proof"); + assert_eq!( + http_proof.request_fingerprint(), + ws_proof.request_fingerprint() + ); + assert_eq!( + http_proof.target_fingerprint(), + ws_proof.target_fingerprint() + ); + assert_eq!(http_proof.actor_pubkey(), ws_proof.actor_pubkey()); + assert_ne!( + http_proof.transport_context_fingerprint(), + ws_proof.transport_context_fingerprint() + ); + + let no_payload = make_nip98_event(&keys, url, "POST", None, Some(created_at)); + assert!( + verify_nip98_moderation_command_proof(&no_payload, &http_coordinates, &body, now,) + .is_err() + ); + let wrong_body = serde_json::to_vec( + &EventBuilder::new(Kind::from(9040_u16), "") + .tag(nostr::Tag::parse(["p", target_hex.as_str()]).expect("target tag")) + .custom_created_at(created_at) + .sign_with_keys(&keys) + .expect("sign substituted command"), + ) + .expect("serialize substituted command"); + assert!(verify_nip98_moderation_command_proof( + &http_event, + &http_coordinates, + &wrong_body, + now, + ) + .is_err()); + let wrong_signer = + make_nip98_event(&other_keys, url, "POST", Some(&payload), Some(created_at)); + assert!(verify_nip98_moderation_command_proof( + &wrong_signer, + &http_coordinates, + &body, + now, + ) + .is_err()); + assert!(verify_nip42_moderation_command_proof( + &command, + &ws_coordinates, + now + Duration::seconds(121), + ) + .is_err()); + } } diff --git a/crates/buzz-auth/src/trusted_proxy.rs b/crates/buzz-auth/src/trusted_proxy.rs index bb230c5912..99cb53e0cc 100644 --- a/crates/buzz-auth/src/trusted_proxy.rs +++ b/crates/buzz-auth/src/trusted_proxy.rs @@ -595,11 +595,12 @@ fn mac_input( ) -> Vec { let mut input = Vec::with_capacity( MAC_DOMAIN.len() - + 8 * 8 + + 8 * 9 + 8 + 1 + nonce.len() + assertion_digest.len() + + request.authorization_domain.as_uuid().as_bytes().len() + request.method.len() + request.authority.len() + request.path_and_query.len() @@ -609,6 +610,10 @@ fn mac_input( append_length_prefixed(&mut input, ×tamp.to_be_bytes()); append_length_prefixed(&mut input, nonce); append_length_prefixed(&mut input, assertion_digest); + append_length_prefixed( + &mut input, + request.authorization_domain.as_uuid().as_bytes(), + ); append_length_prefixed(&mut input, request.method.as_bytes()); append_length_prefixed(&mut input, request.authority.as_bytes()); append_length_prefixed(&mut input, request.path_and_query.as_bytes()); diff --git a/crates/buzz-auth/tests/trusted_proxy_provenance.rs b/crates/buzz-auth/tests/trusted_proxy_provenance.rs index 4ac2388c29..ca023a87f9 100644 --- a/crates/buzz-auth/tests/trusted_proxy_provenance.rs +++ b/crates/buzz-auth/tests/trusted_proxy_provenance.rs @@ -76,6 +76,11 @@ fn verifier() -> TrustedProxyProvenanceVerifier { .expect("valid verifier policy") } +struct ProvenanceFreshness<'a> { + timestamp: u64, + nonce: &'a [u8], +} + fn sign_provenance( assertion: &str, method: &str, @@ -85,6 +90,27 @@ fn sign_provenance( timestamp: u64, nonce: &[u8], ) -> String { + sign_provenance_in_domain( + DOMAIN_A, + assertion, + method, + authority, + path_and_query, + body, + ProvenanceFreshness { timestamp, nonce }, + ) +} + +fn sign_provenance_in_domain( + authorization_domain: CommunityId, + assertion: &str, + method: &str, + authority: &str, + path_and_query: &str, + body: &[u8], + freshness: ProvenanceFreshness<'_>, +) -> String { + let ProvenanceFreshness { timestamp, nonce } = freshness; let canonical_path = if path_and_query.is_empty() { "/".to_owned() } else if path_and_query.starts_with('?') { @@ -100,6 +126,7 @@ fn sign_provenance( timestamp.to_be_bytes().as_slice(), nonce, &assertion_digest, + authorization_domain.as_uuid().as_bytes(), method.as_bytes(), authority.as_bytes(), canonical_path.as_bytes(), @@ -223,6 +250,80 @@ async fn valid_provenance_seals_redacted_move_only_evidence() { ); } +#[tokio::test] +async fn provenance_binds_authorization_domain_and_transport_context() { + let verifier = verifier(); + let request_a = request("POST", "relay.example.com:443", "/events", b"body"); + let request_b = request_in_domain( + DOMAIN_B, + "POST", + "relay.example.com:443", + "/events", + b"body", + ); + let provenance_a = sign_provenance( + ASSERTION, + "POST", + "relay.example.com:443", + "/events", + b"body", + NOW, + &[0x29; 16], + ); + let assertion = assertion_header(ASSERTION); + let replay = ReplayReader::default(); + let evidence_a = verifier + .verify( + &headers(&assertion, &provenance_a), + &request_a, + now(), + &replay, + ) + .await + .expect("domain A provenance"); + + let unavailable = ReplayReader::default(); + unavailable.make_unavailable(); + assert_eq!( + verifier + .verify( + &headers(&assertion, &provenance_a), + &request_b, + now(), + &unavailable, + ) + .await + .unwrap_err(), + TrustedProxyError::InvalidMac + ); + + let provenance_b = sign_provenance_in_domain( + DOMAIN_B, + ASSERTION, + "POST", + "relay.example.com:443", + "/events", + b"body", + ProvenanceFreshness { + timestamp: NOW, + nonce: &[0x29; 16], + }, + ); + let evidence_b = verifier + .verify( + &headers(&assertion, &provenance_b), + &request_b, + now(), + &replay, + ) + .await + .expect("domain B provenance"); + assert_ne!( + evidence_a.transport_context_fingerprint(), + evidence_b.transport_context_fingerprint() + ); +} + #[tokio::test] async fn direct_origin_and_injected_headers_fail_closed() { let verifier = verifier(); @@ -508,26 +609,38 @@ async fn committed_nonce_and_unavailable_replay_state_fail_closed() { ); let other_authority_request = request("GET", "other.example.com:443", "/same-nonce", b""); - let same_nonce_other_authority = sign_provenance( + let same_nonce_other_domain = sign_provenance_in_domain( + DOMAIN_B, ASSERTION, "GET", "other.example.com:443", "/same-nonce", b"", - NOW, - &[0x6a; 16], + ProvenanceFreshness { + timestamp: NOW, + nonce: &[0x6a; 16], + }, ); let other_domain_request = request_in_domain(DOMAIN_B, "GET", "other.example.com:443", "/same-nonce", b""); verifier .verify( - &headers(&assertion, &same_nonce_other_authority), + &headers(&assertion, &same_nonce_other_domain), &other_domain_request, now(), &replay, ) .await .expect("the frozen replay key is isolated by authorization domain"); + let same_nonce_other_authority = sign_provenance( + ASSERTION, + "GET", + "other.example.com:443", + "/same-nonce", + b"", + NOW, + &[0x6a; 16], + ); assert_eq!( verifier .verify( diff --git a/crates/buzz-db/src/authorization_admission.rs b/crates/buzz-db/src/authorization_admission.rs index 8157b8996b..ee9237ea0d 100644 --- a/crates/buzz-db/src/authorization_admission.rs +++ b/crates/buzz-db/src/authorization_admission.rs @@ -14,18 +14,24 @@ use std::{ }; use buzz_auth::{ - AuthoritativeAuthorizationRecheck, AuthorizationError, AuthorizationFinalizationRechecker, - AuthorizationFinalizer, AuthorizationInput, AuthorizationReason, DirectEnrollmentProposal, - FinalizedAuthContext, LocalAuthorizationPolicy, LocalBindingResolution, PreparedAuthorization, - PreparedAuthorizationRecheck, ProofTransport, RouteCapability, VerifiedFederatedAssertion, - VerifiedNostrProof, + ActiveLocalBinding, AuthoritativeAuthorizationRecheck, AuthorizationError, + AuthorizationFinalizationRechecker, AuthorizationFinalizer, AuthorizationInput, + AuthorizationReason, DirectEnrollmentProposal, FinalizedAuthContext, LocalAuthorizationPolicy, + LocalBindingResolution, PreparedAuthorization, PreparedAuthorizationRecheck, ProofTransport, + RouteCapability, VerifiedFederatedAssertion, VerifiedModerationCommandProof, + VerifiedNip98InviteClaimProof, VerifiedNostrProof, VerifierPolicyStamp, }; -use buzz_core::CommunityId; -use chrono::{DateTime, Datelike, Utc}; +use buzz_core::{AuthorizationLeaseFence, CommunityId}; +use chrono::{DateTime, Datelike, Duration as TimeDelta, Utc}; use sha2::{Digest, Sha256}; use sqlx::{PgPool, Postgres, Row, Transaction}; use uuid::Uuid; +use crate::authorization_events::{ + record_authorization_event_tx, AuthorizationAuditFailureCode, AuthorizationEventActor, + AuthorizationEventKind, AuthorizationEventOutcome, AuthorizationEventWriteError, + AuthorizationReasonCode, NewAuthorizationEvent, +}; use crate::identity_enrollment::{ actor_fingerprint, execute_authoritative_enrollment_tx, EnrollmentDisposition, IdentityEnrollmentError, PreparedDirectEnrollment, @@ -46,10 +52,12 @@ pub enum AdmissionObjectKind { ModerationTarget, /// One audio session. AudioSession, + /// One server-resolved invitation. + Invitation, } impl AdmissionObjectKind { - /// Stable database code shared with `protected_object_authority`. + /// Stable durable namespace code shared with `protected_object_authority`. pub const fn database_code(self) -> i16 { match self { Self::Domain => 1, @@ -58,6 +66,7 @@ impl AdmissionObjectKind { Self::Media => 4, Self::ModerationTarget => 5, Self::AudioSession => 6, + Self::Invitation => 9, } } } @@ -271,6 +280,7 @@ pub struct AdmissionCommitRequest { object: AdmissionObject, replay_claim: Option, application_effect: Option>, + semantic_fingerprint_override: Option<[u8; 32]>, preparation: AdmissionPreparation, } @@ -289,6 +299,7 @@ impl AdmissionCommitRequest { object, replay_claim: None, application_effect: None, + semantic_fingerprint_override: None, preparation: AdmissionPreparation::Existing { prepared: Box::new(prepared), }, @@ -312,6 +323,7 @@ impl AdmissionCommitRequest { object, replay_claim: None, application_effect: None, + semantic_fingerprint_override: None, preparation: AdmissionPreparation::Enrollment { correlation_id, capability, @@ -363,6 +375,17 @@ impl AdmissionCommitRequest { Ok(self) } + fn with_semantic_fingerprint_override( + mut self, + semantic_fingerprint: [u8; 32], + ) -> Result { + if semantic_fingerprint == [0; 32] { + return Err(AdmissionCommitError::InvalidRequest); + } + self.semantic_fingerprint_override = Some(semantic_fingerprint); + Ok(self) + } + /// Domain sealed by the independently verified evidence. pub fn authorization_domain(&self) -> CommunityId { match &self.preparation { @@ -388,6 +411,33 @@ impl AdmissionCommitRequest { } } + fn denial_actor(&self) -> Result { + match &self.preparation { + AdmissionPreparation::Existing { prepared } => { + AuthorizationEventActor::from_prepared_authorization(prepared) + } + AdmissionPreparation::Enrollment { evidence, .. } => { + AuthorizationEventActor::from_verified_direct_enrollment( + &evidence.assertion, + &evidence.proof, + ) + } + } + .map_err(|_| AdmissionCommitError::AuthorizationDenied) + } + + fn denial_correlation_id(&self) -> Uuid { + match &self.preparation { + AdmissionPreparation::Enrollment { correlation_id, .. } => *correlation_id, + AdmissionPreparation::Existing { prepared } => prepared.correlation_id(), + } + } + + /// Server-generated correlation identifier sealed by the prepared authority. + pub fn correlation_id(&self) -> Uuid { + self.denial_correlation_id() + } + /// Exact provider-free transport binding sealed by the prepared witness. /// /// Trusted transport adapters must compare both values byte-for-byte with @@ -408,6 +458,9 @@ impl AdmissionCommitRequest { /// Full canonical route/application intent persisted with the receipt. pub fn semantic_fingerprint(&self) -> [u8; 32] { + if let Some(semantic_fingerprint) = self.semantic_fingerprint_override { + return semantic_fingerprint; + } let domain = self.authorization_domain(); let request_fingerprint = self.request_fingerprint(); let ( @@ -652,6 +705,57 @@ impl fmt::Debug for AdmissionCommitReceipt { } } +/// Server-derived coordinates for validating one fresh application result. +/// +/// The canonical committer creates this value from its pre-commit application +/// context. Application adapters can therefore validate a returned result +/// without treating fields reconstructed from the durable receipt as expected +/// values. Exact replay never exposes a fresh binding. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct AdmissionApplicationResultBinding { + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_intent_digest: [u8; 32], +} + +impl AdmissionApplicationResultBinding { + fn from_context(context: &AdmissionApplicationContext<'_>) -> Self { + Self { + authorization_domain: context.authorization_domain(), + object: context.object(), + semantic_fingerprint: *context.semantic_fingerprint(), + application_intent_digest: *context.application_intent_digest(), + } + } + + /// Server-owned authorization domain. + pub const fn authorization_domain(self) -> CommunityId { + self.authorization_domain + } + + /// Server-resolved application object. + pub const fn object(self) -> AdmissionObject { + self.object + } + + /// Complete canonical admission semantic fingerprint. + pub const fn semantic_fingerprint(&self) -> &[u8; 32] { + &self.semantic_fingerprint + } + + /// Effect-owned intent digest captured before application DML. + pub const fn application_intent_digest(&self) -> &[u8; 32] { + &self.application_intent_digest + } +} + +impl fmt::Debug for AdmissionApplicationResultBinding { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("AdmissionApplicationResultBinding([REDACTED])") + } +} + /// Atomic canonical-admission result. pub enum AdmissionCommitOutcome { /// This transaction committed every required authority effect. @@ -662,6 +766,8 @@ pub enum AdmissionCommitOutcome { receipt: AdmissionCommitReceipt, /// Fresh closed application result, when application DML participated. application_result: Option, + /// Pre-commit coordinates for independently validating that result. + application_result_binding: Option, }, /// The exact operation and request were committed previously. ExactReplay { @@ -696,6 +802,21 @@ pub enum AdmissionCommitError { /// A proxy nonce or proof replay identity was already consumed. #[error("canonical admission replay rejected")] ReplayRejected, + /// Invalid canonical input was already retained as denial evidence. + #[error("recorded invalid canonical admission request")] + RecordedInvalidRequest, + /// Authorization denial was already retained as canonical evidence. + #[error("recorded canonical admission denial")] + RecordedAuthorizationDenied, + /// An intent conflict was already retained as canonical denial evidence. + #[error("recorded canonical admission intent conflict")] + RecordedIntentConflict, + /// A replay rejection was already retained as canonical denial evidence. + #[error("recorded canonical admission replay rejection")] + RecordedReplayRejected, + /// Audit unavailability was already retained as canonical denial evidence. + #[error("recorded canonical authorization audit unavailable")] + RecordedAuditUnavailable, /// Required canonical audit evidence could not be retained. #[error("canonical authorization audit unavailable")] AuditUnavailable, @@ -713,6 +834,51 @@ pub enum CanonicalInviteClaimOutcome { AlreadyMember, } +/// Whether canonical invite admission executed application DML or reused it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanonicalInviteClaimDisposition { + /// This request committed the stored invite outcome. + Fresh, + /// This request returned an already committed exact outcome. + ExactReplay, +} + +/// Origin-sealed canonical invite result returned to the relay adapter. +/// +/// The outcome preserves the original public response on exact replay. The +/// disposition prevents callers from repeating post-commit side effects. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CanonicalInviteClaimResult { + outcome: CanonicalInviteClaimOutcome, + disposition: CanonicalInviteClaimDisposition, +} + +impl CanonicalInviteClaimResult { + const fn fresh(outcome: CanonicalInviteClaimOutcome) -> Self { + Self { + outcome, + disposition: CanonicalInviteClaimDisposition::Fresh, + } + } + + const fn exact_replay(outcome: CanonicalInviteClaimOutcome) -> Self { + Self { + outcome, + disposition: CanonicalInviteClaimDisposition::ExactReplay, + } + } + + /// Original typed outcome stored by canonical admission. + pub const fn outcome(self) -> CanonicalInviteClaimOutcome { + self.outcome + } + + /// Whether this call committed or reused the stored outcome. + pub const fn disposition(self) -> CanonicalInviteClaimDisposition { + self.disposition + } +} + impl CanonicalInviteClaimOutcome { const fn database_code(self) -> i16 { match self { @@ -1151,7 +1317,7 @@ impl AdmissionApplicationEffect for CanonicalInviteClaimEffect { > { Box::pin(async move { if context.authorization().capability() != RouteCapability::InviteClaim - || context.object().kind() != AdmissionObjectKind::Domain + || context.object().kind() != AdmissionObjectKind::Invitation { return Err(AdmissionCommitError::AuthorizationDenied); } @@ -1162,6 +1328,7 @@ impl AdmissionApplicationEffect for CanonicalInviteClaimEffect { self.token_hash, self.policy_version.as_deref(), self.intent_digest, + *context.object().key(), ) .await }) @@ -1175,19 +1342,17 @@ async fn apply_canonical_invite_claim_tx( token_hash: [u8; 32], policy_version: Option<&str>, intent_digest: [u8; 32], + expected_resource_key: [u8; 32], ) -> Result { if domain.as_uuid().is_nil() || actor_pubkey.len() != 64 || !actor_pubkey.bytes().all(|byte| byte.is_ascii_hexdigit()) || token_hash == [0; 32] || intent_digest == [0; 32] + || expected_resource_key == [0; 32] { return Err(AdmissionCommitError::InvalidRequest); } - let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") - .fetch_one(&mut **transaction) - .await - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; let invite = sqlx::query( r#" SELECT id, max_uses, use_count, expires_at @@ -1214,6 +1379,13 @@ async fn apply_canonical_invite_claim_tx( let expires_at: DateTime = invite .try_get("expires_at") .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if canonical_invite_resource_key(domain, invite_id) != expected_resource_key { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let authoritative_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; if invite_id.is_nil() || use_count < 0 || max_uses.is_some_and(|maximum| maximum <= 0 || use_count > maximum) @@ -1222,6 +1394,15 @@ async fn apply_canonical_invite_claim_tx( return Err(AdmissionCommitError::AuthorizationDenied); } + let actor_bytes = + hex::decode(actor_pubkey).map_err(|_| AdmissionCommitError::InvalidRequest)?; + let restrictions = crate::moderation::restriction_state_tx(transaction, domain, &actor_bytes) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if restrictions.banned { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let existing = sqlx::query_scalar::<_, i32>( "SELECT 1 FROM relay_members WHERE community_id=$1 AND pubkey=$2 FOR SHARE", ) @@ -1232,6 +1413,7 @@ async fn apply_canonical_invite_claim_tx( .map_err(|_| AdmissionCommitError::DependencyUnavailable)? .is_some(); if existing { + recheck_invite_claimability_tx(transaction, domain, invite_id, use_count).await?; persist_invite_policy_acceptance(transaction, domain, actor_pubkey, policy_version).await?; return invite_application_outcome( intent_digest, @@ -1242,6 +1424,14 @@ async fn apply_canonical_invite_claim_tx( return Err(AdmissionCommitError::AuthorizationDenied); } + let final_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if final_now >= expires_at { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let inserted = sqlx::query( "INSERT INTO relay_members (community_id,pubkey,role,added_by) \ VALUES ($1,$2,'member','invite') ON CONFLICT (community_id,pubkey) DO NOTHING", @@ -1252,8 +1442,9 @@ async fn apply_canonical_invite_claim_tx( .await .map_err(|_| AdmissionCommitError::DependencyUnavailable)? .rows_affected(); - persist_invite_policy_acceptance(transaction, domain, actor_pubkey, policy_version).await?; if inserted == 0 { + recheck_invite_claimability_tx(transaction, domain, invite_id, use_count).await?; + persist_invite_policy_acceptance(transaction, domain, actor_pubkey, policy_version).await?; return invite_application_outcome( intent_digest, CanonicalInviteClaimOutcome::AlreadyMember, @@ -1262,12 +1453,15 @@ async fn apply_canonical_invite_claim_tx( if inserted != 1 { return Err(AdmissionCommitError::DependencyUnavailable); } + persist_invite_policy_acceptance(transaction, domain, actor_pubkey, policy_version).await?; let next_use_count = use_count .checked_add(1) .ok_or(AdmissionCommitError::DependencyUnavailable)?; let updated = sqlx::query( "UPDATE relay_invites SET use_count=$1 \ - WHERE community_id=$2 AND id=$3 AND use_count=$4", + WHERE community_id=$2 AND id=$3 AND use_count=$4 \ + AND expires_at > clock_timestamp() \ + AND (max_uses IS NULL OR use_count < max_uses)", ) .bind(next_use_count) .bind(domain.as_uuid()) @@ -1277,11 +1471,36 @@ async fn apply_canonical_invite_claim_tx( .await .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; if updated.rows_affected() != 1 { - return Err(AdmissionCommitError::DependencyUnavailable); + return Err(AdmissionCommitError::AuthorizationDenied); } invite_application_outcome(intent_digest, CanonicalInviteClaimOutcome::Joined) } +async fn recheck_invite_claimability_tx( + transaction: &mut Transaction<'_, Postgres>, + domain: CommunityId, + invite_id: Uuid, + expected_use_count: i32, +) -> Result<(), AdmissionCommitError> { + let claimable: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM relay_invites \ + WHERE community_id=$1 AND id=$2 AND use_count=$3 \ + AND expires_at > clock_timestamp() \ + AND (max_uses IS NULL OR use_count < max_uses))", + ) + .bind(domain.as_uuid()) + .bind(invite_id) + .bind(expected_use_count) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if claimable { + Ok(()) + } else { + Err(AdmissionCommitError::AuthorizationDenied) + } +} + async fn persist_invite_policy_acceptance( transaction: &mut Transaction<'_, Postgres>, domain: CommunityId, @@ -1335,195 +1554,1137 @@ pub trait AdmissionFinalRechecker: Send + Sync { >; } -/// PostgreSQL implementation of the canonical final-admission boundary. -pub struct PostgresCanonicalAdmissionCommitter { - pool: PgPool, - rechecker: Arc, +/// External verifier-generation recheck required inside final admission. +/// +/// Implementations retain only verifier policy and key-generation state. They +/// must not accept assertions, credentials, or caller-selected coordinates. +pub trait AdmissionVerifierRechecker: Send + Sync { + /// Confirm that one prepared verifier stamp is still current. + fn recheck<'a>( + &'a self, + expected: VerifierPolicyStamp, + ) -> Pin> + Send + 'a>>; } -impl PostgresCanonicalAdmissionCommitter { - /// Bind canonical admission to the writer pool and configured rechecker. - pub fn new(pool: PgPool, rechecker: Arc) -> Self { - Self { pool, rechecker } +/// Server-resolved invitation resource accepted by canonical admission. +/// +/// Construction is restricted to the read-only database resolver so callers +/// cannot substitute a client-selected token digest or generic domain target. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct CanonicalInviteResource { + key: [u8; 32], +} + +impl CanonicalInviteResource { + /// Exact opaque invitation fingerprint used by proof verification. + pub const fn fingerprint(self) -> [u8; 32] { + self.key } +} - async fn commit_inner( - &self, - request: AdmissionCommitRequest, - ) -> Result { - let domain = request.authorization_domain(); - let operation_id = request.operation_id(); - let semantic_fingerprint = request.semantic_fingerprint(); - let attempt_id = request.attempt_id; - let object = request.object; - let request_fingerprint = request.request_fingerprint(); - let receipt_shape = AdmissionReceiptShape::from_preparation(&request.preparation); - let replay_claim = request.replay_claim; - let expected_application_schema = request - .application_effect - .as_ref() - .map(|effect| effect.result_schema()); - let application_intent_digest = request - .application_effect - .as_ref() - .map(|effect| effect.intent_digest()); - let mut application_effect = request.application_effect; - let mut transaction = self - .pool - .begin() - .await - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; +impl fmt::Debug for CanonicalInviteResource { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str("CanonicalInviteResource([REDACTED])") + } +} - lock_operation(&mut transaction, domain, operation_id).await?; - if let Some(receipt) = read_existing_receipt( - &mut transaction, - ExistingAdmissionLookup { - domain, - operation_id, - request_fingerprint, - semantic_fingerprint, - object, - receipt_shape, - expected_application_schema, - }, - ) - .await? - { - if let Some(claim) = replay_claim { - let authoritative_now: DateTime = - sqlx::query_scalar("SELECT transaction_timestamp()") - .fetch_one(&mut *transaction) - .await - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - claim_replay_identity_tx(&mut transaction, domain, claim, authoritative_now) - .await?; - transaction - .commit() - .await - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - } else { - transaction - .rollback() - .await - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; +struct CanonicalModerationFinalRechecker; + +impl AdmissionFinalRechecker for CanonicalModerationFinalRechecker { + fn authoritative_recheck<'a, 'transaction>( + &'a self, + transaction: &'a mut Transaction<'transaction, Postgres>, + request: &'a PreparedAuthorizationRecheck, + object: AdmissionObject, + ) -> Pin< + Box< + dyn Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + let snapshot = request.lease_dependencies(); + let (_, domain) = snapshot.identity(); + let (capability, actor, owner) = snapshot.authority(); + let (binding_id, binding_version) = snapshot.binding(); + let (request_fingerprint, target, _, _) = snapshot.request_binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + snapshot.dependency_versions(); + if object.kind() != AdmissionObjectKind::ModerationTarget + || target != object.key() + || capability != RouteCapability::Moderation + || owner.is_some() + || request_fingerprint == &[0; 32] + || request.verifier_stamp().is_some() + { + return Err(AdmissionCommitError::AuthorizationDenied); } - return Ok(AdmissionCommitOutcome::ExactReplay { - receipt: receipt.0, - application_result: receipt.1, - }); - } - let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") - .fetch_one(&mut *transaction) + let actor_bytes = actor.to_bytes(); + let binding_current: Option = sqlx::query_scalar( + "SELECT binding_state=1 \ + AND (expires_at IS NULL OR transaction_timestamp() < expires_at) \ + FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2 AND binding_version=$3 \ + AND event_author_pubkey=$4 FOR SHARE", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .bind(to_i64(binding_version)?) + .bind(actor_bytes.as_slice()) + .fetch_optional(&mut **transaction) .await .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - if let Some(claim) = replay_claim { - claim_replay_identity_tx(&mut transaction, domain, claim, authoritative_now).await?; - } - - let (prepared, enrollment_disposition) = match request.preparation { - AdmissionPreparation::Existing { prepared } => (*prepared, None), - AdmissionPreparation::Enrollment { - correlation_id, - capability, - evidence, - } => { - let AdmissionEnrollmentEvidence { - proposal, - enrollment_policy_digest, - assertion, - proof, - policy, - } = *evidence; - let enrollment = execute_authoritative_enrollment_tx( - &mut transaction, - operation_id, - request_fingerprint, - &proposal, - enrollment_policy_digest, - &assertion, - &proof, - authoritative_now, - ) + sqlx::query("LOCK TABLE identity_enrollment_policies IN SHARE MODE") + .execute(&mut **transaction) .await - .map_err(map_enrollment_error)?; - let input = AuthorizationInput::new(domain, correlation_id, proof, capability) - .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; - let resolution = LocalBindingResolution::enrollment(proposal, enrollment.binding); - let prepared = - AuthorizationFinalizer::prepare(input, resolution, policy, authoritative_now) - .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; - (prepared, Some(enrollment.disposition)) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_policy: Option = sqlx::query_scalar( + "SELECT MAX(policy_revision) FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= transaction_timestamp() \ + AND (expires_at IS NULL OR transaction_timestamp() < expires_at)", + ) + .bind(domain.as_uuid()) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_generation: Option = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR SHARE", + ) + .bind(domain.as_uuid()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch: Option = sqlx::query_scalar( + "SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR UPDATE", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let next_epoch = current_epoch + .unwrap_or(0) + .checked_add(1) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + if binding_current != Some(true) + || current_policy != Some(to_i64(policy_revision)?) + || current_generation != Some(to_i64_allow_zero(invalidation_generation)?) + || next_epoch != to_i64(authority_epoch)? + { + return Err(AdmissionCommitError::AuthorizationDenied); } - }; + let authoritative_now: DateTime = + sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + Ok(AuthoritativeAuthorizationRecheck::from_authoritative_parts( + snapshot, + None, + authoritative_now, + )) + }) + } +} - let recheck_request = prepared.recheck_request(); - let observation = self - .rechecker - .authoritative_recheck(&mut transaction, &recheck_request, object) - .await?; - let one_shot = OneShotRechecker::new(observation); - let witness = AuthorizationFinalizer::recheck(&prepared, &one_shot) +impl crate::Db { + /// Resolve current local authority for one exact verified moderation + /// command without performing any mutation. + pub async fn prepare_canonical_moderation_request( + &self, + proof: VerifiedModerationCommandProof, + object: AdmissionObject, + ) -> Result { + if object.kind() != AdmissionObjectKind::ModerationTarget { + return Err(AdmissionCommitError::InvalidRequest); + } + let domain = proof.authorization_domain(); + if proof.target_fingerprint() != object.key() { + return Err(AdmissionCommitError::InvalidRequest); + } + let actor = proof.actor_pubkey(); + let proof_expires_at = proof.expires_at(); + let request_fingerprint = *proof.request_fingerprint(); + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&self.pool) .await - .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; - let authorization = AuthorizationFinalizer::finalize(prepared, witness) - .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; - - let application_context = application_intent_digest - .map(|intent| { - AdmissionApplicationContext::new( - &authorization, - operation_id, - object, - semantic_fingerprint, - intent, - authoritative_now, - ) - }) - .transpose()?; - let application_outcome = match (application_effect.as_mut(), application_context.as_ref()) - { - (Some(effect), Some(context)) => { - let outcome = effect.apply(&mut transaction, context).await?; - let _ = context.canonical_result_digest(&outcome)?; - Some(outcome) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let row = sqlx::query( + "SELECT issuer, subject, binding_id, binding_version, expires_at \ + FROM identity_bindings \ + WHERE community_id=$1 AND event_author_pubkey=$2 AND binding_state=1 \ + AND (expires_at IS NULL OR $3 < expires_at)", + ) + .bind(domain.as_uuid()) + .bind(actor.as_bytes()) + .bind(authoritative_now) + .fetch_optional(&self.pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + let binding_version = u64::try_from( + row.try_get::("binding_version") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let binding = ActiveLocalBinding::from_storage_parts( + domain, + row.try_get("issuer") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + row.try_get("subject") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + row.try_get("binding_id") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + binding_version, + actor, + row.try_get("expires_at") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let policy = prepare_moderation_authorization_policy( + &self.pool, + domain, + object, + proof_expires_at, + authoritative_now, + ) + .await?; + let prepared = proof + .prepare_authorization(binding, policy, authoritative_now) + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + let semantic_fingerprint = admission_framed_digest( + b"buzz:canonical-moderation-admission:v1", + &[ + domain.as_uuid().as_bytes(), + actor.as_bytes(), + &request_fingerprint, + &(object.kind().database_code()).to_be_bytes(), + object.key(), + b"Moderation", + ], + ); + AdmissionCommitRequest::existing(Uuid::new_v4(), object, prepared)? + .with_semantic_fingerprint_override(semantic_fingerprint) + } + + /// Build the sole PostgreSQL committer for prepared moderation authority. + pub fn canonical_moderation_committer(&self) -> PostgresCanonicalAdmissionCommitter { + PostgresCanonicalAdmissionCommitter::new( + self.pool.clone(), + Arc::new(CanonicalModerationFinalRechecker), + ) + } +} + +async fn prepare_moderation_authorization_policy( + pool: &PgPool, + domain: CommunityId, + object: AdmissionObject, + proof_expires_at: DateTime, + authoritative_now: DateTime, +) -> Result { + let policy = sqlx::query( + "SELECT policy_revision, expires_at FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= $2 \ + AND (expires_at IS NULL OR $2 < expires_at) \ + ORDER BY policy_revision DESC LIMIT 1", + ) + .bind(domain.as_uuid()) + .bind(authoritative_now) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + let policy_revision = u64::try_from( + policy + .try_get::("policy_revision") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .ok() + .filter(|value| *value > 0) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let policy_expires_at: Option> = policy + .try_get("expires_at") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation: i64 = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation = u64::try_from(invalidation_generation) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch: Option = sqlx::query_scalar( + "SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let authority_epoch = u64::try_from( + current_epoch + .unwrap_or(0) + .checked_add(1) + .ok_or(AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fence_bytes: Vec = + sqlx::query_scalar("SELECT uuid_send(gen_random_uuid()) || uuid_send(gen_random_uuid())") + .fetch_one(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fence = AuthorizationLeaseFence::from_bytes( + fence_bytes + .try_into() + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let mut expires_at = proof_expires_at.min(authoritative_now + TimeDelta::minutes(1)); + if let Some(policy_expires_at) = policy_expires_at { + expires_at = expires_at.min(policy_expires_at); + } + if expires_at <= authoritative_now { + return Err(AdmissionCommitError::AuthorizationDenied); + } + LocalAuthorizationPolicy::from_database( + domain, + Uuid::new_v4(), + policy_revision, + invalidation_generation, + authority_epoch, + fence, + RouteCapability::Moderation, + expires_at, + None, + None, + ) + .ok_or(AdmissionCommitError::DependencyUnavailable) +} + +struct CanonicalInviteFinalRechecker { + verifier: Arc, +} + +impl AdmissionFinalRechecker for CanonicalInviteFinalRechecker { + fn authoritative_recheck<'a, 'transaction>( + &'a self, + transaction: &'a mut Transaction<'transaction, Postgres>, + request: &'a PreparedAuthorizationRecheck, + object: AdmissionObject, + ) -> Pin< + Box< + dyn Future> + + Send + + 'a, + >, + > { + Box::pin(async move { + let snapshot = request.lease_dependencies(); + let (_, domain) = snapshot.identity(); + let (capability, actor, owner) = snapshot.authority(); + let (binding_id, binding_version) = snapshot.binding(); + let (request_fingerprint, target, transport, _) = snapshot.request_binding(); + let (policy_revision, invalidation_generation, authority_epoch) = + snapshot.dependency_versions(); + if object.kind() != AdmissionObjectKind::Invitation + || target != object.key() + || capability != RouteCapability::InviteClaim + || transport != ProofTransport::Nip98 + || owner.is_some() + || request_fingerprint == &[0; 32] + { + return Err(AdmissionCommitError::AuthorizationDenied); } - (None, None) => None, - _ => return Err(AdmissionCommitError::DependencyUnavailable), - }; - if application_outcome - .as_ref() - .map(|outcome| outcome.result().schema()) - != expected_application_schema - { - return Err(AdmissionCommitError::DependencyUnavailable); + + let actor_bytes = actor.to_bytes(); + let binding_current: bool = sqlx::query_scalar( + "SELECT EXISTS(SELECT 1 FROM identity_bindings \ + WHERE community_id=$1 AND binding_id=$2 AND binding_version=$3 \ + AND event_author_pubkey=$4 AND binding_state=1 \ + AND (expires_at IS NULL OR clock_timestamp() < expires_at))", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .bind(to_i64(binding_version)?) + .bind(actor_bytes.as_slice()) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_policy: Option = sqlx::query_scalar( + "SELECT MAX(policy_revision) FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= clock_timestamp() \ + AND (expires_at IS NULL OR clock_timestamp() < expires_at)", + ) + .bind(domain.as_uuid()) + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_generation: Option = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains \ + WHERE community_id=$1 FOR SHARE", + ) + .bind(domain.as_uuid()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch: Option = sqlx::query_scalar( + "SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3 FOR SHARE", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_optional(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let next_epoch = current_epoch + .unwrap_or(0) + .checked_add(1) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + if !binding_current + || current_policy != Some(to_i64(policy_revision)?) + || current_generation != Some(to_i64_allow_zero(invalidation_generation)?) + || next_epoch != to_i64(authority_epoch)? + { + return Err(AdmissionCommitError::AuthorizationDenied); + } + let verifier_stamp = request + .verifier_stamp() + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + self.verifier.recheck(verifier_stamp).await?; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + Ok(AuthoritativeAuthorizationRecheck::from_authoritative_parts( + snapshot, + Some(verifier_stamp), + authoritative_now, + )) + }) + } +} + +impl crate::Db { + /// Resolve one opaque invite digest to its server-owned protected target. + pub async fn resolve_canonical_invite_target( + &self, + domain: CommunityId, + token_hash: [u8; 32], + ) -> Result { + if domain.as_uuid().is_nil() || token_hash == [0; 32] { + return Err(AdmissionCommitError::InvalidRequest); + } + let invite_id: Uuid = sqlx::query_scalar( + "SELECT id FROM relay_invites WHERE community_id=$1 AND token_hash=$2", + ) + .bind(domain.as_uuid()) + .bind(token_hash.as_slice()) + .fetch_optional(&self.pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::AuthorizationDenied)?; + if invite_id.is_nil() { + return Err(AdmissionCommitError::AuthorizationDenied); } + Ok(CanonicalInviteResource { + key: canonical_invite_resource_key(domain, invite_id), + }) + } - let receipt = persist_committed_admission( - &mut transaction, - operation_id, - attempt_id, + /// Prepare read-only evidence, then claim one invite through canonical admission. + #[allow(clippy::too_many_arguments)] + pub async fn commit_canonical_invite_claim( + &self, + assertion: VerifiedFederatedAssertion, + proof: VerifiedNip98InviteClaimProof, + resource: CanonicalInviteResource, + token_hash: [u8; 32], + policy_version: Option<&str>, + verifier: Arc, + ) -> Result { + for attempt in 0..2 { + let result = self + .commit_canonical_invite_claim_once( + assertion.clone(), + proof.clone(), + resource, + token_hash, + policy_version, + Arc::clone(&verifier), + ) + .await; + if attempt == 0 && result == Err(AdmissionCommitError::AuthorizationDenied) { + continue; + } + return result; + } + Err(AdmissionCommitError::AuthorizationDenied) + } + + #[allow(clippy::too_many_arguments)] + async fn commit_canonical_invite_claim_once( + &self, + assertion: VerifiedFederatedAssertion, + proof: VerifiedNip98InviteClaimProof, + resource: CanonicalInviteResource, + token_hash: [u8; 32], + policy_version: Option<&str>, + verifier: Arc, + ) -> Result { + let proof = proof.into_verified_nostr_proof(); + if assertion.authorization_domain() != proof.authorization_domain() + || proof.transport() != ProofTransport::Nip98 + || proof.target_fingerprint() != &resource.key + { + return Err(AdmissionCommitError::InvalidRequest); + } + let domain = proof.authorization_domain(); + let object = canonical_invite_admission_object(*proof.target_fingerprint())?; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT clock_timestamp()") + .fetch_one(&self.pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let prepared = crate::identity_enrollment::prepare_direct_enrollment( + &self.pool, + assertion.clone(), + proof.clone(), + authoritative_now, + ) + .await + .map_err(map_enrollment_error)?; + let policy = prepare_invite_authorization_policy( + &self.pool, + domain, object, - request_fingerprint, - semantic_fingerprint, - &authorization, - enrollment_disposition, - application_intent_digest, - application_outcome.as_ref(), + proof.expires_at(), authoritative_now, ) .await?; - sqlx::query("SET CONSTRAINTS ALL IMMEDIATE") + let effect = CanonicalInviteClaimEffect::new(token_hash, policy_version)?; + let principal = assertion.principal_storage_key(); + let effect_intent = effect.intent_digest(); + let semantic_fingerprint = admission_framed_digest( + b"buzz:canonical-invite-admission:v1", + &[ + domain.as_uuid().as_bytes(), + principal.issuer().as_bytes(), + principal.subject().as_bytes(), + proof.actor_pubkey().as_bytes(), + proof.request_fingerprint(), + proof.target_fingerprint(), + proof.transport_context_fingerprint(), + b"InviteClaim", + b"Invitation", + b"Mutate", + b"Nip98", + &effect_intent, + ], + ); + let evidence = AdmissionEnrollmentEvidence::new(prepared, assertion, proof, policy)?; + let request = AdmissionCommitRequest::enrollment( + Uuid::new_v4(), + Uuid::new_v4(), + object, + RouteCapability::InviteClaim, + evidence, + )? + .with_semantic_fingerprint_override(semantic_fingerprint)? + .with_application_effect(Box::new(effect))?; + let committer = PostgresCanonicalAdmissionCommitter::new( + self.pool.clone(), + Arc::new(CanonicalInviteFinalRechecker { verifier }), + ); + match committer.commit(request).await? { + AdmissionCommitOutcome::Committed { + receipt, + application_result: Some(result), + application_result_binding: Some(binding), + .. + } => validate_committed_invite_result( + domain, + object, + semantic_fingerprint, + effect_intent, + binding, + receipt, + &result, + ) + .map(CanonicalInviteClaimResult::fresh), + AdmissionCommitOutcome::ExactReplay { + receipt, + application_result: Some(result), + } => validate_stored_invite_result( + domain, + object, + semantic_fingerprint, + effect_intent, + receipt, + &result, + ) + .map(CanonicalInviteClaimResult::exact_replay), + AdmissionCommitOutcome::ExactReplay { + application_result: None, + .. + } => Err(AdmissionCommitError::IntentConflict), + AdmissionCommitOutcome::Committed { .. } => { + Err(AdmissionCommitError::DependencyUnavailable) + } + } + } +} + +#[allow(clippy::too_many_arguments)] +fn validate_committed_invite_result( + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_intent_digest: [u8; 32], + binding: AdmissionApplicationResultBinding, + receipt: AdmissionCommitReceipt, + result: &AdmissionApplicationResult, +) -> Result { + if binding.authorization_domain() != authorization_domain + || binding.object() != object + || binding.semantic_fingerprint() != &semantic_fingerprint + || binding.application_intent_digest() != &application_intent_digest + { + return Err(AdmissionCommitError::IntentConflict); + } + validate_stored_invite_result( + authorization_domain, + object, + semantic_fingerprint, + application_intent_digest, + receipt, + result, + ) +} + +fn validate_stored_invite_result( + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_intent_digest: [u8; 32], + receipt: AdmissionCommitReceipt, + result: &AdmissionApplicationResult, +) -> Result { + if object.kind() != AdmissionObjectKind::Invitation + || receipt.authorization_domain() != authorization_domain + || receipt.object() != object + || receipt.semantic_fingerprint() != &semantic_fingerprint + || result.schema() != AdmissionApplicationResultSchema::invite_claim() + || !result.payload().is_empty() + { + return Err(AdmissionCommitError::IntentConflict); + } + let outcome = result + .decode_invite_claim() + .map_err(|_| AdmissionCommitError::IntentConflict)?; + let expected_result = AdmissionApplicationResult::invite_claim(outcome); + if result != &expected_result { + return Err(AdmissionCommitError::IntentConflict); + } + let expected_digest = canonical_application_result_digest( + authorization_domain, + object, + semantic_fingerprint, + application_intent_digest, + &expected_result, + )?; + if receipt.application_result_digest() != Some(&expected_digest) { + return Err(AdmissionCommitError::IntentConflict); + } + Ok(outcome) +} + +fn canonical_invite_resource_key(domain: CommunityId, invite_id: Uuid) -> [u8; 32] { + admission_framed_digest( + b"buzz:canonical-invite-resource:v1", + &[domain.as_uuid().as_bytes(), invite_id.as_bytes()], + ) +} + +/// Encode an exact invitation inside its dedicated durable namespace. +/// +/// The object key is the server-resolved, domain-separated fingerprint of one +/// invitation. Clients cannot select this coordinate. +fn canonical_invite_admission_object( + resource_key: [u8; 32], +) -> Result { + AdmissionObject::new(AdmissionObjectKind::Invitation, resource_key) + .ok_or(AdmissionCommitError::InvalidRequest) +} + +async fn prepare_invite_authorization_policy( + pool: &PgPool, + domain: CommunityId, + object: AdmissionObject, + proof_expires_at: DateTime, + authoritative_now: DateTime, +) -> Result { + let policy = sqlx::query( + "SELECT policy_revision, expires_at FROM identity_enrollment_policies \ + WHERE community_id=$1 AND effective_at <= $2 \ + AND (expires_at IS NULL OR $2 < expires_at) \ + ORDER BY policy_revision DESC LIMIT 1", + ) + .bind(domain.as_uuid()) + .bind(authoritative_now) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let policy_revision = u64::try_from( + policy + .try_get::("policy_revision") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .ok() + .filter(|value| *value > 0) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let policy_expires_at: Option> = policy + .try_get("expires_at") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation: i64 = sqlx::query_scalar( + "SELECT current_generation FROM authorization_invalidation_domains WHERE community_id=$1", + ) + .bind(domain.as_uuid()) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)? + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + let invalidation_generation = u64::try_from(invalidation_generation) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let current_epoch: Option = sqlx::query_scalar( + "SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3", + ) + .bind(domain.as_uuid()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .fetch_optional(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let authority_epoch = u64::try_from( + current_epoch + .unwrap_or(0) + .checked_add(1) + .ok_or(AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fence_bytes: Vec = + sqlx::query_scalar("SELECT uuid_send(gen_random_uuid()) || uuid_send(gen_random_uuid())") + .fetch_one(pool) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fence = AuthorizationLeaseFence::from_bytes( + fence_bytes + .try_into() + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let mut expires_at = proof_expires_at.min(authoritative_now + TimeDelta::minutes(1)); + if let Some(policy_expires_at) = policy_expires_at { + expires_at = expires_at.min(policy_expires_at); + } + if expires_at <= authoritative_now { + return Err(AdmissionCommitError::AuthorizationDenied); + } + LocalAuthorizationPolicy::from_database( + domain, + Uuid::new_v4(), + policy_revision, + invalidation_generation, + authority_epoch, + fence, + RouteCapability::InviteClaim, + expires_at, + None, + None, + ) + .ok_or(AdmissionCommitError::DependencyUnavailable) +} + +/// PostgreSQL implementation of the canonical final-admission boundary. +pub struct PostgresCanonicalAdmissionCommitter { + pool: PgPool, + rechecker: Arc, +} + +impl PostgresCanonicalAdmissionCommitter { + /// Bind canonical admission to the writer pool and configured rechecker. + pub fn new(pool: PgPool, rechecker: Arc) -> Self { + Self { pool, rechecker } + } + + async fn commit_inner( + &self, + request: AdmissionCommitRequest, + ) -> Result { + let domain = request.authorization_domain(); + let operation_id = request.operation_id(); + let semantic_fingerprint = request.semantic_fingerprint(); + let attempt_id = request.attempt_id; + let object = request.object; + let request_fingerprint = request.request_fingerprint(); + let denial_actor = request.denial_actor()?; + let denial_correlation_id = request.denial_correlation_id(); + let receipt_shape = AdmissionReceiptShape::from_preparation(&request.preparation); + let replay_claim = request.replay_claim; + let expected_application_schema = request + .application_effect + .as_ref() + .map(|effect| effect.result_schema()); + let application_intent_digest = request + .application_effect + .as_ref() + .map(|effect| effect.intent_digest()); + let mut transaction = self + .pool + .begin() + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + + lock_operation(&mut transaction, domain, operation_id).await?; + if let Some(existing) = read_existing_receipt( + &mut transaction, + ExistingAdmissionLookup { + domain, + operation_id, + request_fingerprint, + semantic_fingerprint, + object, + receipt_shape, + expected_application_schema, + }, + ) + .await? + { + match existing { + ExistingAdmissionRecord::Denied(denial) => { + transaction + .rollback() + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + return Err(denial); + } + ExistingAdmissionRecord::Allowed(allowed) => { + let (receipt, application_result) = *allowed; + if let Some(claim) = replay_claim { + let authoritative_now: DateTime = + sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut *transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + claim_replay_identity_tx( + &mut transaction, + domain, + claim, + authoritative_now, + ) + .await?; + transaction + .commit() + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + } else { + transaction + .rollback() + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + } + return Ok(AdmissionCommitOutcome::ExactReplay { + receipt, + application_result, + }); + } + } + } + + sqlx::query("SAVEPOINT canonical_admission_attempt") .execute(&mut *transaction) .await - .map_err(map_commit_error)?; - transaction.commit().await.map_err(map_commit_error)?; - Ok(AdmissionCommitOutcome::Committed { - authorization: Box::new(authorization), - receipt, - application_result: application_outcome.map(|outcome| outcome.result), + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let fresh = execute_fresh_admission( + &mut transaction, + self.rechecker.as_ref(), + request, + FreshAdmissionCoordinates { + operation_id, + attempt_id, + object, + request_fingerprint, + semantic_fingerprint, + replay_claim, + expected_application_schema, + application_intent_digest, + }, + ) + .await; + match fresh { + Ok(fresh) => { + transaction.commit().await.map_err(map_commit_error)?; + Ok(AdmissionCommitOutcome::Committed { + authorization: Box::new(fresh.authorization), + receipt: fresh.receipt, + application_result: fresh.application_result, + application_result_binding: fresh.application_result_binding, + }) + } + Err(denial) => { + sqlx::query("ROLLBACK TO SAVEPOINT canonical_admission_attempt") + .execute(&mut *transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let Some(reason) = denial_reason(denial) else { + transaction + .rollback() + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + return Err(denial); + }; + if sqlx::query("SET CONSTRAINTS ALL DEFERRED") + .execute(&mut *transaction) + .await + .is_err() + { + let _ = transaction.rollback().await; + latch_admission_audit_failure( + &self.pool, + domain, + AuthorizationAuditFailureCode::StorageUnavailable, + ) + .await; + return Err(AdmissionCommitError::AuditUnavailable); + } + match persist_denied_admission( + &mut transaction, + DeniedAdmissionRecord { + domain, + operation_id, + attempt_id, + correlation_id: denial_correlation_id, + object, + request_fingerprint, + semantic_fingerprint, + actor: denial_actor, + reason, + }, + ) + .await + { + Ok(()) => {} + Err(failure) => { + let _ = transaction.rollback().await; + latch_admission_audit_failure(&self.pool, domain, failure.audit_code()) + .await; + return Err(AdmissionCommitError::AuditUnavailable); + } + } + if sqlx::query("SET CONSTRAINTS ALL IMMEDIATE") + .execute(&mut *transaction) + .await + .is_err() + { + let _ = transaction.rollback().await; + latch_admission_audit_failure( + &self.pool, + domain, + AuthorizationAuditFailureCode::StorageUnavailable, + ) + .await; + return Err(AdmissionCommitError::AuditUnavailable); + } + if transaction.commit().await.is_err() { + latch_admission_audit_failure( + &self.pool, + domain, + AuthorizationAuditFailureCode::StorageUnavailable, + ) + .await; + return Err(AdmissionCommitError::AuditUnavailable); + } + Err(recorded_denial_error(denial)) + } + } + } +} + +async fn latch_admission_audit_failure( + pool: &PgPool, + domain: CommunityId, + failure: AuthorizationAuditFailureCode, +) { + let _: Result = + sqlx::query_scalar("SELECT authorization_event_capacity_report_failure_v2($1,$2)") + .bind(domain.as_uuid()) + .bind(failure as i16) + .fetch_one(pool) + .await; +} + +struct FreshAdmissionOutcome { + authorization: FinalizedAuthContext, + receipt: AdmissionCommitReceipt, + application_result: Option, + application_result_binding: Option, +} + +struct FreshAdmissionCoordinates { + operation_id: Uuid, + attempt_id: Uuid, + object: AdmissionObject, + request_fingerprint: [u8; 32], + semantic_fingerprint: [u8; 32], + replay_claim: Option, + expected_application_schema: Option, + application_intent_digest: Option<[u8; 32]>, +} + +async fn execute_fresh_admission( + transaction: &mut Transaction<'_, Postgres>, + rechecker: &dyn AdmissionFinalRechecker, + request: AdmissionCommitRequest, + coordinates: FreshAdmissionCoordinates, +) -> Result { + let FreshAdmissionCoordinates { + operation_id, + attempt_id, + object, + request_fingerprint, + semantic_fingerprint, + replay_claim, + expected_application_schema, + application_intent_digest, + } = coordinates; + let domain = request.authorization_domain(); + let AdmissionCommitRequest { + preparation, + mut application_effect, + .. + } = request; + let authoritative_now: DateTime = sqlx::query_scalar("SELECT transaction_timestamp()") + .fetch_one(&mut **transaction) + .await + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if let Some(claim) = replay_claim { + claim_replay_identity_tx(transaction, domain, claim, authoritative_now).await?; + } + + let (prepared, enrollment_disposition) = match preparation { + AdmissionPreparation::Existing { prepared } => (*prepared, None), + AdmissionPreparation::Enrollment { + correlation_id, + capability, + evidence, + } => { + let AdmissionEnrollmentEvidence { + proposal, + enrollment_policy_digest, + assertion, + proof, + policy, + } = *evidence; + let enrollment = execute_authoritative_enrollment_tx( + transaction, + operation_id, + request_fingerprint, + &proposal, + enrollment_policy_digest, + &assertion, + &proof, + authoritative_now, + ) + .await + .map_err(map_enrollment_error)?; + let input = AuthorizationInput::new(domain, correlation_id, proof, capability) + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + let resolution = LocalBindingResolution::enrollment(proposal, enrollment.binding); + let prepared = + AuthorizationFinalizer::prepare(input, resolution, policy, authoritative_now) + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + (prepared, Some(enrollment.disposition)) + } + }; + + let recheck_request = prepared.recheck_request(); + let observation = rechecker + .authoritative_recheck(transaction, &recheck_request, object) + .await?; + let one_shot = OneShotRechecker::new(observation); + let witness = AuthorizationFinalizer::recheck(&prepared, &one_shot) + .await + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + let authorization = AuthorizationFinalizer::finalize(prepared, witness) + .map_err(|_| AdmissionCommitError::AuthorizationDenied)?; + + let application_context = application_intent_digest + .map(|intent| { + AdmissionApplicationContext::new( + &authorization, + operation_id, + object, + semantic_fingerprint, + intent, + authoritative_now, + ) }) + .transpose()?; + let application_result_binding = application_context + .as_ref() + .map(AdmissionApplicationResultBinding::from_context); + let application_outcome = match (application_effect.as_mut(), application_context.as_ref()) { + (Some(effect), Some(context)) => { + let outcome = effect.apply(transaction, context).await?; + let _ = context.canonical_result_digest(&outcome)?; + Some(outcome) + } + (None, None) => None, + _ => return Err(AdmissionCommitError::DependencyUnavailable), + }; + if application_outcome + .as_ref() + .map(|outcome| outcome.result().schema()) + != expected_application_schema + { + return Err(AdmissionCommitError::DependencyUnavailable); } + + let receipt = persist_committed_admission( + transaction, + operation_id, + attempt_id, + object, + request_fingerprint, + semantic_fingerprint, + &authorization, + enrollment_disposition, + application_intent_digest, + application_outcome.as_ref(), + authoritative_now, + ) + .await?; + sqlx::query("SET CONSTRAINTS ALL IMMEDIATE") + .execute(&mut **transaction) + .await + .map_err(map_commit_error)?; + Ok(FreshAdmissionOutcome { + authorization, + receipt, + application_result: application_outcome.map(|outcome| outcome.result), + application_result_binding, + }) } async fn claim_replay_identity_tx( @@ -1644,13 +2805,15 @@ struct ExistingAdmissionLookup { expected_application_schema: Option, } +enum ExistingAdmissionRecord { + Allowed(Box<(AdmissionCommitReceipt, Option)>), + Denied(AdmissionCommitError), +} + async fn read_existing_receipt( transaction: &mut Transaction<'_, Postgres>, lookup: ExistingAdmissionLookup, -) -> Result< - Option<(AdmissionCommitReceipt, Option)>, - AdmissionCommitError, -> { +) -> Result, AdmissionCommitError> { let ExistingAdmissionLookup { domain, operation_id, @@ -1677,12 +2840,14 @@ async fn read_existing_receipt( event.event_id AS audit_event_id, event.event_kind AS audit_event_kind, event.outcome_code AS audit_outcome_code, + event.reason_code AS audit_reason_code, event.matching_event_count FROM authorization_operation_receipts receipt LEFT JOIN LATERAL ( SELECT (array_agg(event_id ORDER BY accepted_at, event_id))[1] AS event_id, min(event_kind) AS event_kind, min(outcome_code) AS outcome_code, + min(reason_code) AS reason_code, count(*) AS matching_event_count FROM authorization_events WHERE community_id = receipt.community_id @@ -1741,9 +2906,7 @@ async fn read_existing_receipt( let event_count: i64 = row .try_get("matching_event_count") .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - if event_count != 1 - || !receipt_shape.matches(operation_kind, receipt_outcome, event_kind, event_outcome) - { + if event_count != 1 { return Err(AdmissionCommitError::IntentConflict); } let result_digest = fixed_digest(&row, "result_digest")?; @@ -1771,6 +2934,35 @@ async fn read_existing_receipt( let stored_application_result_digest: Option> = row .try_get("application_result_digest") .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if receipt_outcome == 2 + && operation_kind == 11 + && event_kind == Some(11) + && event_outcome == Some(2) + { + if stored_application_type.is_some() + || stored_application_version.is_some() + || stored_application_code.is_some() + || stored_application_payload.is_some() + || stored_application_intent.is_some() + || stored_application_effect.is_some() + || stored_application_result_digest.is_some() + { + return Err(AdmissionCommitError::IntentConflict); + } + let reason_code: i16 = row + .try_get("audit_reason_code") + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let denial = denial_error_from_reason_code(reason_code)?; + if result_digest + != canonical_denial_result_digest(domain, object, semantic_fingerprint, reason_code) + { + return Err(AdmissionCommitError::DependencyUnavailable); + } + return Ok(Some(ExistingAdmissionRecord::Denied(denial))); + } + if !receipt_shape.matches(operation_kind, receipt_outcome, event_kind, event_outcome) { + return Err(AdmissionCommitError::IntentConflict); + } let ( application_result, stored_application_intent, @@ -1840,30 +3032,207 @@ async fn read_existing_receipt( if calculated_application_result_digest != stored_application_result_digest { return Err(AdmissionCommitError::DependencyUnavailable); } - let calculated_result_digest = canonical_admission_result_digest( + let calculated_result_digest = canonical_admission_result_digest( + domain, + object, + semantic_fingerprint, + stored_application_intent, + application_result.as_ref(), + stored_application_effect, + ) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + if calculated_result_digest != result_digest { + return Err(AdmissionCommitError::DependencyUnavailable); + } + let receipt = AdmissionCommitReceipt::from_storage( + domain, + object, + operation_id, + stored_request, + semantic_fingerprint, + AdmissionCommitDigests::new(result_digest, stored_application_result_digest) + .ok_or(AdmissionCommitError::DependencyUnavailable)?, + audit_event_id.ok_or(AdmissionCommitError::DependencyUnavailable)?, + ) + .ok_or(AdmissionCommitError::DependencyUnavailable)?; + Ok(Some(ExistingAdmissionRecord::Allowed(Box::new(( + receipt, + application_result, + ))))) +} + +fn denial_reason(error: AdmissionCommitError) -> Option { + match error { + AdmissionCommitError::InvalidRequest | AdmissionCommitError::RecordedInvalidRequest => { + Some(AuthorizationReasonCode::Invalid) + } + AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied => { + Some(AuthorizationReasonCode::PolicyDenied) + } + AdmissionCommitError::IntentConflict | AdmissionCommitError::RecordedIntentConflict => { + Some(AuthorizationReasonCode::IntentConflict) + } + AdmissionCommitError::ReplayRejected | AdmissionCommitError::RecordedReplayRejected => { + Some(AuthorizationReasonCode::ReplayRejected) + } + AdmissionCommitError::AuditUnavailable | AdmissionCommitError::RecordedAuditUnavailable => { + Some(AuthorizationReasonCode::CapacityExhausted) + } + AdmissionCommitError::DependencyUnavailable => None, + } +} + +const fn recorded_denial_error(error: AdmissionCommitError) -> AdmissionCommitError { + match error { + AdmissionCommitError::InvalidRequest => AdmissionCommitError::RecordedInvalidRequest, + AdmissionCommitError::AuthorizationDenied => { + AdmissionCommitError::RecordedAuthorizationDenied + } + AdmissionCommitError::IntentConflict => AdmissionCommitError::RecordedIntentConflict, + AdmissionCommitError::ReplayRejected => AdmissionCommitError::RecordedReplayRejected, + AdmissionCommitError::AuditUnavailable => AdmissionCommitError::RecordedAuditUnavailable, + other => other, + } +} + +fn denial_error_from_reason_code( + reason_code: i16, +) -> Result { + match reason_code { + 3 => Ok(AdmissionCommitError::RecordedInvalidRequest), + 9 => Ok(AdmissionCommitError::RecordedAuthorizationDenied), + 11 => Ok(AdmissionCommitError::RecordedAuditUnavailable), + 15 => Ok(AdmissionCommitError::RecordedIntentConflict), + 17 => Ok(AdmissionCommitError::RecordedReplayRejected), + _ => Err(AdmissionCommitError::DependencyUnavailable), + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum DeniedAdmissionPersistenceError { + CapacityUnavailable, + InvalidEnvelope, + StorageUnavailable, +} + +impl DeniedAdmissionPersistenceError { + const fn audit_code(self) -> AuthorizationAuditFailureCode { + match self { + Self::CapacityUnavailable => AuthorizationAuditFailureCode::CapacityExhausted, + Self::InvalidEnvelope => AuthorizationAuditFailureCode::InvalidEnvelope, + Self::StorageUnavailable => AuthorizationAuditFailureCode::StorageUnavailable, + } + } +} + +fn canonical_denial_result_digest( + domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + reason_code: i16, +) -> [u8; 32] { + admission_framed_digest( + b"buzz:canonical-admission-denial:v1", + &[ + domain.as_uuid().as_bytes(), + &object.kind().database_code().to_be_bytes(), + object.key(), + &semantic_fingerprint, + &reason_code.to_be_bytes(), + ], + ) +} + +struct DeniedAdmissionRecord { + domain: CommunityId, + operation_id: Uuid, + attempt_id: Uuid, + correlation_id: Uuid, + object: AdmissionObject, + request_fingerprint: [u8; 32], + semantic_fingerprint: [u8; 32], + actor: AuthorizationEventActor, + reason: AuthorizationReasonCode, +} + +async fn persist_denied_admission( + transaction: &mut Transaction<'_, Postgres>, + record: DeniedAdmissionRecord, +) -> Result<(), DeniedAdmissionPersistenceError> { + let DeniedAdmissionRecord { domain, + operation_id, + attempt_id, + correlation_id, object, + request_fingerprint, semantic_fingerprint, - stored_application_intent, - application_result.as_ref(), - stored_application_effect, + actor, + reason, + } = record; + let actor_digest = actor + .fingerprint() + .ok_or(DeniedAdmissionPersistenceError::InvalidEnvelope)?; + let reason_code = reason as i16; + let result_digest = + canonical_denial_result_digest(domain, object, semantic_fingerprint, reason_code); + let event_id = Uuid::new_v4(); + + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id,operation_id,request_fingerprint,operation_kind,actor_fingerprint, \ + outcome_code,result_digest) VALUES ($1,$2,$3,11,$4,2,$5)", ) - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - if calculated_result_digest != result_digest { - return Err(AdmissionCommitError::DependencyUnavailable); - } - let receipt = AdmissionCommitReceipt::from_storage( + .bind(domain.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(actor_digest.as_slice()) + .bind(result_digest.as_slice()) + .execute(&mut **transaction) + .await + .map_err(|_| DeniedAdmissionPersistenceError::StorageUnavailable)?; + + let event = NewAuthorizationEvent::new( domain, - object, + event_id, + AuthorizationEventKind::ProtectedDenied, + AuthorizationEventOutcome::Denied, + reason, + actor, + None, operation_id, - stored_request, - semantic_fingerprint, - AdmissionCommitDigests::new(result_digest, stored_application_result_digest) - .ok_or(AdmissionCommitError::DependencyUnavailable)?, - audit_event_id.ok_or(AdmissionCommitError::DependencyUnavailable)?, + Some(request_fingerprint), + correlation_id, + attempt_id, ) - .ok_or(AdmissionCommitError::DependencyUnavailable)?; - Ok(Some((receipt, application_result))) + .map_err(|_| DeniedAdmissionPersistenceError::InvalidEnvelope)?; + record_authorization_event_tx(transaction, &event) + .await + .map_err(|error| match error { + AuthorizationEventWriteError::CapacityUnavailable => { + DeniedAdmissionPersistenceError::CapacityUnavailable + } + AuthorizationEventWriteError::Database(_) => { + DeniedAdmissionPersistenceError::StorageUnavailable + } + })?; + + sqlx::query( + "INSERT INTO authorization_admission_results \ + (community_id,operation_id,request_fingerprint,semantic_fingerprint, \ + object_kind,object_key) VALUES ($1,$2,$3,$4,$5,$6)", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(semantic_fingerprint.as_slice()) + .bind(object.kind().database_code()) + .bind(object.key().as_slice()) + .execute(&mut **transaction) + .await + .map_err(|_| DeniedAdmissionPersistenceError::StorageUnavailable)?; + Ok(()) } #[allow(clippy::too_many_arguments)] @@ -2470,6 +3839,7 @@ fn map_commit_error(error: sqlx::Error) -> AdmissionCommitError { mod tests { use std::{ io::Write, + path::PathBuf, process::{Command, Stdio}, }; @@ -2479,42 +3849,12 @@ mod tests { }; use buzz_core::AuthorizationLeaseFence; use chrono::TimeDelta; - use nostr::{EventBuilder, Keys, RelayUrl}; + use nostr::{EventBuilder, Keys, Kind, RelayUrl, Tag}; use super::*; - const TEST_RSA_PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDA+T6BKOFQyrEz -Xd/zoFuWoLu95Gmhfr3KcynweqwNWNELcVvxMkMp/3HKwzSHERtgd1BH8AkMA/le -Oy2FeZBPMkePEXcZ6EdGgkd8E1aSuoRZeK+k/GdR84dqFHiRpY9ZNiivKo31SrMB -tp1dB6NkWvVk4VdZnMac6iwUVtjMAZYZbiv9jquDkrArk0hzES/ldmuSbw/zYpwB -0qvsjPpxjYHuD6BbDG9LK8yDpr8Cr5E6M1Y/ToTJ026XfgEjQeW24lXv/enMNX0B -ds/vM9qfQ0uqxMHGq5JhP3SONK1+kJ1iUju7OU/bug8D8ojNYqDAHcLlFq36T8we -s7cXHUybAgMBAAECggEAOet1ecHh4uR7jD3cZpzWcJ78yrGgjNLkNzwate2z72ud -jvAu1vWtmBDuQBwYC4Q0cd9N7tGafYtB0Sk08A99I3Alb0kgNNv1bLHUr+aEARVP -fSVOntgNUNkl506Oo7SMEoxBaNX+dsW9dcGweMg+n/P3HJjQJXr7WASnR1GLz6sS -Xvns5FEHXAs6BpxvGmJm32HzwjsHmnrTIrn50VtbpLRgmUV7bqT2/Wr480PaofwJ -h7IEbHu8QI/Cdg75dGe/wW2EaeQGBSHHVGUHYXure74cWSH7e1a5roLO+PXo9CdO -x5KQ1rA3GzxduM+NISWliYehd0O/hBKUZakdWOxyFQKBgQDwwrddIqbV4t4lJn1d -YfYVb4wwChqhEDuVSbjoMgdK7x+ag+M5QwcSDlHhTabv6ed+0mSJTpmn9EpdhfY9 -okYWpvyQF6jEsiywUh15LXGjHMlYvtFTraAAs+W9StsbHoy6xufIZvZdyU+8dufF -vWn8mLSRvEr0TAKtaEpHR96JTQKBgQDNMDAE/EM4Klne6AgOGp6NVcqKZcKBNHlT -8IKaAa7HUYwCRJ6K/7sb1dYOI3Z4HfjZh4yiO8PaJ+JLDnqycYoUjofekS8B4fbi -vfxLlyEZTeqbY9yERtcYPJFToLQsN7TFfSjJldp7SJTn+umkHI13E4QlGlWBWAOz -FjRMfpP5hwKBgQCr9sg1k7yKZOK6skU03/V+1g/ReEYQ6KFGPkP+RU2ELkvqd21i -xwdT1DqTrH0iO3WH1grNMAD8P7amGjsJRtC8+UTIPr3i0EivH9fBZ74U/UirRJAL -LqZsGhJsI/1f33AxMET5lOE/l7yGJn/hcysyqne+6Di5SVlYNndndmPyuQKBgBXm -hcmSb05IXu1G0M1IlBG7zXF2KQuHYUfPTPFJKrGFh68aSd3GK99ttHov2M47TLtT -F3SdcmsPhLzEH9559eX5zJC56E2II8TRyGL9D4BW66qIPxozQXQJyu0lIvXxQC8w -C7FweDBeb95Ozq9AiOzjvWAEbonurf5oaU6c2AhzAoGBAPCXgwCtFTjrchoK5j8e -uXTlkrQZkDYKsHwgoDh0jOfeuZ/f7+0T15+wWuh/3Yx5BXjMaaz7V58oPXCZkHDX -5EaFLmiTniLlQrRwoTtcfFUNSNZOEwoqGWfrtA0tMibkZdxcM3IBu4Oa75nXKVPZ -f9hDSYt0+Hl9FGuNjpkgLxIa ------END PRIVATE KEY----- -"#; - - const TEST_RSA_JWKS: &str = r#"{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"s3-test","n":"wPk-gSjhUMqxM13f86BblqC7veRpoX69ynMp8HqsDVjRC3Fb8TJDKf9xysM0hxEbYHdQR_AJDAP5XjsthXmQTzJHjxF3GehHRoJHfBNWkrqEWXivpPxnUfOHahR4kaWPWTYoryqN9UqzAbadXQejZFr1ZOFXWZzGnOosFFbYzAGWGW4r_Y6rg5KwK5NIcxEv5XZrkm8P82KcAdKr7Iz6cY2B7g-gWwxvSyvMg6a_Aq-ROjNWP06EydNul34BI0HltuJV7_3pzDV9AXbP7zPan0NLqsTBxquSYT90jjStfpCdYlI7uzlP27oPA_KIzWKgwB3C5Rat-k_MHrO3Fx1Mmw","e":"AQAB"}]}"#; - + const TEST_VERIFIER_ISSUER: &str = "https://verifier.example"; + const TEST_VERIFIER_AUDIENCE: &str = "buzz-relay-test"; fn base64_url(input: &[u8]) -> String { const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; @@ -2535,11 +3875,84 @@ f9hDSYt0+Hl9FGuNjpkgLxIa output } - fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> String { + struct EphemeralRsaKey { + path: PathBuf, + } + + impl EphemeralRsaKey { + fn generate(kid: &str, prefix: &str) -> (Self, serde_json::Value) { + let key = Self { + path: std::env::temp_dir().join(format!("{prefix}-{}.pem", Uuid::new_v4())), + }; + let generated = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-pkeyopt", + "rsa_keygen_pubexp:65537", + "-out", + ]) + .arg(&key.path) + .output() + .expect("generate ephemeral RSA test key"); + assert!( + generated.status.success(), + "OpenSSL RSA key generation failed: {}", + String::from_utf8_lossy(&generated.stderr) + ); + let modulus = Command::new("openssl") + .args(["rsa", "-in"]) + .arg(&key.path) + .args(["-noout", "-modulus"]) + .output() + .expect("read ephemeral RSA test modulus"); + assert!( + modulus.status.success(), + "OpenSSL RSA modulus extraction failed: {}", + String::from_utf8_lossy(&modulus.stderr) + ); + let modulus = std::str::from_utf8(&modulus.stdout) + .expect("UTF-8 RSA test modulus") + .trim() + .strip_prefix("Modulus=") + .expect("OpenSSL RSA modulus prefix"); + let modulus = hex::decode(modulus).expect("hex RSA test modulus"); + let jwks = serde_json::json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": base64_url(&modulus), + "e": "AQAB", + }], + }); + (key, jwks) + } + } + + impl Drop for EphemeralRsaKey { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } + } + + fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> (String, serde_json::Value) { let issued_at = Utc::now().timestamp() - 1; + signed_test_jwt_at(subject, event_author, issued_at) + } + + fn signed_test_jwt_at( + subject: &str, + event_author: [u8; 32], + issued_at: i64, + ) -> (String, serde_json::Value) { let claims = serde_json::json!({ - "iss": "https://s3-verifier.test", - "aud": "buzz-s3-test", + "iss": TEST_VERIFIER_ISSUER, + "aud": TEST_VERIFIER_AUDIENCE, "sub": subject, "event_author": hex::encode(event_author), "iat": issued_at, @@ -2552,11 +3965,10 @@ f9hDSYt0+Hl9FGuNjpkgLxIa base64_url(&serde_json::to_vec(&header).expect("serialize JWT header")), base64_url(&serde_json::to_vec(&claims).expect("serialize JWT claims")), ); - let key_path = std::env::temp_dir().join(format!("buzz-s3-jwt-{}.pem", Uuid::new_v4())); - std::fs::write(&key_path, TEST_RSA_PRIVATE_KEY).expect("write temporary test key"); + let (key, jwks) = EphemeralRsaKey::generate("s3-test", "buzz-s3-jwt"); let mut child = Command::new("openssl") .args(["dgst", "-sha256", "-sign"]) - .arg(&key_path) + .arg(&key.path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -2568,9 +3980,11 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .write_all(signing_input.as_bytes()) .expect("write JWT signing input"); let signed = child.wait_with_output().expect("wait for JWT signer"); - std::fs::remove_file(&key_path).expect("remove temporary test key"); assert!(signed.status.success(), "OpenSSL JWT signer failed"); - format!("{signing_input}.{}", base64_url(&signed.stdout)) + ( + format!("{signing_input}.{}", base64_url(&signed.stdout)), + jwks, + ) } fn verified_enrollment_evidence( @@ -2581,11 +3995,11 @@ f9hDSYt0+Hl9FGuNjpkgLxIa request: [u8; 32], transport_context: [u8; 32], ) -> (VerifiedFederatedAssertion, VerifiedNostrProof) { - let token = signed_test_jwt(subject, keys.public_key().to_bytes()); + let (token, jwks) = signed_test_jwt(subject, keys.public_key().to_bytes()); let verifier = CanonicalFederatedAssertionVerifier::new( CanonicalVerifierPolicy::new( - "https://s3-verifier.test".to_owned(), - "buzz-s3-test".to_owned(), + TEST_VERIFIER_ISSUER.to_owned(), + TEST_VERIFIER_AUDIENCE.to_owned(), "sub".to_owned(), Some("event_author".to_owned()), 5, @@ -2595,7 +4009,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa ); let key_set = CanonicalVerifierKeySet::new( VerifierKeyGeneration::new(1).expect("positive verifier generation"), - serde_json::from_str(TEST_RSA_JWKS).expect("parse test JWKS"), + serde_json::from_value(jwks).expect("parse generated test JWKS"), ); let assertion = verifier .verify( @@ -2633,6 +4047,77 @@ f9hDSYt0+Hl9FGuNjpkgLxIa (assertion, proof) } + fn verified_invite_evidence( + domain: CommunityId, + keys: &Keys, + subject: &str, + target: [u8; 32], + body: &[u8], + issued_at: i64, + ) -> (VerifiedFederatedAssertion, VerifiedNip98InviteClaimProof) { + let url = "https://invite-admission.test/api/invites/claim"; + let coordinates = buzz_auth::Nip98InviteClaimCoordinates::new(domain, target, url, body) + .expect("invite proof coordinates"); + let (request, target, context) = coordinates.request_binding(); + let (token, jwks) = signed_test_jwt_at(subject, keys.public_key().to_bytes(), issued_at); + let verifier = CanonicalFederatedAssertionVerifier::new( + CanonicalVerifierPolicy::new( + TEST_VERIFIER_ISSUER.to_owned(), + TEST_VERIFIER_AUDIENCE.to_owned(), + "sub".to_owned(), + Some("event_author".to_owned()), + 5, + 600, + ) + .expect("canonical verifier policy"), + ); + let key_set = CanonicalVerifierKeySet::new( + VerifierKeyGeneration::new(1).expect("positive verifier generation"), + serde_json::from_value(jwks).expect("parse generated test JWKS"), + ); + let assertion = verifier + .verify( + &token, + &key_set, + domain, + ProofTransport::Nip98, + *target, + *request, + *context, + ) + .expect("verify invite assertion"); + let payload = hex::encode(Sha256::digest(body)); + let event = EventBuilder::new(Kind::HttpAuth, "") + .tags(vec![ + Tag::parse(["u", url]).expect("invite u tag"), + Tag::parse(["method", "POST"]).expect("invite method tag"), + Tag::parse(["payload", payload.as_str()]).expect("invite payload tag"), + ]) + .sign_with_keys(keys) + .expect("sign invite proof"); + let event_json = serde_json::to_string(&event).expect("serialize invite proof"); + let proof = buzz_auth::verify_nip98_invite_claim_proof( + &event_json, + &coordinates, + body, + &assertion, + Utc::now(), + ) + .expect("verify invite proof"); + (assertion, proof) + } + + struct TestInviteVerifierRechecker; + + impl AdmissionVerifierRechecker for TestInviteVerifierRechecker { + fn recheck<'a>( + &'a self, + _expected: VerifierPolicyStamp, + ) -> Pin> + Send + 'a>> { + Box::pin(async { Ok(()) }) + } + } + struct TestPostgresAdmissionRechecker; impl AdmissionFinalRechecker for TestPostgresAdmissionRechecker { @@ -2879,6 +4364,59 @@ f9hDSYt0+Hl9FGuNjpkgLxIa ); } + #[test] + fn canonical_denial_reason_round_trip_preserves_conflict_class() { + for (error, code, replayed) in [ + ( + AdmissionCommitError::InvalidRequest, + AuthorizationReasonCode::Invalid as i16, + AdmissionCommitError::RecordedInvalidRequest, + ), + ( + AdmissionCommitError::AuthorizationDenied, + AuthorizationReasonCode::PolicyDenied as i16, + AdmissionCommitError::RecordedAuthorizationDenied, + ), + ( + AdmissionCommitError::IntentConflict, + AuthorizationReasonCode::IntentConflict as i16, + AdmissionCommitError::RecordedIntentConflict, + ), + ( + AdmissionCommitError::ReplayRejected, + AuthorizationReasonCode::ReplayRejected as i16, + AdmissionCommitError::RecordedReplayRejected, + ), + ( + AdmissionCommitError::AuditUnavailable, + AuthorizationReasonCode::CapacityExhausted as i16, + AdmissionCommitError::RecordedAuditUnavailable, + ), + ] { + assert_eq!(denial_reason(error).map(|reason| reason as i16), Some(code)); + assert_eq!(denial_error_from_reason_code(code), Ok(replayed)); + assert_eq!(recorded_denial_error(error), replayed); + } + } + + #[test] + fn invite_resources_use_the_dedicated_durable_namespace() { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let first_key = canonical_invite_resource_key(domain, Uuid::new_v4()); + let second_key = canonical_invite_resource_key(domain, Uuid::new_v4()); + let first = canonical_invite_admission_object(first_key).expect("invite object"); + + assert_eq!(first.kind(), AdmissionObjectKind::Invitation); + assert_eq!(first.kind().database_code(), 9); + assert_eq!(first.key(), &first_key); + assert_ne!(first_key, second_key); + assert_ne!( + first, + AdmissionObject::new(AdmissionObjectKind::Domain, first_key) + .expect("domain-scoped object") + ); + } + #[test] fn logical_operation_id_is_stable_domain_separated_and_server_formed() { let semantic = admission_framed_digest( @@ -2894,56 +4432,215 @@ f9hDSYt0+Hl9FGuNjpkgLxIa &[b"domain", b"request", b"capability", b"other-target"], )) ); - assert_eq!(operation_id.get_version_num(), 8); - assert_eq!(operation_id.get_variant(), uuid::Variant::RFC4122); + assert_eq!(operation_id.get_version_num(), 8); + assert_eq!(operation_id.get_variant(), uuid::Variant::RFC4122); + } + + #[test] + fn invite_application_results_are_closed_and_replay_decodable() { + for expected in [ + CanonicalInviteClaimOutcome::Joined, + CanonicalInviteClaimOutcome::AlreadyMember, + ] { + let result = AdmissionApplicationResult::invite_claim(expected); + assert_eq!( + result.schema(), + AdmissionApplicationResultSchema::invite_claim() + ); + assert_eq!(result.decode_invite_claim(), Ok(expected)); + assert_eq!( + AdmissionApplicationResult::from_database( + result.schema().type_key().to_vec(), + result.schema().version() as i16, + result.code(), + result.payload().to_vec(), + ), + Ok(result) + ); + } + assert_eq!( + AdmissionApplicationResult::from_database( + INVITE_CLAIM_RESULT_TYPE.to_vec(), + 1, + 3, + Vec::new(), + ) + .and_then(|result| result.decode_invite_claim()), + Err(AdmissionCommitError::InvalidRequest) + ); + assert_eq!( + AdmissionApplicationResult::new( + AdmissionApplicationResultSchema::invite_claim(), + 1, + vec![0; MAX_ADMISSION_APPLICATION_RESULT_PAYLOAD_BYTES + 1], + ), + Err(AdmissionCommitError::InvalidRequest) + ); + assert_eq!(AdmissionApplicationResultSchema::new([0; 32], 1), None); + assert_eq!(AdmissionApplicationResultSchema::new([1; 32], 0), None); + assert_eq!( + AdmissionApplicationResult::from_database(vec![1; 31], 1, 1, Vec::new()), + Err(AdmissionCommitError::DependencyUnavailable) + ); + } + + #[test] + fn committed_invite_result_requires_the_precommit_binding_and_exact_digest() { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let object = canonical_invite_admission_object([81; 32]).expect("invite object"); + let semantic_fingerprint = [82; 32]; + let application_intent_digest = [83; 32]; + let result = AdmissionApplicationResult::invite_claim(CanonicalInviteClaimOutcome::Joined); + let application_result_digest = canonical_application_result_digest( + domain, + object, + semantic_fingerprint, + application_intent_digest, + &result, + ) + .expect("canonical application result digest"); + let binding = AdmissionApplicationResultBinding { + authorization_domain: domain, + object, + semantic_fingerprint, + application_intent_digest, + }; + let receipt = AdmissionCommitReceipt::from_storage( + domain, + object, + Uuid::new_v4(), + [84; 32], + semantic_fingerprint, + AdmissionCommitDigests::new([85; 32], Some(application_result_digest)) + .expect("canonical admission digests"), + Uuid::new_v4(), + ) + .expect("canonical admission receipt"); + + assert_eq!( + validate_committed_invite_result( + domain, + object, + semantic_fingerprint, + application_intent_digest, + binding, + receipt, + &result, + ), + Ok(CanonicalInviteClaimOutcome::Joined) + ); + let wrong_digest_receipt = AdmissionCommitReceipt::from_storage( + domain, + object, + Uuid::new_v4(), + [84; 32], + semantic_fingerprint, + AdmissionCommitDigests::new([85; 32], Some([86; 32])) + .expect("non-sentinel admission digests"), + Uuid::new_v4(), + ) + .expect("adversarial admission receipt"); + assert_eq!( + validate_committed_invite_result( + domain, + object, + semantic_fingerprint, + application_intent_digest, + binding, + wrong_digest_receipt, + &result, + ), + Err(AdmissionCommitError::IntentConflict) + ); + let noncanonical_result = AdmissionApplicationResult::new( + AdmissionApplicationResultSchema::invite_claim(), + CanonicalInviteClaimOutcome::Joined.database_code(), + b"noncanonical".to_vec(), + ) + .expect("bounded adversarial result"); + assert_eq!( + validate_committed_invite_result( + domain, + object, + semantic_fingerprint, + application_intent_digest, + binding, + receipt, + &noncanonical_result, + ), + Err(AdmissionCommitError::IntentConflict) + ); + assert_eq!( + validate_committed_invite_result( + domain, + object, + semantic_fingerprint, + [87; 32], + binding, + receipt, + &result, + ), + Err(AdmissionCommitError::IntentConflict) + ); } #[test] - fn invite_application_results_are_closed_and_replay_decodable() { - for expected in [ + fn stored_invite_replay_returns_the_original_result_and_rejects_cross_resource() { + let domain = CommunityId::from_uuid(Uuid::new_v4()); + let object = canonical_invite_admission_object([88; 32]).expect("invite object"); + let other_object = + canonical_invite_admission_object([89; 32]).expect("other invite object"); + let semantic_fingerprint = [90; 32]; + let application_intent_digest = [91; 32]; + + for outcome in [ CanonicalInviteClaimOutcome::Joined, CanonicalInviteClaimOutcome::AlreadyMember, ] { - let result = AdmissionApplicationResult::invite_claim(expected); + let result = AdmissionApplicationResult::invite_claim(outcome); + let application_result_digest = canonical_application_result_digest( + domain, + object, + semantic_fingerprint, + application_intent_digest, + &result, + ) + .expect("canonical application result digest"); + let receipt = AdmissionCommitReceipt::from_storage( + domain, + object, + Uuid::new_v4(), + [92; 32], + semantic_fingerprint, + AdmissionCommitDigests::new([93; 32], Some(application_result_digest)) + .expect("canonical admission digests"), + Uuid::new_v4(), + ) + .expect("canonical admission receipt"); + assert_eq!( - result.schema(), - AdmissionApplicationResultSchema::invite_claim() + validate_stored_invite_result( + domain, + object, + semantic_fingerprint, + application_intent_digest, + receipt, + &result, + ), + Ok(outcome) ); - assert_eq!(result.decode_invite_claim(), Ok(expected)); assert_eq!( - AdmissionApplicationResult::from_database( - result.schema().type_key().to_vec(), - result.schema().version() as i16, - result.code(), - result.payload().to_vec(), + validate_stored_invite_result( + domain, + other_object, + semantic_fingerprint, + application_intent_digest, + receipt, + &result, ), - Ok(result) + Err(AdmissionCommitError::IntentConflict) ); } - assert_eq!( - AdmissionApplicationResult::from_database( - INVITE_CLAIM_RESULT_TYPE.to_vec(), - 1, - 3, - Vec::new(), - ) - .and_then(|result| result.decode_invite_claim()), - Err(AdmissionCommitError::InvalidRequest) - ); - assert_eq!( - AdmissionApplicationResult::new( - AdmissionApplicationResultSchema::invite_claim(), - 1, - vec![0; MAX_ADMISSION_APPLICATION_RESULT_PAYLOAD_BYTES + 1], - ), - Err(AdmissionCommitError::InvalidRequest) - ); - assert_eq!(AdmissionApplicationResultSchema::new([0; 32], 1), None); - assert_eq!(AdmissionApplicationResultSchema::new([1; 32], 0), None); - assert_eq!( - AdmissionApplicationResult::from_database(vec![1; 31], 1, 1, Vec::new()), - Err(AdmissionCommitError::DependencyUnavailable) - ); } #[test] @@ -3033,32 +4730,24 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .expect("create transaction marker table"); let domain = CommunityId::from_uuid(Uuid::new_v4()); - let target = [101_u8; 32]; + let invite_id = Uuid::new_v4(); + let alternate_invite_id = Uuid::new_v4(); + let specialized_invite_id = Uuid::new_v4(); + let concurrent_invite_id = Uuid::new_v4(); + let target = canonical_invite_resource_key(domain, invite_id); + let alternate_target = canonical_invite_resource_key(domain, alternate_invite_id); + let specialized_target = canonical_invite_resource_key(domain, specialized_invite_id); + let concurrent_target = canonical_invite_resource_key(domain, concurrent_invite_id); let request_fingerprint = [102_u8; 32]; let transport_context = [103_u8; 32]; - let object = - AdmissionObject::new(AdmissionObjectKind::Domain, target).expect("test object"); + let object = canonical_invite_admission_object(target).expect("test object"); sqlx::query("INSERT INTO communities (id,host) VALUES ($1,$2)") .bind(domain.as_uuid()) .bind(format!("admission-{}.example", domain.as_uuid().simple())) .execute(&pool) .await .expect("insert admission community"); - // Migration 0032 is owned and registered by S4. Install only its frozen - // provider-free claim contract in this disposable S3 integration database. - sqlx::query( - "CREATE TABLE authorization_proxy_nonce_claims ( \ - authorization_domain uuid NOT NULL REFERENCES communities(id), \ - claim_kind smallint NOT NULL CHECK (claim_kind = 1), \ - claim_key bytea NOT NULL CHECK (octet_length(claim_key) = 32 \ - AND claim_key <> decode(repeat('00', 32), 'hex')), \ - committed_at timestamptz NOT NULL, retain_until timestamptz NOT NULL, \ - PRIMARY KEY (authorization_domain,claim_kind,claim_key), \ - CHECK (committed_at < retain_until))", - ) - .execute(&pool) - .await - .expect("install frozen S4 replay-claim fixture"); + // Use the replay-claim table installed by the registered migrations. sqlx::query( "INSERT INTO identity_enrollment_policies \ (community_id,policy_revision,enrollment_mode,policy_digest,effective_at) \ @@ -3089,14 +4778,23 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .expect("activate admission invalidation"); let invite_token = [105_u8; 32]; let alternate_invite = [106_u8; 32]; - for token in [invite_token, alternate_invite] { + let specialized_invite = [129_u8; 32]; + let concurrent_invite = [130_u8; 32]; + for (invite_id, token, maximum) in [ + (invite_id, invite_token, 8_i32), + (alternate_invite_id, alternate_invite, 8_i32), + (specialized_invite_id, specialized_invite, 8_i32), + (concurrent_invite_id, concurrent_invite, 2_i32), + ] { sqlx::query( "INSERT INTO relay_invites \ - (community_id,token_hash,max_uses,expires_at,created_by) \ - VALUES ($1,$2,8,transaction_timestamp()+interval '1 hour','operator')", + (id,community_id,token_hash,max_uses,expires_at,created_by) \ + VALUES ($1,$2,$3,$4,transaction_timestamp()+interval '1 hour','operator')", ) + .bind(invite_id) .bind(domain.as_uuid()) .bind(token.as_slice()) + .bind(maximum) .execute(&pool) .await .expect("insert relay invite"); @@ -3111,6 +4809,8 @@ f9hDSYt0+Hl9FGuNjpkgLxIa request_fingerprint, transport_context, ); + let alternate_object = + canonical_invite_admission_object(alternate_target).expect("alternate invite object"); let policy = LocalAuthorizationPolicy::from_database( domain, Uuid::new_v4(), @@ -3158,7 +4858,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await; assert!(matches!( committer.commit(cross_capability).await, - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); let cross_object = enrollment_commit_request( @@ -3177,7 +4877,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await; assert!(matches!( committer.commit(cross_object).await, - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); let ( denied_bindings, @@ -3212,7 +4912,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa denied_events, denied_claims, ), - (0, 0, 0, 0, 0, 0) + (0, 0, 0, 2, 2, 0) ); sqlx::query( @@ -3284,7 +4984,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa failed_claims, failed_acceptances, ), - (0, 0, 0, 0, 0, 0, 0) + (0, 0, 0, 2, 2, 0, 0) ); sqlx::query("DROP TRIGGER s3_reject_policy_acceptance ON join_policy_acceptances") .execute(&pool) @@ -3295,6 +4995,102 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await .expect("remove concrete invite DML failure function"); + for (reason, expected, semantic, replay_key) in [ + ( + AuthorizationReasonCode::IntentConflict, + AdmissionCommitError::RecordedIntentConflict, + [201_u8; 32], + [211_u8; 32], + ), + ( + AuthorizationReasonCode::ReplayRejected, + AdmissionCommitError::RecordedReplayRejected, + [202_u8; 32], + [212_u8; 32], + ), + ] { + let denied_request = enrollment_commit_request( + &pool, + assertion.clone(), + proof.clone(), + policy.clone(), + object, + replay_key, + Box::new( + CanonicalInviteClaimEffect::new(invite_token, None) + .expect("denied replay effect"), + ), + ) + .await + .with_semantic_fingerprint_override(semantic) + .expect("distinct denial semantic fingerprint"); + let denial_operation = denied_request.operation_id(); + let denial_attempt = denied_request.attempt_id; + let denial_correlation = denied_request.denial_correlation_id(); + let denial_request_fingerprint = denied_request.request_fingerprint(); + let denial_semantic = denied_request.semantic_fingerprint(); + let denial_actor = denied_request.denial_actor().expect("sealed denial actor"); + let mut denial_tx = pool.begin().await.expect("begin denied admission"); + sqlx::query("SET CONSTRAINTS ALL DEFERRED") + .execute(&mut *denial_tx) + .await + .expect("defer denial evidence constraints"); + persist_denied_admission( + &mut denial_tx, + DeniedAdmissionRecord { + domain, + operation_id: denial_operation, + attempt_id: denial_attempt, + correlation_id: denial_correlation, + object, + request_fingerprint: denial_request_fingerprint, + semantic_fingerprint: denial_semantic, + actor: denial_actor, + reason, + }, + ) + .await + .expect("persist typed denied admission"); + denial_tx.commit().await.expect("commit denied admission"); + + assert!(matches!( + committer.commit(denied_request).await, + Err(error) if error == expected + )); + let denied_retry = enrollment_commit_request( + &pool, + assertion.clone(), + proof.clone(), + policy.clone(), + object, + replay_key, + Box::new( + CanonicalInviteClaimEffect::new(invite_token, None) + .expect("denied replay retry effect"), + ), + ) + .await + .with_semantic_fingerprint_override(semantic) + .expect("stable denial semantic fingerprint"); + assert!(matches!( + committer.commit(denied_retry).await, + Err(error) if error == expected + )); + let denial_counts: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_id=$2 AND outcome_code=2), \ + (SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND operation_id=$2 AND event_kind=11)", + ) + .bind(domain.as_uuid()) + .bind(denial_operation) + .fetch_one(&pool) + .await + .expect("count exact denied replay evidence"); + assert_eq!(denial_counts, (1, 1)); + } + let first_request = enrollment_commit_request( &pool, assertion.clone(), @@ -3492,7 +5288,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa let mismatched_operation = mismatched_same_epoch.operation_id(); assert!(matches!( committer.commit(mismatched_same_epoch).await, - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); let lower_epoch_policy = LocalAuthorizationPolicy::from_database( @@ -3524,7 +5320,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa let lower_operation = lower_epoch.operation_id(); assert!(matches!( committer.commit(lower_epoch).await, - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); let denied_authority_markers: i64 = sqlx::query_scalar( "SELECT count(*) FROM s3_admission_markers WHERE operation_id IN ($1,$2)", @@ -3578,7 +5374,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa domain, &audit_keys, "audit-capacity-subject", - target, + alternate_target, request_fingerprint, transport_context, ); @@ -3587,7 +5383,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa audit_assertion, audit_proof, policy.clone(), - object, + alternate_object, [114; 32], Box::new( CanonicalInviteClaimEffect::new(alternate_invite, None) @@ -3654,7 +5450,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa domain, &other_keys, "canonical-subject", - target, + alternate_target, request_fingerprint, transport_context, ); @@ -3663,7 +5459,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa other_assertion, other_proof, policy.clone(), - object, + alternate_object, [116; 32], Box::new( CanonicalInviteClaimEffect::new(alternate_invite, None) @@ -3673,15 +5469,376 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await; assert!(matches!( committer.commit(partial_bijection_request).await, - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); + let specialized_body = br#"{"code":"v2.specialized"}"#; + let specialized_keys = Keys::generate(); + let issued_at = Utc::now().timestamp() - 2; + let (specialized_assertion, specialized_proof) = verified_invite_evidence( + domain, + &specialized_keys, + "specialized-invite-subject", + specialized_target, + specialized_body, + issued_at, + ); + let (refreshed_assertion, refreshed_proof) = verified_invite_evidence( + domain, + &specialized_keys, + "specialized-invite-subject", + specialized_target, + specialized_body, + issued_at + 1, + ); + let db = crate::Db::from_pool(pool.clone()); + let terminal_denial_invite_id = Uuid::new_v4(); + let terminal_denial_token = [203_u8; 32]; + let terminal_denial_target = + canonical_invite_resource_key(domain, terminal_denial_invite_id); + sqlx::query( + "INSERT INTO relay_invites \ + (id,community_id,token_hash,max_uses,expires_at,created_by) \ + VALUES ($1,$2,$3,1,transaction_timestamp()-interval '1 second','operator')", + ) + .bind(terminal_denial_invite_id) + .bind(domain.as_uuid()) + .bind(terminal_denial_token.as_slice()) + .execute(&pool) + .await + .expect("insert terminal denial invitation"); + let terminal_denial_resource = db + .resolve_canonical_invite_target(domain, terminal_denial_token) + .await + .expect("resolve terminal denial invitation"); + let terminal_denial_keys = Keys::generate(); + let terminal_denial_body = br#"{"code":"v2.terminal-denial"}"#; + let (terminal_denial_assertion, terminal_denial_proof) = verified_invite_evidence( + domain, + &terminal_denial_keys, + "terminal-denial-subject", + terminal_denial_target, + terminal_denial_body, + issued_at, + ); + let terminal_evidence_before: (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND outcome_code=2), \ + (SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2)", + ) + .bind(domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count denial evidence before terminal invite denial"); + assert_eq!( + db.commit_canonical_invite_claim( + terminal_denial_assertion, + terminal_denial_proof, + terminal_denial_resource, + terminal_denial_token, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Err(AdmissionCommitError::RecordedAuthorizationDenied) + ); + let terminal_evidence_after: (i64, i64, i32, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND outcome_code=2), \ + (SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2), \ + (SELECT use_count FROM relay_invites \ + WHERE community_id=$1 AND token_hash=$2), \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey=$3)", + ) + .bind(domain.as_uuid()) + .bind(terminal_denial_token.as_slice()) + .bind(terminal_denial_keys.public_key().to_hex()) + .fetch_one(&pool) + .await + .expect("read terminal invite denial evidence"); + assert_eq!(terminal_evidence_after.0, terminal_evidence_before.0 + 1); + assert_eq!(terminal_evidence_after.1, terminal_evidence_before.1 + 1); + assert_eq!( + (terminal_evidence_after.2, terminal_evidence_after.3), + (0, 0) + ); + let specialized_resource = db + .resolve_canonical_invite_target(domain, specialized_invite) + .await + .expect("resolve specialized invitation"); + let alternate_resource = db + .resolve_canonical_invite_target(domain, alternate_invite) + .await + .expect("resolve alternate invitation"); + assert_eq!(specialized_resource.fingerprint(), specialized_target); + assert_eq!( + db.commit_canonical_invite_claim( + specialized_assertion.clone(), + specialized_proof.clone(), + alternate_resource, + specialized_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Err(AdmissionCommitError::InvalidRequest) + ); + assert_eq!( + db.commit_canonical_invite_claim( + specialized_assertion.clone(), + specialized_proof.clone(), + specialized_resource, + alternate_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Err(AdmissionCommitError::RecordedAuthorizationDenied) + ); + let ( + mismatched_bindings, + mismatched_members, + mismatched_specialized_uses, + mismatched_alternate_uses, + mismatched_results, + mismatched_events, + mismatched_authorities, + ): (i64, i64, i32, i32, i64, i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM identity_bindings \ + WHERE community_id=$1 AND event_author_pubkey=$2), \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey=$3), \ + (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$4), \ + (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$5), \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_key IN ($6,$7)), \ + (SELECT count(*) FROM authorization_events WHERE community_id=$1 \ + AND operation_id IN (SELECT operation_id FROM authorization_admission_results \ + WHERE community_id=$1 AND object_key IN ($6,$7))), \ + (SELECT count(*) FROM protected_object_authority \ + WHERE community_id=$1 AND object_key IN ($6,$7))", + ) + .bind(domain.as_uuid()) + .bind(specialized_keys.public_key().to_bytes().to_vec()) + .bind(specialized_keys.public_key().to_hex()) + .bind(specialized_invite.as_slice()) + .bind(alternate_invite.as_slice()) + .bind(specialized_target.as_slice()) + .bind(alternate_target.as_slice()) + .fetch_one(&pool) + .await + .expect("read cross-invite denial residue"); + assert_eq!( + ( + mismatched_bindings, + mismatched_members, + mismatched_specialized_uses, + mismatched_alternate_uses, + mismatched_results, + mismatched_events, + mismatched_authorities, + ), + (0, 0, 0, 0, 2, 2, 0) + ); + assert_eq!( + db.commit_canonical_invite_claim( + specialized_assertion.clone(), + specialized_proof.clone(), + specialized_resource, + specialized_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Ok(CanonicalInviteClaimResult::fresh( + CanonicalInviteClaimOutcome::Joined + )) + ); + assert_eq!( + db.commit_canonical_invite_claim( + refreshed_assertion, + refreshed_proof, + specialized_resource, + specialized_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Ok(CanonicalInviteClaimResult::exact_replay( + CanonicalInviteClaimOutcome::Joined + )) + ); + let (specialized_members, specialized_uses, specialized_receipts): (i64, i32, i64) = + sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey=$2), \ + (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$3), \ + (SELECT count(*) FROM authorization_operation_receipts receipt \ + JOIN authorization_admission_results admission \ + ON admission.community_id=receipt.community_id \ + AND admission.operation_id=receipt.operation_id \ + AND admission.request_fingerprint=receipt.request_fingerprint \ + WHERE receipt.community_id=$1 AND admission.object_key=$4)", + ) + .bind(domain.as_uuid()) + .bind(specialized_keys.public_key().to_hex()) + .bind(specialized_invite.as_slice()) + .bind(specialized_target.as_slice()) + .fetch_one(&pool) + .await + .expect("read specialized invite residue"); + assert_eq!( + (specialized_members, specialized_uses, specialized_receipts), + (1, 1, 2) + ); + + let already_member_body = br#"{"code":"v2.already-member"}"#; + let already_member_keys = Keys::generate(); + let (already_member_assertion, already_member_proof) = verified_invite_evidence( + domain, + &already_member_keys, + "already-member-subject", + alternate_target, + already_member_body, + issued_at, + ); + let (refreshed_member_assertion, refreshed_member_proof) = verified_invite_evidence( + domain, + &already_member_keys, + "already-member-subject", + alternate_target, + already_member_body, + issued_at + 1, + ); + sqlx::query( + "INSERT INTO relay_members (community_id,pubkey,role,added_by) \ + VALUES ($1,$2,'member','test')", + ) + .bind(domain.as_uuid()) + .bind(already_member_keys.public_key().to_hex()) + .execute(&pool) + .await + .expect("insert existing invite member"); + assert_eq!( + db.commit_canonical_invite_claim( + already_member_assertion, + already_member_proof, + alternate_resource, + alternate_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Ok(CanonicalInviteClaimResult::fresh( + CanonicalInviteClaimOutcome::AlreadyMember + )) + ); + assert_eq!( + db.commit_canonical_invite_claim( + refreshed_member_assertion, + refreshed_member_proof, + alternate_resource, + alternate_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ) + .await, + Ok(CanonicalInviteClaimResult::exact_replay( + CanonicalInviteClaimOutcome::AlreadyMember + )) + ); + let (already_members, already_uses, already_results): (i64, i32, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey=$2), \ + (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$3), \ + (SELECT count(*) FROM authorization_admission_results \ + WHERE community_id=$1 AND object_key=$4)", + ) + .bind(domain.as_uuid()) + .bind(already_member_keys.public_key().to_hex()) + .bind(alternate_invite.as_slice()) + .bind(alternate_target.as_slice()) + .fetch_one(&pool) + .await + .expect("read already-member replay residue"); + assert_eq!((already_members, already_uses, already_results), (1, 0, 2)); + + let concurrent_body = br#"{"code":"v2.concurrent"}"#; + let concurrent_first_keys = Keys::generate(); + let concurrent_second_keys = Keys::generate(); + let (concurrent_first_assertion, concurrent_first_proof) = verified_invite_evidence( + domain, + &concurrent_first_keys, + "concurrent-first-subject", + concurrent_target, + concurrent_body, + issued_at, + ); + let (concurrent_second_assertion, concurrent_second_proof) = verified_invite_evidence( + domain, + &concurrent_second_keys, + "concurrent-second-subject", + concurrent_target, + concurrent_body, + issued_at, + ); + let concurrent_resource = db + .resolve_canonical_invite_target(domain, concurrent_invite) + .await + .expect("resolve concurrent invitation"); + let first_claim = db.commit_canonical_invite_claim( + concurrent_first_assertion, + concurrent_first_proof, + concurrent_resource, + concurrent_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ); + let second_claim = db.commit_canonical_invite_claim( + concurrent_second_assertion, + concurrent_second_proof, + concurrent_resource, + concurrent_invite, + None, + Arc::new(TestInviteVerifierRechecker), + ); + let (first_claim, second_claim) = tokio::join!(first_claim, second_claim); + assert_eq!( + first_claim, + Ok(CanonicalInviteClaimResult::fresh( + CanonicalInviteClaimOutcome::Joined + )) + ); + assert_eq!( + second_claim, + Ok(CanonicalInviteClaimResult::fresh( + CanonicalInviteClaimOutcome::Joined + )) + ); + let (concurrent_members, concurrent_uses): (i64, i32) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey IN ($2,$3)), \ + (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$4)", + ) + .bind(domain.as_uuid()) + .bind(concurrent_first_keys.public_key().to_hex()) + .bind(concurrent_second_keys.public_key().to_hex()) + .bind(concurrent_invite.as_slice()) + .fetch_one(&pool) + .await + .expect("read concurrent invite result"); + assert_eq!((concurrent_members, concurrent_uses), (2, 2)); + let stale_keys = Keys::generate(); let (stale_assertion, stale_proof) = verified_enrollment_evidence( domain, &stale_keys, "stale-policy-subject", - target, + alternate_target, request_fingerprint, transport_context, ); @@ -3690,7 +5847,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa stale_assertion.clone(), stale_proof.clone(), policy.clone(), - object, + alternate_object, [117; 32], Box::new( CanonicalInviteClaimEffect::new(alternate_invite, None) @@ -3730,7 +5887,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .expect("commit concurrent restrictive policy"); assert!(matches!( stale_commit.await.expect("join serialized stale admission"), - Err(AdmissionCommitError::AuthorizationDenied) + Err(AdmissionCommitError::RecordedAuthorizationDenied) )); assert!(matches!( crate::identity_enrollment::prepare_direct_enrollment( @@ -3751,7 +5908,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa (SELECT use_count FROM relay_invites WHERE community_id=$1 AND token_hash=$2), \ (SELECT count(*) FROM authorization_operation_receipts WHERE community_id=$1 AND operation_id IN ($3,$4)), \ (SELECT count(*) FROM authorization_events WHERE community_id=$1 AND operation_id IN ($3,$4)), \ - (SELECT count(*) FROM protected_object_authority WHERE community_id=$1 AND object_kind=1 AND object_key=$5)", + (SELECT count(*) FROM protected_object_authority WHERE community_id=$1 AND object_kind=9 AND object_key=$5)", ) .bind(domain.as_uuid()) .bind(invite_token.as_slice()) @@ -3770,8 +5927,9 @@ f9hDSYt0+Hl9FGuNjpkgLxIa event_count, authority_count, ), - (1, 1, 1, 2, 2, 1) + (5, 5, 1, 2, 2, 1) ); + pool.close().await; sqlx::query(sqlx::AssertSqlSafe(format!( "DROP DATABASE {database_name}" @@ -3812,13 +5970,19 @@ f9hDSYt0+Hl9FGuNjpkgLxIa let actor = "11".repeat(32); let rollback_actor = "22".repeat(32); let capacity_actor = "33".repeat(32); + let banned_actor = "44".repeat(32); + let expired_actor = "55".repeat(32); let joined_token = [41_u8; 32]; let rollback_token = [42_u8; 32]; let capacity_token = [43_u8; 32]; + let banned_token = [44_u8; 32]; + let expired_token = [45_u8; 32]; for (token, maximum) in [ (joined_token, 2_i32), (rollback_token, 1_i32), (capacity_token, 1_i32), + (banned_token, 1_i32), + (expired_token, 1_i32), ] { sqlx::query( "INSERT INTO relay_invites \ @@ -3832,6 +5996,44 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await .expect("insert invite"); } + sqlx::query( + "UPDATE relay_invites SET expires_at=clock_timestamp()-INTERVAL '1 second' \ + WHERE community_id=$1 AND token_hash=$2", + ) + .bind(domain.as_uuid()) + .bind(expired_token.as_slice()) + .execute(&pool) + .await + .expect("expire denial fixture"); + crate::moderation::ban_member( + &pool, + domain, + &hex::decode(&banned_actor).expect("banned actor bytes"), + &[99_u8; 32], + Some("canonical invite denial fixture"), + None, + ) + .await + .expect("ban denial fixture"); + let invite_resource = |token: [u8; 32]| { + let pool = pool.clone(); + async move { + let invite_id: Uuid = sqlx::query_scalar( + "SELECT id FROM relay_invites WHERE community_id=$1 AND token_hash=$2", + ) + .bind(domain.as_uuid()) + .bind(token.as_slice()) + .fetch_one(&pool) + .await + .expect("resolve invite resource"); + canonical_invite_resource_key(domain, invite_id) + } + }; + let joined_resource = invite_resource(joined_token).await; + let rollback_resource = invite_resource(rollback_token).await; + let capacity_resource = invite_resource(capacity_token).await; + let banned_resource = invite_resource(banned_token).await; + let expired_resource = invite_resource(expired_token).await; let policy_version = "a1".repeat(32); let effect = CanonicalInviteClaimEffect::new(joined_token, Some(&policy_version)) @@ -3844,6 +6046,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa joined_token, Some(&policy_version), effect.intent_digest(), + joined_resource, ) .await .expect("apply joined effect"); @@ -3861,6 +6064,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa joined_token, Some(&policy_version), effect.intent_digest(), + joined_resource, ) .await .expect("apply already-member effect"); @@ -3882,6 +6086,44 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .expect("read joined use count"); assert_eq!(joined_count, 1); + for (denied_actor, denied_token, denied_resource) in [ + (&banned_actor, banned_token, banned_resource), + (&expired_actor, expired_token, expired_resource), + ] { + let denied_effect = + CanonicalInviteClaimEffect::new(denied_token, None).expect("denied effect"); + let mut denied_tx = pool.begin().await.expect("begin denied invite effect"); + assert_eq!( + apply_canonical_invite_claim_tx( + &mut denied_tx, + domain, + denied_actor, + denied_token, + None, + denied_effect.intent_digest(), + denied_resource, + ) + .await, + Err(AdmissionCommitError::AuthorizationDenied) + ); + denied_tx.rollback().await.expect("roll back denied invite"); + } + let (denied_members, denied_uses): (i64, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM relay_members WHERE community_id=$1 AND pubkey IN ($2,$3)), \ + (SELECT sum(use_count) FROM relay_invites \ + WHERE community_id=$1 AND token_hash IN ($4,$5))", + ) + .bind(domain.as_uuid()) + .bind(&banned_actor) + .bind(&expired_actor) + .bind(banned_token.as_slice()) + .bind(expired_token.as_slice()) + .fetch_one(&pool) + .await + .expect("read denied invite residue"); + assert_eq!((denied_members, denied_uses), (0, 0)); + let rollback_effect = CanonicalInviteClaimEffect::new(rollback_token, None).expect("rollback effect"); let mut rollback_tx = pool.begin().await.expect("begin rollback effect"); @@ -3893,6 +6135,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa rollback_token, Some("invalid-policy-version"), rollback_effect.intent_digest(), + rollback_resource, ) .await .expect_err("policy persistence failure must abort the effect"), @@ -3924,6 +6167,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa capacity_token, None, capacity_effect.intent_digest(), + capacity_resource, ) .await .expect("stage capacity rollback effect"); @@ -3979,8 +6223,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa let semantic_fingerprint = [53_u8; 32]; let application_intent = [56_u8; 32]; let application_effect = [57_u8; 32]; - let object = - AdmissionObject::new(AdmissionObjectKind::Domain, [58; 32]).expect("test object"); + let object = canonical_invite_admission_object([58; 32]).expect("test object"); let persisted_result = AdmissionApplicationResult::invite_claim(CanonicalInviteClaimOutcome::AlreadyMember); let persisted_result_digest = canonical_admission_result_digest( @@ -4070,7 +6313,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .expect_err("persisted application result is immutable"); let mut read_tx = pool.begin().await.expect("begin replay read"); - let (replayed_receipt, replayed_result) = read_existing_receipt( + let replayed = read_existing_receipt( &mut read_tx, ExistingAdmissionLookup { domain, @@ -4085,6 +6328,10 @@ f9hDSYt0+Hl9FGuNjpkgLxIa .await .expect("read exact replay") .expect("persisted replay exists"); + let ExistingAdmissionRecord::Allowed(replayed) = replayed else { + panic!("persisted allowed admission replayed as a denial") + }; + let (replayed_receipt, replayed_result) = *replayed; assert_eq!(replayed_receipt.authorization_domain(), domain); assert_eq!(replayed_receipt.object(), object); assert_eq!( @@ -4097,7 +6344,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa CanonicalInviteClaimOutcome::AlreadyMember )) ); - assert_eq!( + assert!(matches!( read_existing_receipt( &mut read_tx, ExistingAdmissionLookup { @@ -4114,8 +6361,8 @@ f9hDSYt0+Hl9FGuNjpkgLxIa ) .await, Err(AdmissionCommitError::IntentConflict) - ); - assert_eq!( + )); + assert!(matches!( read_existing_receipt( &mut read_tx, ExistingAdmissionLookup { @@ -4133,7 +6380,7 @@ f9hDSYt0+Hl9FGuNjpkgLxIa ) .await, Err(AdmissionCommitError::IntentConflict) - ); + )); read_tx.rollback().await.expect("close replay read"); pool.close().await; } diff --git a/crates/buzz-db/src/authorization_events.rs b/crates/buzz-db/src/authorization_events.rs index bc2aa5a1b5..1fa5e3fd1c 100644 --- a/crates/buzz-db/src/authorization_events.rs +++ b/crates/buzz-db/src/authorization_events.rs @@ -9,7 +9,7 @@ use std::{collections::HashSet, fmt}; use buzz_auth::{ operator_lifecycle::{VerifiedOperatorLifecycleGrant, VerifiedProvisionBindingIntent}, - AuthorizationEventCapacityPolicy, FinalizedAuthContext, ProofTransport, + AuthorizationEventCapacityPolicy, FinalizedAuthContext, PreparedAuthorization, ProofTransport, VerifiedFederatedAssertion, VerifiedNostrProof, }; use buzz_core::{CanonicalCurrentBindingEvidence, CommunityId}; @@ -23,6 +23,49 @@ use crate::{Db, DbError, Result}; const MAX_EVENT_PAGE: u16 = 1_000; +/// Closed protected surfaces sharing the bounded denial bucket family. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i16)] +pub enum ProtectedDenialSurface { + /// Invite claim admission. + InviteClaim = 2, + /// Moderation command admission. + Moderation = 3, +} + +/// Closed protected actions represented by denial evidence. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i16)] +pub enum ProtectedDenialAction { + /// Claim one relay invite. + InviteClaim = 1, + /// Apply one moderation command. + ModerationCommand = 2, +} + +/// Coarse durable denial reasons. These values are operational evidence, not +/// public error detail, and intentionally retain no request coordinates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(i16)] +pub enum ProtectedDenialReason { + /// Required command proof or assertion was absent. + MissingProof = 1, + /// A supplied command proof or assertion was malformed or unverifiable. + InvalidProof = 2, + /// The configured admission mode closed the protected surface. + ModeDenied = 3, + /// Current authority or policy did not permit the operation. + AuthorizationDenied = 4, + /// The protected resource was expired, exhausted, banned, or revoked. + ResourceDenied = 5, + /// Commit-time policy or authority state superseded preparation. + PolicySuperseded = 6, + /// Replay or intent identity conflicted with retained state. + ReplayConflict = 7, + /// A required audit, database, or verifier dependency was unavailable. + DependencyUnavailable = 8, +} + /// Closed operation kinds stored in the single canonical receipt table. #[derive(Clone, Copy, Debug, PartialEq, Eq)] #[repr(i16)] @@ -248,6 +291,8 @@ pub enum AuthorizationReasonCode { IntentConflict = 15, /// Explicit withdrawal. Withdrawn = 16, + /// A replay identity was rejected rather than accepted as an exact replay. + ReplayRejected = 17, } /// Pseudonymous actor classification derived only from sealed or @@ -315,6 +360,23 @@ pub struct AuthorizationEventActor { } impl AuthorizationEventActor { + /// Derive direct/delegated attribution from one sealed prepared route. + pub(crate) fn from_prepared_authorization(prepared: &PreparedAuthorization) -> Result { + let snapshot = prepared.recheck_request().lease_dependencies(); + let (_, domain) = snapshot.identity(); + let (_, actor, owner) = snapshot.authority(); + let relationship = snapshot + .delegated_relationship() + .map(|(id, revision, _)| (id, revision)); + actor_from_route_coordinates( + domain, + actor.to_bytes(), + owner.map(|value| value.to_bytes()), + snapshot.binding(), + relationship, + ) + } + /// Derive operator attribution only from one origin-sealed lifecycle /// grant. Callers cannot select the actor class or fingerprint. pub fn from_verified_operator_grant(grant: &VerifiedOperatorLifecycleGrant) -> Result { @@ -468,7 +530,7 @@ impl AuthorizationEventActor { self.kind } - const fn fingerprint(&self) -> Option<[u8; 32]> { + pub(crate) const fn fingerprint(&self) -> Option<[u8; 32]> { self.fingerprint } @@ -1377,6 +1439,42 @@ impl Db { }) } + /// Increment one fixed-cardinality, redacted protected-denial bucket. + /// + /// This uses an independent autocommit statement, so a denial remains + /// observable after an admission transaction rolls back. The bucket stores + /// only closed coordinates, a bounded count, and authoritative timestamps. + pub async fn record_protected_denial_bucket( + &self, + community_id: CommunityId, + surface: ProtectedDenialSurface, + reason: ProtectedDenialReason, + action: ProtectedDenialAction, + ) -> Result { + if community_id.as_uuid().is_nil() { + return Err(DbError::InvalidData( + "protected denial bucket coordinate is invalid".to_owned(), + )); + } + let count: i64 = + sqlx::query_scalar("SELECT authorization_denial_bucket_record_v1($1,$2,$3,$4)") + .bind(community_id.as_uuid()) + .bind(surface as i16) + .bind(reason as i16) + .bind(action as i16) + .fetch_one(&self.pool) + .await?; + nonnegative(count).and_then(|value| { + if value == 0 { + Err(DbError::InvalidData( + "protected denial bucket count is invalid".to_owned(), + )) + } else { + Ok(value) + } + }) + } + /// Read immutable-capacity health for readiness and operator controls. pub async fn authorization_event_capacity_health( &self, @@ -1684,6 +1782,7 @@ mod tests { fn page_limits_are_bounded() { assert_eq!(MAX_EVENT_PAGE, 1_000); assert_eq!(AuthorizationAuditFailureCode::CapacityExhausted as i16, 1); + assert_eq!(AuthorizationReasonCode::ReplayRejected as i16, 17); } #[test] diff --git a/crates/buzz-db/src/migration.rs b/crates/buzz-db/src/migration.rs index fe3a04b620..6a74cee6fb 100644 --- a/crates/buzz-db/src/migration.rs +++ b/crates/buzz-db/src/migration.rs @@ -561,7 +561,7 @@ mod tests { let mut migrations: Vec<_> = MIGRATOR.iter().collect(); migrations.sort_by_key(|migration| migration.version); - assert_eq!(migrations.len(), 33); + assert_eq!(migrations.len(), 34); assert_eq!(migrations[0].version, 1); assert_eq!(&*migrations[0].description, "initial schema"); assert!(migrations[0] @@ -1022,6 +1022,22 @@ mod tests { "migration 0034 missing operator pre-auth audit surface: {required}", ); } + + assert_eq!(migrations[33].version, 36); + let invitation_object_kind = migrations[33].sql.as_str(); + for required in [ + "authorization_admission_results_object_kind_check", + "authorization_authority_epochs_object_kind_check", + "protected_object_authority_object_kind_check", + "object_kind IN (1, 2, 3, 4, 5, 6, 9)", + "NOT VALID", + "VALIDATE CONSTRAINT", + ] { + assert!( + invitation_object_kind.contains(required), + "migration 0036 missing Invitation object-kind closure: {required}", + ); + } } #[test] @@ -1264,7 +1280,7 @@ mod tests { run_migrations(&pool) .await .expect("retry succeeds after operator repair"); - assert_eq!(applied_versions(&pool).await.last().copied(), Some(34)); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(36)); } #[tokio::test] @@ -1397,4 +1413,6 @@ mod tests { mod nip_fi_authorization_tests; #[path = "migration_nip_fi_tests.rs"] mod nip_fi_direct_final_tests; + #[path = "migration_nip_fi_invitation_object_tests.rs"] + mod nip_fi_invitation_object_tests; } diff --git a/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs b/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs new file mode 100644 index 0000000000..550d4238be --- /dev/null +++ b/crates/buzz-db/src/migration/tests/migration_nip_fi_invitation_object_tests.rs @@ -0,0 +1,274 @@ +use super::*; + +use sqlx::{PgConnection, PgPool}; +use uuid::Uuid; + +const INVITATION_OBJECT_MIGRATION: &str = + include_str!("../../../../../migrations/0036_nip_fi_invitation_object_kind.sql"); +const DESIRED_SCHEMA: &str = include_str!("../../../../../schema/schema.sql"); +const OBJECT_KIND_EXPRESSION: &str = "object_kind IN (1, 2, 3, 4, 5, 6, 9)"; +const OBJECT_KIND_CHECKS: [(&str, &str); 3] = [ + ( + "authorization_admission_results", + "authorization_admission_results_object_kind_check", + ), + ( + "authorization_authority_epochs", + "authorization_authority_epochs_object_kind_check", + ), + ( + "protected_object_authority", + "protected_object_authority_object_kind_check", + ), +]; + +#[derive(Clone, Copy)] +enum ProbeTable { + AdmissionResults, + AuthorityEpochs, + ProtectedAuthority, +} + +#[test] +fn invitation_object_migration_and_desired_schema_are_exact() { + let executable = INVITATION_OBJECT_MIGRATION + .lines() + .map(|line| line.split_once("--").map_or(line, |(sql, _)| sql)) + .collect::>() + .join("\n"); + + assert_eq!(executable.matches("DROP CONSTRAINT").count(), 3); + assert_eq!(executable.matches("ADD CONSTRAINT").count(), 3); + assert_eq!(executable.matches("NOT VALID").count(), 3); + assert_eq!(executable.matches("VALIDATE CONSTRAINT").count(), 3); + assert_eq!(executable.matches(OBJECT_KIND_EXPRESSION).count(), 3); + for forbidden in [ + "CREATE TABLE", + "DROP TABLE", + "ADD COLUMN", + "DROP COLUMN", + "ALTER COLUMN", + "INSERT INTO", + "UPDATE ", + "DELETE FROM", + "TRUNCATE", + ] { + assert!( + !executable.contains(forbidden), + "migration 0036 must change only the three object-kind checks: {forbidden}", + ); + } + + for (table, constraint) in OBJECT_KIND_CHECKS { + let drop = format!("ALTER TABLE {table}\n DROP CONSTRAINT {constraint};"); + let add = format!( + "ALTER TABLE {table}\n ADD CONSTRAINT {constraint}\n CHECK ({OBJECT_KIND_EXPRESSION}) NOT VALID;" + ); + let validate = format!("ALTER TABLE {table}\n VALIDATE CONSTRAINT {constraint};"); + assert_eq!(executable.matches(&drop).count(), 1, "missing {drop}"); + assert_eq!(executable.matches(&add).count(), 1, "missing {add}"); + assert_eq!( + executable.matches(&validate).count(), + 1, + "missing {validate}" + ); + + let desired = format!("CONSTRAINT {constraint} CHECK ({OBJECT_KIND_EXPRESSION})"); + assert_eq!( + DESIRED_SCHEMA.matches(&desired).count(), + 1, + "desired schema must carry exactly one {constraint}", + ); + } + + for rejected in [7_i16, 8, 10] { + assert!( + !OBJECT_KIND_EXPRESSION + .split(|character: char| !character.is_ascii_digit()) + .filter_map(|value| value.parse::().ok()) + .any(|value| value == rejected), + "unallocated object kind {rejected} must remain rejected", + ); + } +} + +async fn assert_exact_catalog(pool: &PgPool) { + let rows: Vec<(String, String, bool)> = sqlx::query_as( + "SELECT c.conrelid::regclass::text,pg_get_constraintdef(c.oid,true),c.convalidated \ + FROM pg_constraint c \ + WHERE c.connamespace='public'::regnamespace AND c.conname=ANY($1) \ + ORDER BY c.conrelid::regclass::text", + ) + .bind( + OBJECT_KIND_CHECKS + .iter() + .map(|(_, constraint)| *constraint) + .collect::>(), + ) + .fetch_all(pool) + .await + .expect("read Invitation object-kind constraint catalog"); + + assert_eq!(rows.len(), OBJECT_KIND_CHECKS.len()); + assert!(rows.iter().all(|(_, _, validated)| *validated)); + assert!(rows.windows(2).all(|pair| pair[0].1 == pair[1].1)); + let codes = rows[0] + .1 + .split(|character: char| !character.is_ascii_digit()) + .filter_map(|value| value.parse::().ok()) + .collect::>(); + assert_eq!(codes, vec![1, 2, 3, 4, 5, 6, 9]); +} + +async fn create_probe_tables(connection: &mut PgConnection) { + sqlx::raw_sql( + "CREATE TEMP TABLE invitation_admission_probe \ + (LIKE authorization_admission_results INCLUDING DEFAULTS INCLUDING CONSTRAINTS); \ + CREATE TEMP TABLE invitation_epoch_probe \ + (LIKE authorization_authority_epochs INCLUDING DEFAULTS INCLUDING CONSTRAINTS); \ + CREATE TEMP TABLE invitation_authority_probe \ + (LIKE protected_object_authority INCLUDING DEFAULTS INCLUDING CONSTRAINTS);", + ) + .execute(&mut *connection) + .await + .expect("clone object-kind checks into isolated probe tables"); +} + +async fn drop_probe_tables(connection: &mut PgConnection) { + sqlx::raw_sql( + "DROP TABLE invitation_admission_probe; \ + DROP TABLE invitation_epoch_probe; \ + DROP TABLE invitation_authority_probe;", + ) + .execute(&mut *connection) + .await + .expect("drop isolated object-kind probe tables"); +} + +async fn probe_kind( + connection: &mut PgConnection, + table: ProbeTable, + kind: i16, +) -> std::result::Result<(), sqlx::Error> { + let marker = u8::try_from(kind).unwrap_or_default().saturating_add(1); + match table { + ProbeTable::AdmissionResults => { + sqlx::query( + "INSERT INTO invitation_admission_probe \ + (community_id,operation_id,request_fingerprint,semantic_fingerprint, \ + object_kind,object_key) VALUES ($1,$2,$3,$4,$5,$6)", + ) + .bind(Uuid::new_v4()) + .bind(Uuid::new_v4()) + .bind(vec![marker; 32]) + .bind(vec![marker.saturating_add(1); 32]) + .bind(kind) + .bind(vec![marker.saturating_add(2); 32]) + .execute(&mut *connection) + .await?; + } + ProbeTable::AuthorityEpochs => { + sqlx::query( + "INSERT INTO invitation_epoch_probe \ + (community_id,object_kind,object_key,authority_epoch,fence,operation_id, \ + request_fingerprint) VALUES ($1,$2,$3,1,$4,$5,$6)", + ) + .bind(Uuid::new_v4()) + .bind(kind) + .bind(vec![marker; 32]) + .bind(vec![marker.saturating_add(1); 32]) + .bind(Uuid::new_v4()) + .bind(vec![marker.saturating_add(2); 32]) + .execute(&mut *connection) + .await?; + } + ProbeTable::ProtectedAuthority => { + sqlx::query( + "INSERT INTO invitation_authority_probe \ + (community_id,object_kind,object_key,capability,actor_pubkey,binding_id, \ + binding_version,policy_revision,invalidation_generation,authority_epoch, \ + fence,issued_at,expires_at,operation_id,request_fingerprint) \ + VALUES ($1,$2,$3,1,$4,$5,1,1,0,1,$6,transaction_timestamp(), \ + transaction_timestamp()+INTERVAL '5 minutes',$7,$8)", + ) + .bind(Uuid::new_v4()) + .bind(kind) + .bind(vec![marker; 32]) + .bind(vec![marker.saturating_add(1); 32]) + .bind(Uuid::new_v4()) + .bind(vec![marker.saturating_add(2); 32]) + .bind(Uuid::new_v4()) + .bind(vec![marker.saturating_add(3); 32]) + .execute(&mut *connection) + .await?; + } + } + Ok(()) +} + +async fn assert_kind_behavior(connection: &mut PgConnection, invitation_is_allowed: bool) { + for table in [ + ProbeTable::AdmissionResults, + ProbeTable::AuthorityEpochs, + ProbeTable::ProtectedAuthority, + ] { + let invitation = probe_kind(connection, table, 9).await; + assert_eq!( + invitation.is_ok(), + invitation_is_allowed, + "Invitation object kind has the wrong admission behavior: {invitation:?}", + ); + for kind in [7, 8, 10] { + let error = probe_kind(connection, table, kind) + .await + .expect_err("unallocated object kind must remain rejected"); + assert_eq!( + error + .as_database_error() + .and_then(|database_error| database_error.code().map(|code| code.into_owned())) + .as_deref(), + Some("23514"), + ); + } + } +} + +#[tokio::test] +#[ignore = "requires a dedicated disposable Postgres database"] +async fn invitation_object_kind_is_exact_on_brownfield_and_fresh_catalogs() { + let pool = connect_test_pool().await; + reset_public_schema(&pool).await; + MIGRATOR + .run_to(34, &pool) + .await + .expect("apply brownfield migrations through 0034"); + let mut connection = pool + .acquire() + .await + .expect("acquire brownfield probe session"); + create_probe_tables(&mut connection).await; + assert_kind_behavior(&mut connection, false).await; + drop_probe_tables(&mut connection).await; + drop(connection); + + MIGRATOR + .run_to(36, &pool) + .await + .expect("upgrade brownfield catalog through 0036"); + assert_eq!(applied_versions(&pool).await.last().copied(), Some(36)); + assert_exact_catalog(&pool).await; + let mut connection = pool + .acquire() + .await + .expect("acquire upgraded probe session"); + create_probe_tables(&mut connection).await; + assert_kind_behavior(&mut connection, true).await; + drop_probe_tables(&mut connection).await; + drop(connection); + + reset_public_schema(&pool).await; + run_migrations(&pool) + .await + .expect("apply Invitation object kind on a fresh database"); + assert_exact_catalog(&pool).await; +} diff --git a/crates/buzz-db/src/migration/tests/migration_nip_fi_operator_audit_tests.rs b/crates/buzz-db/src/migration/tests/migration_nip_fi_operator_audit_tests.rs index 857b891449..a99c2efacb 100644 --- a/crates/buzz-db/src/migration/tests/migration_nip_fi_operator_audit_tests.rs +++ b/crates/buzz-db/src/migration/tests/migration_nip_fi_operator_audit_tests.rs @@ -13,6 +13,11 @@ fn operator_audit_upgrade_is_bounded_and_uses_constant_time_capacity() { "recovery_generation", "authorization_event_capacity_before_insert_v2", "authorization_operator_denial_buckets", + "authorization_denial_bucket_record_v1", + "surface_kind", + "lifetime_count", + "clock_timestamp()", + "at most 960 rows", "selected_slot := (generation % 12)::SMALLINT", "authorization_operator_denial_bucket_record_v1", ] { @@ -25,6 +30,20 @@ fn operator_audit_upgrade_is_bounded_and_uses_constant_time_capacity() { assert!( !OPERATOR_AUDIT_SQL.contains("CREATE TABLE authorization_operator_preauth_denial_events") ); + for sensitive in [ + "actor_fingerprint", + "request_fingerprint", + "subject_fingerprint", + "canonical_envelope", + "token_hash", + ] { + let bucket_definition = OPERATOR_AUDIT_SQL + .split("CREATE TABLE authorization_operator_denial_buckets") + .nth(1) + .and_then(|sql| sql.split(");").next()) + .expect("bounded denial bucket definition"); + assert!(!bucket_definition.contains(sensitive)); + } } #[tokio::test] @@ -147,6 +166,82 @@ async fn operator_0034_reserves_restrictive_audit_and_recovers_by_generation() { .await .expect("count bounded denial rows"); assert_eq!(bucket_rows, 1); + + for expected_count in 1_i64..=4 { + let retained: i64 = + sqlx::query_scalar("SELECT authorization_denial_bucket_record_v1($1,$2,$3,$4)") + .bind(community_id) + .bind(2_i16) + .bind(1_i16) + .bind(1_i16) + .fetch_one(&pool) + .await + .expect("record bounded invite denial"); + assert_eq!(retained, expected_count); + } + let protected_bucket: (i64, i64, i16) = sqlx::query_as( + "SELECT denial_count,lifetime_count,surface_kind \ + FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=2 AND denial_class=1 AND action_kind=1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read bounded invite denial"); + assert_eq!(protected_bucket, (4, 4, 2)); + let invalid_cross_product = + sqlx::query_scalar::<_, i64>("SELECT authorization_denial_bucket_record_v1($1,$2,$3,$4)") + .bind(community_id) + .bind(2_i16) + .bind(1_i16) + .bind(2_i16) + .fetch_one(&pool) + .await + .expect_err("invite surface rejects moderation action"); + assert_eq!( + invalid_cross_product + .as_database_error() + .and_then(|error| error.code().map(|code| code.into_owned())), + Some("23514".to_owned()) + ); + + let concurrent: Vec<_> = (0..32) + .map(|_| { + let pool = pool.clone(); + tokio::spawn(async move { + sqlx::query_scalar::<_, i64>( + "SELECT authorization_denial_bucket_record_v1($1,$2,$3,$4)", + ) + .bind(community_id) + .bind(2_i16) + .bind(1_i16) + .bind(1_i16) + .fetch_one(&pool) + .await + }) + }) + .collect(); + for write in concurrent { + assert!(write.await.expect("join concurrent denial writer").is_ok()); + } + let concurrent_counts: (i64, i64) = sqlx::query_as( + "SELECT denial_count,lifetime_count \ + FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=2 AND denial_class=1 AND action_kind=1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("read concurrent denial count"); + assert_eq!(concurrent_counts, (36, 36)); + let total_bucket_rows: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_operator_denial_buckets WHERE community_id=$1", + ) + .bind(community_id) + .fetch_one(&pool) + .await + .expect("count shared bounded denial rows"); + assert_eq!(total_bucket_rows, 2); } async fn insert_authenticated_event( diff --git a/crates/buzz-db/src/migration/tests/migration_nip_fi_tests.rs b/crates/buzz-db/src/migration/tests/migration_nip_fi_tests.rs index 5f987e88a5..8fc21065cb 100644 --- a/crates/buzz-db/src/migration/tests/migration_nip_fi_tests.rs +++ b/crates/buzz-db/src/migration/tests/migration_nip_fi_tests.rs @@ -389,6 +389,42 @@ async fn insert_binding( .expect("insert immutable binding generation") } +async fn insert_lifecycle_event( + connection: &mut PgConnection, + community_id: Uuid, + operation_id: Uuid, + fingerprint: &[u8], + outcome_code: i16, + envelope_byte: u8, +) { + sqlx::query( + "INSERT INTO authorization_events \ + (community_id,event_id,event_kind,outcome_code,reason_code,actor_kind, \ + actor_fingerprint,operation_id,request_fingerprint,correlation_id,attempt_id, \ + occurred_at,canonical_envelope,envelope_digest) \ + SELECT $1,$2,CASE receipt.operation_kind \ + WHEN 1 THEN 1 WHEN 2 THEN 1 WHEN 3 THEN 6 WHEN 4 THEN 7 \ + WHEN 5 THEN 2 WHEN 6 THEN 3 WHEN 7 THEN 4 WHEN 8 THEN 5 WHEN 9 THEN 8 \ + END,$3,1,1,$4,$5,$6,$7,$8,transaction_timestamp(),$9,$10 \ + FROM authorization_operation_receipts receipt \ + WHERE receipt.community_id=$1 AND receipt.operation_id=$5 \ + AND receipt.operation_kind BETWEEN 1 AND 9", + ) + .bind(community_id) + .bind(Uuid::new_v4()) + .bind(outcome_code) + .bind(vec![213_u8; 32]) + .bind(operation_id) + .bind(fingerprint) + .bind(Uuid::new_v4()) + .bind(Uuid::new_v4()) + .bind(vec![envelope_byte; 32]) + .bind(vec![214_u8; 32]) + .execute(&mut *connection) + .await + .expect("insert lifecycle audit event"); +} + #[allow(clippy::too_many_arguments)] async fn insert_history( connection: &mut PgConnection, @@ -431,9 +467,19 @@ async fn insert_history( .bind(operation_id) .bind(fingerprint) .bind(vec![212_u8; 32]) - .execute(connection) + .execute(&mut *connection) .await .expect("insert canonical lifecycle transition"); + + insert_lifecycle_event( + connection, + community_id, + operation_id, + fingerprint, + outcome_code, + transition_kind as u8, + ) + .await; } #[allow(clippy::too_many_arguments)] @@ -728,6 +774,8 @@ async fn assert_race_loser_has_no_residue(pool: &PgPool, community_id: Uuid, ope (SELECT count(*) FROM identity_lifecycle_selectors \ WHERE community_id=$1 AND selected_by_operation_id=$2) + \ (SELECT count(*) FROM identity_lifecycle_selector_consumptions \ + WHERE community_id=$1 AND operation_id=$2) + \ + (SELECT count(*) FROM authorization_events \ WHERE community_id=$1 AND operation_id=$2)", ) .bind(community_id) @@ -1193,6 +1241,15 @@ async fn nip_fi_direct_final_catalog_and_behavior() { .expect_err("V1 hard audit-capacity ceiling must reject oversized policy"); assert_eq!(constraint(&error), Some(expected_constraint)); } + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id,max_events_per_domain,max_bytes_per_domain,max_envelope_bytes) \ + VALUES ($1,10000,16777216,16384)", + ) + .bind(community_id) + .execute(&pool) + .await + .expect("install direct-final audit capacity"); // Both transaction orderings are exercised for every closed selector // class. The waiter is observed in PostgreSQL's advisory wait state, the @@ -1964,6 +2021,15 @@ async fn nip_fi_direct_final_catalog_and_behavior() { outcome_code, ) .await; + insert_lifecycle_event( + &mut missing_history, + community_id, + operation_id, + &fingerprint, + outcome_code, + 105_u8.wrapping_add(outcome_code as u8), + ) + .await; let error = (&mut *missing_history) .execute("SET CONSTRAINTS ALL IMMEDIATE") .await @@ -1990,6 +2056,15 @@ async fn nip_fi_direct_final_catalog_and_behavior() { 2, ) .await; + insert_lifecycle_event( + &mut denied, + community_id, + denied_operation, + &denied_fingerprint, + 2, + 109, + ) + .await; (&mut *denied) .execute("SET CONSTRAINTS ALL IMMEDIATE") .await @@ -2047,6 +2122,15 @@ async fn nip_fi_direct_final_catalog_and_behavior() { 1, ) .await; + insert_lifecycle_event( + &mut rollback, + community_id, + rollback_operation, + &rollback_fingerprint, + 1, + 113, + ) + .await; sqlx::query( "UPDATE identity_bindings SET binding_state=2, lifecycle_revision=2, \ retirement_history_id=$1 WHERE community_id=$2 AND binding_id=$3", diff --git a/crates/buzz-db/src/moderation.rs b/crates/buzz-db/src/moderation.rs index 7a49c709fd..0bab0b0990 100644 --- a/crates/buzz-db/src/moderation.rs +++ b/crates/buzz-db/src/moderation.rs @@ -934,6 +934,14 @@ mod tests { bytes } + fn assert_database_constraint(error: &sqlx::Error, code: &str, constraint: &str) { + let database = error + .as_database_error() + .expect("expected PostgreSQL database error"); + assert_eq!(database.code().as_deref(), Some(code)); + assert_eq!(database.constraint(), Some(constraint)); + } + fn new_report<'a>( report_event_id: &'a [u8], reporter_pubkey: &'a [u8], @@ -1721,50 +1729,6 @@ mod tests { .await; assert!(expired.is_err(), "equality at retain_until is expired"); - // Construct an exact equality row transactionally with only the stamp - // trigger disabled. The table CHECK remains active. This proves the - // deletion trigger retains through, not merely until, the boundary. - let equality_key = random_32(); - let mut equality = pool.begin().await.expect("begin equality probe"); - sqlx::query( - "ALTER TABLE authorization_proxy_nonce_claims \ - DISABLE TRIGGER authorization_proxy_nonce_claims_stamp", - ) - .execute(&mut *equality) - .await - .expect("disable insert stamp in rollback-only probe"); - sqlx::query( - "INSERT INTO authorization_proxy_nonce_claims \ - (authorization_domain, claim_kind, claim_key, committed_at, retain_until) \ - VALUES ($1, 1, $2, transaction_timestamp() - INTERVAL '1 second', \ - transaction_timestamp())", - ) - .bind(domain_a.as_uuid()) - .bind(&equality_key) - .execute(&mut *equality) - .await - .expect("insert exact-boundary probe row"); - sqlx::query( - "ALTER TABLE authorization_proxy_nonce_claims \ - ENABLE TRIGGER authorization_proxy_nonce_claims_stamp", - ) - .execute(&mut *equality) - .await - .expect("restore insert stamp in probe"); - let equality_delete = sqlx::query( - "DELETE FROM authorization_proxy_nonce_claims \ - WHERE authorization_domain = $1 AND claim_kind = 1 AND claim_key = $2", - ) - .bind(domain_a.as_uuid()) - .bind(&equality_key) - .execute(&mut *equality) - .await; - assert!( - equality_delete.is_err(), - "deletion at the exclusive retain_until equality must be rejected" - ); - equality.rollback().await.expect("rollback equality probe"); - let rolled_back_key = random_32(); let mut transaction = pool.begin().await.expect("begin claim transaction"); sqlx::query( @@ -1815,7 +1779,107 @@ mod tests { #[tokio::test] #[ignore = "requires Postgres"] - async fn protected_projection_outbox_is_target_bound_ordered_and_idempotent() { + async fn proxy_nonce_deletion_is_strictly_after_retention_bound() { + let pool = setup_pool().await; + crate::migration::run_migrations(&pool) + .await + .expect("apply nonce-claim migration"); + let domain = make_test_community(&pool).await; + + // transaction_timestamp() is stable within this transaction, making + // retain_until byte-for-byte equal to the DELETE trigger's clock. + let equality_key = random_32(); + let mut equality = pool.begin().await.expect("begin equality probe"); + sqlx::query( + "ALTER TABLE authorization_proxy_nonce_claims \ + DISABLE TRIGGER authorization_proxy_nonce_claims_stamp", + ) + .execute(&mut *equality) + .await + .expect("disable only the insert stamp in equality probe"); + sqlx::query( + "INSERT INTO authorization_proxy_nonce_claims \ + (authorization_domain, claim_kind, claim_key, committed_at, retain_until) \ + VALUES ($1, 1, $2, transaction_timestamp() - INTERVAL '1 microsecond', \ + transaction_timestamp())", + ) + .bind(domain.as_uuid()) + .bind(&equality_key) + .execute(&mut *equality) + .await + .expect("insert exact-boundary nonce claim"); + sqlx::query( + "ALTER TABLE authorization_proxy_nonce_claims \ + ENABLE TRIGGER authorization_proxy_nonce_claims_stamp", + ) + .execute(&mut *equality) + .await + .expect("restore insert stamp before equality DELETE"); + let equality_error = sqlx::query( + "DELETE FROM authorization_proxy_nonce_claims \ + WHERE authorization_domain = $1 AND claim_kind = 1 AND claim_key = $2", + ) + .bind(domain.as_uuid()) + .bind(&equality_key) + .execute(&mut *equality) + .await + .expect_err("deletion at retain_until equality must be rejected"); + assert_database_constraint( + &equality_error, + "23514", + "authorization_proxy_nonce_claim_retention", + ); + equality + .rollback() + .await + .expect("rollback aborted equality probe"); + + let strictly_after_key = random_32(); + let mut strictly_after = pool.begin().await.expect("begin strictly-after probe"); + sqlx::query( + "ALTER TABLE authorization_proxy_nonce_claims \ + DISABLE TRIGGER authorization_proxy_nonce_claims_stamp", + ) + .execute(&mut *strictly_after) + .await + .expect("disable only the insert stamp in strictly-after probe"); + sqlx::query( + "INSERT INTO authorization_proxy_nonce_claims \ + (authorization_domain, claim_kind, claim_key, committed_at, retain_until) \ + VALUES ($1, 1, $2, transaction_timestamp() - INTERVAL '2 microseconds', \ + transaction_timestamp() - INTERVAL '1 microsecond')", + ) + .bind(domain.as_uuid()) + .bind(&strictly_after_key) + .execute(&mut *strictly_after) + .await + .expect("insert elapsed-boundary nonce claim"); + sqlx::query( + "ALTER TABLE authorization_proxy_nonce_claims \ + ENABLE TRIGGER authorization_proxy_nonce_claims_stamp", + ) + .execute(&mut *strictly_after) + .await + .expect("restore insert stamp before strictly-after DELETE"); + let deleted = sqlx::query( + "DELETE FROM authorization_proxy_nonce_claims \ + WHERE authorization_domain = $1 AND claim_kind = 1 AND claim_key = $2", + ) + .bind(domain.as_uuid()) + .bind(&strictly_after_key) + .execute(&mut *strictly_after) + .await + .expect("strictly elapsed retention bound permits deletion"); + assert_eq!(deleted.rows_affected(), 1); + strictly_after + .commit() + .await + .expect("commit strictly-after deletion"); + } + + #[tokio::test] + #[ignore = "requires Postgres"] + async fn protected_projection_outbox_releases_only_terminal_targets() { let pool = setup_pool().await; crate::migration::run_migrations(&pool) .await @@ -1833,90 +1897,186 @@ mod tests { .await .expect("install outbox-test audit capacity"); - let seed = stage_publication_admission(&pool, community, &object_key, 1, false) + let first = stage_publication_admission(&pool, community, &object_key, 2, true) .await - .expect("stage seed admission"); - sqlx::query( - "INSERT INTO authorization_authority_epochs \ - (community_id, object_kind, object_key, authority_epoch, fence, \ - operation_id, request_fingerprint) \ - VALUES ($1, 3, $2, 7, $3, $4, $5)", + .expect("stage first publication admission"); + set_publication_authority_epoch(&pool, community, &object_key, &first, 7) + .await + .expect("install first publication authority epoch"); + insert_projection(&pool, community, &first, &object_key, &projection_key) + .await + .expect("insert first projection"); + + let second = stage_publication_admission(&pool, community, &object_key, 3, true) + .await + .expect("stage second publication under the same authority epoch"); + let retained_epoch_operation: Uuid = sqlx::query_scalar( + "SELECT operation_id FROM authorization_authority_epochs \ + WHERE community_id = $1 AND object_kind = 3 AND object_key = $2", ) .bind(community.as_uuid()) .bind(&object_key) - .bind(random_32()) - .bind(seed.operation) - .bind(&seed.request) - .execute(&pool) + .fetch_one(&pool) .await - .expect("install unchanged same-epoch authority row"); - - let first = stage_publication_admission(&pool, community, &object_key, 2, true); - let second = stage_publication_admission(&pool, community, &object_key, 3, true); - let (first, second) = tokio::join!(first, second); - let first = first.expect("first concurrent same-epoch publication"); - let second = second.expect("second concurrent same-epoch publication"); - - let first_projection = - insert_projection(&pool, community, &first, &object_key, &projection_key); - let second_projection = - insert_projection(&pool, community, &second, &object_key, &projection_key); - let (first_projection, second_projection) = - tokio::join!(first_projection, second_projection); - first_projection.expect("insert first concurrent same-epoch projection"); - second_projection.expect("insert second concurrent same-epoch projection"); - - let rows: Vec<(Uuid, i64)> = sqlx::query_as( - "SELECT operation_id, publication_sequence \ - FROM protected_publication_projection_outbox \ - WHERE community_id = $1 AND object_kind = 3 AND object_key = $2 \ - AND projection_kind = 3 AND projection_key = $3 \ - ORDER BY publication_sequence", + .expect("read retained authority epoch origin"); + assert_eq!(retained_epoch_operation, first.operation); + assert_ne!(retained_epoch_operation, second.operation); + let second_conflict = + insert_projection(&pool, community, &second, &object_key, &projection_key) + .await + .expect_err("one exact target cannot have two pending projections"); + assert_database_constraint( + &second_conflict, + "23505", + "protected_publication_projection_outbox_active_target", + ); + + let delivered = sqlx::query( + "UPDATE protected_publication_projection_outbox \ + SET delivery_state = 2, attempt_count = attempt_count + 1, failure_code = 0 \ + WHERE community_id = $1 AND operation_id = $2 AND projection_kind = 3", ) .bind(community.as_uuid()) - .bind(&object_key) - .bind(&projection_key) - .fetch_all(&pool) + .bind(first.operation) + .execute(&pool) .await - .expect("read concurrent publication order"); - assert_eq!(rows.len(), 2); - assert_ne!(rows[0].0, rows[1].0); - assert!(rows[0].1 < rows[1].1); + .expect("transition first projection to delivered"); + assert_eq!(delivered.rows_affected(), 1); + + let duplicate = + insert_projection(&pool, community, &first, &object_key, &projection_key).await; + let duplicate = duplicate.expect_err("exact replay cannot duplicate an outbox effect"); + assert_database_constraint( + &duplicate, + "23505", + "protected_publication_projection_outbox_pkey", + ); - let stale_delivery = sqlx::query( + let delivered_reopen = sqlx::query( "UPDATE protected_publication_projection_outbox \ - SET delivery_state = 2, attempt_count = 1, failure_code = 0 \ + SET delivery_state = 1, attempt_count = attempt_count + 1, failure_code = 1 \ WHERE community_id = $1 AND operation_id = $2 AND projection_kind = 3", ) .bind(community.as_uuid()) - .bind(rows[1].0) + .bind(first.operation) .execute(&pool) - .await; - assert!( - stale_delivery.is_err(), - "newer concurrent version is fenced" + .await + .expect_err("delivered projection must not return to pending"); + assert_database_constraint( + &delivered_reopen, + "23514", + "protected_publication_projection_transition", ); - for (operation, _) in &rows { - sqlx::query( - "UPDATE protected_publication_projection_outbox \ - SET delivery_state = 2, attempt_count = 1, failure_code = 0 \ - WHERE community_id = $1 AND operation_id = $2 AND projection_kind = 3", - ) - .bind(community.as_uuid()) - .bind(operation) - .execute(&pool) + + insert_projection(&pool, community, &second, &object_key, &projection_key) .await - .expect("deliver concurrent versions in database order"); - } + .expect("terminal first projection releases the exact target"); - let duplicate = - insert_projection(&pool, community, &first, &object_key, &projection_key).await; - assert!( - duplicate.is_err(), - "exact replay cannot duplicate an outbox effect" + let third = stage_publication_admission(&pool, community, &object_key, 4, true) + .await + .expect("stage third publication under the same authority epoch"); + let third_conflict = + insert_projection(&pool, community, &third, &object_key, &projection_key) + .await + .expect_err("second pending projection must retain the exact target"); + assert_database_constraint( + &third_conflict, + "23505", + "protected_publication_projection_outbox_active_target", + ); + + let terminal = sqlx::query( + "UPDATE protected_publication_projection_outbox \ + SET delivery_state = 3, attempt_count = attempt_count + 1, failure_code = 3 \ + WHERE community_id = $1 AND operation_id = $2 AND projection_kind = 3", + ) + .bind(community.as_uuid()) + .bind(second.operation) + .execute(&pool) + .await + .expect("transition second projection to permanent terminal failure"); + assert_eq!(terminal.rows_affected(), 1); + + let terminal_reopen = sqlx::query( + "UPDATE protected_publication_projection_outbox \ + SET delivery_state = 1, attempt_count = attempt_count + 1, failure_code = 1 \ + WHERE community_id = $1 AND operation_id = $2 AND projection_kind = 3", + ) + .bind(community.as_uuid()) + .bind(second.operation) + .execute(&pool) + .await + .expect_err("terminal projection must not return to pending"); + assert_database_constraint( + &terminal_reopen, + "23514", + "protected_publication_projection_transition", ); - let mismatch = stage_publication_admission(&pool, community, &object_key, 4, true) + insert_projection(&pool, community, &third, &object_key, &projection_key) + .await + .expect("terminal second projection releases the exact target"); + + let (active_count, historical_count): (i64, i64) = sqlx::query_as( + "SELECT count(*) FILTER (WHERE delivery_state = 1), count(*) \ + FROM protected_publication_projection_outbox \ + WHERE community_id = $1 AND projection_kind = 3 AND projection_key = $2", + ) + .bind(community.as_uuid()) + .bind(&projection_key) + .fetch_one(&pool) + .await + .expect("count active and historical exact-target projections"); + assert_eq!(active_count, 1); + assert_eq!(historical_count, 3); + + let other_target = stage_publication_admission(&pool, community, &object_key, 5, true) + .await + .expect("stage independent target admission"); + insert_projection( + &pool, + community, + &other_target, + &object_key, + &format!("{projection_key}/independent-target"), + ) + .await + .expect("different target tuple remains independent"); + + let other_community = make_test_community(&pool).await; + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, 32, 1048576, 16384)", + ) + .bind(other_community.as_uuid()) + .execute(&pool) + .await + .expect("install independent-community audit capacity"); + let other_community_projection = + stage_publication_admission(&pool, other_community, &object_key, 6, true) + .await + .expect("stage independent-community admission"); + set_publication_authority_epoch( + &pool, + other_community, + &object_key, + &other_community_projection, + 7, + ) + .await + .expect("install independent-community authority epoch"); + insert_projection( + &pool, + other_community, + &other_community_projection, + &object_key, + &projection_key, + ) + .await + .expect("same target coordinates remain independent across communities"); + + let mismatch = stage_publication_admission(&pool, community, &object_key, 7, true) .await .expect("stage mismatch admission"); let mut mismatch_tx = pool.begin().await.expect("begin mismatch projection"); @@ -1941,12 +2101,72 @@ mod tests { .execute(&mut *mismatch_tx) .await .expect("stage deferred effect mismatch"); - assert!( - mismatch_tx.commit().await.is_err(), - "application effect mismatch must fail deferred binding" + let mismatch_error = mismatch_tx + .commit() + .await + .expect_err("application effect mismatch must fail deferred binding"); + assert_database_constraint( + &mismatch_error, + "23503", + "protected_publication_projection_receipt", ); - let lower = stage_publication_admission(&pool, community, &object_key, 5, false) + let tampered = stage_publication_admission_with_receipt_binding( + &pool, + community, + &object_key, + 8, + true, + false, + ) + .await + .expect("stage receipt-digest tamper probe"); + let mut tampered_tx = pool.begin().await.expect("begin receipt tamper probe"); + insert_projection_in_transaction( + &mut tampered_tx, + community, + &tampered, + &object_key, + &format!("{projection_key}/tampered-receipt"), + ) + .await + .expect("stage receipt-digest tamper projection"); + let tampered_error = tampered_tx + .commit() + .await + .expect_err("receipt result_digest bytes must bind the projection result"); + assert_database_constraint( + &tampered_error, + "23503", + "protected_publication_projection_receipt", + ); + + let independent_object_key = random_32(); + let independent = + stage_publication_admission(&pool, community, &independent_object_key, 9, true) + .await + .expect("stage independently inserted projection probe"); + let mut independent_tx = pool.begin().await.expect("begin independent row probe"); + insert_projection_in_transaction( + &mut independent_tx, + community, + &independent, + &independent_object_key, + &format!("{projection_key}/independent"), + ) + .await + .expect("stage independently inserted outbox row"); + let independent_error = independent_tx + .commit() + .await + .expect_err("outbox row for an object without an authority epoch must fail"); + assert_database_constraint( + &independent_error, + "23503", + "protected_publication_projection_receipt", + ); + + let lower = stage_publication_admission(&pool, community, &object_key, 10, false) .await .expect("stage receipt rejected before an admission result"); let mut lower_tx = pool.begin().await.expect("begin lower-epoch projection"); @@ -1971,9 +2191,14 @@ mod tests { .execute(&mut *lower_tx) .await .expect("stage lower-epoch projection without admitted result"); - assert!( - lower_tx.commit().await.is_err(), - "a lower-epoch denial without a committed admission result cannot publish" + let lower_error = lower_tx + .commit() + .await + .expect_err("a lower-epoch denial without a committed admission result cannot publish"); + assert_database_constraint( + &lower_error, + "23503", + "protected_publication_projection_receipt", ); } @@ -1992,6 +2217,25 @@ mod tests { object_key: &[u8], marker: u8, include_admission_result: bool, + ) -> std::result::Result { + stage_publication_admission_with_receipt_binding( + pool, + community, + object_key, + marker, + include_admission_result, + true, + ) + .await + } + + async fn stage_publication_admission_with_receipt_binding( + pool: &PgPool, + community: CommunityId, + object_key: &[u8], + marker: u8, + include_admission_result: bool, + receipt_matches_application: bool, ) -> std::result::Result { let fixture = PublicationAdmissionFixture { operation: Uuid::new_v4(), @@ -2012,7 +2256,11 @@ mod tests { .bind(fixture.operation) .bind(&fixture.request) .bind(vec![marker.saturating_add(60); 32]) - .bind(vec![marker.saturating_add(70); 32]) + .bind(if receipt_matches_application { + fixture.application_result.clone() + } else { + vec![marker.saturating_add(70); 32] + }) .execute(&mut *transaction) .await?; sqlx::query( @@ -2060,6 +2308,39 @@ mod tests { Ok(fixture) } + async fn set_publication_authority_epoch( + pool: &PgPool, + community: CommunityId, + object_key: &[u8], + fixture: &PublicationAdmissionFixture, + authority_epoch: i64, + ) -> std::result::Result<(), sqlx::Error> { + sqlx::query( + "INSERT INTO authorization_authority_epochs \ + (community_id, object_kind, object_key, authority_epoch, fence, \ + operation_id, request_fingerprint) \ + VALUES ($1, 3, $2, $3, $4, $5, $6) \ + ON CONFLICT (community_id, object_kind, object_key) DO UPDATE \ + SET authority_epoch = EXCLUDED.authority_epoch, \ + fence = EXCLUDED.fence, \ + operation_id = EXCLUDED.operation_id, \ + request_fingerprint = EXCLUDED.request_fingerprint, \ + updated_at = GREATEST( \ + transaction_timestamp(), \ + authorization_authority_epochs.updated_at + INTERVAL '1 microsecond' \ + )", + ) + .bind(community.as_uuid()) + .bind(object_key) + .bind(authority_epoch) + .bind(random_32()) + .bind(fixture.operation) + .bind(&fixture.request) + .execute(pool) + .await?; + Ok(()) + } + async fn insert_projection( pool: &PgPool, community: CommunityId, @@ -2088,4 +2369,33 @@ mod tests { .execute(pool) .await } + + async fn insert_projection_in_transaction( + transaction: &mut Transaction<'_, Postgres>, + community: CommunityId, + fixture: &PublicationAdmissionFixture, + object_key: &[u8], + projection_key: &str, + ) -> std::result::Result { + sqlx::query( + "INSERT INTO protected_publication_projection_outbox \ + (community_id, operation_id, request_fingerprint, publication_result_digest, \ + object_kind, object_key, application_type, application_version, \ + application_effect_digest, projection_kind, projection_key, \ + staged_object_key, payload_digest) \ + VALUES ($1, $2, $3, $4, 3, $5, $6, 1, $7, 3, $8, $9, $10)", + ) + .bind(community.as_uuid()) + .bind(fixture.operation) + .bind(&fixture.request) + .bind(&fixture.application_result) + .bind(object_key) + .bind(&fixture.application_type) + .bind(&fixture.application_effect) + .bind(projection_key) + .bind(format!("manifests/{}", fixture.operation)) + .bind(&fixture.payload) + .execute(&mut **transaction) + .await + } } diff --git a/crates/buzz-relay/Cargo.toml b/crates/buzz-relay/Cargo.toml index bd2d28c556..7112c187e7 100644 --- a/crates/buzz-relay/Cargo.toml +++ b/crates/buzz-relay/Cargo.toml @@ -85,8 +85,8 @@ async-compression = { version = "0.4.42", features = ["tokio", "gzip"] } dev = ["buzz-auth/dev"] [dev-dependencies] -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"] } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"] } # Relay-driven mesh lifecycle smoke (examples/mesh_relay_lifecycle_smoke.rs): # the relay client for discovery notes and the exact ed25519 the mesh owner # keys use for binding verification. diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index 5a8f24df89..bb441270c3 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -127,6 +127,19 @@ pub(crate) fn verify_bridge_auth_with_options( Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) } +fn exact_nip98_authorization_event(headers: &HeaderMap) -> Option> { + let mut values = headers.get_all("authorization").iter(); + let value = values.next()?.to_str().ok()?; + if values.next().is_some() { + return None; + } + let encoded = value.strip_prefix("Nostr ")?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded) + .ok()?; + String::from_utf8(bytes).ok().map(Arc::::from) +} + /// Corporate identity enrollment must always start from cryptographic proof of /// the Nostr key. The development-only `X-Pubkey` fallback is caller-controlled /// and therefore cannot safely participate in a durable identity binding. @@ -889,10 +902,21 @@ async fn submit_event_authed( } let kind_u32 = buzz_core::kind::event_kind_u32(&event); + let moderation_evidence = buzz_core::kind::is_moderation_command_kind(kind_u32) + .then(|| { + exact_nip98_authorization_event(headers).map(|authorization_event| { + crate::handlers::ingest::ModerationTransportEvidence::Nip98 { + authorization_event, + body: axum::body::Bytes::copy_from_slice(body), + } + }) + }) + .flatten(); let auth = IngestAuth::Http { pubkey, scopes: buzz_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, + moderation_evidence, }; match crate::handlers::ingest::ingest_event(state, tenant, event, auth).await { diff --git a/crates/buzz-relay/src/api/invites.rs b/crates/buzz-relay/src/api/invites.rs index 83ecb2fcc3..f22c41eec8 100644 --- a/crates/buzz-relay/src/api/invites.rs +++ b/crates/buzz-relay/src/api/invites.rs @@ -403,6 +403,32 @@ pub async fn claim_invite( headers: HeaderMap, body: axum::body::Bytes, ) -> Result, (StatusCode, Json)> { + match state.config.nip_fi_mode { + buzz_auth::NipFiMode::Enforce => { + return claim_invite_enforced(state, headers, body).await; + } + buzz_auth::NipFiMode::DenyProtected => { + if let Some(raw_host) = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + { + if let Ok(tenant) = crate::tenant::bind_community(&state.db, raw_host).await { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::ModeDenied, + ) + .await?; + } + } + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + )); + } + buzz_auth::NipFiMode::Off => {} + } + let (tenant, pubkey, identity_proof) = authenticate(&state, &headers, "/api/invites/claim", &body).await?; @@ -647,6 +673,444 @@ pub async fn claim_invite( }))) } +async fn claim_invite_enforced( + state: Arc, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let raw_host = headers + .get(axum::http::header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let tenant = crate::tenant::bind_community(&state.db, raw_host) + .await + .map_err(|_| api_error(StatusCode::NOT_FOUND, "invite_domain_not_found"))?; + let request: ClaimInviteRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ) + .await?; + return Err(api_error(StatusCode::BAD_REQUEST, "invite_request_invalid")); + } + }; + if !request.code.starts_with(V2_PREFIX) { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ) + .await?; + return Err(api_error( + StatusCode::FORBIDDEN, + "invite_legacy_claim_disabled", + )); + } + if validate_v2_code(&request.code).is_err() { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ) + .await?; + return Err(api_error(StatusCode::FORBIDDEN, "invite_invalid")); + } + + let invite_key = invite_token::derive_invite_key(&state.relay_keypair); + if let Some(policy) = &state.config.join_policy { + let Some(receipt) = request.policy_receipt.as_deref() else { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied, + ) + .await?; + return Err(api_error(StatusCode::FORBIDDEN, "join_policy_required")); + }; + if invite_token::verify_policy_acceptance( + &invite_key, + receipt, + &request.code, + &policy.version, + ) + .is_err() + { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied, + ) + .await?; + return Err(api_error(StatusCode::FORBIDDEN, "join_policy_required")); + } + } + + let token_hash = hash_v2_code(&request.code); + let target = match state + .db + .resolve_canonical_invite_target(tenant.community(), token_hash) + .await + { + Ok(target) => target, + Err(commit_error) => { + record_invite_denial( + &state, + tenant.community(), + invite_denial_reason(commit_error), + ) + .await?; + return Err(canonical_invite_error(commit_error)); + } + }; + let expected_url = + bridge::nip98_expected_url(&state.config.relay_url, &tenant, "/api/invites/claim"); + let coordinates = match buzz_auth::Nip98InviteClaimCoordinates::new( + tenant.community(), + target.fingerprint(), + &expected_url, + &body, + ) { + Ok(coordinates) => coordinates, + Err(_) => { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ) + .await?; + return Err(api_error( + StatusCode::FORBIDDEN, + "invite_authorization_denied", + )); + } + }; + let event_json = match exact_nostr_authorization_event(&headers) { + Ok(event_json) => event_json, + Err(public_error) => { + record_invite_denial( + &state, + tenant.community(), + header_denial_reason(&headers, axum::http::header::AUTHORIZATION), + ) + .await?; + return Err(public_error); + } + }; + let assertion_token = match exact_federated_assertion(&headers) { + Ok(assertion) => assertion, + Err(public_error) => { + record_invite_denial( + &state, + tenant.community(), + header_denial_reason(&headers, buzz_auth::ASSERTION_HEADER_NAME), + ) + .await?; + return Err(public_error); + } + }; + let authority = match state.canonical_invite_authority() { + Some(authority) => authority, + None => { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::DependencyUnavailable, + ) + .await?; + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + )); + } + }; + let (request_fingerprint, target_fingerprint, transport_context) = + coordinates.request_binding(); + let assertion = match authority + .assertion_verifier() + .verify( + &assertion_token, + tenant.community(), + buzz_auth::ProofTransport::Nip98, + *target_fingerprint, + *request_fingerprint, + *transport_context, + ) + .await + { + Ok(assertion) => assertion, + Err(assertion_error) => { + let reason = match assertion_error { + crate::state::InviteAssertionError::Unavailable => { + buzz_db::authorization_events::ProtectedDenialReason::DependencyUnavailable + } + crate::state::InviteAssertionError::Denied => { + buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied + } + }; + record_invite_denial(&state, tenant.community(), reason).await?; + return Err(canonical_assertion_error(assertion_error)); + } + }; + let proof = match buzz_auth::verify_nip98_invite_claim_proof( + &event_json, + &coordinates, + &body, + &assertion, + chrono::Utc::now(), + ) { + Ok(proof) => proof, + Err(_) => { + record_invite_denial( + &state, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ) + .await?; + return Err(api_error( + StatusCode::FORBIDDEN, + "invite_authorization_denied", + )); + } + }; + let actor = proof.actor_pubkey(); + let verifier_rechecker = authority.final_rechecker(); + let admission = match state + .db + .commit_canonical_invite_claim( + assertion, + proof, + target, + token_hash, + state + .config + .join_policy + .as_ref() + .map(|policy| policy.version.as_str()), + verifier_rechecker, + ) + .await + { + Ok(outcome) => outcome, + Err(commit_error) => { + let fallback_reason = match commit_error { + buzz_db::authorization_admission::AdmissionCommitError::InvalidRequest => { + Some(buzz_db::authorization_events::ProtectedDenialReason::InvalidProof) + } + buzz_db::authorization_admission::AdmissionCommitError::AuthorizationDenied => { + Some(buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied) + } + buzz_db::authorization_admission::AdmissionCommitError::IntentConflict + | buzz_db::authorization_admission::AdmissionCommitError::ReplayRejected => { + Some(buzz_db::authorization_events::ProtectedDenialReason::ReplayConflict) + } + buzz_db::authorization_admission::AdmissionCommitError::AuditUnavailable + | buzz_db::authorization_admission::AdmissionCommitError::DependencyUnavailable => { + Some( + buzz_db::authorization_events::ProtectedDenialReason::DependencyUnavailable, + ) + } + _ => None, + }; + if let Some(reason) = fallback_reason { + record_invite_denial(&state, tenant.community(), reason).await?; + } + return Err(canonical_invite_error(commit_error)); + } + }; + let actor_hex = actor.to_hex(); + let disposition = admission.disposition(); + match admission.outcome() { + buzz_db::authorization_admission::CanonicalInviteClaimOutcome::Joined => { + if should_publish_canonical_invite_side_effects(admission.outcome(), disposition) { + if let Err(error) = publish_nip43_member_added(&tenant, &state, &actor_hex).await { + tracing::warn!( + "failed to publish member-added delta after invite claim: {error}" + ); + } + if let Err(error) = publish_nip43_membership_list(&tenant, &state).await { + tracing::warn!("failed to publish membership list after invite claim: {error}"); + } + } + Ok(Json(serde_json::json!({ + "status": "joined", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + buzz_db::authorization_admission::CanonicalInviteClaimOutcome::AlreadyMember => { + Ok(Json(serde_json::json!({ + "status": "already_member", + "community_id": tenant.community().to_string(), + "host": tenant.host(), + "role": "member", + }))) + } + } +} + +fn should_publish_canonical_invite_side_effects( + outcome: buzz_db::authorization_admission::CanonicalInviteClaimOutcome, + disposition: buzz_db::authorization_admission::CanonicalInviteClaimDisposition, +) -> bool { + outcome == buzz_db::authorization_admission::CanonicalInviteClaimOutcome::Joined + && disposition == buzz_db::authorization_admission::CanonicalInviteClaimDisposition::Fresh +} + +fn exact_nostr_authorization_event( + headers: &HeaderMap, +) -> Result)> { + use base64::Engine as _; + + let mut values = headers.get_all(axum::http::header::AUTHORIZATION).iter(); + let value = values + .next() + .filter(|_| values.next().is_none()) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Nostr ")) + .filter(|value| !value.is_empty() && value.len() <= 128 * 1024) + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_authorization_denied"))?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(value) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_authorization_denied"))?; + if decoded.is_empty() || decoded.len() > 64 * 1024 { + return Err(api_error( + StatusCode::FORBIDDEN, + "invite_authorization_denied", + )); + } + String::from_utf8(decoded) + .map_err(|_| api_error(StatusCode::FORBIDDEN, "invite_authorization_denied")) +} + +fn exact_federated_assertion(headers: &HeaderMap) -> Result)> { + let mut values = headers.get_all(buzz_auth::ASSERTION_HEADER_NAME).iter(); + let value = values + .next() + .filter(|_| values.next().is_none()) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.strip_prefix("Bearer ")) + .filter(|value| { + !value.is_empty() + && value.len() <= 64 * 1024 + && value.split('.').count() == 3 + && !value.contains(',') + }) + .ok_or_else(|| api_error(StatusCode::FORBIDDEN, "invite_authorization_denied"))?; + Ok(value.to_owned()) +} + +fn canonical_assertion_error( + error: crate::state::InviteAssertionError, +) -> (StatusCode, Json) { + match error { + crate::state::InviteAssertionError::Unavailable => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + ), + crate::state::InviteAssertionError::Denied => { + api_error(StatusCode::FORBIDDEN, "invite_authorization_denied") + } + } +} + +fn canonical_invite_error( + error: buzz_db::authorization_admission::AdmissionCommitError, +) -> (StatusCode, Json) { + use buzz_db::authorization_admission::AdmissionCommitError; + + match error { + AdmissionCommitError::ReplayRejected | AdmissionCommitError::RecordedReplayRejected => { + api_error(StatusCode::FORBIDDEN, "invite_authorization_replay") + } + AdmissionCommitError::InvalidRequest + | AdmissionCommitError::RecordedInvalidRequest + | AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied + | AdmissionCommitError::IntentConflict + | AdmissionCommitError::RecordedIntentConflict => { + api_error(StatusCode::FORBIDDEN, "invite_authorization_denied") + } + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + ), + } +} + +fn invite_denial_reason( + error: buzz_db::authorization_admission::AdmissionCommitError, +) -> buzz_db::authorization_events::ProtectedDenialReason { + use buzz_db::authorization_admission::AdmissionCommitError; + use buzz_db::authorization_events::ProtectedDenialReason; + + match error { + AdmissionCommitError::InvalidRequest | AdmissionCommitError::RecordedInvalidRequest => { + ProtectedDenialReason::InvalidProof + } + AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied => { + ProtectedDenialReason::AuthorizationDenied + } + AdmissionCommitError::IntentConflict + | AdmissionCommitError::ReplayRejected + | AdmissionCommitError::RecordedIntentConflict + | AdmissionCommitError::RecordedReplayRejected => ProtectedDenialReason::ReplayConflict, + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => { + ProtectedDenialReason::DependencyUnavailable + } + } +} + +fn header_denial_reason( + headers: &HeaderMap, + name: impl axum::http::header::AsHeaderName, +) -> buzz_db::authorization_events::ProtectedDenialReason { + if headers.get_all(name).iter().next().is_none() { + buzz_db::authorization_events::ProtectedDenialReason::MissingProof + } else { + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof + } +} + +async fn record_invite_denial( + state: &AppState, + community: buzz_core::CommunityId, + reason: buzz_db::authorization_events::ProtectedDenialReason, +) -> Result<(), (StatusCode, Json)> { + if state + .db + .record_protected_denial_bucket( + community, + buzz_db::authorization_events::ProtectedDenialSurface::InviteClaim, + reason, + buzz_db::authorization_events::ProtectedDenialAction::InviteClaim, + ) + .await + .is_err() + { + let _ = state + .db + .latch_authorization_event_failure( + community, + buzz_db::authorization_events::AuthorizationAuditFailureCode::StorageUnavailable, + ) + .await; + return Err(api_error( + StatusCode::SERVICE_UNAVAILABLE, + "invite_authorization_unavailable", + )); + } + Ok(()) +} + /// Fixed-window rate limit on claim attempts, keyed by community and claimer /// pubkey so traffic for one tenant cannot consume another tenant's allowance. /// @@ -778,6 +1242,138 @@ mod tests { assert!(cache.entry_count() <= capacity); } + #[test] + fn canonical_invite_headers_require_one_exact_credential_each() { + let mut headers = axum::http::HeaderMap::new(); + let encoded = base64::engine::general_purpose::STANDARD.encode(b"{}"); + headers.insert( + header::AUTHORIZATION, + format!("Nostr {encoded}") + .parse() + .expect("authorization header"), + ); + headers.insert( + buzz_auth::ASSERTION_HEADER_NAME, + "Bearer first.second.third" + .parse() + .expect("assertion header"), + ); + assert_eq!( + super::exact_nostr_authorization_event(&headers).expect("one Nostr proof"), + "{}" + ); + assert_eq!( + super::exact_federated_assertion(&headers).expect("one assertion"), + "first.second.third" + ); + + headers.append( + header::AUTHORIZATION, + format!("Nostr {encoded}").parse().expect("duplicate proof"), + ); + headers.append( + buzz_auth::ASSERTION_HEADER_NAME, + "Bearer other.second.third" + .parse() + .expect("duplicate assertion"), + ); + assert!(super::exact_nostr_authorization_event(&headers).is_err()); + assert!(super::exact_federated_assertion(&headers).is_err()); + } + + #[test] + fn canonical_invite_denials_have_stable_public_codes() { + let (denied_status, denied_body) = super::canonical_invite_error( + buzz_db::authorization_admission::AdmissionCommitError::AuthorizationDenied, + ); + assert_eq!(denied_status, StatusCode::FORBIDDEN); + assert_eq!( + denied_body.0.get("error").and_then(Value::as_str), + Some("invite_authorization_denied") + ); + + let (replay_status, replay_body) = super::canonical_invite_error( + buzz_db::authorization_admission::AdmissionCommitError::ReplayRejected, + ); + assert_eq!(replay_status, StatusCode::FORBIDDEN); + assert_eq!( + replay_body.0.get("error").and_then(Value::as_str), + Some("invite_authorization_replay") + ); + + let (conflict_status, conflict_body) = super::canonical_invite_error( + buzz_db::authorization_admission::AdmissionCommitError::IntentConflict, + ); + assert_eq!(conflict_status, StatusCode::FORBIDDEN); + assert_eq!( + conflict_body.0.get("error").and_then(Value::as_str), + Some("invite_authorization_denied") + ); + for (recorded, expected) in [ + ( + buzz_db::authorization_admission::AdmissionCommitError::RecordedInvalidRequest, + "invite_authorization_denied", + ), + ( + buzz_db::authorization_admission::AdmissionCommitError::RecordedAuthorizationDenied, + "invite_authorization_denied", + ), + ( + buzz_db::authorization_admission::AdmissionCommitError::RecordedReplayRejected, + "invite_authorization_replay", + ), + ( + buzz_db::authorization_admission::AdmissionCommitError::RecordedIntentConflict, + "invite_authorization_denied", + ), + ] { + let (status, body) = super::canonical_invite_error(recorded); + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!(body.0.get("error").and_then(Value::as_str), Some(expected)); + } + + let (unavailable_status, unavailable_body) = super::canonical_invite_error( + buzz_db::authorization_admission::AdmissionCommitError::DependencyUnavailable, + ); + assert_eq!(unavailable_status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + unavailable_body.0.get("error").and_then(Value::as_str), + Some("invite_authorization_unavailable") + ); + let (recorded_status, recorded_body) = super::canonical_invite_error( + buzz_db::authorization_admission::AdmissionCommitError::RecordedAuditUnavailable, + ); + assert_eq!(recorded_status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + recorded_body.0.get("error").and_then(Value::as_str), + Some("invite_authorization_unavailable") + ); + } + + #[test] + fn canonical_invite_publications_only_follow_a_fresh_join() { + use buzz_db::authorization_admission::{ + CanonicalInviteClaimDisposition, CanonicalInviteClaimOutcome, + }; + + assert!(super::should_publish_canonical_invite_side_effects( + CanonicalInviteClaimOutcome::Joined, + CanonicalInviteClaimDisposition::Fresh, + )); + assert!(!super::should_publish_canonical_invite_side_effects( + CanonicalInviteClaimOutcome::Joined, + CanonicalInviteClaimDisposition::ExactReplay, + )); + assert!(!super::should_publish_canonical_invite_side_effects( + CanonicalInviteClaimOutcome::AlreadyMember, + CanonicalInviteClaimDisposition::Fresh, + )); + assert!(!super::should_publish_canonical_invite_side_effects( + CanonicalInviteClaimOutcome::AlreadyMember, + CanonicalInviteClaimDisposition::ExactReplay, + )); + } + fn nip98_auth_header(keys: &Keys, url: &str, body: &[u8]) -> String { let hash: [u8; 32] = Sha256::digest(body).into(); let tags = vec![ @@ -1077,6 +1673,138 @@ mod tests { } } + #[tokio::test] + #[ignore = "requires Postgres"] + async fn canonical_modes_close_before_legacy_mutation_and_preserve_off_claims() { + let host = format!("invites-canonical-{}.example", Uuid::new_v4().simple()); + let owner = Keys::generate(); + let joiner = Keys::generate(); + let state = invite_test_state(&host) + .await + .expect("requires reachable Postgres and relay test state"); + let community = state + .db + .lookup_community_by_host(&host) + .await + .expect("lookup community") + .expect("community exists"); + state + .db + .add_relay_member(community.id, &owner.public_key().to_hex(), "owner", None) + .await + .expect("seed invite owner"); + let code = mint_code( + state.clone(), + &host, + &owner, + serde_json::json!({ "max_uses": 1 }), + ) + .await; + let claim_body = serde_json::json!({ "code": code }).to_string(); + + let state_with_mode = |mode| { + let mut inner = (*state).clone(); + let mut config = inner.config.as_ref().clone(); + config.nip_fi_mode = mode; + inner.config = Arc::new(config); + Arc::new(inner) + }; + let enforce = state_with_mode(buzz_auth::NipFiMode::Enforce); + let legacy = post_json( + enforce.clone(), + &host, + "/api/invites/claim", + &joiner, + serde_json::json!({ "code": "legacy" }).to_string(), + ) + .await; + assert_eq!(legacy.status(), StatusCode::FORBIDDEN); + assert_eq!( + read_json(legacy).await.get("error").and_then(Value::as_str), + Some("invite_legacy_claim_disabled") + ); + + let url = format!("https://{host}/api/invites/claim"); + let authorization = nip98_auth_header(&joiner, &url, claim_body.as_bytes()); + let unavailable = build_router(enforce) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/invites/claim") + .header(header::HOST, &host) + .header(header::AUTHORIZATION, authorization) + .header( + buzz_auth::ASSERTION_HEADER_NAME, + "Bearer first.second.third", + ) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(claim_body.clone())) + .expect("canonical invite request"), + ) + .await + .expect("canonical unavailable response"); + assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(unavailable) + .await + .get("error") + .and_then(Value::as_str), + Some("invite_authorization_unavailable") + ); + + let denied = post_json( + state_with_mode(buzz_auth::NipFiMode::DenyProtected), + &host, + "/api/invites/claim", + &joiner, + claim_body.clone(), + ) + .await; + assert_eq!(denied.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + read_json(denied).await.get("error").and_then(Value::as_str), + Some("invite_authorization_unavailable") + ); + + let evidence_pool = sqlx::PgPool::connect(&state.config.database_url) + .await + .expect("connect invite denial evidence pool"); + let denial_buckets: Vec<(i16, i64)> = sqlx::query_as( + "SELECT denial_class,sum(denial_count)::BIGINT \ + FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=2 AND action_kind=1 \ + GROUP BY denial_class ORDER BY denial_class", + ) + .bind(community.id.as_uuid()) + .fetch_all(&evidence_pool) + .await + .expect("read durable invite denial buckets"); + assert_eq!(denial_buckets, vec![(2, 1), (3, 1), (8, 1)]); + + let off = post_json( + state.clone(), + &host, + "/api/invites/claim", + &joiner, + claim_body, + ) + .await; + assert_eq!(off.status(), StatusCode::OK); + assert_eq!( + read_json(off).await.get("status").and_then(Value::as_str), + Some("joined") + ); + let post_off_denials: i64 = sqlx::query_scalar( + "SELECT sum(denial_count)::BIGINT FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=2", + ) + .bind(community.id.as_uuid()) + .fetch_one(&evidence_pool) + .await + .expect("count invite denials after Off claim"); + assert_eq!(post_off_denials, 3); + } + #[tokio::test] #[ignore = "requires Postgres"] async fn bounded_v2_claims_publish_side_effects_only_for_joined() { diff --git a/crates/buzz-relay/src/api/media.rs b/crates/buzz-relay/src/api/media.rs index 4d03164b9f..e5c31e8729 100644 --- a/crates/buzz-relay/src/api/media.rs +++ b/crates/buzz-relay/src/api/media.rs @@ -738,9 +738,48 @@ pub(crate) struct AuthenticatedUpload { /// door in `bridge.rs`. Server-resolved, never client-supplied. tenant: TenantContext, route_mode: UploadRouteMode, + attribution: UploadAttributionSnapshot, _upload_permit: UploadPermit, } +/// Mutable upload labels and network facts captured once at admission. +/// +/// Fresh publication consumes these owned values after body processing. Exact +/// replay must instead use its persisted first result; neither path re-reads a +/// profile or request header after admission succeeds. +struct UploadAttributionSnapshot(Option); + +impl UploadAttributionSnapshot { + fn capture( + enabled: bool, + uploader_name: Option, + upload_ip_header: &Option, + upload_port_header: &Option, + headers: &HeaderMap, + ) -> Self { + if !enabled { + return Self(None); + } + + let header_value = |name: &Option| { + name.as_deref() + .and_then(|header| headers.get(header)) + .and_then(|value| value.to_str().ok()) + }; + let ip = header_value(upload_ip_header).and_then(buzz_media::parse_public_ip); + let port = ip.and(header_value(upload_port_header).and_then(buzz_media::parse_port)); + + Self(Some(UploadAttribution { + uploader_name, + net: UploadNetworkInfo { ip, port }, + })) + } + + fn into_attribution(self) -> Option { + self.0 + } +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum UploadRouteMode { Upload, @@ -985,11 +1024,14 @@ impl FromRequestParts> for AuthenticatedUpload { })?; finalize_media_corporate_identity(state, &tenant, auth_event.pubkey, identity_proof) .await?; + let attribution = + snapshot_upload_attribution(state, &tenant, &auth_event.pubkey, headers).await; Ok(AuthenticatedUpload { auth_event, tenant, route_mode, + attribution, _upload_permit: upload_permit, }) } @@ -1007,19 +1049,20 @@ impl FromRequestParts> for AuthenticatedUpload { /// socket address is never used; behind a sidecar it is meaningless, and a /// wrong address is worse than none. /// - `net.port` (optional companion header) is only kept alongside a valid IP. -async fn upload_attribution( +async fn snapshot_upload_attribution( state: &AppState, - auth: &AuthenticatedUpload, + tenant: &TenantContext, + uploader: &nostr::PublicKey, headers: &HeaderMap, -) -> Option { +) -> UploadAttributionSnapshot { let cfg = &state.config.media; if !cfg.upload_records_enabled { - return None; + return UploadAttributionSnapshot(None); } let uploader_name = state .db - .get_user(auth.tenant.community(), &auth.auth_event.pubkey.to_bytes()) + .get_user(tenant.community(), &uploader.to_bytes()) .await .ok() .flatten() @@ -1027,18 +1070,13 @@ async fn upload_attribution( .map(|name| name.trim().to_string()) .filter(|name| !name.is_empty()); - let header_value = |name: &Option| { - name.as_deref() - .and_then(|h| headers.get(h)) - .and_then(|v| v.to_str().ok()) - }; - let ip = header_value(&cfg.upload_ip_header).and_then(buzz_media::parse_public_ip); - let port = ip.and(header_value(&cfg.upload_port_header).and_then(buzz_media::parse_port)); - - Some(UploadAttribution { + UploadAttributionSnapshot::capture( + true, uploader_name, - net: UploadNetworkInfo { ip, port }, - }) + &cfg.upload_ip_header, + &cfg.upload_port_header, + headers, + ) } /// PUT `/upload` or the temporary media-only `/media/upload` alias. @@ -1066,7 +1104,7 @@ pub async fn upload_blob( headers: HeaderMap, body: axum::body::Body, ) -> Result, MediaError> { - let attribution = upload_attribution(&state, &auth, &headers).await; + let attribution = auth.attribution.into_attribution(); if auth.route_mode == UploadRouteMode::LegacyMedia { metrics::counter!("buzz_media_legacy_upload_route_total").increment(1); @@ -1914,6 +1952,7 @@ mod tests { PROXY_NOW.to_be_bytes().as_slice(), nonce, &assertion_digest, + domain.as_uuid().as_bytes(), b"POST".as_slice(), b"relay.example.com:443".as_slice(), path.as_bytes(), @@ -1963,6 +2002,77 @@ mod tests { )); } + #[test] + fn upload_attribution_snapshot_owns_captured_values() { + let ip_header = Some("x-client-ip".to_owned()); + let port_header = Some("x-client-port".to_owned()); + let mut headers = HeaderMap::new(); + headers.insert("x-client-ip", "8.8.8.8".parse().expect("IP header")); + headers.insert("x-client-port", "443".parse().expect("port header")); + let mut mutable_name = "admitted name".to_owned(); + + let snapshot = UploadAttributionSnapshot::capture( + true, + Some(mutable_name.clone()), + &ip_header, + &port_header, + &headers, + ); + + mutable_name.replace_range(.., "later profile"); + headers.insert( + "x-client-ip", + "1.1.1.1".parse().expect("replacement IP header"), + ); + headers.insert( + "x-client-port", + "8443".parse().expect("replacement port header"), + ); + + let attribution = snapshot + .into_attribution() + .expect("enabled attribution snapshot"); + assert_eq!(attribution.uploader_name.as_deref(), Some("admitted name")); + assert_eq!( + attribution.net.ip, + Some("8.8.8.8".parse().expect("expected IP")) + ); + assert_eq!(attribution.net.port, Some(443)); + } + + #[test] + fn upload_attribution_disabled_captures_nothing() { + let headers = HeaderMap::new(); + assert!(UploadAttributionSnapshot::capture( + false, + Some("unused".to_owned()), + &Some("x-client-ip".to_owned()), + &Some("x-client-port".to_owned()), + &headers, + ) + .into_attribution() + .is_none()); + } + + #[test] + fn upload_attribution_snapshot_fails_empty_without_a_public_ip() { + let ip_header = Some("x-client-ip".to_owned()); + let port_header = Some("x-client-port".to_owned()); + for raw_ip in [None, Some("not-an-ip"), Some("10.0.0.1")] { + let mut headers = HeaderMap::new(); + if let Some(raw_ip) = raw_ip { + headers.insert("x-client-ip", raw_ip.parse().expect("IP header")); + } + headers.insert("x-client-port", "443".parse().expect("port header")); + + let attribution = + UploadAttributionSnapshot::capture(true, None, &ip_header, &port_header, &headers) + .into_attribution() + .expect("enabled attribution snapshot"); + assert_eq!(attribution.net, UploadNetworkInfo::default()); + } + } + #[test] fn proprietary_iso_bmff_brand_still_uses_video_pipeline() { let bytes = b"\x00\x00\x00\x18ftypPRIV\x00\x00\x00\x00isommp42"; diff --git a/crates/buzz-relay/src/config.rs b/crates/buzz-relay/src/config.rs index c32e761ae0..4c5110f926 100644 --- a/crates/buzz-relay/src/config.rs +++ b/crates/buzz-relay/src/config.rs @@ -188,6 +188,8 @@ pub struct Config { pub slow_client_grace_limit: u8, /// Authentication provider configuration. pub auth: buzz_auth::AuthConfig, + /// Canonical protected-admission rollout mode. + pub nip_fi_mode: buzz_auth::NipFiMode, /// Whether REST API requests must present a valid token. Independent of /// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT. pub require_auth_token: bool, @@ -881,6 +883,19 @@ impl Config { let auth = buzz_auth::AuthConfig { rate_limits: rate_limit_config_from_env()?, }; + let nip_fi_mode = match std::env::var("BUZZ_NIP_FI_MODE") { + Ok(value) if value.eq_ignore_ascii_case("off") => buzz_auth::NipFiMode::Off, + Ok(value) if value.eq_ignore_ascii_case("enforce") => buzz_auth::NipFiMode::Enforce, + Ok(value) if value.eq_ignore_ascii_case("deny-protected") => { + buzz_auth::NipFiMode::DenyProtected + } + Err(std::env::VarError::NotPresent) => buzz_auth::NipFiMode::Off, + _ => { + return Err(ConfigError::InvalidValue( + "BUZZ_NIP_FI_MODE must be off, enforce, or deny-protected".to_owned(), + )); + } + }; if !require_auth_token { warn!( @@ -1194,6 +1209,7 @@ impl Config { max_frame_bytes, slow_client_grace_limit, auth, + nip_fi_mode, require_auth_token, cors_origins, relay_private_key, diff --git a/crates/buzz-relay/src/corporate_identity.rs b/crates/buzz-relay/src/corporate_identity.rs index d984c5467b..f9f164cc4f 100644 --- a/crates/buzz-relay/src/corporate_identity.rs +++ b/crates/buzz-relay/src/corporate_identity.rs @@ -173,6 +173,45 @@ impl CorporateIdentityService { } } + /// Verify one assertion against exact provider-free route coordinates. + pub(crate) async fn verify_route_assertion( + &self, + token: &str, + authorization_domain: CommunityId, + transport: ProofTransport, + target_fingerprint: [u8; 32], + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> Result { + let header = decode_header(token) + .map_err(|e| CorporateIdentityError::InvalidJwt(format!("invalid JWT header: {e}")))?; + if !is_allowed_jwt_algorithm(header.alg) { + return Err(CorporateIdentityError::InvalidJwt(format!( + "unsupported JWT algorithm: {:?}", + header.alg + ))); + } + let kid = header + .kid + .as_deref() + .ok_or(CorporateIdentityError::MissingKid)?; + let (jwk, generation) = self.jwk_snapshot_for_kid(kid).await?; + let key_set = CanonicalVerifierKeySet::new(generation, JwkSet { keys: vec![jwk] }); + self.verifier + .as_ref() + .map_err(|error| CorporateIdentityError::InvalidJwt(error.code().to_owned()))? + .verify( + token, + &key_set, + authorization_domain, + transport, + target_fingerprint, + request_fingerprint, + transport_context_fingerprint, + ) + .map_err(|error| CorporateIdentityError::InvalidJwt(error.code().to_owned())) + } + /// Validate a JWT and extract the configured corporate identity claims. pub async fn validate_jwt( &self, @@ -253,11 +292,9 @@ impl CorporateIdentityService { if verifier.policy_id() != stamp.policy_id() { return false; } - self.jwks - .read() - .await - .as_ref() - .is_some_and(|cached| cached.generation == stamp.key_generation()) + self.jwks.read().await.as_ref().is_some_and(|cached| { + cached.generation == stamp.key_generation() && cached.expires_at > Instant::now() + }) } async fn jwk_snapshot_for_kid( @@ -361,6 +398,70 @@ impl CorporateIdentityService { } } +impl buzz_db::authorization_admission::AdmissionVerifierRechecker for CorporateIdentityService { + fn recheck<'a>( + &'a self, + expected: VerifierPolicyStamp, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result<(), buzz_db::authorization_admission::AdmissionCommitError>, + > + Send + + 'a, + >, + > { + Box::pin(async move { + if self.accepts_final_verifier_stamp(expected).await { + Ok(()) + } else { + Err(buzz_db::authorization_admission::AdmissionCommitError::AuthorizationDenied) + } + }) + } +} + +impl crate::state::InviteAssertionVerifier for CorporateIdentityService { + fn verify<'a>( + &'a self, + token: &'a str, + authorization_domain: CommunityId, + transport: ProofTransport, + target_fingerprint: [u8; 32], + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result< + buzz_auth::VerifiedFederatedAssertion, + crate::state::InviteAssertionError, + >, + > + Send + + 'a, + >, + > { + Box::pin(async move { + self.verify_route_assertion( + token, + authorization_domain, + transport, + target_fingerprint, + request_fingerprint, + transport_context_fingerprint, + ) + .await + .map_err(|error| match error { + CorporateIdentityError::Jwks(_) + | CorporateIdentityError::Db(_) + | CorporateIdentityError::FoundationIntegrationRequired => { + crate::state::InviteAssertionError::Unavailable + } + _ => crate::state::InviteAssertionError::Denied, + }) + }) + } +} + fn projected_verified_claims(token: &str) -> Result, CorporateIdentityError> { let mut segments = token.split('.'); let _header = segments.next(); @@ -1863,6 +1964,28 @@ mod tests { server.abort(); } + #[tokio::test] + async fn final_recheck_rejects_an_expired_key_generation() { + let service = CorporateIdentityService::new(test_config()); + let generation = VerifierKeyGeneration::new(1).expect("positive generation"); + let policy_id = service + .verifier + .as_ref() + .expect("test verifier policy") + .policy_id(); + *service.jwks.write().await = Some(CachedJwks { + set: JwkSet { keys: Vec::new() }, + generation, + expires_at: Instant::now() - Duration::from_secs(1), + }); + + assert!( + !service + .accepts_final_verifier_stamp(VerifierPolicyStamp::new(policy_id, generation)) + .await + ); + } + #[tokio::test] async fn jwks_refresh_is_single_flight() { let body = r#"{"keys":[{"kty":"RSA","n":"AQAB","e":"AQAB","kid":"test-kid","alg":"RS256","use":"sig"}]}"#; diff --git a/crates/buzz-relay/src/handlers/event.rs b/crates/buzz-relay/src/handlers/event.rs index fc1f109ffb..903ccb12af 100644 --- a/crates/buzz-relay/src/handlers/event.rs +++ b/crates/buzz-relay/src/handlers/event.rs @@ -723,6 +723,13 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc, + /// Exact HTTP body authenticated by the payload tag. + body: axum::body::Bytes, + }, + /// NIP-42-authenticated WebSocket origin. The submitted command event, + /// rather than connection scopes, supplies the operation-bound signature. + Nip42 { + /// Exact tenant WebSocket origin. + relay_url: Arc, + }, +} + +impl std::fmt::Debug for ModerationTransportEvidence { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + Self::Nip98 { .. } => "Nip98([REDACTED])", + Self::Nip42 { .. } => "Nip42([REDACTED])", + }; + formatter.write_str(name) + } +} + /// Authentication context for event ingestion — transport-neutral. #[derive(Debug, Clone)] pub enum IngestAuth { @@ -121,6 +150,8 @@ pub enum IngestAuth { channel_ids: Option>, /// WebSocket connection identifier. conn_id: Uuid, + /// Exact command-proof transport material. + moderation_evidence: ModerationTransportEvidence, }, /// HTTP bridge authenticated request (NIP-98 or dev X-Pubkey). Http { @@ -130,6 +161,8 @@ pub enum IngestAuth { scopes: Vec, /// How the HTTP request was authenticated. auth_method: HttpAuthMethod, + /// Exact command-proof material, present only for NIP-98 `/events`. + moderation_evidence: Option, }, } @@ -178,6 +211,20 @@ impl IngestAuth { pub fn is_http(&self) -> bool { matches!(self, Self::Http { .. }) } + + /// Borrow exact transport material for canonical moderation admission. + pub fn moderation_evidence(&self) -> Option<&ModerationTransportEvidence> { + match self { + Self::Nip42 { + moderation_evidence, + .. + } => Some(moderation_evidence), + Self::Http { + moderation_evidence, + .. + } => moderation_evidence.as_ref(), + } + } } fn emit_product_feedback_success( @@ -2073,7 +2120,7 @@ async fn ingest_event_inner( // The handler independently checks the durable ban state before executing // any command, which also covers NIP-98 and missed live disconnects. if buzz_core::kind::is_moderation_command_kind(kind_u32) { - super::moderation_commands::handle_moderation_command(tenant, state, &event) + super::moderation_commands::handle_moderation_command(tenant, state, &event, &auth) .await .map_err(IngestError::Rejected)?; return Ok(IngestResult { @@ -3192,6 +3239,7 @@ mod tests { pubkey: keys.public_key(), scopes: vec![Scope::MessagesWrite], auth_method: HttpAuthMethod::Nip98, + moderation_evidence: None, }; let tracer = Arc::new(VecTracer::default()); let abstract_state = state_for_request(&tenant, auth.pubkey()); @@ -3606,6 +3654,9 @@ mod tests { scopes: vec![], channel_ids: None, conn_id: Uuid::new_v4(), + moderation_evidence: ModerationTransportEvidence::Nip42 { + relay_url: "wss://relay.example".into(), + }, }; assert_ne!(principal.public_key(), envelope_signer.public_key()); @@ -3623,6 +3674,7 @@ mod tests { pubkey: keys.public_key(), scopes: vec![], auth_method: HttpAuthMethod::Nip98, + moderation_evidence: None, }; assert!( http_auth.is_http(), @@ -3639,6 +3691,9 @@ mod tests { scopes: vec![], channel_ids: None, conn_id: uuid::Uuid::new_v4(), + moderation_evidence: ModerationTransportEvidence::Nip42 { + relay_url: "wss://relay.example".into(), + }, }; assert!( !ws_auth.is_http(), diff --git a/crates/buzz-relay/src/handlers/moderation_commands.rs b/crates/buzz-relay/src/handlers/moderation_commands.rs index 5bb9e97daa..b7bbfb216e 100644 --- a/crates/buzz-relay/src/handlers/moderation_commands.rs +++ b/crates/buzz-relay/src/handlers/moderation_commands.rs @@ -10,7 +10,7 @@ use std::sync::Arc; -use buzz_auth::RouteCapability; +use buzz_auth::{FinalizedAuthContext, RouteCapability}; use buzz_core::kind::{ KIND_MODERATION_BAN, KIND_MODERATION_RESOLVE_REPORT, KIND_MODERATION_TIMEOUT, KIND_MODERATION_UNBAN, KIND_MODERATION_UNTIMEOUT, @@ -18,8 +18,9 @@ use buzz_core::kind::{ use buzz_core::tenant::{CommunityId, TenantContext}; use buzz_db::authorization_admission::{ AdmissionApplicationContext, AdmissionApplicationEffect, AdmissionApplicationOutcome, - AdmissionApplicationResult, AdmissionApplicationResultSchema, AdmissionCommitError, - AdmissionCommitOutcome, AdmissionCommitRequest, AdmissionObject, AdmissionObjectKind, + AdmissionApplicationResult, AdmissionApplicationResultBinding, + AdmissionApplicationResultSchema, AdmissionCommitError, AdmissionCommitOutcome, + AdmissionCommitRequest, AdmissionObject, AdmissionObjectKind, CanonicalAdmissionCommitter, }; use buzz_db::moderation::NewAction; use chrono::{DateTime, TimeZone, Utc}; @@ -30,6 +31,7 @@ use sqlx::{Postgres, Transaction}; use tracing::info; use uuid::Uuid; +use crate::handlers::ingest::{IngestAuth, ModerationTransportEvidence}; use crate::handlers::moderation_authz::{ authorize_moderation_action_tx, ModerationAction, ModerationTarget, }; @@ -38,21 +40,282 @@ use crate::state::AppState; const MAX_COMMAND_SKEW_SECS: i64 = 120; -/// Legacy direct entry point. -/// -/// It validates the command but fails closed because this signature has no -/// caller-owned transaction. Canonical admission must instead call -/// [`prepare_moderation_application_effect`], apply the returned effect on its -/// transaction, commit, and then dispatch the returned post-commit action. +/// Execute one moderation command under the configured admission mode. pub async fn handle_moderation_command( tenant: &TenantContext, - _state: &Arc, + state: &Arc, event: &Event, + auth: &IngestAuth, ) -> Result<(), String> { - let _effect = prepare_moderation_application_effect(tenant, event)?; - Err(error( - "moderation command requires the canonical caller-owned admission transaction", - )) + execute_moderation_command( + tenant, + &state.db, + state.config.nip_fi_mode, + &state.config.relay_url, + event, + auth, + |action| async move { + dispatch_moderation_postcommit(tenant, state, action).await; + }, + ) + .await +} + +async fn execute_moderation_command( + tenant: &TenantContext, + db: &buzz_db::Db, + mode: buzz_auth::NipFiMode, + relay_url: &str, + event: &Event, + auth: &IngestAuth, + dispatch: F, +) -> Result<(), String> +where + F: FnOnce(ModerationPostCommitAction) -> Fut, + Fut: std::future::Future, +{ + match mode { + buzz_auth::NipFiMode::Off => { + let mut effect = prepare_moderation_application_effect(tenant, event)?; + let mut transaction = db.begin_transaction().await.map_err(database_error)?; + let action = effect.apply_in_transaction(&mut transaction).await?; + transaction.commit().await.map_err(database_error)?; + dispatch(action).await; + Ok(()) + } + buzz_auth::NipFiMode::DenyProtected => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::ModeDenied, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + Err(moderation_unavailable()) + } + buzz_auth::NipFiMode::Enforce => { + let effect = match prepare_moderation_application_effect(tenant, event) { + Ok(effect) => effect, + Err(_) => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + }; + let object = effect.admission_object(); + let now = Utc::now(); + let proof = match auth.moderation_evidence() { + Some(ModerationTransportEvidence::Nip98 { + authorization_event, + body, + }) => { + let expected_url = + crate::api::bridge::nip98_expected_url(relay_url, tenant, "/events"); + let coordinates = match buzz_auth::Nip98ModerationCommandCoordinates::new( + tenant.community(), + *object.key(), + &expected_url, + body, + event, + ) { + Ok(coordinates) => coordinates, + Err(_) => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + }; + match buzz_auth::verify_nip98_moderation_command_proof( + authorization_event, + &coordinates, + body, + now, + ) { + Ok(proof) => proof, + Err(_) => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + } + } + Some(ModerationTransportEvidence::Nip42 { relay_url }) => { + let coordinates = match buzz_auth::Nip42ModerationCommandCoordinates::new( + tenant.community(), + *object.key(), + relay_url, + event, + ) { + Ok(coordinates) => coordinates, + Err(_) => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + }; + match buzz_auth::verify_nip42_moderation_command_proof(event, &coordinates, now) + { + Ok(proof) => proof, + Err(_) => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + } + } + None => { + record_moderation_denial( + db, + tenant.community(), + buzz_db::authorization_events::ProtectedDenialReason::MissingProof, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(moderation_denied()); + } + }; + let request = match db.prepare_canonical_moderation_request(proof, object).await { + Ok(request) => request, + Err(commit_error) => { + record_moderation_denial( + db, + tenant.community(), + protected_denial_reason(commit_error), + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(admission_error(commit_error)); + } + }; + let request = match install_moderation_application_effect(request, effect) { + Ok(request) => request, + Err(commit_error) => { + record_moderation_denial( + db, + tenant.community(), + protected_denial_reason(commit_error), + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + return Err(admission_error(commit_error)); + } + }; + let outcome = match db.canonical_moderation_committer().commit(request).await { + Ok(outcome) => outcome, + Err(commit_error) => { + let fallback_reason = match commit_error { + AdmissionCommitError::InvalidRequest => Some( + buzz_db::authorization_events::ProtectedDenialReason::InvalidProof, + ), + AdmissionCommitError::AuthorizationDenied => Some( + buzz_db::authorization_events::ProtectedDenialReason::AuthorizationDenied, + ), + AdmissionCommitError::IntentConflict + | AdmissionCommitError::ReplayRejected => Some( + buzz_db::authorization_events::ProtectedDenialReason::ReplayConflict, + ), + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::DependencyUnavailable => Some( + buzz_db::authorization_events::ProtectedDenialReason::DependencyUnavailable, + ), + _ => None, + }; + if let Some(reason) = fallback_reason { + record_moderation_denial( + db, + tenant.community(), + reason, + buzz_db::authorization_events::ProtectedDenialAction::ModerationCommand, + ) + .await?; + } + return Err(admission_error(commit_error)); + } + }; + if let Err(commit_error) = + dispatch_committed_moderation_outcome(tenant, outcome, dispatch).await + { + return Err(admission_error(commit_error)); + } + Ok(()) + } + } +} + +fn protected_denial_reason( + commit_error: AdmissionCommitError, +) -> buzz_db::authorization_events::ProtectedDenialReason { + use buzz_db::authorization_events::ProtectedDenialReason; + match commit_error { + AdmissionCommitError::InvalidRequest | AdmissionCommitError::RecordedInvalidRequest => { + ProtectedDenialReason::InvalidProof + } + AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied => { + ProtectedDenialReason::AuthorizationDenied + } + AdmissionCommitError::IntentConflict + | AdmissionCommitError::ReplayRejected + | AdmissionCommitError::RecordedIntentConflict + | AdmissionCommitError::RecordedReplayRejected => ProtectedDenialReason::ReplayConflict, + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => { + ProtectedDenialReason::DependencyUnavailable + } + } +} + +async fn record_moderation_denial( + db: &buzz_db::Db, + community: CommunityId, + reason: buzz_db::authorization_events::ProtectedDenialReason, + action: buzz_db::authorization_events::ProtectedDenialAction, +) -> Result<(), String> { + if db + .record_protected_denial_bucket( + community, + buzz_db::authorization_events::ProtectedDenialSurface::Moderation, + reason, + action, + ) + .await + .is_err() + { + let _ = db + .latch_authorization_event_failure( + community, + buzz_db::authorization_events::AuthorizationAuditFailureCode::StorageUnavailable, + ) + .await; + return Err(moderation_unavailable()); + } + Ok(()) } /// A validated, tenant-bound mutation consumed by one canonical admission @@ -94,13 +357,43 @@ enum PreparedModerationCommand { }, } +#[derive(Clone, Copy)] +enum ModerationApplicationTarget<'a> { + Pubkey(&'a [u8]), + ReportEvent(&'a [u8]), +} + +impl<'a> ModerationApplicationTarget<'a> { + fn parts(self) -> Option<(&'static [u8], &'a [u8])> { + let (kind, target) = match self { + Self::Pubkey(target) => (b"pubkey".as_slice(), target), + Self::ReportEvent(target) => (b"report".as_slice(), target), + }; + (target.len() == 32).then_some((kind, target)) + } +} + +impl PreparedModerationCommand { + fn application_target(&self) -> ModerationApplicationTarget<'_> { + match self { + Self::Ban { target, .. } + | Self::Unban { target } + | Self::Timeout { target, .. } + | Self::Untimeout { target } => ModerationApplicationTarget::Pubkey(target), + Self::ResolveReport { + report_event_id, .. + } => ModerationApplicationTarget::ReportEvent(report_event_id), + } + } +} + /// Opaque socket/notice work returned by an applied effect. /// /// The admission owner must retain this value until its transaction commits. #[must_use = "dispatch this action only after the transaction commits"] pub struct ModerationPostCommitAction(ModerationPostCommitKind); -#[derive(Serialize, Deserialize)] +#[derive(Clone, Serialize, Deserialize)] enum ModerationPostCommitKind { Ban { target: Vec, @@ -120,6 +413,7 @@ enum ModerationPostCommitKind { target: Vec, }, ResolveReport { + report_event_id: Vec, report_id: Uuid, reporter_pubkey: Vec, status: String, @@ -128,6 +422,24 @@ enum ModerationPostCommitKind { }, } +impl ModerationPostCommitKind { + fn application_target(&self) -> Result, AdmissionCommitError> { + let target = match self { + Self::Ban { target, .. } + | Self::Unban { target } + | Self::Timeout { target, .. } + | Self::Untimeout { target } => ModerationApplicationTarget::Pubkey(target), + Self::ResolveReport { + report_event_id, .. + } => ModerationApplicationTarget::ReportEvent(report_event_id), + }; + target + .parts() + .map(|_| target) + .ok_or(AdmissionCommitError::IntentConflict) + } +} + #[derive(Serialize, Deserialize)] struct ModerationApplicationResultPayload { object_key: [u8; 32], @@ -135,6 +447,25 @@ struct ModerationApplicationResultPayload { action: ModerationPostCommitKind, } +#[derive(Clone, Copy)] +struct ModerationResultBinding { + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_intent_digest: [u8; 32], +} + +impl From for ModerationResultBinding { + fn from(binding: AdmissionApplicationResultBinding) -> Self { + Self { + authorization_domain: binding.authorization_domain(), + object: binding.object(), + semantic_fingerprint: *binding.semantic_fingerprint(), + application_intent_digest: *binding.application_intent_digest(), + } + } +} + /// Parse, validate, and bind a signed command without performing I/O. pub fn prepare_moderation_application_effect( tenant: &TenantContext, @@ -170,7 +501,8 @@ pub fn prepare_moderation_application_effect( let actor = event.pubkey.to_bytes().to_vec(); let event_id = event.id.to_hex(); - let object = moderation_object(tenant.community(), &command)?; + let object = moderation_object(tenant.community(), command.application_target()) + .ok_or_else(|| invalid("moderation target could not be bound"))?; let intent_digest = framed_digest( b"buzz:nip-fi:moderation-effect-intent:v1", &[ @@ -403,6 +735,7 @@ impl ModerationAdmissionApplicationEffect { }); Ok(ModerationPostCommitAction( ModerationPostCommitKind::ResolveReport { + report_event_id, report_id: report.id, reporter_pubkey: report.reporter_pubkey, status, @@ -477,14 +810,15 @@ impl AdmissionApplicationEffect for ModerationAdmissionApplicationEffect { .apply_in_transaction(transaction) .await .map_err(map_application_error)?; - let payload = serde_json::to_vec(&ModerationApplicationResultPayload { - object_key: *self.object.key(), - intent_digest: self.intent_digest, - action: action.0, - }) - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; - let result = AdmissionApplicationResult::new(moderation_result_schema(), 1, payload) - .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + let action_object = moderation_object( + context.authorization_domain(), + action.0.application_target()?, + ) + .ok_or(AdmissionCommitError::IntentConflict)?; + if action_object != self.object { + return Err(AdmissionCommitError::IntentConflict); + } + let result = moderation_application_result(self.object, self.intent_digest, action.0)?; let effect_digest = framed_digest( b"buzz:nip-fi:moderation-effect:v1", &[ @@ -518,46 +852,86 @@ pub fn install_moderation_application_effect( /// Exact replay returns the original typed result but deliberately performs no /// socket disconnect or notice. A failed/rolled-back commit produces no /// outcome, so it cannot reach this boundary either. -pub async fn dispatch_committed_moderation_outcome( +async fn dispatch_committed_moderation_outcome( tenant: &TenantContext, - state: &Arc, - outcome: &AdmissionCommitOutcome, -) -> Result { + outcome: AdmissionCommitOutcome, + dispatch: F, +) -> Result +where + F: FnOnce(ModerationPostCommitAction) -> Fut, + Fut: std::future::Future, +{ let Some(action) = committed_moderation_action(tenant, outcome)? else { return Ok(false); }; - dispatch_moderation_postcommit(tenant, state, action).await; + dispatch(action).await; Ok(true) } fn committed_moderation_action( tenant: &TenantContext, - outcome: &AdmissionCommitOutcome, + outcome: AdmissionCommitOutcome, ) -> Result, AdmissionCommitError> { - let (receipt, application_result) = match outcome { + let (receipt, application_result, binding) = match outcome { AdmissionCommitOutcome::Committed { + authorization, receipt, application_result, - .. + application_result_binding, } => { - if receipt.authorization_domain() != tenant.community() - || receipt.object().kind() != AdmissionObjectKind::ModerationTarget - { - return Err(AdmissionCommitError::IntentConflict); - } + validate_moderation_dispatch_binding(&authorization, tenant, receipt)?; ( - *receipt, - application_result - .as_ref() - .ok_or(AdmissionCommitError::IntentConflict)?, + receipt, + application_result.ok_or(AdmissionCommitError::IntentConflict)?, + application_result_binding + .ok_or(AdmissionCommitError::IntentConflict)? + .into(), ) } AdmissionCommitOutcome::ExactReplay { .. } => return Ok(None), }; - validate_committed_moderation_result(receipt, application_result).map(Some) + validate_committed_moderation_result(tenant, binding, receipt, &application_result).map(Some) +} + +fn validate_moderation_dispatch_binding( + authorization: &FinalizedAuthContext, + tenant: &TenantContext, + receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, +) -> Result<(), AdmissionCommitError> { + validate_moderation_dispatch_coordinates( + authorization.authorization_domain(), + authorization.capability(), + authorization.request_fingerprint(), + authorization.lease().request_binding().1, + tenant, + receipt, + ) +} + +fn validate_moderation_dispatch_coordinates( + authorization_domain: CommunityId, + capability: RouteCapability, + request_fingerprint: &[u8; 32], + target_fingerprint: &[u8; 32], + tenant: &TenantContext, + receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, +) -> Result<(), AdmissionCommitError> { + if authorization_domain != tenant.community() + || authorization_domain != receipt.authorization_domain() + || capability != RouteCapability::Moderation + || request_fingerprint != receipt.request_fingerprint() + || target_fingerprint != receipt.object().key() + || receipt.object().kind() != AdmissionObjectKind::ModerationTarget + { + Err(AdmissionCommitError::IntentConflict) + } else { + Ok(()) + } } fn validate_committed_moderation_result( + tenant: &TenantContext, + binding: ModerationResultBinding, receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, application_result: &AdmissionApplicationResult, ) -> Result { @@ -567,14 +941,33 @@ fn validate_committed_moderation_result( let payload = serde_json::from_slice::(application_result.payload()) .map_err(|_| AdmissionCommitError::IntentConflict)?; - if receipt.object().kind() != AdmissionObjectKind::ModerationTarget - || receipt.object().key() != &payload.object_key - || payload.intent_digest == [0; 32] + let action_object = moderation_object(tenant.community(), payload.action.application_target()?) + .ok_or(AdmissionCommitError::IntentConflict)?; + if binding.authorization_domain != tenant.community() + || binding.object != action_object + || receipt.authorization_domain() != binding.authorization_domain + || receipt.object() != action_object + || receipt.semantic_fingerprint() != &binding.semantic_fingerprint + || action_object.key() != &payload.object_key + || payload.intent_digest != binding.application_intent_digest { return Err(AdmissionCommitError::IntentConflict); } - let expected_digest = - canonical_moderation_result_digest(receipt, payload.intent_digest, application_result)?; + let expected_result = moderation_application_result( + action_object, + binding.application_intent_digest, + payload.action.clone(), + )?; + if application_result != &expected_result { + return Err(AdmissionCommitError::IntentConflict); + } + let expected_digest = canonical_moderation_result_digest( + binding.authorization_domain, + action_object, + binding.semantic_fingerprint, + binding.application_intent_digest, + &expected_result, + )?; if receipt.application_result_digest() != Some(&expected_digest) { return Err(AdmissionCommitError::IntentConflict); } @@ -585,23 +978,42 @@ fn moderation_result_schema() -> AdmissionApplicationResultSchema { AdmissionApplicationResultSchema::moderation() } +fn moderation_application_result( + object: AdmissionObject, + intent_digest: [u8; 32], + action: ModerationPostCommitKind, +) -> Result { + let payload = serde_json::to_vec(&ModerationApplicationResultPayload { + object_key: *object.key(), + intent_digest, + action, + }) + .map_err(|_| AdmissionCommitError::DependencyUnavailable)?; + AdmissionApplicationResult::new(moderation_result_schema(), 1, payload) + .map_err(|_| AdmissionCommitError::DependencyUnavailable) +} + fn canonical_moderation_result_digest( - receipt: buzz_db::authorization_admission::AdmissionCommitReceipt, + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], application_intent_digest: [u8; 32], result: &AdmissionApplicationResult, ) -> Result<[u8; 32], AdmissionCommitError> { - if application_intent_digest == [0; 32] { + if authorization_domain.as_uuid().is_nil() + || semantic_fingerprint == [0; 32] + || application_intent_digest == [0; 32] + { return Err(AdmissionCommitError::InvalidRequest); } - let object = receipt.object(); let schema = result.schema(); Ok(admission_framed_digest( b"buzz:canonical-application-result:v1", &[ - receipt.authorization_domain().as_uuid().as_bytes(), + authorization_domain.as_uuid().as_bytes(), &object.kind().database_code().to_be_bytes(), object.key(), - receipt.semantic_fingerprint(), + &semantic_fingerprint, &application_intent_digest, schema.type_key(), &schema.version().to_be_bytes(), @@ -634,7 +1046,7 @@ fn map_application_error(error: String) -> AdmissionCommitError { } /// Run the socket and notice work represented by `action` after commit. -pub async fn dispatch_moderation_postcommit( +pub(crate) async fn dispatch_moderation_postcommit( tenant: &TenantContext, state: &Arc, action: ModerationPostCommitAction, @@ -696,6 +1108,7 @@ pub async fn dispatch_moderation_postcommit( info!(target = %hex::encode(&target), "community timeout cleared"); } ModerationPostCommitKind::ResolveReport { + report_event_id: _, report_id, reporter_pubkey, status, @@ -723,25 +1136,14 @@ pub async fn dispatch_moderation_postcommit( fn moderation_object( community: CommunityId, - command: &PreparedModerationCommand, -) -> Result { - let (target_kind, target) = match command { - PreparedModerationCommand::Ban { target, .. } - | PreparedModerationCommand::Unban { target } - | PreparedModerationCommand::Timeout { target, .. } - | PreparedModerationCommand::Untimeout { target } => { - (b"pubkey".as_slice(), target.as_slice()) - } - PreparedModerationCommand::ResolveReport { - report_event_id, .. - } => (b"report".as_slice(), report_event_id.as_slice()), - }; + target: ModerationApplicationTarget<'_>, +) -> Option { + let (target_kind, target) = target.parts()?; let key = framed_digest( b"buzz:nip-fi:moderation-target:v1", &[community.as_uuid().as_bytes(), target_kind, target], ); AdmissionObject::new(AdmissionObjectKind::ModerationTarget, key) - .ok_or_else(|| invalid("moderation target could not be bound")) } fn framed_digest(domain: &[u8], fields: &[&[u8]]) -> [u8; 32] { @@ -864,6 +1266,30 @@ fn database_error(e: impl std::fmt::Display) -> String { error(format!("database error: {e}")) } +fn admission_error(error_value: AdmissionCommitError) -> String { + match error_value { + AdmissionCommitError::InvalidRequest + | AdmissionCommitError::RecordedInvalidRequest + | AdmissionCommitError::AuthorizationDenied + | AdmissionCommitError::RecordedAuthorizationDenied + | AdmissionCommitError::IntentConflict + | AdmissionCommitError::ReplayRejected + | AdmissionCommitError::RecordedIntentConflict + | AdmissionCommitError::RecordedReplayRejected => moderation_denied(), + AdmissionCommitError::AuditUnavailable + | AdmissionCommitError::RecordedAuditUnavailable + | AdmissionCommitError::DependencyUnavailable => moderation_unavailable(), + } +} + +fn moderation_denied() -> String { + invalid("moderation_admission_denied") +} + +fn moderation_unavailable() -> String { + error("moderation_admission_unavailable") +} + fn invalid(message: impl Into) -> String { format!("invalid: {}", message.into()) } @@ -932,6 +1358,8 @@ mod tests { use super::*; use chrono::Duration; use nostr::{EventBuilder, Keys, Kind, Tag}; + use sqlx::PgPool; + use std::sync::atomic::{AtomicUsize, Ordering}; fn make_event(kind: u16, created_at_secs: u64, tags: Vec>) -> Event { let keys = Keys::generate(); @@ -957,6 +1385,324 @@ mod tests { TenantContext::resolved(CommunityId::from_uuid(Uuid::from_u128(id)), "relay.example") } + async fn install_moderation_authority( + pool: &PgPool, + domain: CommunityId, + actor: nostr::PublicKey, + with_audit_capacity: bool, + ) { + let operation_id = Uuid::new_v4(); + let history_id = Uuid::new_v4(); + let binding_id = Uuid::new_v4(); + let request_fingerprint = [31_u8; 32]; + let actor_bytes = actor.to_bytes(); + let mut transaction = pool.begin().await.expect("begin authority fixture"); + sqlx::query("INSERT INTO communities (id, host) VALUES ($1, $2)") + .bind(domain.as_uuid()) + .bind(format!("moderation-{}.example", domain.as_uuid().simple())) + .execute(&mut *transaction) + .await + .expect("insert authority community"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 1, 2, $2, transaction_timestamp() - interval '1 second')", + ) + .bind(domain.as_uuid()) + .bind([32_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert authority policy"); + sqlx::query( + "INSERT INTO authorization_invalidation_domains (community_id, current_generation) \ + VALUES ($1, 0)", + ) + .bind(domain.as_uuid()) + .execute(&mut *transaction) + .await + .expect("insert invalidation domain"); + let max_events = if with_audit_capacity { 32_i64 } else { 1_i64 }; + sqlx::query( + "INSERT INTO authorization_event_capacity \ + (community_id, max_events_per_domain, max_bytes_per_domain, max_envelope_bytes) \ + VALUES ($1, $2, 2097152, 16384)", + ) + .bind(domain.as_uuid()) + .bind(max_events) + .execute(&mut *transaction) + .await + .expect("insert audit capacity"); + sqlx::query( + "INSERT INTO authorization_operation_receipts \ + (community_id, operation_id, request_fingerprint, operation_kind, \ + actor_fingerprint, outcome_code, result_digest) \ + VALUES ($1, $2, $3, 1, $4, 1, $5)", + ) + .bind(domain.as_uuid()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(actor_bytes.as_slice()) + .bind([33_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert binding receipt"); + sqlx::query( + "INSERT INTO authorization_events \ + (community_id, event_id, event_kind, outcome_code, reason_code, actor_kind, \ + actor_fingerprint, operation_id, request_fingerprint, correlation_id, attempt_id, \ + occurred_at, canonical_envelope, envelope_digest) \ + VALUES ($1, $2, 1, 1, 1, 1, $3, $4, $5, $6, $7, \ + transaction_timestamp(), $8, $9)", + ) + .bind(domain.as_uuid()) + .bind(Uuid::new_v4()) + .bind(actor_bytes.as_slice()) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind(Uuid::new_v4()) + .bind(Uuid::new_v4()) + .bind([1_u8].as_slice()) + .bind([37_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert binding audit event"); + let binding_version: i64 = sqlx::query_scalar( + "INSERT INTO identity_bindings \ + (community_id, binding_id, issuer, subject, principal_fingerprint, \ + event_author_pubkey, binding_state, lifecycle_revision, binding_provenance, \ + policy_revision, enrollment_evidence_digest, birth_history_id, \ + creation_operation_id, creation_request_fingerprint) \ + VALUES ($1, $2, 'https://issuer.example', $3, $4, $5, 1, 1, 2, 1, $6, $7, $8, $9) \ + RETURNING binding_version", + ) + .bind(domain.as_uuid()) + .bind(binding_id) + .bind(format!("moderator-{binding_id}")) + .bind([34_u8; 32].as_slice()) + .bind(actor_bytes.as_slice()) + .bind([35_u8; 32].as_slice()) + .bind(history_id) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .fetch_one(&mut *transaction) + .await + .expect("insert active moderation binding"); + sqlx::query( + "INSERT INTO identity_lifecycle_history \ + (community_id, history_id, transition_kind, outcome_code, successor_binding_id, \ + successor_binding_version, successor_lifecycle_revision, successor_state, \ + operation_id, request_fingerprint, transition_digest) \ + VALUES ($1, $2, 1, 1, $3, $4, 1, 1, $5, $6, $7)", + ) + .bind(domain.as_uuid()) + .bind(history_id) + .bind(binding_id) + .bind(binding_version) + .bind(operation_id) + .bind(request_fingerprint.as_slice()) + .bind([36_u8; 32].as_slice()) + .execute(&mut *transaction) + .await + .expect("insert binding history"); + sqlx::query( + "INSERT INTO relay_members (community_id, pubkey, role, added_by) \ + VALUES ($1, $2, 'owner', NULL)", + ) + .bind(domain.as_uuid()) + .bind(hex::encode(actor_bytes)) + .execute(&mut *transaction) + .await + .expect("insert moderation owner"); + transaction + .commit() + .await + .expect("commit authority fixture"); + } + + fn signed_ban(keys: &Keys, target: nostr::PublicKey) -> Event { + let target_hex = target.to_hex(); + EventBuilder::new(Kind::from(KIND_MODERATION_BAN as u16), "") + .tag(Tag::parse(["p", target_hex.as_str()]).expect("moderation target tag")) + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(keys) + .expect("sign moderation command") + } + + fn signed_unban(keys: &Keys, target: nostr::PublicKey, nonce: &str) -> Event { + let target_hex = target.to_hex(); + EventBuilder::new(Kind::from(KIND_MODERATION_UNBAN as u16), "") + .tags([ + Tag::parse(["p", target_hex.as_str()]).expect("moderation target tag"), + Tag::parse(["nonce", nonce]).expect("test nonce tag"), + ]) + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(keys) + .expect("sign moderation command") + } + + fn websocket_moderation_proof( + domain: CommunityId, + object: AdmissionObject, + event: &Event, + ) -> buzz_auth::VerifiedModerationCommandProof { + let coordinates = buzz_auth::Nip42ModerationCommandCoordinates::new( + domain, + *object.key(), + "wss://relay.example", + event, + ) + .expect("moderation coordinates"); + buzz_auth::verify_nip42_moderation_command_proof(event, &coordinates, Utc::now()) + .expect("moderation proof") + } + + fn websocket_ingest_auth(keys: &Keys, relay_url: &str) -> IngestAuth { + IngestAuth::Nip42 { + pubkey: keys.public_key(), + scopes: Vec::new(), + channel_ids: None, + conn_id: Uuid::new_v4(), + moderation_evidence: ModerationTransportEvidence::Nip42 { + relay_url: relay_url.into(), + }, + } + } + + fn http_ingest_auth(event: &Event, keys: &Keys) -> IngestAuth { + let url = "https://relay.example/events"; + let body = serde_json::to_vec(event).expect("serialize moderation command"); + let payload = hex::encode(Sha256::digest(&body)); + let authorization = EventBuilder::new(Kind::HttpAuth, "") + .tags([ + Tag::parse(["u", url]).expect("NIP-98 URL tag"), + Tag::parse(["method", "POST"]).expect("NIP-98 method tag"), + Tag::parse(["payload", payload.as_str()]).expect("NIP-98 payload tag"), + ]) + .custom_created_at(nostr::Timestamp::now()) + .sign_with_keys(keys) + .expect("sign NIP-98 authorization"); + IngestAuth::Http { + pubkey: keys.public_key(), + scopes: buzz_auth::Scope::all_known(), + auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, + moderation_evidence: Some(ModerationTransportEvidence::Nip98 { + authorization_event: serde_json::to_string(&authorization) + .expect("serialize NIP-98 authorization") + .into(), + body: body.into(), + }), + } + } + + fn receipt_with_application_result( + tenant: &TenantContext, + object: AdmissionObject, + intent_digest: [u8; 32], + result: &AdmissionApplicationResult, + ) -> buzz_db::authorization_admission::AdmissionCommitReceipt { + let provisional = buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( + tenant.community(), + object, + Uuid::new_v4(), + [9; 32], + [10; 32], + buzz_db::authorization_admission::AdmissionCommitDigests::new([11; 32], Some([12; 32])) + .expect("provisional digests"), + Uuid::new_v4(), + ) + .expect("provisional receipt"); + let application_result_digest = canonical_moderation_result_digest( + tenant.community(), + object, + *provisional.semantic_fingerprint(), + intent_digest, + result, + ) + .expect("canonical result digest"); + buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( + tenant.community(), + object, + provisional.operation_id(), + *provisional.request_fingerprint(), + *provisional.semantic_fingerprint(), + buzz_db::authorization_admission::AdmissionCommitDigests::new( + *provisional.result_digest(), + Some(application_result_digest), + ) + .expect("canonical digests"), + provisional.audit_event_id(), + ) + .expect("canonical receipt") + } + + fn result_binding( + tenant: &TenantContext, + object: AdmissionObject, + intent_digest: [u8; 32], + ) -> ModerationResultBinding { + ModerationResultBinding { + authorization_domain: tenant.community(), + object, + semantic_fingerprint: [10; 32], + application_intent_digest: intent_digest, + } + } + + fn application_result_digest( + authorization_domain: CommunityId, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_intent_digest: [u8; 32], + result: &AdmissionApplicationResult, + ) -> [u8; 32] { + let schema = result.schema(); + let domain = b"buzz:canonical-application-result:v1"; + let object_kind = object.kind().database_code().to_be_bytes(); + let schema_version = schema.version().to_be_bytes(); + let result_code = result.code().to_be_bytes(); + let fields: [&[u8]; 9] = [ + authorization_domain.as_uuid().as_bytes(), + &object_kind, + object.key(), + &semantic_fingerprint, + &application_intent_digest, + schema.type_key(), + &schema_version, + &result_code, + result.payload(), + ]; + let mut digest = Sha256::new(); + digest.update((domain.len() as u64).to_be_bytes()); + digest.update(domain); + for field in fields { + digest.update((field.len() as u64).to_be_bytes()); + digest.update(field); + } + digest.finalize().into() + } + + fn receipt_with_application_digest( + tenant: &TenantContext, + object: AdmissionObject, + semantic_fingerprint: [u8; 32], + application_result_digest: [u8; 32], + ) -> buzz_db::authorization_admission::AdmissionCommitReceipt { + buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( + tenant.community(), + object, + Uuid::new_v4(), + [9; 32], + semantic_fingerprint, + buzz_db::authorization_admission::AdmissionCommitDigests::new( + [11; 32], + Some(application_result_digest), + ) + .expect("receipt digests"), + Uuid::new_v4(), + ) + .expect("storage receipt") + } + #[test] fn banned_admin_cannot_reach_an_unban_command() { let banned = buzz_db::moderation::RestrictionState { @@ -975,6 +1721,37 @@ mod tests { assert!(ensure_actor_not_banned(&timed_out).is_ok()); } + #[test] + fn protected_moderation_denials_use_stable_public_tokens() { + use buzz_db::authorization_admission::AdmissionCommitError; + + for denial in [ + AdmissionCommitError::InvalidRequest, + AdmissionCommitError::AuthorizationDenied, + AdmissionCommitError::IntentConflict, + AdmissionCommitError::ReplayRejected, + AdmissionCommitError::RecordedInvalidRequest, + AdmissionCommitError::RecordedAuthorizationDenied, + AdmissionCommitError::RecordedIntentConflict, + AdmissionCommitError::RecordedReplayRejected, + ] { + assert_eq!( + admission_error(denial), + "invalid: moderation_admission_denied" + ); + } + for unavailable in [ + AdmissionCommitError::AuditUnavailable, + AdmissionCommitError::RecordedAuditUnavailable, + AdmissionCommitError::DependencyUnavailable, + ] { + assert_eq!( + admission_error(unavailable), + "error: moderation_admission_unavailable" + ); + } + } + #[test] fn prepared_effect_is_bound_to_server_resolved_tenant() { let event = make_event( @@ -992,7 +1769,7 @@ mod tests { } #[test] - fn exact_replay_never_yields_a_postcommit_action() { + fn moderation_exact_replay_never_yields_a_postcommit_action() { let tenant = tenant(9); let object = AdmissionObject::new(AdmissionObjectKind::ModerationTarget, [7; 32]) .expect("moderation object"); @@ -1021,16 +1798,469 @@ mod tests { application_result: Some(result), }; assert!(matches!( - committed_moderation_action(&tenant, &outcome), + committed_moderation_action(&tenant, outcome), Ok(None) )); } + #[tokio::test] + async fn production_commit_dispatches_only_one_fresh_moderation_outcome() { + let database_url = std::env::var("BUZZ_TEST_DATABASE_URL") + .expect("BUZZ_TEST_DATABASE_URL must name disposable PostgreSQL"); + let pool = PgPool::connect(&database_url) + .await + .expect("connect disposable PostgreSQL"); + buzz_db::migration::run_migrations(&pool) + .await + .expect("run migrations"); + let db = buzz_db::Db::new(&buzz_db::DbConfig { + database_url, + max_connections: 4, + min_connections: 0, + ..buzz_db::DbConfig::default() + }) + .await + .expect("connect canonical database"); + let keys = Keys::generate(); + + let committed_domain = CommunityId::from_uuid(Uuid::new_v4()); + install_moderation_authority(&pool, committed_domain, keys.public_key(), true).await; + let committed_tenant = TenantContext::resolved(committed_domain, "relay.example"); + let committed_target = Keys::generate().public_key(); + let committed_event = signed_ban(&keys, committed_target); + let dispatches = AtomicUsize::new(0); + execute_moderation_command( + &committed_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://relay.example", + &committed_event, + &websocket_ingest_auth(&keys, "wss://relay.example"), + |_| async { + dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .expect("execute committed moderation command"); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + + execute_moderation_command( + &committed_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://relay.example", + &committed_event, + &http_ingest_auth(&committed_event, &keys), + |_| async { + dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .expect("execute cross-transport exact replay"); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + + let rollback_domain = CommunityId::from_uuid(Uuid::new_v4()); + install_moderation_authority(&pool, rollback_domain, keys.public_key(), true).await; + sqlx::query("UPDATE relay_members SET role='member' WHERE community_id=$1 AND pubkey=$2") + .bind(rollback_domain.as_uuid()) + .bind(keys.public_key().to_hex()) + .execute(&pool) + .await + .expect("remove moderator role before protected admission"); + let rollback_tenant = TenantContext::resolved(rollback_domain, "rollback.example"); + let rollback_target = Keys::generate().public_key(); + let rollback_event = signed_ban(&keys, rollback_target); + assert!(execute_moderation_command( + &rollback_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://rollback.example", + &rollback_event, + &websocket_ingest_auth(&keys, "wss://rollback.example"), + |_| async { + dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .is_err()); + let rollback_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM moderation_actions WHERE community_id=$1") + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count rolled-back actions"); + assert_eq!(rollback_rows, 0); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + let rollback_denials: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2 AND reason_code=9", + ) + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count durable rolled-back moderation denial"); + assert_eq!(rollback_denials, 1); + let rollback_envelope: Vec = sqlx::query_scalar( + "SELECT canonical_envelope FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2", + ) + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("read redacted moderation denial envelope"); + for sensitive in [keys.public_key().to_bytes(), rollback_target.to_bytes()] { + assert!( + !rollback_envelope + .windows(sensitive.len()) + .any(|window| window == sensitive), + "canonical denial envelope retained a raw moderation coordinate" + ); + } + let rollback_receipts: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_kind=11 AND outcome_code=2", + ) + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count durable denied moderation receipt"); + assert_eq!(rollback_receipts, 1); + let rollback_buckets: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=3", + ) + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count unresolved rollback buckets"); + assert_eq!(rollback_buckets, 0); + assert!(execute_moderation_command( + &rollback_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://rollback.example", + &rollback_event, + &websocket_ingest_auth(&keys, "wss://rollback.example"), + |_| async { + dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .is_err()); + let replayed_denials: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2", + ) + .bind(rollback_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count exact denied replay evidence"); + assert_eq!(replayed_denials, 1); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + + let capacity_domain = CommunityId::from_uuid(Uuid::new_v4()); + install_moderation_authority(&pool, capacity_domain, keys.public_key(), false).await; + let capacity_tenant = TenantContext::resolved(capacity_domain, "capacity.example"); + let capacity_event = signed_ban(&keys, Keys::generate().public_key()); + assert!(execute_moderation_command( + &capacity_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://capacity.example", + &capacity_event, + &websocket_ingest_auth(&keys, "wss://capacity.example"), + |_| async { + dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .is_err()); + let capacity_state: (i64, i64, i64, i16, Option, i64) = sqlx::query_as( + "SELECT \ + (SELECT count(*) FROM moderation_actions WHERE community_id=$1), \ + (SELECT count(*) FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11), \ + (SELECT count(*) FROM authorization_operation_receipts \ + WHERE community_id=$1 AND operation_kind=11), \ + (SELECT health_state FROM authorization_event_capacity \ + WHERE community_id=$1), \ + (SELECT failure_code FROM authorization_event_capacity \ + WHERE community_id=$1), \ + (SELECT COALESCE(sum(lifetime_count),0)::BIGINT \ + FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=3 AND denial_class=8)", + ) + .bind(capacity_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("read failed-closed moderation capacity state"); + assert_eq!(capacity_state, (0, 0, 0, 2, Some(1), 1)); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + + let mode_dispatches = AtomicUsize::new(0); + assert!(execute_moderation_command( + &committed_tenant, + &db, + buzz_auth::NipFiMode::DenyProtected, + "wss://relay.example", + &signed_ban(&keys, Keys::generate().public_key()), + &websocket_ingest_auth(&keys, "wss://relay.example"), + |_| async { + mode_dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .is_err()); + let missing_proof = IngestAuth::Http { + pubkey: keys.public_key(), + scopes: buzz_auth::Scope::all_known(), + auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, + moderation_evidence: None, + }; + assert!(execute_moderation_command( + &committed_tenant, + &db, + buzz_auth::NipFiMode::Enforce, + "wss://relay.example", + &signed_ban(&keys, Keys::generate().public_key()), + &missing_proof, + |_| async { + mode_dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .is_err()); + assert_eq!(mode_dispatches.load(Ordering::SeqCst), 0); + let mode_denials: Vec<(i16, i64)> = sqlx::query_as( + "SELECT denial_class,sum(denial_count)::BIGINT \ + FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=3 AND action_kind=2 \ + GROUP BY denial_class ORDER BY denial_class", + ) + .bind(committed_domain.as_uuid()) + .fetch_all(&pool) + .await + .expect("read durable moderation mode denials"); + assert_eq!(mode_denials, vec![(1, 1), (3, 1)]); + + let off_domain = CommunityId::from_uuid(Uuid::new_v4()); + install_moderation_authority(&pool, off_domain, keys.public_key(), true).await; + let off_tenant = TenantContext::resolved(off_domain, "off.example"); + let off_event = signed_ban(&keys, Keys::generate().public_key()); + execute_moderation_command( + &off_tenant, + &db, + buzz_auth::NipFiMode::Off, + "wss://off.example", + &off_event, + &websocket_ingest_auth(&keys, "wss://off.example"), + |_| async { + mode_dispatches.fetch_add(1, Ordering::SeqCst); + }, + ) + .await + .expect("execute Off-mode moderation command"); + assert_eq!(mode_dispatches.load(Ordering::SeqCst), 1); + let off_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM moderation_actions WHERE community_id=$1") + .bind(off_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count Off-mode actions"); + assert_eq!(off_rows, 1); + let off_denials: i64 = sqlx::query_scalar( + "SELECT count(*) FROM authorization_operator_denial_buckets \ + WHERE community_id=$1 AND surface_kind=3", + ) + .bind(off_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count Off-mode denial buckets"); + assert_eq!(off_denials, 0); + + let first_unban = signed_unban(&keys, committed_target, "first"); + let second_unban = signed_unban(&keys, committed_target, "second"); + let first_effect = prepare_moderation_application_effect(&committed_tenant, &first_unban) + .expect("prepare first same-target effect"); + let second_effect = prepare_moderation_application_effect(&committed_tenant, &second_unban) + .expect("prepare second same-target effect"); + let same_target_object = first_effect.admission_object(); + assert_eq!(same_target_object, second_effect.admission_object()); + let first_request = db + .prepare_canonical_moderation_request( + websocket_moderation_proof(committed_domain, same_target_object, &first_unban), + same_target_object, + ) + .await + .expect("prepare first same-target request"); + let second_request = db + .prepare_canonical_moderation_request( + websocket_moderation_proof(committed_domain, same_target_object, &second_unban), + same_target_object, + ) + .await + .expect("prepare second same-target request"); + let first_request = install_moderation_application_effect(first_request, first_effect) + .expect("install first same-target effect"); + let second_request = install_moderation_application_effect(second_request, second_effect) + .expect("install second same-target effect"); + let first_committer = db.canonical_moderation_committer(); + let second_committer = db.canonical_moderation_committer(); + let (first_result, second_result) = tokio::join!( + first_committer.commit(first_request), + second_committer.commit(second_request), + ); + assert_eq!( + usize::from(first_result.is_ok()) + usize::from(second_result.is_ok()), + 1 + ); + let rejected = if first_result.is_err() { + first_result + } else { + second_result + }; + assert!(matches!( + rejected, + Err(AdmissionCommitError::RecordedAuthorizationDenied) + )); + let authority_epoch: i64 = sqlx::query_scalar( + "SELECT authority_epoch FROM authorization_authority_epochs \ + WHERE community_id=$1 AND object_kind=$2 AND object_key=$3", + ) + .bind(committed_domain.as_uuid()) + .bind(same_target_object.kind().database_code()) + .bind(same_target_object.key().as_slice()) + .fetch_one(&pool) + .await + .expect("read serialized moderation epoch"); + assert_eq!(authority_epoch, 2); + + let denied_domain = CommunityId::from_uuid(Uuid::new_v4()); + install_moderation_authority(&pool, denied_domain, keys.public_key(), true).await; + let denied_tenant = TenantContext::resolved(denied_domain, "denied.example"); + let denied_event = signed_ban(&keys, Keys::generate().public_key()); + let denied_effect = prepare_moderation_application_effect(&denied_tenant, &denied_event) + .expect("prepare denied effect"); + let denied_object = denied_effect.admission_object(); + let denied_proof = websocket_moderation_proof(denied_domain, denied_object, &denied_event); + let denied_request = db + .prepare_canonical_moderation_request(denied_proof, denied_object) + .await + .expect("prepare denied request"); + let denied_correlation_id = denied_request.correlation_id(); + let denied_attempt_id = denied_request.attempt_id(); + assert_ne!(denied_correlation_id, denied_attempt_id); + let denied_request = install_moderation_application_effect(denied_request, denied_effect) + .expect("install denied effect"); + sqlx::query( + "INSERT INTO identity_enrollment_policies \ + (community_id, policy_revision, enrollment_mode, policy_digest, effective_at) \ + VALUES ($1, 2, 2, $2, transaction_timestamp())", + ) + .bind(denied_domain.as_uuid()) + .bind([38_u8; 32].as_slice()) + .execute(&pool) + .await + .expect("supersede prepared authority policy"); + assert!(matches!( + db.canonical_moderation_committer() + .commit(denied_request) + .await, + Err(AdmissionCommitError::RecordedAuthorizationDenied) + )); + let denied_rows: i64 = + sqlx::query_scalar("SELECT COUNT(*) FROM moderation_actions WHERE community_id=$1") + .bind(denied_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("count denied actions"); + assert_eq!(denied_rows, 0); + let retained_denial_identity: (Uuid, Uuid) = sqlx::query_as( + "SELECT correlation_id,attempt_id FROM authorization_events \ + WHERE community_id=$1 AND event_kind=11 AND outcome_code=2", + ) + .bind(denied_domain.as_uuid()) + .fetch_one(&pool) + .await + .expect("read retained moderation denial correlation"); + assert_eq!(retained_denial_identity.0, denied_correlation_id); + assert_eq!(retained_denial_identity.1, denied_attempt_id); + assert_eq!(dispatches.load(Ordering::SeqCst), 1); + } + #[test] - fn committed_result_must_match_receipt_digest_and_moderation_object() { - let tenant = tenant(10); + fn moderation_committed_dispatch_rejects_authority_reused_for_another_receipt() { + let tenant_context = tenant(10); let object = AdmissionObject::new(AdmissionObjectKind::ModerationTarget, [7; 32]) .expect("moderation object"); + let receipt = buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( + tenant_context.community(), + object, + Uuid::new_v4(), + [9; 32], + [10; 32], + buzz_db::authorization_admission::AdmissionCommitDigests::new([11; 32], Some([12; 32])) + .expect("digests"), + Uuid::new_v4(), + ) + .expect("receipt"); + + assert!(validate_moderation_dispatch_coordinates( + tenant_context.community(), + RouteCapability::Moderation, + &[9; 32], + object.key(), + &tenant_context, + receipt, + ) + .is_ok()); + for denied in [ + validate_moderation_dispatch_coordinates( + tenant(11).community(), + RouteCapability::Moderation, + &[9; 32], + object.key(), + &tenant_context, + receipt, + ), + validate_moderation_dispatch_coordinates( + tenant_context.community(), + RouteCapability::MediaWrite, + &[9; 32], + object.key(), + &tenant_context, + receipt, + ), + validate_moderation_dispatch_coordinates( + tenant_context.community(), + RouteCapability::Moderation, + &[13; 32], + object.key(), + &tenant_context, + receipt, + ), + validate_moderation_dispatch_coordinates( + tenant_context.community(), + RouteCapability::Moderation, + &[9; 32], + &[14; 32], + &tenant_context, + receipt, + ), + ] { + assert!(matches!(denied, Err(AdmissionCommitError::IntentConflict))); + } + } + + #[test] + fn committed_result_must_match_receipt_digest_and_moderation_object() { + let tenant = tenant(10); + let target = vec![8; 32]; + let object = moderation_object( + tenant.community(), + ModerationApplicationTarget::Pubkey(&target), + ) + .expect("moderation object"); let intent_digest = [13; 32]; let result = AdmissionApplicationResult::new( moderation_result_schema(), @@ -1039,7 +2269,7 @@ mod tests { object_key: *object.key(), intent_digest, action: ModerationPostCommitKind::Unban { - target: vec![8; 32], + target: target.clone(), }, }) .expect("serialize application result"), @@ -1056,9 +2286,14 @@ mod tests { Uuid::new_v4(), ) .expect("provisional receipt"); - let application_result_digest = - canonical_moderation_result_digest(provisional, intent_digest, &result) - .expect("canonical result digest"); + let application_result_digest = canonical_moderation_result_digest( + tenant.community(), + object, + *provisional.semantic_fingerprint(), + intent_digest, + &result, + ) + .expect("canonical result digest"); let receipt = buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( tenant.community(), object, @@ -1073,15 +2308,20 @@ mod tests { provisional.audit_event_id(), ) .expect("canonical receipt"); - let action = validate_committed_moderation_result(receipt, &result) + let binding = result_binding(&tenant, object, intent_digest); + let action = validate_committed_moderation_result(&tenant, binding, receipt, &result) .expect("matching receipt and result"); assert!(matches!( action.0, ModerationPostCommitKind::Unban { target } if target == vec![8; 32] )); - let other_object = AdmissionObject::new(AdmissionObjectKind::ModerationTarget, [14; 32]) - .expect("other moderation object"); + let other_target = vec![14; 32]; + let other_object = moderation_object( + tenant.community(), + ModerationApplicationTarget::Pubkey(&other_target), + ) + .expect("other moderation object"); let other_receipt = buzz_db::authorization_admission::AdmissionCommitReceipt::from_storage( tenant.community(), other_object, @@ -1097,7 +2337,7 @@ mod tests { ) .expect("other receipt"); assert!(matches!( - validate_committed_moderation_result(other_receipt, &result), + validate_committed_moderation_result(&tenant, binding, other_receipt, &result), Err(AdmissionCommitError::IntentConflict) )); @@ -1117,9 +2357,213 @@ mod tests { ) .expect("wrong-digest receipt"); assert!(matches!( - validate_committed_moderation_result(wrong_digest_receipt, &result), + validate_committed_moderation_result(&tenant, binding, wrong_digest_receipt, &result), + Err(AdmissionCommitError::IntentConflict) + )); + + let substituted_result = AdmissionApplicationResult::new( + moderation_result_schema(), + 1, + serde_json::to_vec(&ModerationApplicationResultPayload { + object_key: *object.key(), + intent_digest, + action: ModerationPostCommitKind::Unban { + target: vec![16; 32], + }, + }) + .expect("serialize substituted decoded target"), + ) + .expect("substituted decoded target result"); + let substituted_receipt = + receipt_with_application_result(&tenant, object, intent_digest, &substituted_result); + assert!(matches!( + validate_committed_moderation_result( + &tenant, + binding, + substituted_receipt, + &substituted_result, + ), + Err(AdmissionCommitError::IntentConflict) + )); + } + + #[test] + fn moderation_result_binding_rejects_recomputed_substitutions() { + let tenant = tenant(12); + let target = vec![19; 32]; + let object = moderation_object( + tenant.community(), + ModerationApplicationTarget::Pubkey(&target), + ) + .expect("moderation object"); + let intent_digest = [20; 32]; + let semantic_fingerprint = [10; 32]; + let binding = result_binding(&tenant, object, intent_digest); + let action = ModerationPostCommitKind::Unban { target }; + let result = moderation_application_result(object, intent_digest, action.clone()) + .expect("canonical result"); + let result_digest = application_result_digest( + tenant.community(), + object, + semantic_fingerprint, + intent_digest, + &result, + ); + let receipt = + receipt_with_application_digest(&tenant, object, semantic_fingerprint, result_digest); + assert!(validate_committed_moderation_result(&tenant, binding, receipt, &result).is_ok()); + + let substituted_semantic = [21; 32]; + let substituted_semantic_digest = application_result_digest( + tenant.community(), + object, + substituted_semantic, + intent_digest, + &result, + ); + let substituted_semantic_receipt = receipt_with_application_digest( + &tenant, + object, + substituted_semantic, + substituted_semantic_digest, + ); + assert!(matches!( + validate_committed_moderation_result( + &tenant, + binding, + substituted_semantic_receipt, + &result, + ), + Err(AdmissionCommitError::IntentConflict) + )); + + let substituted_intent = [22; 32]; + let substituted_intent_result = + moderation_application_result(object, substituted_intent, action) + .expect("substituted intent result"); + let substituted_intent_digest = application_result_digest( + tenant.community(), + object, + semantic_fingerprint, + substituted_intent, + &substituted_intent_result, + ); + let substituted_intent_receipt = receipt_with_application_digest( + &tenant, + object, + semantic_fingerprint, + substituted_intent_digest, + ); + assert!(matches!( + validate_committed_moderation_result( + &tenant, + binding, + substituted_intent_receipt, + &substituted_intent_result, + ), + Err(AdmissionCommitError::IntentConflict) + )); + + let mut payload: serde_json::Value = + serde_json::from_slice(result.payload()).expect("canonical payload"); + payload + .as_object_mut() + .expect("application result object") + .insert("ignored".to_owned(), serde_json::Value::Bool(true)); + let noncanonical = AdmissionApplicationResult::new( + moderation_result_schema(), + 1, + serde_json::to_vec(&payload).expect("noncanonical payload"), + ) + .expect("bounded noncanonical result"); + let noncanonical_digest = application_result_digest( + tenant.community(), + object, + semantic_fingerprint, + intent_digest, + &noncanonical, + ); + let noncanonical_receipt = receipt_with_application_digest( + &tenant, + object, + semantic_fingerprint, + noncanonical_digest, + ); + assert!(matches!( + validate_committed_moderation_result( + &tenant, + binding, + noncanonical_receipt, + &noncanonical, + ), + Err(AdmissionCommitError::IntentConflict) + )); + } + + #[test] + fn moderation_resolve_result_rejects_a_substituted_report_event_target() { + let tenant = tenant(11); + let report_event_id = vec![17; 32]; + let object = moderation_object( + tenant.community(), + ModerationApplicationTarget::ReportEvent(&report_event_id), + ) + .expect("report moderation object"); + let intent_digest = [18; 32]; + let make_result = |decoded_report_event_id: Vec| { + AdmissionApplicationResult::new( + moderation_result_schema(), + 1, + serde_json::to_vec(&ModerationApplicationResultPayload { + object_key: *object.key(), + intent_digest, + action: ModerationPostCommitKind::ResolveReport { + report_event_id: decoded_report_event_id, + report_id: Uuid::new_v4(), + reporter_pubkey: vec![19; 32], + status: "resolved".to_owned(), + action: "delete".to_owned(), + summary: "resolved".to_owned(), + }, + }) + .expect("serialize report result"), + ) + .expect("typed report result") + }; + + let matching_result = make_result(report_event_id.clone()); + let matching_receipt = + receipt_with_application_result(&tenant, object, intent_digest, &matching_result); + let binding = result_binding(&tenant, object, intent_digest); + assert!(validate_committed_moderation_result( + &tenant, + binding, + matching_receipt, + &matching_result, + ) + .is_ok()); + + let substituted_result = make_result(vec![20; 32]); + let substituted_receipt = + receipt_with_application_result(&tenant, object, intent_digest, &substituted_result); + assert!(matches!( + validate_committed_moderation_result( + &tenant, + binding, + substituted_receipt, + &substituted_result, + ), Err(AdmissionCommitError::IntentConflict) )); + + assert_ne!( + moderation_object( + tenant.community(), + ModerationApplicationTarget::Pubkey(&report_event_id), + ), + Some(object), + "target namespace must distinguish a report event from a pubkey" + ); } #[test] diff --git a/crates/buzz-relay/src/state.rs b/crates/buzz-relay/src/state.rs index 75df6f74ef..1515ec7684 100644 --- a/crates/buzz-relay/src/state.rs +++ b/crates/buzz-relay/src/state.rs @@ -38,6 +38,56 @@ use crate::subscription::SubscriptionRegistry; pub(crate) type ScopedPubkeyKey = (CommunityId, [u8; 32]); +/// Provider-neutral failure returned by the invite assertion adapter. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum InviteAssertionError { + /// The presented evidence did not satisfy the exact route coordinates. + Denied, + /// The configured verifier could not provide current authoritative state. + Unavailable, +} + +/// Provider-neutral assertion verifier used by canonical invite admission. +pub(crate) trait InviteAssertionVerifier: Send + Sync { + /// Verify one assertion against exact server-derived request coordinates. + #[allow(clippy::too_many_arguments)] + fn verify<'a>( + &'a self, + token: &'a str, + authorization_domain: CommunityId, + transport: buzz_auth::ProofTransport, + target_fingerprint: [u8; 32], + request_fingerprint: [u8; 32], + transport_context_fingerprint: [u8; 32], + ) -> std::pin::Pin< + Box< + dyn Future> + + Send + + 'a, + >, + >; +} + +/// Installed provider-neutral verifier and transaction-time rechecker. +pub(crate) struct CanonicalInviteAuthority { + assertion_verifier: Arc, + final_rechecker: Arc, +} + +impl CanonicalInviteAuthority { + /// Borrow the verifier used before canonical preparation. + pub(crate) fn assertion_verifier(&self) -> &dyn InviteAssertionVerifier { + self.assertion_verifier.as_ref() + } + + /// Clone the same authority's transaction-time verifier rechecker. + pub(crate) fn final_rechecker( + &self, + ) -> Arc { + Arc::clone(&self.final_rechecker) + } +} + /// Leaves headroom under the process-wide drain deadline for a stalled writer. const RESTART_CLOSE_ACK_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5); type SlidingWindowCounter = (u32, Instant); @@ -723,6 +773,18 @@ pub struct AppState { } impl AppState { + /// Return the installed provider-neutral authority for invite admission. + pub(crate) fn canonical_invite_authority(&self) -> Option { + let service = self.corporate_identity.as_ref()?.clone(); + let assertion_verifier: Arc = service.clone(); + let final_rechecker: Arc = + service; + Some(CanonicalInviteAuthority { + assertion_verifier, + final_rechecker, + }) + } + /// Constructs `AppState` from its component services. /// /// Returns `(state, audit_shutdown)`. The caller should call diff --git a/crates/buzz-relay/tests/nip_fi_protected_objects.rs b/crates/buzz-relay/tests/nip_fi_protected_objects.rs index 1898862609..eb8cc119f4 100644 --- a/crates/buzz-relay/tests/nip_fi_protected_objects.rs +++ b/crates/buzz-relay/tests/nip_fi_protected_objects.rs @@ -7,6 +7,7 @@ use std::{ future::Future, io::Write, + path::PathBuf, pin::Pin, process::{Command, Stdio}, sync::Arc, @@ -49,36 +50,6 @@ use uuid::Uuid; const PROXY_NOW: u64 = 1_800_000_000; const PROXY_ASSERTION: &str = "eyJhbGciOiJFUzI1NiJ9.eyJzdWIiOiJzdWJqZWN0In0.c2lnbmF0dXJl"; const PROXY_SECRET: [u8; 32] = [0x53; 32]; -const TEST_RSA_PRIVATE_KEY: &str = r#"-----BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDA+T6BKOFQyrEz -Xd/zoFuWoLu95Gmhfr3KcynweqwNWNELcVvxMkMp/3HKwzSHERtgd1BH8AkMA/le -Oy2FeZBPMkePEXcZ6EdGgkd8E1aSuoRZeK+k/GdR84dqFHiRpY9ZNiivKo31SrMB -tp1dB6NkWvVk4VdZnMac6iwUVtjMAZYZbiv9jquDkrArk0hzES/ldmuSbw/zYpwB -0qvsjPpxjYHuD6BbDG9LK8yDpr8Cr5E6M1Y/ToTJ026XfgEjQeW24lXv/enMNX0B -ds/vM9qfQ0uqxMHGq5JhP3SONK1+kJ1iUju7OU/bug8D8ojNYqDAHcLlFq36T8we -s7cXHUybAgMBAAECggEAOet1ecHh4uR7jD3cZpzWcJ78yrGgjNLkNzwate2z72ud -jvAu1vWtmBDuQBwYC4Q0cd9N7tGafYtB0Sk08A99I3Alb0kgNNv1bLHUr+aEARVP -fSVOntgNUNkl506Oo7SMEoxBaNX+dsW9dcGweMg+n/P3HJjQJXr7WASnR1GLz6sS -Xvns5FEHXAs6BpxvGmJm32HzwjsHmnrTIrn50VtbpLRgmUV7bqT2/Wr480PaofwJ -h7IEbHu8QI/Cdg75dGe/wW2EaeQGBSHHVGUHYXure74cWSH7e1a5roLO+PXo9CdO -x5KQ1rA3GzxduM+NISWliYehd0O/hBKUZakdWOxyFQKBgQDwwrddIqbV4t4lJn1d -YfYVb4wwChqhEDuVSbjoMgdK7x+ag+M5QwcSDlHhTabv6ed+0mSJTpmn9EpdhfY9 -okYWpvyQF6jEsiywUh15LXGjHMlYvtFTraAAs+W9StsbHoy6xufIZvZdyU+8dufF -vWn8mLSRvEr0TAKtaEpHR96JTQKBgQDNMDAE/EM4Klne6AgOGp6NVcqKZcKBNHlT -8IKaAa7HUYwCRJ6K/7sb1dYOI3Z4HfjZh4yiO8PaJ+JLDnqycYoUjofekS8B4fbi -vfxLlyEZTeqbY9yERtcYPJFToLQsN7TFfSjJldp7SJTn+umkHI13E4QlGlWBWAOz -FjRMfpP5hwKBgQCr9sg1k7yKZOK6skU03/V+1g/ReEYQ6KFGPkP+RU2ELkvqd21i -xwdT1DqTrH0iO3WH1grNMAD8P7amGjsJRtC8+UTIPr3i0EivH9fBZ74U/UirRJAL -LqZsGhJsI/1f33AxMET5lOE/l7yGJn/hcysyqne+6Di5SVlYNndndmPyuQKBgBXm -hcmSb05IXu1G0M1IlBG7zXF2KQuHYUfPTPFJKrGFh68aSd3GK99ttHov2M47TLtT -F3SdcmsPhLzEH9559eX5zJC56E2II8TRyGL9D4BW66qIPxozQXQJyu0lIvXxQC8w -C7FweDBeb95Ozq9AiOzjvWAEbonurf5oaU6c2AhzAoGBAPCXgwCtFTjrchoK5j8e -uXTlkrQZkDYKsHwgoDh0jOfeuZ/f7+0T15+wWuh/3Yx5BXjMaaz7V58oPXCZkHDX -5EaFLmiTniLlQrRwoTtcfFUNSNZOEwoqGWfrtA0tMibkZdxcM3IBu4Oa75nXKVPZ -f9hDSYt0+Hl9FGuNjpkgLxIa ------END PRIVATE KEY----- -"#; -const TEST_RSA_JWKS: &str = r#"{"keys":[{"kty":"RSA","use":"sig","alg":"RS256","kid":"s4-test","n":"wPk-gSjhUMqxM13f86BblqC7veRpoX69ynMp8HqsDVjRC3Fb8TJDKf9xysM0hxEbYHdQR_AJDAP5XjsthXmQTzJHjxF3GehHRoJHfBNWkrqEWXivpPxnUfOHahR4kaWPWTYoryqN9UqzAbadXQejZFr1ZOFXWZzGnOosFFbYzAGWGW4r_Y6rg5KwK5NIcxEv5XZrkm8P82KcAdKr7Iz6cY2B7g-gWwxvSyvMg6a_Aq-ROjNWP06EydNul34BI0HltuJV7_3pzDV9AXbP7zPan0NLqsTBxquSYT90jjStfpCdYlI7uzlP27oPA_KIzWKgwB3C5Rat-k_MHrO3Fx1Mmw","e":"AQAB"}]}"#; #[derive(Default)] struct EmptyReplayReader; @@ -145,7 +116,72 @@ fn base64_url(input: &[u8]) -> String { output } -fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> String { +struct EphemeralRsaKey { + path: PathBuf, +} + +impl EphemeralRsaKey { + fn generate(kid: &str, prefix: &str) -> (Self, serde_json::Value) { + let key = Self { + path: std::env::temp_dir().join(format!("{prefix}-{}.pem", Uuid::new_v4())), + }; + let generated = Command::new("openssl") + .args([ + "genpkey", + "-algorithm", + "RSA", + "-pkeyopt", + "rsa_keygen_bits:2048", + "-pkeyopt", + "rsa_keygen_pubexp:65537", + "-out", + ]) + .arg(&key.path) + .output() + .expect("generate ephemeral RSA test key"); + assert!( + generated.status.success(), + "OpenSSL RSA key generation failed: {}", + String::from_utf8_lossy(&generated.stderr) + ); + let modulus = Command::new("openssl") + .args(["rsa", "-in"]) + .arg(&key.path) + .args(["-noout", "-modulus"]) + .output() + .expect("read ephemeral RSA test modulus"); + assert!( + modulus.status.success(), + "OpenSSL RSA modulus extraction failed: {}", + String::from_utf8_lossy(&modulus.stderr) + ); + let modulus = std::str::from_utf8(&modulus.stdout) + .expect("UTF-8 RSA test modulus") + .trim() + .strip_prefix("Modulus=") + .expect("OpenSSL RSA modulus prefix"); + let modulus = hex::decode(modulus).expect("hex RSA test modulus"); + let jwks = serde_json::json!({ + "keys": [{ + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": base64_url(&modulus), + "e": "AQAB", + }], + }); + (key, jwks) + } +} + +impl Drop for EphemeralRsaKey { + fn drop(&mut self) { + let _ = std::fs::remove_file(&self.path); + } +} + +fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> (String, serde_json::Value) { let issued_at = Utc::now().timestamp() - 1; let claims = serde_json::json!({ "iss": "https://s4-verifier.test", @@ -162,11 +198,10 @@ fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> String { base64_url(&serde_json::to_vec(&header).expect("serialize JWT header")), base64_url(&serde_json::to_vec(&claims).expect("serialize JWT claims")), ); - let key_path = std::env::temp_dir().join(format!("buzz-s4-jwt-{}.pem", Uuid::new_v4())); - std::fs::write(&key_path, TEST_RSA_PRIVATE_KEY).expect("write temporary test key"); + let (key, jwks) = EphemeralRsaKey::generate("s4-test", "buzz-s4-jwt"); let mut child = Command::new("openssl") .args(["dgst", "-sha256", "-sign"]) - .arg(&key_path) + .arg(&key.path) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .spawn() @@ -178,9 +213,11 @@ fn signed_test_jwt(subject: &str, event_author: [u8; 32]) -> String { .write_all(signing_input.as_bytes()) .expect("write JWT signing input"); let signed = child.wait_with_output().expect("wait for JWT signer"); - std::fs::remove_file(&key_path).expect("remove temporary test key"); assert!(signed.status.success(), "OpenSSL JWT signer failed"); - format!("{signing_input}.{}", base64_url(&signed.stdout)) + ( + format!("{signing_input}.{}", base64_url(&signed.stdout)), + jwks, + ) } fn verified_authorization_evidence( @@ -190,7 +227,7 @@ fn verified_authorization_evidence( request: [u8; 32], transport_context: [u8; 32], ) -> (VerifiedFederatedAssertion, VerifiedNostrProof) { - let token = signed_test_jwt( + let (token, jwks) = signed_test_jwt( "canonical-application-subject", keys.public_key().to_bytes(), ); @@ -207,7 +244,7 @@ fn verified_authorization_evidence( ); let key_set = CanonicalVerifierKeySet::new( VerifierKeyGeneration::new(1).expect("positive verifier generation"), - serde_json::from_str(TEST_RSA_JWKS).expect("parse test JWKS"), + serde_json::from_value(jwks).expect("parse generated test JWKS"), ); let assertion = verifier .verify( @@ -405,6 +442,7 @@ async fn same_domain_evidence_from_another_request_has_a_distinct_exact_binding( let evidence_a = verify_proxy_request( &verifier, &request_a, + domain, ProofTransport::Nip98, "/upload?slot=a", b"body-a", @@ -414,6 +452,7 @@ async fn same_domain_evidence_from_another_request_has_a_distinct_exact_binding( let evidence_b = verify_proxy_request( &verifier, &request_b, + domain, ProofTransport::Blossom, "/upload?slot=b", b"body-b", @@ -1187,13 +1226,15 @@ async fn canonical_moderation_is_atomic_target_bound_and_fresh_only() { async fn verify_proxy_request( verifier: &TrustedProxyProvenanceVerifier, request: &TrustedProxyRequest, + authorization_domain: CommunityId, transport: ProofTransport, path_and_query: &str, body: &[u8], nonce: &[u8], ) -> buzz_auth::SealedTransportEvidence { let assertion = format!("Bearer {PROXY_ASSERTION}"); - let provenance = sign_proxy_provenance(transport, path_and_query, body, nonce); + let provenance = + sign_proxy_provenance(authorization_domain, transport, path_and_query, body, nonce); verifier .verify( &[ @@ -1211,6 +1252,7 @@ async fn verify_proxy_request( } fn sign_proxy_provenance( + authorization_domain: CommunityId, transport: ProofTransport, path_and_query: &str, body: &[u8], @@ -1229,6 +1271,7 @@ fn sign_proxy_provenance( PROXY_NOW.to_be_bytes().as_slice(), nonce, &assertion_digest, + authorization_domain.as_uuid().as_bytes(), b"POST".as_slice(), b"relay.example.com:443".as_slice(), path_and_query.as_bytes(), diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index c66bf4cb54..8b0c120631 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -447,20 +447,21 @@ dependencies = [ [[package]] name = "async-wsocket" -version = "0.13.2" +version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c92385c7c8b3eb2de1b78aeca225212e4c9a69a78b802832759b108681a5069" +checksum = "2c713e1f14c7b82e32ea159af1c6e2f070cfadbdf23fb2512acce9af0a26f1a2" dependencies = [ - "async-utility", "futures", "futures-util", "js-sys", "tokio", + "tokio-happy-eyeballs", "tokio-rustls", "tokio-socks", - "tokio-tungstenite 0.26.2", + "tokio-tungstenite 0.28.0", "url", "wasm-bindgen", + "wasm-bindgen-futures", "web-sys", ] @@ -507,12 +508,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "atomic-destructor" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef49f5882e4b6afaac09ad239a4f8c70a24b8f2b0897edb1f706008efd109cf4" - [[package]] name = "atomic-waker" version = "1.1.2" @@ -762,6 +757,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" +[[package]] +name = "bech32" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "efbd3e1070bbdf4cd88a75264e18e8a26f7cb5c6949eadf0ceb85fb159cf08f8" + [[package]] name = "beef" version = "0.5.2" @@ -774,7 +775,7 @@ version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" dependencies = [ - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "serde", "unicode-normalization", ] @@ -815,7 +816,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b2d6094e2a1ba3c93b5a596fe5a10d1a10c3c6e06785cde89f693a044c01aa40" dependencies = [ - "bitcoin-internals", + "bitcoin-internals 0.5.0", ] [[package]] @@ -827,6 +828,12 @@ dependencies = [ "hex-conservative 0.3.2", ] +[[package]] +name = "bitcoin-internals" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d573f4cf32996a8dce612e4348cece65a241f1882ed594047c9ba348e8869fa5" + [[package]] name = "bitcoin-io" version = "0.1.101" @@ -847,6 +854,18 @@ dependencies = [ "serde", ] +[[package]] +name = "bitcoin_hashes" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5304e53726dbe5f93141535e102ed97b5bf4714fbecefdda8f9fb98d7fdaff0e" +dependencies = [ + "bitcoin-consensus-encoding", + "bitcoin-internals 0.6.0", + "hex-conservative 1.2.0", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -1046,7 +1065,7 @@ dependencies = [ "chrono", "hex", "hmac 0.13.0", - "nostr", + "nostr 0.44.7", "percent-encoding", "rand 0.10.2", "serde", @@ -1100,7 +1119,7 @@ dependencies = [ "mesh-llm-sdk", "mesh-llm-system", "neteq", - "nostr", + "nostr 0.44.7", "notify-rust", "objc2", "objc2-app-kit", @@ -1168,7 +1187,7 @@ dependencies = [ "imagesize", "infer", "mp4", - "nostr", + "nostr 0.44.7", "rust-s3", "serde", "serde_json", @@ -1197,7 +1216,7 @@ name = "buzz-sdk" version = "0.1.0" dependencies = [ "buzz-core", - "nostr", + "nostr 0.44.7", "serde", "serde_json", "thiserror 2.0.18", @@ -1603,7 +1622,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -1841,7 +1860,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "windows 0.62.2", + "windows 0.61.3", ] [[package]] @@ -2819,6 +2838,16 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2e7510819d6fbf51a5545c8f922716ecfb14df168a3242f7d33e0239efe6a1" +[[package]] +name = "faster-hex" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7223ae2d2f179b803433d9c830478527e92b8117eab39460edae7f1614d9fb73" +dependencies = [ + "heapless", + "serde", +] + [[package]] name = "fastrand" version = "2.4.1" @@ -3237,8 +3266,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.2.1", - "windows-result 0.4.1", + "windows-link 0.1.3", + "windows-result 0.3.4", ] [[package]] @@ -3562,6 +3591,15 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -3620,6 +3658,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0049b265b7f201ca9ab25475b22b47fe444060126a51abe00f77d986fc5cc52e" +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heck" version = "0.4.1" @@ -3663,29 +3711,12 @@ dependencies = [ ] [[package]] -name = "hf-hub" -version = "1.0.0-rc.1" +name = "hex-conservative" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f89305dc8fe34e165eaf0eb12b6e294e12381d9df9a431bcc52a5809bab4319" +checksum = "35431185f361ccf3ffc58254628af5f1f5d5f28531da2e02e5d6c82bbc282a10" dependencies = [ - "base64 0.22.1", - "bon", - "bytes", - "futures", - "globset", - "hf-xet", - "hyper", - "pathdiff", - "reqwest 0.13.4", - "serde", - "serde_json", - "sha2 0.11.0", - "thiserror 2.0.18", - "tokio", - "tokio-retry", - "tokio-util", - "tracing", - "url", + "arrayvec", ] [[package]] @@ -3983,7 +4014,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry 0.6.1", + "windows-registry 0.5.3", ] [[package]] @@ -3998,7 +4029,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.62.2", + "windows-core 0.61.2", ] [[package]] @@ -4448,7 +4479,7 @@ dependencies = [ "iroh-base", "iroh-dns", "iroh-metrics", - "lru 0.18.1", + "lru", "n0-error", "n0-future", "noq", @@ -4930,12 +4961,6 @@ dependencies = [ "tracing-subscriber", ] -[[package]] -name = "lru" -version = "0.16.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f66e8d5d03f609abc3a39e6f08e4164ebf1447a732906d39eb9b99b7919ef39" - [[package]] name = "lru" version = "0.18.1" @@ -5133,8 +5158,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "hex", "mesh-llm-client", @@ -5143,8 +5168,8 @@ dependencies = [ [[package]] name = "mesh-llm-api-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5154,13 +5179,13 @@ dependencies = [ [[package]] name = "mesh-llm-build-info" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "mesh-llm-client" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -5191,8 +5216,8 @@ dependencies = [ [[package]] name = "mesh-llm-config" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5207,8 +5232,8 @@ dependencies = [ [[package]] name = "mesh-llm-embedded-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-host-runtime", @@ -5217,8 +5242,8 @@ dependencies = [ [[package]] name = "mesh-llm-events" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "clap", @@ -5229,12 +5254,9 @@ dependencies = [ [[package]] name = "mesh-llm-gpu-bench" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ - "anyhow", - "cc", - "libc", "serde", "serde_json", "tracing", @@ -5242,8 +5264,8 @@ dependencies = [ [[package]] name = "mesh-llm-guardrails" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", "serde_json", @@ -5251,16 +5273,45 @@ dependencies = [ [[package]] name = "mesh-llm-hardware-profile" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "mesh-llm-native-runtime", ] +[[package]] +name = "mesh-llm-hf-hub" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43088a838cf0c6715c65f65a5ac99045fd6d6e90949a8a4183b8104ab791e96b" +dependencies = [ + "base64 0.22.1", + "bon", + "bytes", + "futures", + "getrandom 0.2.17", + "globset", + "hf-xet", + "hyper", + "pathdiff", + "percent-encoding", + "reqwest 0.13.4", + "serde", + "serde_json", + "sha2 0.11.0", + "thiserror 2.0.18", + "tokio", + "tokio-retry", + "tokio-util", + "tracing", + "url", + "wasm-bindgen-futures", +] + [[package]] name = "mesh-llm-host-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "argon2", @@ -5278,7 +5329,6 @@ dependencies = [ "flate2", "futures-util", "hex", - "hf-hub", "http", "http-body-util", "httparse", @@ -5293,6 +5343,7 @@ dependencies = [ "mesh-llm-config", "mesh-llm-events", "mesh-llm-guardrails", + "mesh-llm-hf-hub", "mesh-llm-identity", "mesh-llm-native-runtime", "mesh-llm-node", @@ -5305,6 +5356,7 @@ dependencies = [ "mesh-llm-types", "mesh-llm-ui", "mesh-mixture-of-agents", + "mesh-native-serving-plugin-host", "model-artifact", "model-hf", "model-package", @@ -5352,8 +5404,8 @@ dependencies = [ [[package]] name = "mesh-llm-identity" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "argon2", "base64 0.22.1", @@ -5374,8 +5426,8 @@ dependencies = [ [[package]] name = "mesh-llm-native-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "serde", @@ -5385,8 +5437,8 @@ dependencies = [ [[package]] name = "mesh-llm-node" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-types", @@ -5399,8 +5451,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -5416,8 +5468,8 @@ dependencies = [ [[package]] name = "mesh-llm-plugin-manager" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5435,8 +5487,8 @@ dependencies = [ [[package]] name = "mesh-llm-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "hex", @@ -5446,18 +5498,27 @@ dependencies = [ "sha2 0.10.9", ] +[[package]] +name = "mesh-llm-release-footer" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "hex", + "sha2 0.10.9", +] + [[package]] name = "mesh-llm-routing" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "iroh", ] [[package]] name = "mesh-llm-runtime-install" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5479,8 +5540,8 @@ dependencies = [ [[package]] name = "mesh-llm-sdk" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "mesh-llm-api-client", @@ -5494,8 +5555,8 @@ dependencies = [ [[package]] name = "mesh-llm-skills" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "dirs", @@ -5505,8 +5566,8 @@ dependencies = [ [[package]] name = "mesh-llm-system" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "chrono", @@ -5514,8 +5575,12 @@ dependencies = [ "dirs", "hex", "libc", + "libloading 0.8.9", "mesh-llm-build-info", "mesh-llm-gpu-bench", + "mesh-llm-native-runtime", + "mesh-llm-release-footer", + "mesh-llm-runtime-install", "reqwest 0.12.28", "semver", "serde", @@ -5528,8 +5593,8 @@ dependencies = [ [[package]] name = "mesh-llm-types" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "hex", "serde", @@ -5539,13 +5604,13 @@ dependencies = [ [[package]] name = "mesh-llm-ui" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "mesh-mixture-of-agents" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "async-trait", "mesh-llm-guardrails", @@ -5556,6 +5621,22 @@ dependencies = [ "tracing", ] +[[package]] +name = "mesh-native-serving-plugin-api" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" + +[[package]] +name = "mesh-native-serving-plugin-host" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "anyhow", + "libloading 0.8.9", + "mesh-native-serving-plugin-api", + "skippy-server", +] + [[package]] name = "miette" version = "7.6.0" @@ -5648,8 +5729,8 @@ dependencies = [ [[package]] name = "model-artifact" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", @@ -5659,14 +5740,14 @@ dependencies = [ [[package]] name = "model-hf" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "async-trait", "chrono", "dirs", - "hf-hub", + "mesh-llm-hf-hub", "model-artifact", "model-ref", "serde", @@ -5677,14 +5758,14 @@ dependencies = [ [[package]] name = "model-package" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "bytes", "chrono", "futures", - "hf-hub", + "mesh-llm-hf-hub", "model-hf", "model-ref", "reqwest 0.12.28", @@ -5697,16 +5778,16 @@ dependencies = [ [[package]] name = "model-ref" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", ] [[package]] name = "model-resolver" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "model-artifact", @@ -6160,6 +6241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa6c890013591e709a3e45dd53501351b7e27e7ff3c7e9fc3dce43e300e7e9d3" dependencies = [ "aes-gcm", + "aws-lc-rs", "bytes", "derive_more", "enum-assoc", @@ -6200,9 +6282,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7d3d987ea7078dc36947cde532637c472a229426702e4331dd7667325378bd9" dependencies = [ "base64 0.22.1", - "bech32", + "bech32 0.11.1", "bip39", - "bitcoin_hashes", + "bitcoin_hashes 0.14.101", "cbc", "chacha20 0.9.1", "chacha20poly1305", @@ -6210,7 +6292,7 @@ dependencies = [ "hex", "instant", "scrypt", - "secp256k1", + "secp256k1 0.29.1", "serde", "serde_json", "unicode-normalization", @@ -6218,56 +6300,71 @@ dependencies = [ ] [[package]] -name = "nostr-database" -version = "0.44.0" +name = "nostr" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7462c9d8ae5ef6a28d66a192d399ad2530f1f2130b13186296dbb11bdef5b3d1" +checksum = "5dde8c76076d334409d86c2e1db3e97abe5deb8cb92744f939cbc1fa45bd69e7" dependencies = [ - "lru 0.16.4", - "nostr", - "tokio", + "base64 0.22.1", + "bech32 0.12.0", + "bip39", + "bitcoin_hashes 1.2.0", + "cbc", + "chacha20 0.9.1", + "chacha20poly1305", + "faster-hex", + "opaquerr", + "rand 0.10.2", + "secp256k1 0.30.0", + "serde", + "serde_json", + "unicode-normalization", + "universal-time", + "url", + "zeroize", ] [[package]] -name = "nostr-gossip" -version = "0.44.0" +name = "nostr-database" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ade30de16869618919c6b5efc8258f47b654a98b51541eb77f85e8ec5e3c83a6" +checksum = "4b1fdb9fcba732e32719662afad1b267e50322dbe89e506017ec13f24361bddf" dependencies = [ - "nostr", + "nostr 0.45.1", + "opaquerr", ] [[package]] -name = "nostr-relay-pool" -version = "0.44.3" +name = "nostr-gossip" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c85c54d6ca9aae4ae2bf19a7663ba9db5f45f783f1d24aff55f006386b8b99a1" +checksum = "fa07539e52a71cb91fe0d693facaa298f03fcf9edcd66a521094e18e286e2336" dependencies = [ - "async-utility", - "async-wsocket", - "atomic-destructor", - "hex", - "lru 0.16.4", - "negentropy", - "nostr", - "nostr-database", - "tokio", - "tracing", + "nostr 0.45.1", + "opaquerr", ] [[package]] name = "nostr-sdk" -version = "0.44.1" +version = "0.45.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "471732576710e779b64f04c55e3f8b5292f865fea228436daf19694f0bf70393" +checksum = "26c86342f367bd9b173ec4a697e936e3a82d6dad5b4aa06c0d35d9b4f88a8e72" dependencies = [ "async-utility", - "nostr", + "async-wsocket", + "faster-hex", + "futures", + "lru", + "negentropy", + "nostr 0.45.1", "nostr-database", "nostr-gossip", - "nostr-relay-pool", + "opaquerr", + "rand 0.10.2", "tokio", + "tokio-stream", "tracing", + "universal-time", ] [[package]] @@ -6409,7 +6506,7 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" dependencies = [ - "proc-macro-crate 3.5.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.118", @@ -6772,6 +6869,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "opaquerr" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f933a4265d5cdad61d19bbdfc972ea5726d56cd8d3d57b8f2d3c365dd42bee9" + [[package]] name = "open" version = "5.3.6" @@ -6785,8 +6888,8 @@ dependencies = [ [[package]] name = "openai-frontend" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "async-trait", "axum", @@ -6997,7 +7100,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -7757,7 +7860,7 @@ version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be769465445e8c1474e9c5dac2018218498557af32d9ed057325ec9a41ae81bf" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -7777,7 +7880,7 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" dependencies = [ - "heck 0.5.0", + "heck 0.4.1", "itertools", "log", "multimap", @@ -8179,7 +8282,7 @@ dependencies = [ "hashbrown 0.17.1", "itertools", "kasuari", - "lru 0.18.1", + "lru", "palette", "serde", "strum", @@ -9009,6 +9112,17 @@ dependencies = [ "serde", ] +[[package]] +name = "secp256k1" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50c5943d326858130af85e049f2661ba3c78b26589b8ab98e65e80ae44a1252" +dependencies = [ + "bitcoin_hashes 0.14.101", + "rand 0.8.6", + "secp256k1-sys", +] + [[package]] name = "secp256k1-sys" version = "0.10.1" @@ -9565,8 +9679,8 @@ checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skippy-cache" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "blake3", @@ -9575,40 +9689,41 @@ dependencies = [ [[package]] name = "skippy-coordinator" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "thiserror 2.0.18", ] [[package]] name = "skippy-ffi" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "libloading 0.8.9", ] [[package]] name = "skippy-metrics" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" [[package]] name = "skippy-protocol" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "prost 0.14.4", "prost-build 0.14.4", "protoc-bin-vendored", "serde", + "skippy-tokenizer", ] [[package]] name = "skippy-runtime" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "anyhow", "libc", @@ -9621,8 +9736,8 @@ dependencies = [ [[package]] name = "skippy-server" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "ahash", "anyhow", @@ -9633,6 +9748,8 @@ dependencies = [ "clap", "futures-util", "libc", + "mesh-native-serving-plugin-api", + "model-artifact", "openai-frontend", "opentelemetry-proto", "serde", @@ -9642,16 +9759,25 @@ dependencies = [ "skippy-metrics", "skippy-protocol", "skippy-runtime", + "skippy-tokenizer", "socket2", "tokio", "tokio-stream", "tonic", ] +[[package]] +name = "skippy-tokenizer" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" +dependencies = [ + "serde", +] + [[package]] name = "skippy-topology" -version = "0.74.0" -source = "git+https://github.com/Mesh-LLM/mesh-llm.git?tag=v0.74.0#e60b2fe43aa05271569fbeff2a457133aef456a1" +version = "0.72.1" +source = "git+https://github.com/Mesh-LLM/mesh-llm.git?rev=223835b0e34b5a661e8a8558efb94295d64cabe7#223835b0e34b5a661e8a8558efb94295d64cabe7" dependencies = [ "serde", "serde_json", @@ -10972,6 +11098,15 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-happy-eyeballs" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8564c32dfb6f4257f8bc6edfc178a34af97520e0b7b9815500c55eb3d092f29f" +dependencies = [ + "tokio", +] + [[package]] name = "tokio-macros" version = "2.7.0" @@ -11040,9 +11175,9 @@ dependencies = [ [[package]] name = "tokio-tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" dependencies = [ "futures-util", "log", @@ -11050,7 +11185,7 @@ dependencies = [ "rustls-pki-types", "tokio", "tokio-rustls", - "tungstenite 0.26.2", + "tungstenite 0.28.0", "webpki-roots 0.26.11", ] @@ -11090,6 +11225,7 @@ version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d52efb639344a7c6adb8e62c6f3d2c19c001ff1b79a5041ba1c6ed42e19c6aa5" dependencies = [ + "aws-lc-rs", "base64 0.22.1", "bytes", "futures-core", @@ -11480,9 +11616,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "tungstenite" -version = "0.26.2" +version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" dependencies = [ "bytes", "data-encoding", @@ -11700,6 +11836,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "universal-time" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47a939edecc3c5a7b83c02e5f6b3c31d2bc69eabcc9a87ab12c6d37ee6dbc856" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -12404,7 +12546,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -13042,8 +13184,8 @@ dependencies = [ "log", "serde", "thiserror 2.0.18", - "windows 0.62.2", - "windows-core 0.62.2", + "windows 0.61.3", + "windows-core 0.61.2", ] [[package]] diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 9b2de6a575..6bac85709c 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -109,14 +109,14 @@ buzz_voice_pkg = { package = "buzz-voice", path = "../../crates/buzz-voice" } buzz_terminal = { package = "buzz-terminal", path = "crates/buzz-terminal" } portable-pty = "0.9" iroh = { version = "1.0.2", optional = true } -mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } -mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } +mesh-llm-sdk = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-sdk", default-features = false, features = ["client", "serving"], optional = true } +mesh-llm-host-runtime = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-host-runtime", default-features = false, features = ["dynamic-native-runtime"], optional = true } # Model catalog + hardware survey for the Share-compute model picker (same # diagnose pattern as mesh-console). Lib name of mesh-llm-client is mesh_client. -mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-client", optional = true } -mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-node", optional = true } -mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-system", optional = true } -mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", tag = "v0.74.0", package = "mesh-llm-events", optional = true } +mesh-llm-client = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-client", optional = true } +mesh-llm-node = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-node", optional = true } +mesh-llm-system = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-system", optional = true } +mesh-llm-events = { git = "https://github.com/Mesh-LLM/mesh-llm.git", rev = "223835b0e34b5a661e8a8558efb94295d64cabe7", package = "mesh-llm-events", optional = true } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/migrations/0032_nip_fi_protected_authority.sql b/migrations/0032_nip_fi_protected_authority.sql index 8a761bc463..9596cc6cd7 100644 --- a/migrations/0032_nip_fi_protected_authority.sql +++ b/migrations/0032_nip_fi_protected_authority.sql @@ -139,6 +139,11 @@ CREATE TABLE protected_publication_projection_outbox ( ALTER SEQUENCE protected_publication_projection_sequence_v1 OWNED BY protected_publication_projection_outbox.publication_sequence; +CREATE UNIQUE INDEX protected_publication_projection_outbox_active_target + ON protected_publication_projection_outbox + (community_id, projection_kind, projection_key) + WHERE delivery_state = 1; + CREATE INDEX protected_publication_projection_outbox_pending ON protected_publication_projection_outbox (delivery_state, next_attempt_at, community_id, operation_id) @@ -174,8 +179,6 @@ BEGIN jsonb_build_array( 'buzz:nip-fi:protected-publication-projection:v1', NEW.community_id::TEXT, - NEW.object_kind, - encode(NEW.object_key, 'hex'), NEW.projection_kind, NEW.projection_key )::TEXT, @@ -205,6 +208,10 @@ BEGIN ON admission.community_id = receipt.community_id AND admission.operation_id = receipt.operation_id AND admission.request_fingerprint = receipt.request_fingerprint + JOIN authorization_authority_epochs epoch + ON epoch.community_id = admission.community_id + AND epoch.object_kind = admission.object_kind + AND epoch.object_key = admission.object_key WHERE receipt.community_id = NEW.community_id AND receipt.operation_id = NEW.operation_id AND receipt.request_fingerprint = NEW.request_fingerprint @@ -212,6 +219,7 @@ BEGIN -- every later established-binding mutation uses kind 11. AND receipt.operation_kind IN (1, 11) AND receipt.outcome_code = 1 + AND receipt.result_digest = NEW.publication_result_digest AND admission.object_kind = NEW.object_kind AND admission.object_key = NEW.object_key AND admission.application_type = NEW.application_type diff --git a/migrations/0034_nip_fi_operator_preauth.sql b/migrations/0034_nip_fi_operator_preauth.sql index c92b9fac93..9cddadae83 100644 --- a/migrations/0034_nip_fi_operator_preauth.sql +++ b/migrations/0034_nip_fi_operator_preauth.sql @@ -281,51 +281,86 @@ BEGIN END; $$ LANGUAGE plpgsql VOLATILE STRICT; --- Fixed cardinality: eight closed denial classes x eight actions x twelve --- rotating five-minute slots = at most 768 rows per server-owned domain. +-- Keep rejected replay distinct from an accepted exact replay and from an +-- operation-identity conflict. Migration 0030 intentionally reserved the +-- original closed reason set; this migration extends the immutable event +-- contract before any protected-denial events can use the new code. +ALTER TABLE authorization_events + DROP CONSTRAINT authorization_events_reason_code_check; +ALTER TABLE authorization_events + ADD CONSTRAINT authorization_events_reason_code_check CHECK ( + reason_code IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17) + ); + +-- Fixed cardinality: eight operator actions plus one invite action plus one +-- moderation action, each with eight denial classes and twelve rotating +-- five-minute slots = at most 960 rows per server-owned domain. Buckets retain +-- no actor, credential, target, request, or error detail. CREATE TABLE authorization_operator_denial_buckets ( community_id UUID NOT NULL REFERENCES communities(id), + surface_kind SMALLINT NOT NULL CHECK (surface_kind BETWEEN 1 AND 3), denial_class SMALLINT NOT NULL CHECK (denial_class BETWEEN 1 AND 8), action_kind SMALLINT NOT NULL CHECK (action_kind BETWEEN 1 AND 8), slot SMALLINT NOT NULL CHECK (slot BETWEEN 0 AND 11), window_generation BIGINT NOT NULL CHECK (window_generation >= 0), window_started_at TIMESTAMPTZ NOT NULL, denial_count BIGINT NOT NULL CHECK (denial_count > 0), + lifetime_count BIGINT NOT NULL CHECK (lifetime_count > 0), last_denied_at TIMESTAMPTZ NOT NULL, - PRIMARY KEY (community_id, denial_class, action_kind, slot), + PRIMARY KEY (community_id, surface_kind, denial_class, action_kind, slot), + CHECK ( + (surface_kind = 1 AND action_kind BETWEEN 1 AND 8) + OR (surface_kind = 2 AND action_kind = 1) + OR (surface_kind = 3 AND action_kind = 2) + ), CHECK (window_started_at <= last_denied_at) ); -CREATE FUNCTION authorization_operator_denial_bucket_record_v1( +CREATE FUNCTION authorization_denial_bucket_record_v1( selected_community_id UUID, + selected_surface_kind SMALLINT, selected_denial_class SMALLINT, selected_action_kind SMALLINT ) RETURNS BIGINT AS $$ DECLARE - authoritative_now TIMESTAMPTZ := transaction_timestamp(); + authoritative_now TIMESTAMPTZ := clock_timestamp(); generation BIGINT; selected_slot SMALLINT; retained_count BIGINT; BEGIN - IF selected_denial_class NOT BETWEEN 1 AND 8 + IF selected_surface_kind NOT BETWEEN 1 AND 3 + OR selected_denial_class NOT BETWEEN 1 AND 8 OR selected_action_kind NOT BETWEEN 1 AND 8 + OR (selected_surface_kind = 2 AND selected_action_kind <> 1) + OR (selected_surface_kind = 3 AND selected_action_kind <> 2) THEN - RAISE EXCEPTION 'invalid operator denial bucket coordinate' + RAISE EXCEPTION 'invalid denial bucket coordinate' USING ERRCODE = 'check_violation'; END IF; generation := floor(extract(epoch FROM authoritative_now) / 300)::BIGINT; selected_slot := (generation % 12)::SMALLINT; INSERT INTO authorization_operator_denial_buckets ( - community_id, denial_class, action_kind, slot, - window_generation, window_started_at, denial_count, last_denied_at + community_id, surface_kind, denial_class, action_kind, slot, + window_generation, window_started_at, denial_count, lifetime_count, + last_denied_at ) VALUES ( - selected_community_id, selected_denial_class, selected_action_kind, - selected_slot, generation, to_timestamp(generation * 300), 1, - authoritative_now + selected_community_id, selected_surface_kind, selected_denial_class, + selected_action_kind, selected_slot, generation, + to_timestamp(generation * 300), 1, 1, authoritative_now ) - ON CONFLICT (community_id, denial_class, action_kind, slot) DO UPDATE SET - window_generation = EXCLUDED.window_generation, - window_started_at = EXCLUDED.window_started_at, + ON CONFLICT ( + community_id, surface_kind, denial_class, action_kind, slot + ) DO UPDATE SET + window_generation = GREATEST( + authorization_operator_denial_buckets.window_generation, + EXCLUDED.window_generation + ), + window_started_at = CASE + WHEN authorization_operator_denial_buckets.window_generation + <= EXCLUDED.window_generation + THEN EXCLUDED.window_started_at + ELSE authorization_operator_denial_buckets.window_started_at + END, denial_count = CASE WHEN authorization_operator_denial_buckets.window_generation = EXCLUDED.window_generation @@ -335,27 +370,66 @@ BEGIN THEN 9223372036854775807 ELSE authorization_operator_denial_buckets.denial_count + 1 END - ELSE 1 + WHEN authorization_operator_denial_buckets.window_generation + < EXCLUDED.window_generation + THEN 1 + WHEN authorization_operator_denial_buckets.denial_count + = 9223372036854775807 + THEN 9223372036854775807 + ELSE authorization_operator_denial_buckets.denial_count + 1 END, - last_denied_at = EXCLUDED.last_denied_at + lifetime_count = CASE + WHEN authorization_operator_denial_buckets.lifetime_count + = 9223372036854775807 + THEN 9223372036854775807 + ELSE authorization_operator_denial_buckets.lifetime_count + 1 + END, + last_denied_at = GREATEST( + authorization_operator_denial_buckets.last_denied_at, + EXCLUDED.last_denied_at + ) RETURNING denial_count INTO retained_count; RETURN retained_count; END; $$ LANGUAGE plpgsql VOLATILE STRICT; +CREATE FUNCTION authorization_operator_denial_bucket_record_v1( + selected_community_id UUID, + selected_denial_class SMALLINT, + selected_action_kind SMALLINT +) RETURNS BIGINT AS $$ + SELECT authorization_denial_bucket_record_v1( + selected_community_id, 1::SMALLINT, + selected_denial_class, selected_action_kind + ); +$$ LANGUAGE sql VOLATILE STRICT; + CREATE FUNCTION authorization_operator_denial_bucket_guard_v1() RETURNS TRIGGER AS $$ BEGIN IF NEW.community_id IS DISTINCT FROM OLD.community_id + OR NEW.surface_kind IS DISTINCT FROM OLD.surface_kind OR NEW.denial_class IS DISTINCT FROM OLD.denial_class OR NEW.action_kind IS DISTINCT FROM OLD.action_kind OR NEW.slot IS DISTINCT FROM OLD.slot OR NEW.window_generation < OLD.window_generation + OR NEW.lifetime_count IS DISTINCT FROM (CASE + WHEN OLD.lifetime_count = 9223372036854775807 + THEN 9223372036854775807 + ELSE OLD.lifetime_count + 1 + END) OR (NEW.window_generation = OLD.window_generation AND ( NEW.window_started_at IS DISTINCT FROM OLD.window_started_at - OR NEW.denial_count < OLD.denial_count + OR NEW.denial_count IS DISTINCT FROM (CASE + WHEN OLD.denial_count = 9223372036854775807 + THEN 9223372036854775807 + ELSE OLD.denial_count + 1 + END) OR NEW.last_denied_at < OLD.last_denied_at )) - OR (NEW.window_generation > OLD.window_generation AND NEW.denial_count <> 1) + OR (NEW.window_generation > OLD.window_generation AND ( + NEW.denial_count <> 1 + OR NEW.window_started_at <= OLD.window_started_at + )) THEN RAISE EXCEPTION 'operator denial bucket transition is invalid' USING ERRCODE = 'check_violation'; diff --git a/migrations/0036_nip_fi_invitation_object_kind.sql b/migrations/0036_nip_fi_invitation_object_kind.sql new file mode 100644 index 0000000000..cec2635081 --- /dev/null +++ b/migrations/0036_nip_fi_invitation_object_kind.sql @@ -0,0 +1,28 @@ +-- Admit Invitation as a dedicated protected-object coordinate. +-- +-- Object kind 7 remains the permanent tombstone for the retired durable +-- status model. Object kind 9 is Invitation; no adjacent code is opened. + +ALTER TABLE authorization_admission_results + DROP CONSTRAINT authorization_admission_results_object_kind_check; +ALTER TABLE authorization_admission_results + ADD CONSTRAINT authorization_admission_results_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)) NOT VALID; +ALTER TABLE authorization_admission_results + VALIDATE CONSTRAINT authorization_admission_results_object_kind_check; + +ALTER TABLE authorization_authority_epochs + DROP CONSTRAINT authorization_authority_epochs_object_kind_check; +ALTER TABLE authorization_authority_epochs + ADD CONSTRAINT authorization_authority_epochs_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)) NOT VALID; +ALTER TABLE authorization_authority_epochs + VALIDATE CONSTRAINT authorization_authority_epochs_object_kind_check; + +ALTER TABLE protected_object_authority + DROP CONSTRAINT protected_object_authority_object_kind_check; +ALTER TABLE protected_object_authority + ADD CONSTRAINT protected_object_authority_object_kind_check + CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)) NOT VALID; +ALTER TABLE protected_object_authority + VALIDATE CONSTRAINT protected_object_authority_object_kind_check; diff --git a/schema/schema.sql b/schema/schema.sql index 71e1f0b111..c10b332825 100644 --- a/schema/schema.sql +++ b/schema/schema.sql @@ -253,6 +253,40 @@ CREATE TABLE IF NOT EXISTS authorization_operation_receipts ( CONSTRAINT authorization_operation_receipts_result_digest_check CHECK (octet_length(result_digest) = 32) ); +-- +-- Name: authorization_admission_results; Type: TABLE; Schema: -; Owner: - +-- + +CREATE TABLE IF NOT EXISTS authorization_admission_results ( + community_id uuid, + operation_id uuid, + request_fingerprint bytea NOT NULL, + semantic_fingerprint bytea NOT NULL, + object_kind smallint NOT NULL, + object_key bytea NOT NULL, + application_type bytea, + application_version smallint, + application_code smallint, + application_payload bytea, + application_intent_digest bytea, + application_effect_digest bytea, + application_result_digest bytea, + recorded_at timestamptz DEFAULT transaction_timestamp() NOT NULL, + CONSTRAINT authorization_admission_results_pkey PRIMARY KEY (community_id, operation_id), + CONSTRAINT authorization_admission_resul_community_id_operation_id_re_fkey FOREIGN KEY (community_id, operation_id, request_fingerprint) REFERENCES authorization_operation_receipts (community_id, operation_id, request_fingerprint), + CONSTRAINT authorization_admission_results_application_code_check CHECK (application_code > 0), + CONSTRAINT authorization_admission_results_application_effect_digest_check CHECK (application_effect_digest IS NULL OR octet_length(application_effect_digest) = 32 AND application_effect_digest <> decode(repeat('00'::text, 32), 'hex'::text)), + CONSTRAINT authorization_admission_results_application_intent_digest_check CHECK (application_intent_digest IS NULL OR octet_length(application_intent_digest) = 32 AND application_intent_digest <> decode(repeat('00'::text, 32), 'hex'::text)), + CONSTRAINT authorization_admission_results_application_payload_check CHECK (application_payload IS NULL OR octet_length(application_payload) <= 4096), + CONSTRAINT authorization_admission_results_application_result_digest_check CHECK (application_result_digest IS NULL OR octet_length(application_result_digest) = 32 AND application_result_digest <> decode(repeat('00'::text, 32), 'hex'::text)), + CONSTRAINT authorization_admission_results_application_type_check CHECK (application_type IS NULL OR octet_length(application_type) = 32 AND application_type <> decode(repeat('00'::text, 32), 'hex'::text)), + CONSTRAINT authorization_admission_results_application_version_check CHECK (application_version > 0), + CONSTRAINT authorization_admission_results_object_key_check CHECK (octet_length(object_key) = 32 AND object_key <> decode(repeat('00'::text, 32), 'hex'::text)), + CONSTRAINT authorization_admission_results_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), + CONSTRAINT authorization_admission_results_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32), + CONSTRAINT authorization_admission_results_semantic_fingerprint_check CHECK (octet_length(semantic_fingerprint) = 32 AND semantic_fingerprint <> decode(repeat('00'::text, 32), 'hex'::text)) +); + -- -- Name: authorization_authority_epochs; Type: TABLE; Schema: -; Owner: - -- @@ -273,7 +307,7 @@ CREATE TABLE IF NOT EXISTS authorization_authority_epochs ( CONSTRAINT authorization_authority_epochs_authority_epoch_check CHECK (authority_epoch > 0), CONSTRAINT authorization_authority_epochs_fence_check CHECK (octet_length(fence) = 32 AND fence <> decode(repeat('00'::text, 32), 'hex'::text)), CONSTRAINT authorization_authority_epochs_object_key_check CHECK (octet_length(object_key) = 32), - CONSTRAINT authorization_authority_epochs_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + CONSTRAINT authorization_authority_epochs_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), CONSTRAINT authorization_authority_epochs_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32) ); @@ -2731,7 +2765,7 @@ CREATE TABLE IF NOT EXISTS protected_object_authority ( CONSTRAINT protected_object_authority_fence_check CHECK (octet_length(fence) = 32 AND fence <> decode(repeat('00'::text, 32), 'hex'::text)), CONSTRAINT protected_object_authority_invalidation_generation_check CHECK (invalidation_generation >= 0), CONSTRAINT protected_object_authority_object_key_check CHECK (octet_length(object_key) = 32), - CONSTRAINT protected_object_authority_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6)), + CONSTRAINT protected_object_authority_object_kind_check CHECK (object_kind IN (1, 2, 3, 4, 5, 6, 9)), CONSTRAINT protected_object_authority_owner_pubkey_check CHECK (owner_pubkey IS NULL OR octet_length(owner_pubkey) = 32), CONSTRAINT protected_object_authority_policy_revision_check CHECK (policy_revision > 0), CONSTRAINT protected_object_authority_request_fingerprint_check CHECK (octet_length(request_fingerprint) = 32)