diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d6bd987..6fef4ed 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,7 @@ env: CARGO_TERM_COLOR: always RUSTFLAGS: "-D warnings" RUST_BACKTRACE: 1 + SQLX_OFFLINE: true jobs: @@ -30,7 +31,9 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: { components: clippy } - uses: Swatinem/rust-cache@v2 - - run: cargo clippy --all-targets --all-features + - name: Install native build dependencies + run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev + - run: cargo clippy --workspace --all-targets --all-features --locked -- -D warnings test: name: Tests @@ -60,14 +63,16 @@ jobs: - uses: actions/checkout@v4 - uses: dtolnay/rust-toolchain@stable - uses: Swatinem/rust-cache@v2 - - name: Install psql client - run: sudo apt-get install -y postgresql-client + - name: Install native build and database dependencies + run: sudo apt-get update && sudo apt-get install -y pkg-config libssl-dev postgresql-client - name: Run migrations run: | - PGPASSWORD=vauxl psql -h localhost -U vauxl -d vauxl \ - -f migrations/001_initial_schema.sql - - name: Run tests - run: cargo test --all + for migration in migrations/*.sql; do + PGPASSWORD=vauxl psql -h localhost -U vauxl -d vauxl \ + -v ON_ERROR_STOP=1 -f "$migration" + done + - name: Run workspace and HTTP security regression tests + run: cargo test --workspace --all-targets --locked deny: name: Security & Licenses (cargo-deny) diff --git a/.github/workflows/sytest.yml b/.github/workflows/sytest.yml index bf9b9a4..ad737e7 100644 --- a/.github/workflows/sytest.yml +++ b/.github/workflows/sytest.yml @@ -1,20 +1,41 @@ -name: Matrix Spec Compliance +name: Matrix Federation Smoke Checks on: [pull_request] jobs: - sytest: + matrix-smoke: runs-on: ubuntu-latest - continue-on-error: true # don't block PRs until baseline established + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: vauxl + POSTGRES_PASSWORD: vauxl + POSTGRES_DB: vauxl + POSTGRES_HOST_AUTH_METHOD: trust + ports: ["5432:5432"] + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + redis: + image: redis:7-alpine + ports: ["6379:6379"] + env: + VAUXL__DATABASE__URL: postgres://vauxl:vauxl@localhost/vauxl?sslmode=disable + VAUXL__REDIS__URL: redis://localhost:6379 + SQLX_OFFLINE: true steps: - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 - name: Build server run: cargo build --release -p vauxl-server - - name: Run Sytest - uses: matrix-org/sytest-action@v0 - with: - server-binary: target/release/vauxl-server - - name: Upload results + - name: Run local Matrix smoke checks + run: ./scripts/sytest-smoke.sh target/release/vauxl-server + - name: Upload smoke logs + if: always() uses: actions/upload-artifact@v4 with: - name: sytest-results - path: sytest-results.xml + name: matrix-smoke-logs + path: sytest-server.log diff --git a/Cargo.lock b/Cargo.lock index 45345f3..0d60630 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -258,9 +258,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.17.1" +version = "1.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" +checksum = "b281d307588d634de920874890732659e2e7672f72b5e10e81badc1a8a83621e" dependencies = [ "aws-lc-sys", "zeroize", @@ -268,9 +268,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.42.0" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" +checksum = "9bff6c3b54fad79a2e60b8102caf565819711497c1f5f092f49508e2f5c31b27" dependencies = [ "cc", "cmake", @@ -289,10 +289,10 @@ dependencies = [ "axum-core", "bytes", "futures-util", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.10.1", + "hyper", "hyper-util", "itoa", "matchit", @@ -305,7 +305,7 @@ dependencies = [ "serde_json", "serde_path_to_error", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower", "tower-layer", @@ -322,13 +322,13 @@ dependencies = [ "async-trait", "bytes", "futures-util", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", "mime", "pin-project-lite", "rustversion", - "sync_wrapper 1.0.2", + "sync_wrapper", "tower-layer", "tower-service", "tracing", @@ -369,12 +369,6 @@ version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" -[[package]] -name = "bitflags" -version = "1.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" - [[package]] name = "bitflags" version = "2.13.0" @@ -485,9 +479,9 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" @@ -502,12 +496,12 @@ dependencies = [ [[package]] name = "chacha20" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", "rand_core 0.10.1", ] @@ -564,15 +558,6 @@ dependencies = [ "tokio-util", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "config" version = "0.15.25" @@ -637,32 +622,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - [[package]] name = "cpufeatures" version = "0.2.17" @@ -674,9 +633,9 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] @@ -988,11 +947,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1012,12 +970,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "fastrand" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" - [[package]] name = "fax" version = "0.2.7" @@ -1072,12 +1024,6 @@ dependencies = [ "spin", ] -[[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" - [[package]] name = "foldhash" version = "0.1.5" @@ -1256,25 +1202,6 @@ version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" -[[package]] -name = "h2" -version = "0.3.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" -dependencies = [ - "bytes", - "fnv", - "futures-core", - "futures-sink", - "futures-util", - "http 0.2.12", - "indexmap", - "slab", - "tokio", - "tokio-util", - "tracing", -] - [[package]] name = "half" version = "2.7.1" @@ -1375,17 +1302,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "http" -version = "0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" -dependencies = [ - "bytes", - "fnv", - "itoa", -] - [[package]] name = "http" version = "1.4.2" @@ -1396,17 +1312,6 @@ dependencies = [ "itoa", ] -[[package]] -name = "http-body" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" -dependencies = [ - "bytes", - "http 0.2.12", - "pin-project-lite", -] - [[package]] name = "http-body" version = "1.0.1" @@ -1414,7 +1319,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.2", + "http", ] [[package]] @@ -1425,8 +1330,8 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", ] @@ -1442,30 +1347,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - [[package]] name = "hyper" version = "1.10.1" @@ -1476,8 +1357,8 @@ dependencies = [ "bytes", "futures-channel", "futures-core", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "httparse", "httpdate", "itoa", @@ -1493,8 +1374,8 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.2", - "hyper 1.10.1", + "http", + "hyper", "hyper-util", "rustls", "tokio", @@ -1503,19 +1384,6 @@ dependencies = [ "webpki-roots 1.0.8", ] -[[package]] -name = "hyper-tls" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" -dependencies = [ - "bytes", - "hyper 0.14.32", - "native-tls", - "tokio", - "tokio-native-tls", -] - [[package]] name = "hyper-util" version = "0.1.20" @@ -1526,9 +1394,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.2", - "http-body 1.0.1", - "hyper 1.10.1", + "http", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", @@ -1858,7 +1726,7 @@ version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ - "bitflags 2.13.0", + "bitflags", "libc", "plain", "redox_syscall 0.9.0", @@ -1874,12 +1742,6 @@ dependencies = [ "vcpkg", ] -[[package]] -name = "linux-raw-sys" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" - [[package]] name = "litemap" version = "0.8.2" @@ -1912,9 +1774,9 @@ dependencies = [ [[package]] name = "lru-slab" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" +checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f" [[package]] name = "maplit" @@ -2028,23 +1890,6 @@ dependencies = [ "pxfm", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2205,7 +2050,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags 2.13.0", + "bitflags", "cfg-if", "foreign-types", "libc", @@ -2224,12 +2069,6 @@ dependencies = [ "syn", ] -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - [[package]] name = "openssl-sys" version = "0.9.117" @@ -2423,7 +2262,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.13.0", + "bitflags", "crc32fast", "fdeflate", "flate2", @@ -2579,9 +2418,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quinn" -version = "0.11.11" +version = "0.11.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +checksum = "4051e23e9185c255a7e33ef59cdbca87a22d359052eecd22fc6b901fb37d9d11" dependencies = [ "bytes", "cfg_aliases", @@ -2599,9 +2438,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.16" +version = "0.11.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +checksum = "a9746dbde176634f4f2f1faf2404e30a31b2bc1e9cafb5329c95d8177a18c9fc" dependencies = [ "bytes", "getrandom 0.4.3", @@ -2681,7 +2520,7 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ - "chacha20 0.10.1", + "chacha20 0.10.2", "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -2839,7 +2678,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2848,7 +2687,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ - "bitflags 2.13.0", + "bitflags", ] [[package]] @@ -2880,46 +2719,6 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" -[[package]] -name = "reqwest" -version = "0.11.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" -dependencies = [ - "base64 0.21.7", - "bytes", - "encoding_rs", - "futures-core", - "futures-util", - "h2", - "http 0.2.12", - "http-body 0.4.6", - "hyper 0.14.32", - "hyper-tls", - "ipnet", - "js-sys", - "log", - "mime", - "native-tls", - "once_cell", - "percent-encoding", - "pin-project-lite", - "rustls-pemfile", - "serde", - "serde_json", - "serde_urlencoded", - "sync_wrapper 0.1.2", - "system-configuration", - "tokio", - "tokio-native-tls", - "tower-service", - "url", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", - "winreg", -] - [[package]] name = "reqwest" version = "0.12.28" @@ -2929,10 +2728,10 @@ dependencies = [ "base64 0.22.1", "bytes", "futures-core", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "http-body-util", - "hyper 1.10.1", + "hyper", "hyper-rustls", "hyper-util", "js-sys", @@ -2945,7 +2744,7 @@ dependencies = [ "serde", "serde_json", "serde_urlencoded", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tokio-rustls", "tower", @@ -2984,7 +2783,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.13.0", + "bitflags", "once_cell", "serde", "serde_derive", @@ -3039,7 +2838,7 @@ dependencies = [ "assign", "bytes", "date_header", - "http 1.4.2", + "http", "js_int", "js_option", "maplit", @@ -3062,7 +2861,7 @@ dependencies = [ "base64 0.22.1", "bytes", "form_urlencoded", - "http 1.4.2", + "http", "indexmap", "js_int", "konst", @@ -3193,24 +2992,11 @@ dependencies = [ "nom 7.1.3", ] -[[package]] -name = "rustix" -version = "1.1.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" -dependencies = [ - "bitflags 2.13.0", - "errno", - "libc", - "linux-raw-sys", - "windows-sys 0.61.2", -] - [[package]] name = "rustls" -version = "0.23.41" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "aws-lc-rs", "log", @@ -3222,15 +3008,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rustls-pemfile" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" -dependencies = [ - "base64 0.21.7", -] - [[package]] name = "rustls-pki-types" version = "1.15.0" @@ -3243,9 +3020,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "aws-lc-rs", "ring", @@ -3265,44 +3042,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags 2.13.0", - "core-foundation 0.10.1", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3665,7 +3410,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.0", + "bitflags", "byteorder", "bytes", "crc", @@ -3707,7 +3452,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64 0.22.1", - "bitflags 2.13.0", + "bitflags", "byteorder", "crc", "dotenvy", @@ -3794,12 +3539,6 @@ dependencies = [ "unicode-ident", ] -[[package]] -name = "sync_wrapper" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" - [[package]] name = "sync_wrapper" version = "1.0.2" @@ -3820,40 +3559,6 @@ dependencies = [ "syn", ] -[[package]] -name = "system-configuration" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" -dependencies = [ - "bitflags 1.3.2", - "core-foundation 0.9.4", - "system-configuration-sys", -] - -[[package]] -name = "system-configuration-sys" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" -dependencies = [ - "core-foundation-sys", - "libc", -] - -[[package]] -name = "tempfile" -version = "3.27.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" -dependencies = [ - "fastrand", - "getrandom 0.4.3", - "once_cell", - "rustix", - "windows-sys 0.61.2", -] - [[package]] name = "thiserror" version = "1.0.69" @@ -4009,16 +3714,6 @@ dependencies = [ "syn", ] -[[package]] -name = "tokio-native-tls" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" -dependencies = [ - "native-tls", - "tokio", -] - [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4150,7 +3845,7 @@ dependencies = [ "futures-core", "futures-util", "pin-project-lite", - "sync_wrapper 1.0.2", + "sync_wrapper", "tokio", "tower-layer", "tower-service", @@ -4163,11 +3858,11 @@ version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ - "bitflags 2.13.0", + "bitflags", "bytes", "futures-util", - "http 1.4.2", - "http-body 1.0.1", + "http", + "http-body", "pin-project-lite", "tower", "tower-layer", @@ -4426,20 +4121,13 @@ dependencies = [ name = "vauxl-federation" version = "0.1.0" dependencies = [ - "anyhow", - "axum", "base64 0.22.1", "ed25519-dalek", "redis", - "reqwest 0.12.28", + "reqwest", "ruma", - "serde", "serde_json", - "sqlx", - "thiserror 1.0.69", "tokio", - "tracing", - "vauxl-matrix", ] [[package]] @@ -4466,7 +4154,7 @@ dependencies = [ "hex", "rand 0.8.6", "redis", - "reqwest 0.11.27", + "reqwest", "ruma", "serde", "serde_json", @@ -4479,6 +4167,7 @@ dependencies = [ "tracing", "urlencoding", "uuid", + "vauxl-federation", ] [[package]] @@ -4513,15 +4202,19 @@ dependencies = [ "ed25519-dalek", "rand 0.8.6", "redis", + "ruma", "serde", "serde_json", "sqlx", "thiserror 1.0.69", "tokio", "tokio-rustls", + "tower", "tower-http", "tracing", "tracing-subscriber", + "urlencoding", + "vauxl-federation", "vauxl-matrix", ] @@ -4953,16 +4646,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "winreg" -version = "0.50.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" -dependencies = [ - "cfg-if", - "windows-sys 0.48.0", -] - [[package]] name = "wit-bindgen" version = "0.57.1" diff --git a/Cargo.toml b/Cargo.toml index a8aa6a7..aad9eb7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,7 +15,7 @@ resolver = "2" tokio = { version = "1", features = ["full"] } axum = { version = "0.7" } sqlx = { version = "0.8", features = ["postgres", "runtime-tokio-rustls", "migrate"] } -ruma = { version = "0.10", features = ["client-api", "federation-api", "events"] } +ruma = { version = "0.10", features = ["client-api", "federation-api", "events", "signatures"] } serde = { version = "1", features = ["derive"] } serde_json = { version = "1.0.150" } tracing = "0.1" diff --git a/README.md b/README.md index d50197a..8e917e1 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ This repository hosts the Matrix-first server strategy for Vauxl. +See [federation security and supported behavior](docs/FEDERATION_SECURITY.md) for +the implemented checks, validation commands, and current interoperability and +existing-data limitations. + ## MVP Goals - Deploy a compliant Matrix homeserver baseline (fork or implementation path). - Provide authentication, room state, federation basics, and media service compatibility. @@ -12,7 +16,7 @@ This repository hosts the Matrix-first server strategy for Vauxl. - Undocumented proprietary event formats. ## Structure -- `crates/` Cargo workspace: `vauxl-server` (binary), `vauxl-matrix` (client-server API), `vauxl-crypto`, plus stubs for federation, identity, media, admin, push +- `crates/` Cargo workspace: `vauxl-server` (binary), `vauxl-matrix` (Matrix APIs), `vauxl-federation` (transport and key verification), `vauxl-crypto`, plus stubs for identity, media, admin, push - `migrations/` PostgreSQL schema (regenerate `.sqlx/` with `cargo sqlx prepare --workspace` after changing queries) - `config/` runtime configuration defaults - `docker/` dev compose stack and Dockerfiles diff --git a/crates/vauxl-federation/Cargo.toml b/crates/vauxl-federation/Cargo.toml index abc2d28..a3cb0c7 100644 --- a/crates/vauxl-federation/Cargo.toml +++ b/crates/vauxl-federation/Cargo.toml @@ -6,16 +6,9 @@ license = "MIT OR Apache-2.0" [dependencies] tokio = { workspace = true } -axum = { workspace = true } -sqlx = { workspace = true } ruma = { workspace = true } -serde = { workspace = true } serde_json = { workspace = true } -tracing = { workspace = true } -thiserror = { workspace = true } -anyhow = { workspace = true } -reqwest = { version = "0.12", features = ["rustls-tls", "json"], default-features = false } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } redis = { version = "0.27", features = ["tokio-comp"] } ed25519-dalek = "2" base64 = "0.22" -vauxl-matrix = { path = "../vauxl-matrix" } diff --git a/crates/vauxl-federation/src/client.rs b/crates/vauxl-federation/src/client.rs index ccaec55..5fc1dde 100644 --- a/crates/vauxl-federation/src/client.rs +++ b/crates/vauxl-federation/src/client.rs @@ -17,7 +17,7 @@ pub async fn send_federation_request( signing_key: &ed25519_dalek::SigningKey, key_id: &str, ) -> Result { - let resolved = resolve_server_name(destination).await; + let resolved = resolve_server_name(destination).await?; let url = format!("https://{}:{}{}", resolved.host, resolved.port, path); @@ -25,10 +25,7 @@ pub async fn send_federation_request( let auth_header = build_auth_header(method, origin, destination, path, body, signing_key, key_id)?; - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(30)) - .build() - .map_err(|e| e.to_string())?; + let client = crate::resolver::http_client(Duration::from_secs(30))?; let mut req = match method { "GET" => client.get(&url), @@ -59,6 +56,11 @@ fn build_auth_header( signing_key: &ed25519_dalek::SigningKey, key_id: &str, ) -> Result { + if !crate::resolver::is_valid_server_name(origin) + || !crate::resolver::is_valid_server_name(destination) + { + return Err("Invalid server name".into()); + } // Build the object to sign let mut to_sign = serde_json::json!({ "method": method, @@ -72,7 +74,7 @@ fn build_auth_header( } // Canonical JSON - let canonical = crate::canonical_json(&to_sign); + let canonical = crate::signing_json(&to_sign)?; // Sign let sig = signing_key.sign(canonical.as_bytes()); @@ -83,44 +85,3 @@ fn build_auth_header( origin, destination, key_id, sig_b64 )) } - -/// Simple canonical JSON for federation signing. -fn canonical_json_inner(value: &Value) -> String { - match value { - Value::Object(map) => { - let mut sorted: Vec<(&String, &Value)> = map.iter().collect(); - sorted.sort_by_key(|(k, _)| k.as_str()); - let pairs: Vec = sorted - .iter() - .map(|(k, v)| format!("{}:{}", json_str(k), canonical_json_inner(v))) - .collect(); - format!("{{{}}}", pairs.join(",")) - } - Value::Array(arr) => { - let items: Vec = arr.iter().map(canonical_json_inner).collect(); - format!("[{}]", items.join(",")) - } - Value::String(s) => json_str(s), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - Value::Null => "null".into(), - } -} - -fn json_str(s: &str) -> String { - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for ch in s.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 32 => out.push_str(&format!("\\u{:04x}", c as u32)), - c => out.push(c), - } - } - out.push('"'); - out -} diff --git a/crates/vauxl-federation/src/keys.rs b/crates/vauxl-federation/src/keys.rs index c22c40c..a4d80cc 100644 --- a/crates/vauxl-federation/src/keys.rs +++ b/crates/vauxl-federation/src/keys.rs @@ -1,88 +1,214 @@ -//! Remote server key fetching and caching. -//! -//! We fetch remote server signing keys and cache them in Redis -//! for the validity period specified by the remote server. - +//! HTTPS-authenticated, self-signed remote key documents, cached until expiry. use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; +use ed25519_dalek::{Signature, VerifyingKey}; +use redis::AsyncCommands; use serde_json::Value; -use std::time::Duration; - -use crate::resolver::resolve_server_name; - -/// Fetch the signing keys for a remote server. -/// Returns a map of key_id → base64-encoded Ed25519 public key. -pub async fn fetch_server_keys( - server_name: &str, -) -> Result>, String> { - let resolved = resolve_server_name(server_name).await; - - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| e.to_string())?; - - let url = format!( - "https://{}:{}/_matrix/key/v2/server", - resolved.host, resolved.port - ); +use std::{ + collections::HashMap, + time::{Duration, SystemTime, UNIX_EPOCH}, +}; + +pub fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 +} - let resp = client - .get(&url) - .send() - .await - .map_err(|e| format!("Key fetch failed for {server_name}: {e}"))?; +pub fn verify_signature(key: &[u8], signature: &str, message: &[u8]) -> Result<(), String> { + let bytes: &[u8; 32] = key.try_into().map_err(|_| "Invalid Ed25519 key length")?; + let key = VerifyingKey::from_bytes(bytes).map_err(|e| e.to_string())?; + let signature = BASE64.decode(signature).map_err(|e| e.to_string())?; + let signature = Signature::from_slice(&signature).map_err(|e| e.to_string())?; + key.verify_strict(message, &signature) + .map_err(|e| e.to_string()) +} - if !resp.status().is_success() { - return Err(format!("Key server returned {}", resp.status())); +/// Retired keys are deliberately excluded from live request authentication. Historical +/// event verification may use them only before their documented expired_ts. +pub fn document_keys( + document: &Value, + server: &str, + now: u64, + event_ts: Option, +) -> Result>, String> { + if !crate::resolver::is_valid_server_name(server) + || document.get("server_name").and_then(Value::as_str) != Some(server) + { + return Err("Key document server identity mismatch".into()); } - - let json: Value = resp.json().await.map_err(|e| e.to_string())?; - - let mut keys = std::collections::HashMap::new(); - - if let Some(verify_keys) = json.get("verify_keys").and_then(|v| v.as_object()) { - for (key_id, key_obj) in verify_keys { - if let Some(key_b64) = key_obj.get("key").and_then(|v| v.as_str()) { - if let Ok(key_bytes) = BASE64.decode(key_b64) { - keys.insert(key_id.clone(), key_bytes); + let valid_until = document + .get("valid_until_ts") + .and_then(Value::as_u64) + .ok_or("Missing key validity")?; + if valid_until <= now { + return Err("Expired server key document".into()); + } + let current = document + .get("verify_keys") + .and_then(Value::as_object) + .ok_or("Missing verify_keys")?; + let mut signable = document.as_object().ok_or("Invalid key document")?.clone(); + signable.remove("signatures"); + signable.remove("unsigned"); + let canonical = crate::signing_json(&Value::Object(signable))?; + let signatures = document + .get("signatures") + .and_then(|v| v.get(server)) + .and_then(Value::as_object) + .ok_or("Missing key self-signature")?; + let mut keys = HashMap::new(); + for (id, value) in current { + if !id.starts_with("ed25519:") { + continue; + } + let Some(encoded) = value.get("key").and_then(Value::as_str) else { + continue; + }; + let Ok(key) = BASE64.decode(encoded) else { + continue; + }; + if key.len() == 32 { + keys.insert(id.clone(), key); + } + } + let self_signed = keys.iter().any(|(id, key)| { + signatures + .get(id) + .and_then(Value::as_str) + .is_some_and(|sig| verify_signature(key, sig, canonical.as_bytes()).is_ok()) + }); + if !self_signed { + return Err("Invalid key self-signature".into()); + } + if let Some(timestamp) = event_ts { + if timestamp > valid_until { + return Err("Event exceeds key validity".into()); + } + if let Some(old) = document.get("old_verify_keys").and_then(Value::as_object) { + for (id, value) in old { + if !id.starts_with("ed25519:") + || value + .get("expired_ts") + .and_then(Value::as_u64) + .is_none_or(|expiry| timestamp >= expiry) + { + continue; + } + if let Some(key) = value + .get("key") + .and_then(Value::as_str) + .and_then(|s| BASE64.decode(s).ok()) + .filter(|key| key.len() == 32) + { + keys.entry(id.clone()).or_insert(key); } } } } - Ok(keys) } -/// Get (or fetch + cache) remote server keys. -pub async fn get_server_keys( - server_name: &str, - redis: &redis::Client, -) -> Result>, String> { - use redis::AsyncCommands; - - let cache_key = format!("federation:keys:{}", server_name); +pub async fn fetch_key_document(server: &str) -> Result { + let resolved = crate::resolver::resolve_server_name(server).await?; + let client = crate::resolver::http_client(Duration::from_secs(10))?; + let response = client + .get(format!( + "https://{}:{}/_matrix/key/v2/server", + resolved.host, resolved.port + )) + .send() + .await + .map_err(|e| e.to_string())?; + if !response.status().is_success() { + return Err(format!("Key server returned {}", response.status())); + } + let document = crate::resolver::bounded_json(response).await?; + document_keys(&document, server, now_millis(), None)?; + Ok(document) +} - // Try cache first - if let Ok(mut conn) = redis.get_multiplexed_async_connection().await { - let cached: Option = conn.get(&cache_key).await.unwrap_or(None); - if let Some(json_str) = cached { - if let Ok(map) = - serde_json::from_str::>>(&json_str) - { - return Ok(map); +/// Cache complete validated documents, using a new namespace to exclude old unsigned +/// key-map cache entries. Refresh immediately for a newly advertised rotation key. +pub async fn get_verification_key( + server: &str, + key_id: &str, + redis: &redis::Client, + event_ts: Option, +) -> Result, String> { + if !crate::resolver::is_valid_server_name(server) { + return Err("Invalid server name".into()); + } + let cache_key = format!("federation:verified-key-document:{server}"); + if let Ok(mut connection) = redis.get_multiplexed_async_connection().await { + let cached: Option = connection.get(&cache_key).await.unwrap_or(None); + if let Some(document) = cached.and_then(|s| serde_json::from_str::(&s).ok()) { + if let Ok(keys) = document_keys(&document, server, now_millis(), event_ts) { + if let Some(key) = keys.get(key_id) { + return Ok(key.clone()); + } } } } - - // Fetch fresh - let keys = fetch_server_keys(server_name).await?; - - // Cache for 24 hours - if let Ok(mut conn) = redis.get_multiplexed_async_connection().await { - if let Ok(serialized) = serde_json::to_string(&keys) { - let _: Result<(), _> = conn.set_ex(&cache_key, serialized, 86400).await; + let document = fetch_key_document(server).await?; + let now = now_millis(); + let keys = document_keys(&document, server, now, event_ts)?; + if let Ok(mut connection) = redis.get_multiplexed_async_connection().await { + // Cap cache lifetime even if a remote server publishes an unusually long validity. + let ttl = (document["valid_until_ts"] + .as_u64() + .unwrap_or(now) + .saturating_sub(now) + / 1000) + .min(86400); + if ttl > 0 { + let _: Result<(), _> = connection + .set_ex(cache_key, document.to_string(), ttl) + .await; } } + keys.get(key_id) + .cloned() + .ok_or_else(|| "No valid verification key".into()) +} - Ok(keys) +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + use serde_json::json; + fn document() -> Value { + let key = SigningKey::from_bytes(&[7; 32]); + let mut document = json!({"server_name":"remote.example", "valid_until_ts":2000, "verify_keys":{"ed25519:new":{"key":BASE64.encode(key.verifying_key().as_bytes())}}, "old_verify_keys":{"ed25519:old":{"key":BASE64.encode([9;32]), "expired_ts":900}}}); + let signature = key.sign(crate::signing_json(&document).unwrap().as_bytes()); + document["signatures"] = + json!({"remote.example":{"ed25519:new":BASE64.encode(signature.to_bytes())}}); + document + } + #[test] + fn rejects_untrusted_expired_or_modified_key_documents() { + let d = document(); + assert!(document_keys(&d, "remote.example", 1000, None).is_ok()); + assert!(document_keys(&d, "other.example", 1000, None).is_err()); + assert!(document_keys(&d, "remote.example", 2000, None).is_err()); + let mut tampered = d.clone(); + tampered["valid_until_ts"] = json!(9000); + assert!(document_keys(&tampered, "remote.example", 1000, None).is_err()); + let mut unsigned = d; + unsigned.as_object_mut().unwrap().remove("signatures"); + assert!(document_keys(&unsigned, "remote.example", 1000, None).is_err()); + } + #[test] + fn retired_keys_only_verify_events_from_before_expiry() { + let d = document(); + assert!(!document_keys(&d, "remote.example", 1000, None) + .unwrap() + .contains_key("ed25519:old")); + assert!(document_keys(&d, "remote.example", 1000, Some(899)) + .unwrap() + .contains_key("ed25519:old")); + assert!(!document_keys(&d, "remote.example", 1000, Some(900)) + .unwrap() + .contains_key("ed25519:old")); + } } diff --git a/crates/vauxl-federation/src/lib.rs b/crates/vauxl-federation/src/lib.rs index 96b796c..8dd7c15 100644 --- a/crates/vauxl-federation/src/lib.rs +++ b/crates/vauxl-federation/src/lib.rs @@ -2,4 +2,53 @@ pub mod client; pub mod keys; pub mod resolver; -pub use vauxl_matrix::event_signing::canonical_json; +use serde_json::Value; + +/// Produces canonical JSON with lexicographically sorted object keys. +pub fn canonical_json(value: &Value) -> String { + match value { + Value::Object(map) => { + let mut sorted: Vec<(&String, &Value)> = map.iter().collect(); + sorted.sort_by_key(|(key, _)| key.as_str()); + let pairs = sorted + .iter() + .map(|(key, value)| format!("{}:{}", json_string(key), canonical_json(value))) + .collect::>(); + format!("{{{}}}", pairs.join(",")) + } + Value::Array(values) => { + let values = values.iter().map(canonical_json).collect::>(); + format!("[{}]", values.join(",")) + } + Value::String(value) => json_string(value), + Value::Number(value) => value.to_string(), + Value::Bool(value) => value.to_string(), + Value::Null => "null".to_owned(), + } +} + +fn json_string(value: &str) -> String { + let mut output = String::with_capacity(value.len() + 2); + output.push('"'); + for character in value.chars() { + match character { + '"' => output.push_str("\\\""), + '\\' => output.push_str("\\\\"), + '\n' => output.push_str("\\n"), + '\r' => output.push_str("\\r"), + '\t' => output.push_str("\\t"), + character if (character as u32) < 32 => { + output.push_str(&format!("\\u{:04x}", character as u32)); + } + character => output.push(character), + } + } + output.push('"'); + output +} + +/// Matrix canonical JSON rejects floats and integers outside the interoperable range. +pub fn signing_json(value: &Value) -> Result { + let canonical = ruma::CanonicalJsonValue::try_from(value.clone()).map_err(|e| e.to_string())?; + serde_json::to_string(&canonical).map_err(|e| e.to_string()) +} diff --git a/crates/vauxl-federation/src/resolver.rs b/crates/vauxl-federation/src/resolver.rs index 19185f0..6e06224 100644 --- a/crates/vauxl-federation/src/resolver.rs +++ b/crates/vauxl-federation/src/resolver.rs @@ -1,11 +1,4 @@ -//! Matrix server name resolution. -//! -//! Resolution order per Matrix spec: -//! 1. If server_name has an explicit port → use directly -//! 2. Check /.well-known/matrix/server for delegation -//! 3. SRV DNS lookup _matrix._tcp. -//! 4. Fall back to server_name:8448 - +//! Matrix server discovery. Only validated authorities are used in HTTPS URLs. use std::time::Duration; #[derive(Debug, Clone)] @@ -14,59 +7,254 @@ pub struct ResolvedServer { pub port: u16, } -/// Resolve a Matrix server name to a host:port for federation connections. -pub async fn resolve_server_name(server_name: &str) -> ResolvedServer { - // If explicit port given, use as-is - if let Some((host, port_str)) = server_name.rsplit_once(':') { - if let Ok(port) = port_str.parse::() { - return ResolvedServer { - host: host.to_owned(), - port, - }; - } +pub fn is_valid_server_name(value: &str) -> bool { + let Ok(server) = <&ruma::ServerName>::try_from(value) else { + return false; + }; + let host = server.host(); + // HTTP URL parsers interpret integer, hexadecimal, octal and shortened IPv4 + // names as IP literals. Reject those aliases so ACLs and the connector use + // the same server identity and IP classification. + let Ok(url) = reqwest::Url::parse(&format!("https://{value}")) else { + return false; + }; + let normalized_host = url + .host_str() + .unwrap_or_default() + .trim_start_matches('[') + .trim_end_matches(']'); + let original_host = host.trim_start_matches('[').trim_end_matches(']'); + if normalized_host.parse::().is_ok() + && original_host.parse::().is_err() + { + return false; } + !host.is_empty() + && value.len() <= 255 + && server.port() != Some(0) + && (host.starts_with('[') + || host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && !label.starts_with('-') + && !label.ends_with('-') + })) +} - // Try /.well-known/matrix/server delegation - if let Some(resolved) = try_well_known(server_name).await { - return resolved; +fn parse_server(server_name: &str) -> Result { + if !is_valid_server_name(server_name) { + return Err("Invalid Matrix server name".into()); } + let server = <&ruma::ServerName>::try_from(server_name).map_err(|e| e.to_string())?; + // URL parsers also accept shortened/octal/integer IPv4 spellings. Validate + // their normalized host, since an IP literal bypasses the DNS resolver. + let url = reqwest::Url::parse(&format!("https://{server_name}")).map_err(|e| e.to_string())?; + let host = url + .host_str() + .ok_or("Missing federation host")? + .trim_start_matches('[') + .trim_end_matches(']'); + if host + .parse::() + .is_ok_and(|ip| !is_public_ip(ip)) + { + return Err("Federation destination must be a public address".into()); + } + Ok(ResolvedServer { + host: server.host().to_owned(), + port: server.port().unwrap_or(8448), + }) +} - // Fall back to port 8448 - ResolvedServer { - host: server_name.to_owned(), - port: 8448, +pub async fn resolve_server_name(server_name: &str) -> Result { + let direct = parse_server(server_name)?; + let server = <&ruma::ServerName>::try_from(server_name).map_err(|e| e.to_string())?; + if server.port().is_some() || server.is_ip_literal() { + return Ok(direct); } + Ok(try_well_known(server_name).await.unwrap_or(direct)) } -async fn try_well_known(server_name: &str) -> Option { - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(5)) - .danger_accept_invalid_certs(false) +/// No redirects: discovery must not silently change the HTTPS trust authority. +pub fn http_client(timeout: Duration) -> Result { + reqwest::Client::builder() + .timeout(timeout) + .redirect(reqwest::redirect::Policy::none()) + .no_proxy() + .dns_resolver(std::sync::Arc::new(PublicDns)) .build() - .ok()?; + .map_err(|e| e.to_string()) +} - let url = format!("https://{}/.well-known/matrix/server", server_name); - let resp = client.get(&url).send().await.ok()?; +/// Resolve once, validate the actual addresses, and hand the pinned answers to the +/// HTTP connector. Checking a hostname separately would permit DNS rebinding. +struct PublicDns; +impl reqwest::dns::Resolve for PublicDns { + fn resolve(&self, name: reqwest::dns::Name) -> reqwest::dns::Resolving { + Box::pin(async move { + let addresses: Vec<_> = tokio::net::lookup_host((name.as_str(), 0)).await?.collect(); + if addresses.is_empty() || addresses.iter().any(|address| !is_public_ip(address.ip())) { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "Non-public federation address", + ) + .into()); + } + Ok(Box::new(addresses.into_iter()) as reqwest::dns::Addrs) + }) + } +} + +fn is_public_ip(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(ip) => { + let [a, b, c, _] = ip.octets(); + !ip.is_private() + && !ip.is_loopback() + && !ip.is_link_local() + && !ip.is_broadcast() + && !ip.is_documentation() + && !ip.is_unspecified() + && a != 0 + && a < 224 + && !(a == 100 && (64..=127).contains(&b)) + && !(a == 192 && b == 0 && c == 0) + && !(a == 198 && (b == 18 || b == 19)) + } + std::net::IpAddr::V6(ip) => { + if let Some(mapped) = ip.to_ipv4_mapped() { + return is_public_ip(mapped.into()); + } + let segments = ip.segments(); + // Permit global unicast only; exclude documentation and protocol + // transition ranges which can tunnel to private IPv4 destinations. + (segments[0] & 0xe000) == 0x2000 + && !(segments[0] == 0x2001 && (segments[1] < 0x0200 || segments[1] == 0x0db8)) + && segments[0] != 0x2002 + && !(segments[0] == 0x3fff && segments[1] < 0x1000) + } + } +} +async fn try_well_known(server_name: &str) -> Option { + let client = http_client(Duration::from_secs(5)).ok()?; + let resp = client + .get(format!("https://{server_name}/.well-known/matrix/server")) + .send() + .await + .ok()?; if !resp.status().is_success() { return None; } + let json = bounded_json(resp).await.ok()?; + parse_server(json.get("m.server")?.as_str()?).ok() +} - let json: serde_json::Value = resp.json().await.ok()?; - let delegated = json.get("m.server")?.as_str()?; +pub async fn bounded_json(mut response: reqwest::Response) -> Result { + const MAX_BYTES: usize = 1024 * 1024; + if response + .content_length() + .is_some_and(|length| length > MAX_BYTES as u64) + { + return Err("Remote JSON response too large".into()); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await.map_err(|e| e.to_string())? { + if bytes.len() + chunk.len() > MAX_BYTES { + return Err("Remote JSON response too large".into()); + } + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes).map_err(|e| e.to_string()) +} - // Parse "host:port" or just "host" - if let Some((host, port_str)) = delegated.rsplit_once(':') { - if let Ok(port) = port_str.parse::() { - return Some(ResolvedServer { - host: host.to_owned(), - port, - }); +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn public_addresses_exclude_private_and_transition_networks() { + for address in [ + "127.0.0.1", + "10.1.1.1", + "172.16.2.3", + "192.168.1.1", + "169.254.169.254", + "100.64.1.1", + "0.0.0.0", + "224.0.0.1", + "::1", + "::ffff:127.0.0.1", + "fc00::1", + "fe80::1", + "2002:7f00:1::", + "2001:db8::1", + ] { + assert!(!is_public_ip(address.parse().unwrap()), "{address}"); + } + for address in ["1.1.1.1", "8.8.8.8", "2606:4700:4700::1111"] { + assert!(is_public_ip(address.parse().unwrap()), "{address}"); + } + } + #[test] + fn url_normalization_cannot_bypass_address_checks() { + for server in [ + "127.1", + "2130706433", + "0177.0.0.1", + "0x7f000001", + "[::ffff:127.0.0.1]", + ] { + assert!(parse_server(server).is_err(), "{server}"); + } + } + #[test] + fn alternate_public_ip_spellings_are_not_server_names() { + for server in [ + "0x08080808", + "134744072", + "010.010.010.010", + "8.8.2056", + "0x08080808:8448", + ] { + assert!(!is_valid_server_name(server), "{server}"); + } + for server in [ + "8.8.8.8", + "8.8.8.8:8448", + "dns.google", + "node123.example", + "[2606:4700:4700::1111]", + ] { + assert!(is_valid_server_name(server), "{server}"); } } - Some(ResolvedServer { - host: delegated.to_owned(), - port: 8448, - }) + #[test] + fn authorities_are_not_urls() { + for value in [ + "", + ":8448", + "evil@localhost", + "example.org/path", + "example.org?x", + "a\\b", + "a:0", + "a:65536", + "::1", + "-a.org", + "a..org", + "a#fragment", + ] { + assert!(!is_valid_server_name(value), "{value}"); + } + for value in [ + "example.org", + "example.org:443", + "127.0.0.1:8448", + "[::1]", + "[2001:db8::1]:443", + ] { + assert!(is_valid_server_name(value), "{value}"); + } + } } diff --git a/crates/vauxl-matrix/Cargo.toml b/crates/vauxl-matrix/Cargo.toml index 279f327..63d5bec 100644 --- a/crates/vauxl-matrix/Cargo.toml +++ b/crates/vauxl-matrix/Cargo.toml @@ -23,9 +23,9 @@ uuid = { version = "1", features = ["v4"] } sha2 = "0.10" serde_urlencoded = "0.7" redis = { workspace = true} -reqwest = { version = "0.11", features = ["json"] } +reqwest = { version = "0.12", features = ["json", "rustls-tls"], default-features = false } urlencoding = "2.1" -#vauxl-federation = { path = "../vauxl-federation/" } +vauxl-federation = { path = "../vauxl-federation" } [dev-dependencies] tokio-test = { workspace = true } diff --git a/crates/vauxl-matrix/src/db/history_visibility.rs b/crates/vauxl-matrix/src/db/history_visibility.rs new file mode 100644 index 0000000..3ded322 --- /dev/null +++ b/crates/vauxl-matrix/src/db/history_visibility.rs @@ -0,0 +1,507 @@ +//! Historical visibility for the accepted, linear room-version 11 event model. +//! +//! Callers hold the room lock and enforce current access separately. History is +//! rejected if any event lacks a continuous depth/predecessor chain: legacy +//! timestamp-only history requires a trusted rebuild before it can be disclosed. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::Value; +use sqlx::{Postgres, Transaction}; + +use crate::error::MatrixError; + +type HistoryRow = ( + String, + String, + Option, + Option, + Option, + Value, +); + +struct HistoryEvent { + id: String, + event_type: String, + state_key: Option, + depth: u64, + content: Value, +} + +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +enum Visibility { + WorldReadable, + Shared, + Invited, + Joined, +} + +impl Visibility { + fn parse(content: &Value) -> Result { + match content.get("history_visibility").and_then(Value::as_str) { + Some("world_readable") => Ok(Self::WorldReadable), + Some("shared") => Ok(Self::Shared), + Some("invited") => Ok(Self::Invited), + Some("joined") => Ok(Self::Joined), + _ => Err(MatrixError::Forbidden), + } + } +} + +enum Audience<'a> { + User(&'a str), + Server(&'a str), +} + +impl Audience<'_> { + fn includes(&self, user: &str) -> bool { + match self { + Self::User(expected) => user == *expected, + Self::Server(expected) => { + ruma::UserId::parse(user).is_ok_and(|id| id.server_name().as_str() == *expected) + } + } + } +} + +/// Filter persisted events by a local user's historical membership. +pub async fn filter_for_user( + tx: &mut Transaction<'_, Postgres>, + room_id: &str, + events: Vec, + user_id: &str, +) -> Result, MatrixError> { + filter(tx, room_id, events, Audience::User(user_id)).await +} + +/// A server can receive an event when at least one of its users could see it. +pub async fn filter_for_server( + tx: &mut Transaction<'_, Postgres>, + room_id: &str, + events: Vec, + server: &str, +) -> Result, MatrixError> { + filter(tx, room_id, events, Audience::Server(server)).await +} + +async fn filter( + tx: &mut Transaction<'_, Postgres>, + room_id: &str, + events: Vec, + audience: Audience<'_>, +) -> Result, MatrixError> { + // Read only ordering and authorization metadata for the history scan. + let rows: Vec = sqlx::query_as( + "SELECT event_id, event_type, state_key, raw_event->'depth', raw_event->'prev_events', + CASE WHEN event_type IN ('m.room.create','m.room.member','m.room.history_visibility') THEN content ELSE '{}'::jsonb END + FROM events WHERE room_id=$1", + ).bind(room_id).fetch_all(&mut **tx).await?; + // Distinguish unprovable history from permission or database failures so a + // sync response can omit this timeline without blocking unrelated rooms. + let history = validate_history(rows).map_err(|_| MatrixError::HistoryUnavailable)?; + let allowed = visible_ids(&history, audience).map_err(|_| MatrixError::HistoryUnavailable)?; + Ok(events + .into_iter() + .filter(|event| { + event + .get("event_id") + .and_then(Value::as_str) + .is_some_and(|id| allowed.contains(id)) + }) + .collect()) +} + +fn validate_history(rows: Vec) -> Result, MatrixError> { + let mut ordered = BTreeMap::new(); + for (id, event_type, state_key, depth, previous, content) in rows { + let depth = depth + .and_then(|v| v.as_u64()) + .filter(|v| *v > 0 && *v <= 9_007_199_254_740_991) + .ok_or(MatrixError::Forbidden)?; + if ordered + .insert(depth, (id, event_type, state_key, previous, content)) + .is_some() + { + return Err(MatrixError::Forbidden); + } + } + let mut history: Vec = Vec::new(); + for (depth, (id, event_type, state_key, previous, content)) in ordered { + if depth != history.len() as u64 + 1 { + return Err(MatrixError::Forbidden); + } + let previous = previous + .and_then(|v| v.as_array().cloned()) + .ok_or(MatrixError::Forbidden)?; + if let Some(last) = history.last() { + if previous.len() != 1 || previous[0].as_str() != Some(&last.id) { + return Err(MatrixError::Forbidden); + } + } else if event_type != "m.room.create" + || state_key.as_deref() != Some("") + || !previous.is_empty() + || content.get("room_version").and_then(Value::as_str) != Some("11") + { + return Err(MatrixError::Forbidden); + } + history.push(HistoryEvent { + id, + event_type, + state_key, + depth, + content, + }); + } + if history.is_empty() { + return Err(MatrixError::Forbidden); + } + Ok(history) +} + +fn visible_ids( + history: &[HistoryEvent], + audience: Audience<'_>, +) -> Result, MatrixError> { + let latest_join = history + .iter() + .filter(|event| { + event.event_type == "m.room.member" + && event + .state_key + .as_deref() + .is_some_and(|user| audience.includes(user)) + && event.content.get("membership").and_then(Value::as_str) == Some("join") + }) + .map(|event| event.depth) + .max() + .unwrap_or(0); + let mut memberships: BTreeMap = BTreeMap::new(); + let mut policy = Visibility::Shared; + let mut allowed = BTreeSet::new(); + for event in history { + let joined_before = memberships.values().any(|membership| membership == "join"); + let invited_before = memberships + .values() + .any(|membership| membership == "invite"); + if event.event_type == "m.room.member" { + let user = event.state_key.as_deref().ok_or(MatrixError::Forbidden)?; + let membership = event + .content + .get("membership") + .and_then(Value::as_str) + .filter(|m| matches!(*m, "join" | "invite" | "leave" | "ban")) + .ok_or(MatrixError::Forbidden)?; + if audience.includes(user) { + memberships.insert(user.into(), membership.into()); + } + } + let joined = joined_before || memberships.values().any(|membership| membership == "join"); + let invited = invited_before + || memberships + .values() + .any(|membership| membership == "invite"); + let next_policy = if event.event_type == "m.room.history_visibility" { + if event.state_key.as_deref() != Some("") { + return Err(MatrixError::Forbidden); + } + Visibility::parse(&event.content)? + } else { + policy + }; + // The policy-change event itself uses the less restrictive policy. A + // membership transition similarly uses membership on either side. + let visible = match policy.min(next_policy) { + Visibility::WorldReadable => true, + Visibility::Shared => joined || latest_join >= event.depth, + Visibility::Invited => joined || invited, + Visibility::Joined => joined, + }; + if visible { + allowed.insert(event.id.clone()); + } + policy = next_policy; + } + Ok(allowed) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const USER: &str = "@user:remote.example"; + const OTHER: &str = "@other:remote.example"; + const ROOM: &str = "!room:example.org"; + const OWNER: &str = "@owner:example.org"; + + fn history(sequence: &[(&str, Option<&str>, Value)]) -> Vec { + let mut events = vec![HistoryEvent { + id: "$create".into(), + event_type: "m.room.create".into(), + state_key: Some("".into()), + depth: 1, + content: json!({"room_version":"11"}), + }]; + for (index, (kind, key, content)) in sequence.iter().enumerate() { + events.push(HistoryEvent { + id: format!("${index}"), + event_type: (*kind).into(), + state_key: key.map(str::to_owned), + depth: index as u64 + 2, + content: content.clone(), + }); + } + events + } + + #[test] + fn membership_intervals_and_policy_changes_are_historical() { + for (policy, expected) in [ + ("invited", vec!["$3", "$5", "$9"]), + ("joined", vec!["$5", "$9"]), + ("shared", vec!["$1", "$3", "$5", "$7", "$9"]), + ("world_readable", vec!["$1", "$3", "$5", "$7", "$9"]), + ] { + let events = history(&[ + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":policy}), + ), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"invite"})), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"join"})), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"leave"})), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"join"})), + ("m.room.message", None, json!({})), + ]); + for audience in [Audience::User(USER), Audience::Server("remote.example")] { + let allowed = visible_ids(&events, audience).unwrap(); + let visible: Vec<_> = events + .iter() + .filter(|e| e.event_type == "m.room.message" && allowed.contains(&e.id)) + .map(|e| e.id.as_str()) + .collect(); + assert_eq!(visible, expected, "{policy}"); + } + } + let events = history(&[ + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"invited"}), + ), + ("m.room.message", None, json!({})), + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"shared"}), + ), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"join"})), + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"joined"}), + ), + ("m.room.member", Some(USER), json!({"membership":"leave"})), + ("m.room.message", None, json!({})), + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"world_readable"}), + ), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"join"})), + ]); + let allowed = visible_ids(&events, Audience::User(USER)).unwrap(); + assert!(!allowed.contains("$1")); // later shared policy does not expose invited history + assert!(allowed.contains("$3")); // shared before join is visible + assert!(!allowed.contains("$7")); // later rejoin does not fill a joined-only gap + assert!(allowed.contains("$9")); // world-readable gap is visible + assert!(allowed.contains("$8")); // policy transition itself uses the less restrictive mode + } + + #[test] + fn server_visibility_is_the_union_of_its_users() { + let events = history(&[ + ( + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"joined"}), + ), + ("m.room.member", Some(USER), json!({"membership":"join"})), + ("m.room.message", None, json!({})), + ("m.room.member", Some(USER), json!({"membership":"leave"})), + ("m.room.message", None, json!({})), + ("m.room.member", Some(OTHER), json!({"membership":"join"})), + ("m.room.message", None, json!({})), + ]); + let allowed = visible_ids(&events, Audience::Server("remote.example")).unwrap(); + assert!(allowed.contains("$2")); + assert!(!allowed.contains("$4")); + assert!(allowed.contains("$6")); + assert!(!visible_ids(&events, Audience::User(USER)) + .unwrap() + .contains("$6")); + assert!(!visible_ids(&events, Audience::Server("unrelated.example")) + .unwrap() + .contains("$2")); + } + + #[test] + fn untrusted_legacy_order_is_rejected() { + let create = ( + "$create".into(), + "m.room.create".into(), + Some("".into()), + Some(json!(1)), + Some(json!([])), + json!({"room_version":"11"}), + ); + let message = ( + "$message".into(), + "m.room.message".into(), + None, + Some(json!(2)), + Some(json!(["$create"])), + json!({}), + ); + assert!(validate_history(vec![message.clone(), create.clone()]).is_ok()); + let mut legacy = message.clone(); + legacy.3 = None; + assert!(validate_history(vec![create.clone(), legacy]).is_err()); + let mut gap = message.clone(); + gap.3 = Some(json!(3)); + assert!(validate_history(vec![create.clone(), gap]).is_err()); + let mut fork = message.clone(); + fork.4 = Some(json!(["$untrusted"])); + assert!(validate_history(vec![create.clone(), fork]).is_err()); + assert!(validate_history(vec![create, message.clone(), message]).is_err()); + } + + fn key() -> crate::signing_key::HomeserverSigningKey { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[9; 32]); + crate::signing_key::HomeserverSigningKey { + verifying_key: signing_key.verifying_key(), + signing_key, + key_id: "ed25519:test".into(), + } + } + async fn member(pool: &sqlx::PgPool, target: &str, sender: &str, membership: &str) { + crate::db::rooms::put_room_state_event( + pool, + ROOM, + "m.room.member", + target, + sender, + json!({"membership":membership}), + "example.org", + &key(), + ) + .await + .unwrap(); + } + async fn message(pool: &sqlx::PgPool, body: &str) -> String { + crate::db::rooms::put_room_event( + pool, + ROOM, + "m.room.message", + OWNER, + json!({"body":body}), + "example.org", + &key(), + ) + .await + .unwrap() + } + + #[sqlx::test(migrations = "../../migrations")] + async fn local_history_and_sync_omit_preinvite_and_leave_gap_events(pool: sqlx::PgPool) { + crate::db::rooms::create_room_with_state( + &pool, + ROOM, + OWNER, + vec![ + ( + "m.room.create".into(), + "".into(), + json!({"room_version":"11"}), + ), + ( + "m.room.member".into(), + OWNER.into(), + json!({"membership":"join"}), + ), + ( + "m.room.power_levels".into(), + "".into(), + json!({"users":{OWNER:100},"invite":50}), + ), + ( + "m.room.join_rules".into(), + "".into(), + json!({"join_rule":"invite"}), + ), + ( + "m.room.history_visibility".into(), + "".into(), + json!({"history_visibility":"invited"}), + ), + ], + "example.org", + &key(), + ) + .await + .unwrap(); + let hidden = message(&pool, "before invite").await; + member(&pool, USER, OWNER, "invite").await; + let invited = message(&pool, "invited").await; + member(&pool, USER, USER, "join").await; + let joined = message(&pool, "joined").await; + member(&pool, USER, USER, "leave").await; + let gap = message(&pool, "leave gap").await; + member(&pool, USER, OWNER, "invite").await; + member(&pool, USER, USER, "join").await; + let rejoined = message(&pool, "rejoined").await; + // Hostile timestamps may affect legacy pagination, never permission. + sqlx::query("UPDATE events SET origin_ts=9999999999999 WHERE event_id=$1") + .bind(&hidden) + .execute(&pool) + .await + .unwrap(); + let (events, _) = + crate::db::rooms::get_room_messages(&pool, ROOM, USER, None, None, "b", 100) + .await + .unwrap(); + let ids: BTreeSet<_> = events + .iter() + .map(|e| e["event_id"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + BTreeSet::from([invited.as_str(), joined.as_str(), rejoined.as_str()]) + ); + assert!(!ids.contains(hidden.as_str()) && !ids.contains(gap.as_str())); + let mut tx = pool.begin().await.unwrap(); + crate::db::room_auth::lock_room(&mut tx, ROOM) + .await + .unwrap(); + for since in [0, 1] { + let events = crate::db::sync::get_room_timeline(&mut tx, ROOM, USER, since) + .await + .unwrap(); + let ids: BTreeSet<_> = events + .iter() + .map(|e| e["event_id"].as_str().unwrap()) + .collect(); + assert_eq!( + ids, + BTreeSet::from([invited.as_str(), joined.as_str(), rejoined.as_str()]) + ); + } + } +} diff --git a/crates/vauxl-matrix/src/db/mod.rs b/crates/vauxl-matrix/src/db/mod.rs index 8021792..d745115 100644 --- a/crates/vauxl-matrix/src/db/mod.rs +++ b/crates/vauxl-matrix/src/db/mod.rs @@ -92,11 +92,13 @@ pub fn generate_access_token() -> (String, String) { let hash = format!("{:x}", Sha256::digest(token.as_bytes())); (token, hash) } +pub mod history_visibility; +pub mod room_auth; pub mod rooms; pub use rooms::{ assert_joined, create_room_with_state, generate_event_id, get_full_room_state, - get_room_messages, put_room_event, put_room_state_event, + get_room_messages, put_room_event, put_room_event_idempotent, put_room_state_event, }; pub mod membership; diff --git a/crates/vauxl-matrix/src/db/room_auth.rs b/crates/vauxl-matrix/src/db/room_auth.rs new file mode 100644 index 0000000..2596cca --- /dev/null +++ b/crates/vauxl-matrix/src/db/room_auth.rs @@ -0,0 +1,968 @@ +//! Authorization for the supported local room-version 11 event model. +//! +//! This checks current room state, not federation DAG/state resolution. Callers +//! must lock the room before reading authorization state and hold that lock until +//! the authorized event and its state/membership updates have committed. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value}; +use sqlx::{Postgres, Transaction}; + +use crate::error::MatrixError; + +type Result = std::result::Result; + +/// Serialize every event writer for an existing room, including federation. +pub async fn lock_room(tx: &mut Transaction<'_, Postgres>, room_id: &str) -> Result<()> { + let room: Option = + sqlx::query_scalar("SELECT room_id FROM rooms WHERE room_id = $1 FOR UPDATE") + .bind(room_id) + .fetch_optional(&mut **tx) + .await?; + room.ok_or(MatrixError::NotFound).map(|_| ()) +} + +/// Authorize against state read inside the caller's locked write transaction. +pub async fn authorize_event( + tx: &mut Transaction<'_, Postgres>, + room_id: &str, + sender: &str, + event_type: &str, + state_key: Option<&str>, + content: &Value, +) -> Result<()> { + let rows: Vec<(String, String, String, Value)> = sqlx::query_as( + "SELECT rs.event_type, rs.state_key, e.sender, e.content + FROM room_state rs JOIN events e ON e.event_id = rs.event_id + WHERE rs.room_id = $1 AND rs.event_type IN + ('m.room.create', 'm.room.member', 'm.room.power_levels', 'm.room.join_rules')", + ) + .bind(room_id) + .fetch_all(&mut **tx) + .await?; + let state = AuthState( + rows.into_iter() + .map(|(t, k, s, c)| ((t, k), (s, c))) + .collect(), + ); + state.authorize(room_id, sender, event_type, state_key, content) +} + +#[derive(Default)] +struct AuthState(BTreeMap<(String, String), (String, Value)>); + +impl AuthState { + fn get(&self, event_type: &str, key: &str) -> Option<&Value> { + self.0.get(&(event_type.into(), key.into())).map(|(_, c)| c) + } + + fn membership(&self, user: &str) -> Option<&str> { + self.get("m.room.member", user)?.get("membership")?.as_str() + } + + fn authorize( + &self, + room_id: &str, + sender: &str, + event_type: &str, + state_key: Option<&str>, + content: &Value, + ) -> Result<()> { + let sender_id = ruma::UserId::parse(sender).map_err(|_| MatrixError::Forbidden)?; + let room = ruma::RoomId::parse(room_id).map_err(|_| MatrixError::Forbidden)?; + ensure(content.is_object() && !event_type.is_empty())?; + let create = self.0.get(&("m.room.create".into(), String::new())); + if event_type == "m.room.create" { + // Bootstrap is possible only in a newly inserted, empty room. + return ensure( + create.is_none() + && self.0.is_empty() + && state_key == Some("") + && room.server_name() == Some(sender_id.server_name()) + && content.get("room_version").and_then(Value::as_str) == Some("11"), + ); + } + let (creator, create_content) = create.ok_or(MatrixError::Forbidden)?; + ensure(create_content.get("room_version").and_then(Value::as_str) == Some("11"))?; + if create_content.get("m.federate") == Some(&Value::Bool(false)) { + let creator_id = ruma::UserId::parse(creator).map_err(|_| MatrixError::Forbidden)?; + ensure(creator_id.server_name() == sender_id.server_name())?; + } + let empty = serde_json::json!({}); + let old_power = self.get("m.room.power_levels", ""); + let power = PowerLevels::parse(old_power.unwrap_or(&empty))?; + let sender_level = if old_power.is_none() && sender == creator { + 100 + } else { + power.user(sender) + }; + + if event_type == "m.room.member" { + let target = state_key.ok_or(MatrixError::Forbidden)?; + ruma::UserId::parse(target).map_err(|_| MatrixError::Forbidden)?; + // Third-party invites, restricted joins and knocks need additional + // proofs and auth events which this server does not implement. + ensure( + !content + .as_object() + .unwrap() + .contains_key("third_party_invite") + && !content + .as_object() + .unwrap() + .contains_key("join_authorised_via_users_server"), + )?; + let next = content + .get("membership") + .and_then(Value::as_str) + .ok_or(MatrixError::Forbidden)?; + let previous = self.membership(target); + if next == "join" { + ensure(sender == target && previous != Some("ban"))?; + if sender == creator && self.0.len() == 1 { + return Ok(()); + } + if previous == Some("join") { + return Ok(()); + } + let rule = self + .get("m.room.join_rules", "") + .and_then(|v| v.get("join_rule")) + .and_then(Value::as_str) + .unwrap_or("invite"); + return ensure( + rule == "public" || (rule == "invite" && previous == Some("invite")), + ); + } + if next == "leave" && sender == target { + return ensure(matches!(previous, Some("join" | "invite"))); + } + ensure(self.membership(sender) == Some("join"))?; + let target_level = if old_power.is_none() && target == creator { + 100 + } else { + power.user(target) + }; + return match next { + "invite" => ensure( + sender_level >= power.scalar("invite") + && !matches!(previous, Some("join" | "ban")), + ), + "ban" => ensure(sender_level >= power.scalar("ban") && sender_level > target_level), + "leave" => ensure( + matches!(previous, Some("join" | "invite" | "ban")) + && sender_level >= power.scalar("kick") + && sender_level > target_level + && (previous != Some("ban") || sender_level >= power.scalar("ban")), + ), + _ => Err(MatrixError::Forbidden), + }; + } + ensure(self.membership(sender) == Some("join"))?; + // These types require a state key (and the singleton types an empty one). + if matches!( + event_type, + "m.room.power_levels" + | "m.room.join_rules" + | "m.room.history_visibility" + | "m.room.server_acl" + ) { + ensure(state_key == Some(""))?; + } + // Unsupported special auth must not fall through to ordinary event auth. + ensure(!matches!( + event_type, + "m.room.third_party_invite" | "m.room.redaction" + ))?; + if let Some(key) = state_key { + ensure(!key.starts_with('@') || key == sender)?; + } + let required = power + .map("events") + .get(event_type) + .copied() + .unwrap_or_else(|| { + power.scalar(if state_key.is_some() { + "state_default" + } else { + "events_default" + }) + }); + ensure(sender_level >= required)?; + if event_type == "m.room.history_visibility" { + ensure(matches!( + content.get("history_visibility").and_then(Value::as_str), + Some("world_readable" | "shared" | "invited" | "joined") + ))?; + } + if event_type == "m.room.server_acl" { + serde_json::from_value::( + content.clone(), + ) + .map_err(|_| MatrixError::Forbidden)?; + } + if event_type == "m.room.power_levels" { + power.authorize_change(&PowerLevels::parse(content)?, sender, sender_level)?; + } + if event_type == "m.room.join_rules" { + ensure(matches!( + content.get("join_rule").and_then(Value::as_str), + Some("public" | "invite") + ))?; + } + Ok(()) + } +} + +fn ensure(allowed: bool) -> Result<()> { + if allowed { + Ok(()) + } else { + Err(MatrixError::Forbidden) + } +} + +const SCALARS: &[(&str, i64)] = &[ + ("users_default", 0), + ("events_default", 0), + ("state_default", 50), + ("ban", 50), + ("kick", 50), + ("redact", 50), + ("invite", 0), +]; + +struct PowerLevels { + scalars: BTreeMap, + maps: BTreeMap>, +} + +impl PowerLevels { + fn parse(value: &Value) -> Result { + let object = value.as_object().ok_or(MatrixError::Forbidden)?; + let mut scalars = BTreeMap::new(); + for &(key, default) in SCALARS { + scalars.insert( + key.into(), + object.get(key).map(level).transpose()?.unwrap_or(default), + ); + } + let mut maps = BTreeMap::new(); + for key in ["users", "events", "notifications"] { + let empty = Map::new(); + let values = match object.get(key) { + Some(v) => v.as_object().ok_or(MatrixError::Forbidden)?, + None => &empty, + }; + let mut levels = BTreeMap::new(); + for (name, value) in values { + if key == "users" { + ruma::UserId::parse(name).map_err(|_| MatrixError::Forbidden)?; + } + levels.insert(name.clone(), level(value)?); + } + if key == "notifications" { + levels.entry("room".into()).or_insert(50); + } + maps.insert(key.into(), levels); + } + Ok(Self { scalars, maps }) + } + + fn scalar(&self, key: &str) -> i64 { + self.scalars[key] + } + fn map(&self, key: &str) -> &BTreeMap { + &self.maps[key] + } + fn user(&self, user: &str) -> i64 { + self.map("users") + .get(user) + .copied() + .unwrap_or_else(|| self.scalar("users_default")) + } + + fn authorize_change(&self, next: &Self, sender: &str, sender_level: i64) -> Result<()> { + for &(key, _) in SCALARS { + let old = self.scalar(key); + let new = next.scalar(key); + if old != new { + ensure(old <= sender_level && new <= sender_level)?; + } + } + for map in ["users", "events", "notifications"] { + let old = self.map(map); + let new = next.map(map); + let keys: BTreeSet<_> = old.keys().chain(new.keys()).collect(); + for key in keys { + if old.get(key) == new.get(key) { + continue; + } + ensure( + old.get(key).is_none_or(|v| *v <= sender_level) + && new.get(key).is_none_or(|v| *v <= sender_level), + )?; + if map == "users" { + // Removing an entry assigns users_default. Check the + // resulting effective level too, or deleting a low explicit + // level could promote the sender or a subordinate above it. + ensure(self.user(key) <= sender_level && next.user(key) <= sender_level)?; + if key != sender { + // An equal-power peer cannot be demoted, including by + // deleting their entry and falling back to default. + ensure(self.user(key) < sender_level)?; + } + } + } + } + Ok(()) + } +} + +fn level(value: &Value) -> Result { + let number = value.as_i64().ok_or(MatrixError::Forbidden)?; + ensure((-9_007_199_254_740_991..=9_007_199_254_740_991).contains(&number))?; + Ok(number) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + const ROOM: &str = "!room:example.org"; + const OWNER: &str = "@owner:example.org"; + const MOD: &str = "@mod:example.org"; + const PEER: &str = "@peer:example.org"; + const USER: &str = "@user:example.org"; + const OUTSIDER: &str = "@outsider:example.org"; + + fn state() -> AuthState { + let mut state = AuthState::default(); + state.insert("m.room.create", "", json!({"room_version":"11"})); + state.insert( + "m.room.power_levels", + "", + json!({ + "users": {OWNER:100, MOD:50, PEER:50}, "invite":50, + }), + ); + state.insert("m.room.join_rules", "", json!({"join_rule":"invite"})); + for user in [OWNER, MOD, PEER, USER] { + state.insert("m.room.member", user, json!({"membership":"join"})); + } + state + } + + impl AuthState { + fn insert(&mut self, t: &str, key: &str, c: Value) { + self.0.insert((t.into(), key.into()), (OWNER.into(), c)); + } + fn allowed(&self, sender: &str, t: &str, key: Option<&str>, c: Value) -> bool { + self.authorize(ROOM, sender, t, key, &c).is_ok() + } + } + + #[test] + fn event_thresholds_and_special_types_cannot_bypass_state_auth() { + let state = state(); + for (sender, kind, key, content, allowed) in [ + (USER, "m.room.message", None, json!({"body":"hello"}), true), + (OUTSIDER, "m.room.message", None, json!({}), false), + ( + USER, + "m.room.topic", + Some(""), + json!({"topic":"takeover"}), + false, + ), + ( + MOD, + "m.room.topic", + Some(""), + json!({"topic":"allowed"}), + true, + ), + ( + OWNER, + "m.room.create", + Some(""), + json!({"room_version":"11"}), + false, + ), + (OWNER, "m.room.power_levels", None, json!({}), false), + ( + OWNER, + "m.room.power_levels", + Some("other"), + json!({}), + false, + ), + ( + OWNER, + "m.room.member", + None, + json!({"membership":"ban"}), + false, + ), + ( + OWNER, + "m.room.join_rules", + None, + json!({"join_rule":"public"}), + false, + ), + ( + OWNER, + "m.room.join_rules", + Some(""), + json!({"join_rule":"restricted"}), + false, + ), + ( + OWNER, + "m.room.third_party_invite", + Some("token"), + json!({}), + false, + ), + (OWNER, "m.room.redaction", None, json!({}), false), + (OWNER, "custom.state", Some(USER), json!({}), false), + (USER, "m.room.message", None, Value::Null, false), + ( + OWNER, + "m.room.history_visibility", + None, + json!({"history_visibility":"shared"}), + false, + ), + ( + OWNER, + "m.room.history_visibility", + Some(""), + json!({"history_visibility":"invalid"}), + false, + ), + ( + OWNER, + "m.room.server_acl", + Some("other"), + json!({"allow":["*"]}), + false, + ), + ( + OWNER, + "m.room.server_acl", + Some(""), + json!({"allow":"*"}), + false, + ), + ( + OWNER, + "m.room.server_acl", + Some(""), + json!({"allow":["*"]}), + true, + ), + ] { + assert_eq!( + state.allowed(sender, kind, key, content), + allowed, + "{sender} {kind} {key:?}" + ); + } + let mut state = state; + state.insert( + "m.room.power_levels", + "", + json!({"users":{OWNER:100}, "events":{"m.room.message":50}}), + ); + assert!(!state.allowed(USER, "m.room.message", None, json!({}))); + } + + #[test] + fn membership_transitions_enforce_hierarchy_and_bans() { + for (sender, target, previous, next, public, allowed) in [ + (USER, OWNER, "join", "leave", false, false), + (MOD, PEER, "join", "leave", false, false), + (MOD, USER, "join", "leave", false, true), + (MOD, USER, "invite", "leave", false, true), + (MOD, OWNER, "join", "ban", false, false), + (MOD, PEER, "join", "ban", false, false), + (MOD, USER, "join", "ban", false, true), + (USER, OUTSIDER, "leave", "invite", false, false), + (MOD, OUTSIDER, "leave", "invite", false, true), + (OWNER, OUTSIDER, "ban", "invite", false, false), + (OUTSIDER, OUTSIDER, "ban", "join", true, false), + (OUTSIDER, OUTSIDER, "ban", "leave", true, false), + (OUTSIDER, OUTSIDER, "leave", "join", false, false), + (OUTSIDER, OUTSIDER, "invite", "join", false, true), + (OUTSIDER, OUTSIDER, "leave", "join", true, true), + (OWNER, OUTSIDER, "leave", "join", true, false), + (OUTSIDER, OUTSIDER, "invite", "leave", false, true), + (USER, USER, "join", "leave", false, true), + (MOD, USER, "ban", "leave", false, true), + (USER, USER, "join", "knock", false, false), + (USER, USER, "join", "invalid", false, false), + ] { + let mut state = state(); + state.insert("m.room.member", target, json!({"membership":previous})); + state.insert( + "m.room.join_rules", + "", + json!({"join_rule":if public {"public"} else {"invite"}}), + ); + assert_eq!( + state.allowed( + sender, + "m.room.member", + Some(target), + json!({"membership":next}) + ), + allowed, + "{sender} {target} {previous}->{next}, public={public}" + ); + } + let state = state(); + for content in [ + json!({}), + json!({"membership":null}), + json!({"membership":"join","join_authorised_via_users_server":OWNER}), + json!({"membership":"invite","third_party_invite":{}}), + ] { + assert!(!state.allowed(OWNER, "m.room.member", Some(OUTSIDER), content)); + } + } + + #[test] + fn power_changes_cannot_escalate_or_demote_equal_power_peers() { + let state = state(); + let old = state.get("m.room.power_levels", "").unwrap(); + for (sender, field, value, allowed) in [ + (USER, "users_default", json!(100), false), + (MOD, "users_default", json!(51), false), + (MOD, "events_default", json!(51), false), + (MOD, "state_default", json!(51), false), + (MOD, "ban", json!(51), false), + (MOD, "kick", json!(51), false), + (MOD, "invite", json!(51), false), + (MOD, "redact", json!(51), false), + (MOD, "events", json!({"m.room.message":51}), false), + (MOD, "notifications", json!({"room":51}), false), + (MOD, "notifications", json!({"room":40}), true), + (OWNER, "users_default", json!(50), true), + (OWNER, "users_default", json!("50"), false), + (OWNER, "events", json!({"x":1.5}), false), + (OWNER, "users", json!({"bad":50}), false), + (OWNER, "notifications", Value::Null, false), + ] { + let mut content = old.clone(); + content[field] = value; + assert_eq!( + state.allowed(sender, "m.room.power_levels", Some(""), content), + allowed, + "{sender} {field}" + ); + } + for (sender, target, new, allowed) in [ + (MOD, MOD, Some(51), false), + (MOD, MOD, Some(0), true), + (MOD, USER, Some(50), true), + (MOD, PEER, Some(0), false), + (MOD, PEER, None, false), + (MOD, OWNER, None, false), + (OWNER, OWNER, None, true), + (OWNER, USER, Some(101), false), + ] { + let mut content = old.clone(); + if let Some(level) = new { + content["users"][target] = json!(level); + } else { + content["users"].as_object_mut().unwrap().remove(target); + } + assert_eq!( + state.allowed(sender, "m.room.power_levels", Some(""), content), + allowed, + "{sender} changes {target} to {new:?}" + ); + } + let mut old = old.clone(); + let mut state = state; + old["events"] = json!({"protected":100}); + old["notifications"] = json!({"room":100}); + old["ban"] = json!(100); + state.insert("m.room.power_levels", "", old.clone()); + for field in ["events", "notifications", "ban"] { + let mut content = old.clone(); + content.as_object_mut().unwrap().remove(field); + assert!( + !state.allowed(MOD, "m.room.power_levels", Some(""), content), + "removed {field}" + ); + } + } + + #[test] + fn removing_user_levels_checks_the_effective_default() { + for (default, sender, target, allowed) in [ + (100, MOD, MOD, false), + (100, MOD, USER, false), + (0, MOD, MOD, true), + (0, MOD, USER, true), + (50, MOD, MOD, true), + (50, MOD, USER, true), + (100, OWNER, OWNER, true), + (100, OWNER, USER, true), + ] { + let mut state = state(); + let old = json!({"users_default":default, "users":{OWNER:100,MOD:50,USER:0}}); + state.insert("m.room.power_levels", "", old.clone()); + let mut next = old; + next["users"].as_object_mut().unwrap().remove(target); + assert_eq!( + state.allowed(sender, "m.room.power_levels", Some(""), next), + allowed, + "{sender} removes {target} with users_default={default}" + ); + } + } + + fn signing_key() -> crate::signing_key::HomeserverSigningKey { + let signing_key = ed25519_dalek::SigningKey::from_bytes(&[7; 32]); + crate::signing_key::HomeserverSigningKey { + verifying_key: signing_key.verifying_key(), + signing_key, + key_id: "ed25519:test".into(), + } + } + + async fn create_test_room(pool: &sqlx::PgPool) { + create_test_room_with_id(pool, ROOM).await; + } + + async fn create_test_room_with_id(pool: &sqlx::PgPool, room: &str) { + crate::db::rooms::create_room_with_state( + pool, + room, + OWNER, + vec![ + ( + "m.room.create".into(), + "".into(), + json!({"room_version":"11"}), + ), + ( + "m.room.member".into(), + OWNER.into(), + json!({"membership":"join"}), + ), + ( + "m.room.power_levels".into(), + "".into(), + json!({"users":{OWNER:100},"invite":50}), + ), + ( + "m.room.join_rules".into(), + "".into(), + json!({"join_rule":"public"}), + ), + ], + "example.org", + &signing_key(), + ) + .await + .unwrap(); + } + + async fn member( + pool: &sqlx::PgPool, + sender: &str, + target: &str, + membership: &str, + ) -> Result { + crate::db::rooms::put_room_state_event( + pool, + ROOM, + "m.room.member", + target, + sender, + json!({"membership":membership}), + "example.org", + &signing_key(), + ) + .await + } + + #[sqlx::test(migrations = "../../migrations")] + async fn unauthorized_writes_leave_events_state_and_membership_unchanged(pool: sqlx::PgPool) { + create_test_room(&pool).await; + member(&pool, USER, USER, "join").await.unwrap(); + let before: i64 = sqlx::query_scalar("SELECT count(*) FROM events") + .fetch_one(&pool) + .await + .unwrap(); + assert!(member(&pool, USER, OWNER, "ban").await.is_err()); + assert!(crate::db::rooms::put_room_state_event( + &pool, + ROOM, + "m.room.power_levels", + "", + USER, + json!({"users":{USER:100}}), + "example.org", + &signing_key() + ) + .await + .is_err()); + assert!(crate::db::rooms::put_room_event( + &pool, + ROOM, + "m.room.member", + USER, + json!({"membership":"ban"}), + "example.org", + &signing_key() + ) + .await + .is_err()); + let after: i64 = sqlx::query_scalar("SELECT count(*) FROM events") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(before, after); + assert_eq!( + crate::db::membership::get_membership(&pool, ROOM, OWNER) + .await + .unwrap() + .as_deref(), + Some("join") + ); + member(&pool, OWNER, USER, "ban").await.unwrap(); + assert!(member(&pool, USER, USER, "join").await.is_err()); + assert!(member(&pool, USER, USER, "leave").await.is_err()); + assert!(member(&pool, OWNER, USER, "invite").await.is_err()); + assert_eq!( + crate::db::membership::get_membership(&pool, ROOM, USER) + .await + .unwrap() + .as_deref(), + Some("ban") + ); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn authorization_reads_state_after_waiting_for_the_room_lock(pool: sqlx::PgPool) { + create_test_room(&pool).await; + member(&pool, USER, USER, "join").await.unwrap(); + let mut tx = pool.begin().await.unwrap(); + lock_room(&mut tx, ROOM).await.unwrap(); + let write_pool = pool.clone(); + let mut writer = tokio::spawn(async move { + crate::db::rooms::put_room_event( + &write_pool, + ROOM, + "m.room.message", + USER, + json!({"body":"must not pass after ban"}), + "example.org", + &signing_key(), + ) + .await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(100), &mut writer) + .await + .is_err() + ); + // Publish a ban while owning the lock, as another authorized writer does. + sqlx::query("UPDATE events SET content = '{\"membership\":\"ban\"}'::jsonb WHERE event_id IN + (SELECT event_id FROM room_state WHERE room_id=$1 AND event_type='m.room.member' AND state_key=$2)") + .bind(ROOM).bind(USER).execute(&mut *tx).await.unwrap(); + sqlx::query("UPDATE room_members SET membership='ban' WHERE room_id=$1 AND user_id=$2") + .bind(ROOM) + .bind(USER) + .execute(&mut *tx) + .await + .unwrap(); + tx.commit().await.unwrap(); + assert!(matches!(writer.await.unwrap(), Err(MatrixError::Forbidden))); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn invalid_initial_state_rolls_back_the_entire_room(pool: sqlx::PgPool) { + let result = crate::db::rooms::create_room_with_state( + &pool, + ROOM, + OWNER, + vec![ + ( + "m.room.create".into(), + "".into(), + json!({"room_version":"11"}), + ), + ( + "m.room.member".into(), + OWNER.into(), + json!({"membership":"join"}), + ), + ( + "m.room.create".into(), + "".into(), + json!({"room_version":"11"}), + ), + ], + "example.org", + &signing_key(), + ) + .await; + assert!(result.is_err()); + let rooms: i64 = sqlx::query_scalar("SELECT count(*) FROM rooms") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(rooms, 0); + } + async fn send_once( + pool: &sqlx::PgPool, + sender: &str, + txn: &str, + content: Value, + ) -> Result<(String, bool)> { + crate::db::rooms::put_room_event_idempotent( + pool, + ROOM, + "m.room.message", + sender, + "DEVICE", + txn, + content, + "example.org", + &signing_key(), + ) + .await + } + + #[sqlx::test(migrations = "../../migrations")] + async fn concurrent_retries_return_one_real_event_id(pool: sqlx::PgPool) { + create_test_room(&pool).await; + let (first, second) = tokio::join!( + send_once(&pool, OWNER, "retry", json!({"body":"one"})), + send_once(&pool, OWNER, "retry", json!({"body":"one"})), + ); + let first = first.unwrap(); + let second = second.unwrap(); + assert_eq!(first.0, second.0); + assert_ne!(first.1, second.1); + let stored: i64 = sqlx::query_scalar( + "SELECT count(*) FROM events WHERE event_id=$1 AND state_key IS NULL", + ) + .bind(&first.0) + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(stored, 1); + let messages: i64 = + sqlx::query_scalar("SELECT count(*) FROM events WHERE state_key IS NULL") + .fetch_one(&pool) + .await + .unwrap(); + let markers: i64 = sqlx::query_scalar("SELECT count(*) FROM transaction_ids") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!((messages, markers), (1, 1)); + assert_eq!( + send_once(&pool, OWNER, "retry", json!({"body":"ignored retry body"})) + .await + .unwrap(), + (first.0, false) + ); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn rejected_sends_do_not_consume_transactions(pool: sqlx::PgPool) { + create_test_room(&pool).await; + let before: i64 = sqlx::query_scalar("SELECT count(*) FROM events") + .fetch_one(&pool) + .await + .unwrap(); + assert!(matches!( + send_once(&pool, USER, "retry", json!({"body":"not joined"})).await, + Err(MatrixError::Forbidden) + )); + // Floats cannot be signed as Matrix canonical JSON. + assert!(matches!( + send_once(&pool, OWNER, "bad-json", json!({"number":1.5})).await, + Err(MatrixError::BadJson(_)) + )); + let after: i64 = sqlx::query_scalar("SELECT count(*) FROM events") + .fetch_one(&pool) + .await + .unwrap(); + let markers: i64 = sqlx::query_scalar("SELECT count(*) FROM transaction_ids") + .fetch_one(&pool) + .await + .unwrap(); + assert_eq!(before, after); + assert_eq!(markers, 0); + member(&pool, USER, USER, "join").await.unwrap(); + assert!( + send_once(&pool, USER, "retry", json!({"body":"now permitted"})) + .await + .unwrap() + .1 + ); + assert!( + send_once(&pool, OWNER, "bad-json", json!({"number":1})) + .await + .unwrap() + .1 + ); + // A valid old transaction does not grant current room authorization. + member(&pool, OWNER, USER, "ban").await.unwrap(); + assert!(matches!( + send_once(&pool, USER, "retry", json!({"body":"banned retry"})).await, + Err(MatrixError::Forbidden) + )); + } + + #[sqlx::test(migrations = "../../migrations")] + async fn transaction_scope_separates_room_type_device_and_endpoint(pool: sqlx::PgPool) { + create_test_room(&pool).await; + let other_room = "!other:example.org"; + create_test_room_with_id(&pool, other_room).await; + // A to-device marker with the same client transaction must not collide. + sqlx::query( + "INSERT INTO transaction_ids (user_id,device_id,txn_id) VALUES ($1,'DEVICE','same')", + ) + .bind(OWNER) + .execute(&pool) + .await + .unwrap(); + let mut ids = BTreeSet::new(); + for (room, kind, device) in [ + (ROOM, "m.room.message", "DEVICE"), + (other_room, "m.room.message", "DEVICE"), + (ROOM, "m.reaction", "DEVICE"), + (ROOM, "m.room.message", "OTHER_DEVICE"), + ] { + let (id, is_new) = crate::db::rooms::put_room_event_idempotent( + &pool, + room, + kind, + OWNER, + device, + "same", + json!({"body":"separate event"}), + "example.org", + &signing_key(), + ) + .await + .unwrap(); + assert!(is_new); + assert!(ids.insert(id)); + } + assert_eq!(ids.len(), 4); + } +} diff --git a/crates/vauxl-matrix/src/db/rooms.rs b/crates/vauxl-matrix/src/db/rooms.rs index 20e2773..f84f2a1 100644 --- a/crates/vauxl-matrix/src/db/rooms.rs +++ b/crates/vauxl-matrix/src/db/rooms.rs @@ -2,9 +2,10 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; use serde_json::Value; -use sqlx::PgPool; +use sqlx::{PgPool, Postgres, Transaction}; use uuid::Uuid; +use super::room_auth::{authorize_event, lock_room}; use crate::{error::MatrixError, event_signing::sign_event, signing_key::HomeserverSigningKey}; /// Creates a room and inserts all initial state events atomically. @@ -25,6 +26,15 @@ pub async fn create_room_with_state( let now_ms = now_millis(); for (event_type, state_key, content) in state_events { + authorize_event( + &mut tx, + room_id, + creator_id, + &event_type, + Some(&state_key), + &content, + ) + .await?; let event_id = generate_event_id(server_name); let mut raw_event = serde_json::json!({ @@ -38,7 +48,8 @@ pub async fn create_room_with_state( "unsigned": {} }); - sign_event(&mut raw_event, server_name, signing_key); + populate_event_auth(&mut tx, room_id, &mut raw_event).await?; + sign_event(&mut raw_event, server_name, signing_key)?; let event_id = raw_event["event_id"].as_str().unwrap().to_owned(); let ev_type = raw_event["type"].as_str().unwrap().to_owned(); @@ -77,19 +88,22 @@ pub async fn create_room_with_state( ) .execute(&mut *tx) .await?; - } - sqlx::query!( - r#" - INSERT INTO room_members (room_id, user_id, membership) - VALUES ($1, $2, 'join') - ON CONFLICT (room_id, user_id) DO UPDATE SET membership = 'join' - "#, - room_id, - creator_id, - ) - .execute(&mut *tx) - .await?; + if ev_type == "m.room.member" { + let membership = ev_content["membership"] + .as_str() + .ok_or(MatrixError::Forbidden)?; + sqlx::query( + "INSERT INTO room_members (room_id, user_id, membership) VALUES ($1, $2, $3) + ON CONFLICT (room_id, user_id) DO UPDATE SET membership = EXCLUDED.membership", + ) + .bind(room_id) + .bind(&ev_key) + .bind(membership) + .execute(&mut *tx) + .await?; + } + } tx.commit().await?; Ok(()) @@ -107,6 +121,17 @@ pub async fn put_room_state_event( server_name: &str, signing_key: &HomeserverSigningKey, ) -> Result { + let mut tx = pool.begin().await?; + lock_room(&mut tx, room_id).await?; + authorize_event( + &mut tx, + room_id, + sender, + event_type, + Some(state_key), + &content, + ) + .await?; let now_ms = now_millis(); let event_id = generate_event_id(server_name); @@ -121,13 +146,12 @@ pub async fn put_room_state_event( "unsigned": {} }); - sign_event(&mut raw_event, server_name, signing_key); + populate_event_auth(&mut tx, room_id, &mut raw_event).await?; + sign_event(&mut raw_event, server_name, signing_key)?; let event_id = raw_event["event_id"].as_str().unwrap().to_owned(); let ev_content = raw_event["content"].clone(); - let mut tx = pool.begin().await?; - sqlx::query!( r#" INSERT INTO events @@ -165,7 +189,7 @@ pub async fn put_room_state_event( let membership = ev_content .get("membership") .and_then(|v| v.as_str()) - .unwrap_or("leave"); + .ok_or(MatrixError::Forbidden)?; sqlx::query!( r#" @@ -195,6 +219,80 @@ pub async fn put_room_event( server_name: &str, signing_key: &HomeserverSigningKey, ) -> Result { + put_room_event_inner( + pool, + room_id, + event_type, + sender, + None, + content, + server_name, + signing_key, + ) + .await + .map(|(event_id, _)| event_id) +} + +/// Atomically authorize and send once for an endpoint-scoped client transaction. +/// Returns the stored event ID and whether this call inserted a new event. +#[allow(clippy::too_many_arguments)] +pub async fn put_room_event_idempotent( + pool: &PgPool, + room_id: &str, + event_type: &str, + sender: &str, + device_id: &str, + txn_id: &str, + content: Value, + server_name: &str, + signing_key: &HomeserverSigningKey, +) -> Result<(String, bool), MatrixError> { + put_room_event_inner( + pool, + room_id, + event_type, + sender, + Some((device_id, txn_id)), + content, + server_name, + signing_key, + ) + .await +} + +#[allow(clippy::too_many_arguments)] +async fn put_room_event_inner( + pool: &PgPool, + room_id: &str, + event_type: &str, + sender: &str, + transaction: Option<(&str, &str)>, + content: Value, + server_name: &str, + signing_key: &HomeserverSigningKey, +) -> Result<(String, bool), MatrixError> { + let mut tx = pool.begin().await?; + lock_room(&mut tx, room_id).await?; + authorize_event(&mut tx, room_id, sender, event_type, None, &content).await?; + // The room lock serializes retries for this endpoint scope. Check after + // authorization so a departed or demoted sender cannot reuse a transaction. + let scoped_txn = transaction.map(|(_, txn_id)| { + serde_json::to_string(&("room.send", room_id, event_type, txn_id)) + .expect("string tuple is serializable") + }); + if let (Some((device_id, _)), Some(scoped_txn)) = (transaction, scoped_txn.as_ref()) { + let existing: Option> = sqlx::query_scalar( + "SELECT event_id FROM transaction_ids WHERE user_id = $1 AND device_id = $2 AND txn_id = $3", + ) + .bind(sender).bind(device_id).bind(scoped_txn) + .fetch_optional(&mut *tx).await?; + if let Some(event_id) = existing { + let event_id = event_id + .ok_or_else(|| MatrixError::Internal("Transaction has no event ID".into()))?; + tx.commit().await?; + return Ok((event_id, false)); + } + } let now_ms = now_millis(); let event_id = generate_event_id(server_name); @@ -208,7 +306,8 @@ pub async fn put_room_event( "unsigned": {} }); - sign_event(&mut raw_event, server_name, signing_key); + populate_event_auth(&mut tx, room_id, &mut raw_event).await?; + sign_event(&mut raw_event, server_name, signing_key)?; let event_id = raw_event["event_id"].as_str().unwrap().to_owned(); let ev_content = raw_event["content"].clone(); @@ -227,20 +326,31 @@ pub async fn put_room_event( ev_content, raw_event, ) - .execute(pool) + .execute(&mut *tx) .await?; - Ok(event_id) + if let (Some((device_id, _)), Some(scoped_txn)) = (transaction, scoped_txn.as_ref()) { + sqlx::query( + "INSERT INTO transaction_ids (user_id, device_id, txn_id, event_id) VALUES ($1, $2, $3, $4)", + ) + .bind(sender).bind(device_id).bind(scoped_txn).bind(&event_id) + .execute(&mut *tx).await?; + } + tx.commit().await?; + Ok((event_id, true)) } /// Checks that a user is a joined member of a room. -pub async fn assert_joined(pool: &PgPool, room_id: &str, user_id: &str) -> Result<(), MatrixError> { +pub async fn assert_joined<'e, E>(db: E, room_id: &str, user_id: &str) -> Result<(), MatrixError> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ let row = sqlx::query!( "SELECT membership FROM room_members WHERE room_id = $1 AND user_id = $2", room_id, user_id, ) - .fetch_optional(pool) + .fetch_optional(db) .await?; match row.as_ref().map(|r| r.membership.as_str()) { @@ -250,7 +360,10 @@ pub async fn assert_joined(pool: &PgPool, room_id: &str, user_id: &str) -> Resul } /// Returns all current state events for a room. -pub async fn get_full_room_state(pool: &PgPool, room_id: &str) -> Result, MatrixError> { +pub async fn get_full_room_state<'e, E>(db: E, room_id: &str) -> Result, MatrixError> +where + E: sqlx::Executor<'e, Database = Postgres>, +{ let rows = sqlx::query!( r#" SELECT e.raw_event @@ -260,7 +373,7 @@ pub async fn get_full_room_state(pool: &PgPool, room_id: &str) -> Result Result, to: Option<&str>, dir: &str, // "f" (forward) or "b" (backward) limit: i64, ) -> Result<(Vec, Option), MatrixError> { + let mut tx = pool.begin().await?; + lock_room(&mut tx, room_id).await?; + assert_joined(&mut *tx, room_id, user_id).await?; // Parse pagination tokens — we use origin_ts as the cursor let from_ts: Option = from .and_then(|t| t.strip_prefix('t')) @@ -309,7 +426,7 @@ pub async fn get_room_messages( anchor, limit + 1, ) - .fetch_all(pool) + .fetch_all(&mut *tx) .await?; rows } else { @@ -329,7 +446,7 @@ pub async fn get_room_messages( anchor, limit + 1, ) - .fetch_all(pool) + .fetch_all(&mut *tx) .await?; rows }; @@ -350,9 +467,70 @@ pub async fn get_room_messages( .filter_map(|r| serde_json::from_value(r.raw_event.clone()).ok()) .collect(); + let result = + super::history_visibility::filter_for_user(&mut tx, room_id, result, user_id).await?; + tx.commit().await?; Ok((result, end_token)) } +/// Add graph references for a local event while holding the room write lock. +/// The supported event model is a single accepted chain, not state resolution. +pub async fn populate_event_auth( + tx: &mut Transaction<'_, Postgres>, + room_id: &str, + event: &mut Value, +) -> Result<(), MatrixError> { + let previous: Option<(String, Value)> = sqlx::query_as( + "SELECT event_id, raw_event FROM events WHERE room_id = $1 + ORDER BY COALESCE((raw_event->>'depth')::bigint, 0) DESC, origin_ts DESC, event_id DESC LIMIT 1", + ) + .bind(room_id).fetch_optional(&mut **tx).await?; + let (prev_events, depth) = match previous { + Some((id, raw)) => { + let depth = raw.get("depth").and_then(Value::as_u64).unwrap_or(0); + let next = depth + .checked_add(1) + .filter(|n| *n <= 9_007_199_254_740_991) + .ok_or(MatrixError::Forbidden)?; + (serde_json::json!([id]), next) + } + None => (serde_json::json!([]), 1), + }; + let event_type = event["type"].as_str().ok_or(MatrixError::Forbidden)?; + let sender = event["sender"].as_str().ok_or(MatrixError::Forbidden)?; + let target = if event_type == "m.room.member" { + event["state_key"].as_str() + } else { + None + }; + let needs_join_rules = event_type == "m.room.member" + && matches!( + event["content"]["membership"].as_str(), + Some("join" | "invite") + ); + let auth_events: Vec = if event_type == "m.room.create" { + Vec::new() + } else { + sqlx::query_scalar( + "SELECT event_id FROM room_state WHERE room_id = $1 AND ( + (state_key = '' AND event_type IN ('m.room.create', 'm.room.power_levels')) OR + (event_type = 'm.room.member' AND (state_key = $2 OR state_key = $3)) OR + (event_type = 'm.room.join_rules' AND state_key = '' AND $4)) + ORDER BY event_id", + ) + .bind(room_id) + .bind(sender) + .bind(target) + .bind(needs_join_rules) + .fetch_all(&mut **tx) + .await? + }; + event["prev_events"] = prev_events; + event["depth"] = depth.into(); + event["auth_events"] = serde_json::json!(auth_events); + Ok(()) +} + pub fn generate_event_id(server_name: &str) -> String { let random = Uuid::new_v4().as_bytes().to_vec(); format!("${}:{}", URL_SAFE_NO_PAD.encode(random), server_name) diff --git a/crates/vauxl-matrix/src/db/sync.rs b/crates/vauxl-matrix/src/db/sync.rs index 84ee400..b745361 100644 --- a/crates/vauxl-matrix/src/db/sync.rs +++ b/crates/vauxl-matrix/src/db/sync.rs @@ -38,23 +38,11 @@ pub async fn get_user_rooms( } /// Returns current state events for a room (m.room.create, m.room.name, etc.) -pub async fn get_room_state(pool: &PgPool, room_id: &str) -> Result, MatrixError> { - let rows = sqlx::query!( - r#" - SELECT e.raw_event - FROM room_state rs - JOIN events e ON e.event_id = rs.event_id - WHERE rs.room_id = $1 - "#, - room_id, - ) - .fetch_all(pool) - .await?; - - Ok(rows - .into_iter() - .filter_map(|r| serde_json::from_value(r.raw_event).ok()) - .collect()) +pub async fn get_room_state<'e, E>(db: E, room_id: &str) -> Result, MatrixError> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + super::rooms::get_full_room_state(db, room_id).await } /// Returns the most recent timeline events for a room (last 50). @@ -65,10 +53,12 @@ struct EventRow { /// Returns the most recent timeline events for a room (last 50). pub async fn get_room_timeline( - pool: &PgPool, + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, room_id: &str, + user_id: &str, since: u64, // position counter — 0 means initial sync ) -> Result, MatrixError> { + super::rooms::assert_joined(&mut **tx, room_id, user_id).await?; // For initial sync we return the last 50 events. // For incremental sync we return events newer than the since position. // We use origin_ts as a proxy for position — good enough for MVP. @@ -86,7 +76,7 @@ pub async fn get_room_timeline( "#, room_id, ) - .fetch_all(pool) + .fetch_all(&mut **tx) .await? } else { // 3. Auch hier nutzen wir query_as! mit EventRow @@ -103,7 +93,7 @@ pub async fn get_room_timeline( room_id, since as i64, ) - .fetch_all(pool) + .fetch_all(&mut **tx) .await? }; @@ -117,5 +107,5 @@ pub async fn get_room_timeline( events.reverse(); } - Ok(events) + super::history_visibility::filter_for_user(tx, room_id, events, user_id).await } diff --git a/crates/vauxl-matrix/src/error.rs b/crates/vauxl-matrix/src/error.rs index e0addec..3f46ffe 100644 --- a/crates/vauxl-matrix/src/error.rs +++ b/crates/vauxl-matrix/src/error.rs @@ -22,6 +22,9 @@ pub enum MatrixError { #[error("Forbidden")] Forbidden, + #[error("Room history does not have a verifiable event order")] + HistoryUnavailable, + #[error("User ID already taken")] UserInUse, @@ -49,7 +52,7 @@ impl MatrixError { match self { Self::MissingToken => "M_MISSING_TOKEN", Self::UnknownToken => "M_UNKNOWN_TOKEN", - Self::Forbidden => "M_FORBIDDEN", + Self::Forbidden | Self::HistoryUnavailable => "M_FORBIDDEN", Self::UserInUse => "M_USER_IN_USE", Self::InvalidUsername => "M_INVALID_USERNAME", Self::WeakPassword => "M_WEAK_PASSWORD", @@ -63,7 +66,7 @@ impl MatrixError { fn status(&self) -> StatusCode { match self { Self::MissingToken | Self::UnknownToken => StatusCode::UNAUTHORIZED, - Self::Forbidden => StatusCode::FORBIDDEN, + Self::Forbidden | Self::HistoryUnavailable => StatusCode::FORBIDDEN, Self::NotFound => StatusCode::NOT_FOUND, Self::LimitExceeded => StatusCode::TOO_MANY_REQUESTS, Self::UserInUse | Self::InvalidUsername | Self::WeakPassword | Self::BadJson(_) => { diff --git a/crates/vauxl-matrix/src/event_signing.rs b/crates/vauxl-matrix/src/event_signing.rs index 7bf2da2..1bfd51f 100644 --- a/crates/vauxl-matrix/src/event_signing.rs +++ b/crates/vauxl-matrix/src/event_signing.rs @@ -1,126 +1,131 @@ -//! Matrix event signing. -//! -//! Every event the homeserver creates must be signed with the server's -//! Ed25519 key before storage and before sending to federation. -//! -//! Matrix signing spec: -//! 1. Remove "unsigned" and "signatures" fields -//! 2. Canonical JSON encode the result -//! 3. Sign with Ed25519 -//! 4. Add signature back as {"signatures": {"server_name": {"ed25519:key_id": "base64sig"}}} +//! Matrix room-version 11 event hashes, reference IDs, and Ed25519 signatures. -use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; -use ed25519_dalek::Signer; +use ruma::{ + signatures::{hash_and_sign_event, reference_hash}, + CanonicalJsonObject, RoomVersionId, +}; use serde_json::Value; -use crate::signing_key::HomeserverSigningKey; - -/// Signs a Matrix event JSON object in-place. -/// Adds the "signatures" field required by the Matrix spec. -pub fn sign_event(event: &mut Value, server_name: &str, key: &HomeserverSigningKey) { - // Step 1: build the object to sign (no "unsigned" or "signatures") - let signable = signable_content(event); - - // Step 2: canonical JSON — sorted keys, no extra whitespace - let canonical = canonical_json(&signable); - - // Step 3: sign - let signature = key.signing_key.sign(canonical.as_bytes()); - let sig_b64 = BASE64.encode(signature.to_bytes()); - - // Step 4: attach signature - let signatures = serde_json::json!({ - server_name: { - key.key_id.clone(): sig_b64 - } - }); - - if let Some(obj) = event.as_object_mut() { - obj.insert("signatures".into(), signatures); - } +use crate::{error::MatrixError, signing_key::HomeserverSigningKey}; + +pub use vauxl_federation::canonical_json; + +/// Hash and sign a locally constructed v11 event, then attach its client-facing ID. +/// +/// `event_id` is not part of a v11 wire PDU. Remove it before signing or verifying; +/// the database and client API representation carry the derived ID separately. +pub fn sign_event( + event: &mut Value, + server_name: &str, + key: &HomeserverSigningKey, +) -> Result<(), MatrixError> { + let mut object: CanonicalJsonObject = serde_json::from_value(event.clone()) + .map_err(|_| MatrixError::BadJson("Event is not Matrix canonical JSON".into()))?; + object.remove("event_id"); + object.remove("signatures"); + object.remove("hashes"); + + hash_and_sign_event(server_name, key, &mut object, &RoomVersionId::V11) + .map_err(|e| MatrixError::BadJson(format!("Cannot sign event: {e}")))?; + let event_id = format!( + "${}", + reference_hash(&object, &RoomVersionId::V11) + .map_err(|e| MatrixError::BadJson(format!("Cannot derive event ID: {e}")))? + ); + object.insert("event_id".into(), event_id.into()); + *event = serde_json::to_value(object) + .map_err(|e| MatrixError::Internal(format!("Cannot serialize signed event: {e}")))?; + Ok(()) } -/// Builds the content to be signed: the event minus "unsigned" and "signatures". -fn signable_content(event: &Value) -> Value { - let mut obj = match event.as_object() { - Some(o) => o.clone(), - None => return event.clone(), +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::SigningKey; + use ruma::{ + serde::Base64, + signatures::{verify_event, PublicKeyMap, Verified}, }; - obj.remove("unsigned"); - obj.remove("signatures"); - Value::Object(obj) -} - -/// Produces canonical JSON: keys sorted lexicographically, no whitespace. -/// This is the Matrix canonical JSON format (MSC1301). -pub fn canonical_json(value: &Value) -> String { - match value { - Value::Object(map) => { - // Sort keys - let mut sorted: Vec<(&String, &Value)> = map.iter().collect(); - sorted.sort_by_key(|(k, _)| k.as_str()); - - let pairs: Vec = sorted - .iter() - .map(|(k, v)| format!("{}:{}", json_string(k), canonical_json(v))) - .collect(); + use serde_json::json; - format!("{{{}}}", pairs.join(",")) - } - Value::Array(arr) => { - let items: Vec = arr.iter().map(canonical_json).collect(); - format!("[{}]", items.join(",")) + fn key() -> HomeserverSigningKey { + let signing_key = SigningKey::from_bytes(&[31; 32]); + HomeserverSigningKey { + verifying_key: signing_key.verifying_key(), + signing_key, + key_id: "ed25519:a".into(), } - Value::String(s) => json_string(s), - Value::Number(n) => n.to_string(), - Value::Bool(b) => b.to_string(), - Value::Null => "null".into(), } -} -fn json_string(s: &str) -> String { - // Escape according to JSON spec - let mut out = String::with_capacity(s.len() + 2); - out.push('"'); - for ch in s.chars() { - match ch { - '"' => out.push_str("\\\""), - '\\' => out.push_str("\\\\"), - '\n' => out.push_str("\\n"), - '\r' => out.push_str("\\r"), - '\t' => out.push_str("\\t"), - c if (c as u32) < 32 => { - out.push_str(&format!("\\u{:04x}", c as u32)); - } - c => out.push(c), - } + fn event() -> Value { + json!({ + "event_id": "$temporary:example.test", + "room_id": "!room:example.test", "type": "m.room.message", + "sender": "@alice:example.test", "origin_server_ts": 123, + "content": {"msgtype": "m.text", "body": "hello"}, + "prev_events": ["$previous"], "auth_events": ["$create", "$member"], + "depth": 3 + }) } - out.push('"'); - out -} -#[cfg(test)] -mod tests { - use super::*; - use serde_json::json; + fn keys(key: &HomeserverSigningKey) -> PublicKeyMap { + [( + "example.test".into(), + [( + key.key_id.clone(), + Base64::new(key.verifying_key.to_bytes().to_vec()), + )] + .into(), + )] + .into() + } #[test] - fn test_canonical_json_sorts_keys() { - let val = json!({"b": 2, "a": 1, "c": 3}); - assert_eq!(canonical_json(&val), r#"{"a":1,"b":2,"c":3}"#); + fn generated_event_verifies_with_ruma_and_has_reference_id() { + let key = key(); + let mut event = event(); + sign_event(&mut event, "example.test", &key).unwrap(); + let mut wire: CanonicalJsonObject = serde_json::from_value(event.clone()).unwrap(); + wire.remove("event_id"); + assert_eq!( + verify_event(&keys(&key), &wire, &RoomVersionId::V11).unwrap(), + Verified::All + ); + assert_eq!( + event["event_id"], + format!("${}", reference_hash(&wire, &RoomVersionId::V11).unwrap()) + ); + + event["content"]["body"] = json!("tampered"); + let mut tampered: CanonicalJsonObject = serde_json::from_value(event).unwrap(); + tampered.remove("event_id"); + assert_eq!( + verify_event(&keys(&key), &tampered, &RoomVersionId::V11).unwrap(), + Verified::Signatures + ); } #[test] - fn test_canonical_json_nested() { - let val = json!({"z": {"b": 2, "a": 1}}); - assert_eq!(canonical_json(&val), r#"{"z":{"a":1,"b":2}}"#); + fn malformed_canonical_numbers_are_rejected_without_mutating_event() { + let key = key(); + for number in [json!(1.5), json!(9_007_199_254_740_992_u64)] { + let mut event = event(); + event["content"]["number"] = number; + let original = event.clone(); + assert!(sign_event(&mut event, "example.test", &key).is_err()); + assert_eq!(event, original); + } } #[test] - fn test_signable_removes_unsigned() { - let val = json!({"type": "m.room.message", "unsigned": {"age": 100}}); - let signable = signable_content(&val); - assert!(signable.get("unsigned").is_none()); - assert!(signable.get("type").is_some()); + fn successive_events_with_same_content_and_timestamp_have_distinct_ids() { + let key = key(); + let mut first = event(); + sign_event(&mut first, "example.test", &key).unwrap(); + let mut next = event(); + next["prev_events"] = json!([first["event_id"]]); + next["depth"] = json!(4); + sign_event(&mut next, "example.test", &key).unwrap(); + assert_ne!(first["event_id"], next["event_id"]); } } diff --git a/crates/vauxl-matrix/src/federation_auth.rs b/crates/vauxl-matrix/src/federation_auth.rs new file mode 100644 index 0000000..9ee8339 --- /dev/null +++ b/crates/vauxl-matrix/src/federation_auth.rs @@ -0,0 +1,363 @@ +//! Request authentication uses the original encoded request target, including its query. +use crate::{error::MatrixError, state::AppState}; +use axum::http::{HeaderMap, Method, Uri}; +use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; +use serde_json::{json, Value}; + +#[derive(Debug)] +struct Authorization { + origin: String, + destination: String, + key: String, + signature: String, +} + +fn parse_authorization(value: &str) -> Option { + let (scheme, mut rest) = value.split_once(char::is_whitespace)?; + if !scheme.eq_ignore_ascii_case("X-Matrix") { + return None; + } + let mut fields = std::collections::HashMap::new(); + loop { + rest = rest.trim_start(); + let equals = rest.find('=')?; + let name = rest[..equals].trim(); + if name.is_empty() || !name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') { + return None; + } + rest = rest[equals + 1..].trim_start(); + let value; + if let Some(quoted) = rest.strip_prefix('"') { + let end = quoted.find('"')?; + value = "ed[..end]; + rest = "ed[end + 1..]; + } else { + let end = rest.find(',').unwrap_or(rest.len()); + value = rest[..end].trim_end(); + rest = &rest[end..]; + } + if value.is_empty() + || value + .chars() + .any(|c| c.is_control() || c.is_whitespace() || c == '\\' || c == '"') + || fields.insert(name, value).is_some() + { + return None; + } + rest = rest.trim_start(); + if rest.is_empty() { + break; + } + rest = rest.strip_prefix(',')?; + if rest.trim().is_empty() { + return None; + } + } + let auth = Authorization { + origin: fields.get("origin")?.to_string(), + destination: fields.get("destination")?.to_string(), + key: fields.get("key")?.to_string(), + signature: fields.get("sig")?.to_string(), + }; + if !vauxl_federation::resolver::is_valid_server_name(&auth.origin) + || !vauxl_federation::resolver::is_valid_server_name(&auth.destination) + || !auth.key.strip_prefix("ed25519:").is_some_and(|v| { + !v.is_empty() && v.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') + }) + || BASE64.decode(&auth.signature).ok()?.len() != 64 + { + return None; + } + Some(auth) +} + +fn request_json( + auth: &Authorization, + method: &Method, + uri: &Uri, + content: Option<&Value>, +) -> Result { + let mut request = json!({"method":method.as_str(), "uri":uri.path_and_query().ok_or(MatrixError::Forbidden)?.as_str(), "origin":auth.origin, "destination":auth.destination}); + if let Some(body) = content { + request["content"] = body.clone(); + } + vauxl_federation::signing_json(&request) + .map_err(|_| MatrixError::BadJson("Invalid canonical JSON".into())) +} + +pub async fn authenticate( + state: &AppState, + headers: &HeaderMap, + method: &Method, + original_uri: &Uri, + content: Option<&Value>, +) -> Result { + let mut authorizations = Vec::new(); + for header in headers.get_all("authorization") { + let value = header.to_str().map_err(|_| MatrixError::Forbidden)?; + let auth = parse_authorization(value).ok_or(MatrixError::Forbidden)?; + if auth.destination != state.config.server.server_name { + return Err(MatrixError::Forbidden); + } + if authorizations + .first() + .is_some_and(|first: &Authorization| first.origin != auth.origin) + { + return Err(MatrixError::Forbidden); + } + authorizations.push(auth); + if authorizations.len() > 8 { + return Err(MatrixError::Forbidden); + } + } + if authorizations.is_empty() { + return Err(MatrixError::MissingToken); + } + for auth in authorizations { + let canonical = request_json(&auth, method, original_uri, content)?; + let key = if auth.origin == state.config.server.server_name { + if auth.key != state.signing_key.key_id { + continue; + } + state.signing_key.verifying_key.as_bytes().to_vec() + } else { + match vauxl_federation::keys::get_verification_key( + &auth.origin, + &auth.key, + &state.redis, + None, + ) + .await + { + Ok(key) => key, + Err(error) => { + tracing::debug!(%error, origin = %auth.origin, "Federation key rejected"); + continue; + } + } + }; + if vauxl_federation::keys::verify_signature(&key, &auth.signature, canonical.as_bytes()) + .is_ok() + { + return Ok(auth.origin); + } + } + Err(MatrixError::Forbidden) +} + +/// Attach the v11 reference-hash ID for the internal storage representation. +pub fn with_event_id(event: &Value) -> Result { + let canonical = + ruma::canonical_json::to_canonical_value(event).map_err(|_| MatrixError::Forbidden)?; + let mut object = canonical.as_object().ok_or(MatrixError::Forbidden)?.clone(); + let supplied = object.remove("event_id"); + let hash = ruma::signatures::reference_hash(&object, &ruma::RoomVersionId::V11) + .map_err(|_| MatrixError::Forbidden)?; + let id = format!("${hash}"); + if supplied.is_some_and(|value| value.as_str() != Some(&id)) { + return Err(MatrixError::Forbidden); + } + let mut event = event.clone(); + event["event_id"] = json!(id); + Ok(event) +} + +/// Verify the sender signature, content hash and room-v11 reference hash before any +/// authorization/database mutation. Event auth against current room state is separate. +pub async fn verify_event(state: &AppState, event: &Value) -> Result<(), MatrixError> { + let sender = event + .get("sender") + .and_then(Value::as_str) + .ok_or(MatrixError::Forbidden)?; + let sender = <&ruma::UserId>::try_from(sender).map_err(|_| MatrixError::Forbidden)?; + let server = sender.server_name().as_str(); + let timestamp = event + .get("origin_server_ts") + .and_then(Value::as_u64) + .ok_or(MatrixError::Forbidden)?; + let signatures = event + .get("signatures") + .and_then(|s| s.get(server)) + .and_then(Value::as_object) + .ok_or(MatrixError::Forbidden)?; + if signatures.len() > 8 { + return Err(MatrixError::Forbidden); + } + let mut keys = std::collections::BTreeMap::new(); + for id in signatures.keys() { + if !id.starts_with("ed25519:") { + continue; + } + let key = if server == state.config.server.server_name && id == &state.signing_key.key_id { + Some(state.signing_key.verifying_key.as_bytes().to_vec()) + } else if server == state.config.server.server_name { + None + } else { + vauxl_federation::keys::get_verification_key(server, id, &state.redis, Some(timestamp)) + .await + .ok() + }; + if let Some(key) = key { + keys.insert(id.clone(), ruma::serde::Base64::new(key)); + } + } + let public_keys = std::collections::BTreeMap::from([(server.to_owned(), keys)]); + verify_event_integrity(event, &public_keys) +} + +fn verify_event_integrity( + event: &Value, + public_keys: &ruma::signatures::PublicKeyMap, +) -> Result<(), MatrixError> { + let mut object = + ruma::canonical_json::to_canonical_value(event).map_err(|_| MatrixError::Forbidden)?; + let object = object.as_object_mut().ok_or(MatrixError::Forbidden)?; + // event_id is a transport/storage field, absent from v3+ signed PDUs. + let event_id = object + .remove("event_id") + .and_then(|v| v.as_str().map(str::to_owned)) + .ok_or(MatrixError::Forbidden)?; + let version = ruma::RoomVersionId::V11; + if ruma::signatures::verify_event(public_keys, object, &version) + .map_err(|_| MatrixError::Forbidden)? + != ruma::signatures::Verified::All + { + return Err(MatrixError::Forbidden); + } + let hash = + ruma::signatures::reference_hash(object, &version).map_err(|_| MatrixError::Forbidden)?; + if event_id != format!("${hash}") { + return Err(MatrixError::Forbidden); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use ed25519_dalek::{Signer, SigningKey}; + fn auth() -> Authorization { + Authorization { + origin: "remote.example".into(), + destination: "local.example".into(), + key: "ed25519:a".into(), + signature: BASE64.encode([0; 64]), + } + } + #[test] + fn only_intact_sender_signed_v11_events_are_accepted() { + let signing_key = SigningKey::from_bytes(&[23; 32]); + let key = crate::signing_key::HomeserverSigningKey { + verifying_key: signing_key.verifying_key(), + signing_key, + key_id: "ed25519:a".into(), + }; + let keys = std::collections::BTreeMap::from([( + "remote.example".into(), + std::collections::BTreeMap::from([( + "ed25519:a".into(), + ruma::serde::Base64::new(key.verifying_key.as_bytes().to_vec()), + )]), + )]); + let mut event = json!({"type":"m.room.message","sender":"@alice:remote.example","room_id":"!room:local.example", "origin_server_ts":100, "content":{"body":"hello","msgtype":"m.text"},"prev_events":["$prev"],"auth_events":["$create","$member"],"depth":3}); + crate::event_signing::sign_event(&mut event, "remote.example", &key).unwrap(); + assert!(verify_event_integrity(&event, &keys).is_ok()); + let mut wire = event.clone(); + wire.as_object_mut().unwrap().remove("event_id"); + assert_eq!(with_event_id(&wire).unwrap()["event_id"], event["event_id"]); + for (field, value) in [ + ("event_id", json!("$forged")), + ("sender", json!("@admin:local.example")), + ("content", json!({"body":"tampered","msgtype":"m.text"})), + ("signatures", json!({})), + ("auth_events", json!(["$forged"])), + ] { + let mut tampered = event.clone(); + tampered[field] = value; + assert!(verify_event_integrity(&tampered, &keys).is_err(), "{field}"); + } + event["event_id"] = json!("$forged"); + assert!(with_event_id(&event).is_err()); + } + #[test] + fn rejects_ambiguous_or_malformed_authorization() { + let signature = BASE64.encode([0; 64]); + let valid = format!("X-Matrix origin=remote.example,destination=local.example,key=\"ed25519:a\",sig=\"{signature}\""); + assert!(parse_authorization(&valid).is_some()); + for suffix in [",origin=evil.example", ",sig=x", ",", " trailing"] { + assert!(parse_authorization(&(valid.clone() + suffix)).is_none()); + } + assert!(parse_authorization(&valid.replace("remote.example", "evil@localhost")).is_none()); + assert!(parse_authorization(&valid.replace("ed25519:a", "ed25519:")).is_none()); + assert!(parse_authorization(&valid.replace(&signature, "garbage")).is_none()); + } + #[test] + fn signature_binds_every_request_component_and_encoded_uri() { + let auth = auth(); + let key = SigningKey::from_bytes(&[42; 32]); + let uri: Uri = "/_matrix/federation/v1/state/%21room%3Aremote.example?event_id=%24one&x=2" + .parse() + .unwrap(); + let body = json!({"nested":{"z":1,"a":2}}); + let canonical = request_json(&auth, &Method::PUT, &uri, Some(&body)).unwrap(); + let sig = BASE64.encode(key.sign(canonical.as_bytes()).to_bytes()); + let verify = |value: String| { + vauxl_federation::keys::verify_signature( + key.verifying_key().as_bytes(), + &sig, + value.as_bytes(), + ) + .is_ok() + }; + assert!(verify(canonical)); + assert!(!verify( + request_json(&auth, &Method::GET, &uri, Some(&body)).unwrap() + )); + assert!(!verify( + request_json(&auth, &Method::PUT, &uri, None).unwrap() + )); + assert!(!verify( + request_json( + &auth, + &Method::PUT, + &uri, + Some(&json!({"nested":{"z":2,"a":2}})) + ) + .unwrap() + )); + for changed in [ + "/_matrix/federation/v1/state/!room:remote.example?event_id=%24one&x=2", + "/_matrix/federation/v1/state/%21room%3Aremote.example?event_id=%24two&x=2", + ] { + assert!(!verify( + request_json(&auth, &Method::PUT, &changed.parse().unwrap(), Some(&body)).unwrap() + )); + } + let mut changed = auth; + changed.origin = "other.example".into(); + assert!(!verify( + request_json(&changed, &Method::PUT, &uri, Some(&body)).unwrap() + )); + changed.destination = "other.example".into(); + assert!(!verify( + request_json(&changed, &Method::PUT, &uri, Some(&body)).unwrap() + )); + } + #[test] + fn rejects_noncanonical_numbers() { + assert!(request_json( + &auth(), + &Method::PUT, + &"/".parse().unwrap(), + Some(&json!({"n":1.5})) + ) + .is_err()); + assert!(request_json( + &auth(), + &Method::PUT, + &"/".parse().unwrap(), + Some(&json!({"n":9007199254740992u64})) + ) + .is_err()); + } +} diff --git a/crates/vauxl-matrix/src/lib.rs b/crates/vauxl-matrix/src/lib.rs index 05dc03a..d7db544 100644 --- a/crates/vauxl-matrix/src/lib.rs +++ b/crates/vauxl-matrix/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod db; pub mod error; pub mod event_signing; +pub mod federation_auth; pub mod middleware; pub mod routes; pub mod signing_key; diff --git a/crates/vauxl-matrix/src/routes/federation.rs b/crates/vauxl-matrix/src/routes/federation.rs index 3be08f5..32fe26a 100644 --- a/crates/vauxl-matrix/src/routes/federation.rs +++ b/crates/vauxl-matrix/src/routes/federation.rs @@ -7,19 +7,21 @@ //! P1-019: Sending federation events (/send) use axum::{ - extract::{Path, State}, - http::HeaderMap, + extract::{OriginalUri, Path, State}, + http::{HeaderMap, Method}, Json, }; use serde_json::{json, Value}; use std::collections::HashMap; use crate::{ - db::rooms::{ - create_room_with_state, generate_event_id, get_full_room_state, put_room_state_event, + db::{ + history_visibility::filter_for_server, + room_auth::{authorize_event, lock_room}, + rooms::{get_full_room_state, populate_event_auth}, }, error::MatrixError, - event_signing::{canonical_json, sign_event}, + federation_auth::{authenticate, verify_event, with_event_id}, state::SharedState, }; @@ -31,47 +33,16 @@ pub async fn key_query_remote( State(state): State, Path(server_name): Path, ) -> Result, MatrixError> { - // If querying ourselves, return our own keys if server_name == state.config.server.server_name { - return key_v2_server_inner(&state); + return crate::well_known::key_v2_server(State(state)).await; } - - // Otherwise fetch from the remote server and return - // This is used during event verification when we need a remote server's key - let url = format!("https://{}/_matrix/key/v2/server", server_name); - let client = reqwest::Client::new(); - let resp = client - .get(&url) - .send() - .await - .map_err(|e| MatrixError::Internal(format!("Key fetch failed: {e}")))?; - - let body: Value = resp - .json() + if !is_valid_server_name(&server_name) { + return Err(MatrixError::BadJson("Invalid server name".into())); + } + let document = vauxl_federation::keys::fetch_key_document(&server_name) .await - .map_err(|e| MatrixError::Internal(format!("Key parse failed: {e}")))?; - - Ok(Json(body)) -} - -fn key_v2_server_inner(state: &SharedState) -> Result, MatrixError> { - use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; - - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - - Ok(Json(json!({ - "server_name": state.config.server.server_name, - "valid_until_ts": now_ms + 86_400_000, - "verify_keys": { - state.signing_key.key_id.clone(): { - "key": BASE64.encode(state.signing_key.verifying_key.as_bytes()) - } - }, - "old_verify_keys": {} - }))) + .map_err(|_| MatrixError::Forbidden)?; + Ok(Json(document)) } // ── P1-017: Federation join flow (receiving side) ───────────────────────── @@ -81,49 +52,41 @@ fn key_v2_server_inner(state: &SharedState) -> Result, MatrixError> pub async fn make_join( State(state): State, Path((room_id, user_id)): Path<(String, String)>, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, ) -> Result, MatrixError> { - let server_name = &state.config.server.server_name; + let origin = authenticate(&state, &headers, &method, &uri, None).await?; - // Verify room exists - let room_exists = sqlx::query!("SELECT room_id FROM rooms WHERE room_id = $1", room_id,) - .fetch_optional(&state.db) - .await - .map_err(MatrixError::from)?; - - if room_exists.is_none() { - return Err(MatrixError::NotFound); + if !is_valid_room_id(&room_id) || !is_valid_user_id(&user_id) { + return Err(MatrixError::BadJson("Invalid room or user ID".into())); } - - // Check join rules - let join_rule = sqlx::query!( - r#" - SELECT e.content->>'join_rule' AS join_rule - FROM room_state rs - JOIN events e ON e.event_id = rs.event_id - WHERE rs.room_id = $1 AND rs.event_type = 'm.room.join_rules' - "#, - room_id, - ) - .fetch_optional(&state.db) - .await - .map_err(MatrixError::from)? - .and_then(|r| r.join_rule) - .unwrap_or_else(|| "invite".to_string()); - - if join_rule != "public" { + if user_server_name(&user_id) != Some(origin.as_str()) { return Err(MatrixError::Forbidden); } - let now_ms = now_millis(); - let _event_id = generate_event_id(server_name); + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + authorize_event( + &mut tx, + &room_id, + &user_id, + "m.room.member", + Some(&user_id), + &json!({"membership":"join"}), + ) + .await?; + let now_ms = now_millis(); // Return a join event template for the remote server to fill in and sign - let template = json!({ + let mut template = json!({ "type": "m.room.member", "room_id": room_id, "sender": user_id, "state_key": user_id, - "origin": server_name, + "origin": origin, "origin_server_ts": now_ms, "content": { "membership": "join" }, "auth_events": [], @@ -132,6 +95,9 @@ pub async fn make_join( "room_version": "11" }); + populate_event_auth(&mut tx, &room_id, &mut template).await?; + tx.commit().await?; + Ok(Json(json!({ "room_version": "11", "event": template @@ -142,64 +108,42 @@ pub async fn make_join( /// Remote server sends back the signed join event. pub async fn send_join( State(state): State, - Path((room_id, _event_id)): Path<(String, String)>, + Path((room_id, event_id)): Path<(String, String)>, headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, Json(body): Json, ) -> Result, MatrixError> { - let server_name = &state.config.server.server_name; - - // Verify this is actually a join event - let event_type = body.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let membership = body - .get("content") - .and_then(|c| c.get("membership")) - .and_then(|v| v.as_str()) - .unwrap_or(""); - - if event_type != "m.room.member" || membership != "join" { + let origin = authenticate(&state, &headers, &method, &uri, Some(&body)).await?; + let body = with_event_id(&body)?; + if user_server_name(required_string(&body, "sender")?) != Some(origin.as_str()) { + return Err(MatrixError::Forbidden); + } + if body.get("room_id").and_then(Value::as_str) != Some(&room_id) + || body.get("event_id").and_then(Value::as_str) != Some(&event_id) + || body.get("type").and_then(Value::as_str) != Some("m.room.member") + || body + .get("content") + .and_then(|v| v.get("membership")) + .and_then(Value::as_str) + != Some("join") + || body.get("sender") != body.get("state_key") + { return Err(MatrixError::BadJson( - "Expected m.room.member join event".into(), + "Expected matching self-join event".into(), )); } - - let joining_user = body - .get("sender") - .and_then(|v| v.as_str()) - .ok_or_else(|| MatrixError::BadJson("Missing sender".into()))? - .to_owned(); - - // Verify the origin from Authorization header - let origin = extract_federation_origin(&headers).unwrap_or_else(|| "unknown".to_string()); - - tracing::info!( - room_id = %room_id, - joining_user = %joining_user, - origin = %origin, - "Processing federation join" - ); - - // Store the join membership event - put_room_state_event( - &state.db, - &room_id, - "m.room.member", - &joining_user, - &joining_user, - json!({ "membership": "join", "displayname": null, "avatar_url": null }), - server_name, - &state.signing_key, - ) - .await?; - - // Return the full room state for the joining server - let state_events = get_full_room_state(&state.db, &room_id).await?; - - Ok(Json(json!({ - "origin": server_name, - "auth_chain": [], - "state": state_events, - "event": body, - }))) + process_incoming_pdu(&state, &body, &origin, true).await?; + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + require_server_participation(&mut *tx, &room_id, &origin).await?; + let state_events = get_full_room_state(&mut *tx, &room_id).await?; + let state_events: Vec = state_events.into_iter().map(wire_pdu).collect(); + Ok(Json( + json!({"origin":state.config.server.server_name, "auth_chain":[], "state":state_events, "event":wire_pdu(body)}), + )) } // ── P1-018: Backfill & state ────────────────────────────────────────────── @@ -208,7 +152,21 @@ pub async fn send_join( pub async fn backfill( State(state): State, Path(room_id): Path, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, ) -> Result, MatrixError> { + let origin = authenticate(&state, &headers, &method, &uri, None).await?; + if !is_valid_room_id(&room_id) { + return Err(MatrixError::BadJson("Invalid room ID".into())); + } + + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + require_server_participation(&mut *tx, &room_id, &origin).await?; + let rows = sqlx::query!( r#" SELECT raw_event FROM events @@ -218,7 +176,7 @@ pub async fn backfill( "#, room_id, ) - .fetch_all(&state.db) + .fetch_all(&mut *tx) .await .map_err(MatrixError::from)?; @@ -226,6 +184,8 @@ pub async fn backfill( .into_iter() .filter_map(|r| serde_json::from_value(r.raw_event).ok()) .collect(); + let events = filter_for_server(&mut tx, &room_id, events, &origin).await?; + let events: Vec = events.into_iter().map(wire_pdu).collect(); Ok(Json(json!({ "origin": state.config.server.server_name, @@ -237,8 +197,24 @@ pub async fn backfill( pub async fn federation_room_state( State(state): State, Path(room_id): Path, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, ) -> Result, MatrixError> { - let state_events = get_full_room_state(&state.db, &room_id).await?; + let origin = authenticate(&state, &headers, &method, &uri, None).await?; + reject_historical_state_query(&uri)?; + if !is_valid_room_id(&room_id) { + return Err(MatrixError::BadJson("Invalid room ID".into())); + } + + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + require_server_participation(&mut *tx, &room_id, &origin).await?; + + let state_events = get_full_room_state(&mut *tx, &room_id).await?; + let state_events: Vec = state_events.into_iter().map(wire_pdu).collect(); Ok(Json(json!({ "pdus": state_events, @@ -250,16 +226,26 @@ pub async fn federation_room_state( pub async fn federation_room_state_ids( State(state): State, Path(room_id): Path, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, ) -> Result, MatrixError> { - let rows = sqlx::query!( - "SELECT event_id FROM room_state WHERE room_id = $1", - room_id, - ) - .fetch_all(&state.db) - .await - .map_err(MatrixError::from)?; + let origin = authenticate(&state, &headers, &method, &uri, None).await?; + reject_historical_state_query(&uri)?; + if !is_valid_room_id(&room_id) { + return Err(MatrixError::BadJson("Invalid room ID".into())); + } + + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + require_server_participation(&mut *tx, &room_id, &origin).await?; - let ids: Vec = rows.into_iter().map(|r| r.event_id).collect(); + let ids: Vec = sqlx::query_scalar("SELECT event_id FROM room_state WHERE room_id = $1") + .bind(&room_id) + .fetch_all(&mut *tx) + .await?; Ok(Json(json!({ "pdu_ids": ids, @@ -271,9 +257,28 @@ pub async fn federation_room_state_ids( pub async fn get_event( State(state): State, Path(event_id): Path, + headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, ) -> Result, MatrixError> { - let row = sqlx::query!("SELECT raw_event FROM events WHERE event_id = $1", event_id,) + let origin = authenticate(&state, &headers, &method, &uri, None).await?; + if event_id.is_empty() || !is_safe_identifier(&event_id) { + return Err(MatrixError::BadJson("Invalid event ID".into())); + } + + let room_id: String = sqlx::query_scalar("SELECT room_id FROM events WHERE event_id = $1") + .bind(&event_id) .fetch_optional(&state.db) + .await? + .ok_or(MatrixError::NotFound)?; + let mut tx = state.db.begin().await?; + lock_room(&mut tx, &room_id).await?; + require_federated_room(&mut *tx, &room_id).await?; + require_server_acl(&mut *tx, &room_id, &origin).await?; + require_server_participation(&mut *tx, &room_id, &origin).await?; + + let row = sqlx::query!("SELECT raw_event FROM events WHERE event_id = $1", event_id,) + .fetch_optional(&mut *tx) .await .map_err(MatrixError::from)? .ok_or(MatrixError::NotFound)?; @@ -281,6 +286,8 @@ pub async fn get_event( let event: Value = serde_json::from_value(row.raw_event).map_err(|e| MatrixError::Internal(e.to_string()))?; + let mut events = filter_for_server(&mut tx, &room_id, vec![event], &origin).await?; + let event = wire_pdu(events.pop().ok_or(MatrixError::Forbidden)?); Ok(Json(json!({ "origin": state.config.server.server_name, "pdus": [event] @@ -295,15 +302,27 @@ pub async fn federation_send( State(state): State, Path(txn_id): Path, headers: HeaderMap, + method: Method, + OriginalUri(uri): OriginalUri, Json(body): Json, ) -> Result, MatrixError> { - let origin = extract_federation_origin(&headers).unwrap_or_else(|| "unknown".to_string()); + let origin = authenticate(&state, &headers, &method, &uri, Some(&body)).await?; + if body + .get("origin") + .is_some_and(|value| value.as_str() != Some(&origin)) + { + return Err(MatrixError::Forbidden); + } let pdus = body .get("pdus") .and_then(|v| v.as_array()) .cloned() - .unwrap_or_default(); + .ok_or_else(|| MatrixError::BadJson("Missing pdus array".into()))?; + + if pdus.len() > 50 { + return Err(MatrixError::BadJson("Too many PDUs".into())); + } tracing::debug!( origin = %origin, @@ -315,13 +334,15 @@ pub async fn federation_send( let mut results = HashMap::new(); for pdu in &pdus { + let normalized = with_event_id(pdu); + let pdu = normalized.as_ref().unwrap_or(pdu); let event_id = pdu .get("event_id") .and_then(|v| v.as_str()) .unwrap_or("unknown") .to_owned(); - match process_incoming_pdu(&state, pdu, &origin).await { + match process_incoming_pdu(&state, pdu, &origin, false).await { Ok(()) => { results.insert(event_id, json!({})); } @@ -341,27 +362,60 @@ pub async fn federation_send( } async fn process_incoming_pdu( - state: &SharedState, + state: &crate::state::AppState, pdu: &Value, origin: &str, + joining: bool, ) -> Result<(), MatrixError> { - let event_type = pdu.get("type").and_then(|v| v.as_str()).unwrap_or(""); - let room_id = pdu.get("room_id").and_then(|v| v.as_str()).unwrap_or(""); - let sender = pdu.get("sender").and_then(|v| v.as_str()).unwrap_or(""); - let event_id = pdu.get("event_id").and_then(|v| v.as_str()).unwrap_or(""); - let content = pdu.get("content").cloned().unwrap_or(json!({})); + let event_type = required_string(pdu, "type")?; + let room_id = required_string(pdu, "room_id")?; + let sender = required_string(pdu, "sender")?; + let event_id = required_string(pdu, "event_id")?; + let content = pdu + .get("content") + .filter(|value| value.is_object()) + .cloned() + .ok_or_else(|| MatrixError::BadJson("Missing object content".into()))?; let origin_ts = pdu .get("origin_server_ts") .and_then(|v| v.as_i64()) - .unwrap_or(0); - let state_key = pdu.get("state_key").and_then(|v| v.as_str()); + .filter(|timestamp| *timestamp >= 0) + .ok_or_else(|| MatrixError::BadJson("Missing origin_server_ts".into()))?; + let state_key = match pdu.get("state_key") { + Some(Value::String(value)) => Some(value.as_str()), + Some(_) => return Err(MatrixError::BadJson("Invalid state_key".into())), + None => None, + }; + + if !is_valid_room_id(room_id) + || !is_safe_identifier(event_type) + || !is_valid_user_id(sender) + || !is_safe_identifier(event_id) + { + return Err(MatrixError::BadJson("Invalid event fields".into())); + } + verify_event(state, pdu).await?; + let mut tx = state.db.begin().await?; + lock_room(&mut tx, room_id).await?; + require_federated_room(&mut *tx, room_id).await?; + require_server_acl(&mut *tx, room_id, origin).await?; + require_server_acl( + &mut *tx, + room_id, + user_server_name(sender).ok_or(MatrixError::Forbidden)?, + ) + .await?; + if !joining { + require_server_participation(&mut *tx, room_id, origin).await?; + } + authorize_event(&mut tx, room_id, sender, event_type, state_key, &content).await?; // Skip if we already have this event let exists = sqlx::query!( "SELECT 1 AS exists FROM events WHERE event_id = $1", event_id, ) - .fetch_optional(&state.db) + .fetch_optional(&mut *tx) .await .map_err(MatrixError::from)?; @@ -369,11 +423,7 @@ async fn process_incoming_pdu( return Ok(()); } - // Basic validation — sender must be from the origin server - let sender_server = sender.split(':').nth(1).unwrap_or(""); - if sender_server != origin { - return Err(MatrixError::Forbidden); - } + require_current_event_auth(&mut tx, room_id, pdu).await?; // Store the event sqlx::query!( @@ -392,7 +442,7 @@ async fn process_incoming_pdu( content, pdu, ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(MatrixError::from)?; @@ -410,7 +460,7 @@ async fn process_incoming_pdu( sk, event_id, ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(MatrixError::from)?; @@ -431,12 +481,14 @@ async fn process_incoming_pdu( sk, membership, ) - .execute(&state.db) + .execute(&mut *tx) .await .map_err(MatrixError::from)?; } } + tx.commit().await?; + // Wake /sync handlers so local users see the new event let _ = state.wake_tx.send(crate::state::WakeEvent { room_id: room_id.to_owned(), @@ -451,174 +503,175 @@ async fn process_incoming_pdu( /// Join a room on a remote server. /// Called when a local user wants to join a room that lives on another homeserver. pub async fn join_remote_room( - state: &SharedState, - room_id: &str, - user_id: &str, + _state: &crate::state::AppState, + _room_id: &str, + _user_id: &str, ) -> Result<(), MatrixError> { - let server_name = &state.config.server.server_name; - - // Extract the remote server from the room ID - let remote_server = room_id - .split(':') - .nth(1) - .ok_or_else(|| MatrixError::BadJson("Invalid room ID".into()))?; + Err(MatrixError::BadJson("Remote room bootstrap requires auth-chain validation and state resolution, which are not supported yet".into())) +} - if remote_server == server_name { - return Err(MatrixError::BadJson("Room is local".into())); +/// V11 reference IDs belong to the storage/client representation, not the signed +/// wire PDU. Strip them at every federation response boundary. +fn wire_pdu(mut event: Value) -> Value { + if let Some(object) = event.as_object_mut() { + object.remove("event_id"); } + event +} - tracing::info!( - room_id = %room_id, - user_id = %user_id, - remote_server = %remote_server, - "Initiating federation room join" - ); - - let client = reqwest::Client::new(); - let resolved = vauxl_federation::resolver::resolve_server_name(remote_server).await; - - // Step 1: make_join — get the join event template - let make_join_url = format!( - "https://{}:{}/_matrix/federation/v1/make_join/{}/{}", - resolved.host, - resolved.port, - urlencoding::encode(room_id), - urlencoding::encode(user_id), - ); - - let make_join_resp = client - .get(&make_join_url) - .send() - .await - .map_err(|e| MatrixError::Internal(format!("make_join failed: {e}")))?; - - if !make_join_resp.status().is_success() { - return Err(MatrixError::Internal(format!( - "make_join returned {}", - make_join_resp.status() - ))); +fn reject_historical_state_query(uri: &axum::http::Uri) -> Result<(), MatrixError> { + let query: Vec<(String, String)> = serde_urlencoded::from_str(uri.query().unwrap_or_default()) + .map_err(|_| MatrixError::BadJson("Invalid state query".into()))?; + if query.iter().any(|(key, _)| key == "event_id") { + return Err(MatrixError::BadJson( + "Historical state reconstruction is not supported".into(), + )); } + Ok(()) +} - let make_join_body: Value = make_join_resp - .json() - .await - .map_err(|e| MatrixError::Internal(format!("make_join parse: {e}")))?; - - let mut join_event = make_join_body - .get("event") - .cloned() - .ok_or_else(|| MatrixError::Internal("No event in make_join response".into()))?; - - // Step 2: fill in and sign the event - let now_ms = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64; - - let event_id = generate_event_id(server_name); - - if let Some(obj) = join_event.as_object_mut() { - obj.insert("event_id".into(), json!(event_id)); - obj.insert("origin".into(), json!(server_name)); - obj.insert("origin_server_ts".into(), json!(now_ms)); - obj.insert("sender".into(), json!(user_id)); - obj.insert("state_key".into(), json!(user_id)); - obj.insert("content".into(), json!({"membership": "join"})); +fn acl_allows(content: Value, server: &str) -> Result { + if !is_valid_server_name(server) { + return Ok(false); } - - sign_event(&mut join_event, server_name, &state.signing_key); - - // Step 3: send_join — submit the signed event - let send_join_url = format!( - "https://{}:{}/_matrix/federation/v2/send_join/{}/{}", - resolved.host, - resolved.port, - urlencoding::encode(room_id), - urlencoding::encode(&event_id), - ); - - let send_join_resp = client - .put(&send_join_url) - .json(&join_event) - .send() - .await - .map_err(|e| MatrixError::Internal(format!("send_join failed: {e}")))?; - - if !send_join_resp.status().is_success() { - return Err(MatrixError::Internal(format!( - "send_join returned {}", - send_join_resp.status() - ))); + let mut acl: ruma::events::room::server_acl::RoomServerAclEventContent = + serde_json::from_value(content).map_err(|_| MatrixError::Forbidden)?; + // Matrix server ACL matching is case-insensitive and ignores ports. Ruma + // handles host/port/IP semantics; normalize case before its glob matching. + for pattern in acl.allow.iter_mut().chain(acl.deny.iter_mut()) { + pattern.make_ascii_lowercase(); } + let server = server.to_ascii_lowercase(); + let server = + <&ruma::ServerName>::try_from(server.as_str()).map_err(|_| MatrixError::Forbidden)?; + Ok(acl.is_allowed(server)) +} - let send_join_body: Value = send_join_resp - .json() - .await - .map_err(|e| MatrixError::Internal(format!("send_join parse: {e}")))?; +async fn require_server_acl<'e, E>(db: E, room_id: &str, server: &str) -> Result<(), MatrixError> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + let content: Option = sqlx::query_scalar("SELECT e.content FROM room_state rs JOIN events e ON e.event_id = rs.event_id WHERE rs.room_id = $1 AND rs.event_type = 'm.room.server_acl' AND rs.state_key = ''") + .bind(room_id).fetch_optional(db).await?; + if let Some(content) = content { + if !acl_allows(content, server)? { + return Err(MatrixError::Forbidden); + } + } + Ok(()) +} - // Step 4 & 5: store room state from response - let room_exists = sqlx::query!("SELECT 1 AS exists FROM rooms WHERE room_id = $1", room_id,) - .fetch_optional(&state.db) - .await - .map_err(MatrixError::from)?; +async fn require_federated_room<'e, E>(db: E, room_id: &str) -> Result<(), MatrixError> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + let create: Option = sqlx::query_scalar("SELECT e.content FROM room_state rs JOIN events e ON e.event_id = rs.event_id WHERE rs.room_id = $1 AND rs.event_type = 'm.room.create' AND rs.state_key = ''") + .bind(room_id).fetch_optional(db).await?; + let create = create.ok_or(MatrixError::Forbidden)?; + if create + .get("m.federate") + .is_some_and(|value| value != &Value::Bool(true)) + { + return Err(MatrixError::Forbidden); + } + Ok(()) +} - if room_exists.is_none() { - sqlx::query!("INSERT INTO rooms (room_id) VALUES ($1)", room_id) - .execute(&state.db) - .await - .map_err(MatrixError::from)?; +async fn require_server_participation<'e, E>( + db: E, + room_id: &str, + origin: &str, +) -> Result<(), MatrixError> +where + E: sqlx::Executor<'e, Database = sqlx::Postgres>, +{ + let users: Vec = sqlx::query_scalar( + "SELECT user_id FROM room_members WHERE room_id = $1 AND membership = 'join'", + ) + .bind(room_id) + .fetch_all(db) + .await?; + if !users + .iter() + .any(|user| user_server_name(user) == Some(origin)) + { + return Err(MatrixError::Forbidden); } + Ok(()) +} - // Import state events from the remote server - if let Some(state_events) = send_join_body.get("state").and_then(|v| v.as_array()) { - for event in state_events { - let _ = process_incoming_pdu(state, event, remote_server).await; +/// Until DAG auth-chain/state resolution exists, accept only events extending the +/// locally known predecessor and authenticated current state. Reject stale forks. +async fn require_current_event_auth( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + room_id: &str, + event: &Value, +) -> Result<(), MatrixError> { + let mut expected = event.clone(); + populate_event_auth(tx, room_id, &mut expected).await?; + for field in ["prev_events", "auth_events"] { + let ids = |value: &Value| -> Option> { + let array = value.get(field)?.as_array()?; + let ids: std::collections::BTreeSet = array + .iter() + .map(|id| id.as_str().map(str::to_owned)) + .collect::>()?; + (ids.len() == array.len()).then_some(ids) + }; + if ids(event).is_none() || ids(event) != ids(&expected) { + return Err(MatrixError::Forbidden); } } + if event.get("depth") != expected.get("depth") { + return Err(MatrixError::Forbidden); + } + Ok(()) +} - // Mark local user as joined - sqlx::query!( - r#" - INSERT INTO room_members (room_id, user_id, membership) - VALUES ($1, $2, 'join') - ON CONFLICT (room_id, user_id) DO UPDATE SET membership = 'join' - "#, - room_id, - user_id, - ) - .execute(&state.db) - .await - .map_err(MatrixError::from)?; +fn required_string<'a>(value: &'a Value, field: &str) -> Result<&'a str, MatrixError> { + value + .get(field) + .and_then(Value::as_str) + .filter(|value| !value.is_empty()) + .ok_or_else(|| MatrixError::BadJson(format!("Missing {field}"))) +} - tracing::info!( - room_id = %room_id, - user_id = %user_id, - "Federation join completed" - ); +fn is_safe_identifier(value: &str) -> bool { + !value.is_empty() + && value + .chars() + .all(|character| !character.is_control() && !character.is_whitespace()) +} - Ok(()) +fn is_valid_server_name(value: &str) -> bool { + vauxl_federation::resolver::is_valid_server_name(value) } -// ── Helpers ─────────────────────────────────────────────────────────────── +fn is_valid_room_id(value: &str) -> bool { + let Some((localpart, server)) = value + .strip_prefix('!') + .and_then(|value| value.split_once(':')) + else { + return false; + }; + is_safe_identifier(localpart) && is_valid_server_name(server) +} -/// Extract the origin server from the X-Matrix Authorization header. -pub fn extract_federation_origin(headers: &HeaderMap) -> Option { - let auth = headers.get("authorization")?.to_str().ok()?; - if !auth.starts_with("X-Matrix ") { - return None; - } +fn is_valid_user_id(value: &str) -> bool { + let Some((localpart, server)) = value + .strip_prefix('@') + .and_then(|value| value.split_once(':')) + else { + return false; + }; + is_safe_identifier(localpart) && is_valid_server_name(server) +} - // Parse: X-Matrix origin="server",destination="...",key="...",sig="..." - for part in auth[9..].split(',') { - let part = part.trim(); - if let Some(val) = part - .strip_prefix("origin=") - .or(part.strip_prefix("origin=\"")) - { - return Some(val.trim_matches('"').to_owned()); - } - } - None +fn user_server_name(value: &str) -> Option<&str> { + value + .strip_prefix('@')? + .split_once(':') + .map(|(_, server)| server) } fn now_millis() -> u64 { @@ -627,3 +680,52 @@ fn now_millis() -> u64 { .unwrap_or_default() .as_millis() as u64 } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn federation_acl_checks_denies_ports_case_and_ip_literals() { + let acl = + json!({"allow":["*.example"],"deny":["BLOCKED.EXAMPLE"],"allow_ip_literals":false}); + assert!(acl_allows(acl.clone(), "ALLOWED.EXAMPLE:8448").unwrap()); + assert!(!acl_allows(acl.clone(), "blocked.example:443").unwrap()); + assert!(!acl_allows(acl, "unrelated.test").unwrap()); + for server in ["1.1.1.1:8448", "[2606:4700:4700::1111]"] { + assert!(!acl_allows(json!({"allow":["*"],"allow_ip_literals":false}), server).unwrap()); + } + assert!(!acl_allows(json!({}), "remote.example").unwrap()); + assert!(acl_allows(json!({"allow":"*"}), "remote.example").is_err()); + } + + #[test] + fn acl_ip_ban_cannot_be_bypassed_with_public_ip_aliases() { + let acl = json!({"allow":["*"], "allow_ip_literals":false}); + for server in [ + "8.8.8.8", + "0x08080808", + "134744072", + "010.010.010.010", + "8.8.2056", + "134744072:8448", + ] { + assert!(!acl_allows(acl.clone(), server).unwrap(), "{server}"); + } + assert!(acl_allows(acl, "dns.google:8448").unwrap()); + } + + #[test] + fn historical_state_query_cannot_silently_return_current_state() { + assert!(reject_historical_state_query( + &"/_matrix/federation/v1/state/room?event_id=%24old" + .parse() + .unwrap() + ) + .is_err()); + assert!(reject_historical_state_query( + &"/_matrix/federation/v1/state/room".parse().unwrap() + ) + .is_ok()); + } +} diff --git a/crates/vauxl-matrix/src/routes/membership.rs b/crates/vauxl-matrix/src/routes/membership.rs index 52de3c3..f9c1871 100644 --- a/crates/vauxl-matrix/src/routes/membership.rs +++ b/crates/vauxl-matrix/src/routes/membership.rs @@ -16,10 +16,7 @@ use serde_json::{json, Value}; use crate::{ auth::AuthenticatedUser, - db::{ - assert_joined, get_join_rule, get_membership, resolve_room_alias, room_exists, - set_membership, store_invite_notification, - }, + db::{resolve_room_alias, room_exists, set_membership, store_invite_notification}, error::MatrixError, state::SharedState, }; @@ -29,7 +26,7 @@ use crate::{ #[derive(Debug, Deserialize, Default)] pub struct JoinRequest { pub reason: Option, - pub third_party_signed: Option, // ignored in MVP + pub third_party_signed: Option, } /// POST /_matrix/client/v3/join/{roomIdOrAlias} @@ -37,8 +34,11 @@ pub async fn join_room_by_id_or_alias( State(state): State, auth: AuthenticatedUser, Path(room_id_or_alias): Path, - Json(_body): Json, + Json(body): Json, ) -> Result, MatrixError> { + if body.third_party_signed.is_some() { + return Err(MatrixError::Forbidden); + } let server_name = &state.config.server.server_name; // Resolve alias to room_id if needed @@ -60,8 +60,11 @@ pub async fn join_room( State(state): State, auth: AuthenticatedUser, Path(room_id): Path, - Json(_body): Json, + Json(body): Json, ) -> Result, MatrixError> { + if body.third_party_signed.is_some() { + return Err(MatrixError::Forbidden); + } let server_name = &state.config.server.server_name; join_room_inner(&state, &auth.user_id, &room_id, server_name).await?; Ok(Json(json!({ "room_id": room_id }))) @@ -73,37 +76,19 @@ async fn join_room_inner( room_id: &str, server_name: &str, ) -> Result<(), MatrixError> { - // Room must exist - if !room_exists(&state.db, room_id).await? { - return Err(MatrixError::NotFound); - } - // Check if room is on a remote server - let room_server = room_id.split(':').nth(1).unwrap_or(""); + let room_server = room_id + .split_once(':') + .map(|(_, server)| server) + .unwrap_or(""); if room_server != server_name { // Remote room — use federation join return crate::routes::federation::join_remote_room(state, room_id, user_id).await; } - // Already joined — idempotent, just return ok - if get_membership(&state.db, room_id, user_id).await? == Some("join".into()) { - return Ok(()); - } - - // Check join rules - let join_rule = get_join_rule(&state.db, room_id).await?; - match join_rule.as_str() { - "public" => { - // Anyone can join - } - "invite" => { - // Must have a pending invite - let membership = get_membership(&state.db, room_id, user_id).await?; - if membership.as_deref() != Some("invite") { - return Err(MatrixError::Forbidden); - } - } - _ => return Err(MatrixError::Forbidden), + // Room must exist + if !room_exists(&state.db, room_id).await? { + return Err(MatrixError::NotFound); } set_membership( @@ -138,13 +123,6 @@ pub async fn leave_room( ) -> Result, MatrixError> { let server_name = &state.config.server.server_name; - // Must be joined or invited to leave - let membership = get_membership(&state.db, &room_id, &auth.user_id).await?; - match membership.as_deref() { - Some("join") | Some("invite") => {} - _ => return Err(MatrixError::Forbidden), - } - set_membership( &state.db, &room_id, @@ -183,15 +161,6 @@ pub async fn invite_to_room( ) -> Result, MatrixError> { let server_name = &state.config.server.server_name; - // Inviter must be joined - assert_joined(&state.db, &room_id, &auth.user_id).await?; - - // Don't double-invite - let existing = get_membership(&state.db, &room_id, &body.user_id).await?; - if existing.as_deref() == Some("invite") || existing.as_deref() == Some("join") { - return Ok(Json(json!({}))); - } - // Write the invite membership event set_membership( &state.db, @@ -239,18 +208,6 @@ pub async fn kick_from_room( ) -> Result, MatrixError> { let server_name = &state.config.server.server_name; - // Kicker must be joined - assert_joined(&state.db, &room_id, &auth.user_id).await?; - - // Target must be joined - if get_membership(&state.db, &room_id, &body.user_id) - .await? - .as_deref() - != Some("join") - { - return Err(MatrixError::Forbidden); - } - set_membership( &state.db, &room_id, @@ -290,8 +247,6 @@ pub async fn ban_from_room( ) -> Result, MatrixError> { let server_name = &state.config.server.server_name; - assert_joined(&state.db, &room_id, &auth.user_id).await?; - set_membership( &state.db, &room_id, diff --git a/crates/vauxl-matrix/src/routes/rooms.rs b/crates/vauxl-matrix/src/routes/rooms.rs index d10750c..ba5e21a 100644 --- a/crates/vauxl-matrix/src/routes/rooms.rs +++ b/crates/vauxl-matrix/src/routes/rooms.rs @@ -11,8 +11,8 @@ use uuid::Uuid; use crate::{ auth::AuthenticatedUser, db::{ - assert_joined, check_and_store_txn, create_room_with_state, get_full_room_state, - get_room_messages, put_room_event, put_room_state_event, + assert_joined, create_room_with_state, get_full_room_state, get_room_messages, + put_room_event_idempotent, put_room_state_event, }, error::MatrixError, state::{SharedState, WakeEvent}, @@ -73,7 +73,7 @@ pub async fn create_room( ( "m.room.power_levels".into(), "".into(), - build_power_levels(creator, body.power_level_content_override.as_ref()), + build_power_levels(creator, None), ), ( "m.room.join_rules".into(), @@ -108,6 +108,21 @@ pub async fn create_room( } } + // Apply a requested final power configuration after room setup, so a + // deliberate creator demotion does not prevent the setup events. + if let Some(override_content) = &body.power_level_content_override { + if !override_content.is_object() { + return Err(MatrixError::BadJson( + "Power levels must be an object".into(), + )); + } + state_events.push(( + "m.room.power_levels".into(), + "".into(), + build_power_levels(creator, Some(override_content)), + )); + } + create_room_with_state( &state.db, &room_id, @@ -128,8 +143,6 @@ pub async fn send_state_event( Path((room_id, event_type, state_key)): Path<(String, String, String)>, Json(content): Json, ) -> Result, MatrixError> { - assert_joined(&state.db, &room_id, &auth.user_id).await?; - let event_id = put_room_state_event( &state.db, &room_id, @@ -154,8 +167,6 @@ pub async fn send_state_event_no_key( Path((room_id, event_type)): Path<(String, String)>, Json(content): Json, ) -> Result, MatrixError> { - assert_joined(&state.db, &room_id, &auth.user_id).await?; - let event_id = put_room_state_event( &state.db, &room_id, @@ -180,26 +191,23 @@ pub async fn send_message_event( Path((room_id, event_type, txn_id)): Path<(String, String, String)>, Json(content): Json, ) -> Result, MatrixError> { - assert_joined(&state.db, &room_id, &auth.user_id).await?; - - let is_new = check_and_store_txn(&state.db, &auth.user_id, &auth.device_id, &txn_id).await?; - - if !is_new { - tracing::debug!(txn_id = %txn_id, "Duplicate txn — skipping"); - return Ok(Json(json!({ "event_id": format!("$dup:{}", txn_id) }))); - } - - let event_id = put_room_event( + let (event_id, is_new) = put_room_event_idempotent( &state.db, &room_id, &event_type, &auth.user_id, + &auth.device_id, + &txn_id, content, &state.config.server.server_name, &state.signing_key, ) .await?; + if !is_new { + return Ok(Json(json!({ "event_id": event_id }))); + } + // Wake any long-polling /sync handlers for this room let _ = state.wake_tx.send(WakeEvent { room_id: room_id.clone(), @@ -273,6 +281,7 @@ pub async fn get_room_messages_handler( let (events, end) = get_room_messages( &state.db, &room_id, + &auth.user_id, query.from.as_deref(), query.to.as_deref(), dir, diff --git a/crates/vauxl-matrix/src/routes/sync.rs b/crates/vauxl-matrix/src/routes/sync.rs index 7c476a5..fbfa076 100644 --- a/crates/vauxl-matrix/src/routes/sync.rs +++ b/crates/vauxl-matrix/src/routes/sync.rs @@ -61,7 +61,6 @@ async fn build_sync_response( ) -> Result { let next_batch = get_next_batch(&state.redis, user_id).await?; let rooms = get_user_rooms(&state.db, user_id).await?; - let to_device_events = pop_to_device_messages(&state.db, user_id, device_id).await?; // Presence events for all joined rooms let mut all_presence: Vec = vec![]; @@ -95,6 +94,9 @@ async fn build_sync_response( seen.insert(sender) }); + // Build every fallible room section before consuming queued device messages. + let to_device_events = pop_to_device_messages(&state.db, user_id, device_id).await?; + Ok(json!({ "next_batch": next_batch, "rooms": { @@ -113,12 +115,20 @@ async fn build_sync_response( async fn build_joined_room( state: &SharedState, room_id: &str, - _user_id: &str, + user_id: &str, since: u64, ) -> Result { - let state_events = get_room_state(&state.db, room_id).await?; - let timeline_events = get_room_timeline(&state.db, room_id, since).await?; - let limited = false; + let mut tx = state.db.begin().await?; + crate::db::room_auth::lock_room(&mut tx, room_id).await?; + crate::db::assert_joined(&mut *tx, room_id, user_id).await?; + let state_events = get_room_state(&mut *tx, room_id).await?; + let (timeline_events, limited) = match get_room_timeline(&mut tx, room_id, user_id, since).await + { + Ok(events) => (events, false), + Err(MatrixError::HistoryUnavailable) => (Vec::new(), true), + Err(error) => return Err(error), + }; + tx.commit().await?; // Ephemeral: typing + receipts let typing_users = get_typing_users(&state.redis, room_id).await; diff --git a/crates/vauxl-matrix/src/signing_key.rs b/crates/vauxl-matrix/src/signing_key.rs index 3798ddc..138bf12 100644 --- a/crates/vauxl-matrix/src/signing_key.rs +++ b/crates/vauxl-matrix/src/signing_key.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; -use ed25519_dalek::{SigningKey, VerifyingKey}; +use ed25519_dalek::{Signer, SigningKey, VerifyingKey}; use rand::rngs::OsRng; use std::path::Path; @@ -18,6 +18,13 @@ pub struct HomeserverSigningKey { pub key_id: String, } +impl ruma::signatures::KeyPair for HomeserverSigningKey { + fn sign(&self, message: &[u8]) -> ruma::signatures::Signature { + ruma::signatures::Signature::new(&self.key_id, &self.signing_key.sign(message).to_bytes()) + .expect("locally generated Ed25519 key identifier is valid") + } +} + impl HomeserverSigningKey { /// Load the signing key from disk, or generate and save a new one. pub fn load_or_generate(path: &str) -> Result { diff --git a/crates/vauxl-matrix/src/well_known.rs b/crates/vauxl-matrix/src/well_known.rs index b0e99f6..1e0a84a 100644 --- a/crates/vauxl-matrix/src/well_known.rs +++ b/crates/vauxl-matrix/src/well_known.rs @@ -1,7 +1,10 @@ use axum::{extract::State, Json}; use serde_json::{json, Value}; -use crate::state::SharedState; +use crate::{ + error::MatrixError, + state::{AppState, SharedState}, +}; pub async fn well_known_client(State(s): State) -> Json { // Use http for local dev — Element Web rejects https:// when TLS is not configured @@ -28,13 +31,17 @@ pub async fn well_known_server(State(s): State) -> Json { })) } -pub async fn key_v2_server(State(s): State) -> Json { +pub async fn key_v2_server(State(s): State) -> Result, MatrixError> { + Ok(Json(signing_key_document(&s)?)) +} + +pub fn signing_key_document(s: &AppState) -> Result { let now_ms = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .unwrap_or_default() .as_millis() as u64; - Json(json!({ + let mut document = serde_json::from_value(json!({ "server_name": s.config.server.server_name, "valid_until_ts": now_ms + 86_400_000, "verify_keys": { @@ -44,6 +51,11 @@ pub async fn key_v2_server(State(s): State) -> Json { }, "old_verify_keys": {} })) + .map_err(|e| MatrixError::Internal(format!("Cannot construct signing key document: {e}")))?; + ruma::signatures::sign_json(&s.config.server.server_name, &s.signing_key, &mut document) + .map_err(|e| MatrixError::Internal(format!("Cannot sign server keys: {e}")))?; + serde_json::to_value(document) + .map_err(|e| MatrixError::Internal(format!("Cannot serialize server keys: {e}"))) } pub async fn federation_version() -> Json { diff --git a/crates/vauxl-server/Cargo.toml b/crates/vauxl-server/Cargo.toml index 794923e..75b38fe 100644 --- a/crates/vauxl-server/Cargo.toml +++ b/crates/vauxl-server/Cargo.toml @@ -26,3 +26,9 @@ rand = "0.8" base64 = "0.22" tower-http = { version = "0.6", features = ["cors", "trace"] } redis = { workspace = true } + +[dev-dependencies] +ruma = { workspace = true } +tower = { version = "0.5", features = ["util"] } +vauxl-federation = { path = "../vauxl-federation" } +urlencoding = "2.1" diff --git a/crates/vauxl-server/src/main.rs b/crates/vauxl-server/src/main.rs index 783743f..cce6e27 100644 --- a/crates/vauxl-server/src/main.rs +++ b/crates/vauxl-server/src/main.rs @@ -100,6 +100,18 @@ async fn main() -> Result<()> { wake_tx, }); + let app = router(state); + + let addr = format!("{}:{}", cfg.server.listen_address, cfg.server.port); + let listener = tokio::net::TcpListener::bind(&addr).await?; + tracing::info!(address = %addr, "Listening"); + + axum::serve(listener, app).await?; + Ok(()) +} + +fn router(state: vauxl_matrix::state::SharedState) -> Router { + let db = state.db.clone(); // ── Cors ──────────────────────────────────────────────────────────── let cors = CorsLayer::new() .allow_origin(Any) @@ -107,7 +119,7 @@ async fn main() -> Result<()> { .allow_headers(Any); // ── Router ──────────────────────────────────────────────────────────── - let app = Router::new() + Router::new() // Discovery .route("/.well-known/matrix/client", get(well_known_client)) .route("/.well-known/matrix/server", get(well_known_server)) @@ -302,16 +314,12 @@ async fn main() -> Result<()> { .route("/_vauxl/health", get(health)) .with_state(state) .layer(middleware::from_fn_with_state(db, inject_db)) - .layer(cors); - - let addr = format!("{}:{}", cfg.server.listen_address, cfg.server.port); - let listener = tokio::net::TcpListener::bind(&addr).await?; - tracing::info!(address = %addr, "Listening"); - - axum::serve(listener, app).await?; - Ok(()) + .layer(cors) } +#[cfg(test)] +mod security_tests; + async fn health() -> &'static str { "ok" } diff --git a/crates/vauxl-server/src/security_tests.rs b/crates/vauxl-server/src/security_tests.rs new file mode 100644 index 0000000..62ae7f2 --- /dev/null +++ b/crates/vauxl-server/src/security_tests.rs @@ -0,0 +1,1093 @@ +//! Exercise the production router with real PostgreSQL and Redis. +//! The peer publishes its own signed key document; an isolated Redis entry pins +//! it for local tests without weakening production HTTPS/DNS verification. + +use super::router; +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + Router, +}; +use base64::{engine::general_purpose::STANDARD_NO_PAD as BASE64, Engine as _}; +use ed25519_dalek::{Signer, SigningKey}; +use redis::AsyncCommands; +use serde_json::{json, Value}; +use sqlx::PgPool; +use std::sync::{ + atomic::{AtomicU64, Ordering}, + Arc, +}; +use tower::ServiceExt; +use vauxl_matrix::{ + config::{AppConfig, DatabaseConfig, RedisConfig, ServerConfig, SigningKeyConfig}, + db::{room_auth::lock_room, rooms::populate_event_auth}, + event_signing::sign_event, + signing_key::HomeserverSigningKey, + state::{AppState, SharedState}, +}; + +static NEXT_SERVER: AtomicU64 = AtomicU64::new(1); + +struct Servers { + local: SharedState, + peer: SharedState, + app: Router, +} + +async fn request( + app: &Router, + method: &str, + path: &str, + authorization: Option<&str>, + body: Option<&Value>, +) -> (StatusCode, Value) { + let mut request = Request::builder().method(method).uri(path); + if let Some(auth) = authorization { + request = request.header("authorization", auth); + } + if body.is_some() { + request = request.header("content-type", "application/json"); + } + let response = app + .clone() + .oneshot( + request + .body(body.map_or_else(Body::empty, |v| Body::from(v.to_string()))) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + let json = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"non_json_response":String::from_utf8_lossy(&bytes)})); + (status, json) +} + +fn state(pool: PgPool, name: String, seed: u8) -> SharedState { + let redis_url = std::env::var("REDIS_URL").expect("security tests require REDIS_URL"); + let signing_key = SigningKey::from_bytes(&[seed; 32]); + Arc::new(AppState { + config: AppConfig { + server: ServerConfig { + server_name: name, + listen_address: "127.0.0.1".into(), + port: 8008, + }, + database: DatabaseConfig { + url: "unused: test pool supplied directly".into(), + }, + redis: RedisConfig { + url: redis_url.clone(), + }, + signing_key: SigningKeyConfig { + path: "unused: test key supplied directly".into(), + }, + }, + db: pool, + redis: redis::Client::open(redis_url).unwrap(), + signing_key: HomeserverSigningKey { + verifying_key: signing_key.verifying_key(), + signing_key, + key_id: "ed25519:a".into(), + }, + wake_tx: tokio::sync::broadcast::channel(16).0, + }) +} + +impl Servers { + fn verify_wire_pdu(&self, event: &Value) { + assert!( + event.get("event_id").is_none(), + "v11 wire PDU contains storage event_id: {event}" + ); + let keys: ruma::signatures::PublicKeyMap = [ + ( + self.local.config.server.server_name.clone(), + [( + self.local.signing_key.key_id.clone(), + ruma::serde::Base64::new( + self.local.signing_key.verifying_key.as_bytes().to_vec(), + ), + )] + .into(), + ), + ( + self.peer.config.server.server_name.clone(), + [( + self.peer.signing_key.key_id.clone(), + ruma::serde::Base64::new( + self.peer.signing_key.verifying_key.as_bytes().to_vec(), + ), + )] + .into(), + ), + ] + .into(); + let object: ruma::CanonicalJsonObject = serde_json::from_value(event.clone()).unwrap(); + assert_eq!( + ruma::signatures::verify_event(&keys, &object, &ruma::RoomVersionId::V11).unwrap(), + ruma::signatures::Verified::All + ); + } + + async fn new(pool: PgPool) -> Self { + let id = NEXT_SERVER.fetch_add(1, Ordering::Relaxed); + let local = state(pool.clone(), format!("local-{id}.test"), 41); + let peer = state(pool, format!("peer-{id}.test"), 42); + let (status, document) = request( + &router(peer.clone()), + "GET", + "/_matrix/key/v2/server", + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{document}"); + assert!(document["signatures"][&peer.config.server.server_name]["ed25519:a"].is_string()); + let mut redis = local + .redis + .get_multiplexed_async_connection() + .await + .unwrap(); + let _: () = redis + .set_ex( + format!( + "federation:verified-key-document:{}", + peer.config.server.server_name + ), + document.to_string(), + 1200, + ) + .await + .unwrap(); + Self { + app: router(local.clone()), + local, + peer, + } + } + + fn user(&self, localpart: &str) -> String { + format!("@{localpart}:{}", self.local.config.server.server_name) + } + + fn peer_user(&self) -> String { + format!("@remote:{}", self.peer.config.server.server_name) + } + + fn authorization(&self, method: &str, path: &str, body: Option<&Value>) -> String { + let origin = &self.peer.config.server.server_name; + let destination = &self.local.config.server.server_name; + let mut signed = + json!({"method":method,"uri":path,"origin":origin,"destination":destination}); + if let Some(body) = body { + signed["content"] = body.clone(); + } + let canonical: ruma::CanonicalJsonObject = serde_json::from_value(signed).unwrap(); + let signature = self + .peer + .signing_key + .signing_key + .sign(&serde_json::to_vec(&canonical).unwrap()); + format!("X-Matrix origin=\"{origin}\",destination=\"{destination}\",key=\"ed25519:a\",sig=\"{}\"", BASE64.encode(signature.to_bytes())) + } + + async fn signed(&self, method: &str, path: &str, body: Option<&Value>) -> (StatusCode, Value) { + request( + &self.app, + method, + path, + Some(&self.authorization(method, path, body)), + body, + ) + .await + } + + async fn register(&self, name: &str) -> String { + let body = json!({"username":name,"password":"testing-password-only","auth":{"type":"m.login.dummy"}}); + let (status, response) = request( + &self.app, + "POST", + "/_matrix/client/v3/register", + None, + Some(&body), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + format!("Bearer {}", response["access_token"].as_str().unwrap()) + } + + async fn room(&self, token: &str) -> String { + let (status, response) = request( + &self.app, + "POST", + "/_matrix/client/v3/createRoom", + Some(token), + Some(&json!({"preset":"public_chat","name":"Security regression room"})), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + response["room_id"].as_str().unwrap().to_owned() + } + + fn make_join_path(&self, room: &str) -> String { + format!( + "/_matrix/federation/v1/make_join/{}/{}?ver=11", + urlencoding::encode(room), + urlencoding::encode(&self.peer_user()) + ) + } + + async fn join_peer(&self, room: &str) -> Value { + let path = self.make_join_path(room); + let (status, response) = self.signed("GET", &path, None).await; + assert_eq!(status, StatusCode::OK, "{response}"); + let mut event = response["event"].clone(); + sign_event( + &mut event, + &self.peer.config.server.server_name, + &self.peer.signing_key, + ) + .unwrap(); + let path = format!( + "/_matrix/federation/v2/send_join/{}/{}", + urlencoding::encode(room), + urlencoding::encode(event["event_id"].as_str().unwrap()) + ); + let mut wire = event.clone(); + wire.as_object_mut().unwrap().remove("event_id"); + let (status, response) = self.signed("PUT", &path, Some(&wire)).await; + assert_eq!(status, StatusCode::OK, "{response}"); + self.verify_wire_pdu(&response["event"]); + for event in response["state"].as_array().unwrap() { + self.verify_wire_pdu(event); + } + event + } + + async fn peer_event( + &self, + room: &str, + event_type: &str, + state_key: Option<&str>, + content: Value, + ) -> Value { + let mut event = json!({"room_id":room,"sender":self.peer_user(),"type":event_type,"content":content,"origin_server_ts":vauxl_federation::keys::now_millis()}); + if let Some(key) = state_key { + event["state_key"] = json!(key); + } + let mut tx = self.local.db.begin().await.unwrap(); + lock_room(&mut tx, room).await.unwrap(); + populate_event_auth(&mut tx, room, &mut event) + .await + .unwrap(); + tx.commit().await.unwrap(); + sign_event( + &mut event, + &self.peer.config.server.server_name, + &self.peer.signing_key, + ) + .unwrap(); + event + } + + async fn send_pdu(&self, event: &Value, txn: &str) -> Value { + let mut wire = event.clone(); + wire.as_object_mut().unwrap().remove("event_id"); + let body = json!({"origin":self.peer.config.server.server_name,"origin_server_ts":vauxl_federation::keys::now_millis(),"pdus":[wire],"edus":[]}); + let (status, response) = self + .signed( + "PUT", + &format!("/_matrix/federation/v1/send/{txn}"), + Some(&body), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + response + } + + async fn event_count(&self, room: &str) -> i64 { + sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE room_id=$1") + .bind(room) + .fetch_one(&self.local.db) + .await + .unwrap() + } + + async fn message(&self, token: &str, room: &str, text: &str) -> Value { + let path = format!( + "/_matrix/client/v3/rooms/{}/send/m.room.message/{}", + urlencoding::encode(room), + urlencoding::encode(text) + ); + let (status, response) = request( + &self.app, + "PUT", + &path, + Some(token), + Some(&json!({"msgtype":"m.text","body":text})), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + response + } +} + +#[sqlx::test(migrations = "../../migrations")] +async fn signed_requests_bind_exact_uri_method_destination_and_body(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = s.room(&owner).await; + let path = s.make_join_path(&room); + assert_eq!(s.signed("GET", &path, None).await.0, StatusCode::OK); + for (signed_method, signed_path) in [ + ("POST", path.clone()), + ("GET", path.replace("ver=11", "ver=10")), + ("GET", path.replace("%3A", ":")), + ] { + let auth = s.authorization(signed_method, &signed_path, None); + assert_eq!( + request(&s.app, "GET", &path, Some(&auth), None).await.0, + StatusCode::FORBIDDEN + ); + } + let auth = s.authorization("GET", &path, None).replace( + &format!("destination=\"{}\"", s.local.config.server.server_name), + "destination=\"wrong.test\"", + ); + assert_eq!( + request(&s.app, "GET", &path, Some(&auth), None).await.0, + StatusCode::FORBIDDEN + ); + let auth = format!( + "X-Matrix origin=\"{}\",destination=\"{}\",key=\"ed25519:a\",sig=\"{}\"", + s.peer.config.server.server_name, + s.local.config.server.server_name, + BASE64.encode([0; 64]) + ); + assert_eq!( + request(&s.app, "GET", &path, Some(&auth), None).await.0, + StatusCode::FORBIDDEN + ); + + let send = "/_matrix/federation/v1/send/body-binding"; + let original = json!({"origin":s.peer.config.server.server_name,"pdus":[]}); + let mut altered = original.clone(); + altered["pdus"] = json!([{"type":"m.room.member"}]); + let auth = s.authorization("PUT", send, Some(&original)); + let before = s.event_count(&room).await; + assert_eq!( + request(&s.app, "PUT", send, Some(&auth), Some(&altered)) + .await + .0, + StatusCode::FORBIDDEN + ); + assert_eq!(s.event_count(&room).await, before); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn federation_routes_require_authentication_and_room_participation(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = s.room(&owner).await; + let event_id: String = + sqlx::query_scalar("SELECT event_id FROM events WHERE room_id=$1 LIMIT 1") + .bind(&room) + .fetch_one(&s.local.db) + .await + .unwrap(); + let reads = [ + format!( + "/_matrix/federation/v1/backfill/{}?limit=10&v={}", + urlencoding::encode(&room), + urlencoding::encode(&event_id) + ), + format!( + "/_matrix/federation/v1/state/{}", + urlencoding::encode(&room) + ), + format!( + "/_matrix/federation/v1/state_ids/{}", + urlencoding::encode(&room) + ), + format!( + "/_matrix/federation/v1/event/{}", + urlencoding::encode(&event_id) + ), + ]; + for path in &reads { + assert_eq!( + request(&s.app, "GET", path, None, None).await.0, + StatusCode::UNAUTHORIZED + ); + assert_eq!(s.signed("GET", path, None).await.0, StatusCode::FORBIDDEN); + } + assert_eq!( + request(&s.app, "GET", &s.make_join_path(&room), None, None) + .await + .0, + StatusCode::UNAUTHORIZED + ); + for path in [ + "/_matrix/federation/v1/send/no-auth".to_owned(), + format!( + "/_matrix/federation/v2/send_join/{}/%24fake", + urlencoding::encode(&room) + ), + ] { + assert_eq!( + request(&s.app, "PUT", &path, None, Some(&json!({}))) + .await + .0, + StatusCode::UNAUTHORIZED + ); + } + s.join_peer(&room).await; + for path in &reads { + let (status, response) = s.signed("GET", path, None).await; + assert_eq!(status, StatusCode::OK, "{response}"); + if let Some(events) = response["pdus"].as_array() { + for event in events { + s.verify_wire_pdu(event); + } + } + } + let ban = format!( + "/_matrix/client/v3/rooms/{}/ban", + urlencoding::encode(&room) + ); + assert_eq!( + request( + &s.app, + "POST", + &ban, + Some(&owner), + Some(&json!({"user_id":s.peer_user()})) + ) + .await + .0, + StatusCode::OK + ); + for path in &reads { + assert_eq!(s.signed("GET", path, None).await.0, StatusCode::FORBIDDEN); + } +} + +#[sqlx::test(migrations = "../../migrations")] +async fn signed_pdus_check_content_permissions_and_current_history(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = s.room(&owner).await; + s.join_peer(&room).await; + let valid = s + .peer_event( + &room, + "m.room.message", + None, + json!({"msgtype":"m.text","body":"valid"}), + ) + .await; + let before = s.event_count(&room).await; + let response = s.send_pdu(&valid, "valid").await; + assert_eq!( + response["pdus"][valid["event_id"].as_str().unwrap()], + json!({}), + "{response}" + ); + assert_eq!(s.event_count(&room).await, before + 1); + assert_eq!( + s.send_pdu(&valid, "retry").await["pdus"][valid["event_id"].as_str().unwrap()], + json!({}) + ); + assert_eq!(s.event_count(&room).await, before + 1); + + let mut tampered = s + .peer_event(&room, "m.room.message", None, json!({"body":"original"})) + .await; + tampered["content"]["body"] = json!("tampered after PDU signing"); + let unauthorized = s + .peer_event( + &room, + "m.room.power_levels", + Some(""), + json!({"users":{s.peer_user():100}}), + ) + .await; + for (index, event) in [tampered, unauthorized].iter().enumerate() { + let response = s.send_pdu(event, &format!("invalid-{index}")).await; + assert!( + response["pdus"] + .as_object() + .unwrap() + .values() + .all(|v| v["error"].is_string()), + "{response}" + ); + assert_eq!(s.event_count(&room).await, before + 1); + } + let stale = s + .peer_event( + &room, + "m.room.message", + None, + json!({"body":"stale branch"}), + ) + .await; + let topic = format!( + "/_matrix/client/v3/rooms/{}/state/m.room.topic", + urlencoding::encode(&room) + ); + assert_eq!( + request( + &s.app, + "PUT", + &topic, + Some(&owner), + Some(&json!({"topic":"advance history"})) + ) + .await + .0, + StatusCode::OK + ); + let response = s.send_pdu(&stale, "stale").await; + assert!( + response["pdus"] + .as_object() + .unwrap() + .values() + .all(|v| v["error"].is_string()), + "{response}" + ); + assert_eq!(s.event_count(&room).await, before + 2); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn client_routes_enforce_roles_and_prevent_generic_membership_bypass(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let member = s.register("member").await; + let room = s.room(&owner).await; + let base = format!("/_matrix/client/v3/rooms/{}", urlencoding::encode(&room)); + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/join"), + Some(&member), + Some(&json!({})) + ) + .await + .0, + StatusCode::OK + ); + let levels = json!({"users":{s.user("owner"):100,s.user("member"):0},"events_default":50,"state_default":50,"invite":50,"kick":50,"ban":50}); + assert_eq!( + request( + &s.app, + "PUT", + &format!("{base}/state/m.room.power_levels"), + Some(&owner), + Some(&levels) + ) + .await + .0, + StatusCode::OK + ); + let before = s.event_count(&room).await; + let denied = [ + ( + "PUT", + format!("{base}/state/m.room.topic"), + json!({"topic":"unauthorized"}), + ), + ( + "PUT", + format!("{base}/state/m.room.power_levels"), + json!({"users":{s.user("member"):100}}), + ), + ( + "PUT", + format!( + "{base}/state/m.room.member/{}", + urlencoding::encode(&s.user("owner")) + ), + json!({"membership":"leave"}), + ), + ( + "PUT", + format!("{base}/send/m.room.message/retry-after-denial"), + json!({"msgtype":"m.text","body":"no access"}), + ), + ( + "POST", + format!("{base}/kick"), + json!({"user_id":s.user("owner")}), + ), + ( + "POST", + format!("{base}/ban"), + json!({"user_id":s.user("owner")}), + ), + ( + "POST", + format!("{base}/invite"), + json!({"user_id":s.user("newmember")}), + ), + ]; + for (method, path, body) in denied { + let (status, response) = request(&s.app, method, &path, Some(&member), Some(&body)).await; + assert_eq!(status, StatusCode::FORBIDDEN, "{path}: {response}"); + assert_eq!(s.event_count(&room).await, before); + } + let mut levels = levels; + levels["events_default"] = json!(0); + assert_eq!( + request( + &s.app, + "PUT", + &format!("{base}/state/m.room.power_levels"), + Some(&owner), + Some(&levels) + ) + .await + .0, + StatusCode::OK + ); + let send = format!("{base}/send/m.room.message/retry-after-denial"); + let body = json!({"msgtype":"m.text","body":"permitted retry"}); + let (status, first) = request(&s.app, "PUT", &send, Some(&member), Some(&body)).await; + assert_eq!(status, StatusCode::OK, "{first}"); + let (_, again) = request(&s.app, "PUT", &send, Some(&member), Some(&body)).await; + assert_eq!(first, again); + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM events WHERE event_id=$1)") + .bind(first["event_id"].as_str().unwrap()) + .fetch_one(&s.local.db) + .await + .unwrap(); + assert!(exists, "retry must return an actual persisted event ID"); + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/ban"), + Some(&owner), + Some(&json!({"user_id":s.user("member")})) + ) + .await + .0, + StatusCode::OK + ); + for (method, path, body) in [ + ("POST", format!("{base}/join"), json!({})), + ( + "PUT", + format!( + "{base}/state/m.room.member/{}", + urlencoding::encode(&s.user("member")) + ), + json!({"membership":"join"}), + ), + ] { + assert_eq!( + request(&s.app, method, &path, Some(&member), Some(&body)) + .await + .0, + StatusCode::FORBIDDEN + ); + } +} + +#[sqlx::test(migrations = "../../migrations")] +async fn unsupported_remote_join_does_not_import_room_history(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = format!("!unverified:{}", s.peer.config.server.server_name); + let path = format!("/_matrix/client/v3/join/{}", urlencoding::encode(&room)); + let (status, response) = request(&s.app, "POST", &path, Some(&owner), Some(&json!({}))).await; + assert!(status.is_client_error(), "{response}"); + let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM rooms WHERE room_id=$1)") + .bind(room) + .fetch_one(&s.local.db) + .await + .unwrap(); + assert!(!exists); +} + +fn message_bodies(events: &Value) -> std::collections::BTreeSet { + events + .as_array() + .unwrap() + .iter() + .filter(|e| e["type"] == "m.room.message") + .filter_map(|e| e["content"]["body"].as_str().map(str::to_owned)) + .collect() +} + +#[sqlx::test(migrations = "../../migrations")] +async fn private_history_is_filtered_for_clients_and_remote_servers(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let member = s.register("member").await; + for visibility in ["invited", "joined"] { + let body = json!({"preset":"private_chat","initial_state":[{"type":"m.room.history_visibility","content":{"history_visibility":visibility}}]}); + let (status, response) = request( + &s.app, + "POST", + "/_matrix/client/v3/createRoom", + Some(&owner), + Some(&body), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + let room = response["room_id"].as_str().unwrap(); + let base = format!("/_matrix/client/v3/rooms/{}", urlencoding::encode(room)); + let before = s.message(&owner, room, "before invitation").await; + for user in [s.user("member"), s.peer_user()] { + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/invite"), + Some(&owner), + Some(&json!({"user_id":user})) + ) + .await + .0, + StatusCode::OK + ); + } + let invited = s.message(&owner, room, "during invitation").await; + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/join"), + Some(&member), + Some(&json!({})) + ) + .await + .0, + StatusCode::OK + ); + s.join_peer(room).await; + let joined = s.message(&owner, room, "after join").await; + for (event, allowed) in [ + (&before, false), + (&invited, visibility == "invited"), + (&joined, true), + ] { + let path = format!( + "/_matrix/federation/v1/event/{}", + urlencoding::encode(event["event_id"].as_str().unwrap()) + ); + assert_eq!( + s.signed("GET", &path, None).await.0, + if allowed { + StatusCode::OK + } else { + StatusCode::FORBIDDEN + }, + "{visibility}: {event}" + ); + } + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/leave"), + Some(&member), + Some(&json!({})) + ) + .await + .0, + StatusCode::OK + ); + let leave = s + .peer_event( + room, + "m.room.member", + Some(&s.peer_user()), + json!({"membership":"leave"}), + ) + .await; + let result = s.send_pdu(&leave, "leave").await; + assert_eq!( + result["pdus"][leave["event_id"].as_str().unwrap()], + json!({}), + "{result}" + ); + let gap = s.message(&owner, room, "while absent").await; + for user in [s.user("member"), s.peer_user()] { + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/invite"), + Some(&owner), + Some(&json!({"user_id":user})) + ) + .await + .0, + StatusCode::OK + ); + } + assert_eq!( + request( + &s.app, + "POST", + &format!("{base}/join"), + Some(&member), + Some(&json!({})) + ) + .await + .0, + StatusCode::OK + ); + s.join_peer(room).await; + s.message(&owner, room, "after rejoin").await; + + // Changing visibility now must not expose earlier restricted messages. + assert_eq!( + request( + &s.app, + "PUT", + &format!("{base}/state/m.room.history_visibility"), + Some(&owner), + Some(&json!({"history_visibility":"shared"})) + ) + .await + .0, + StatusCode::OK + ); + s.message(&owner, room, "after policy change").await; + let mut expected = std::collections::BTreeSet::from([ + "after join".into(), + "after rejoin".into(), + "after policy change".into(), + ]); + if visibility == "invited" { + expected.insert("during invitation".into()); + } + let (status, messages) = request( + &s.app, + "GET", + &format!("{base}/messages?dir=b&limit=100"), + Some(&member), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{messages}"); + assert_eq!( + message_bodies(&messages["chunk"]), + expected, + "local messages, {visibility}" + ); + let (status, sync) = request( + &s.app, + "GET", + "/_matrix/client/v3/sync", + Some(&member), + None, + ) + .await; + assert_eq!(status, StatusCode::OK, "{sync}"); + assert_eq!( + message_bodies(&sync["rooms"]["join"][room]["timeline"]["events"]), + expected, + "initial sync, {visibility}" + ); + let backfill = format!( + "/_matrix/federation/v1/backfill/{}?limit=100", + urlencoding::encode(room) + ); + let (status, history) = s.signed("GET", &backfill, None).await; + assert_eq!(status, StatusCode::OK, "{history}"); + assert_eq!( + message_bodies(&history["pdus"]), + expected, + "remote history, {visibility}" + ); + for event in [before, gap] { + let path = format!( + "/_matrix/federation/v1/event/{}", + urlencoding::encode(event["event_id"].as_str().unwrap()) + ); + assert_eq!(s.signed("GET", &path, None).await.0, StatusCode::FORBIDDEN); + } + } +} + +#[sqlx::test(migrations = "../../migrations")] +async fn room_server_acl_revokes_federation_access(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = s.room(&owner).await; + s.join_peer(&room).await; + let path = format!( + "/_matrix/client/v3/rooms/{}/state/m.room.server_acl", + urlencoding::encode(&room) + ); + let acl = json!({"allow":["*"],"deny":[s.peer.config.server.server_name.to_uppercase()],"allow_ip_literals":false}); + let (status, response) = request(&s.app, "PUT", &path, Some(&owner), Some(&acl)).await; + assert_eq!(status, StatusCode::OK, "{response}"); + let state_path = format!( + "/_matrix/federation/v1/state/{}", + urlencoding::encode(&room) + ); + assert_eq!( + s.signed("GET", &state_path, None).await.0, + StatusCode::FORBIDDEN + ); + assert_eq!( + s.signed("GET", &s.make_join_path(&room), None).await.0, + StatusCode::FORBIDDEN + ); + let event = s + .peer_event( + &room, + "m.room.message", + None, + json!({"body":"blocked by ACL"}), + ) + .await; + let before = s.event_count(&room).await; + let response = s.send_pdu(&event, "denied-acl").await; + assert!( + response["pdus"] + .as_object() + .unwrap() + .values() + .all(|r| r["error"].is_string()), + "{response}" + ); + assert_eq!(s.event_count(&room).await, before); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn expired_cached_keys_cannot_authenticate_requests(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let room = s.room(&owner).await; + let path = s.make_join_path(&room); + assert_eq!(s.signed("GET", &path, None).await.0, StatusCode::OK); + let mut expired = vauxl_matrix::well_known::signing_key_document(&s.peer).unwrap(); + expired["valid_until_ts"] = json!(1); + let mut expired: ruma::CanonicalJsonObject = serde_json::from_value(expired).unwrap(); + ruma::signatures::sign_json( + &s.peer.config.server.server_name, + &s.peer.signing_key, + &mut expired, + ) + .unwrap(); + let mut redis = s + .local + .redis + .get_multiplexed_async_connection() + .await + .unwrap(); + let _: () = redis + .set_ex( + format!( + "federation:verified-key-document:{}", + s.peer.config.server.server_name + ), + serde_json::to_string(&expired).unwrap(), + 1200, + ) + .await + .unwrap(); + assert_eq!(s.signed("GET", &path, None).await.0, StatusCode::FORBIDDEN); +} + +#[sqlx::test(migrations = "../../migrations")] +async fn legacy_history_does_not_block_other_rooms_or_device_delivery(pool: PgPool) { + let s = Servers::new(pool).await; + let owner = s.register("owner").await; + let legacy = s.room(&owner).await; + s.message(&owner, &legacy, "legacy hidden message").await; + // Model the baseline's persisted events, which had no depth/prev metadata. + sqlx::query("UPDATE events SET raw_event=raw_event-'depth'-'prev_events' WHERE room_id=$1") + .bind(&legacy) + .execute(&s.local.db) + .await + .unwrap(); + let current = s.room(&owner).await; + s.message(&owner, ¤t, "current visible message").await; + let queued = json!({"messages":{s.user("owner"):{"*":{"marker":"device-delivery-survives"}}}}); + let (status, response) = request( + &s.app, + "PUT", + "/_matrix/client/v3/sendToDevice/m.test/legacy-queue", + Some(&owner), + Some(&queued), + ) + .await; + assert_eq!(status, StatusCode::OK, "{response}"); + let (status, sync) = + request(&s.app, "GET", "/_matrix/client/v3/sync", Some(&owner), None).await; + assert_eq!(status, StatusCode::OK, "{sync}"); + assert_eq!( + sync["rooms"]["join"][&legacy]["timeline"]["events"], + json!([]) + ); + assert_eq!( + sync["rooms"]["join"][&legacy]["timeline"]["limited"], + json!(true) + ); + assert!(!sync["rooms"]["join"][&legacy]["state"]["events"] + .as_array() + .unwrap() + .is_empty()); + assert_eq!( + message_bodies(&sync["rooms"]["join"][¤t]["timeline"]["events"]), + std::collections::BTreeSet::from(["current visible message".into()]) + ); + assert!(sync["to_device"]["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["content"]["marker"] == "device-delivery-survives")); + let path = format!( + "/_matrix/client/v3/rooms/{}/messages?dir=b", + urlencoding::encode(&legacy) + ); + assert_eq!( + request(&s.app, "GET", &path, Some(&owner), None).await.0, + StatusCode::FORBIDDEN + ); + + // An unrelated database failure must not consume the next device message. + assert_eq!( + request( + &s.app, + "PUT", + "/_matrix/client/v3/sendToDevice/m.test/failing-sync", + Some(&owner), + Some(&queued) + ) + .await + .0, + StatusCode::OK + ); + sqlx::query("ALTER TABLE room_state RENAME TO unavailable_room_state") + .execute(&s.local.db) + .await + .unwrap(); + let failed = request(&s.app, "GET", "/_matrix/client/v3/sync", Some(&owner), None).await; + sqlx::query("ALTER TABLE unavailable_room_state RENAME TO room_state") + .execute(&s.local.db) + .await + .unwrap(); + assert_eq!(failed.0, StatusCode::INTERNAL_SERVER_ERROR); + let (status, retry) = + request(&s.app, "GET", "/_matrix/client/v3/sync", Some(&owner), None).await; + assert_eq!(status, StatusCode::OK, "{retry}"); + assert!(retry["to_device"]["events"] + .as_array() + .unwrap() + .iter() + .any(|e| e["content"]["marker"] == "device-delivery-survives")); +} diff --git a/docs/FEDERATION_SECURITY.md b/docs/FEDERATION_SECURITY.md new file mode 100644 index 0000000..78389bf --- /dev/null +++ b/docs/FEDERATION_SECURITY.md @@ -0,0 +1,80 @@ +# Federation security and supported behavior + +The server implements an experimental subset of Matrix room version 11. The +security checks below do not establish complete Matrix interoperability. + +## Authentication and authorization + +Protected federation routes verify Ed25519 request signatures against the HTTP +method, original encoded path and query, origin, destination, and canonical JSON +content. A well-formed `X-Matrix` header alone grants no access. + +Remote signing keys must come from HTTPS discovery and a matching, unexpired, +self-signed key document. Redis caches the complete document and revalidates its +identity, signature, and expiry on reads. Rotation to a new key triggers a fresh +fetch. Retired keys can verify events only before their recorded expiry; they +cannot authenticate live requests. Key discovery restricts destinations to +public addresses, pins DNS results, rejects redirects, and bounds response sizes. +Private-network federation and SRV discovery are currently unsupported. + +Room access requires a participating server and respects `m.federate` and +`m.room.server_acl`. Incoming events additionally require valid sender signatures, +content hashes, reference IDs, and room authorization. Relay servers and event +senders are both subject to room ACLs. + +Local and federated state, membership, and message writes check permissions and +persist under one room transaction lock. Generic state endpoints cannot bypass +membership or power-level rules. Message retries are scoped to the user, device, +room, event type, and endpoint, and return the original persisted event ID. +Rejected authorization or invalid event content leaves no transaction marker. + +## History and unsupported operations + +Historical messages are filtered using membership and visibility at the accepted +event's position, including invitations, leave/rejoin gaps, and policy changes. +This applies to client `/messages`, sync timelines, federation backfill, and +individual event reads. Current state snapshots remain available to authorized +members and joining servers. + +Only events extending the known linear predecessor and current authorization +state are accepted. Outbound remote room bootstrap, historical state +reconstruction, stale or forked histories, and unsupported membership proofs are +rejected. Complete auth-chain validation and state resolution remain follow-up +work. Redacted events with mismatching content hashes are also rejected rather +than imported. + +**Existing data:** historical reads require a continuous, verifiable v11 +depth/predecessor sequence. Older stored events without this ordering cause +history reads for that room to be denied. Records are retained. A trusted rebuild +or migration is required before serving such history; there is no automatic +conversion that assumes old membership or ordering was trustworthy. +Sync omits only that room's timeline and marks it limited, while continuing to +deliver authorized current state, other rooms, and queued device messages. + +History checks scan event-order and authorization metadata, without loading every +message body. This is linear in room history size. Existing timestamp-based sync +and pagination behavior remains limited and is not a new conformance guarantee. +Backfill remains a bounded recent-history subset. + +## Local validation + +On Debian/Ubuntu, install Rust stable plus `pkg-config`, `libssl-dev`, and +`postgresql-client`. Use isolated PostgreSQL and Redis services. SQLx tests create +temporary databases, so the test database role must have `CREATEDB` permission. + +```bash +export DATABASE_URL='postgres://vauxl:vauxl@127.0.0.1:55432/vauxl_pr2?sslmode=disable' +export REDIS_URL='redis://127.0.0.1:56379' +export SQLX_OFFLINE=true +cargo fmt --all -- --check +cargo test --workspace --all-targets --locked +cargo clippy --workspace --all-targets --all-features --locked -- -D warnings +``` + +The HTTP security regression tests use the production Axum router with real +PostgreSQL and Redis. A second server identity publishes its own signed key +document, which the fixture pins in isolated Redis. Tests cover accepted joins +and PDUs, tampered requests/events, expired cached keys, room permissions, retry +atomicity, ACLs, and historical visibility. This fixture does not establish public +TLS discovery or interoperability with an independent homeserver implementation. +The discovery smoke script is an additional startup check, not a conformance suite. diff --git a/docs/MVP_CHECKLIST.md b/docs/MVP_CHECKLIST.md index 3798deb..814e3c4 100644 --- a/docs/MVP_CHECKLIST.md +++ b/docs/MVP_CHECKLIST.md @@ -12,6 +12,10 @@ - [ ] Backup and restore procedure - [ ] Security baseline hardening checklist +Implemented security checks and remaining federation limitations are described in +[Federation security](FEDERATION_SECURITY.md). Passing regression tests does not +complete the federation baseline item above. + ## Extensions - [ ] Capability discovery endpoint/event documented - [ ] `org.vauxl.*` extension contracts versioned @@ -19,6 +23,6 @@ ## Deferred Decisions (2026-07-06) - Workspace lints (`clippy pedantic`, `unsafe_code = "forbid"`) are declared in the root `Cargo.toml` but no crate sets `[lints] workspace = true`, so they are not enforced. CI runs with `RUSTFLAGS="-D warnings"`, so wiring them up requires fixing the pedantic fallout in the same change. -- The `AuthenticatedUser` extractor in `vauxl-matrix` exists but no route uses it yet; gate endpoints with it as soon as the first protected route lands. -- Redis is required by config and started by the dev compose stack but no crate uses it yet. Either wire it up or drop it from required config. +- Protected client routes use `AuthenticatedUser`; room mutations additionally authorize against locked room state. +- Redis is used for ephemeral state, sync bookkeeping, and validated federation key caching. - After changing any sqlx query, regenerate the offline cache with `cargo sqlx prepare --workspace` against a migrated database and commit `.sqlx/`. diff --git a/docs/progress.md b/docs/progress.md index fce5cee..8417406 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -1 +1,7 @@ -P1 Sytest baseline: X/350 passing +The server has targeted federation authentication, room authorization, and history +visibility regression tests. See [Federation security](FEDERATION_SECURITY.md) +for reproducible validation and the supported subset. + +A Sytest/Complement conformance baseline and independent-homeserver federation +interoperability have not been established. Discovery smoke checks do not measure +Matrix conformance. diff --git a/scripts/sytest-smoke.sh b/scripts/sytest-smoke.sh new file mode 100755 index 0000000..763ecd4 --- /dev/null +++ b/scripts/sytest-smoke.sh @@ -0,0 +1,106 @@ +#!/usr/bin/env bash +# Exercise the built homeserver through the public Matrix discovery and +# federation endpoints. This is intentionally local and deterministic so the +# workflow does not depend on an unavailable third-party action. + +set -Eeuo pipefail + +BINARY="${1:-target/release/vauxl-server}" +BASE_URL="${BASE_URL:-http://127.0.0.1:8008}" +LOG_FILE="${SYTEST_LOG:-sytest-server.log}" + +if [[ ! -x "$BINARY" ]]; then + echo "Server binary not found or not executable: $BINARY" >&2 + exit 1 +fi + +mkdir -p data +"$BINARY" >"$LOG_FILE" 2>&1 & +server_pid=$! + +cleanup() { + status=$? + if kill -0 "$server_pid" 2>/dev/null; then + kill "$server_pid" 2>/dev/null || true + wait "$server_pid" 2>/dev/null || true + fi + if ((status != 0)); then + echo "Homeserver smoke check failed. Recent server log:" >&2 + tail -n 80 "$LOG_FILE" >&2 || true + fi + exit "$status" +} +trap cleanup EXIT + +wait_for_health() { + for _ in {1..60}; do + if [[ "$(curl --silent --show-error --max-time 2 "$BASE_URL/_vauxl/health" 2>/dev/null || true)" == "ok" ]]; then + return 0 + fi + if ! kill -0 "$server_pid" 2>/dev/null; then + return 1 + fi + sleep 1 + done + return 1 +} + +echo "Waiting for the homeserver to become ready" +wait_for_health +echo "PASS health endpoint" + +health="$(curl --fail --silent --show-error "$BASE_URL/_vauxl/health")" +[[ "$health" == "ok" ]] + +versions="$(curl --fail --silent --show-error "$BASE_URL/_matrix/client/versions")" +python3 -c ' +import json, sys +versions = json.load(sys.stdin).get("versions", []) +required = {"v1.1", "v1.6"} +missing = required.difference(versions) +if missing: + raise SystemExit(f"missing client API versions: {sorted(missing)}") +' <<<"$versions" +echo "PASS client versions" + +client_well_known="$(curl --fail --silent --show-error "$BASE_URL/.well-known/matrix/client")" +python3 -c ' +import json, sys +base_url = json.load(sys.stdin).get("m.homeserver", {}).get("base_url", "") +if not base_url.startswith(("http://", "https://")): + raise SystemExit("m.homeserver.base_url is missing or invalid") +' <<<"$client_well_known" +echo "PASS client well-known" + +server_well_known="$(curl --fail --silent --show-error "$BASE_URL/.well-known/matrix/server")" +python3 -c ' +import json, sys +server = json.load(sys.stdin).get("m.server", "") +if not server or ":" not in server: + raise SystemExit("m.server is missing a host and port") +' <<<"$server_well_known" +echo "PASS server well-known" + +keys="$(curl --fail --silent --show-error "$BASE_URL/_matrix/key/v2/server")" +python3 -c ' +import json, sys, time +data = json.load(sys.stdin) +if not data.get("server_name"): + raise SystemExit("key response has no server_name") +if not data.get("verify_keys"): + raise SystemExit("key response has no verify_keys") +if data.get("valid_until_ts", 0) <= int(time.time() * 1000): + raise SystemExit("key response is already expired") +' <<<"$keys" +echo "PASS federation keys" + +federation_version="$(curl --fail --silent --show-error "$BASE_URL/_matrix/federation/v1/version")" +python3 -c ' +import json, sys +server = json.load(sys.stdin).get("server", {}) +if not server.get("name") or not server.get("version"): + raise SystemExit("federation version response is incomplete") +' <<<"$federation_version" +echo "PASS federation version" + +echo "Matrix smoke checks completed successfully"