No description
  • TypeScript 86.4%
  • Shell 10.4%
  • JavaScript 2.6%
  • Dockerfile 0.6%
Find a file
2026-09-17 22:18:15 +00:00
.ngit/act/workflows Build via docker buildx on the mounted host daemon 2026-08-31 00:04:47 -05:00
deploy Harden broker, bind loopback, and add UDP-mux media deploy 2026-07-28 09:58:00 -05:00
scripts Bound every nak call so a stalled bunker or relay fails fast 2026-08-31 11:31:17 -05:00
src Send X-Content-Type-Options: nosniff on every response 2026-08-19 21:04:17 -05:00
.gitignore Harden broker, bind loopback, and add UDP-mux media deploy 2026-07-28 09:58:00 -05:00
bun.lock Add a blind Concord AV broker and its LiveKit fleet 2026-07-26 17:54:12 -05:00
Caddyfile pull up docker compose 2026-09-18 00:17:44 +02:00
docker-compose.yml Update docker-compose.yml 2026-09-17 22:18:15 +00:00
Dockerfile Add a blind Concord AV broker and its LiveKit fleet 2026-07-26 17:54:12 -05:00
livekit.yaml pull up docker compose 2026-09-18 00:17:44 +02:00
package.json Add a blind Concord AV broker and its LiveKit fleet 2026-07-26 17:54:12 -05:00
README.md Harden broker, bind loopback, and add UDP-mux media deploy 2026-07-28 09:58:00 -05:00
tsconfig.json Add a blind Concord AV broker and its LiveKit fleet 2026-07-26 17:54:12 -05:00

armada-av

A blind Concord AV broker (CORD-07 §2) and the LiveKit fleet behind it.

Concord communities are serverless and end-to-end encrypted: no host, no roster, no server that can check membership. Calls need some infrastructure — an SFU has to forward packets — so CORD-07 makes that infrastructure blind. Clients prove they hold the Channel's key, get a short-lived SFU token, and encrypt media under keys only members can derive. The broker and the SFU forward ciphertext and learn nothing else.

This project is that infrastructure, and nothing else.

What it is not

  • Not a Nostr relay. It opens no relay connections, stores no events, and speaks no Nostr protocol. It performs exactly one schnorr verification per token request.
  • Not stateful. No database, no Redis, no volumes. The only state is an in-memory set of recently-seen grant ids, held for four minutes.
  • Not community-aware. It cannot tell which Community a room belongs to, who is joining, or how many rooms belong together. It never learns a display name, a pubkey of a member, or a channel name.
  • Not a NIP-29 voice server. That path authorizes by group membership, which requires being the relay. It cannot be blind and does not belong here.

The protocol surface

Two endpoints. That's the whole thing.

GET /.well-known/concord/av               → 204 No Content
GET /.well-known/concord/av/<voice_room>  → { "token", "url", "identity" }
    Authorization: Concord <base64(kind-27235 event)>

<voice_room> is voice_key.pk — a 64-char lowercase-hex x-only pubkey derived from the Channel's secret and epoch (§1). It is also the SFU room name.

Authorization is proof of possession: the client self-signs a NIP-98-style kind-27235 event with voice_key.sk, so event.pubkey is the room name. Only a holder of the Channel key can produce it, so the broker needs no lookup. Per §2 a grant is accepted when:

  1. the signature is valid,
  2. event.pubkey equals the room in the path,
  3. the u and method tags match this exact request,
  4. created_at is within ±60s,
  5. the event id has not been seen before (retained ≥240s).

On the nonce tag. §2 requires clients to include 32 fresh random bytes, because every member of a Channel signs with the same voice_key.sk — two members joining one room in the same second would otherwise produce byte-identical grants, and check 5 would reject the second one. It is deliberately not in the broker's acceptance list, and this broker does not require it: doing so would lock out every already-installed client that predates the requirement. Old clients keep working and retain that rare same-second collision; updated clients don't have it.

Quick start (one box)

cd deploy/single
cp .env.example .env      # set AV_DOMAIN and a generated LiveKit key pair
docker compose up -d

