MoQ is under active development. APIs and protocols may change between releases.

Skip to content

MoQ End-to-End Encryption Profile ​

INFO

Rendered from the Internet-Draft source in this repository. Submitted versions are on the IETF datatracker.

Abstract ​

This document specifies moq-e2ee-00, a versioned profile for end-to-end encryption of MoQ application payloads. Authorized publishers and subscribers share a 32-byte broadcast secret out of band. Each publisher instance mints an epoch and publishes under an opaque broadcast path ending in it. HKDF-SHA-256 derives opaque physical track names and per-track AES-128-GCM keys from the secret and the epoch; grouped frames and datagrams use separate key domains. Media frames and datagrams carry only ciphertext plus a 16-byte tag. The profile binds object identity through derivation and the nonce, not an on-wire header.

Note to Readers ​

This document was generated by an AI model from the implementation at github.com/moq-dev/moq and is maintained alongside it. Submit an issue or PR if this spec sucks and you want to fix anything.

Conventions and Definitions ​

The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", "SHOULD NOT", "RECOMMENDED", "NOT RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be interpreted as described in BCP 14 RFC2119 RFC8174 when, and only when, they appear in all capitals, as shown here.

Introduction ​

MoQ relays forward named tracks of groups and frames (draft-lcurley-moq-lite, draft-ietf-moq-transport) without parsing application payloads. This profile encrypts those payloads and hides the semantic broadcast and track names that would otherwise describe them, so a relay cannot recover content. It reuses AES-128-GCM and the 96-bit group/frame nonce shape of draft-ietf-moq-secure-objects where those identities map, and specifies the moq-lite and datagram bindings that draft does not cover.

The profile does not distribute keys, sign senders, pad payloads, or rotate a key inside an epoch. Applications that need those properties terminate this profile and run a different one.

draft-ietf-moq-secure-objects encrypts a MoQ Transport object under a per-track base key. Its nonce is a per-track salt XOR uint64_be(group) || uint32_be(object), its AAD includes publisher priority and immutable properties, and the Key ID rides in those properties. The object payload is a length-prefixed plaintext plus an optional encrypted-properties list.

This profile keeps AES-128-GCM, HKDF-SHA-256, a 96-bit nonce of uint64_be(group) || uint32_be(frame), and the rule that an identity is used at most once. It differs where the models diverge:

  • One 32-byte broadcast secret authorizes every track. Per-track keys are derived, not supplied.
  • The Key ID is part of the out-of-band credential. No per-frame header or immutable property carries it.
  • The salt is not derived from the key. The publisher instance's epoch is an input to every derivation and the last segment of the broadcast path (Epoch and Broadcast Path), so a restarted publisher derives new keys instead of needing a new Key ID.
  • The nonce is the identity counter itself, not a salt XOR. The derived key is already unique per credential, epoch, physical name, and domain.
  • The payload is ciphertext concatenated with the 16-byte tag, with no inner length prefix, encrypted properties, or padding.
  • moq-lite frame indices are implied by position in the group (draft-lcurley-moq-lite Section "Frame"), not an on-wire object ID. This profile still uses that index as the 32-bit nonce half, because it is the only canonical end-to-end frame identity on both moq-lite and MoQ Transport.
  • moq-lite datagrams share the group sequence namespace of the same track (draft-lcurley-moq-lite Section "Datagrams"). A grouped frame 0 and a datagram with that sequence would collide under one AES-GCM key, so datagrams use a separate key domain with frame ID zero.

SFrame RFC 9605 is the cryptographic ancestor of draft-ietf-moq-secure-objects. This profile is not SFrame: it has no SFrame header, no CTR field on the wire, and no per-object KID.

