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]>
240 lines
9.9 KiB
Markdown
240 lines
9.9 KiB
Markdown
# 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.
|