# WAI Extension: Confidentiality (MLS + SFrame) > Status: Draft. Sub-spec of WAI v1.0. Reference implementation: the > `moq-sframe` (SFrame object encryption) and `joule-comm-mls` (MLS group > key agreement) crates of the WAI reference tree, with browser bindings > in `joule-comm-mls-wasm`. Live demonstration: > https://wai.transaction.science/private-channel Keywords **MUST**, **MUST NOT**, **SHOULD**, **MAY**, **REQUIRED**, **OPTIONAL** are RFC 2119 / RFC 8174. --- ## 1. Scope and model This extension makes a WAI payload **private**: only members of a group may recover the content, while any relay or multiplexer that carries the payload can still route, meter, and attest the bytes **without reading them**. It is the `e2e_encrypted` path — the confidential counterpart to the public floor, where a shared world or a broadcast film is plaintext by design. The construction is the `draft-jennings-moq-e2ee-mls` binding: - **MLS** (RFC 9420) — the group agrees a shared secret out of band and every member derives the same **SFrame base key** from it. Membership, not a transported key, is the requirement of existence. - **SFrame** (RFC 9605) — each object payload is sealed under that base key with an AEAD, so the wire carries only ciphertext. Confidentiality is **orthogonal to the capability** (WAI SPEC.md §5). SFrame wraps the payload; the capability still names how to decode the *plaintext* once recovered. A sink decrypts first, then runs capability dispatch (SPEC.md §4) on the plaintext. In scope: the SFrame object format, its key schedule, the MLS exporter binding, the decrypt-then-dispatch integration, and relay transparency. Out of scope: - **A transport.** Sealed objects ride in the carrier protocol exactly as plaintext objects do; this extension does not define how they move. - **MLS itself.** Group formation, commits, welcomes, and epoch advancement are RFC 9420; this extension only names the ciphersuite and the exporter call that yields the SFrame base key. - **The receipt chain.** JWP attestation is [`jwp-receipts.md`](jwp-receipts.md); §6 states only how the two compose. --- ## 2. Ciphersuites - **SFrame:** cipher suite `0x0006`, `AES_256_GCM_SHA512_128` (RFC 9605 §4.5): AEAD = AES-256-GCM (`Nk = 32`, `Nn = 12`, `Nt = 16`), KDF = HKDF-SHA-512. The two-byte suite id `0x00 0x06` is a constant of the key schedule (§3.2), not carried on the wire. - **MLS:** ciphersuite `0x0001`, `MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519` (RFC 9420 §17.1) — the mandatory-to-implement baseline. Credentials are BasicCredential over Ed25519. A sink MUST reject an envelope whose declared `security.encryption` names a scheme or suite it does not implement (SPEC.md §6.4), rather than attempt to decode ciphertext. --- ## 3. SFrame object format A sealed payload is `header ‖ AEAD.Seal(...)`. The manifest's payload bytes ARE this sealed object; nothing else wraps it. ### 3.1 Header (RFC 9605 §4.3) The first byte is: ``` bit 7 6 5 4 3 2 1 0 +---+-------+---+-------+ | R | LEN | X | KID/ | |=0 | (3) | | KLEN | +---+-------+---+-------+ ``` - `R` (1 bit) — reserved, MUST be 0. - `LEN` (3 bits) — `ctr_len − 1`; the counter is `LEN+1` bytes, big-endian, minimal (no leading zeros; a zero counter is one `0x00`). - `X` (1 bit) — key-id form. `X = 0`: the key id is the low 3 bits of byte 0 (values 0–7). `X = 1`: the low 3 bits are `KLEN = kid_len − 1` and the `kid_len`-byte big-endian minimal key id follows byte 0. - The counter bytes follow the key id. The header is therefore `1 + (X ? kid_len : 0) + ctr_len` bytes and is self-delimiting: `(kid, ctr)` and the header length are recoverable from the payload alone. ### 3.2 Key schedule (RFC 9605 §4.4.3) From the 32-byte base key (§4), with `suite = 0x00 0x06`: ``` secret = HKDF-Extract(salt = "", IKM = base_key) ; HMAC-SHA-512 key = HKDF-Expand(secret, "SFrame 1.0 Secret key " ‖ suite, Nk=32) salt = HKDF-Expand(secret, "SFrame 1.0 Secret salt " ‖ suite, Nn=12) ``` The label strings include their trailing space, immediately followed by the two suite bytes. ### 3.3 Nonce (RFC 9605 §4.4.4) The per-object nonce is `salt XOR ctr`, where `ctr` is the 64-bit counter written big-endian and right-aligned into the 12-byte nonce (the low 8 bytes are XORed; the high 4 bytes are `salt` unchanged). ### 3.4 Seal / open - **Seal:** `AES-256-GCM(key, nonce, plaintext, aad = header)` → `ct ‖ tag`; the object is `header ‖ ct ‖ tag`. The header is the AEAD associated data, so any edit to `(kid, ctr)` fails authentication. - **Open:** parse the header, derive `(key, salt)`, recompute the nonce from the parsed `ctr`, and AEAD-open with the header as AAD. A wrong key, a tampered header, or a tampered ciphertext/tag MUST fail with a single indistinguishable authentication error. The sender MUST NOT reuse a `(base_key, ctr)` pair — GCM nonce reuse is catastrophic. Counters are per base key and strictly increasing. --- ## 4. MLS binding — deriving the base key Every group member derives the SFrame base key from the MLS group's exporter secret (RFC 9420 §8.5): ``` base_key = MLS-Exporter(label = "moq-jwp sframe base key", context, length = 32) ``` - All members at the same MLS **epoch** derive the same key; a non-member has no group state and cannot derive it. Advancing the epoch (any MLS commit — add, remove, update) rotates the base key, giving the channel MLS's forward secrecy and post-compromise security for free. - **Single-sender channel:** `context = ""`. One publisher, monotonic counter, no `(key, ctr)` collision. This is the profile the reference `sframe_base_key()` and the live private-channel demo implement. - **Multi-sender channel:** a sender MUST derive a per-sender base key by binding its identity into the exporter — `context = KID` (or the member's MLS leaf index) — so two senders never share a `(key, ctr)` space. The SFrame `kid` then selects the sender whose base key opens the object. A multi-sender sink MUST use per-sender derivation; it MUST NOT seal two senders under one base key. The SFrame `kid` (§3.1) names *which* base key opens an object; the MLS epoch names *which generation* of that key. A sink resolves both before `open`. --- ## 5. Decrypt-then-dispatch This refines SPEC.md §6.4. Before capability dispatch (SPEC.md §4) a sink MUST: 1. If the manifest has no `security` object, or `security.encryption` is `null` / `"none"`, dispatch the payload unchanged. 2. Else, for `"mls-sframe"`: if the sink is not a member of the group (holds no MLS state) or holds no base key for `security.kid`, the file is **inert at this sink** — return a clear "no key" error. The sink MUST NOT feed ciphertext to a codec. 3. Else `open` the SFrame object (§3.4) to recover the plaintext payload, then run SPEC.md §4 on the plaintext. `media`, `intent`, `conditioning.kind`, and `model_requirement.capability` describe the **plaintext** codec and are NOT encrypted: confidentiality protects the *content*, not the *shape*, so a multiplexer can still route by capability without a key. --- ## 6. Relay transparency and JWP composition A relay or multiplexer that carries a sealed payload MUST treat it as opaque: it MUST NOT require the plaintext to route, meter, or attest, and MUST NOT attempt capability dispatch on ciphertext. Attestation composes with confidentiality because the JWP receipt chain ([`jwp-receipts.md`](jwp-receipts.md)) commits over the **ciphertext** content hash. A relay signs that it forwarded the exact sealed bytes it was handed — with the per-object energy and delivery lineage bound into the Merkle leaf — while never learning what they say. Content secrecy (SFrame/MLS) and delivery attestation (JWP) are independent layers over the same bytes: - SFrame answers *what does it say* — only members can. - JWP answers *who delivered it and at what energy* — anyone can verify, no key required. --- ## 7. Security considerations - **Counter uniqueness** (§3.4) is the sender's obligation; a persisted or monotonic counter per base key is REQUIRED. Epoch rotation (§4) resets the counter space safely because it rotates the key. - **Replay.** SFrame authenticates each object but does not by itself reject a replayed object. Freshness is enforced above this layer — the MLS epoch bounds a key's lifetime, and the application op-log (e.g. a Lamport/causal clock) orders and de-duplicates. Note that a channel which legitimately re-sends old sealed objects for late-joiner resync MUST NOT treat re-delivery as an attack. - **Metadata.** The manifest shape (capability, dimensions, timing) and object sizes/timing are visible to relays by design. This extension protects payload content, not traffic analysis. - **Trust boundary.** A relay is untrusted for confidentiality (it never holds a key) and semi-trusted only for the attestation it signs under its own key; the two roles do not overlap. --- ## 8. Conformance - A **confidentiality-aware sink** MUST implement §3 (SFrame open), §4 (MLS base-key derivation for the profiles it supports), and §5 (decrypt-then-dispatch), and MUST fail closed on a missing key or unsupported scheme. - A **confidentiality-aware encoder** MUST emit `security.encryption` in the manifest whenever the payload is sealed, and MUST seal per §3 under a base key derived per §4. - A sink that does NOT implement this extension MUST refuse an envelope whose manifest carries a non-`none` `security.encryption` cleanly (SPEC.md §6.4) — never mis-decode ciphertext as a codec. - Conformance is verified against the reference `moq-sframe` / `joule-comm-mls` crates: byte-level SFrame round-trip and rejection vectors, MLS group derivation of a shared base key, and the end-to-end check that a member opens what a non-member cannot — the same flow the live `/private-channel` demo runs in-browser over the real relay.