matrix-rtc: separate standalone publication from room creation

This commit is contained in:
heather 2026-11-07 21:55:21 +00:00
commit 2ff4caa788
5 changed files with 321 additions and 1 deletions

View file

@ -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" ];

View file

@ -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"]

View file

@ -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.

View file

@ -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.

View file

@ -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) => {