Skip to main content

Key rotation

A stream owns one session key for its entire lifetime. When the key nears expiry, you end the stream and start a fresh one. That is rotation: there is no in-place rotation, no overlap window, no roster mutation. See Sign a live stream for the lifecycle.

Rotation policy

The rotationPolicy argument is required and passed per init call:

const rotationPolicy = {
refreshPercent: 80, // 0-100: signal rotation at 80% of validity
validityPeriodSecs: 3600, // session key lifetime
};
  • validityPeriodSecs: how long the minted session key is valid.
  • refreshPercent: once this percentage of the validity window has elapsed, sign starts returning rotationDue: true.

The gap between refreshPercent and 100% is your window to rotate before the key expires. SignedSegmentOutcome.validUntilEpochSecs gives the exact expiry, so you can schedule the rotation a few segments out or align it to a clock boundary.

rotationDue signals start-a-fresh-stream

sign returns rotationDue: true; the consumer decides when to act. The SDK has no view of the segment timeline, the CDN's invalidation API, or which region is authoritative under active-active failover. The encoder is the only component with visibility into all three, so rotation is not automatic.

When you act on the signal, re-init under the same streamId:

rotate.ts
let stream = await sdk.init(streamId, initBytes, title, storage, rotationPolicy, manifest);
publishInit(stream.signedInitBytes);

for await (const segment of encoderSegments) {
const out = await sdk.sign(streamId, segment.bytes);
publishSegment(segment.name, out.bytes);

if (out.rotationDue) {
const fresh = await encoder.initSegment();
stream = await sdk.init(streamId, fresh, title, storage, rotationPolicy, manifest);
publishInit(stream.signedInitBytes);
// Whatever your delivery layer does to signal a discontinuity goes here.
}
}

await sdk.stop(streamId);

Re-initing under the same streamId is the rotation primitive: init mints the new key, records the fresh session state in your ledger, and forgets the previous key from the vault on its way out. stop is for "this stream is done for good." The verifier identifies streams by C2PA manifest_id, so a re-used streamId with a fresh manifest is a brand-new stream from its perspective.

Discontinuity handling

How a viewer transitions from one stream's segments to the next is a delivery-layer concern. Anything that causes a compliant player to re-fetch the initialization segment works: #EXT-X-DISCONTINUITY in HLS, a new Period in DASH, a separate playlist, a CMAF switching set, or whatever your pipeline already does for codec switches and ad insertions. This is the same delivery-level model viewers and verifiers already implement for discontinuities.

Chaining manifests across rotations

Each rotated stream is an independent C2PA manifest. For a provenance chain across rotations, pass the previous stream's signed init bytes as a parentOf ingredient in the next init's manifest:

const next = await sdk.init(streamId, initBytes, title, storage, rotationPolicy, {
...manifest,
origin: { parent: { data: previous.signedInitBytes } },
});

Pair with storage: { mode: "remote" } so the Signer persists prior manifests and the published init segment carries a remote URL instead of the full ingredient bytes. Manifest size then stays bounded no matter how long the chain grows. See Stream storage for the storage model.

See also