open-standards/wai/extensions/confidentiality.md
dcharlot65-transactionsci fcb2e4483e
wai: register confidentiality (MLS+SFrame) + document opt-in JWP freshness (#37)
Two additions to the WAI standard, rebased clean onto current main:

- SPEC.md gains section 6 Confidentiality (OPTIONAL): a security.encryption
  manifest field, keys as a requirement-of-existence (never transported),
  relay-sees-only-ciphertext transparency, and a decrypt-then-dispatch
  refinement of the capability-dispatch algorithm. Conformance/Versioning
  renumbered to 7/8; the neural intent bullet's replicate-soundness ref
  follows to 7. No cross-references broken.
- New extensions/confidentiality.md sub-spec: byte-level SFrame (RFC 9605
  suite 0x0006) framing + HKDF-SHA-512 schedule + nonce, the MLS (RFC 9420)
  exporter binding with single- vs multi-sender keying, and how it composes
  with the JWP receipt chain over ciphertext.
- extensions/jwp-receipts.md: cross-links payload- vs receipt-confidentiality,
  and documents the optional wall-clock freshness bound on the live edge
  (companion to the monotonic group_id guard already in 6.1).

Grounded byte-for-byte in the moq-sframe / joule-comm-mls reference impl;
the private-channel path is live at wai.transaction.science/private-channel.

Co-authored-by: Transaction Science <[email protected]>
Co-authored-by: Claude Opus 4.8 <[email protected]>
2026-07-04 21:16:39 -04:00

240 lines
9.9 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. 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.