open-standards/wai/extensions/confidentiality.md
Transaction Science 3bd6d38cd8 wai: a group's labelled leaves and total need their class's authority
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]>
2026-10-01 07:47:50 -04:00

251 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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.