Migrating HMAC → Signed Connect Delivery
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:
bothmode 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
(
createConnectReceiverdefaults to signed;legacyHmacSecretis opt-in). authorizeis 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.createConnectReceivermakesauthorizea 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 defaultConfirm 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/provisionresult reportsdeliveryScheme === "ed25519-did"(anddelivered === true). - Platform: your receiver logs / metrics show every accepted delivery took the signed
path, none the
ocss-ext01/provision.v1HMAC 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 introduces a liveness risk HMAC never had. In sandbox, verification role-gates
the signer to an active enforcement-agent fixture and verifies to the test root, so connect
delivery couples to sandbox Trust-List availability and fixture freshness. A transient
sandbox outage could reject an otherwise valid test delivery.
Mitigate with last-known-good caching. The receiver should cache the last successfully
verified sandbox Trust List and fall back to it when a fresh fetch fails, so a blip does not
reject a provider fixture whose test signature still verifies. 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= yourdid), so a captured delivery cannot be replayed to a different receiver. The SDK rejects an audience mismatch beforeonBound. - Replay-freshness. A tight
createdwindow (createdSkewSec, default 300s) plus idempotency on theendpoint_id_labeldefeats same-receiver replay — a re-sent label returns a2xxidempotently 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. -
authorizelists exactly the provider DIDs you accept — nothing wider. - Migrating receiver runs
both(legacyHmacSecretset) — 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;
legacyHmacSecretremoved. ← completion. - Last-known-good Trust-List caching in place for the liveness dependency.
Next
- Provider Quickstart · createLink
- Platform Quickstart · createConnectReceiver
- OCSS Trust Framework — the pinned-root model this rests on