920 lines
33 KiB
Rust
920 lines
33 KiB
Rust
//! Live composition — `wai.live.compose` ([extensions/live-compose.md]).
|
||
//!
|
||
//! A room's composition is an integer function. A signed **composition descriptor**
|
||
//! (`WCD1`), by the room authority or a principal whose grant has the compose power
|
||
//! (live-rooms §3.3), fixes the canvas, the background, the layout (a grid, or a grid with
|
||
//! a picture-in-picture window), the attribute keys it reads and its limits. The
|
||
//! **participant set** at a room-clock instant is derived from the room's lifecycle
|
||
//! replay ([`crate::live_room`]): who publishes, each one's video and audio source and its
|
||
//! dimensions, the attributes of its grant in effect, and its join order. The layout and
|
||
//! the voices of the mix are then a pure function of the two ([`layout::layout`]), and the
|
||
//! mix a pure function of decoded PCM ([`layout::mix`]), so every sink composes the same
|
||
//! rectangles locally, and a server compositor that publishes a composed track signs a
|
||
//! **composition receipt** (`WCR1`) that anyone can check against the same function.
|
||
//!
|
||
//! What this module implements, by section of the extension:
|
||
//!
|
||
//! - §3: the descriptor, read, its canvas limits and field rules, its digest and
|
||
//! signature, and who may sign it;
|
||
//! - §4: the room state at an instant, the participant set, the attributes the descriptor
|
||
//! reads, the order, and the set's digest;
|
||
//! - §5, §6: the layout and the mixer, in [`layout`], which uses nothing beyond `core`,
|
||
//! `Vec` and `String`;
|
||
//! - §8: the receipt, read, signed, and verified against the room and the descriptor.
|
||
//!
|
||
//! The corpus `live-conformance/compose/` is written by [`corpus::generate`] and checked
|
||
//! by [`corpus::verify`] here, and by `live-conformance/live_compose_verify.py`, which
|
||
//! shares no code with this module.
|
||
//!
|
||
//! [extensions/live-compose.md]: ../../extensions/live-compose.md
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use ed25519_dalek::{Signer, SigningKey};
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::live_events::{self as events, anchor_track, kind, Event, Payload};
|
||
use crate::live_room::{
|
||
self as room, layering, media, media_name, power, render_namespace, track_name, track_namespace, verify_room, At, Descriptor, Grant, Held, Owner, RoomOutcome, RoomRun,
|
||
RoomState,
|
||
};
|
||
|
||
pub mod corpus;
|
||
pub mod layout;
|
||
|
||
use layout::{Entrant, Layout, Params, Refusal};
|
||
|
||
/// The record this extension registers.
|
||
pub const CAPABILITY: &str = "wai.live.compose";
|
||
/// The magic of a composition descriptor (§3.1).
|
||
pub const DESCRIPTOR_MAGIC: [u8; 4] = *b"WCD1";
|
||
/// The magic of a composition receipt (§8.1).
|
||
pub const RECEIPT_MAGIC: [u8; 4] = *b"WCR1";
|
||
/// The version of both records in this revision.
|
||
pub const RECORD_VERSION: u8 = 1;
|
||
/// The domain a descriptor's signature is made over (§3.2).
|
||
pub const DESCRIPTOR_DOMAIN: &[u8] = b"wai:live-compose\x01";
|
||
/// The domain a receipt's signature is made over (§8.1).
|
||
pub const RECEIPT_DOMAIN: &[u8] = b"wai:live-compose-receipt\x01";
|
||
/// The domain of a participant set's digest (§4.5).
|
||
pub const SET_DOMAIN: &[u8] = b"wai:live-compose-set\x01";
|
||
/// A descriptor's `compositor` when no server composes (§3.1, §7).
|
||
pub const NO_COMPOSITOR: [u8; 32] = [0; 32];
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §10 Reasons
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// Why a composition, a mix or a receipt is refused (§10).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum Reason {
|
||
/// A code of live-rooms §11, or of live-events §9 through it.
|
||
Room(room::Reason),
|
||
CanvasOutOfRange,
|
||
ComposeInvalid,
|
||
ComposeNotPermitted,
|
||
NotYetEffective,
|
||
LifecycleIncomplete,
|
||
AttrMalformed,
|
||
MixInputMalformed,
|
||
ReceiptInvalid,
|
||
DescriptorMismatch,
|
||
CompositorNotPermitted,
|
||
OutputNotPublished,
|
||
StateMismatch,
|
||
InputsMismatch,
|
||
}
|
||
|
||
impl From<room::Reason> for Reason {
|
||
fn from(r: room::Reason) -> Self {
|
||
Reason::Room(r)
|
||
}
|
||
}
|
||
|
||
impl From<events::Reason> for Reason {
|
||
fn from(r: events::Reason) -> Self {
|
||
Reason::Room(room::Reason::Events(r))
|
||
}
|
||
}
|
||
|
||
impl From<Refusal> for Reason {
|
||
fn from(r: Refusal) -> Self {
|
||
match r {
|
||
Refusal::CanvasOutOfRange => Reason::CanvasOutOfRange,
|
||
Refusal::ComposeInvalid => Reason::ComposeInvalid,
|
||
Refusal::AttrMalformed => Reason::AttrMalformed,
|
||
Refusal::MixInputMalformed => Reason::MixInputMalformed,
|
||
}
|
||
}
|
||
}
|
||
|
||
const TRUNCATED: Reason = Reason::Room(room::Reason::Events(events::Reason::Truncated));
|
||
const MALFORMED: Reason = Reason::Room(room::Reason::Events(events::Reason::Malformed));
|
||
const UNKNOWN_VERSION: Reason = Reason::Room(room::Reason::Events(events::Reason::UnknownVersion));
|
||
const WRONG_ROOM: Reason = Reason::Room(room::Reason::Events(events::Reason::WrongRoom));
|
||
|
||
impl Reason {
|
||
/// The codes this extension adds, in §10's order.
|
||
pub const ALL: [Reason; 13] = [
|
||
Reason::CanvasOutOfRange,
|
||
Reason::ComposeInvalid,
|
||
Reason::ComposeNotPermitted,
|
||
Reason::NotYetEffective,
|
||
Reason::LifecycleIncomplete,
|
||
Reason::AttrMalformed,
|
||
Reason::MixInputMalformed,
|
||
Reason::ReceiptInvalid,
|
||
Reason::DescriptorMismatch,
|
||
Reason::CompositorNotPermitted,
|
||
Reason::OutputNotPublished,
|
||
Reason::StateMismatch,
|
||
Reason::InputsMismatch,
|
||
];
|
||
|
||
/// The reason's code.
|
||
pub fn code(self) -> &'static str {
|
||
match self {
|
||
Reason::Room(r) => r.code(),
|
||
Reason::CanvasOutOfRange => "canvas_out_of_range",
|
||
Reason::ComposeInvalid => "compose_invalid",
|
||
Reason::ComposeNotPermitted => "compose_not_permitted",
|
||
Reason::NotYetEffective => "not_yet_effective",
|
||
Reason::LifecycleIncomplete => "lifecycle_incomplete",
|
||
Reason::AttrMalformed => "attr_malformed",
|
||
Reason::MixInputMalformed => "mix_input_malformed",
|
||
Reason::ReceiptInvalid => "receipt_invalid",
|
||
Reason::DescriptorMismatch => "descriptor_mismatch",
|
||
Reason::CompositorNotPermitted => "compositor_not_permitted",
|
||
Reason::OutputNotPublished => "output_not_published",
|
||
Reason::StateMismatch => "state_mismatch",
|
||
Reason::InputsMismatch => "inputs_mismatch",
|
||
}
|
||
}
|
||
}
|
||
|
||
impl std::fmt::Display for Reason {
|
||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
f.write_str(self.code())
|
||
}
|
||
}
|
||
|
||
impl std::error::Error for Reason {}
|
||
|
||
/// Where a verification stopped (§9).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Place {
|
||
/// The room: where live-rooms §9 refused it, or none for the room as a whole.
|
||
Room(Option<At>),
|
||
Descriptor,
|
||
Receipt,
|
||
/// The participant set or the layout at this instant.
|
||
Set(u64),
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Reading
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||
let mut h = Sha256::new();
|
||
for p in parts {
|
||
h.update(p);
|
||
}
|
||
h.finalize().into()
|
||
}
|
||
|
||
/// A cursor; running out is `truncated`.
|
||
struct Rd<'a> {
|
||
b: &'a [u8],
|
||
p: usize,
|
||
}
|
||
|
||
impl<'a> Rd<'a> {
|
||
fn take(&mut self, n: usize) -> Result<&'a [u8], Reason> {
|
||
let e = self.p.checked_add(n).filter(|&e| e <= self.b.len()).ok_or(TRUNCATED)?;
|
||
let s = &self.b[self.p..e];
|
||
self.p = e;
|
||
Ok(s)
|
||
}
|
||
fn arr<const N: usize>(&mut self) -> Result<[u8; N], Reason> {
|
||
Ok(self.take(N)?.try_into().expect("N bytes"))
|
||
}
|
||
fn u8(&mut self) -> Result<u8, Reason> {
|
||
Ok(self.take(1)?[0])
|
||
}
|
||
fn u16(&mut self) -> Result<u16, Reason> {
|
||
Ok(u16::from_le_bytes(self.arr()?))
|
||
}
|
||
fn u32(&mut self) -> Result<u32, Reason> {
|
||
Ok(u32::from_le_bytes(self.arr()?))
|
||
}
|
||
fn u64(&mut self) -> Result<u64, Reason> {
|
||
Ok(u64::from_le_bytes(self.arr()?))
|
||
}
|
||
/// A short field: a u8 length and that many bytes.
|
||
fn short(&mut self) -> Result<&'a [u8], Reason> {
|
||
let n = self.u8()? as usize;
|
||
self.take(n)
|
||
}
|
||
}
|
||
|
||
/// live-events §3.3's first two steps, with `magic` for `WEV1`.
|
||
fn read_head(b: &[u8], magic: &[u8; 4]) -> Result<(), Reason> {
|
||
if b.len() < 4 {
|
||
return Err(TRUNCATED);
|
||
}
|
||
if b[..4] != *magic {
|
||
return Err(MALFORMED);
|
||
}
|
||
if b.len() < 5 {
|
||
return Err(TRUNCATED);
|
||
}
|
||
if b[4] != RECORD_VERSION {
|
||
return Err(UNKNOWN_VERSION);
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
fn verify_sig(key: &[u8; 32], domain: &[u8], body: &[u8], sig: &[u8; 64]) -> Result<(), Reason> {
|
||
let mut m = domain.to_vec();
|
||
m.extend_from_slice(body);
|
||
crate::sig::verify_strict(key, &m, sig).map_err(|e| match e {
|
||
crate::sig::SigError::BadKey => events::Reason::BadKey.into(),
|
||
crate::sig::SigError::BadSignature => events::Reason::BadSignature.into(),
|
||
})
|
||
}
|
||
|
||
fn put_short(o: &mut Vec<u8>, s: &[u8]) {
|
||
assert!(s.len() <= u8::MAX as usize, "a short field is at most 255 bytes");
|
||
o.push(s.len() as u8);
|
||
o.extend_from_slice(s);
|
||
}
|
||
|
||
fn u16_of(v: u32) -> [u8; 2] {
|
||
u16::try_from(v).expect("a field of 16 bits").to_le_bytes()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §3 The composition descriptor
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// A composition descriptor (§3.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Compose {
|
||
/// The room id (live-rooms §3.2).
|
||
pub room: [u8; 32],
|
||
/// The key that signs it: the room authority, or a principal with the compose power.
|
||
pub signer: [u8; 32],
|
||
/// The room-clock instant from which it applies.
|
||
pub effective: u64,
|
||
/// The principal that may publish the composed tracks; [`NO_COMPOSITOR`] for none.
|
||
pub compositor: [u8; 32],
|
||
/// sRGB, 8 bits each of red, green and blue.
|
||
pub background: [u8; 3],
|
||
/// The highest layer composed (§4.2).
|
||
pub layer: u8,
|
||
/// Everything else, as the layout reads it.
|
||
pub params: Params,
|
||
pub sig: [u8; 64],
|
||
}
|
||
|
||
impl Compose {
|
||
/// The descriptor without `sig` (§3.1).
|
||
pub fn body(&self) -> Vec<u8> {
|
||
let p = &self.params;
|
||
let mut o = Vec::with_capacity(256);
|
||
o.extend_from_slice(&DESCRIPTOR_MAGIC);
|
||
o.push(RECORD_VERSION);
|
||
o.extend_from_slice(&self.room);
|
||
o.extend_from_slice(&self.signer);
|
||
o.extend_from_slice(&self.effective.to_le_bytes());
|
||
o.extend_from_slice(&self.compositor);
|
||
o.extend_from_slice(&u16_of(p.width));
|
||
o.extend_from_slice(&u16_of(p.height));
|
||
o.push(p.fps);
|
||
o.extend_from_slice(&self.background);
|
||
o.push(self.layer);
|
||
o.push(p.layout);
|
||
o.extend_from_slice(&u16_of(p.gap));
|
||
o.push(p.aspect);
|
||
o.push(p.fill);
|
||
put_short(&mut o, p.featured_attr.as_bytes());
|
||
put_short(&mut o, p.order_attr.as_bytes());
|
||
o.push(p.omit_stopped as u8);
|
||
put_short(&mut o, p.pip_attr.as_bytes());
|
||
o.push(p.corner);
|
||
o.extend_from_slice(&u16_of(p.pip_width));
|
||
o.extend_from_slice(&u16_of(p.pip_height));
|
||
o.extend_from_slice(&u16_of(p.offset));
|
||
o.push(p.behaviour);
|
||
o.push(p.channels);
|
||
o.extend_from_slice(&p.sample_rate.to_le_bytes());
|
||
put_short(&mut o, p.gain_attr.as_bytes());
|
||
put_short(&mut o, p.pan_attr.as_bytes());
|
||
o.push(u8::try_from(p.max_tiles).expect("max_tiles fits a byte"));
|
||
o.push(u8::try_from(p.max_voices).expect("max_voices fits a byte"));
|
||
o
|
||
}
|
||
|
||
pub fn to_bytes(&self) -> Vec<u8> {
|
||
let mut o = self.body();
|
||
o.extend_from_slice(&self.sig);
|
||
o
|
||
}
|
||
|
||
/// The descriptor's digest (§3.2): SHA-256 over [`Compose::body`].
|
||
pub fn digest(&self) -> [u8; 32] {
|
||
sha256(&[&self.body()])
|
||
}
|
||
|
||
/// Signed under `key`; `signer` is left as it is.
|
||
pub fn sign(mut self, key: &SigningKey) -> Compose {
|
||
let mut m = DESCRIPTOR_DOMAIN.to_vec();
|
||
m.extend_from_slice(&self.body());
|
||
self.sig = key.sign(&m).to_bytes();
|
||
self
|
||
}
|
||
|
||
/// Read a descriptor (§3.3): the record, its canvas, its field rules, its signature.
|
||
pub fn parse(b: &[u8]) -> Result<Compose, Reason> {
|
||
read_head(b, &DESCRIPTOR_MAGIC)?;
|
||
let mut r = Rd { b, p: 5 };
|
||
let room = r.arr()?;
|
||
let signer = r.arr()?;
|
||
let effective = r.u64()?;
|
||
let compositor = r.arr()?;
|
||
let width = r.u16()? as u32;
|
||
let height = r.u16()? as u32;
|
||
let fps = r.u8()?;
|
||
let background = r.arr()?;
|
||
let layer = r.u8()?;
|
||
let layout = r.u8()?;
|
||
let gap = r.u16()? as u32;
|
||
let aspect = r.u8()?;
|
||
let fill = r.u8()?;
|
||
let featured = r.short()?;
|
||
let order = r.short()?;
|
||
let omit = r.u8()?;
|
||
let pip_attr = r.short()?;
|
||
let corner = r.u8()?;
|
||
let pip_width = r.u16()? as u32;
|
||
let pip_height = r.u16()? as u32;
|
||
let offset = r.u16()? as u32;
|
||
let behaviour = r.u8()?;
|
||
let channels = r.u8()?;
|
||
let sample_rate = r.u32()?;
|
||
let gain = r.short()?;
|
||
let pan = r.short()?;
|
||
let max_tiles = r.u8()? as u32;
|
||
let max_voices = r.u8()? as u32;
|
||
let sig = r.arr()?;
|
||
if r.p != b.len() {
|
||
return Err(MALFORMED);
|
||
}
|
||
let keys = [featured, order, pip_attr, gain, pan];
|
||
let keys_ok = keys.iter().all(|k| k.is_empty() || layout::key_ok(k));
|
||
let s = |k: &[u8]| String::from_utf8_lossy(k).into_owned();
|
||
let params = Params {
|
||
width,
|
||
height,
|
||
fps,
|
||
layout,
|
||
gap,
|
||
aspect,
|
||
fill,
|
||
featured_attr: s(featured),
|
||
order_attr: s(order),
|
||
omit_stopped: omit == 1,
|
||
pip_attr: s(pip_attr),
|
||
corner,
|
||
pip_width,
|
||
pip_height,
|
||
offset,
|
||
behaviour,
|
||
channels,
|
||
sample_rate,
|
||
gain_attr: s(gain),
|
||
pan_attr: s(pan),
|
||
max_tiles,
|
||
max_voices,
|
||
};
|
||
params.check_canvas()?;
|
||
if !keys_ok || omit > 1 {
|
||
return Err(Reason::ComposeInvalid);
|
||
}
|
||
params.check_fields()?;
|
||
let c = Compose { room, signer, effective, compositor, background, layer, params, sig };
|
||
verify_sig(&c.signer, DESCRIPTOR_DOMAIN, &b[..b.len() - 64], &c.sig)?;
|
||
debug_assert_eq!(c.body(), b[..b.len() - 64], "a descriptor that reads re-encodes to its bytes");
|
||
Ok(c)
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §4 The participant set
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// A room as live-rooms §9 is given it.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct RoomInput<'a> {
|
||
pub descriptor: &'a [u8],
|
||
pub grants: &'a [Vec<u8>],
|
||
pub head: Option<&'a str>,
|
||
pub chains: &'a [Vec<Vec<u8>>],
|
||
}
|
||
|
||
/// The room at one instant (§4.1): its state, and each present principal's join order.
|
||
#[derive(Clone, Debug)]
|
||
pub struct Instant {
|
||
pub state: RoomState,
|
||
pub joins: BTreeMap<[u8; 32], u32>,
|
||
}
|
||
|
||
/// A room that verified (§9 step 1), with what composition reads of it.
|
||
pub struct VerifiedRoom<'a> {
|
||
pub input: RoomInput<'a>,
|
||
pub run: RoomRun,
|
||
pub d: Descriptor,
|
||
pub id: [u8; 32],
|
||
}
|
||
|
||
impl<'a> VerifiedRoom<'a> {
|
||
/// live-rooms §9 over the room: its refusal; `lifecycle_incomplete` when a room key's
|
||
/// chain has a gap (§4.1). A gap in another principal's chain is not a refusal here.
|
||
pub fn verify(input: RoomInput<'a>) -> Result<VerifiedRoom<'a>, (Place, Reason)> {
|
||
let run = verify_room(input.descriptor, input.grants, input.head, input.chains);
|
||
if let RoomOutcome::Refuse { at, reason } = run.outcome {
|
||
return Err((Place::Room(Some(at)), reason.into()));
|
||
}
|
||
let d = run.descriptor.clone().expect("a room that is not refused read its descriptor");
|
||
if let RoomOutcome::Gap(gaps) = &run.outcome
|
||
&& gaps.iter().any(|g| d.is_room_key(&g.principal))
|
||
{
|
||
return Err((Place::Room(None), Reason::LifecycleIncomplete));
|
||
}
|
||
let id = d.room_id();
|
||
Ok(VerifiedRoom { input, run, d, id })
|
||
}
|
||
|
||
/// The grant a digest names.
|
||
pub fn grant(&self, digest: &[u8; 32]) -> Option<&Grant> {
|
||
self.run.grants.iter().find(|h| h.digest == *digest).map(|h| &h.grant)
|
||
}
|
||
|
||
/// The room at `t` (§4.1): live-rooms §9 over each chain's records up to the first
|
||
/// whose `t` is above `t`, and the join order of each `join` among them.
|
||
pub fn at(&self, t: u64) -> Instant {
|
||
let chains: Vec<Vec<Vec<u8>>> = self
|
||
.input
|
||
.chains
|
||
.iter()
|
||
.map(|c| c.iter().take_while(|r| Event::parse(r).is_ok_and(|e| e.t <= t)).cloned().collect())
|
||
.collect();
|
||
let run = verify_room(self.input.descriptor, self.input.grants, self.input.head, &chains);
|
||
let mut joins = BTreeMap::new();
|
||
let mut seen = BTreeSet::new();
|
||
let mut n = 0u32;
|
||
for &(c, i, _) in &run.order {
|
||
let Ok(ev) = Event::parse(&chains[c][i]) else { continue };
|
||
if !seen.insert(ev.digest()) || ev.kind != kind::JOIN || !self.d.is_room_key(&ev.principal) {
|
||
continue;
|
||
}
|
||
if let Ok(Payload::Lifecycle { subject, .. }) = events::parse_payload(ev.kind, &ev.payload, u16::MAX) {
|
||
joins.insert(subject, n);
|
||
n += 1;
|
||
}
|
||
}
|
||
Instant { state: run.state, joins }
|
||
}
|
||
|
||
/// The grant in effect of `p` at `at`, if `p` is present.
|
||
pub fn grant_in_effect(&self, at: &Instant, p: &[u8; 32]) -> Option<&Grant> {
|
||
at.state.present.get(p).and_then(|g| self.grant(g))
|
||
}
|
||
}
|
||
|
||
/// A participant's video or audio source (§4.2).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Source {
|
||
/// Empty for the participant's own track; otherwise the slot it is live in.
|
||
pub slot: String,
|
||
pub media: u8,
|
||
/// The index of its profile in the room's descriptor, and the layers it publishes.
|
||
pub profile: u8,
|
||
pub layers: u8,
|
||
/// The layer composed: the lower of `layers` − 1 and the descriptor's `layer`.
|
||
pub layer: u8,
|
||
pub layering: u8,
|
||
/// That layer's dimensions in the profile; 0 for audio.
|
||
pub width: u16,
|
||
pub height: u16,
|
||
}
|
||
|
||
impl Source {
|
||
/// The tracks a compositor reads it from (§8.2), each as an anchor's `track` digest
|
||
/// (live-rooms §6.2): track 0 for `single` and `svc`, track `layer` for `simulcast`,
|
||
/// and tracks 0 to `layer` for `staged`.
|
||
pub fn tracks(&self, room_id: &[u8; 32], principal: &[u8; 32]) -> Vec<[u8; 32]> {
|
||
let owner = if self.slot.is_empty() { Owner::Principal(*principal) } else { Owner::Slot(self.slot.clone()) };
|
||
let ns = render_namespace(&track_namespace(room_id, &owner, media_name(self.media).expect("a media kind")));
|
||
let layers: Vec<u8> = match self.layering {
|
||
layering::SIMULCAST => vec![self.layer],
|
||
layering::STAGED => (0..=self.layer).collect(),
|
||
_ => vec![0],
|
||
};
|
||
layers.into_iter().map(|l| anchor_track(&ns, &track_name(l))).collect()
|
||
}
|
||
|
||
fn encode(s: Option<&Source>, o: &mut Vec<u8>) {
|
||
match s {
|
||
None => o.push(0),
|
||
Some(s) => {
|
||
o.push(s.media);
|
||
put_short(o, s.slot.as_bytes());
|
||
o.extend_from_slice(&[s.profile, s.layers, s.layer]);
|
||
o.extend_from_slice(&s.width.to_le_bytes());
|
||
o.extend_from_slice(&s.height.to_le_bytes());
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One participant of the set (§4.2).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Participant {
|
||
pub principal: [u8; 32],
|
||
/// The digest of its grant in effect.
|
||
pub grant: [u8; 32],
|
||
pub join: u32,
|
||
/// Its grant's attributes (live-rooms §4.4).
|
||
pub attrs: Vec<(String, String)>,
|
||
pub video: Option<Source>,
|
||
pub audio: Option<Source>,
|
||
}
|
||
|
||
fn source(d: &Descriptor, c: &Compose, held: &Held, slot: &str, m: u8) -> Source {
|
||
let p = &d.profiles[held.profile as usize];
|
||
let layer = (held.layers - 1).min(c.layer);
|
||
let l = &p.layers[layer as usize];
|
||
Source { slot: slot.into(), media: m, profile: held.profile, layers: held.layers, layer, layering: p.layering, width: l.width, height: l.height }
|
||
}
|
||
|
||
/// §4.2: the participant set of `c` at `at`, in ascending order of key.
|
||
pub fn participants(c: &Compose, vr: &VerifiedRoom, at: &Instant) -> Vec<Participant> {
|
||
let st = &at.state;
|
||
let d = &vr.d;
|
||
let live_in = |p: &[u8; 32], kinds: &[u8]| -> Option<Source> {
|
||
d.slots.iter().zip(&st.slots).find_map(|(s, ss)| match ss.live {
|
||
Some(h) if h.principal == *p && kinds.contains(&s.media) => Some(source(d, c, &h, &s.name, s.media)),
|
||
_ => None,
|
||
})
|
||
};
|
||
let own = |p: &[u8; 32], m: u8| st.own.get(&(*p, m)).map(|h| source(d, c, h, "", m));
|
||
let mut out = Vec::new();
|
||
for (p, g) in &st.present {
|
||
if *p == c.compositor {
|
||
continue;
|
||
}
|
||
let publishes = st.own.keys().any(|(q, _)| q == p) || st.slots.iter().any(|s| s.live.is_some_and(|h| h.principal == *p));
|
||
if !publishes {
|
||
continue;
|
||
}
|
||
let video = own(p, media::VIDEO).or_else(|| own(p, media::SCREEN)).or_else(|| live_in(p, &[media::VIDEO, media::SCREEN]));
|
||
let audio = own(p, media::AUDIO).or_else(|| live_in(p, &[media::AUDIO]));
|
||
let attrs = vr.grant(g).map(|g| g.attrs.clone()).unwrap_or_default();
|
||
let join = *at.joins.get(p).expect("a present principal joined");
|
||
out.push(Participant { principal: *p, grant: *g, join, attrs, video, audio });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// §4.5: the digest of the participant set at `t`.
|
||
pub fn set_digest(room_id: &[u8; 32], t: u64, ps: &[Participant]) -> [u8; 32] {
|
||
let mut o = Vec::new();
|
||
o.extend_from_slice(SET_DOMAIN);
|
||
o.extend_from_slice(room_id);
|
||
o.extend_from_slice(&t.to_le_bytes());
|
||
o.extend_from_slice(&(ps.len() as u32).to_le_bytes());
|
||
for p in ps {
|
||
o.extend_from_slice(&p.principal);
|
||
o.extend_from_slice(&p.grant);
|
||
o.extend_from_slice(&p.join.to_le_bytes());
|
||
Source::encode(p.video.as_ref(), &mut o);
|
||
Source::encode(p.audio.as_ref(), &mut o);
|
||
}
|
||
sha256(&[&o])
|
||
}
|
||
|
||
/// The composition at one instant: the set, its digest, and the layout and voices.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Frame {
|
||
pub t: u64,
|
||
pub participants: Vec<Participant>,
|
||
pub set_digest: [u8; 32],
|
||
pub layout: Layout,
|
||
}
|
||
|
||
impl Frame {
|
||
fn participant(&self, p: &[u8; 32]) -> &Participant {
|
||
self.participants.iter().find(|q| q.principal == *p).expect("a tile's participant is in the set")
|
||
}
|
||
|
||
/// §8.2: the inputs a composed track of `m` reads, in order, as (principal, track):
|
||
/// for video, each tile that shows video, in drawing order; for audio, each voice.
|
||
pub fn inputs(&self, room_id: &[u8; 32], m: u8) -> Vec<([u8; 32], [u8; 32])> {
|
||
let mut out = Vec::new();
|
||
let sources: Vec<(&[u8; 32], Option<&Source>)> = if m == media::AUDIO {
|
||
self.layout.voices.iter().map(|v| (&v.principal, self.participant(&v.principal).audio.as_ref())).collect()
|
||
} else {
|
||
self.layout.tiles.iter().filter(|t| t.video.is_some()).map(|t| (&t.principal, self.participant(&t.principal).video.as_ref())).collect()
|
||
};
|
||
for (p, s) in sources {
|
||
for tr in s.expect("a composed source").tracks(room_id, p) {
|
||
out.push((*p, tr));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
}
|
||
|
||
/// §4, §5 at `t` over the room as it is at `at`.
|
||
fn frame_at(c: &Compose, vr: &VerifiedRoom, t: u64, at: &Instant) -> Result<Frame, Reason> {
|
||
if t < c.effective {
|
||
return Err(Reason::NotYetEffective);
|
||
}
|
||
let participants = participants(c, vr, at);
|
||
let entrants: Vec<Entrant> = participants
|
||
.iter()
|
||
.map(|p| Entrant {
|
||
principal: p.principal,
|
||
join: p.join,
|
||
video: p.video.as_ref().map(|s| (s.width as u32, s.height as u32)),
|
||
audio: p.audio.is_some(),
|
||
attrs: &p.attrs,
|
||
})
|
||
.collect();
|
||
let layout = layout::layout(&c.params, &entrants)?;
|
||
Ok(Frame { t, set_digest: set_digest(&vr.id, t, &participants), participants, layout })
|
||
}
|
||
|
||
/// The composition of `c` at `t` (§4, §5).
|
||
pub fn compose_at(c: &Compose, vr: &VerifiedRoom, t: u64) -> Result<Frame, Reason> {
|
||
frame_at(c, vr, t, &vr.at(t))
|
||
}
|
||
|
||
/// §3.3, §3.5: a descriptor read, of this room, signed by its authority or by a principal
|
||
/// whose grant in effect at its `effective` has the compose power.
|
||
pub fn check_descriptor(b: &[u8], vr: &VerifiedRoom) -> Result<Compose, Reason> {
|
||
let c = Compose::parse(b)?;
|
||
if c.room != vr.id {
|
||
return Err(WRONG_ROOM);
|
||
}
|
||
if c.signer != vr.d.authority {
|
||
let at = vr.at(c.effective);
|
||
if !vr.grant_in_effect(&at, &c.signer).is_some_and(|g| g.caps.powers & power::COMPOSE != 0) {
|
||
return Err(Reason::ComposeNotPermitted);
|
||
}
|
||
}
|
||
Ok(c)
|
||
}
|
||
|
||
/// The outcome of composing a room at a list of instants.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum ComposeOutcome {
|
||
Frames(Vec<Frame>),
|
||
Refuse { at: Place, reason: Reason },
|
||
}
|
||
|
||
/// §9: the room, the descriptor, then the composition at each instant.
|
||
pub fn verify_composition(room: RoomInput, descriptor: &[u8], instants: &[u64]) -> (ComposeOutcome, Option<[u8; 32]>) {
|
||
let vr = match VerifiedRoom::verify(room) {
|
||
Ok(v) => v,
|
||
Err((at, reason)) => return (ComposeOutcome::Refuse { at, reason }, None),
|
||
};
|
||
let c = match check_descriptor(descriptor, &vr) {
|
||
Ok(c) => c,
|
||
Err(reason) => return (ComposeOutcome::Refuse { at: Place::Descriptor, reason }, None),
|
||
};
|
||
let mut frames = Vec::with_capacity(instants.len());
|
||
for &t in instants {
|
||
match compose_at(&c, &vr, t) {
|
||
Ok(f) => frames.push(f),
|
||
Err(reason) => return (ComposeOutcome::Refuse { at: Place::Set(t), reason }, Some(c.digest())),
|
||
}
|
||
}
|
||
(ComposeOutcome::Frames(frames), Some(c.digest()))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §8 The composition receipt
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// One MoQ object (§8.1): its track, as an anchor's `track` digest, its Location, and the
|
||
/// SHA-256 of its payload.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct ObjectRef {
|
||
pub track: [u8; 32],
|
||
pub group: u64,
|
||
pub object: u64,
|
||
pub digest: [u8; 32],
|
||
}
|
||
|
||
/// One input object, and the participant it is read from.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub struct InputRef {
|
||
pub principal: [u8; 32],
|
||
pub object: ObjectRef,
|
||
}
|
||
|
||
/// A composition receipt (§8.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Receipt {
|
||
pub room: [u8; 32],
|
||
pub compositor: [u8; 32],
|
||
/// The descriptor's digest.
|
||
pub descriptor: [u8; 32],
|
||
/// The room-clock instant of the output object.
|
||
pub t: u64,
|
||
/// The participant set's digest at `t` (§4.5).
|
||
pub state: [u8; 32],
|
||
/// 1 audio (a mix) or 2 video (a layout).
|
||
pub media: u8,
|
||
pub output: ObjectRef,
|
||
pub inputs: Vec<InputRef>,
|
||
pub sig: [u8; 64],
|
||
}
|
||
|
||
fn put_object(o: &mut Vec<u8>, r: &ObjectRef) {
|
||
o.extend_from_slice(&r.track);
|
||
o.extend_from_slice(&r.group.to_le_bytes());
|
||
o.extend_from_slice(&r.object.to_le_bytes());
|
||
o.extend_from_slice(&r.digest);
|
||
}
|
||
|
||
fn read_object(r: &mut Rd) -> Result<ObjectRef, Reason> {
|
||
Ok(ObjectRef { track: r.arr()?, group: r.u64()?, object: r.u64()?, digest: r.arr()? })
|
||
}
|
||
|
||
impl Receipt {
|
||
/// The receipt without `sig` (§8.1).
|
||
pub fn body(&self) -> Vec<u8> {
|
||
let mut o = Vec::new();
|
||
o.extend_from_slice(&RECEIPT_MAGIC);
|
||
o.push(RECORD_VERSION);
|
||
o.extend_from_slice(&self.room);
|
||
o.extend_from_slice(&self.compositor);
|
||
o.extend_from_slice(&self.descriptor);
|
||
o.extend_from_slice(&self.t.to_le_bytes());
|
||
o.extend_from_slice(&self.state);
|
||
o.push(self.media);
|
||
put_object(&mut o, &self.output);
|
||
assert!(self.inputs.len() <= u8::MAX as usize, "at most 255 inputs");
|
||
o.push(self.inputs.len() as u8);
|
||
for i in &self.inputs {
|
||
o.extend_from_slice(&i.principal);
|
||
put_object(&mut o, &i.object);
|
||
}
|
||
o
|
||
}
|
||
|
||
pub fn to_bytes(&self) -> Vec<u8> {
|
||
let mut o = self.body();
|
||
o.extend_from_slice(&self.sig);
|
||
o
|
||
}
|
||
|
||
/// The receipt's digest (§8.1).
|
||
pub fn digest(&self) -> [u8; 32] {
|
||
sha256(&[&self.body()])
|
||
}
|
||
|
||
/// Signed under `key`; `compositor` is left as it is.
|
||
pub fn sign(mut self, key: &SigningKey) -> Receipt {
|
||
let mut m = RECEIPT_DOMAIN.to_vec();
|
||
m.extend_from_slice(&self.body());
|
||
self.sig = key.sign(&m).to_bytes();
|
||
self
|
||
}
|
||
|
||
/// Read a receipt (§8.3 step 1): the record, its rules, its signature.
|
||
pub fn parse(b: &[u8]) -> Result<Receipt, Reason> {
|
||
read_head(b, &RECEIPT_MAGIC)?;
|
||
let mut r = Rd { b, p: 5 };
|
||
let room = r.arr()?;
|
||
let compositor = r.arr()?;
|
||
let descriptor = r.arr()?;
|
||
let t = r.u64()?;
|
||
let state = r.arr()?;
|
||
let media = r.u8()?;
|
||
let output = read_object(&mut r)?;
|
||
let n = r.u8()? as usize;
|
||
let mut inputs = Vec::with_capacity(n);
|
||
for _ in 0..n {
|
||
inputs.push(InputRef { principal: r.arr()?, object: read_object(&mut r)? });
|
||
}
|
||
let sig = r.arr()?;
|
||
if r.p != b.len() {
|
||
return Err(MALFORMED);
|
||
}
|
||
let mut pairs = BTreeSet::new();
|
||
if !matches!(media, media::AUDIO | media::VIDEO) || !inputs.iter().all(|i| pairs.insert((i.principal, i.object.track))) {
|
||
return Err(Reason::ReceiptInvalid);
|
||
}
|
||
let rc = Receipt { room, compositor, descriptor, t, state, media, output, inputs, sig };
|
||
verify_sig(&rc.compositor, RECEIPT_DOMAIN, &b[..b.len() - 64], &rc.sig)?;
|
||
Ok(rc)
|
||
}
|
||
}
|
||
|
||
/// The outcome of verifying a receipt (§8.3).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum ReceiptOutcome {
|
||
Accept { digest: [u8; 32], frame: Frame },
|
||
Refuse { at: Place, reason: Reason },
|
||
}
|
||
|
||
/// §8.3: a receipt checked against the room and the descriptor it names.
|
||
pub fn verify_receipt(room: RoomInput, descriptor: &[u8], receipt: &[u8]) -> ReceiptOutcome {
|
||
let refuse = |at, reason| ReceiptOutcome::Refuse { at, reason };
|
||
let vr = match VerifiedRoom::verify(room) {
|
||
Ok(v) => v,
|
||
Err((at, reason)) => return refuse(at, reason),
|
||
};
|
||
let c = match check_descriptor(descriptor, &vr) {
|
||
Ok(c) => c,
|
||
Err(reason) => return refuse(Place::Descriptor, reason),
|
||
};
|
||
let r = match Receipt::parse(receipt) {
|
||
Ok(r) => r,
|
||
Err(reason) => return refuse(Place::Receipt, reason),
|
||
};
|
||
if r.room != vr.id {
|
||
return refuse(Place::Receipt, WRONG_ROOM);
|
||
}
|
||
if r.descriptor != c.digest() {
|
||
return refuse(Place::Receipt, Reason::DescriptorMismatch);
|
||
}
|
||
if r.t < c.effective {
|
||
return refuse(Place::Receipt, Reason::NotYetEffective);
|
||
}
|
||
let at = vr.at(r.t);
|
||
let composes = vr.grant_in_effect(&at, &r.compositor).is_some_and(|g| g.caps.powers & power::COMPOSE != 0);
|
||
if c.compositor == NO_COMPOSITOR || r.compositor != c.compositor || !composes {
|
||
return refuse(Place::Receipt, Reason::CompositorNotPermitted);
|
||
}
|
||
let ns = render_namespace(&track_namespace(&vr.id, &Owner::Principal(r.compositor), media_name(r.media).expect("a media kind")));
|
||
let published = at.state.own.get(&(r.compositor, r.media)).is_some_and(|h| (0..h.layers).any(|l| anchor_track(&ns, &track_name(l)) == r.output.track));
|
||
if !published {
|
||
return refuse(Place::Receipt, Reason::OutputNotPublished);
|
||
}
|
||
let frame = match frame_at(&c, &vr, r.t, &at) {
|
||
Ok(f) => f,
|
||
Err(reason) => return refuse(Place::Set(r.t), reason),
|
||
};
|
||
if r.state != frame.set_digest {
|
||
return refuse(Place::Receipt, Reason::StateMismatch);
|
||
}
|
||
let stated: Vec<([u8; 32], [u8; 32])> = r.inputs.iter().map(|i| (i.principal, i.object.track)).collect();
|
||
if stated != frame.inputs(&vr.id, r.media) {
|
||
return refuse(Place::Receipt, Reason::InputsMismatch);
|
||
}
|
||
ReceiptOutcome::Accept { digest: r.digest(), frame }
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
/// The corpus regenerates byte for byte, and this module gives every case of it the
|
||
/// outcome it states.
|
||
#[test]
|
||
fn corpus_round_trip() {
|
||
let doc = corpus::generate();
|
||
let fails = corpus::verify(&doc);
|
||
assert!(fails.is_empty(), "{}", fails.join("\n"));
|
||
}
|
||
|
||
/// A descriptor re-encodes to the bytes it was read from, and one bit of its body
|
||
/// changed fails its signature.
|
||
#[test]
|
||
fn descriptor_round_trip() {
|
||
let key = SigningKey::from_bytes(&[7; 32]);
|
||
let c = Compose {
|
||
room: [1; 32],
|
||
signer: key.verifying_key().to_bytes(),
|
||
effective: 0,
|
||
compositor: NO_COMPOSITOR,
|
||
background: [0, 0, 0],
|
||
layer: 2,
|
||
params: corpus::grid_params(),
|
||
sig: [0; 64],
|
||
}
|
||
.sign(&key);
|
||
let b = c.to_bytes();
|
||
assert_eq!(Compose::parse(&b), Ok(c));
|
||
let mut bad = b.clone();
|
||
bad[40] ^= 1;
|
||
assert_eq!(Compose::parse(&bad), Err(events::Reason::BadSignature.into()));
|
||
assert_eq!(Compose::parse(&b[..b.len() - 1]), Err(TRUNCATED));
|
||
}
|
||
}
|