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

# Rotate a compromised key

> A platform credential leaked. Re-mint your endpoint to rotate the connect-secret and endpoint-id-label in one call — the old pair dies, your stable endpoint id survives, and enforcement never stops. The signed rotate flow, run live against the open sandbox.

A secret leaked — it landed in a log, a screenshot, a committed `.env`. The fix is not "delete and
start over"; it is **rotate**: mint a new secret, invalidate the old one, and keep the integration
running. In Phosra a platform's inbound credential pair — the `connect_secret` (the HMAC key that
authenticates deliveries to your webhook) and the `endpoint_id_label` (your registration name) — is
rotated by **re-minting your endpoint**. Re-minting the same `(did, connect_url)` pair issues a
fresh pair and **retires the old one**, while your stable `endpoint_id` never changes.

```mermaid theme={null}
flowchart LR
  A["POST /advisors/self-register<br/>get a sandbox DID + key"] --> B["POST /platforms/{did}/endpoints<br/>mint — connect_secret v1"]
  B --> C["leak detected"]
  C --> D["POST /platforms/{did}/endpoints<br/>re-mint SAME connect_url"]
  D --> E["connect_secret v2 live<br/>v1 dead · endpoint_id unchanged"]
```

Every request and response below is **verbatim live output**, captured against
`https://phosra-api-sandbox-production.up.railway.app`. The self-register step is plain `curl`; the
mint and rotate are **RFC-9421 signed**, so they run through the
[`@openchildsafety/ocss`](/ocss/protocol-sdk) SDK rather than raw `curl` (there is no `phosra_` API
key on this path — the census identifies you by signature).

<Info>
  **Sandbox-first.** Self-registration is sandbox-only (`SANDBOX_MODE=true`) — nothing here touches a
  real Trust List. In production your DID arrives via [accreditation](/integration/production-accreditation)
  instead of self-register, but the mint/rotate call is identical: same endpoint, same signed shape,
  same rotation semantics.
</Info>

## Before you start

