Two statements the Kotlin integration needed. §6.2: a window the platform shows without the input focus (a split screen, a picture-in-picture window, one under a translucent window) is shown, so a host does not end as hidden a session the viewer watches. §11: where the platform refuses unencrypted HTTP by default, the host's exception for its loopback server covers 127.0.0.1 alone. The header names the Kotlin library; CHANGELOG entry. Co-Authored-By: Claude Opus 5.5 <[email protected]>
26 KiB
WAI Extension: Native Sink
Status: Draft. What a native host, an application that plays a stream through its platform's own player, does so that the stream is verified where it carries WAI's receipts and evidenced as stream-audit evidences a page's playback. The platform's player decodes on the platform's media engine and is not changed; the sink sits on the path its requests take and, for one playback session: verifies a media playlist against its playlist receipt and the chain of its live revisions (http-delivery §8.6), holds an object whose name states its content hash to that hash, and tells the host, object by object, whether it may hand the bytes to the player (§4); signs, under a fresh key per session, what stream-audit has a player sign: settlement acknowledgements of every object it received, a presentation claim for each ad the stream marks, and the session record, with the energy of each role and its acquisition class or the unmetered marker (§5, §7); ends a session nobody can see, or whose viewer has gone idle (§6); and hands the host the bundle for the owner's collector. One C interface serves every native platform (§9). No new record, no new transport and no change to the wire format. The reference implementation is the
native_sinkmodule ofwai-rsand its C interfaceffi_sink(featurenative_sink, headerinclude/wai.h); the Swift packagewai/wai-sink-swiftintegrates it with the native player of the platforms whose language it is, and the Kotlin librarywai/wai-sink-androidwith the media player library of the phone and television platform whose applications are written in Kotlin; the corpus islive-conformance/native-sink/, and its second implementation islive-conformance/native_sink_verify.py. Keywords MUST, MUST NOT, SHOULD, MAY are RFC 2119/8174.Texts this revision relies on: the WAI texts it cites;
draft-pantos-hls-rfc8216bis-22§4.4 (the playlist tags a sink reads:EXTINF,EXT-X-MAP,EXT-X-KEY,EXT-X-SESSION-KEY,EXT-X-PROGRAM-DATE-TIME,EXT-X-DATERANGE,EXT-X-ENDLIST,EXT-X-STREAM-INF) and §4.2 (attribute lists); RFC 3339 §5.6 (date-times); RFC 3986 §5.2 and §6.2.2 (resolution and normalization); RFC 6454 §6.2 (the ASCII serialization of an origin); RFC 9110 §8.4 (content codings); RFC 8032; FIPS 180-4; and the BLAKE3 specification. A section number in this text is this extension's unless it names another document.
1. Scope
In scope: the session and its configuration (§3); what a sink reads of a playlist, how it verifies a playlist and an object, and what a host does with the answer (§4); the observations a host gives and the log they make (§5); the rule that ends a session nobody can see (§6); the energy a native host states (§7); the reasons (§8); the C interface (§9); and conformance (§10).
Out of scope: a transport; the records the sink signs, their verification and the channel report, which stream-audit defines; the playlist receipt and its chain, which http-delivery §8.6 defines; the player itself and its decoder; the collector; and how a host routes its player's requests through the sink, which each platform's integration states (§4.4).
Relationship to the core spec: this extension registers nothing. It is a profile of stream-audit for a native host, with verification added from http-delivery and packaging, and the C interface that every native integration shares.
2. Terms
- Native host: an application on a phone or tablet, a television or set-top box, or a computer, that plays a stream with the player its platform provides.
- Player: the platform's player. It requests the playlists and segments of an HTTP presentation, decodes and presents them; this extension changes nothing in it.
- Sink: the code of this extension, in the host's process, on the path the player's requests take: the player asks the sink, the sink fetches, verifies and answers (§4.4).
- Session, channel, owner, session key, object, origin: as stream-audit §2 and §3 define them.
- URL key: an object's URL after redirects, resolved against itself and normalized as http-delivery §11.5 does (RFC 3986 §5.2 and §6.2.2), without its fragment. Two spellings of one URL are one key.
- Content hash: of a WAI object, jwp-receipts §2.1's,
BLAKE3("moq-jwp:object\x01" ‖ envelope); of any other object, its SHA-256 (settlement §14.1). - Time is microseconds since 1970-01-01T00:00:00Z on the host's clock; a media playlist's program time is milliseconds on the same scale.
3. The session
3.1 Configuration
A host opens a session with a JSON object of these members; channel and owner are required,
every other has the default shown, and a member not listed, or a value of another type, is refused
(native_config_invalid):
| member | value | default |
|---|---|---|
channel |
the channel's name, as stream-audit §3.1 has it | — |
owner |
the owner's key, 64 lowercase hexadecimal digits | — |
presentation |
"live", "on_demand", or null to take it from the first media playlist taken: on demand when it has EXT-X-ENDLIST |
null |
window_us |
the acknowledgement window of stream-audit §4.2, at least 1 000 000 | 10 000 000 |
ad_classes |
EXT-X-DATERANGE classes the owner treats as ads (stream-audit §6.1) |
[] |
content_addressed |
true when the owner's packager names segments by the SHA-256 of their bytes (packaging §7), so that such names hold under a playlist with no receipt (§4.3) |
false |
require |
"nothing", or "signed": every media playlist sealed by its receipt, and every object listed by one (§4.2) |
"nothing" |
unseen_grace_ms |
how long a video stream stays unseen before the session ends as hidden, 0 for never (§6.1) |
10 000 |
idle_ms |
how long with no interaction before it ends as idle, 0 for never (§6.1) |
0 |
3.2 The session key
A sink MUST make a fresh Ed25519 key for each session from its platform's random generator, as stream-audit §3.2 requires, and MUST NOT derive it from an identifier of the viewer, the device or an account. The C interface takes a 32-byte seed in its place for conformance vectors and tests only (§9); a host MUST NOT pass one for a viewer's session. The session is stream-audit §3.2's, over the channel id and the key.
3.3 Lifetime
The session's log opens with a start observation at the time the session opens (§5.2). The
session ends when the host ends it (§6), and then signs its records once. Every call after that is
refused (native_session_ended), so no record can be signed twice for one session.
4. Verification
4.1 Reading a playlist
A sink reads a playlist as a player does, line by line (a line ends at LF; a CR, space or tab
before it is not part of it), and refuses (playlist_malformed) one that is not UTF-8, is longer
than 16 MiB, or whose first line, after an optional byte order mark, does not begin #EXTM3U. A
playlist with an EXT-X-STREAM-INF or EXT-X-I-FRAME-STREAM-INF line is a multivariant
playlist, and the sink takes nothing from it but that. Of a media playlist it reads:
- each media segment: the URI line after its
EXTINF, resolved against the playlist's URL key (§2); its duration, theEXTINFvalue as a decimal number of seconds read exactly and rounded half up to the millisecond (a value that does not read, or reads as 0, is 1 ms); and its program time, the lastEXT-X-PROGRAM-DATE-TIMEbefore it plus the durations of the segments since; - the URI of
EXT-X-MAP, and the URIs ofEXT-X-KEYandEXT-X-SESSION-KEY, resolved so; - each marked ad of stream-audit §6.1: an
EXT-X-DATERANGEwithSCTE35-OUTorSCTE35-CMD, or aCLASSthe configuration names, whoseSTART-DATEreads and which has aDURATION(or aPLANNED-DURATION), its marker the line as the playlist carries it; - whether it has
EXT-X-ENDLIST.
An attribute list is read as draft-pantos-hls-rfc8216bis-22 §4.2 writes it: a name of upper-case
letters, digits and -, =, and a quoted string or a value up to the next comma. A date-time is
RFC 3339 §5.6's date-time with an upper-case or lower-case T and Z, its fraction truncated to
the millisecond. These readings are the ones stream-audit's integration reads, so that the page and
the native host name the same segments and the same ads.
4.2 A playlist and its receipt
Before it hands a media playlist to the player, the host fetches the playlist's receipt file,
mr/<BLAKE3 of the playlist's bytes>.json resolved against the playlist (http-delivery §8.6), and
gives the sink both; a receipt that is not served is given as none. Then the sink, in order:
- reads the playlist (§4.1);
- with a receipt: checks the playlist against it as http-delivery §8.6 checks one
(
playlist_receipt_malformed,playlist_receipt_mismatch,playlist_receipt_invalid), and then takes it into the chain of receipts it holds for the playlist's URL key, as that section takes a live revision (playlist_receipt_unchained,playlist_receipt_parent_missing,receipt_name_mismatch,playlist_receipt_walk_exceeded,playlist_listing_rebound). Where the chain needs a parent receipt the sink does not hold, it answers with that parent's name,mr/<hash>.json, and takes nothing: the host fetches it relative to the playlist, gives it to the sink (or says it was not served), and gives the playlist again; - with no receipt: refuses it (
playlist_unsigned) when the configuration requires signing; - takes it: the playlist is signed when its receipt verified and chained, and listed otherwise. Its segments, its initialization section, its key URIs and its marked ads join the session, each segment and initialization section listed by its URL key, as signed or listed.
A refused playlist joins nothing, and the host MUST NOT hand it to the player. A multivariant playlist is listed, never signed: http-delivery §8.6 signs media playlists only.
4.3 An object
The host gives the sink every response body the player's requests received, with its content codings removed (RFC 9110 §8.4), its URL after redirects, the time its body was complete, its status, and whether it is media where it knows (an initialization or media segment; otherwise the playlists it took say so). The sink:
- keys: an object whose URL key is a key URI a playlist named is a key or licence response: it is not acknowledged, not logged and not checked (stream-audit §4.1);
- what its name states: the object's content hash is checked against the hash its URL states,
and an object whose bytes are another's is refused (
object_digest_mismatch):- a cid: the one the playlist line that lists the object states, its fragment's
cid(http-delivery §3.1) or else a last path segment<64 hex>.wai(http-delivery §5.2); else the one the object's own URL states in the same way; against the object's jwp-receipts §2.1 content hash; - a SHA-256: a last path segment of 64 lowercase hexadecimal digits, a dot and an extension
of letters and digits other than
wai, the name a floor fragment has in packaging §7 (floor/<id>/<SHA-256>.m4s), against the SHA-256 of the bytes, where a signed playlist lists the object or the configuration sayscontent_addressed. Under a listed playlist and without it, such a name binds nothing: a stream another service packages is never refused for what its names look like;
- a cid: the one the playlist line that lists the object states, its fragment's
- signing required: under a configuration that requires signing, an object that no signed
playlist lists, and that is not itself a playlist the session took, is refused
(
object_unsigned); - a playlist's body: an object that is the body of a playlist the session was given (by URL key and BLAKE3) is listed as that playlist was, and refused when it was refused;
- received: every object but a key, refused or not, is acknowledged and logged as stream-audit §4 and §7 say. An acknowledgement states what was received, not what was presented.
The answer states the object's SHA-256; its cid, where its URL states one; how it is listed
(signed, listed or unlisted); what bound its bytes (cid, sha256 or none); whether it is
media; whether it is a key; and the refusal, or none.
4.4 What a host does with an answer
A host routes the player's requests through the sink: a server on the loopback interface that the player is given in place of the origin, or the platform's hook for loading a player's resources, whichever the platform lets the player use for every request of an HTTP presentation. For an answer that refuses, the host MUST NOT hand the bytes to the player; it answers the player's request with a failure (the loopback server with status 502), and the player reacts as it reacts to a failed request. A host MAY fetch the object again from another origin the presentation names; the second response is an object of its own. A host MUST NOT answer the player with bytes the sink was not given.
What a verification establishes is http-delivery §11.6's: an object listed by a signed playlist and bound by its name is what the playlist's signer published; one listed, only what the origin that served the playlist named. Nothing here makes a third party's stream signed: such a stream is acknowledged, and evidenced, as stream-audit says.
5. Observations and the log
5.1 Observations
While the player presents, the host gives the sink an observation at least every 200 ms, and on each change of playback, visibility, audibility, rate or position, as JSON:
| member | value |
|---|---|
t_us |
when it observed |
playing |
presenting at normal speed: not paused, not stalled, not ended, rate 1 |
visible_bp |
the fraction of the player's area in view, in basis points (0 to 10 000, a number rounded to an integer): 0 while the application is not shown (§6.2) |
audible |
unmuted at a volume above 0 |
program_ms |
the program time of the position, or null |
seeked |
the position moved by a seek since the last observation |
interaction |
the viewer interacted since the last observation (§6.2) |
The sink builds the record's spans from them as stream-audit §5.2 does, and each marked ad's claim
spans as ad-presentation §10 and stream-audit §6.3 do. An observation with a member not listed, or
t_us or visible_bp missing, is refused (native_input_invalid).
5.2 The log
The sink keeps stream-audit §7.1's observation log. It opens with start when the session opens
(§3.3); the host logs present when the player shows its first frame, stall-start and
stall-end around each stall, and end or abandon when it stops. Where the host logged no
present, the first observation that is playing logs it; where it logged neither end nor
abandon, ending the session logs end for a session that ended ended and abandon for any
other. An event of another name is refused (native_input_invalid). A log's times never
decrease and nothing follows its end or abandon (delivery-telemetry §7.1): an entry made at a
time before the last entry's, which a host's player and its requests, on threads of their own,
make, is logged at the last entry's time; and after end or abandon the log takes nothing,
while an object received then is still verified and acknowledged. The host sends the log's
Event-mode CMCD reports to the owner's collector as it plays (stream-audit §7.2).
6. Ending
6.1 The termination rule
A sink ends a session that nobody can see or that nobody attends to, by the host's configuration:
- hidden:
unseen_grace_msis above 0, and the stream has been unseen (an observation that isplayingwithvisible_bp0, and none since withvisible_bpabove 0) for at leastunseen_grace_ms; - idle:
idle_msis above 0, and the last observation withinteraction, or the session's start where there is none, is at leastidle_msbefore.
The answer to the observation that meets a rule names it. The host then MUST stop the player's requests for the stream (it stops the player or removes its item, so no further segment is fetched) and end the session with that reason (stream-audit §8.2). A host that keeps the audio of a presentation playing while its video is hidden ends the video's session and keeps the audio's.
6.2 What a native host observes
| what | where a native host reads it |
|---|---|
| shown | the platform's state of the application and its window: in the foreground and active, not minimised, not behind the lock screen; visible_bp is 0 otherwise. A window the platform shows without the input focus (one of a split screen, a picture-in-picture window, one under a translucent window) is shown: "active" is the platform's state of being presented to the viewer, not of holding the focus |
| in view | the player's area against the window's visible area, and the platform's report that the window is fully covered, where it makes one |
| display | the platform's notice that the display slept, which makes the application not shown |
| interaction | the platform's input events to the application; at most one observation with interaction per second, carrying nothing of the input |
| audible | the player's mute state and volume, and the platform's audio route where it reports none |
A host states what its platform reports and claims nothing it does not: a platform that does not report occlusion has its window's place on the screen, not what other windows cover.
7. Energy
A session opens with the unmetered marker for the sink decode (role 3) and the display (role 4),
and no entry for the origin (role 1) or the delivery (role 2): stream-audit §8.1's rule for a sink
with no meter. A host states a role's figure with its acquisition class only when a counter or a
model covers that role's work (energy-measurement §2), replaces a marker with it, or removes a
role's entry where it knows nothing of the role. A figure of 0 with a class, or above 0 with none,
and a class energy-measurement §2.7 refuses, are refused (native_input_invalid).
The platform's player decodes on the platform's fixed-function decoder, in a process of the
platform's, which no energy counter an application can read covers. So a native host that plays
through it states the sink decode as the unmetered marker, never as a figure of its own process's
counter: that counter covers the host's own work (its requests, its hashing, its signing), not the
decode. A host that decodes in its own process, a WAI-native capability decoded through wai-rs
for example, states the reading of a counter that covers that process, with its class and
evidence. The display is the marker unless the host holds a figure for the panel with its class.
8. Reasons
| reason | refused when |
|---|---|
native_config_invalid |
the configuration does not read (§3.1) |
playlist_malformed |
a playlist is not UTF-8, is longer than 16 MiB, or does not begin #EXTM3U (§4.1) |
playlist_receipt_malformed |
the receipt file does not read (http-delivery §8.6) |
playlist_receipt_mismatch |
the playlist is not the one its receipt renders and hashes (http-delivery §8.6) |
playlist_receipt_invalid |
the receipt does not verify (http-delivery §8.6) |
playlist_receipt_unchained |
a live revision's receipt does not chain to the head (http-delivery §8.6) |
playlist_receipt_parent_missing |
a parent receipt the chain needs is not served (http-delivery §8.6) |
receipt_name_mismatch |
a parent receipt's hash is not the name it was fetched by (http-delivery §8.1, §8.6) |
playlist_listing_rebound |
a revision lists another object under a URL or sequence number the chain bound (http-delivery §11.5) |
playlist_unsigned |
a media playlist has no receipt where the configuration requires one (§4.2) |
object_digest_mismatch |
an object's bytes are not the content hash its URL states (§4.3) |
object_unsigned |
an object no signed playlist lists, where the configuration requires one (§4.3) |
native_input_invalid |
an observation, an event, an object's description or an energy entry does not read (§4.3, §5, §7) |
native_session_ended |
a call after the session ended (§3.3) |
A sink gives every other refusal of http-delivery §8.6 by its code (playlist_receipt_walk_exceeded
among them). A refusal of a playlist or an object is an answer, not an error: the call succeeds and
the answer says the bytes are not to be presented.
9. The C interface
include/wai.h declares it; libwai built with feature native_sink exports it, for every
native target, with no codec library (cargo build --release --no-default-features --features native_sink). A session is an opaque wai_sink_t. Inputs are bytes, NUL-terminated UTF-8 strings
and JSON; outputs are JSON or bytes in a wai_buffer_t the caller frees with wai_buffer_free.
Every function returns a status: WAI_OK (0); WAI_ERR_INVALID_ARG (1) for a null pointer or a
length that does not fit; WAI_ERR_PANIC (4), never an unwinding across the boundary;
WAI_ERR_SINK_INPUT (8) for an input that does not read, wai_last_error beginning with its reason;
and WAI_ERR_SINK_ENDED (9) after the session ended. A session is safe to call from any thread:
each call holds its lock. WAI_SINK_ABI_VERSION is 1 in this revision; wai_sink_abi_version()
returns the library's, and a host checks it before it opens a session. A later revision adds
members to the JSON it writes and functions to the interface, and does not change those of a
version.
| function | does |
|---|---|
wai_sink_open(config_json, seed, seed_len, start_us, &out) |
opens a session (§3); seed null for a fresh key |
wai_sink_free(s) |
frees it, signing nothing |
wai_sink_info(s, &out) |
{"channel", "owner", "player", "session", "start_us", "presentation", "ended"} |
wai_sink_receipt_name(playlist, len, &out) |
mr/<BLAKE3>.json, the receipt file to fetch beside a playlist |
wai_sink_playlist(s, url, bytes, len, receipt, receipt_len, &out) |
§4.2; receipt null for none. {"kind", "listing", "receipt", "receipt_name", "step", "need_parent", "refused", "present", "segments", "ads"} |
wai_sink_playlist_parent(s, url, name, bytes, len) |
hands over the parent need_parent named; bytes null when it was not served |
wai_sink_object(s, meta_json, bytes, len, &out) |
§4.3; meta_json {"url", "t_us", "media", "status", "cmsd_static", "cmsd_dynamic"}. {"sha256", "cid", "listing", "bound", "media", "key", "acknowledged", "refused", "present"} |
wai_sink_event(s, name, t_us) |
§5.2 |
wai_sink_observe(s, obs_json, &out) |
§5.1; {"end": null} or {"end": "hidden" or "idle"} (§6.1) |
wai_sink_energy(s, entry_json) |
§7; {"role", "joules_micro", "energy_provenance", "energy_uncertainty", "energy_counter_filtering", "energy_calibration_ref"}, the class as jwp-receipts §2.4.5 writes it, or {"role", "absent": true} |
wai_sink_attach(s, name, bytes, len) |
attaches a record (stream-audit §8.3) |
wai_sink_take_cmcd(s, &out) |
the Event-mode CMCD batch of the log since the last call |
wai_sink_end(s, ended, t_us, &out) |
ends the session (§6): the bundle of stream-audit §9 |
wai_audit_verify_bundle(bundle, len, owner, channels_json, &out) |
stream-audit §10's outcome, as the owner's collector reaches it |
wai_sink_now_us() |
the host's clock, for a host with none of its own to hand |
10. Conformance
- A conforming sink gives every call of the corpus the answer it states, and ends every case with the bundle it states, byte for byte; the bundle verifies under stream-audit §10 with the outcome the case states.
- The corpus holds, from fixed seeds: a signed on-demand presentation played to its end; a fragment
changed in transit; a session that requires signing given none; receipts that do not hold; a live
chain walked through a parent, under another key, with a parent not served and one served under
another name; WAI objects held to their cids; a live revision that rebinds an alias; a stream of
another service, with a key, a marked ad and a CMSD note; content addressing by the owner's word,
and names that bind nothing without it; sessions ended
hiddenandidle; energy entries; the log's order and its close; a case the page's integration of stream-audit replays call for call, signing the same bundle byte for byte; and refused calls and configurations. Every reason of §8 refuses at least one of them. - Its second implementation,
native_sink_verify.py, replays every case from this text, verifies every bundle with stream-audit's second implementation, and catches every mutation of the corpus and every one of its checks dropped. - A native integration's own tests drive it through its platform's player and require that every object the player was handed is one the sink presented, and that the bundle verifies.
11. Security and privacy considerations
- What verification proves. A signed playlist binds the segments it lists by URL; a name that states a content hash binds the bytes. A segment that is neither is what the origin served, and its acknowledgement says so and no more. A host that hands the player bytes a refusal withheld defeats the check, and nothing downstream can tell.
- The loopback server. A host's server for its player listens on the loopback interface alone,
on a port the platform assigns, and answers only the paths the session named, so that another
application cannot fetch through it what the session fetched. Where the platform refuses
unencrypted HTTP by default, the host permits it to the loopback address alone (
127.0.0.1), never to a name or another address, so the exception reaches no origin. - The session key names no viewer, device or account, as stream-audit §3.2 and §16 say; a seed is for vectors only.
- What the bundle tells. The URLs a session fetched are not in it: objects are named by their hashes, origins by their serialization, as stream-audit §7.1 has it. The observations' cadence and the spans tell how the stream was watched; a host sends them to the owner's collector and to no one else.