That gets you the broker, one LiveKit node, and automatic TLS on one domain. Point a Concord client at https://<AV_DOMAIN> (in Armada: Settings → Voice) and it will use it.

Generate LiveKit credentials with:

docker run --rm livekit/livekit-server generate-keys

Requirements: a domain resolving to the host, ports 80/443 open, and — for media — UDP 7882 (every participant multiplexes over this one port) plus TCP 7881 (the ICE/TCP fallback for clients on UDP-hostile networks). Everything else binds loopback: the broker (8080) and LiveKit's signal/API port (7880) are reachable only through Caddy.

Configuration

Variable Default Meaning
PUBLIC_ORIGIN (required) The externally-reachable origin, e.g. https://av.example.com. Grants are signed against this. Must be a bare origin — a value carrying a path is refused at boot rather than silently trimmed.
LIVEKIT_URL — Single-node SFU, as a bare wss:// origin.
LIVEKIT_API_KEY / LIVEKIT_API_SECRET — Credentials for that node.
LIVEKIT_API_URL derived Server-API base for health probes.
AV_NODES / AV_NODES_FILE — A JSON array of nodes, instead of the single-node triple. See deploy/fleet/.
PORT 8080 Listen port.
HOST 0.0.0.0 Bind address. deploy/single narrows it to 127.0.0.1 so Caddy is the only way in — with TRUST_PROXY on, a directly-reachable broker would let any caller forge X-Forwarded-For.
TOKEN_TTL_SECONDS 21600 (6h) SFU token lifetime. Capped at 24h: the token is a bearer credential.
GRANT_MAX_SKEW_SECONDS 60 §2's freshness window.
REPLAY_TTL_SECONDS 240 Grant-id retention. §2 floor; lower values are refused, as is anything under 2× the skew window — an id must outlive the freshness window it guards.
RATE_LIMIT_PER_MINUTE 60 Per-caller ceiling, keyed on IPv4 address or IPv6 /64 (see below).
TRUST_PROXY off Read the client IP from the rightmost entry of X-Forwarded-For (the peer your proxy saw), instead of the socket peer. Set only when the broker is reachable solely through a proxy you control.
HEALTH_INTERVAL_SECONDS 15 SFU probe interval.

Scaling past one SFU

CORD-07's rendezvous (§5) converges clients on a broker origin. It says nothing about what sits behind that origin, and the token response carries a per-request url — so one origin can front any number of SFUs. That decoupling is the entire scaling story.

Placement is rendezvous hashing (HRW): sha256(voice_room[32] || utf8(node.url)), lowest wins, over the healthy node set. It is a pure function of the room name and the node list, so:

  • every broker replica independently computes the same node for a room, and every member of a call lands together with no coordination;
  • the broker stays stateless, so replicas scale behind any load balancer;
  • adding or removing a node moves only the rooms that hashed to it.

So a fleet is just N independent single-node LiveKit deployments plus a node list. No Redis, no LiveKit clustering, no shared state, no signaling proxy hop — clients are handed the exact node's URL and connect straight to it.

Two things make this cheaper than it sounds:

  • Room names rotate anyway. They derive from the Channel's epoch (§1), so a Rekey rolls the room name and every member rejoins. Resharding after a fleet change is the same event the protocol already performs routinely — it is not a special case. Long-lived node affinity is impossible by construction, and unnecessary.
  • E2EE forces pure forwarding. The SFU cannot transcode or mix ciphertext, so cost is packet routing plus transport crypto — predictable, and bounded by bandwidth rather than CPU. Size nodes on egress.

Egress is the quadratic term: publishers × subscribers × bitrate. DTX keeps that far below worst case, since silent participants transmit almost nothing — a 50-person audio call with three people talking is tens of Mbps, not hundreds. Video changes the arithmetic completely; size on video if you permit it.

Media is multiplexed over a single UDP port (rtc.udp_port: 7882), so ports are not a ceiling: a node's capacity is bounded by egress bandwidth and CPU. For DTX audio a participant's downlink is its room's few active speakers — roughly 120 kbps — so a 1 Gbps NIC carries on the order of 8,000 concurrent seats before the network saturates, with CPU (SRTP on every forwarded packet) usually binding somewhat earlier on small machines. A port range (port_range_start/end) instead costs one port per participant and caps the node at the range's width; use one only if per-participant ports are a firewall requirement, and size it accordingly.

