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:
- Meets the OCSS security bar (kills a bearer secret the spec explicitly bans).
- Collapses the provider config to the 3-field
createLink (one key it already holds).
- Collapses the platform receiver to one route + an allowlist.
- 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.
| Alternative | Why it loses |
|---|
| Per-binding shared secret | Still a shared symmetric secret — fails verify-to-root + attribution identically. |
| mutual TLS | Introduces an X.509 CA — a second trust root beside the pinned Trust List. |
| Sealed envelope on the connect leg | The seal blinds intermediaries, but the platform is the recipient — pointless encryption on top of the signed path. |
| Census-minted capability token | A bearer token is a bearer secret — same §8.1.6 violation, just census-issued. |
| Hybrid as a resting state | both 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.
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.
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
Next