diff --git a/hosts/heather/arion-compose.nix b/hosts/heather/arion-compose.nix index 29c278f..51e56fe 100644 --- a/hosts/heather/arion-compose.nix +++ b/hosts/heather/arion-compose.nix @@ -187,7 +187,11 @@ in service.ports = [ "127.0.0.1:7880:7880" "7881:7881/tcp" "7882:7882/udp" ]; }; matrix-rtc-jwt = { - service.image = "ghcr.io/element-hq/lk-jwt-service:0.9.0"; + # Pinned-source policy fork: verified remote users may publish, but only + # iscg.dev may create SFU rooms. Source/tests/rollback: services/matrix-rtc-jwt. + service.image = "iscg/lk-jwt-service:0.9.0-policy1-6e21c58c02d6"; + # Local test-gated image: restore/load it before starting on a new host. + out.service.pull_policy = "never"; service.container_name = "matrix-rtc-jwt"; # Avoid public-IP hairpin NAT for same-host OpenID and SFU calls; TLS stays verified. out.service.extra_hosts = [ "iscg.dev:host-gateway" ]; diff --git a/services/matrix-rtc-jwt/Dockerfile b/services/matrix-rtc-jwt/Dockerfile new file mode 100644 index 0000000..bf88703 --- /dev/null +++ b/services/matrix-rtc-jwt/Dockerfile @@ -0,0 +1,22 @@ +# Upstream v0.9.0 (0e176b1), with the reviewed standalone publication patch. +FROM docker.io/library/rust:1-alpine@sha256:44819b27be4c97b0e4d7203bd2e02c4820f97700211b6a37fb4c246a2de59383 AS builder +RUN apk add --no-cache musl-dev cmake make gcc g++ perl linux-headers ca-certificates +WORKDIR /proj +ENV CARGO_BUILD_JOBS=2 CARGO_PROFILE_DEV_DEBUG=0 CARGO_PROFILE_TEST_DEBUG=0 +COPY source/ ./ +# Gate the production image on unit tests and both real HTTP/OpenID endpoint suites. +RUN cargo test --locked -p lk-jwt-service --lib +RUN cargo test --locked -p lk-jwt-service-integration-tests --test get_token --test sfu_get +RUN cargo build --locked --release -p lk-jwt-service --bin lk-jwt-service --bin healthcheck \ + && strip target/release/lk-jwt-service target/release/healthcheck +FROM scratch +COPY --from=builder /proj/target/release/lk-jwt-service /lk-jwt-service +COPY --from=builder /proj/target/release/healthcheck /lk-jwt-service-healthcheck +COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +LABEL org.opencontainers.image.source="https://github.com/element-hq/lk-jwt-service" \ + org.opencontainers.image.revision="0e176b1eca44e67b11484d0bdf56999807bf2b8e+iscg-policy1" \ + org.opencontainers.image.licenses="AGPL-3.0-only" +EXPOSE 8080 +USER 65532:65532 +CMD ["/lk-jwt-service"] +HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD ["/lk-jwt-service-healthcheck"] diff --git a/services/matrix-rtc-jwt/README.md b/services/matrix-rtc-jwt/README.md new file mode 100644 index 0000000..b78532a --- /dev/null +++ b/services/matrix-rtc-jwt/README.md @@ -0,0 +1,97 @@ +# MatrixRTC standalone publication policy (iscg policy1) + +## Policy and boundary + +This is a narrow fork of Element's lk-jwt-service v0.9.0, upstream commit +`0e176b1eca44e67b11484d0bdf56999807bf2b8e` (AGPL-3.0-only). It fixes both +standalone endpoints, `/sfu/get` and `/get_token`: + +- Matrix OpenID verification still runs, including the subject's server name + and the claimed user ID where the protocol supplies one. +- Every successfully authenticated participant may publish audio/video in the + requested, room-scoped session, regardless of their homeserver. +- `LIVEKIT_FULL_ACCESS_HOMESERVERS=iscg.dev` still gates the server-side + `CreateRoom` call and delayed-leave delegation. It is NOT widened to `astr.to` + or `*`. No user's JWT gains room creation, list, recording or admin privileges. +- Existing application-service membership checks, when configured, are unchanged. +- The SFU MUST retain `room.auto_create: false`. Otherwise remote users can + create arbitrary SFU rooms by joining, regardless of JWT-service policy. +- A remote user may obtain a scoped token before a local user creates the room; + the SFU rejects joining a missing room. This is intentional upstream behaviour. +- Existing one-hour token TTL, SFU participant limits and container hardening + are preserved. No new appservice/admin credential is introduced. + +**This is not Matrix room-membership authorization.** On Heather, Tuwunel +1.9.4 has no MSC4502/MSC4512 integration, and lk-jwt-service is standalone. +Someone with valid Matrix OpenID and knowledge of a room ID can request an SFU +token even if not a Matrix member, including for an unencrypted call. This +limitation existed for subscribing before the patch and now includes publishing. +Room IDs are not authorization secrets; E2EE, when enabled by clients, protects +media confidentiality, not resource abuse or forged room participation. Do not +advertise this deployment as membership-checked or suitable for anonymous guests. + +The fuller migration is homeserver-backed MSC4195/MSC4502/MSC4512 integration +with scoped application-service membership checks. It requires a homeserver +that supports those APIs (or a separately reviewed Tuwunel implementation), +client compatibility checks and a backed-up migration. Adding `astr.to` or `*` +to the full-access list does not solve membership authorization. The newer +upstream `LIVEKIT_REMOTE_CAN_PUBLISH_HOMESERVERS` option applies to federated +application-service requests, not to these standalone endpoints. + +## Source and tests + +`standalone-publication.patch` changes two authorization arguments, adds a +startup policy banner, regression tests and updates the two HTTP integration +suites' remote publication expectations. Creation, delegation, OpenID and +membership-check implementations are not replaced. + +The image build fails unless all root-library unit tests and both HTTP/OpenID +integration suites (`get_token`, `sfu_get`) pass. Regressions cover local users, +`astr.to`, an unrelated server, rejected OpenID, foreign/forged subject, +room-scoped JWTs and lack of creation/admin permissions. Existing membership +and delayed-event denial tests remain part of the unit suite. + +The upstream archive, patch and Rust builder image are SHA256-pinned, and Cargo +uses `--locked`. Alpine build-tool packages are fetched from its package +repository, so this is a pinned-source build recipe, NOT a claim of bit-for-bit +reproducible output. Preserve the exported release image and its checksum too. + +## Build (Linux amd64 / Docker) + +From this directory: + +```sh +# Run long work in tmux. No production secrets are needed by the build. +sudo bash build.sh +``` + +The resulting image is +`iscg/lk-jwt-service:0.9.0-policy1-6e21c58c02d6`. +The image is local-only and Compose uses `pull_policy: never`. On a replacement +host, rebuild it or load the preserved Docker image archive BEFORE starting +Arion. Do not replace it with an unreviewed latest tag. Tests and compiler run +in build containers; no Rust toolchain is installed on the NixOS host. + +## Deployment and rollback + +Deploy only `matrix-rtc-jwt` with the evaluated Arion composition, after staging +and health/token/media checks. No homeserver, SFU, Coturn or unrelated service +restart is required. Compare the Nix source baseline to `/run/current-system` +and inspect dry activation before switching so unrelated edits are not applied. + +The deployment receipt records the exact image ID and new Nix generation. +Before deployment preserve the old generation, old composition, old image, +container start-time baseline and config source. Keep release archives outside +cache on Heather and Poppy. + +For an emergency rollback, use the saved pre-policy Arion composition: + +```sh +sudo arion --prebuilt-file /path/from/receipt/old-compose.yaml \ + up -d --no-deps --pull never matrix-rtc-jwt +``` + +Then restore the pre-policy Nix generation/source selectively, retaining any +subsequent unrelated edits. Do not restart the whole `heather.service`, wipe +volumes, revert the homeserver database or disable TLS verification. Rejoin the +call to obtain a fresh JWT; previously issued tokens do not change retroactively. diff --git a/services/matrix-rtc-jwt/build.sh b/services/matrix-rtc-jwt/build.sh new file mode 100644 index 0000000..cc4f4a9 --- /dev/null +++ b/services/matrix-rtc-jwt/build.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +set -euo pipefail +here=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +work=${RTC_BUILD_DIR:-"$HOME/.cache/pi-work/matrix-rtc-policy/build"} +mkdir -p "$work" +revision=0e176b1eca44e67b11484d0bdf56999807bf2b8e +source_sha=32f4d1b31c39689c7b8100c1567eacc646763c96c1054513115ae59cd9efabb9 +patch_sha=6e21c58c02d68e45c12f053a70001954022b88551a8e3afa620c61c1e0a94791 +image=iscg/lk-jwt-service:0.9.0-policy1-${patch_sha:0:12} +if [ ! -f "$work/upstream.tar.gz" ]; then + curl -fL --retry 3 "https://codeload.github.com/element-hq/lk-jwt-service/tar.gz/$revision" -o "$work/upstream.tar.gz" +fi +printf '%s %s\n' "$source_sha" "$work/upstream.tar.gz" "$patch_sha" "$here/standalone-publication.patch" | sha256sum -c - +# Fresh context, never build against an unreviewed/dirty upstream checkout. +context=$(mktemp -d "$work/context.XXXXXX") +mkdir "$context/source" +tar -xzf "$work/upstream.tar.gz" --strip-components=1 -C "$context/source" +(cd "$context/source" && patch --batch --fuzz=0 -p1 < "$here/standalone-publication.patch") +cp "$here/Dockerfile" "$context/Dockerfile" +# BuildKit may require separate routing on this host. The legacy builder uses +# Docker's configured bridge egress; build/test containers never mount secrets. +DOCKER_BUILDKIT=0 docker build --network host --memory=3g --memory-swap=4g --cpu-shares=256 -t "$image" "$context" +printf 'Built and test-gated: %s\n' "$image" +docker image inspect "$image" --format '{{.Id}}' +# Context is deliberately kept for audit/retries. Remove only this task's cache +# manually after preserving the release image/source receipt. diff --git a/services/matrix-rtc-jwt/standalone-publication.patch b/services/matrix-rtc-jwt/standalone-publication.patch new file mode 100644 index 0000000..c8ec712 --- /dev/null +++ b/services/matrix-rtc-jwt/standalone-publication.patch @@ -0,0 +1,171 @@ +diff --git a/integration-tests/tests/get_token.rs b/integration-tests/tests/get_token.rs +index 386f97b..102ff4a 100644 +--- a/integration-tests/tests/get_token.rs ++++ b/integration-tests/tests/get_token.rs +@@ -353,13 +353,12 @@ async fn remote_token() { + assert_eq!(response["url"].as_str(), Some(sfu.url())); + let jwt = response["jwt"].as_str().unwrap_or_default(); + +- // The JWT should grant joining, subscribing and updating the +- // participant's own metadata, but not publishing, since the homeserver +- // is remote. ++ // Remote verified users may publish, but the service must not create a ++ // room for them. Room creation remains gated on the full-access list. + let claims = decode_livekit_jwt(jwt); + assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY)); + assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true)); +- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false)); ++ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true)); + assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true)); + assert_eq!( + claims["video"]["canUpdateOwnMetadata"].as_bool(), +diff --git a/integration-tests/tests/sfu_get.rs b/integration-tests/tests/sfu_get.rs +index 5a08445..2cc8672 100644 +--- a/integration-tests/tests/sfu_get.rs ++++ b/integration-tests/tests/sfu_get.rs +@@ -323,7 +323,7 @@ async fn remote_token() { + let claims = decode_livekit_jwt(jwt); + assert_eq!(claims["iss"].as_str(), Some(LIVEKIT_KEY)); + assert_eq!(claims["video"]["roomJoin"].as_bool(), Some(true)); +- assert_eq!(claims["video"]["canPublish"].as_bool(), Some(false)); ++ assert_eq!(claims["video"]["canPublish"].as_bool(), Some(true)); + assert_eq!(claims["video"]["canSubscribe"].as_bool(), Some(true)); + assert_eq!( + claims["sub"].as_str(), +diff --git a/src/handler.rs b/src/handler.rs +index 2797194..7910e1e 100644 +--- a/src/handler.rs ++++ b/src/handler.rs +@@ -978,7 +978,11 @@ impl Handler { + &lk_room_alias, + &lk_identity, + &matrix_id, +- is_full_access_user, ++ // Publication is an authenticated participation permission, not a ++ // room-creation privilege. OpenID and (when configured) membership ++ // checks above must still succeed. Keep creation/delegation gated ++ // below and LiveKit room.auto_create=false on the SFU. ++ true, + )?; + + if is_full_access_user { +@@ -1058,7 +1062,11 @@ impl Handler { + &lk_room_alias, + &lk_identity, + &matrix_id, +- is_full_access_user, ++ // Publication is an authenticated participation permission, not a ++ // room-creation privilege. OpenID and (when configured) membership ++ // checks above must still succeed. Keep creation/delegation gated ++ // below and LiveKit room.auto_create=false on the SFU. ++ true, + )?; + + if is_full_access_user { +diff --git a/src/handler_tests.rs b/src/handler_tests.rs +index 54d1650..e6c943c 100644 +--- a/src/handler_tests.rs ++++ b/src/handler_tests.rs +@@ -5403,3 +5403,87 @@ async fn test_membership_skipped_unless_hs_is_the_only_full_access_homeserver() + handler.close().await; + } + } ++ ++// iscg standalone publication policy regression tests. The full-access list ++// governs room creation/delegation, never whether a verified participant speaks. ++#[tokio::test] ++async fn test_iscg_standalone_publication_does_not_grant_creation() { ++ for path in ["/sfu/get", "/get_token"] { ++ for server in ["example.com", "astr.to", "unrelated.example"] { ++ let sub = format!("@user:{server}"); ++ let created = Arc::new(AtomicUsize::new(0)); ++ let counter = created.clone(); ++ let deps = HandlerTestDeps { ++ exchange_openid_userinfo_fn: exchange_openid_userinfo_ok(&sub), ++ create_livekit_room_fn: Some(Box::new(move |_, _, _| { ++ counter.fetch_add(1, Ordering::SeqCst); ++ Ok(()) ++ })), ++ ..Default::default() ++ }; ++ let handler = new_get_token_handler(deps); ++ let mut req = valid_sfu_request(); ++ req.openid_token.matrix_server_name = server.into(); ++ req.member.claimed_user_id = sub.clone(); ++ let body = if path == "/get_token" { ++ serde_json::to_string(&req).unwrap() ++ } else { ++ serde_json::to_string(&LegacySfuRequest { ++ room: req.room_id.clone(), ++ openid_token: req.openid_token.clone(), ++ device_id: req.member.claimed_device_id.clone(), ++ ..Default::default() ++ }).unwrap() ++ }; ++ let resp = send_request(&handler, post_json(path, body)).await; ++ assert_eq!(resp.status(), StatusCode::OK, "{path} {server}"); ++ let result: SfuResponse = serde_json::from_slice(&body_bytes(resp).await).unwrap(); ++ let claims = parse_jwt_claims(&result.jwt, &handler.livekit_auth.secret); ++ assert_eq!(claims["video"]["canPublish"], true, "{path} {server}"); ++ assert_eq!(claims["video"]["canSubscribe"], true); ++ assert_eq!(claims["video"]["roomJoin"], true); ++ for privilege in ["roomCreate", "roomAdmin", "roomList", "roomRecord", "ingressAdmin"] { ++ assert_ne!(claims["video"][privilege], true, "{path}: {privilege}"); ++ } ++ let want_room = livekit_room_alias_for(&req.room_id, &req.slot_id); ++ assert_eq!(claims["video"]["room"], want_room.0); ++ assert_eq!(created.load(Ordering::SeqCst), usize::from(server == "example.com")); ++ assert_eq!(handler.is_full_access_user(server), server == "example.com"); ++ handler.close().await; ++ } ++ } ++} ++ ++#[tokio::test] ++async fn test_iscg_publication_still_requires_valid_openid() { ++ for path in ["/sfu/get", "/get_token"] { ++ for forged_subject in [false, true] { ++ let deps = HandlerTestDeps { ++ exchange_openid_userinfo_fn: if forged_subject { ++ exchange_openid_userinfo_ok("@victim:example.com") ++ } else { ++ Some(Box::new(|_| Err("invalid or expired token".into()))) ++ }, ++ // An accidental room creation or membership request panics. ++ ..Default::default() ++ }; ++ let handler = new_get_token_handler(deps); ++ let mut req = valid_sfu_request(); ++ req.openid_token.matrix_server_name = "astr.to".into(); ++ req.member.claimed_user_id = "@user:astr.to".into(); ++ let body = if path == "/get_token" { ++ serde_json::to_string(&req).unwrap() ++ } else { ++ serde_json::to_string(&LegacySfuRequest { ++ room: req.room_id, ++ openid_token: req.openid_token, ++ device_id: "DEVICE".into(), ++ ..Default::default() ++ }).unwrap() ++ }; ++ let resp = send_request(&handler, post_json(path, body)).await; ++ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{path} forged={forged_subject}"); ++ handler.close().await; ++ } ++ } ++} +diff --git a/src/main.rs b/src/main.rs +index 2a057d6..7e7ebfd 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -49,6 +49,10 @@ async fn main() { + } + } + ++ info!( ++ "iscg standalone policy v1: verified users may publish; room creation and delayed-leave delegation remain full-access only" ++ ); ++ + let config = match parse_config() { + Ok(config) => config, + Err(err) => {