A principal other than the room authority may sign WRM1 only under a grant with the record power in effect over the whole span: effective at or before its start, with no leave, grant_exchange or expiry taking the power away before its end. A refusal keeps the code recording_signer_not_permitted and states the first instant of the span without the power, at ["signer", t]. Since a recording holds the room's chains whole, the span now binds the takes the package holds (each start and closing commitment within it) and the takes complete counts, not every context record, so a recorder that gained the power partway seals a span that starts at its grant. New vectors: late_recorder_span (stands), signer_power_late, signer_removed, signer_power_exchanged, signer_grant_expired and signer_grant_revoked (refused at the room: a key-log revocation is not bounded by a time). Co-Authored-By: Claude Opus 5.5 <[email protected]>
1528 lines
61 KiB
Rust
1528 lines
61 KiB
Rust
//! Live recording — `wai.live.recording` ([extensions/live-recording.md]).
|
||
//!
|
||
//! A finished room session becomes one WAI package: a directory of files and its `LISTING`
|
||
//! (SPEC §3.1), as a package of [packaging] §11.1 is, whose components are the room (its
|
||
//! descriptor, grants and key-log head), its events (each principal's chain whole), one
|
||
//! component per participant take (its `WTM1` manifest, the chunks the package holds, the
|
||
//! store's receipts and the consents), and, optionally, the composite. Each component is
|
||
//! named as SPEC §7.1 names one, by an `id` and a `sha256`, the SHA-256 of the listing of its
|
||
//! files; a signed recording manifest (`WRM1`) lists them with the signer, the room id and
|
||
//! the session span on the room clock. A recording verifier gives `complete` and
|
||
//! `publishable` for each purpose their verdicts.
|
||
//!
|
||
//! A **clip** is a span of one take, or an aligned span of several takes, cut as a chunk
|
||
//! range or a byte range. Its signed clip record (`WCL1`) carries, for each take, the
|
||
//! descriptors of the chunks it covers and one range proof (an RFC 6962 audit path taken
|
||
//! over a run of leaves, [`crate::output_tree::range_proof`]) against a commitment the
|
||
//! device made while it captured. A clip verifier needs the clip's bytes, the record, the
|
||
//! take's commitments up to that one, the device's key and the room descriptor: never the
|
||
//! take manifest or any other chunk.
|
||
//!
|
||
//! What this module implements, by section of the extension:
|
||
//!
|
||
//! - §3, §4: the package's files, its components and their digests, the recording
|
||
//! manifest, and recording verification over [`crate::live_room::verify_room`] and
|
||
//! [`crate::capture_take::verify_take`];
|
||
//! - §5: the clip record, cutting a clip by chunks, by bytes or by a room-clock window, and
|
||
//! clip verification;
|
||
//! - §6: the time correspondence of an aligned clip and its bound;
|
||
//! - §8: the MoQ addressing of a recorded take's chunks for a FETCH.
|
||
//!
|
||
//! The HLS and LL-CMAF mapping of §7 is specified and not implemented: a take's master is
|
||
//! the device's bytes, and no muxer for them is in this crate.
|
||
//!
|
||
//! The corpus `live-conformance/recordings/` is written by [`corpus::generate`] and checked
|
||
//! by [`corpus::verify`] here, and by `live-conformance/live_record_verify.py`, which shares
|
||
//! no code with this module.
|
||
//!
|
||
//! [extensions/live-recording.md]: ../../extensions/live-recording.md
|
||
//! [packaging]: ../../extensions/packaging.md
|
||
|
||
use std::collections::{BTreeMap, BTreeSet};
|
||
|
||
use ed25519_dalek::{Signer, SigningKey};
|
||
use sha2::{Digest, Sha256};
|
||
|
||
use crate::capture_take::{self as take, extends, leaves, phase, purpose, take_id, Chunk, Commit, TakeInputs, TakeOutcome, CHUNK_LEN};
|
||
use crate::live_events::{self as events, Command, Event, Payload};
|
||
use crate::live_room::{self as room, check_key_log, power, verify_room, At, Descriptor, Detail, KeyLogState, RoomOutcome, RoomRun};
|
||
use crate::output_tree::{range_proof, verify_range, OutputCommitment};
|
||
|
||
pub mod corpus;
|
||
|
||
/// The record this extension registers.
|
||
pub const CAPABILITY: &str = "wai.live.recording";
|
||
/// The magic of a recording manifest (§4.1).
|
||
pub const MANIFEST_MAGIC: [u8; 4] = *b"WRM1";
|
||
/// The magic of a clip record (§5.1).
|
||
pub const CLIP_MAGIC: [u8; 4] = *b"WCL1";
|
||
/// The version of the two records this revision defines.
|
||
pub const RECORD_VERSION: u8 = 1;
|
||
/// The domain a recording manifest's signature is made over (§4.1).
|
||
pub const MANIFEST_DOMAIN: &[u8] = b"wai:live-recording\x01";
|
||
/// The domain a clip record's signature is made over (§5.1).
|
||
pub const CLIP_DOMAIN: &[u8] = b"wai:live-clip\x01";
|
||
/// The most takes one clip spans (§5.2).
|
||
pub const MAX_PARTS: usize = 16;
|
||
/// The most nodes a part's range proof holds (§5.3).
|
||
pub const MAX_RANGE_PROOF: usize = 128;
|
||
/// The package's listing (§3.1).
|
||
pub const LISTING: &str = "LISTING";
|
||
/// The package's recording manifest (§3.1).
|
||
pub const MANIFEST_FILE: &str = "recording.wrm";
|
||
/// The third Track Namespace field of a recorded take's track (§8).
|
||
pub const TAKE_TRACK: &str = "take";
|
||
|
||
/// A component's kind (§3.2).
|
||
pub mod component {
|
||
pub const ROOM: u8 = 1;
|
||
pub const EVENTS: u8 = 2;
|
||
pub const TAKE: u8 = 3;
|
||
pub const COMPOSITE: u8 = 4;
|
||
}
|
||
|
||
/// The claims a recording manifest makes (§4.1), each a bit.
|
||
pub mod claim {
|
||
pub const COMPLETE: u8 = 1;
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §9 Reasons
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// Why a recording or a clip is refused, or why a recording's claim fails (§9).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||
pub enum Reason {
|
||
/// A code of capture-binding §12, or through it of live-rooms §11 and live-events §9.
|
||
Take(take::Reason),
|
||
ListingMismatch,
|
||
RecordingManifestInvalid,
|
||
ComponentInvalid,
|
||
ComponentDigestMismatch,
|
||
ComponentUnlisted,
|
||
RecordingSignerNotPermitted,
|
||
OutsideSpan,
|
||
TakeOmitted,
|
||
ClipRecordInvalid,
|
||
ClipDeviceMismatch,
|
||
ClipTakeMismatch,
|
||
TreeHeadNotCommitted,
|
||
ClipOutOfRange,
|
||
ClipNotIncluded,
|
||
ClipWindowInvalid,
|
||
ClipChunkMissing,
|
||
}
|
||
|
||
impl From<take::Reason> for Reason {
|
||
fn from(r: take::Reason) -> Self {
|
||
Reason::Take(r)
|
||
}
|
||
}
|
||
|
||
impl From<room::Reason> for Reason {
|
||
fn from(r: room::Reason) -> Self {
|
||
Reason::Take(take::Reason::Room(r))
|
||
}
|
||
}
|
||
|
||
impl From<events::Reason> for Reason {
|
||
fn from(r: events::Reason) -> Self {
|
||
Reason::Take(take::Reason::Room(room::Reason::Events(r)))
|
||
}
|
||
}
|
||
|
||
const TRUNCATED: Reason = Reason::Take(take::Reason::Room(room::Reason::Events(events::Reason::Truncated)));
|
||
const MALFORMED: Reason = Reason::Take(take::Reason::Room(room::Reason::Events(events::Reason::Malformed)));
|
||
const UNKNOWN_VERSION: Reason = Reason::Take(take::Reason::Room(room::Reason::Events(events::Reason::UnknownVersion)));
|
||
const WRONG_ROOM: Reason = Reason::Take(take::Reason::Room(room::Reason::Events(events::Reason::WrongRoom)));
|
||
/// `delivery_gap`.
|
||
pub const DELIVERY_GAP: Reason = Reason::Take(take::DELIVERY_GAP);
|
||
|
||
impl Reason {
|
||
/// The codes this extension adds, in §9's order.
|
||
pub const ALL: [Reason; 16] = [
|
||
Reason::ListingMismatch,
|
||
Reason::RecordingManifestInvalid,
|
||
Reason::ComponentInvalid,
|
||
Reason::ComponentDigestMismatch,
|
||
Reason::ComponentUnlisted,
|
||
Reason::RecordingSignerNotPermitted,
|
||
Reason::OutsideSpan,
|
||
Reason::TakeOmitted,
|
||
Reason::ClipRecordInvalid,
|
||
Reason::ClipDeviceMismatch,
|
||
Reason::ClipTakeMismatch,
|
||
Reason::TreeHeadNotCommitted,
|
||
Reason::ClipOutOfRange,
|
||
Reason::ClipNotIncluded,
|
||
Reason::ClipWindowInvalid,
|
||
Reason::ClipChunkMissing,
|
||
];
|
||
|
||
/// The reason's code.
|
||
pub fn code(self) -> &'static str {
|
||
match self {
|
||
Reason::Take(r) => r.code(),
|
||
Reason::ListingMismatch => "listing_mismatch",
|
||
Reason::RecordingManifestInvalid => "recording_manifest_invalid",
|
||
Reason::ComponentInvalid => "component_invalid",
|
||
Reason::ComponentDigestMismatch => "component_digest_mismatch",
|
||
Reason::ComponentUnlisted => "component_unlisted",
|
||
Reason::RecordingSignerNotPermitted => "recording_signer_not_permitted",
|
||
Reason::OutsideSpan => "outside_span",
|
||
Reason::TakeOmitted => "take_omitted",
|
||
Reason::ClipRecordInvalid => "clip_record_invalid",
|
||
Reason::ClipDeviceMismatch => "clip_device_mismatch",
|
||
Reason::ClipTakeMismatch => "clip_take_mismatch",
|
||
Reason::TreeHeadNotCommitted => "tree_head_not_committed",
|
||
Reason::ClipOutOfRange => "clip_out_of_range",
|
||
Reason::ClipNotIncluded => "clip_not_included",
|
||
Reason::ClipWindowInvalid => "clip_window_invalid",
|
||
Reason::ClipChunkMissing => "clip_chunk_missing",
|
||
}
|
||
}
|
||
}
|
||
|
||
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 {}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// Reading
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
fn sha256(parts: &[&[u8]]) -> [u8; 32] {
|
||
let mut h = Sha256::new();
|
||
for p in parts {
|
||
h.update(p);
|
||
}
|
||
h.finalize().into()
|
||
}
|
||
|
||
fn hex(b: &[u8]) -> String {
|
||
b.iter().map(|x| format!("{x:02x}")).collect()
|
||
}
|
||
|
||
/// A cursor whose 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()?))
|
||
}
|
||
fn done(&self) -> bool {
|
||
self.p == self.b.len()
|
||
}
|
||
}
|
||
|
||
/// live-events §3.3's first 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 sign(key: &SigningKey, domain: &[u8], body: &[u8]) -> [u8; 64] {
|
||
let mut m = domain.to_vec();
|
||
m.extend_from_slice(body);
|
||
key.sign(&m).to_bytes()
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §3 The recording package
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// Whether `id` has SPEC §7.1's form: 1 to 64 of `a`–`z`, `0`–`9`, `_` and `-`.
|
||
pub fn id_ok(id: &[u8]) -> bool {
|
||
(1..=64).contains(&id.len()) && id.iter().all(|&c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'_' || c == b'-')
|
||
}
|
||
|
||
fn is_hex64(id: &[u8]) -> bool {
|
||
id.len() == 64 && id.iter().all(|&c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c))
|
||
}
|
||
|
||
/// One principal's chain as a file of the events component (§3.3, live-events §8.6): its
|
||
/// records concatenated in its stream order.
|
||
pub fn chain_file(records: &[Vec<u8>]) -> Vec<u8> {
|
||
records.concat()
|
||
}
|
||
|
||
/// The length of the `WEV1` record that begins `b`, from its own fields (live-events §3.1).
|
||
fn record_len(b: &[u8]) -> Option<usize> {
|
||
// magic, version, room, principal, seq, prev, t: 117 bytes; then the anchor flag.
|
||
let flag = *b.get(117)?;
|
||
let after = match flag {
|
||
0 => 118,
|
||
1 => 118 + 48,
|
||
_ => return None,
|
||
};
|
||
let len = u16::from_le_bytes(b.get(after + 1..after + 3)?.try_into().ok()?) as usize;
|
||
let total = after + 3 + len + 64;
|
||
(total <= b.len()).then_some(total)
|
||
}
|
||
|
||
/// Split a chain file into its records (§3.3), each of which reads (live-events §3.3).
|
||
/// `None` when the bytes are not records end to end.
|
||
pub fn split_chain(mut b: &[u8]) -> Option<Vec<Vec<u8>>> {
|
||
let mut out = Vec::new();
|
||
while !b.is_empty() {
|
||
let n = record_len(b)?;
|
||
Event::parse(&b[..n]).ok()?;
|
||
out.push(b[..n].to_vec());
|
||
b = &b[n..];
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// The directory of a component (§3.2): `room/`, `events/`, `takes/<take id>/` or
|
||
/// `composite/`.
|
||
pub fn component_dir(kind: u8, id: &str) -> String {
|
||
match kind {
|
||
component::ROOM => "room/".into(),
|
||
component::EVENTS => "events/".into(),
|
||
component::TAKE => format!("takes/{id}/"),
|
||
_ => "composite/".into(),
|
||
}
|
||
}
|
||
|
||
/// The listing (SPEC §3.1) of the files below `dir`, by their paths below it; `None` when
|
||
/// none is there, or one is a path a listing cannot carry.
|
||
pub fn listing_below(files: &BTreeMap<String, Vec<u8>>, dir: &str) -> Option<Vec<u8>> {
|
||
let inner: BTreeMap<String, &[u8]> =
|
||
files.iter().filter_map(|(p, b)| p.strip_prefix(dir).map(|rest| (rest.to_string(), b.as_slice()))).collect();
|
||
if inner.is_empty() {
|
||
return None;
|
||
}
|
||
crate::pin::listing_from(&inner).ok()
|
||
}
|
||
|
||
/// The listing of every file of a package but `LISTING` itself (§3.1).
|
||
pub fn package_listing(files: &BTreeMap<String, Vec<u8>>) -> Option<Vec<u8>> {
|
||
let others: BTreeMap<String, &[u8]> = files.iter().filter(|(p, _)| p.as_str() != LISTING).map(|(p, b)| (p.clone(), b.as_slice())).collect();
|
||
crate::pin::listing_from(&others).ok()
|
||
}
|
||
|
||
/// The name of a room grant's file (§3.3).
|
||
pub fn grant_file(k: usize) -> String {
|
||
format!("room/grant-{k:04}.wrg")
|
||
}
|
||
|
||
/// The name of a chunk's file in a take component (§3.4).
|
||
pub fn chunk_file(take: &[u8; 32], index: u64) -> String {
|
||
format!("takes/{}/chunk-{index:010}.bin", hex(take))
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §4 The recording manifest
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// A component as the recording manifest names it (§4.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Component {
|
||
pub kind: u8,
|
||
pub id: Vec<u8>,
|
||
/// The SHA-256 of the listing of its files.
|
||
pub sha256: [u8; 32],
|
||
}
|
||
|
||
impl Component {
|
||
/// Its id as text (an id that reads is ASCII).
|
||
pub fn id_str(&self) -> &str {
|
||
std::str::from_utf8(&self.id).unwrap_or("")
|
||
}
|
||
|
||
/// Its directory.
|
||
pub fn dir(&self) -> String {
|
||
component_dir(self.kind, self.id_str())
|
||
}
|
||
}
|
||
|
||
/// A recording manifest (§4.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct RecordingManifest {
|
||
pub room: [u8; 32],
|
||
pub signer: [u8; 32],
|
||
/// The session span on the room clock, in microseconds: `from` ≤ `t` ≤ `to`.
|
||
pub from: u64,
|
||
pub to: u64,
|
||
pub claims: u8,
|
||
pub purposes: u8,
|
||
/// Where the package is kept: ISO 3166-1 alpha-2.
|
||
pub region: [u8; 2],
|
||
pub components: Vec<Component>,
|
||
pub sig: [u8; 64],
|
||
}
|
||
|
||
impl RecordingManifest {
|
||
/// The bytes its digest and signature cover.
|
||
pub fn body(&self) -> Vec<u8> {
|
||
let mut o = Vec::new();
|
||
o.extend_from_slice(&MANIFEST_MAGIC);
|
||
o.push(RECORD_VERSION);
|
||
o.extend_from_slice(&self.room);
|
||
o.extend_from_slice(&self.signer);
|
||
o.extend_from_slice(&self.from.to_le_bytes());
|
||
o.extend_from_slice(&self.to.to_le_bytes());
|
||
o.push(self.claims);
|
||
o.push(self.purposes);
|
||
o.extend_from_slice(&self.region);
|
||
assert!(self.components.len() <= u16::MAX as usize);
|
||
o.extend_from_slice(&(self.components.len() as u16).to_le_bytes());
|
||
for c in &self.components {
|
||
o.push(c.kind);
|
||
assert!(c.id.len() <= u8::MAX as usize);
|
||
o.push(c.id.len() as u8);
|
||
o.extend_from_slice(&c.id);
|
||
o.extend_from_slice(&c.sha256);
|
||
}
|
||
o
|
||
}
|
||
|
||
/// The whole record.
|
||
pub fn to_bytes(&self) -> Vec<u8> {
|
||
let mut o = self.body();
|
||
o.extend_from_slice(&self.sig);
|
||
o
|
||
}
|
||
|
||
/// Its digest (§4.1).
|
||
pub fn digest(&self) -> [u8; 32] {
|
||
sha256(&[&self.body()])
|
||
}
|
||
|
||
/// Signed by `key`, whose public key becomes `signer`.
|
||
pub fn sign(mut self, key: &SigningKey) -> RecordingManifest {
|
||
self.signer = key.verifying_key().to_bytes();
|
||
self.sig = sign(key, MANIFEST_DOMAIN, &self.body());
|
||
self
|
||
}
|
||
|
||
fn read(b: &[u8]) -> Result<RecordingManifest, Reason> {
|
||
read_head(b, &MANIFEST_MAGIC)?;
|
||
let mut r = Rd { b, p: 5 };
|
||
let (room, signer) = (r.arr()?, r.arr()?);
|
||
let (from, to) = (r.u64()?, r.u64()?);
|
||
let (claims, purposes) = (r.u8()?, r.u8()?);
|
||
let region = r.arr()?;
|
||
let n = r.u16()? as usize;
|
||
let mut components = Vec::with_capacity(n.min(b.len() / 34));
|
||
for _ in 0..n {
|
||
let kind = r.u8()?;
|
||
let len = r.u8()? as usize;
|
||
let id = r.take(len)?.to_vec();
|
||
components.push(Component { kind, id, sha256: r.arr()? });
|
||
}
|
||
let sig = r.arr()?;
|
||
if !r.done() {
|
||
return Err(MALFORMED);
|
||
}
|
||
Ok(RecordingManifest { room, signer, from, to, claims, purposes, region, components, sig })
|
||
}
|
||
|
||
/// §4.2's rules.
|
||
fn rules_hold(&self) -> bool {
|
||
let c = &self.components;
|
||
let is = |x: &Component, kind: u8, id: &[u8]| x.kind == kind && x.id == id;
|
||
if self.claims & !claim::COMPLETE != 0 || self.purposes & !0b1111 != 0 || self.from > self.to || !self.region.iter().all(u8::is_ascii_uppercase) {
|
||
return false;
|
||
}
|
||
if c.len() < 2 || !is(&c[0], component::ROOM, b"room") || !is(&c[1], component::EVENTS, b"events") {
|
||
return false;
|
||
}
|
||
let mut rest = &c[2..];
|
||
if let Some(last) = rest.last().filter(|x| x.kind == component::COMPOSITE) {
|
||
if last.id != b"composite" {
|
||
return false;
|
||
}
|
||
rest = &rest[..rest.len() - 1];
|
||
}
|
||
rest.iter().all(|x| x.kind == component::TAKE && is_hex64(&x.id)) && rest.windows(2).all(|w| w[0].id < w[1].id)
|
||
}
|
||
|
||
/// Read a recording manifest, hold it to §4.2's rules and verify its signature.
|
||
pub fn parse(b: &[u8]) -> Result<RecordingManifest, Reason> {
|
||
let m = RecordingManifest::read(b)?;
|
||
if !m.rules_hold() {
|
||
return Err(Reason::RecordingManifestInvalid);
|
||
}
|
||
verify_sig(&m.signer, MANIFEST_DOMAIN, &b[..b.len() - 64], &m.sig)?;
|
||
Ok(m)
|
||
}
|
||
}
|
||
|
||
/// The components of a package's files, in §4.2's order, each with its digest: the room,
|
||
/// the events, each take by id, and the composite when `composite/` holds a file.
|
||
pub fn components_of(files: &BTreeMap<String, Vec<u8>>) -> Vec<Component> {
|
||
let digest = |dir: &str| listing_below(files, dir).map(|l| sha256(&[&l])).unwrap_or([0; 32]);
|
||
let mut out = vec![
|
||
Component { kind: component::ROOM, id: b"room".to_vec(), sha256: digest("room/") },
|
||
Component { kind: component::EVENTS, id: b"events".to_vec(), sha256: digest("events/") },
|
||
];
|
||
let takes: BTreeSet<&str> = files.keys().filter_map(|p| p.strip_prefix("takes/")?.split('/').next()).collect();
|
||
for t in takes {
|
||
out.push(Component { kind: component::TAKE, id: t.as_bytes().to_vec(), sha256: digest(&format!("takes/{t}/")) });
|
||
}
|
||
if files.keys().any(|p| p.starts_with("composite/")) {
|
||
out.push(Component { kind: component::COMPOSITE, id: b"composite".to_vec(), sha256: digest("composite/") });
|
||
}
|
||
out
|
||
}
|
||
|
||
/// What a sealer states of a package (§4.1): its room, its session span, its claims and
|
||
/// purposes, and the region it is kept in.
|
||
#[derive(Clone, Copy, Debug)]
|
||
pub struct Sealing {
|
||
pub room: [u8; 32],
|
||
pub from: u64,
|
||
pub to: u64,
|
||
pub claims: u8,
|
||
pub purposes: u8,
|
||
pub region: [u8; 2],
|
||
}
|
||
|
||
/// Seal a package (§3, §4): sign a recording manifest over the components of `files` and
|
||
/// add it and the `LISTING` of every file.
|
||
pub fn seal(mut files: BTreeMap<String, Vec<u8>>, s: &Sealing, signer: &SigningKey) -> BTreeMap<String, Vec<u8>> {
|
||
files.remove(LISTING);
|
||
files.remove(MANIFEST_FILE);
|
||
let m = RecordingManifest {
|
||
room: s.room,
|
||
signer: [0; 32],
|
||
from: s.from,
|
||
to: s.to,
|
||
claims: s.claims,
|
||
purposes: s.purposes,
|
||
region: s.region,
|
||
components: components_of(&files),
|
||
sig: [0; 64],
|
||
}
|
||
.sign(signer);
|
||
files.insert(MANIFEST_FILE.into(), m.to_bytes());
|
||
let listing = package_listing(&files).expect("a package's paths are listing paths");
|
||
files.insert(LISTING.into(), listing);
|
||
files
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §4.4 Recording verification
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// Where a recording verification stopped (§4.4).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Place {
|
||
Listing,
|
||
Manifest,
|
||
/// The component at this index of the manifest.
|
||
Component(usize),
|
||
/// The room verifier's own place.
|
||
Room(At),
|
||
/// The signer, and the first instant of the session span at which it does not hold
|
||
/// the record power (§4.3).
|
||
Signer(u64),
|
||
/// A take verifier's own place, in the take component at this index.
|
||
Take { component: usize, at: take::Place },
|
||
}
|
||
|
||
/// A claim's verdict.
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum Verdict {
|
||
Holds,
|
||
Fails(Reason),
|
||
}
|
||
|
||
impl Verdict {
|
||
pub fn holds(self) -> bool {
|
||
self == Verdict::Holds
|
||
}
|
||
}
|
||
|
||
impl From<take::Verdict> for Verdict {
|
||
fn from(v: take::Verdict) -> Self {
|
||
match v {
|
||
take::Verdict::Holds => Verdict::Holds,
|
||
take::Verdict::Fails(r) => Verdict::Fails(r.into()),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// The verdicts of a recording (§4.5).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct RecordingVerdicts {
|
||
pub complete: Verdict,
|
||
/// By [`purpose::ALL`]'s order.
|
||
pub publishable: [Verdict; 4],
|
||
/// Every claim the manifest makes holds.
|
||
pub stands: bool,
|
||
/// Each take component's id and its verdicts, in the manifest's order.
|
||
pub takes: Vec<([u8; 32], take::Verdicts)>,
|
||
}
|
||
|
||
/// The outcome of a recording verification (§4.5).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum RecordingOutcome {
|
||
Refuse { at: Place, reason: Reason },
|
||
Verdicts(RecordingVerdicts),
|
||
}
|
||
|
||
/// What a recording verification holds at its end, for the corpus.
|
||
#[derive(Clone, Debug)]
|
||
pub struct RecordingRun {
|
||
pub outcome: RecordingOutcome,
|
||
/// The SHA-256 of `LISTING`: the package digest.
|
||
pub package: Option<[u8; 32]>,
|
||
/// The recording manifest's digest.
|
||
pub manifest: Option<[u8; 32]>,
|
||
pub room: Option<[u8; 32]>,
|
||
}
|
||
|
||
/// A take component's files (§3.4): the manifest, the parts as (index, bytes), the receipts
|
||
/// and the consents.
|
||
type TakeFiles = (Vec<u8>, Vec<(u64, Vec<u8>)>, Vec<Vec<u8>>, Vec<Vec<u8>>);
|
||
|
||
/// The room component's files (§3.3): the descriptor, the grants and the key-log head.
|
||
type RoomFiles = (Vec<u8>, Vec<Vec<u8>>, Option<String>);
|
||
|
||
fn take_files(files: &BTreeMap<String, Vec<u8>>, dir: &str) -> Option<TakeFiles> {
|
||
let mut manifest = None;
|
||
let (mut parts, mut receipts, mut consents) = (Vec::new(), Vec::new(), Vec::new());
|
||
for (p, b) in files.iter().filter_map(|(p, b)| p.strip_prefix(dir).map(|r| (r, b))) {
|
||
let numbered = |pre: &str, ext: &str, digits: usize| -> Option<u64> {
|
||
let n = p.strip_prefix(pre)?.strip_suffix(ext)?;
|
||
(n.len() == digits && n.bytes().all(|c| c.is_ascii_digit())).then(|| n.parse().ok())?
|
||
};
|
||
if p == "manifest.wtm" {
|
||
manifest = Some(b.clone());
|
||
} else if let Some(i) = numbered("chunk-", ".bin", 10) {
|
||
parts.push((i, b.clone()));
|
||
} else if let Some(k) = numbered("receipt-", ".wur", 4) {
|
||
(k == receipts.len() as u64).then_some(())?;
|
||
receipts.push(b.clone());
|
||
} else {
|
||
let k = numbered("consent-", ".wcn", 4)?;
|
||
(k == consents.len() as u64).then_some(())?;
|
||
consents.push(b.clone());
|
||
}
|
||
}
|
||
Some((manifest?, parts, receipts, consents))
|
||
}
|
||
|
||
/// The room component's files (§3.3): the descriptor, the grants, the key-log head.
|
||
fn room_files(files: &BTreeMap<String, Vec<u8>>) -> Option<RoomFiles> {
|
||
let mut descriptor = None;
|
||
let mut grants = Vec::new();
|
||
let mut head = None;
|
||
for (p, b) in files.iter().filter_map(|(p, b)| p.strip_prefix("room/").map(|r| (r, b))) {
|
||
if p == "descriptor.wrd" {
|
||
descriptor = Some(b.clone());
|
||
} else if p == "key-log.json" {
|
||
head = Some(String::from_utf8(b.clone()).ok()?);
|
||
} else {
|
||
let n = p.strip_prefix("grant-")?.strip_suffix(".wrg")?;
|
||
(n.len() == 4 && n.bytes().all(|c| c.is_ascii_digit()) && n.parse::<usize>().ok()? == grants.len()).then_some(())?;
|
||
grants.push(b.clone());
|
||
}
|
||
}
|
||
Some((descriptor?, grants, head))
|
||
}
|
||
|
||
/// The events component's files (§3.3): each principal's chain, in the order of their
|
||
/// paths, which is ascending order of the principals' keys.
|
||
fn event_chains(files: &BTreeMap<String, Vec<u8>>) -> Option<Vec<Vec<Vec<u8>>>> {
|
||
let mut out = Vec::new();
|
||
for (p, b) in files.iter().filter_map(|(p, b)| p.strip_prefix("events/").map(|r| (r, b))) {
|
||
let k = p.strip_suffix(".wev").filter(|k| is_hex64(k.as_bytes()))?;
|
||
let records = split_chain(b)?;
|
||
if records.is_empty() || !records.iter().all(|r| Event::parse(r).is_ok_and(|e| hex(&e.principal) == k)) {
|
||
return None;
|
||
}
|
||
out.push(records);
|
||
}
|
||
Some(out)
|
||
}
|
||
|
||
/// The takes the room's events open within the span (§4.5 `complete` 2): for each accepted
|
||
/// `record_start` whose `t` lies in [`from`, `to`], the take id it names, where the room
|
||
/// holds a `take_commit` of its target that names it.
|
||
fn session_takes(run: &RoomRun, chains: &[Vec<Vec<u8>>], room_id: &[u8; 32], from: u64, to: u64) -> Vec<[u8; 32]> {
|
||
let mut seen = BTreeSet::new();
|
||
let mut starts = Vec::new();
|
||
let mut commits: BTreeSet<([u8; 32], [u8; 32])> = BTreeSet::new();
|
||
for &(c, i, _) in &run.order {
|
||
let Ok(e) = Event::parse(&chains[c][i]) else { continue };
|
||
if !seen.insert(e.digest()) {
|
||
continue;
|
||
}
|
||
if e.kind == events::kind::TAKE_COMMIT {
|
||
if let Ok(cm) = Commit::parse(&e.payload) {
|
||
commits.insert((e.principal, cm.take));
|
||
}
|
||
} else if let Ok(Payload::Control { target, command: Command::RecordStart { .. } }) = events::parse_payload(e.kind, &e.payload, u16::MAX)
|
||
&& (from..=to).contains(&e.t)
|
||
{
|
||
starts.push((target, take_id(room_id, &target, &e.digest())));
|
||
}
|
||
}
|
||
starts.into_iter().filter(|(d, t)| commits.contains(&(*d, *t))).map(|(_, t)| t).collect()
|
||
}
|
||
|
||
/// §4.3: the first instant of [`from`, `to`] at which `signer` does not hold the record
|
||
/// power, or `None` when it holds it throughout. The room state is replayed from the
|
||
/// lifecycle the room verifier accepted (live-rooms §9.3): at instant `t` it holds every
|
||
/// `join`, `grant_exchange` and `leave` of the signer with its `t` at or below `t`. The power
|
||
/// is held at `t` when the signer is present there under a grant that has the record power,
|
||
/// that the head does not revoke, and that covers `t`.
|
||
fn signer_lapse(run: &RoomRun, chains: &[Vec<Vec<u8>>], d: &Descriptor, kl: &KeyLogState, signer: &[u8; 32], from: u64, to: u64) -> Option<u64> {
|
||
let mut changes: Vec<(u64, Option<[u8; 32]>)> = Vec::new();
|
||
let mut seen = BTreeSet::new();
|
||
for &(c, i, _) in &run.order {
|
||
let Ok(e) = Event::parse(&chains[c][i]) else { continue };
|
||
if !seen.insert(e.digest()) || !d.is_room_key(&e.principal) {
|
||
continue;
|
||
}
|
||
let Ok(Payload::Lifecycle { kind, subject, detail }) = events::parse_payload(e.kind, &e.payload, u16::MAX) else { continue };
|
||
if subject != *signer {
|
||
continue;
|
||
}
|
||
match Detail::parse(kind, &detail) {
|
||
Ok(Detail::Join { grant }) => changes.push((e.t, Some(grant))),
|
||
Ok(Detail::GrantExchange { new, .. }) => changes.push((e.t, Some(new))),
|
||
Ok(Detail::Leave { .. }) => changes.push((e.t, None)),
|
||
_ => {}
|
||
}
|
||
}
|
||
// The grant in effect at `from`, then a segment of the span for each change in it.
|
||
let mut held = changes.iter().rev().find(|(t, _)| *t <= from).and_then(|c| c.1);
|
||
let mut at = from;
|
||
let mut segments = Vec::new();
|
||
for &(t, g) in changes.iter().filter(|(t, _)| from < *t && *t <= to) {
|
||
if t > at {
|
||
segments.push((at, t - 1, held));
|
||
}
|
||
held = g;
|
||
at = t;
|
||
}
|
||
segments.push((at, to, held));
|
||
for (a, z, g) in segments {
|
||
let grant = g.and_then(|g| run.grants.iter().find(|h| h.digest == g)).filter(|h| h.grant.caps.powers & power::RECORD != 0 && !kl.revokes(h));
|
||
let Some(h) = grant else { return Some(a) };
|
||
if !h.grant.covers(d.t0_us, a) {
|
||
return Some(a);
|
||
}
|
||
if !h.grant.covers(d.t0_us, z) {
|
||
// The first instant past `exp`: the window is [nbf, exp) and covers `a`.
|
||
let past = (h.grant.exp as u128 * 1_000_000).saturating_sub(d.t0_us as u128);
|
||
return Some((past as u64).max(a));
|
||
}
|
||
}
|
||
None
|
||
}
|
||
|
||
/// Verify a recording package (§4.4): its files by path, and the evaluation instant `at`,
|
||
/// in seconds since 1970, that the takes' consents are evaluated at.
|
||
pub fn verify_recording(files: &BTreeMap<String, Vec<u8>>, at: u64) -> RecordingRun {
|
||
let mut run = RecordingRun { outcome: RecordingOutcome::Verdicts(fail_all()), package: None, manifest: None, room: None };
|
||
let refuse = |mut run: RecordingRun, at: Place, reason: Reason| {
|
||
run.outcome = RecordingOutcome::Refuse { at, reason };
|
||
run
|
||
};
|
||
// Step 1: the listing.
|
||
let listed = files.get(LISTING).filter(|l| package_listing(files).as_ref() == Some(l));
|
||
let Some(listing) = listed else { return refuse(run, Place::Listing, Reason::ListingMismatch) };
|
||
run.package = Some(sha256(&[listing]));
|
||
// Step 2: the recording manifest.
|
||
let m = match files.get(MANIFEST_FILE).map(|b| RecordingManifest::parse(b)) {
|
||
None => return refuse(run, Place::Manifest, Reason::RecordingManifestInvalid),
|
||
Some(Err(r)) => return refuse(run, Place::Manifest, r),
|
||
Some(Ok(m)) => m,
|
||
};
|
||
run.manifest = Some(m.digest());
|
||
// Step 3: the components, and no file outside them.
|
||
for (i, c) in m.components.iter().enumerate() {
|
||
match listing_below(files, &c.dir()) {
|
||
None => return refuse(run, Place::Component(i), Reason::ComponentInvalid),
|
||
Some(l) if sha256(&[&l]) != c.sha256 => return refuse(run, Place::Component(i), Reason::ComponentDigestMismatch),
|
||
Some(_) => {}
|
||
}
|
||
}
|
||
let dirs: Vec<String> = m.components.iter().map(Component::dir).collect();
|
||
if files.keys().any(|p| p != LISTING && p != MANIFEST_FILE && !dirs.iter().any(|d| p.starts_with(d.as_str()))) {
|
||
return refuse(run, Place::Listing, Reason::ComponentUnlisted);
|
||
}
|
||
// Step 4: the room.
|
||
let Some((descriptor, grants, head)) = room_files(files) else { return refuse(run, Place::Component(0), Reason::ComponentInvalid) };
|
||
let Some(chains) = event_chains(files) else { return refuse(run, Place::Component(1), Reason::ComponentInvalid) };
|
||
let room_run = verify_room(&descriptor, &grants, head.as_deref(), &chains);
|
||
if let RoomOutcome::Refuse { at, reason } = room_run.outcome {
|
||
return refuse(run, Place::Room(at), reason.into());
|
||
}
|
||
let room_id = room_run.room.expect("a room that verified");
|
||
run.room = Some(room_id);
|
||
let d: Descriptor = room_run.descriptor.clone().expect("a room that verified");
|
||
if m.room != room_id {
|
||
return refuse(run, Place::Manifest, WRONG_ROOM);
|
||
}
|
||
// Step 5: the signer holds the record power over the whole span (§4.3).
|
||
if m.signer != d.authority {
|
||
let kl = check_key_log(&d, head.as_deref()).unwrap_or_default();
|
||
if let Some(t) = signer_lapse(&room_run, &chains, &d, &kl, &m.signer, m.from, m.to) {
|
||
return refuse(run, Place::Signer(t), Reason::RecordingSignerNotPermitted);
|
||
}
|
||
}
|
||
// The region pin (live-rooms §3.7).
|
||
if !d.recording.regions.is_empty() && !d.recording.regions.contains(&m.region) {
|
||
return refuse(run, Place::Manifest, take::Reason::StoreOutsideRegion.into());
|
||
}
|
||
// Step 6: the takes, each within the span.
|
||
let times: BTreeMap<[u8; 32], u64> = room_run
|
||
.order
|
||
.iter()
|
||
.filter_map(|&(c, i, _)| Event::parse(&chains[c][i]).ok())
|
||
.map(|e| (e.digest(), e.t))
|
||
.collect();
|
||
let mut takes = Vec::new();
|
||
for (i, c) in m.components.iter().enumerate().filter(|(_, c)| c.kind == component::TAKE) {
|
||
let Some((manifest, parts, receipts, consents)) = take_files(files, &c.dir()) else {
|
||
return refuse(run, Place::Component(i), Reason::ComponentInvalid);
|
||
};
|
||
let t = take::verify_take(&TakeInputs {
|
||
descriptor: &descriptor,
|
||
grants: &grants,
|
||
head: head.as_deref(),
|
||
chains: &chains,
|
||
manifest: &manifest,
|
||
parts: &parts,
|
||
receipts: &receipts,
|
||
consents: &consents,
|
||
at,
|
||
});
|
||
match t.outcome {
|
||
TakeOutcome::Refuse { at, reason } => return refuse(run, Place::Take { component: i, at }, reason.into()),
|
||
TakeOutcome::Verdicts(v) => {
|
||
let id = t.take.expect("a take that read");
|
||
if hex(&id).as_bytes() != c.id.as_slice() {
|
||
return refuse(run, Place::Component(i), Reason::ComponentInvalid);
|
||
}
|
||
let start = take::Manifest::parse(&manifest, &room_id).expect("a manifest the take verifier read").start;
|
||
let opened = times.get(&start).copied();
|
||
let closed = t.held.last().and_then(|h| times.get(h)).copied();
|
||
if opened.is_none_or(|x| x < m.from) || closed.is_some_and(|x| x > m.to) {
|
||
return refuse(run, Place::Component(i), Reason::OutsideSpan);
|
||
}
|
||
takes.push((id, v));
|
||
}
|
||
}
|
||
}
|
||
// Step 7: the verdicts.
|
||
let complete = recording_complete(&room_run, &chains, &room_id, &takes, m.from, m.to);
|
||
let publishable = [0usize, 1, 2, 3].map(|k| takes.iter().map(|(_, v)| Verdict::from(v.publishable[k])).find(|v| !v.holds()).unwrap_or(Verdict::Holds));
|
||
let stands = (m.claims & claim::COMPLETE == 0 || complete.holds())
|
||
&& purpose::ALL.iter().zip(&publishable).all(|((b, _), v)| m.purposes & b == 0 || v.holds());
|
||
run.outcome = RecordingOutcome::Verdicts(RecordingVerdicts { complete, publishable, stands, takes });
|
||
run
|
||
}
|
||
|
||
/// §4.5 `complete`.
|
||
fn recording_complete(room_run: &RoomRun, chains: &[Vec<Vec<u8>>], room_id: &[u8; 32], takes: &[([u8; 32], take::Verdicts)], from: u64, to: u64) -> Verdict {
|
||
if matches!(room_run.outcome, RoomOutcome::Gap(_)) {
|
||
return Verdict::Fails(DELIVERY_GAP);
|
||
}
|
||
let held: BTreeSet<[u8; 32]> = takes.iter().map(|(t, _)| *t).collect();
|
||
if session_takes(room_run, chains, room_id, from, to).iter().any(|t| !held.contains(t)) {
|
||
return Verdict::Fails(Reason::TakeOmitted);
|
||
}
|
||
for (_, v) in takes {
|
||
if let take::Verdict::Fails(r) = v.single_take
|
||
&& matches!(r, take::DELIVERY_GAP | take::Reason::CommitChainBroken | take::Reason::TakeNotClosed)
|
||
{
|
||
return Verdict::Fails(r.into());
|
||
}
|
||
if let take::Verdict::Fails(r) = v.complete {
|
||
return Verdict::Fails(r.into());
|
||
}
|
||
}
|
||
Verdict::Holds
|
||
}
|
||
|
||
fn fail_all() -> RecordingVerdicts {
|
||
let v = Verdict::Fails(Reason::RecordingManifestInvalid);
|
||
RecordingVerdicts { complete: v, publishable: [v; 4], stands: false, takes: vec![] }
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------------------
|
||
// §5 Clips
|
||
// ---------------------------------------------------------------------------------------
|
||
|
||
/// One take's span in a clip (§5.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct ClipPart {
|
||
pub device: [u8; 32],
|
||
pub take: [u8; 32],
|
||
/// The digest of the `take_commit` event whose commitment the proof is against.
|
||
pub head: [u8; 32],
|
||
/// That commitment's size and root.
|
||
pub size: u64,
|
||
pub root: [u8; 32],
|
||
pub first: u64,
|
||
/// The bytes of the first chunk before the clip's, and of the last chunk after them.
|
||
pub trim_in: u64,
|
||
pub trim_out: u64,
|
||
/// The descriptors of the chunks it covers, `first` onward.
|
||
pub chunks: Vec<Chunk>,
|
||
pub proof: Vec<[u8; 32]>,
|
||
}
|
||
|
||
/// A clip record (§5.1).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct Clip {
|
||
pub room: [u8; 32],
|
||
/// The room-clock window the clip presents: `window_in` ≤ `t` < `window_out`.
|
||
pub window_in: u64,
|
||
pub window_out: u64,
|
||
pub parts: Vec<ClipPart>,
|
||
pub signer: [u8; 32],
|
||
pub sig: [u8; 64],
|
||
}
|
||
|
||
impl Clip {
|
||
/// The bytes its digest and signature cover.
|
||
pub fn body(&self) -> Vec<u8> {
|
||
let mut o = Vec::new();
|
||
o.extend_from_slice(&CLIP_MAGIC);
|
||
o.push(RECORD_VERSION);
|
||
o.extend_from_slice(&self.room);
|
||
o.extend_from_slice(&self.window_in.to_le_bytes());
|
||
o.extend_from_slice(&self.window_out.to_le_bytes());
|
||
assert!(self.parts.len() <= u8::MAX as usize);
|
||
o.push(self.parts.len() as u8);
|
||
for p in &self.parts {
|
||
for f in [&p.device, &p.take, &p.head] {
|
||
o.extend_from_slice(f);
|
||
}
|
||
o.extend_from_slice(&p.size.to_le_bytes());
|
||
o.extend_from_slice(&p.root);
|
||
o.extend_from_slice(&p.first.to_le_bytes());
|
||
assert!(p.chunks.len() <= u32::MAX as usize);
|
||
o.extend_from_slice(&(p.chunks.len() as u32).to_le_bytes());
|
||
o.extend_from_slice(&p.trim_in.to_le_bytes());
|
||
o.extend_from_slice(&p.trim_out.to_le_bytes());
|
||
for c in &p.chunks {
|
||
o.extend_from_slice(&c.to_bytes());
|
||
}
|
||
assert!(p.proof.len() <= u8::MAX as usize);
|
||
o.push(p.proof.len() as u8);
|
||
for n in &p.proof {
|
||
o.extend_from_slice(n);
|
||
}
|
||
}
|
||
o.extend_from_slice(&self.signer);
|
||
o
|
||
}
|
||
|
||
/// The whole record.
|
||
pub fn to_bytes(&self) -> Vec<u8> {
|
||
let mut o = self.body();
|
||
o.extend_from_slice(&self.sig);
|
||
o
|
||
}
|
||
|
||
/// Its digest (§5.1).
|
||
pub fn digest(&self) -> [u8; 32] {
|
||
sha256(&[&self.body()])
|
||
}
|
||
|
||
/// Signed by `key`, whose public key becomes `signer`.
|
||
pub fn sign(mut self, key: &SigningKey) -> Clip {
|
||
self.signer = key.verifying_key().to_bytes();
|
||
self.sig = sign(key, CLIP_DOMAIN, &self.body());
|
||
self
|
||
}
|
||
|
||
fn read(b: &[u8]) -> Result<Clip, Reason> {
|
||
read_head(b, &CLIP_MAGIC)?;
|
||
let mut r = Rd { b, p: 5 };
|
||
let room = r.arr()?;
|
||
let (window_in, window_out) = (r.u64()?, r.u64()?);
|
||
let k = r.u8()? as usize;
|
||
let mut parts = Vec::with_capacity(k);
|
||
for _ in 0..k {
|
||
let (device, take, head) = (r.arr()?, r.arr()?, r.arr()?);
|
||
let size = r.u64()?;
|
||
let root = r.arr()?;
|
||
let first = r.u64()?;
|
||
let count = r.u32()? as usize;
|
||
let (trim_in, trim_out) = (r.u64()?, r.u64()?);
|
||
let mut chunks = Vec::with_capacity(count.min(b.len() / CHUNK_LEN));
|
||
for _ in 0..count {
|
||
let d = r.take(CHUNK_LEN)?;
|
||
let u = |o: usize| u64::from_le_bytes(d[o..o + 8].try_into().expect("8 bytes"));
|
||
chunks.push(Chunk { index: u(0), start: u(8), end: u(16), len: u(24), hash: d[32..].try_into().expect("32 bytes") });
|
||
}
|
||
let n = r.u8()? as usize;
|
||
let proof = (0..n).map(|_| r.arr()).collect::<Result<Vec<[u8; 32]>, _>>()?;
|
||
parts.push(ClipPart { device, take, head, size, root, first, trim_in, trim_out, chunks, proof });
|
||
}
|
||
let signer = r.arr()?;
|
||
let sig = r.arr()?;
|
||
if !r.done() {
|
||
return Err(MALFORMED);
|
||
}
|
||
Ok(Clip { room, window_in, window_out, parts, signer, sig })
|
||
}
|
||
|
||
/// §5.2's rules.
|
||
fn rules_hold(&self) -> bool {
|
||
let takes: BTreeSet<&[u8; 32]> = self.parts.iter().map(|p| &p.take).collect();
|
||
let trimmed = self.parts.iter().any(|p| p.trim_in != 0 || p.trim_out != 0);
|
||
(1..=MAX_PARTS).contains(&self.parts.len())
|
||
&& takes.len() == self.parts.len()
|
||
&& (!trimmed || self.parts.len() == 1)
|
||
&& self.window_in < self.window_out
|
||
&& self.parts.iter().all(|p| {
|
||
let (Some(f), Some(l)) = (p.chunks.first(), p.chunks.last()) else { return false };
|
||
p.chunks.iter().enumerate().all(|(i, c)| p.first.checked_add(i as u64) == Some(c.index) && c.len >= 1)
|
||
&& p.proof.len() <= MAX_RANGE_PROOF
|
||
&& p.trim_in < f.len
|
||
&& p.trim_out < l.len
|
||
&& (p.chunks.len() > 1 || p.trim_in.checked_add(p.trim_out).is_some_and(|s| s < f.len))
|
||
})
|
||
}
|
||
|
||
/// Read a clip record, hold it to §5.2's rules and verify its signature.
|
||
pub fn parse(b: &[u8]) -> Result<Clip, Reason> {
|
||
let c = Clip::read(b)?;
|
||
if !c.rules_hold() {
|
||
return Err(Reason::ClipRecordInvalid);
|
||
}
|
||
verify_sig(&c.signer, CLIP_DOMAIN, &b[..b.len() - 64], &c.sig)?;
|
||
Ok(c)
|
||
}
|
||
}
|
||
|
||
/// What a cutter holds of a take (§5.5): the device, the take id, the chunks of its
|
||
/// manifest, the commitment it proves against (its event's digest and its payload), and
|
||
/// the take's clock offset (its opening commitment's).
|
||
#[derive(Clone, Debug)]
|
||
pub struct TakeSource<'a> {
|
||
pub device: [u8; 32],
|
||
pub take: [u8; 32],
|
||
pub chunks: &'a [Chunk],
|
||
pub head: [u8; 32],
|
||
pub head_commit: &'a Commit,
|
||
pub offset: i64,
|
||
}
|
||
|
||
/// A chunk range resolved from a byte range of the master (§5.5): the first chunk, the
|
||
/// count, and the trims.
|
||
pub fn resolve_bytes(chunks: &[Chunk], from: u64, to: u64) -> Option<(u64, usize, u64, u64)> {
|
||
if from >= to {
|
||
return None;
|
||
}
|
||
let mut at = 0u64;
|
||
let (mut first, mut last) = (None, None);
|
||
for c in chunks {
|
||
let end = at.checked_add(c.len)?;
|
||
if first.is_none() && from < end {
|
||
first = Some((c.index, from - at));
|
||
}
|
||
if to <= end {
|
||
last = Some((c.index, end - to));
|
||
break;
|
||
}
|
||
at = end;
|
||
}
|
||
let ((a, trim_in), (z, trim_out)) = (first?, last?);
|
||
Some((a, (z - a + 1) as usize, trim_in, trim_out))
|
||
}
|
||
|
||
/// The chunks of a take whose mapped capture covers a room-clock window, minimally (§5.5,
|
||
/// §6.1): the first chunk and the count.
|
||
pub fn resolve_window(chunks: &[Chunk], offset: i64, window_in: u64, window_out: u64) -> Option<(u64, usize)> {
|
||
let map = |d: u64| d as i128 + offset as i128;
|
||
let a = chunks.iter().find(|c| map(c.start) <= window_in as i128 && (window_in as i128) < map(c.end))?;
|
||
let z = chunks.iter().find(|c| map(c.start) < window_out as i128 && (window_out as i128) <= map(c.end))?;
|
||
(z.index >= a.index).then(|| (a.index, (z.index - a.index + 1) as usize))
|
||
}
|
||
|
||
/// Cut one take's part of a clip (§5.5): `count` chunks from `first`, the trims, and the
|
||
/// range proof against the source's head; `None` when the range is past the head's size.
|
||
pub fn cut_part(src: &TakeSource, first: u64, count: usize, trim_in: u64, trim_out: u64) -> Option<ClipPart> {
|
||
let size = src.head_commit.size;
|
||
let end = first.checked_add(count as u64)?;
|
||
if count == 0 || end > size || size as usize > src.chunks.len() {
|
||
return None;
|
||
}
|
||
let l = leaves(&src.chunks[..size as usize]);
|
||
let proof = range_proof(first as usize, end as usize, &l)?;
|
||
Some(ClipPart {
|
||
device: src.device,
|
||
take: src.take,
|
||
head: src.head,
|
||
size,
|
||
root: src.head_commit.root,
|
||
first,
|
||
trim_in,
|
||
trim_out,
|
||
chunks: src.chunks[first as usize..end as usize].to_vec(),
|
||
proof,
|
||
})
|
||
}
|
||
|
||
/// A clip record over `parts`, presenting the room-clock window, signed by `signer`.
|
||
pub fn cut_clip(room: [u8; 32], window_in: u64, window_out: u64, parts: Vec<ClipPart>, signer: &SigningKey) -> Clip {
|
||
Clip { room, window_in, window_out, parts, signer: [0; 32], sig: [0; 64] }.sign(signer)
|
||
}
|
||
|
||
/// The mapped capture span of a part's chunks on the room clock (§6.1).
|
||
pub fn mapped_span(chunks: &[Chunk], offset: i64) -> (i128, i128) {
|
||
let map = |d: u64| d as i128 + offset as i128;
|
||
(chunks.first().map_or(0, |c| map(c.start)), chunks.last().map_or(0, |c| map(c.end)))
|
||
}
|
||
|
||
/// Where a clip verification stopped (§5.4).
|
||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||
pub enum ClipPlace {
|
||
Descriptor,
|
||
Clip,
|
||
Part(usize),
|
||
/// A commitment given for a part, by its index in the list given.
|
||
Commit { part: usize, index: usize },
|
||
/// A chunk's bytes given for a part, by its index in the part.
|
||
Chunk { part: usize, index: usize },
|
||
}
|
||
|
||
/// The MoQ address a FETCH of a part's chunks names (§8): the Track Namespace fields and
|
||
/// Track Name of the take's track, and the range of its LOCATION_FILTER.
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct FetchRange {
|
||
pub namespace: [String; 3],
|
||
pub name: String,
|
||
pub start_group: u64,
|
||
pub start_object: u64,
|
||
/// The end group is `start_group` plus this; every object of it is fetched.
|
||
pub end_group_delta: u64,
|
||
}
|
||
|
||
/// The FETCH address of chunks `first` to `first + count − 1` of a take (§8).
|
||
pub fn fetch_range(room: &[u8; 32], device: &[u8; 32], take: &[u8; 32], first: u64, count: u64) -> FetchRange {
|
||
FetchRange {
|
||
namespace: [hex(room), hex(device), TAKE_TRACK.into()],
|
||
name: hex(take),
|
||
start_group: first,
|
||
start_object: 0,
|
||
end_group_delta: count.saturating_sub(1),
|
||
}
|
||
}
|
||
|
||
/// What a verified part states (§5.4, §6).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct PartFacts {
|
||
pub take: [u8; 32],
|
||
pub first: u64,
|
||
pub count: u64,
|
||
/// The take's clock offset, its opening commitment's.
|
||
pub offset: i64,
|
||
/// The mapped capture span of its chunks, on the room clock.
|
||
pub span: (i128, i128),
|
||
/// The window on the device clock: each room-clock instant `t` is `t − offset` there.
|
||
pub device_window: (i128, i128),
|
||
/// The bytes the part presents: their length and SHA-256.
|
||
pub bytes: u64,
|
||
pub sha256: [u8; 32],
|
||
pub fetch: FetchRange,
|
||
}
|
||
|
||
/// What a verified clip states (§5.4, §6.2).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub struct ClipFacts {
|
||
pub digest: [u8; 32],
|
||
pub signer: [u8; 32],
|
||
pub window: (u64, u64),
|
||
/// The bound, in microseconds, on the time correspondence: *J* to the room clock for
|
||
/// one part, 2*J* between any two (§6.2).
|
||
pub bound_us: u64,
|
||
pub parts: Vec<PartFacts>,
|
||
}
|
||
|
||
/// The outcome of a clip verification (§5.4).
|
||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||
pub enum ClipOutcome {
|
||
Refuse { at: ClipPlace, reason: Reason },
|
||
Accept(ClipFacts),
|
||
}
|
||
|
||
/// What a clip verifier is given for one part (§5.4): the device's key, the take's
|
||
/// `take_commit` events from its opening one to the head in their order, and the chunks'
|
||
/// bytes, each one present or absent.
|
||
#[derive(Clone, Debug)]
|
||
pub struct PartInputs<'a> {
|
||
pub device: [u8; 32],
|
||
pub commits: &'a [Vec<u8>],
|
||
pub bytes: &'a [Option<Vec<u8>>],
|
||
}
|
||
|
||
/// One commitment as a clip verifier reads it: its event's digest and `t`, and its payload.
|
||
struct Held {
|
||
digest: [u8; 32],
|
||
t: u64,
|
||
c: Commit,
|
||
}
|
||
|
||
/// §5.4 step 3 for part `k`: the facts, or the refusal.
|
||
fn verify_part(k: usize, p: &ClipPart, i: &PartInputs, room_id: &[u8; 32], clip: &Clip, interval: i128, jitter: i128) -> Result<PartFacts, (ClipPlace, Reason)> {
|
||
let at_part = |r: Reason| (ClipPlace::Part(k), r);
|
||
let at_commit = |j: usize, r: Reason| (ClipPlace::Commit { part: k, index: j }, r);
|
||
// a. The device.
|
||
if i.device != p.device {
|
||
return Err(at_part(Reason::ClipDeviceMismatch));
|
||
}
|
||
// b. The commitments read.
|
||
let mut held = Vec::with_capacity(i.commits.len());
|
||
for (j, b) in i.commits.iter().enumerate() {
|
||
let e = Event::parse(b).map_err(|r| at_commit(j, r.into()))?;
|
||
if e.room != *room_id {
|
||
return Err(at_commit(j, WRONG_ROOM));
|
||
}
|
||
if e.principal != p.device {
|
||
return Err(at_commit(j, Reason::ClipDeviceMismatch));
|
||
}
|
||
e.verify_signature().map_err(|r| at_commit(j, r.into()))?;
|
||
if e.kind != events::kind::TAKE_COMMIT {
|
||
return Err(at_commit(j, take::Reason::CommitMalformed.into()));
|
||
}
|
||
let c = Commit::parse(&e.payload).map_err(|r| at_commit(j, r.into()))?;
|
||
if c.take != p.take {
|
||
return Err(at_commit(j, Reason::ClipTakeMismatch));
|
||
}
|
||
held.push(Held { digest: e.digest(), t: e.t, c });
|
||
}
|
||
// c. The chain (capture-binding §5.3), then each extends its predecessor (§4.3).
|
||
let Some(open) = held.first() else { return Err(at_part(take::Reason::CommitChainBroken.into())) };
|
||
if !open.c.is_opening() {
|
||
return Err(at_commit(0, take::Reason::CommitChainBroken.into()));
|
||
}
|
||
for (j, w) in held.windows(2).enumerate() {
|
||
let (a, b) = (&w[0], &w[1]);
|
||
if b.c.n > a.c.n.saturating_add(1) {
|
||
return Err(at_commit(j + 1, take::DELIVERY_GAP.into()));
|
||
}
|
||
let follows = a.c.phase != phase::CLOSE && b.c.phase != phase::OPEN && b.c.n == a.c.n.wrapping_add(1) && b.c.prev == a.digest && b.c.first == a.c.size;
|
||
if !follows {
|
||
return Err(at_commit(j + 1, take::Reason::CommitChainBroken.into()));
|
||
}
|
||
}
|
||
for (j, w) in held.windows(2).enumerate() {
|
||
let (a, b) = (&w[0].c, &w[1].c);
|
||
if !extends(b.first, &a.root, b.size, &b.root, &b.proof) {
|
||
return Err(at_commit(j + 1, take::Reason::RootNotExtending.into()));
|
||
}
|
||
}
|
||
// d. The head is the last commitment given, with the record's size and root.
|
||
let head = held.last().expect("an opening commitment");
|
||
if head.digest != p.head || head.c.size != p.size || head.c.root != p.root {
|
||
return Err(at_part(Reason::TreeHeadNotCommitted));
|
||
}
|
||
// e. The range lies within the head.
|
||
let count = p.chunks.len() as u64;
|
||
if p.first.checked_add(count).is_none_or(|e| e > p.size) {
|
||
return Err(at_part(Reason::ClipOutOfRange));
|
||
}
|
||
// f. The descriptors are the head's leaves `first` onward.
|
||
if !verify_range(p.first, &leaves(&p.chunks), &p.proof, &OutputCommitment { root: p.root, leaf_count: p.size }) {
|
||
return Err(at_part(Reason::ClipNotIncluded));
|
||
}
|
||
// g. The clock held (capture-binding §9.4 single-take 6).
|
||
let o = open.c.offset as i128;
|
||
if let Some(j) = held.iter().position(|h| (h.c.offset as i128 - o).abs() > jitter) {
|
||
return Err(at_commit(j, take::Reason::ClockUnstable.into()));
|
||
}
|
||
// h. The chunks tile the capture, none longer than the interval (single-take 7, 8).
|
||
let ch = &p.chunks;
|
||
if ch.iter().any(|c| c.start >= c.end) || ch.windows(2).any(|w| w[1].start != w[0].end) {
|
||
return Err(at_part(take::Reason::CaptureDiscontinuous.into()));
|
||
}
|
||
if ch.iter().any(|c| (c.end - c.start) as i128 > interval) {
|
||
return Err(at_part(take::Reason::ChunkTooLong.into()));
|
||
}
|
||
// i. Each chunk was committed after its capture ended, and within I + J (single-take 9, 10).
|
||
let map = |d: u64| d as i128 + o;
|
||
let adder = |c: &Chunk| held.iter().find(|h| h.c.first <= c.index && c.index < h.c.size).expect("a chunk below the head's size");
|
||
if ch.iter().any(|c| (adder(c).t as i128) + jitter < map(c.end)) {
|
||
return Err(at_part(take::Reason::CommitEarly.into()));
|
||
}
|
||
if ch.iter().any(|c| adder(c).t as i128 > map(c.end) + interval + jitter) {
|
||
return Err(at_part(take::Reason::CommitLate.into()));
|
||
}
|
||
// j. The window (§5.2, §6.1).
|
||
let (span_in, span_out) = mapped_span(ch, open.c.offset);
|
||
let (f, l) = (&ch[0], &ch[ch.len() - 1]);
|
||
let (wi, wo) = (clip.window_in as i128, clip.window_out as i128);
|
||
let covers = span_in <= wi && wo <= span_out && wi < map(f.end) && map(l.start) < wo;
|
||
let trimmed = p.trim_in != 0 || p.trim_out != 0;
|
||
if !covers || (trimmed && (wi, wo) != (span_in, span_out)) {
|
||
return Err(at_part(Reason::ClipWindowInvalid));
|
||
}
|
||
// k. The bytes.
|
||
let mut presented = Vec::new();
|
||
for (j, c) in ch.iter().enumerate() {
|
||
let at = (ClipPlace::Chunk { part: k, index: j }, Reason::ClipChunkMissing);
|
||
let b = i.bytes.get(j).and_then(Option::as_ref).ok_or(at)?;
|
||
if !c.is(b) {
|
||
let other = ch.iter().any(|x| x.index != c.index && x.is(b));
|
||
return Err((at.0, if other { take::Reason::ChunkReordered } else { take::Reason::ChunkAltered }.into()));
|
||
}
|
||
presented.extend_from_slice(b);
|
||
}
|
||
if i.bytes.len() > ch.len() {
|
||
return Err((ClipPlace::Chunk { part: k, index: ch.len() }, take::Reason::ChunkExtra.into()));
|
||
}
|
||
let shown = &presented[p.trim_in as usize..presented.len() - p.trim_out as usize];
|
||
Ok(PartFacts {
|
||
take: p.take,
|
||
first: p.first,
|
||
count,
|
||
offset: open.c.offset,
|
||
span: (span_in, span_out),
|
||
device_window: (wi - o, wo - o),
|
||
bytes: shown.len() as u64,
|
||
sha256: sha256(&[shown]),
|
||
fetch: fetch_range(room_id, &p.device, &p.take, p.first, count),
|
||
})
|
||
}
|
||
|
||
/// Verify a clip (§5.4): the room descriptor, the clip record, and for each of its parts in
|
||
/// order what [`PartInputs`] holds.
|
||
pub fn verify_clip(descriptor: &[u8], record: &[u8], parts: &[PartInputs]) -> ClipOutcome {
|
||
let refuse = |at, reason| ClipOutcome::Refuse { at, reason };
|
||
// 1. The descriptor.
|
||
let d = match Descriptor::parse(descriptor) {
|
||
Ok(d) => d,
|
||
Err(r) => return refuse(ClipPlace::Descriptor, r.into()),
|
||
};
|
||
let room_id = d.room_id();
|
||
// 2. The record.
|
||
let clip = match Clip::parse(record) {
|
||
Ok(c) => c,
|
||
Err(r) => return refuse(ClipPlace::Clip, r),
|
||
};
|
||
if clip.room != room_id {
|
||
return refuse(ClipPlace::Clip, WRONG_ROOM);
|
||
}
|
||
if parts.len() != clip.parts.len() {
|
||
return refuse(ClipPlace::Clip, Reason::ClipRecordInvalid);
|
||
}
|
||
// 3. Each part.
|
||
let interval = d.recording.commit_interval_ms as i128 * 1000;
|
||
let jitter = d.recording.jitter_ms as i128 * 1000;
|
||
let mut facts = Vec::with_capacity(parts.len());
|
||
for (k, (p, i)) in clip.parts.iter().zip(parts).enumerate() {
|
||
match verify_part(k, p, i, &room_id, &clip, interval, jitter) {
|
||
Ok(f) => facts.push(f),
|
||
Err((at, r)) => return refuse(at, r),
|
||
}
|
||
}
|
||
let j = d.recording.jitter_ms as u64 * 1000;
|
||
ClipOutcome::Accept(ClipFacts {
|
||
digest: clip.digest(),
|
||
signer: clip.signer,
|
||
window: (clip.window_in, clip.window_out),
|
||
bound_us: if clip.parts.len() == 1 { j } else { 2 * j },
|
||
parts: facts,
|
||
})
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
fn chunks(n: u64) -> Vec<Chunk> {
|
||
(0..n).map(|i| Chunk::of(i, 1_000 * i, 1_000 * (i + 1), &vec![i as u8; 5 + i as usize])).collect()
|
||
}
|
||
|
||
#[test]
|
||
fn a_chain_file_splits_into_its_records() {
|
||
let k = SigningKey::from_bytes(&[4; 32]);
|
||
let mut prev = [0; 32];
|
||
let mut rs = Vec::new();
|
||
for (s, anchor) in [None, Some(events::Anchor { track: [7; 32], group: 3, object: 9 }), None].into_iter().enumerate() {
|
||
let e = Event::sign(&k, [1; 32], s as u64, prev, 10 * s as u64, anchor, events::kind::CHAT, b"hello".to_vec());
|
||
prev = e.digest();
|
||
rs.push(e.to_bytes());
|
||
}
|
||
let f = chain_file(&rs);
|
||
assert_eq!(split_chain(&f), Some(rs.clone()));
|
||
assert_eq!(split_chain(&f[..f.len() - 1]), None);
|
||
let mut extra = f.clone();
|
||
extra.push(0);
|
||
assert_eq!(split_chain(&extra), None);
|
||
assert_eq!(split_chain(&[]), Some(vec![]));
|
||
}
|
||
|
||
#[test]
|
||
fn ids_and_listings() {
|
||
assert!(id_ok(b"room") && id_ok(b"a-b_9") && !id_ok(b"") && !id_ok(b"Room") && !id_ok(b"a.b") && !id_ok(&[b'a'; 65]));
|
||
let mut files = BTreeMap::new();
|
||
files.insert("room/descriptor.wrd".to_string(), b"d".to_vec());
|
||
files.insert("room/grant-0000.wrg".to_string(), b"g".to_vec());
|
||
files.insert("events/x.wev".to_string(), b"e".to_vec());
|
||
let l = listing_below(&files, "room/").unwrap();
|
||
let want = format!("{} descriptor.wrd\n{} grant-0000.wrg\n", hex(&sha256(&[b"d"])), hex(&sha256(&[b"g"])));
|
||
assert_eq!(String::from_utf8(l).unwrap(), want);
|
||
assert_eq!(listing_below(&files, "takes/"), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_byte_range_resolves_to_whole_chunks_and_trims() {
|
||
// Lengths 5, 6, 7, 8: chunk 1 is bytes 5..11, chunk 2 is 11..18.
|
||
let c = chunks(4);
|
||
assert_eq!(resolve_bytes(&c, 0, 5), Some((0, 1, 0, 0)));
|
||
assert_eq!(resolve_bytes(&c, 6, 13), Some((1, 2, 1, 5)));
|
||
assert_eq!(resolve_bytes(&c, 11, 18), Some((2, 1, 0, 0)));
|
||
assert_eq!(resolve_bytes(&c, 0, 26), Some((0, 4, 0, 0)));
|
||
assert_eq!(resolve_bytes(&c, 0, 27), None);
|
||
assert_eq!(resolve_bytes(&c, 4, 4), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_window_resolves_to_its_minimal_cover() {
|
||
let c = chunks(5);
|
||
// Room clock = device clock + 500.
|
||
assert_eq!(resolve_window(&c, 500, 500, 1_500), Some((0, 1)));
|
||
assert_eq!(resolve_window(&c, 500, 1_400, 1_600), Some((0, 2)));
|
||
assert_eq!(resolve_window(&c, 500, 1_500, 3_500), Some((1, 2)));
|
||
assert_eq!(resolve_window(&c, 500, 400, 1_000), None);
|
||
assert_eq!(resolve_window(&c, 500, 4_000, 5_600), None);
|
||
}
|
||
|
||
#[test]
|
||
fn a_clip_record_round_trips_and_is_read_strictly() {
|
||
let k = SigningKey::from_bytes(&[9; 32]);
|
||
let ch = chunks(6);
|
||
let l = leaves(&ch);
|
||
let part = ClipPart {
|
||
device: [1; 32],
|
||
take: [2; 32],
|
||
head: [3; 32],
|
||
size: 6,
|
||
root: crate::output_tree::tree_hash(&l),
|
||
first: 2,
|
||
trim_in: 1,
|
||
trim_out: 2,
|
||
chunks: ch[2..5].to_vec(),
|
||
proof: range_proof(2, 5, &l).unwrap(),
|
||
};
|
||
let c = cut_clip([5; 32], 2_000, 5_000, vec![part.clone()], &k);
|
||
let b = c.to_bytes();
|
||
assert_eq!(Clip::parse(&b), Ok(c.clone()));
|
||
assert_eq!(Clip::parse(&b[..b.len() - 1]), Err(TRUNCATED));
|
||
let mut t = b.clone();
|
||
t.push(0);
|
||
assert_eq!(Clip::parse(&t), Err(MALFORMED));
|
||
let mut s = b.clone();
|
||
let n = s.len();
|
||
s[n - 1] ^= 1;
|
||
assert_eq!(Clip::parse(&s), Err(events::Reason::BadSignature.into()));
|
||
// Trims only within the boundary chunks, and only in a clip of one take.
|
||
for bad in [ClipPart { trim_in: 7, ..part.clone() }, ClipPart { trim_out: 9, ..part.clone() }] {
|
||
assert_eq!(Clip::parse(&cut_clip([5; 32], 2_000, 5_000, vec![bad], &k).to_bytes()), Err(Reason::ClipRecordInvalid));
|
||
}
|
||
let two = vec![part.clone(), ClipPart { take: [4; 32], ..part.clone() }];
|
||
assert_eq!(Clip::parse(&cut_clip([5; 32], 2_000, 5_000, two, &k).to_bytes()), Err(Reason::ClipRecordInvalid));
|
||
let renumbered = ClipPart { first: 3, ..part.clone() };
|
||
assert_eq!(Clip::parse(&cut_clip([5; 32], 2_000, 5_000, vec![renumbered], &k).to_bytes()), Err(Reason::ClipRecordInvalid));
|
||
assert_eq!(Clip::parse(&cut_clip([5; 32], 5_000, 5_000, vec![part], &k).to_bytes()), Err(Reason::ClipRecordInvalid));
|
||
}
|
||
|
||
#[test]
|
||
fn a_recording_manifest_is_held_to_its_order() {
|
||
let k = SigningKey::from_bytes(&[8; 32]);
|
||
let comp = |kind, id: &str| Component { kind, id: id.as_bytes().to_vec(), sha256: [kind; 32] };
|
||
let t1 = "a".repeat(64);
|
||
let t2 = "b".repeat(64);
|
||
let base = RecordingManifest {
|
||
room: [1; 32],
|
||
signer: [0; 32],
|
||
from: 0,
|
||
to: 10,
|
||
claims: claim::COMPLETE,
|
||
purposes: purpose::RECORD,
|
||
region: *b"DE",
|
||
components: vec![comp(component::ROOM, "room"), comp(component::EVENTS, "events"), comp(component::TAKE, &t1), comp(component::TAKE, &t2), comp(component::COMPOSITE, "composite")],
|
||
sig: [0; 64],
|
||
};
|
||
let ok = base.clone().sign(&k);
|
||
assert_eq!(RecordingManifest::parse(&ok.to_bytes()), Ok(ok.clone()));
|
||
let mut swapped = base.clone();
|
||
swapped.components.swap(2, 3);
|
||
let mut no_events = base.clone();
|
||
no_events.components.remove(1);
|
||
let mut late_composite = base.clone();
|
||
late_composite.components.swap(3, 4);
|
||
for bad in [RecordingManifest { claims: 2, ..base.clone() }, RecordingManifest { from: 11, ..base.clone() }, RecordingManifest { region: *b"de", ..base.clone() }, swapped, no_events, late_composite] {
|
||
assert_eq!(RecordingManifest::parse(&bad.sign(&k).to_bytes()), Err(Reason::RecordingManifestInvalid));
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn a_fetch_names_the_takes_track_and_its_groups() {
|
||
let f = fetch_range(&[0xAB; 32], &[0xCD; 32], &[0xEF; 32], 4, 3);
|
||
assert_eq!(f.namespace, ["ab".repeat(32), "cd".repeat(32), "take".to_string()]);
|
||
assert_eq!(f.name, "ef".repeat(32));
|
||
assert_eq!((f.start_group, f.start_object, f.end_group_delta), (4, 0, 2));
|
||
// MOQT §8.7: a Full Track Name is at most 4 096 bytes.
|
||
assert!(f.namespace.iter().map(String::len).sum::<usize>() + f.name.len() <= 4096);
|
||
}
|
||
|
||
#[test]
|
||
fn the_reasons_are_distinct() {
|
||
let codes: BTreeSet<&str> = Reason::ALL.iter().map(|r| r.code()).collect();
|
||
assert_eq!(codes.len(), Reason::ALL.len());
|
||
for r in Reason::ALL {
|
||
assert!(room::Reason::from_code(r.code()).is_none(), "{r} is also a room code");
|
||
assert!(!take::Reason::ALL.iter().any(|t| t.code() == r.code()), "{r} is also a take code");
|
||
}
|
||
}
|
||
}
|