Install the protocol SDK — it is published to npm ([`@openchildsafety/ocss`](https://www.npmjs.com/package/@openchildsafety/ocss),
current `0.1.3`):

```bash theme={null}
npm install @openchildsafety/ocss
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"
```

<Steps>
  <Step title="Get a sandbox DID and signing key">
    Rotation needs an identity that signs. [`POST /api/v1/advisors/self-register`](/ocss/onboarding)
    puts a fresh `did:ocss:<slug>` on the sandbox Trust List, bound to a public key you generate. Keep
    the **seed** private; publish only the public half.

    <CodeGroup>
      ```typescript TypeScript theme={null}
      import { ed25519PublicFromSeed, b64urlRaw } from "@openchildsafety/ocss";
      import crypto from "node:crypto";

      const BASE = "https://phosra-api-sandbox-production.up.railway.app";

      const slug = "dx-rotate-" + crypto.randomBytes(3).toString("hex");
      const did = `did:ocss:${slug}`;
      const seed = crypto.randomBytes(32);                 // KEEP SECRET (Uint8Array 32)
      const publicKeyB64Url = b64urlRaw(ed25519PublicFromSeed(seed));

      const reg = await (
        await fetch(`${BASE}/api/v1/advisors/self-register`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ did, name: "DX Rotate Demo", public_key_b64url: publicKeyB64Url }),
        })
      ).json();

      // The census binds your key under a bare kid = current UTC month:
      const keyID = reg.key_id; // e.g. "did:ocss:dx-rotate-…#2026-07"
      ```

      ```bash cURL theme={null}
      # Self-register is plain HTTP (no signature). You supply the base64url of your
      # Ed25519 PUBLIC key — generate the keypair with the SDK snippet in the other tab,
      # then paste the public half here.
      curl -s -X POST "$PHOSRA_BASE/api/v1/advisors/self-register" \
        -H "Content-Type: application/json" \
        -d '{
          "did": "did:ocss:dx-rotate-dab69f",
          "name": "DX Rotate Demo",
          "public_key_b64url": "CSm9bCM4QLqBv4j0VK7jLC9BWSXJP-DaTnUemYnR8xk"
        }'
      ```
    </CodeGroup>

    Live `200` — note `key_id`: the census binds your key under a bare kid equal to the **current UTC
    month**. That is the `keyID` you sign with everywhere below.

    ```json theme={null}
    {
      "advisor_id": "bf7c4925-3977-4546-a21d-5f5974be0ac4",
      "did": "did:ocss:dx-rotate-dab69f",
      "key_id": "did:ocss:dx-rotate-dab69f#2026-07",
      "kid": "2026-07",
      "published_key_x": "CSm9bCM4QLqBv4j0VK7jLC9BWSXJP-DaTnUemYnR8xk",
      "trust_tier": "provisional"
    }
    ```

    <Warning>
      Sign with **exactly** the `key_id` the census returns. A self-registered DID binds under
      `did:ocss:<slug>#<YYYY-MM>`; signing with any other kid fails `401` (kid-not-in-entry). Do not
      invent a bespoke kid — read it back from the registration response.
    </Warning>
  </Step>

  <Step title="Mint your endpoint — the credential you will later rotate">
    [`POST /api/v1/platforms/{did}/endpoints`](/integration/platform-registration) mints your endpoint
    and returns the two secrets **exactly once**. The request is RFC-9421 signed with the key from step
    1 — `signRequest` covers the request line and a `Content-Digest` of the body.

    ```typescript TypeScript theme={null}
    import { signRequest } from "@openchildsafety/ocss";

    async function mint(connectUrl: string, capabilities: string[]) {
      const bodyText = JSON.stringify({ connect_url: connectUrl, capabilities });
      const targetURI = `${BASE}/api/v1/platforms/${encodeURIComponent(did)}/endpoints`;
      const headers = signRequest({
        method: "POST",
        targetURI,
        body: new TextEncoder().encode(bodyText),
        keyID,
        seed,
        created: Math.floor(Date.now() / 1000),
      });
      headers["Content-Type"] = "application/json";
      const res = await fetch(targetURI, { method: "POST", headers, body: bodyText });
      return res.json();
    }

    const v1 = await mint(
      "https://dx-rotate.example.com/webhooks/ocss-connect",
      ["content_rating", "addictive_pattern_block"],
    );
    ```

    Live `201` — **v1** of the credential pair:

    ```json theme={null}
    {
      "capabilities": ["content_rating", "addictive_pattern_block"],
      "connect_secret": "6NtItJOQmQWEzleAJ5Dl26mLAekhjjHmct_QZx1_ohI",
      "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect",
      "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25",
      "endpoint_id_label": "PxEUb-CDZR3OhqqAG47PBKPMrsIlreXe0oGzvElrUks",
      "rotated_at": "2026-07-06T11:25:27Z"
    }
    ```

    <Warning>
      `connect_secret` and `endpoint_id_label` are each an **unprefixed 43-character base64url** secret
      returned **only in this body** — the census stores only their SHA-256 digests. Persist both to a
      secret manager immediately. If either leaks, you rotate (next step). `endpoint_id` is the stable,
      loggable UUID — it is *not* a secret and does *not* change when you rotate.
    </Warning>
  </Step>

  <Step title="Rotate — re-mint the same connect_url">
    Now the leak. To rotate, call the **same mint endpoint with the same `connect_url`**. The census
    recognises the `(did, connect_url)` pair and re-issues both secrets — this is the OCSS Trust
    Framework §3.5 re-issue path. Your `endpoint_id` is preserved; the old `connect_secret` and
    `endpoint_id_label` stop verifying.

    ```typescript TypeScript theme={null}
    // Same did, same connect_url → rotation (NOT a second endpoint).
    const v2 = await mint(
      "https://dx-rotate.example.com/webhooks/ocss-connect",
      ["content_rating", "addictive_pattern_block"],
    );

    console.log(v2.connect_secret !== v1.connect_secret);       // true  — new secret
    console.log(v2.endpoint_id_label !== v1.endpoint_id_label); // true  — new label
    console.log(v2.endpoint_id === v1.endpoint_id);             // true  — same endpoint
    ```

    Live `201` — **v2**, the rotated pair (compare every field to v1 above):

    ```json theme={null}
    {
      "capabilities": ["content_rating", "addictive_pattern_block"],
      "connect_secret": "pnVMLkBCBtMNgInBUaTTfI-Q-DzXzM4QbdGpj9pJN3w",
      "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect",
      "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25",
      "endpoint_id_label": "oiFMwdVWD8vv3bKf3NV7oO-SmR-qhXz-YzyZnmFYjNU",
      "rotated_at": "2026-07-06T11:25:27Z"
    }
    ```

    | Field               | v1          | v2          | Rotated? |
    | ------------------- | ----------- | ----------- | :------: |
    | `connect_secret`    | `6NtItJOQ…` | `pnVMLkBC…` |   ✅ new  |
    | `endpoint_id_label` | `PxEUb-CD…` | `oiFMwdVW…` |   ✅ new  |
    | `endpoint_id`       | `5b4ed680…` | `5b4ed680…` | ❌ stable |

    The leaked v1 secret is now dead. Any inbound delivery signed with it fails the HMAC check
    fail-closed; only v2 verifies.

    <Accordion title="Fields & errors — the rotate call">
      | Field          | Type      | Notes                                                                                                                                                                                        |
      | -------------- | --------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
      | `connect_url`  | string    | Absolute base URL of your `POST /api/ocss/connect` receiver. Re-minting the **same** value rotates; a **different** value registers a distinct endpoint. HTTPS required outside the sandbox. |
      | `capabilities` | string\[] | Optional declared capability slugs. You may keep them identical across a rotate.                                                                                                             |

      | Status | When                                                                                           |
      | :----: | ---------------------------------------------------------------------------------------------- |
      |  `201` | Rotated. New `connect_secret` + `endpoint_id_label`; same `endpoint_id`.                       |
      |  `401` | Signature invalid or wrong kid — sign with the `key_id` from self-register.                    |
      |  `404` | The signed caller DID does not match `{did}` in the path (no existence leak). Self-scope only. |
    </Accordion>
  </Step>

  <Step title="Deploy the new secret">
    Rotation is only complete once your gatekeeper is running on **v2**. Swap the two `PHOSRA_*` values
    in your secret manager and restart — nothing else in the
    [`@phosra/gatekeeper`](/integration/platform) env contract changes:

    ```bash theme={null}
    # .env — replace with the v2 values from the rotate 201
    PHOSRA_ENDPOINT_ID=oiFMwdVWD8vv3bKf3NV7oO-SmR-qhXz-YzyZnmFYjNU   # new endpoint_id_label
    PHOSRA_CONNECT_SECRET=pnVMLkBCBtMNgInBUaTTfI-Q-DzXzM4QbdGpj9pJN3w # new connect_secret
    ```

    Because your `endpoint_id`, DID, and signing key are unchanged, **already-established connections
    keep working** — a rotate re-keys the inbound channel without tearing down the family links you
    already hold. There is no enforcement gap.
  </Step>
</Steps>

## The whole flow at a glance

| # | Step          | Call                                                   | Live result                                               |
| - | ------------- | ------------------------------------------------------ | --------------------------------------------------------- |
| 1 | Get DID + key | `POST /advisors/self-register`                         | `did:ocss:dx-rotate-dab69f`, kid `2026-07`, `provisional` |
| 2 | Mint (v1)     | `POST /platforms/{did}/endpoints`                      | secret `6NtItJOQ…`, endpoint `5b4ed680…`                  |
| 3 | Rotate (v2)   | `POST /platforms/{did}/endpoints` (same `connect_url`) | secret `pnVMLkBC…`, **same** endpoint `5b4ed680…`         |
| 4 | Deploy v2     | swap `PHOSRA_*` env                                    | old secret dead, no enforcement gap                       |

## Next steps

<CardGroup cols={2}>
  <Card title="Platform registration" icon="id-card" href="/integration/platform-registration">
    The full endpoint-mint contract, the six `PHOSRA_*` env vars, and the webhook verify side.
  </Card>

  <Card title="Onboarding — get a sandbox DID" icon="user-plus" href="/ocss/onboarding">
    Self-register, read back your bound kid, and what `provisional` tier can and cannot do.
  </Card>

  <Card title="Back off and retry a 429" icon="clock-rotate-left" href="/guides/recipes/backoff-retry-429">
    The other half of resilient auth — a leaked-then-rotated key often surfaces first as a `401`.
  </Card>

  <Card title="Error reference" icon="triangle-exclamation" href="/errors">
    `signature_invalid`, kid-not-in-entry, and every other auth failure documented in one place.
  </Card>
</CardGroup>
