Skip to main content
The connect-leg delivery is moving from a shared, platform-scoped HMAC secret to each delivery signed by the provider’s own Trust-List key, verified to the pinned root. This page is the why and the safe how. The end state has no shared secret anywhere in the connect leg.

The one move

The shared HMAC connect secret is simultaneously the security weakness and the biggest source of friction. Retiring it — signing each delivery with the provider’s writer key, verified to root — does four things with one lever:
  1. Meets the OCSS security bar (kills a bearer secret the spec explicitly bans).
  2. Collapses the provider config to the 3-field createLink (one key it already holds).
  3. Collapses the platform receiver to one route + an allowlist.
  4. Removes the failure modes that make the connect leg brittle.
There is no security-vs-ease tradeoff to manage here.

Why HMAC is a genuine risk

Dispositive citation — §8.1 clause 6: “No operation in this document authenticates by API key, platform account, or bearer secret.” A platform-scoped shared HMAC secret is a bearer secret. The crypto is fine; the architecture is the problem. A shared, cleartext-on-both-sides, non-attributable, non-revocable secret is a foreign body in a system whose entire security claim (§11.9) is “recompute to a pinned root; don’t trust the holder.” A valid HMAC proves only “someone holding the platform’s secret” — exactly the non-attribution OCSS forbids: “an attestation that can only be confirmed by asking its author is not an attestation” (§4.3). Sender-DID-signed-to-root is the spec’s universal auth primitive (§4.2.1 sender_signature, §8.1.2) applied to the connect leg — the same primitive the rest of the spine already uses everywhere.
AlternativeWhy it loses
Per-binding shared secretStill a shared symmetric secret — fails verify-to-root + attribution identically.
mutual TLSIntroduces an X.509 CA — a second trust root beside the pinned Trust List.
Sealed envelope on the connect legThe seal blinds intermediaries, but the platform is the recipient — pointless encryption on top of the signed path.
Census-minted capability tokenA bearer token is a bearer secret — same §8.1.6 violation, just census-issued.
Hybrid as a resting stateboth is fine as migration, never permanent — carries (a)‘s strength and (b)‘s liabilities.

The security event is DELETION, not the flag

The single most important idea on this page:
both mode is itself the live vulnerability. While a receiver still holds its HMAC secret, an attacker with that secret can forge via the HMAC path even though signed is available — a downgrade attack on your own migration mechanism. Flipping the posture flag to signed does not close the hole; deleting the secret does.
So both is a bounded drain window, never a resting state. The migration completes for a receiver at the moment its HMAC secret is deleted — not when signed traffic first succeeds. Two standing policies from day one:
  • Stop minting new HMAC secrets. Every new receiver is signed-only (createConnectReceiver defaults to signed; legacyHmacSecret is opt-in).
  • authorize is required at signed-only posture. Role-gate-alone (trust any accredited enforcement-agent) is the never-ship baseline; the platform’s provider allowlist is the real authorization control. createConnectReceiver makes authorize a required field so that baseline is unrepresentable.

The safe cutover — per receiver

Migration is per platform receiver, driven by the provider, and every step is reversible until the delete.

1. Platform: run the signed receiver in both

Add the @phosra/gatekeeper/next receiver, keep the existing secret as legacyHmacSecret for the drain window only:
// app/api/ocss/connect/route.ts
import { createConnectReceiver } from "@phosra/gatekeeper/next"
import { store }   from "@/lib/ocss/store"
import { onBound } from "@/lib/ocss/apply"

export const { POST } = createConnectReceiver({
  env:        "production",
  did:        "did:ocss:notflix",
  seed:       process.env.OCSS_SENDER_SEED_B64URL!,
  authorize:  ["did:ocss:custo"],                 // add the provider DID(s) you accept
  store, onBound,
  legacyHmacSecret: process.env.OCSS_CONNECT_SECRET,  // DRAIN WINDOW ONLY — delete in step 4
})
Setting legacyHmacSecret puts the receiver in posture "both": it accepts a signed-verified-to-root delivery and a legacy HMAC batch. This is the only time a production receiver should carry a secret.