The experimental moq-secure format (https://github.com/cathode-ray-tube/moq-secure) is prior art only. Its independent counter, 17-byte per-frame header, ChaCha20-Poly1305 suite, signing lease, payload padding, and bytes-only processor are not compatibility requirements.

Profile Version ​

This document defines profile moq-e2ee-00. The profile is named out of band alongside the credential; an application MUST refuse a credential that names any other profile. A new profile is a new document; implementations MUST NOT fall back to plaintext or to an older profile because a catalog, announcement, or peer suggested one.

Credential ​

The application supplies an immutable credential:

Credential {
  context (b)
  kid (u64)
  secret (32)
}

context: Opaque bytes chosen by the application as the broadcast's end-to-end identity. Both ends MUST use identical bytes. The MoQ broadcast path is visible to relays and may be remounted under another prefix, so it is not this field unless the application copies it in.

kid: Selects among credentials the application retains. It never changes in place; rotating the secret is a new kid.

secret: Exactly 32 bytes from a cryptographically secure random generator. It MUST NOT be a password, passphrase, or other guessable input.

kid MUST be in 0..=2^53-1 inclusive, the largest integer TypeScript can represent exactly. context, every epoch, and every semantic broadcast or track name MUST be at most 65535 bytes, the bytes encoding width. An implementation MUST refuse a credential outside those ranges (identity) or whose secret is not 32 bytes (invalid_secret).

Applications distribute credentials over their own authenticated channel. MoQ announcements, catalogs, paths, and relay authorization MUST NOT carry the secret or authenticate it. Implementations MUST let the application retain more than one credential and select among them; they MUST NOT infer the kid from the transport.

Epoch and Broadcast Path ​

A generation is one credential under one epoch. Every derivation and every nonce is scoped to a generation.

epoch: Opaque nonempty bytes minted by the publisher instance, containing no /. Each instance of a broadcast MUST mint an epoch that no other instance under the same credential has used or will use. Two instances MUST NOT share an epoch: they would derive the same keys and collide on nonces. The RECOMMENDED epoch is the lowercase text of a UUID version 7 (RFC 9562): its leading 48-bit timestamp makes epochs sort by creation time and its random bits make collisions negligible.

A protected broadcast is published at <opaque>/<epoch>, where <opaque> is the 22-character base64url segment derived from the credential and the application's semantic broadcast name according to Key Derivation, and <epoch> is the epoch text. The opaque derivation does not include the epoch, so every instance of the same semantic broadcast shares a discovery prefix. The path carries no format or protection marker; for example, the semantic name meeting.hang appears only as an input to the opaque derivation. A plaintext consumer that opens the protected broadcast fails because it cannot find the plaintext catalog it expects, not because of a path naming rule. Subscribers discover instances by the <opaque>/ prefix and select the greatest epoch when epochs are UUID version 7 text, where greatest is newest. Opaque epochs carry no creation order, so any other epoch form needs an application rule for which instance is current. A subscriber that already knows the full path takes the epoch from its last segment.

The epoch and the path are not secret and are not authenticated. A relay that presents a wrong epoch causes authentication failure; a relay that withholds a newer instance denies service. Neither can cause a nonce to repeat, because only the publisher instance chooses the epoch it encrypts under.

A restart or replacement of a publisher is a new instance and mints a new epoch. Transport sequence numbers therefore restart freely without any coordination between instances. Ended instances remain readable at their own path for as long as relays or archives retain them.

Canonical Encoding ​

HKDF info fields use unique encodings, not varints.

  • u16 / u32 / u64: unsigned big-endian integers of that width.
  • bytes: u16(length) || data, length at most 65535.
  • ASCII labels are the UTF-8 bytes of the quoted string, with no length prefix of their own.

Integers used as group, frame, or kid identities are refused before encoding if they fail Bounds.

Key Derivation ​

Keys and physical names are derived with HKDF-SHA-256 RFC 5869. Let salt be the ASCII bytes of "moq-e2ee-00".

prk = HKDF-Extract(salt, secret)

Opaque broadcast path material is 16 bytes:

path_info = "moq-e2ee-00 path"
            || bytes(context)
            || u64(kid)
            || bytes(semantic_broadcast)
opaque    = HKDF-Expand(prk, path_info, 16)

semantic_broadcast is the UTF-8 bytes of the application's semantic broadcast name. The opaque path segment is the unpadded base64url encoding of opaque (RFC 4648 Section 5): 22 ASCII characters. The epoch is deliberately absent from this derivation, so a subscriber can derive the prefix before discovering an instance.

Physical track name material is 16 bytes:

name_info = "moq-e2ee-00 name"
            || bytes(context)
            || bytes(epoch)
            || u64(kid)
            || bytes(semantic_name)
physical  = HKDF-Expand(prk, name_info, 16)

semantic_name is the UTF-8 bytes of the application's track name (catalog.json, video, and so on). The physical track name is the unpadded base64url encoding of physical (RFC 4648 Section 5): 22 ASCII characters, which is a valid moq-lite track name. This function may hide any track-shaped name the application wants a relay not to read; it is not limited to media tracks.

AEAD keys are 16 bytes, one per physical name and domain:

key_info = "moq-e2ee-00 key"
           || bytes(context)
           || bytes(epoch)
           || u64(kid)
           || bytes(physical_name)
           || domain
key      = HKDF-Expand(prk, key_info, 16)

domain is a single byte: 0x00 for grouped frames, 0x01 for datagrams. physical_name here is the 22-character ASCII string, not the raw 16-byte material.

A given (generation, physical_name, domain) tuple has one key. Implementations MUST derive names from semantic names, then keys from the resulting physical names. A subscriber that learns a physical name from a decrypted catalog derives its key without ever knowing the semantic name.

Object Identity ​

A protected object is the tuple (generation, physical_name, domain, group, frame).

Grouped Frames ​

A grouped frame uses domain = 0x00, the group's sequence as group, and the frame index within that group as frame. moq-lite numbers frames from 0 in write order (draft-lcurley-moq-lite). On MoQ Transport, frame is the explicit Object ID, never its arrival ordinal. Publishers supporting both transports MUST assign contiguous Object IDs from zero so that each matches its moq-lite write-order index; relays MUST NOT renumber protected objects. An Object ID above 2^32-1 MUST be refused as identity before encryption or decryption.

Datagrams ​

A datagram uses domain = 0x01, its 64-bit sequence as group, and frame = 0. MoQ Transport has no datagram mapping in this profile; shared vectors cover grouped tracks on both transports and datagrams on moq-lite only.

Nonce ​

The 96-bit AES-GCM nonce is:

nonce = u64(group) || u32(frame)

AES-GCM's internal block counter is not frame. Implementations MUST call a standard AEAD API RFC 5116 with this nonce and an empty AAD.

The empty AAD is deliberate: profile version, context, epoch, kid, physical name, and domain are bound by HKDF; group and frame are bound by the nonce. Rewritten timestamps and mutable routing properties are not authenticated.

Payload Protection ​

Let Nt = 16. AES-128-GCM encrypts the application bytes with key, nonce, and empty AAD. The bytes placed in the MoQ frame or datagram payload are ciphertext concatenated with the 16-byte tag, in the RFC 5116 convention. There is no inner header.

Within a generation a publisher MUST allocate group and datagram sequences monotonically and frame indices in write order, so an identity is encrypted at most once. Encrypting at an identity the same instance already used is reuse and MUST be refused. Relays and caches forward ciphertext unchanged, so replay from a cache never re-encrypts.

Plaintext Ceiling ​

Protected payload length is plaintext length plus Nt. A publisher MUST refuse plaintext that would make the protected payload exceed the transport payload limit for that object (oversize), before it touches the network.

The interoperable grouped-frame payload cap matching current moq-net implementations is 32 MiB, so grouped plaintext MUST be at most 32 MiB - 16 bytes. moq-lite datagram bodies MUST remain at most 1200 bytes including Subscribe ID, Group Sequence, and Timestamp (draft-lcurley-moq-lite Section "Datagrams"). Those three varints are at most 24 bytes, so datagram plaintext MUST be at most 1200 - 24 - 16 = 1160 bytes; a publisher cannot observe the Subscribe ID each hop will encode and MUST NOT budget for a smaller header.

Catalogs ​

A catalog is a track like any other: published under the physical name derived from its semantic name, with each snapshot or delta protected at its own group and frame identity. Hang draft-lcurley-moq-hang catalog.json and catalog.json.z and MSF's catalog are semantic names; authorized clients derive those physical names from the generation, then learn the remaining opaque names from the decrypted catalog. A Hang rendition-map key in that catalog is the physical name of the track.

If a representation is compressed, compression is applied to the catalog bytes before AEAD and reversed after decryption. Encrypting then compressing is forbidden: ciphertext does not compress, and the .z sibling would leak the uncompressed size ratio.

Bounds ​

Implementations MUST refuse non-integer identities (including NaN and infinities) and identities outside these bounds before encoding or AEAD:

  • group (grouped sequence or datagram sequence) and kid: 0..=2^53-1. Above that is identity.
  • frame: 0..=2^32-1. 2^32 and above is identity.
  • AEAD operations with one key: at most 2^24 invocations and at most 2^36 plaintext bytes (2^32 16-byte blocks). Exceeding either is exhausted.

2^53-1 is Number.MAX_SAFE_INTEGER. It is the strictest exact integer bound across current TypeScript and Rust implementations. The 32-bit frame width is the nonce field. The 2^24 invocation cap is the interoperable AES-GCM record limit from draft-irtf-cfrg-aead-limits. GCM authenticity also depends on total processed blocks, so 2^24 frames at the 32 MiB transport ceiling would be about 2^45 blocks; the 2^36-byte cap is the matching total-block bound. Small records hit the invocation cap first; large records hit the byte cap first. A receiver counts failed opens too, since each is an AEAD invocation under that key.

Failure Behavior ​

E2EE is an explicit per-broadcast mode. There is no plaintext fallback.

Typed failures:

  • invalid_secret: secret is not 32 bytes.
  • identity: an integer is outside Bounds, a bytes field exceeds 65535, an epoch is empty or contains /, a physical name is not 22 base64url characters, or domain is not 0x00/0x01.
  • exhausted: the next AEAD operation would exceed 2^24 uses of that key or 2^36 plaintext bytes under that key.
  • reuse: encrypting at an identity this instance already used.
  • oversize: plaintext plus tag exceeds the transport payload limit, or a ciphertext is shorter than Nt or larger than that limit.
  • authentication: AEAD open fails. Relocation across context, epoch, kid, physical name, domain, group, or frame is this failure.
  • duplicate: a receiver has already opened this datagram sequence inside its retained window. Operational, not a cryptographic event.

Authentication failure on a grouped track MUST end that track with authentication. Authentication failure on a datagram MUST drop that datagram and emit authentication; the track continues.

Grouped frames need no duplicate window: a transport delivers each frame of a group once, in order, at its index. Receivers SHOULD suppress datagram sequences they still retain; the window MUST be bounded, and 1024 sequences below the greatest opened is RECOMMENDED. The AEAD identity and epoch rules are the security boundary; a relay may still delay, reorder, suppress, or replay ciphertext outside a receiver's window.

A late subscriber MAY start at any group the publisher still holds. Gaps are not errors. A receiver MUST NOT require group 0 or any prior identity before opening a later authentic object.

Test Vectors ​

Known-answer and negative vectors live in moq-e2ee-00.json beside this draft. Hex strings are octet sequences. The JSON is authoritative for primitive interop; an implementation of this profile MUST pass every vector. Each negative row specifies an operation, its inputs, and its expected typed error. Non-finite frame inputs use the strings NaN, Infinity, and -Infinity; group inputs in identity tests are decimal strings. Implementations whose types cannot represent an invalid input MUST reject it at their input boundary.

The file covers opaque path derivation, key derivation, physical naming, grouped frames, a datagram at the fixed budget, the same identity under two epochs, relocation across every identity dimension, tag failure, malformed physical names, identity bounds, and oversize plaintext.

The shared verifier is stateless. It does not verify reuse, exhausted, duplicate, or failure propagation. Each language core MUST test those lifecycle requirements, including monotonic allocation, per-key invocation and plaintext-byte accounting, bounded datagram suppression, and that a new instance under a new epoch authenticates while the old epoch does not. Passing the primitive vectors alone is not profile conformance.

Security Considerations ​

Relays, caches, recorders, and control planes are untrusted for content. Authorized endpoints that hold the broadcast secret are trusted. Sender authenticity against another endpoint that also holds the secret is not a goal of moq-e2ee-00.

A relay can still observe the outer broadcast path including the opaque segment and epoch, opaque physical names, group and frame structure, timestamps, sizes, and traffic patterns. Padding and metadata-flow confidentiality are out of scope.

The opaque segment hides the application's semantic broadcast name from a relay only while the application does not publish or otherwise expose the same name in plaintext. The epoch remains visible to every relay on the path.

Nonce reuse under one key is catastrophic for AES-GCM. The profile prevents it by deriving every key from an epoch that only one publisher instance ever uses, allocating identities monotonically within that instance, separating datagram and grouped domains, and capping invocations and plaintext bytes per key. No state survives an instance: nothing needs to be persisted across restarts to stay safe.

Empty AAD does not weaken the binding: every immutable end-to-end field is in the HKDF info or the nonce. Timestamps are excluded because relays rewrite them; a relay can therefore shift or reorder authentic objects in time within a receiver's tolerance.

The opaque path segment is a deterministic function of the secret and semantic broadcast name; physical track names are deterministic functions of the secret and epoch. An attacker without the secret cannot predict them; an attacker with the secret can derive every name, which is intended.

IANA Considerations ​

This document requests no registrations.

Normative References ​

Informative References ​

Changelog ​

draft-lcurley-moq-e2ee-00 ​

  • Initial moq-e2ee-00 profile: out-of-band credential, opaque broadcast path with a publisher-minted epoch as its last segment, HKDF physical names and keys, AES-128-GCM payloads, identity bounds, typed failures, and shared primitive vectors.

Acknowledgments ​

This document was drafted with the assistance of Grok, an AI assistant by xAI.

Licensed under MIT or Apache-2.0