Rotate a compromised key

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
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 SDK rather than raw curl (there is no phosra_ API key on this path — the census identifies you by signature).

Sandbox-first. Self-registration is sandbox-only (SANDBOX_MODE=true) — nothing here touches a production Trust List. Production identity, governance, and credential issuance remain roadmap; validate rotation semantics in a bounded design-partner pilot before depending on them.

Before you start

Install the protocol SDK — it is published to npm (@openchildsafety/ocss, current 0.1.5):

bash
npm install @openchildsafety/[email protected]
export PHOSRA_BASE="https://phosra-api-sandbox-production.up.railway.app"
  1. 1
    Get a sandbox DID and signing key

    Rotation needs an identity that signs. POST /api/v1/advisors/self-register 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.

    typescript
    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"

    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
    {
      "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"
    }

    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.

  2. 2
    Mint your endpoint — the credential you will later rotate

    POST /api/v1/platforms/{did}/endpoints 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
    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"],
    );

    Example sandbox 201v1 of the credential pair (secrets redacted):

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

    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.

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

    Example sandbox 201v2, the rotated pair (compare every field to v1 above):

    json
    {
      "capabilities": ["content_rating", "addictive_pattern_block"],
      "connect_secret": "REDACTED_SANDBOX_CONNECT_SECRET_V2",
      "connect_url": "https://dx-rotate.example.com/webhooks/ocss-connect",
      "endpoint_id": "5b4ed680-2bae-40ec-82ac-0b00c5976f25",
      "endpoint_id_label": "REDACTED_SANDBOX_ENDPOINT_ID_LABEL_V2",
      "rotated_at": "2026-07-06T11:25:27Z"
    }
    Fieldv1v2Rotated?
    connect_secretREDACTED_V1REDACTED_V2✅ new
    endpoint_id_labelREDACTED_V1REDACTED_V2✅ new
    endpoint_id5b4ed680…5b4ed680…❌ stable

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

  4. 4
    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 env contract changes:

    bash
    # .env — replace with the v2 values from the rotate 201
    PHOSRA_ENDPOINT_ID=REDACTED_SANDBOX_ENDPOINT_ID_LABEL_V2
    PHOSRA_CONNECT_SECRET=REDACTED_SANDBOX_CONNECT_SECRET_V2

    In the sandbox model, your stable endpoint_id, DID, and signing key remain unchanged while the inbound credential pair rotates. Validate this behavior in your design-partner pilot before depending on it for a production migration.

The whole flow at a glance

#StepCallLive result
1Get DID + keyPOST /advisors/self-registerdid:ocss:dx-rotate-dab69f, kid 2026-07, provisional
2Mint (v1)POST /platforms/{did}/endpointssecret 6NtItJOQ…, endpoint 5b4ed680…
3Rotate (v2)POST /platforms/{did}/endpoints (same connect_url)secret pnVMLkBC…, same endpoint 5b4ed680…
4Deploy v2swap PHOSRA_* envold secret dead, no enforcement gap

Next steps