# CI: hand a fresh credential to a server or a later job

A job creates with its own OIDC identity, so the pipeline holds no ckrets key. The start, the keys and the audiences are
in [For agents](/for-agents.md); the full schemas in [/openapi.json](/openapi.json).

The job that mints the credential creates a token bound to the recipient with its own OIDC identity (no ckrets key
stored), a short TTL and one reveal, and revokes it if nobody collects. The recipient pulls it on its own schedule.
Here the recipient is a server that holds an Ed25519 key it never shares (`/docs/signing.md#making-the-keys`), named
as a `publicKey` audience; the server's public JWK is not a secret and can sit in the workflow file.

```yaml
permissions: { id-token: write, contents: read }
steps:
  - name: Hand the new DB password to the server
    shell: bash          # -eo pipefail: any failing command below fails the step
    env:
      NEW_PASSWORD: ${{ steps.rotate.outputs.password }}
      SERVER_JWK: '{"kty":"OKP","crv":"Ed25519","x":"11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo"}'
    run: |
      CKRETS=${CKRETS:-https://ckrets.ai}
      OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
        "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
      jq -n --argjson jwk "$SERVER_JWK" --arg p "db rotation run $GITHUB_RUN_ID" \
        '{value:$ENV.NEW_PASSWORD, ttl:"15m", reveals:1, audience:{type:"publicKey", jwk:$jwk}, purpose:$p}' |
      curl -sS --fail-with-body -X POST "$CKRETS/v1/secrets" \
        -H "authorization: Bearer $OIDC" -H 'content-type: application/json' \
        -H "idempotency-key: $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-db" --data-binary @- |
      jq -er '.token // empty' > token.txt
```

`jq` reads the password from the environment (`$ENV.NEW_PASSWORD`), not from an argument, so it never appears in argv
or the log. A failed create fails the step instead of writing the string `null` to `token.txt`: `--fail-with-body`
makes curl exit non-zero on any non-2xx answer (and still prints the problem body), `jq -e` exits non-zero when there
is no token, and `shell: bash` turns on `pipefail` so either one stops the step. The idempotency key includes the
run attempt, because a rerun would otherwise get back a token that may already be spent. Only `POST /v1/secrets` reads
an idempotency key (`POST /v1/keys` with one still mints a new key). It is scoped to you: your address when anonymous
(an IPv6 caller by its /64), so callers behind one NAT or shared egress address share one scope; else your key; or for
a CI job its issuer and repository, so every job of one repository shares one scope (the credential is checked first).
In a shared scope make the key unguessable (a UUID or a run id), never a counter. It is bound to the request: `value`, `ttl`, `reveals`, `audience`,
whether a passphrase is set, `clientEncrypted` and `wrappedKey`. It is recorded only when a secret is made: a create
refused with `400`, `413`, `428` or `429` leaves it unused. For 24 hours from the create that first used it (to the
whole second), the same key and the same request return the first answer exactly as it was made (`201`,
`Idempotent-Replay: true`, nothing counted): if only `purpose` or the passphrase's value changed, you get the first
secret with its first purpose and passphrase. The same key with a different request is `409 idempotency-mismatch`: it
makes nothing but counts as one create, and after three different requests the key is retired (`409
idempotency-key-retired` for every later request with it, the right one included), so nobody can find your value by
trying requests under your key; another caller's key never returns your answer. Two creates sent at once with the same key make one
secret: the second answers `409 idempotency-in-progress` with `Retry-After: 1`, and its retry gets the first answer. Without the header, every
create makes a new secret. After a `503` with `outcome: "unknown"` the key stays reserved for 30 seconds:
a retry with it answers `409 idempotency-in-progress` until then, and after that makes a new secret (the first, if it
was made, expires unrevealed: nobody holds its token). After a bare `503` from the platform (no body) the create may
have finished: a retry with the same key then gets its answer (`Idempotent-Replay: true`). The server reveals with
`CKRETS_SIGNING_KEY=/etc/ckrets/server.jwk npx -y @ckrets/helper@0.9.0 reveal "$TOKEN" --env DB_PASSWORD -- ./reload.sh`, or
with the Python signer. Its clock must be within 60 seconds (NTP), because the signature carries `created`.

