Stream C left these out until the labelled JWP forms existed. With them on this base: - jwp-receipts §7.5: a group receipt and the objects under it need `delivery` and `energy/<class>` for each class the objects' labelled leaves bind (§2.2) and the class a labelled group payload signs for the total (§2.3). A group with a hardware-class label is signed by one key for delivery and the figures, so `also_delivers` is true there by construction. §3 step 6 and confidentiality §6 say what a labelled leaf binds. - key_log: Item::for_group takes those classes again. Item::from_group reads them from a group receipt that passes §3 steps 1-2 and objects that pass their inclusion check, and Item::from_family from another family's receipt through its verified figure view; neither builds an item from bytes that did not verify. - signer-conformance: four group-labelled vectors, regenerated through wai_signer_vectors gen and reproduced by signer_verify.py, whose group authorities follow the same row. Ignoring the classes in for_group fails all four. Co-Authored-By: Claude Opus 5.5 <[email protected]>
251 lines
11 KiB
Markdown
251 lines
11 KiB
Markdown
# WAI Extension: Confidentiality (MLS + SFrame)
|
||
|
||
> Status: Draft. Sub-spec of WAI v1.0. Its implementation is a companion
|
||
> implementation, outside this tree and under its own licence: the
|
||
> `moq-sframe` (SFrame object encryption) and `joule-comm-mls` (MLS group
|
||
> key agreement) crates, 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 *(2026-09-30:
|
||
draft-jennings-moq-e2ee-mls is an individual Internet-Draft that has expired; the
|
||
MoQ working group's draft-ietf-moq-secure-objects is related work)*:
|
||
|
||
- **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. The publisher signs the group's Merkle root over the
|
||
objects' leaves, and a Merkle leaf commits to the object's content hash
|
||
and, for a labelled object, its figure, class and origin (jwp-receipts
|
||
§2.2); an unlabelled object's figure is signed only where an `energy`
|
||
claim restates it (jwp-receipts §2.4.5). A relay forwards the
|
||
sealed bytes and their receipts unaltered, signs nothing in the
|
||
publisher's name (jwp-receipts §4.3), and never learns what the bytes
|
||
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 *which bytes the publisher signed for, and the group's
|
||
declared energy* — anyone can check it with the publisher's verifying
|
||
key, which the receipt carries, and no content key; whose key it is,
|
||
[`jwp-receipts.md`](jwp-receipts.md) §7 confirms.
|
||
|
||
---
|
||
|
||
## 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 content key) and signs nothing in the JWP chain (jwp-receipts
|
||
§4.3). A relay attestation, where a deployment wants one, is a separate
|
||
receipt under the relay's own key, and no class for it is registered
|
||
yet.
|
||
|
||
---
|
||
|
||
## 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.
|