Partner API Key One-Time Secrets Runbook (#1262 / ADR-062)

On this page

How to operate issue_key / rotate_key under the ADR-062 one-time-secret contract. Mechanism detail lives in ADR-062 (§B one-time secrets) and the program plan (archived) (unit Bs2); the endpoints themselves are in the craig-security API reference.

The contract

A partner API key’s plaintext leaves craig-security exactly once — in the 200 body of the original issue_key or rotate_key call. It is never stored (only its SHA-256 hash is) and can never be re-derived, so these two routes do NOT follow the convert-class replay rule (same client_request_id + intent ⇒ replayed 200). Instead:

  • Retrying the identical request (same client_request_id, same intent) returns 409 with problem type https://docs.craig/problems/one-time-secret-already-issued. The detail names the key id the original call issued — never the secret, its hash, or its prefix.

  • Reusing the id with a different intent (different label/expiry, a different partner or key in the path, or a different actor) returns the generic leak-free idempotency-conflict 409, exactly like every other claimed route — on either side of the horizon: in-window the claim row classifies, and after it prunes the pair-column backstop verifies the stored intent digest before naming anything.

  • The refusal has a LIFETIME horizon: the request_claims row answers in-window replays (default 30 days), and the partner_api_keys pair columns (create_request_id + partial UNIQUE) refuse forever after — a retry from a months-old script can never mint a silent second secret.

  • Partner lifecycle gates both routes (#1290): a suspended partner refuses issue_key AND rotate_key with 409 problem type https://docs.craig/problems/partner-not-active; a soft-deleted partner answers 404 (the standard deletion-masking shape). The check runs inside the write transaction against a row-locked (FOR SHARE) status read, so a suspension racing an issuance can never produce a key on a suspended partner. A lifecycle refusal consumes nothing — no claim, no key row — so the same client_request_id is safe to retry after the partner is re-activated.

  • Precedence: a matching replay outranks lifecycle. If the original call already issued the key, the truthful one-time-secret-already-issued 409 (naming the key id) still answers even when the partner has since been suspended or deleted — the replay describes what already happened, not what is now allowed (the #1289 posture). This is the in-window request_claims answer; after the horizon the lifecycle gate speaks first, but the pair-column backstop still guarantees a retry can never mint a second secret — on a re-activated partner it answers the same named 409.

rotate_key carries the id as its (only) JSON body field — {"client_request_id": "<uuid>"} — and applies the same contract to the rotated-in key. Internally the new key row is inserted before the old key is revoked, so a post-horizon rotate retry hits the pair-column backstop (409) rather than a misleading "already revoked" 404; a genuinely failed rotation (unknown key, cross-partner key, already-revoked key) rolls the whole transaction back and consumes nothing — retrying with the same id after fixing the request is safe.

I got the 409 and I don’t have the plaintext — revoke and reissue

First check the problem type: a https://docs.craig/problems/partner-not-active 409 is not this scenario — no key was issued and nothing was consumed. Its remedy is to re-activate the partner (PUT /v1/security/partners/{id} with status: "active") and retry the same request, same client_request_id. The rest of this section is about the one-time-secret-already-issued 409.

The 409 means the operation already succeeded once and the plaintext was shown to whoever made that call. If it wasn’t captured (lost terminal scrollback, crashed pipeline, misplaced browser tab), the secret is unrecoverable by design. Recover by replacing the key:

  1. Read the key id from the 409 detail ("… already issued partner API key <uuid> …").

  2. Revoke it:

    craig partner revoke-key <partner_id> <key_id>
  3. Reissue with a fresh client_request_id (omit --request-id and the CLI mints one):

    craig partner create-key <partner_id> --label "replacement for <key_id>"
  4. Deliver the new plaintext to the partner over the established secure channel and confirm cutover before their old-credential grace expectations lapse (the old key is already dead from step 2).

The same remedy applies to a lost rotation result: revoke the key the 409 names, then issue a fresh key — there is no need to rotate the (already revoked) original again.

Scripted issuance: pinning the retry id

Automation that must survive re-runs should pin the id explicitly:

craig partner create-key <partner_id> --label ci-provisioned \
  --request-id 0198c000-0000-7000-8000-000000000001
  • First run: 200, plaintext printed once — capture it in that run.

  • Any re-run: 409 naming the key id. The script should treat that as "already provisioned" and fail over to its stored copy of the secret — NOT parse the 409 for a secret (there is none in it).

  • Branch on the problem type, not just the status: a partner-not-active 409 means the partner is suspended and nothing was provisioned — the pinned client_request_id is still unconsumed, so the script can safely re-run unchanged once the partner is re-activated (#1290).

  • A bare re-run without --request-id mints a fresh id and issues a new, additional key — by design. Prune extras with revoke-key.

What the 409 does and does not disclose

The named 409 reaches only a caller reproducing the original request — same body, same path, same effective actor (the stored intent digest embeds all three, and both the in-window claim row and the post-horizon pair-column backstop verify it before naming). Any mismatch — a different label, partner, key, or actor — gets the generic idempotency-conflict and learns nothing, on either side of the horizon. What the matching caller learns is only the key id: never the secret, its hash, or its prefix — and under the reference authorization policy every principal that can reach these routes also holds List on the same resource, so the id was already obtainable via list-keys.

Edit this page · latest