This migration applies to the legacy lanes —
createLink on the provider side and
createConnectReceiver on the platform side. The Golden
credential-driven paths (Provider Quickstart,
Platform Quickstart) never hold a connect secret, so there is
nothing to migrate there.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.
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.1sender_signature, §8.1.2) applied to the connect leg — the same primitive
the rest of the spine already uses everywhere.
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
(
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:
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:
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.
4. Platform: DELETE the secret — the completion event
Remove the secret env var(s) and droplegacyHmacSecret. The receiver is now signed-only —
posture is back to the default and the downgrade path is gone:
resolveConnectAuth
branch).
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= 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.
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 · createLink (writer-plane compatibility)
- Platform · createConnectReceiver (legacy connect receiver)
- OCSS Trust Framework — the pinned-root model this rests on