LAB -

Verified Video in the Browser: SRT Egress and a MoQ Player for Q-Stream

Horia Paulas Oct 20, 2026

Engineering notes from Deviqon for Q-Stream

Introduction

Q-Stream needs to distribute signed camera video over the public internet without letting the delivery infrastructure become a trusted component. Encoders, media servers and relays all sit between the camera and the viewer, and any of them can fail, be misconfigured or be compromised. Our answer is to make the viewer's browser the only thing that has to be trusted.

The trust model is simple to state. The camera signs every H.264 access unit with ML-DSA-44 over a BLAKE3 hash and carries the C2PA assertion in a SEI NAL unit. The browser verifies each unit before decoding it, and it accepts the camera's public key only if its fingerprint matches a pin the operator supplied out of band.

Until recently the only route to that browser was RTSP through a WebSocket proxy. That suits a local network but not internet-scale distribution, so we extended the delivery path with three pieces:

  1. an SRT output in the Rust egress service, so the signed stream can feed a media server;
  2. a MoQ (Media over QUIC) input in the web player;
  3. Mode B, a second verification path for streams that are re-encoded for adaptive bitrate (ABR) delivery.

Delivery Paths (simplified):

camera -► c2pa-egress ─┬─ RTSP -► proxy -► WebSocket ─────────────────┐
 (signed H.264 + SEI)  │                                              |
                       └─ SRT / MPEG-TS -► media server -► MoQ relay-►|   
                                                                      | 
                                                               ┌------┘        
                                                               ▼   
                                                            browser
                                                 (verify → gate → decode)

What "verified" means

"Verified" can mean four different things in this system, and the article uses each term for exactly one of them.

Property Question it answers Mechanism Where
Camera authenticity Who signed this? Is it the camera whose key we pinned? ML-DSA-44 signature; public key accepted only on fingerprint match with the out-of-band pin Mode A
Media-byte integrity Are these the bytes that were signed? Mode A: BLAKE3 over the reconstructed access unit. Mode B: C2PA BMFF hash, bound to the signed claim A and B
Provenance after re-encoding Who attests to this representation of the video? Standard C2PA manifest signed at the distribution point, checked against a pinned CA Mode B

Integrity and authenticity are separate questions. "The bytes match" says nothing about who signed them, since a perfect hash match under an unpinned key proves nothing about origin. And "the signer is the camera" says nothing about whether the bytes were altered afterwards. Mode A answers both at once. Mode B can answer only integrity, plus a different authenticity claim made by a different signer. SRT and QUIC make delivery dependable, but nothing about them is trusted.

Two delivery modes

Mode A: passthrough. The media server re-frames the stream without re-encoding. The camera's signed elementary stream and its SEI arrive intact, so the player verifies camera authenticity and media-byte integrity end to end, exactly as it does on the RTSP path.

Mode B: ABR re-encode. Delivering an ABR ladder means re-encoding, and re-encoded pixels can never match the hash the camera signed. We don't pretend otherwise.

In Mode B, the camera's signature does not survive. What survives is the broadcaster's signature, applied at the distribution point. The broadcaster is attesting to the re-encoded representation of the video, not to the camera-signed pixel and hash identity. The player verifies that attestation against a separate pinned CA.

The UI shows a distinct verdict for each mode, so the two claims can't be confused. Mode B is a narrower claim than Mode A, but it is honest about its scope: provenance from distribution onwards.

SRT egress: a second sink that can't hurt the first

The egress service previously ran CBOR serialisation, SEI injection, NAL splitting and RTP packetisation in one function. We split it into a shared prepare stage and a format-specific final step. RTSP and SRT now share the SEI logic, and the RTSP behaviour is unchanged.

The SRT sink muxes the same access units into MPEG-TS with a small in-house muxer, in keeping with the project's hand-rolled RTSP and RTP code. MPEG-TS is transparent to NAL content, so the C2PA SEI passes through byte for byte. Two details are worth noting:

  • Cadence is not frame-driven timing. PCR (every 30 ms) and PSI (every 200 ms) come from interval timers against a monotonic 27 MHz clock, not from frame arrival. The muxer is designed to stay within TR 101 290 timing limits however long the GOP is.
  • Tunnel-safe datagrams. The usual SRT payload is 7×188 bytes. Our cluster network is tunnelled, and a full-size datagram can be silently dropped while small control packets still get through, so the link looks healthy but the receiver ingests nothing. We default to 6×188 bytes, configurable for un-tunnelled networks.

The core design constraint is that a slow or failing media server must never affect local viewers. The sink owns its own broadcast receiver. When it falls behind, it drops whole access units and resyncs on the next keyframe, so it never applies backpressure to the sender. It dials out only once a source exists, retries with capped, jittered exponential backoff, and is respawned by a supervisor if it panics. Its endpoint is hot-reloadable through ArcSwap, so reconfiguration doesn't take a lock on the video path. The transport sits behind a small trait (SRTTransport, a single send method), so this isolation logic is unit-tested without an SRT stack, including against a transport whose send never returns.

Why libsrt, over FFI

Our first transport was srt-tokio, a pure-Rust SRT implementation that fits a static-binary deployment well, though its own README marks it as not production ready. In practice, our logs showed the caller session dying roughly 14 ms after a successful handshake. Log analysis pointed to a probabilistic interoperability race with the media server's libsrt. As a control, a GStreamer srtsink (libsrt to libsrt) was stable against the same media server.

