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 typehttps://docs.craig/problems/one-time-secret-already-issued. Thedetailnames 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-conflict409, 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_claimsrow answers in-window replays (default 30 days), and thepartner_api_keyspair 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_keyANDrotate_keywith 409 problem typehttps://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 sameclient_request_idis 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-issued409 (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-windowrequest_claimsanswer; 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:
-
Read the key id from the 409
detail("… already issued partner API key<uuid>…"). -
Revoke it:
craig partner revoke-key <partner_id> <key_id> -
Reissue with a fresh
client_request_id(omit--request-idand the CLI mints one):craig partner create-key <partner_id> --label "replacement for <key_id>" -
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:
409naming 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: apartner-not-active409 means the partner is suspended and nothing was provisioned — the pinnedclient_request_idis still unconsumed, so the script can safely re-run unchanged once the partner is re-activated (#1290). -
A bare re-run without
--request-idmints a fresh id and issues a new, additional key — by design. Prune extras withrevoke-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.