Operational notes

Things that will bite, in rough order of likelihood:

  • PUBLIC_ORIGIN must be what the client sees. The u tag is signed over the full external URL and compared byte-for-byte. Behind a reverse proxy the internal address differs, and a mismatch rejects every request. The broker builds the comparison from this setting rather than from Host, so a spoofed forwarding header cannot redirect it — but a wrong value breaks everything. A value carrying a path (https://av.example.com/av) is refused at boot: trimming it silently would build a u tag no client ever signed and turn this into a 401 on every request with nothing pointing at the cause.
  • The SFU url must be a bare origin. wss://av.example.com, never .../rtc. The LiveKit SDK appends /rtc itself.
  • Never load-shed at the capability probe. Clients that find /.well-known/concord/av unreachable fall back to another broker (§5) — so shedding there scatters the members of a call already live on this broker. The probe reports failure only when there is genuinely no SFU to place a room on. Shed at the token endpoint instead, and only for rooms you don't host.
  • Distant participants dropping usually means blocked transports, not distance. Voice survives a 250 ms round trip fine; what kills it is a network that eats UDP. The recovery ladder is UDP 7882 (best), ICE over TCP 7881 (fine), TURN over TLS 5349 (last resort — commented in livekit.yaml because it needs its own certificate). Check that 7881 is actually open: early deployments only opened the UDP range, which leaves UDP-hostile networks with no fallback at all. Beyond transports, place the node to minimize the WORST member's round trip, not the average — every member of a call lands on the same node, so a US-east box serves US+Europe well and a US-west box serves US+Asia.
  • TLS is mandatory (§2). The grant is a bearer credential for its whole freshness window. Clients refuse a non-https broker outright.
  • The replay set is per-process. Behind several replicas, a captured grant could be replayed against a replica that hasn't seen it. It travels only inside TLS, and E2EE bounds a stolen SFU seat to metadata, so the residual risk is an extra unverified participant — who renders as unverified and whose media never decodes (§4, §7).

What an operator can and cannot see

A broker sees room names, request timing, and IP addresses. It cannot see who is in a call, which Community a room belongs to, or any media.

Two consequences §2 states plainly, and neither is fixable at this layer:

  • A room name is stable for a whole epoch, so a broker serving a long-lived channel can link that channel's calls, participant counts, and durations under one opaque label. Communities that find this intolerable spread calls across brokers or rotate by Rekey.
  • A blind broker is an open service. Anyone can mint a keypair, call its pubkey a room, and pass every check — so you will carry strangers' media by design. Abuse is bounded by rate limits, participant caps, and short token TTLs; never by allow-listing rooms or callers, which would end the blindness.

Each of those three bounds lives somewhere specific, and none of them is the broker being clever about who is calling:

  • Rate limit — keyed on the IPv4 address, or on the /64 for IPv6. A single IPv6 host is routinely delegated a /64, so keying on the full address would let one caller rotate through 2^64 of them and reset its counter at will. IPv4-mapped addresses (::ffff:a.b.c.d, what a dual-stack listener reports for IPv4 peers) are folded back to IPv4 first — masking those to a /64 would put every IPv4 client on earth in one bucket.
  • Participant cap — room.max_participants in deploy/single/livekit.yaml, enforced by the SFU. It cannot live in the broker: counting a room's members means learning who they are. It ships unset (0, unbounded) — right for a deployment serving people you know — and becomes worth setting the day the deployment serves strangers.
  • Token TTL — TOKEN_TTL_SECONDS, capped at 24h.

Development

bun install
bun test          # unit + end-to-end handler tests, no network
bun run test      # the above, plus tsc --noEmit
bun run dev       # watch mode

The end-to-end tests exercise the real request handler — routing, CORS, grant verification, replay, rate limiting, and a genuine JWT mint — without touching the network, since AccessToken signs locally.