Skip to main content

Stream storage

StreamSdk.create(client, ledger, vault) needs two customer-operated stores: a SigningVault for the session keys, and a SessionLedger for opaque per-stream bookkeeping. Both are keyed by streamId and both transport Uint8Array values. Use in-memory Maps for development; in production, back them with whatever transactional store you already operate.

Two keys, two layers

C2PA live streaming uses a two-tier signing model from C2PA 2.3 §18.25.

LayerWhat signsWhere the key livesHow often it's used
Long-lived org key + X.509 chainThe init manifest (which embeds the session key as a c2pa.session-keys assertion)Inside the Limbo SignerOnce per init
Ephemeral session keyEach fMP4 media segmentIn the customer-operated SigningVaultEvery segment (sub-millisecond)

The session key carries a cryptographic binding back to the organization's certificate and a validity window. Invoking the org key once per segment is too slow for live, so the spec authorizes a short-lived key once (signed by the long-lived key), then signs segments locally with the rostered key until it expires.

SigningVault

Session keys are minted and used here. Private bytes never leave the implementation.

interface SigningVault {
mint(): Promise<VaultPublicKey>;
sign(kid: Uint8Array, payload: Uint8Array): Promise<Uint8Array>;
forget(kid: Uint8Array): Promise<void>;
}

interface VaultPublicKey {
kid: Buffer; // opaque key handle
publicKeySec1: Buffer; // 65-byte 0x04-prefixed ES256 point
}

The SDK never sees private bytes. mint returns a kid (key handle) and the SEC1 public key; the SDK then asks the vault to sign(kid, payload) per segment.

Why the vault isn't Limbo-managed

Session keys are ephemeral, but they remain signing material under the customer's identity for the duration of their validity:

  1. sign(kid, payload) runs per segment and must complete in local milliseconds. A Limbo-hosted vault would put the public internet on the per-segment path.
  2. The session key's binding to your organization is produced at mint time with the session private key. Whoever operates the vault has visibility into that key, and Limbo deliberately avoids holding customer signing material.
  3. A leaked session key can forge segments under the customer's identity until it expires. The operator accountable for that leak should be the custodian of the key.
  4. Customers typically already mint short-lived keys (Vault Transit, AWS KMS data keys, HSM-derived ephemeral keys). The SigningVault interface is three methods (mint, sign, forget) wrapping that infrastructure.

SessionLedger

The ledger holds the SDK's per-stream state as opaque bytes.

interface SessionLedger {
put(id: string, bytes: Uint8Array): Promise<void>;
get(id: string): Promise<Uint8Array | null | undefined>;
forget(id: string): Promise<void>;
}

The ledger bytes are an internal serialization the SDK owns. Do not inspect or construct them.

Why the ledger isn't Limbo-managed

  1. Atomicity is already provided by the customer's transactional store (Redis WATCH/MULTI, Postgres SERIALIZABLE, DynamoDB conditional writes). A Limbo-hosted ledger would be a strictly weaker hop in front of it.
  2. It is on the per-segment path. The latency argument is the same as the vault.
  3. The bytes are an internal serialization that may change between SDK versions. The customer's store handles bytes; it does not require a schema migration when Limbo ships a fix.

Single writer per stream id

The SDK assumes a single writer per streamId for the lifetime of a stream. If the encoder pipeline can fan out, serialize calls behind a per-id mutex. Different streamId values are fully independent.

See also