So we moved to Haivision's libsrt, the same library the media server runs, through a minimal FFI layer. The surface is small enough that we don't need a binding crate:

#[link(name = "srt")]
extern "C" {
    fn srt_startup() -> c_int;
    fn srt_create_socket() -> Srtsocket;
    fn srt_setsockopt(sock: Srtsocket, level: c_int, optname: c_int,
                      optval: *const c_void, optlen: c_int) -> c_int;
    fn srt_connect(sock: Srtsocket, name: *const libc_sockaddr, namelen: c_int) -> c_int;
    fn srt_send(sock: Srtsocket, buf: *const c_char, len: c_int) -> c_int;
    // srt_close, srt_getlasterror_str
}

libsrt's send and connect calls block, so each runs on Tokio's blocking pool and is bounded twice: by libsrt's own socket timeouts, and by the pump's. The socket closes on Drop, so a failed session can't leak a handle across reconnects. The pure-Rust transport stays behind a feature flag for rollback.

The MoQ player: a transport adapter

The player's verifier was already transport-agnostic. Everything trust-related is driven by two entry points: handleInit(), which receives the public key, and onAccessUnit(), which receives one Annex-B access unit with its SEI. The WebSocket client is just glue around them. MoQ is therefore a second adapter that calls the same two sinks, and the crypto, key pinning, freshness checks and render gate are reused unchanged.

The library choice matters. MoQ's high-level playback layers decode and render on their own, which would bypass our verify-before-decode gate. In our testing, the high-level watcher also hard-coded a catalog track name that our media server doesn't publish. We use only the @moq/net transport layer, bundled locally with esbuild so the player stays self-contained, with no CDN:

conn = await Connection.connect(relayUrl(cfg.relay));
const broadcast = conn.consume(Path.from(cfg.namespace));

// The publisher exposes a streaming-format catalog; read track names, never hardcode them
const catalog = JSON.parse(
  new TextDecoder().decode(await broadcast.subscribe('.catalog', 0).readFrame())
);
// → an init track (avcC) and a media track; each media object is one fMP4 fragment

The camera's public key arrives in-band, inside the C2PA assertion, and is trusted only on a fingerprint match with the out-of-band pin. The pin never travels over MoQ. Relay and namespace are set by URL parameter, server-provided config or built-in default, in that order, and the MoQ bundle is lazy-loaded so the default WebSocket path never depends on it.

One access unit, end to end

The hard part of Mode A is that the media server publishes CMAF, so the player must rebuild the exact access unit the camera signed. Here is the journey of one keyframe (the NAL order is illustrative):

  1. Camera. The access unit is Annex-B, with parameter sets inline: AUD | SPS | PPS | SEI(C2PA) | IDR slice. The signature covers this unit.
  2. Egress. The unit is wrapped in a PES packet with its timestamp, cut into 188-byte TS packets and grouped six per SRT datagram. PCR and PSI packets are interleaved. No NAL byte changes.
  3. Media server (passthrough). The stream is repackaged as CMAF. By convention SPS and PPS move out of the samples into the init segment's avcC box, and each sample's NAL units get length prefixes in place of Annex-B start codes. The SEI stays inside the sample.
  4. Browser. For each sample, the player swaps the length prefixes back to 00 00 00 01, reinserts SPS and PPS from avcC where the camera had them, and hands the rebuilt unit to the verifier.

NAL payloads, including emulation-prevention bytes and the C2PA SEI, survive packaging untouched, so only framing and parameter-set placement need reconstructing. But that is exactly where the risk lies: if the rebuilt unit differs from the signed one by a single byte, the BLAKE3 check fails on that keyframe.

Mode B verification: fail closed

Because Mode B is standard C2PA (JUMBF, COSE_Sign1, X.509), it is a proper second verifier, held to a fail-closed standard:

  • The media hash is enforced. The c2pa.hash.bmff.v3 hash over the init segment must match the played bytes.
  • The signed claim must reference the assertion being checked. The COSE signature covers the claim, not the assertion. Without checking the claim's hashed-URI digest first, an attacker could rewrite the assertion's hash to match substituted media. We verify that binding before trusting the media hash, using the algorithm the assertion declares, and we reject unknown or weak algorithms. The hashing scope follows the C2PA specification.
  • Missing means rejected. A signed manifest with no usable media binding is blocked, not shown as a soft pass. Without this, stripping the assertion would bypass the check.

For per-segment integrity, the player auto-detects the binding the stream uses: a full manifest per fragment (C2PA §19.3), or verifiable-segment-info checked against session keys committed in the signed manifest (§19.4). Once either is seen, the player latches. A later fragment without it is treated as a downgrade and rejected, rather than falling back to the weaker init-only verdict, so a stripped manifest can't silently demote a stream.

Closing

Together, the SRT sink, the MoQ adapter and Mode B verification give Q-Stream an internet-scale delivery path. Every hop is untrusted, and the browser decides what it plays.

Adding transports widens where Q-Stream can go without changing what it trusts.

Working on a video streaming project with similar security or delivery requirements? Get in touch with our engineering team to discuss your challenges: https://www.deviqon.com/contact-us

Subscribe to our newsletter

Rest assured we will not misuse your email