> ## Documentation Index
> Fetch the complete documentation index at: https://docs.phosra.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Migrating HMAC → Signed Connect Delivery

> Retire the shared connect secret. Why OCSS §8.1.6 bans bearer secrets, why sender-DID-signed-to-root is the native auth, and the safe drain-then-delete cutover — with secret DELETION as the completion event.

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`](/integration/link-provider#integrate-in-15-lines) (one key it already holds).
3. Collapses the **platform** receiver to [one route + an allowlist](/integration/link-platform#the-entire-receiver).
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.

| 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.

### 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:

```ts theme={null}
// 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:

```ts theme={null}
const r = await link.connect.finish({ /* … */ })   // signed by default
```

Confirm the lane on the returned result — this is your drain-progress signal:

```ts theme={null}
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:

```ts theme={null}
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).

<Warning>
  **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.
</Warning>

***

## 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

* [Provider Quickstart · createLink](/integration/link-provider)
* [Platform Quickstart · createConnectReceiver](/integration/link-platform)
* [OCSS Trust Framework](/ocss/trust-framework) — the pinned-root model this rests on
