EP06: Publish/play security — signing, tokens and anti-hotlinking¶
| Recap | EP05 "How Simulcast works and what it's for" covered how layers are encoded and switched |
|---|---|
| Next | EP07 "How to strengthen resilience on weak networks" |
0. Goals of this episode¶
By the end, the viewer should be able to:
- Explain why the playback link's anti-hotlink MAC and the ingest auth Token use different cryptographic mechanisms, and the replay risk both bearer credentials share.
- Understand what role
keyIdis meant to play in key rotation: the format already supports it, but you need to tell apart "a reserved rotation interface" from "rotation capability that's actually live".
1. Opening hook (script notes)¶
Previous episodes were about making the picture fast and stable; this one changes topic — how to stop others from stealing your playback link for use elsewhere, and how to stop forged ingest requests impersonating your host. Both look like "just add a signature check", but this episode explains why this project uses two completely different cryptographic tools for the two cases.
2. Playback side: txTime / txSecret MAC anti-hotlinking¶
2.1 Mechanism: HMAC over the canonical resource and expiry¶
The playback link a viewer receives (or the request used to obtain it) carries
Authorization: Bearer {appId}:{txTime}:{txSecret}. Here:
txTime: a hex-encoded Unix-seconds expiry timestamp, visible in plaintext, not encrypted.txSecret: currentlyv1.{keyId}.{hex(HMAC-SHA256)}. The HMAC input is the exact byte concatenationkeyId + ":" + trim(streamID, "/") + ":" + strings.ToUpper(txTime), wherestreamIDis the canonicalized{appId}/{streamName}. Issuer and verifier must use identical path canonicalization, field order, separators, case and character encoding — not a similar variant in a diagram or SDK.
This is the common timestamp + HMAC anti-hotlink scheme. Strictly, HMAC is a shared-key message
authentication code (MAC), not a public/private-key signature: anyone holding appSecret can both
generate and verify, so the key must never be handed to an untrusted client.
Why include streamName (the path) in the MAC: this is the core of "anti-hotlinking" — the
txSecret authenticates "this stream's path + this expiry"; the same value won't verify against a
different path. So even if someone gets a valid link for room A, they can't move it as-is to room B;
but it can still be replayed for room A within its validity.
Why have the plaintext txTime expiry: once a link leaks (forwarded, screenshotted), it's only
valid until expiry and doesn't become a permanent backdoor.
But expiry is not anti-replay: Authorization: Bearer ... is a bearer credential; an attacker
who intercepts it within its validity can replay it as-is. Always use TLS, keep tokens out of URLs,
Referer, access logs and analytics, and set the shortest feasible TTL. For one-time semantics you
also need a server-side, atomically-consumed jti/nonce, or a credential bound to the session/
client key — HMAC + txTime alone cannot do this.
This signing scheme is actually used twice along the playback path, not once: the client first
calls ppcenter's playback API with a Bearer {appId}:{txTime}:{txSecret} it computed itself (or
obtained from its login session). Once ppcenter verifies that, it doesn't forward the token as-is
to the edge node — it signs again with the same GenTxSecretV1, this time over the WHEP address the
edge node will verify, in the form of URL query parameters ?txTime=..&txSecret=.. (see
GenerateEdgeWHEPURL/GenerateLegacyStreamBase in internal/services/stream.go). That second
signature covers the bare {appId}/{streamName}, without a codec suffix like /h264 or /hevc —
the edge node strips the codec segment from the path before verifying. That way the same signature
verifies both /h264/whep and /hevc/whep, and the client decides which suffix to append.
2.2 Two easily-missed verification details that matter¶
- Constant-time comparison: MAC verification must not use an early-exit string compare; this
project uses
hmac.Equal. This avoids the comparator leaking timing about the matching prefix; the full service must still avoid other branches, logs or error responses becoming side channels. - Security-sensitive failures stay undifferentiated — expiry is the exception: the current
implementation (
ApiAuthVerifyHandlerinppcenter/internal/apis/auth.go,V1PlayHandlerinplayer_v1.go) encodes "expired" as its own distinct error (secret expired/viewer_token_expired), because expiry by itself isn't sensitive — the client needs to know it so it can decide whether to fetch a fresh signature. But the two more sensitive failure modes, "signature doesn't match" and "appId doesn't exist", are folded into one generic error (invalid txSecret/invalid_viewer_token) that never tells the caller which step failed. The goal is to deny anyone probing the system any hint for "improving the next attempt" — what gets distinguished is decided by whether it leaks security-relevant information, not by making every failure look identical.
2.3 keyId: the rotation interface is reserved, but not wired up yet¶
The keyId field in v1.{keyId}.{signature} is where key rotation is supposed to hook in.
Without this field at all, rotating appSecret would have one consequence: every link already
issued and not yet expired would instantly stop verifying, because the server would no longer
know which key to check them against.
Imagine a complete multi-version key store: once keyId has somewhere to point, it should let
you:
- Stamp every newly issued signature with the key version currently active, as
keyId(say, a month string like2026-07). - Have the server parse
keyIdout of theBearervalue during verification and look up the matching key version — no guessing, and no verifying an old-key link against a brand-new key. - Rotate by having newly issued links switch to the new
keyId+ new key, while the oldkeyId's key stays valid through a transition window — old links aren't killed off the moment you rotate; they expire naturally on their owntxTime. - Validate the format of
keyIditself: no.or:separator characters allowed, so it can't be confused with the token's other delimiters or abused for parser-level injection (seenormalizeTxSecretKeyIDininternal/utils/utils.go).
But that "complete implementation" is not where ppcenter actually stands today. Read
internal/models/app_credential.go (and the underlying user_app.go) and you'll find
AppCredential stores exactly one AppSecret field per app — there's no slot for a second or
third key. internal/utils/utils.go's GenTxSecretV1WithKeyID does accept an arbitrary keyId,
but every actual signing call site in the repo — ApiAuthGenHandler in auth.go,
play_link_v1.go, origin_v1.go, record_manifest_v1.go, services/stream.go — calls
GenTxSecretV1 instead, which hardcodes keyID = "default". No public API today can issue a
signature with any keyId other than default. On the verification side, verifyTxSecretInternal
may well parse a different keyId out of the Bearer value, but it still recomputes the HMAC
against that one AppSecret — it never "looks up another key by keyId", because there is no other
key to look up.
In other words: if you actually rotate an app's AppSecret today, the effect is identical to not
having a keyId field at all — every old link fails immediately, regardless of its keyId. The
field is reserved in the wire format, paving the way for a future multi-version key store, but that
storage layer and the per-version revocation logic don't exist yet. "The format supports it" should
not be misread as "rotation without interruption works today".
2.4 Backward compatibility: retiring the old MD5 signature¶
The system still keeps an earlier MD5 format (md5(appSecret + streamID + strings.ToUpper(txTime)))
for migration-period compatibility only. It is not HMAC and lacks clear domain separation and modern
protocol design guarantees; MD5 is unsuitable for new security design. New signing must use only
HMAC-SHA256 v1; production should reject legacy MD5 by default and, for apps that truly need it,
use a separate switch, monitoring and a clear sunset date. Even with HMAC, if the claims don't bind
action, node or session, the same bearer may still be replayed across uses; an algorithm upgrade
is not a substitute for authorization-scope design.
3. Ingest side: the WHIP Token uses encryption, not a signature¶
This is not the nodeSecret used for node registration
The "ingest Token" discussed here is the application-layer credential an ingest client like
OBS/ppobs exchanges for a chance to publish. It is a completely separate mechanism from the
nodeSecret an mmx node uses to register itself with ppcenter (WS NodeRegister/
NodeRegisterAck, a node-level credential — and as of the recent v1.0.70, self-hosted nodes
switched from "displaying the nodeSecret" to "registering with a licenseCode"). One
authenticates "this publish request"; the other authenticates "this node itself". Neither
substitutes for or is reused as the other, and their decryption keys and issuance/revocation
lifecycles are completely different.
3.1 Why the playback approach can't be copied¶
The ingest Token must carry more structured information: device id, target appId/streamName,
bound codec (h264/hevc), issue time and expiry. With "plaintext fields + signature", those fields
are exposed in plaintext inside the Token — anyone with the Token sees everything, and a signature
only proves "not tampered", not "content confidential".
Ingest chose another path: wrap the whole Token content with AES-256-GCM encryption, instead of
plaintext + signature. This Token travels in the WHIP-Device-Id request header (falling back to
the standard Authorization: Bearer <token>); WHIP publishing requires it — if it's missing,
the request is rejected outright, with no switch to turn that requirement off.
SRT publishing uses the exact same Token, but doesn't require it by default: SRT carries the
same AES-256-GCM Token in the third segment of streamid (publish:{appId}/{stream}:{token}), and
the verifier (mmx) decrypts it with the exact same Go implementation and the same
WHIP_AUTH_KEY as WHIP — not a separate mechanism. The only difference is whether verification is
enforced, controlled by the srtPublishTokenRequired switch, which defaults to false. In other
words, under today's production configuration, an SRT publish without a Token still gets through,
falling back to SRT's own streamid user/pass (which amounts to no verification at that layer). This
is a stopgap kept for compatibility with older ppobs builds and third-party SRT publishing tools
that don't send a token — it is not a design decision that SRT doesn't need authentication. See §3
of docs/design/ppcdn-mmx-publish-whitelist.zh-CN.md for details.
3.2 Mechanism: one encryption pass gives confidentiality and tamper-resistance at once¶
- Serialize the fields
{uuid, appId, streamName, codec, iat, exp}to JSON as plaintext. - Encrypt the JSON with AES-256-GCM. Each encryption generates a new random nonce, encoded into the Token alongside the ciphertext; the nonce must be unique under one AES-GCM key, or confidentiality and integrity are badly broken. Use a CSPRNG, check for randomness-source errors, monitor key rotation, and avoid counter/fixed nonces that could repeat across implementations.
- The current implementation turns the configured string into a 32-byte AES key via
SHA-256(authKey). That's a deterministic hash mapping, not a salted, cost-parameterized password KDF, and adds no strength to a low-entropy passphrase.authKeymust be CSPRNG-generated with enough entropy; if the product allows human passphrases, switch to Argon2id/scrypt/PBKDF2 with a separate salt. Different uses should also apply domain separation (e.g. HKDF) rather than reusing one raw key for other protocols. - GCM is an "authenticated encryption" (AEAD) algorithm: it carries an integrity check within the encryption, so any tampering with the ciphertext makes decryption fail outright — no separate signature field as in the MAC scheme. That's the essential difference from the playback-side HMAC: HMAC is two-part "plaintext + separate signature", GCM folds encryption and tamper-resistance into one operation.
- After decryption the expiry is also checked; because
codecis bound, a Token signed only for theh264path is rejected when used against thehevcpath — in multitrack, the two codecs' publish rights never cross.
AES-GCM provides only confidentiality, integrity and authenticity from a shared key; it does not
provide replay protection. The WHIP Token is likewise a bearer token: stolen before exp, it can be
replayed. Beyond TLS, short TTL and log redaction, high-risk publishing should use server-side
one-time jti/nonce consumption records, or bind the credential to a device-held key and a specific
session. Also validate iat, exp, allowed clock skew, target app/stream/codec and necessary
action/audience; every node's clock must stay synchronized.
3.3 Two mechanisms, two design goals¶
| Playback (txTime/txSecret) | Ingest (WHIP Token) | |
|---|---|---|
| Crypto primitive | HMAC-SHA256 (shared-key MAC) | AES-256-GCM (authenticated encryption) |
| Content in plaintext? | Yes (path, expiry plaintext; MAC resists tampering) | No (claims are encrypted) |
| Who can verify | Any holder of appSecret can compute and verify |
Only the holder of the encryption key can decrypt |
| Key rotation | Format carries keyId; verifier still needs a multi-version key store and revocation |
Current format has no keyId; rotation needs deployment coordination or a token-envelope upgrade |
| Design goal | Lightweight, stateless, self-computable — good for short-lived playback links | Confidentiality + tamper-resistance in one — good for sensitive structured fields |
| Replay property | Replayable within validity unless one-time state or key binding is added | Replayable within validity; a GCM nonce is not an anti-replay nonce |
In one line: "only needs to authenticate content and can stay public" → MAC; "content must be confidential and tamper-proof" → authenticated encryption. Neither is inherently replay-proof for bearers; authorization scope and token lifetime still need separate design.
4. Diagrams¶
4.1 Playback-side MAC verification¶
Client requests playback
│ carries Authorization: Bearer {appId}:{txTime}:{txSecret}
▼
Server parses keyId out of txSecret's "v1.{keyId}.{hex}" form
│
▼
Fetch that appId's single appSecret (no per-keyId lookup across multiple
key versions exists today, see §2.3)
│
▼
Compute HMAC-SHA256(keyId + ":" + trim(streamID,"/") + ":" + upper(txTime)) with canonical rules
│
▼
Constant-time compare against the client signature, bit by bit
│
├─ expired ──▶ return "expired" (a distinct error from the one below)
├─ not expired, but mismatch ──▶ return a uniform "invalid signature" (an unknown appId gets
│ the same response, with no further distinction)
└─ not expired and matches ──▶ pass, return the playback address
4.2 Key rotation: keyId only works this way once paired with a multi-version key store (not the case today)¶
This diagram shows the target design, not ppcenter's behavior today
Per the code check in §2.3, ppcenter today stores exactly one AppSecret per app, and every
signing call site hardcodes keyId = "default". What follows describes how keyId is meant to
support rotation once a multi-version key store is wired in — understanding it clarifies
"why this field exists", but planning a production rotation around this diagram would fail
today, since looking up an old key by keyId and revoking by version are both still
unimplemented on the backend.
Timeline ──────────────────────────────────────────────▶
[old key keyId=2026-06 active]
│
│ links signed with the old key are still in viewers' hands, until their txTime
│
▼
[rotation: new key keyId=2026-07 starts signing new links]
│
├─ new requests ──▶ signed with keyId=2026-07
│
└─ old links (keyId=2026-06) ──▶ server keeps the old key, verifies them normally
│
▼
[all links for the old key expire naturally]
│
▼
[ops can safely retire keyId=2026-06]
4.3 Ingest Token: encrypt and decrypt¶
ppcenter issues an ingest Token
│
│ plaintext claims: {uuid, appId, streamName, codec, iat, exp}
▼
Generate a unique random nonce, run AES-256-GCM
│ current key mapping = SHA-256(authKey); authKey must be a high-entropy random secret
│
▼
Hand the ciphertext Token to ppobs
ppobs starts a WHIP publish request with the Token
│
▼
ppmmx decrypts with the same key via AES-256-GCM
│
├─ decryption fails (ciphertext tampered, GCM check fails) ──▶ reject
├─ decrypts but time claims invalid (iat/exp/clock skew) ──▶ reject
├─ decrypts but codec doesn't match the requested path ──▶ reject
├─ jti already consumed (if one-time tokens are enabled) ──▶ reject replay
└─ all pass ──▶ allow publish
5. Wrap-up and next episode (script notes)¶
This episode clarified the boundaries of the two security mechanisms: playback-side HMAC protects the integrity of plaintext claims, ingest-side AES-GCM provides confidentiality and integrity at once; the
keyIdfield already reserves an interface for multi-version key rotation, but ppcenter today still has only a singleAppSecretper app, hardcoded todefault— true no-interruption rotation only becomes real once the backend wires in a multi-version key store. A random GCM nonce addresses the uniqueness encryption needs, not bearer replay. Both credentials require TLS, short TTLs, strict claim binding and log redaction; to be non-replayable you must add server-side one-time state or a holder-key binding.Next episode, back to weak networks — beyond the protocol-layer degradation from EP04, what other engineering measures strengthen a live stream's weak-network resilience?