If a later job in the same repository is the recipient instead, bind to its identity:
`"audience":{"type":"oidc","issuer":"https://token.actions.githubusercontent.com","claims":{"repository":"acme/api","job_workflow_ref":"acme/api/.github/workflows/deploy.yml@refs/heads/main"}}`,
and that job reveals with `npx -y @ckrets/helper@0.9.0 reveal "$TOKEN" --env DB_PASSWORD -- ./migrate.sh` under
`permissions: id-token: write`. The ckrets token is safe to pass between jobs as an output here only because its audience binds it to that job; a token without an audience is a bearer secret and must never sit in a log or an output.

## One job to a later job, complete (GitHub Actions)

The token is safe as a job output only because its audience binds it to the deploy workflow. The later job reveals with
plain `curl` and its own OIDC token, so this works before the helper is published.

```yaml
jobs:
  mint:
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read }
    outputs: { token: ${{ steps.hand.outputs.token }} }
    steps:
      - id: hand
        shell: bash
        env: { NEW_PASSWORD: ${{ steps.rotate.outputs.password }} }
        run: |
          CKRETS=${CKRETS:-https://ckrets.ai}
          OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
          jq -n --arg wf "$GITHUB_REPOSITORY/.github/workflows/deploy.yml@$GITHUB_REF" \
            '{value:$ENV.NEW_PASSWORD, ttl:"15m", reveals:1, purpose:"db password for deploy",
              audience:{type:"oidc", issuer:"https://token.actions.githubusercontent.com",
                        claims:{repository:$ENV.GITHUB_REPOSITORY, job_workflow_ref:$wf}}}' |
          curl -sS --fail-with-body -X POST "$CKRETS/v1/secrets" -H "authorization: Bearer $OIDC" \
            -H 'content-type: application/json' -H "idempotency-key: $GITHUB_RUN_ID-$GITHUB_RUN_ATTEMPT-db" --data-binary @- |
          jq -er '"token=" + .token' >> "$GITHUB_OUTPUT"
  deploy:
    needs: mint
    runs-on: ubuntu-latest
    permissions: { id-token: write, contents: read }
    steps:
      - shell: bash
        env: { TOKEN: ${{ needs.mint.outputs.token }} }
        run: |
          CKRETS=${CKRETS:-https://ckrets.ai}
          OIDC=$(curl -sS --fail-with-body -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
            "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://ckrets.ai" | jq -er .value)
          DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $OIDC" "$CKRETS/v1/secrets/$TOKEN" | jq -er .value)
          echo "::add-mask::$DB_PASSWORD"        # the runner hides it if anything prints it later
          export DB_PASSWORD && ./migrate.sh
```

`shell: bash` runs with `-eo pipefail`, so a refused reveal (`--fail-with-body`) or a missing value (`jq -e`) stops the
job before `./migrate.sh` runs. The `deploy` job's token matches `job_workflow_ref`, because both jobs are in
`deploy.yml` on the same ref; a job from any other workflow or branch gets `403 audience-mismatch`.

## GitLab CI and CircleCI, with curl

The helper's OIDC proof is GitHub's. On GitLab and CircleCI, reveal with `curl` and the job's own token; name the job
by `sub` in the audience.

GitLab (`.gitlab-ci.yml`), audience
`{"type":"oidc","issuer":"https://gitlab.com","claims":{"sub":"project_path:acme/api:ref_type:branch:ref:main"}}`:

```yaml
deploy:
  id_tokens:
    CKRETS_OIDC: { aud: https://ckrets.ai }
  script:
    - set -eo pipefail
    - DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $CKRETS_OIDC" "${CKRETS:-https://ckrets.ai}/v1/secrets/$TOKEN" | jq -er .value)
    - export DB_PASSWORD && ./migrate.sh
```

CircleCI, audience `{"type":"oidc","issuer":"https://oidc.circleci.com/org/<org-id>","claims":{"sub":"org/<org-id>/project/<project-id>/user/<user-id>"}}`
(CircleCI's `sub` includes the user who started the pipeline, so another user's run is refused):

```yaml
steps:
  - run: |
      set -eo pipefail
      OIDC=$(circleci run oidc get --claims '{"aud":"https://ckrets.ai"}')
      DB_PASSWORD=$(curl -sS --fail-with-body -H "authorization: Bearer $OIDC" "${CKRETS:-https://ckrets.ai}/v1/secrets/$TOKEN" | jq -er .value)
      export DB_PASSWORD && ./migrate.sh
```

To keep ckrets from ever seeing the password, encrypt in the job before the create (`/docs/client-encryption.md`):
with an `encJwk` in the server's audience, wrap the key with HPKE; or use fragment mode and pass the `#k=` part to the
receiving server alongside the token (never to ckrets: the key in the fragment must not reach us).