2. Provider: go signed (default) — stop passing the secret

createLink delivers signed by default. Simply do not pass __legacyConnectSecret on connect.finish / provision — the label is delivered ed25519-did, no secret involved:
const r = await link.connect.finish({ /* … */ })   // signed by default
Confirm the lane on the returned result — this is your drain-progress signal:
if (r.deliveryScheme !== "ed25519-did") {
  // still going out over HMAC — investigate before you delete the secret
}

3. Confirm zero HMAC traffic

Before deleting anything, prove no delivery is still riding the HMAC lane. Confirm from both ends:
  • Provider: every connect.finish / provision result reports deliveryScheme === "ed25519-did" (and delivered === true).
  • Platform: your receiver logs / metrics show every accepted delivery took the signed path, none the ocss-ext01/provision.v1 HMAC dispatch.
Only when a full traffic cycle is signed-only do you proceed.

4. Platform: DELETE the secret — the completion event

Remove the secret env var(s) and drop legacyHmacSecret. The receiver is now signed-only — posture is back to the default and the downgrade path is gone:
export const { POST } = createConnectReceiver({
  env:       "production",
  did:       "did:ocss:notflix",
  seed:      process.env.OCSS_SENDER_SEED_B64URL!,
  authorize: ["did:ocss:custo"],
  store, onBound,
  // legacyHmacSecret removed — DELETE OCSS_CONNECT_SECRET from the environment
})
Delete the secret from your secret store, not just the code. This delete is what completes the migration. Once every receiver in the fleet is drained-and-deleted, the provider can delete its connect-secret plumbing entirely (no per-DID secret map, no resolveConnectAuth branch).
Never leave a receiver resting in both. A receiver that has been signed-only-in-practice for weeks but still holds its HMAC secret is still exploitable via the HMAC path. Treat a lingering legacyHmacSecret as an open finding until the secret is deleted.

The new dependency: Trust-List liveness

Signed delivery buys a liveness risk HMAC never had — name it, don’t discover it in prod. Signed verification role-gates the signer to an active accredited enforcement-agent and verifies to root, so connect delivery now couples to Trust-List availability and accreditation freshness (the 7-day attestation TTL). A transient census outage or a lapsed re-attestation could reject an otherwise-legitimate delivery. Mitigate with last-known-good caching. The receiver should cache the last successfully verified Trust List and fall back to it when a fresh fetch fails, so a blip does not reject a provider that is still genuinely accredited. createConnectReceiver’s createdSkewSec governs the same-delivery freshness window; the Trust-List cache governs the availability window. Both are ops posture, not per-request code.

Audience-binding and replay-freshness

Don’t just “sign the body.” The signed connect-delivery form carries the same rigor as EXT-01’s provision form:
  • Audience binding (RFC 8707). The envelope binds to your receiver DID (audience_did = your did), so a captured delivery cannot be replayed to a different receiver. The SDK rejects an audience mismatch before onBound.
  • Replay-freshness. A tight created window (createdSkewSec, default 300s) plus idempotency on the endpoint_id_label defeats same-receiver replay — a re-sent label returns a 2xx idempotently without re-binding.
You configure the window; the SDK enforces the binding and the freshness on every delivery.

Checklist

  • New receivers are signed-only (no legacyHmacSecret). No new HMAC secrets minted.
  • authorize lists exactly the provider DIDs you accept — nothing wider.
  • Migrating receiver runs both (legacyHmacSecret set) — temporarily.
  • Provider delivers signed by default; deliveryScheme === "ed25519-did" on every result.
  • Zero HMAC traffic confirmed from both ends across a full cycle.
  • Secret deleted from env + secret store; legacyHmacSecret removed. ← completion.
  • Last-known-good Trust-List caching in place for the liveness dependency.

Next