diff --git a/docs/cli.md b/docs/cli.md index 4df6b8df..1956cdae 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -225,7 +225,11 @@ admitted on a `y`; `--fingerprint SHA256:...`, copied from what `register` printed, admits it without the question and refuses when it differs. Its kind wins over `--kind`, and `--oidc` is refused while it stands. With nothing parked, `--kind` says what the account is and `--oidc -...` makes a token from that issuer its way of signing in. +...` makes a token from that issuer its way of signing in: every +claim named must equal its value, a number or a boolean spelled as JSON +spells it, and a name beginning with `/` is a JSON pointer into a nested +claim (`/kubernetes.io/namespace=agents`). [From AWS OIDC to +didbot](pipelines.md) has the lines for each platform. `--label` is free text kept on the registration. The session the server returns is written under `$DIDBOT_STATE/accounts/.token`, the file `DIDBOT_ACCOUNT_TOKEN_FILE` points `didbot-oauth` at. diff --git a/docs/ownership.md b/docs/ownership.md index 291eab01..bc54cbbd 100644 --- a/docs/ownership.md +++ b/docs/ownership.md @@ -306,7 +306,7 @@ it. | One agent host, automatic | `didbot operate laptop.pds.example operator.example` — shows the parked key's fingerprint, writes the record, creates with `parkedKey` | `didbot register host laptop.pds.example` first: mints the daemon's key, parks its public half, prints the fingerprint, waits, then signs in with the key. `didbot-agentd` then creates one agent per context with a JWT the key signs | agent → laptop's record → laptop's registration → the human's record | | Two servers, hosts I registered | `didbot operate h1.pds-a.example operator.example`, once per host per server | `didbot register host h1.pds-a.example` on each host; the daemon as above | the same, on each server | | An autoscaling pool | `didbot operate web.pds.example operator.example --kind service --oidc https://accounts.google.com email=web@project.iam.gserviceaccount.com`, once | at boot, `didbot register host i-0a9f.pds.example --under web.pds.example --token-file /run/instance-id-token` (the token's audience is `did:web:pds.example`)`; the daemon as above | agent → host → web → the human's record | -| Every run of one pipeline | `didbot operate deploy.pds.example operator.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository=permadeath/didbot`, once | each run: `didbot oauth pending --token-file "$RUNNER_TEMP/id-token"` is a session as the pipeline, and an agent the run starts is created with it | agent → deploy → the human's record | +| Every run of one pipeline | `didbot operate deploy.pds.example operator.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository=permadeath/didbot`, once | each run: `didbot oauth pending --token-file "$RUNNER_TEMP/id-token"` is a session as the pipeline, and an agent the run starts is created with it; [From AWS OIDC to didbot](pipelines.md) is the workflow | agent → deploy → the human's record | The parked key in the second row is how a machine and a human admit an account without a secret crossing between them. `bot.did.parkKey diff --git a/docs/pipelines.md b/docs/pipelines.md new file mode 100644 index 00000000..6c0fcafd --- /dev/null +++ b/docs/pipelines.md @@ -0,0 +1,144 @@ +# From AWS OIDC to didbot + +A GitHub Actions workflow that assumes an AWS role through OIDC becomes a +didbot pipeline with one command on the operator's machine and two steps +in the workflow. This page is the port, piece by piece: what each part of +the AWS setup becomes, a workflow to paste, how the trust policy's +wildcards are spelled when only equality exists, and the lines that differ +for Google, Kubernetes, Azure, GitLab and Cognito. [Who operates an +account](ownership.md) is the model; [the command line](cli.md) documents +every command used here. + +## Side by side + +| AWS | didbot | +|---|---| +| An IAM OIDC identity provider for `https://token.actions.githubusercontent.com`, with the issuer's thumbprint and audience `sts.amazonaws.com` | Nothing to register. The server fetches the issuer's keys through its discovery document when a token arrives. `[oidc] issuers` in the server's config narrows which issuers it will read; empty admits any public HTTPS issuer. | +| A role whose trust policy has `StringEquals token.actions.githubusercontent.com:aud: sts.amazonaws.com` and `StringLike token.actions.githubusercontent.com:sub: repo:org/repo:*` | `didbot operate deploy.pds.example op.example --kind pipeline --oidc https://token.actions.githubusercontent.com repository=org/repo`, once. The account `did:web:deploy.pds.example` *is* the identity: its document names the issuer and the claims. | +| The audience `sts.amazonaws.com` | The server's own DID, `did:web:pds.example`. GitHub mints a token for any audience string. | +| `aws-actions/configure-aws-credentials` with `role-to-assume` (`AssumeRoleWithWebIdentity`) | Request the token yourself with `audience=did:web:pds.example`, write it to a file, and `didbot oauth pending --token-file `: a session as the pipeline. | +| Temporary credentials, one hour by default | A session, one hour by default (`[sessions] proof_ttl_secs`); a run that needs longer presents a fresh token. | +| The role's permissions policy | The session writes the pipeline's own repository and nothing else. Whether the pipeline may create accounts beneath itself is a `bot.did.policy` on `did.write`, bound to it. | +| `StringLike` and `ForAnyValue` conditions | Equality on the claims GitHub already splits out of `sub`; see below. | + +## The workflow + +```yaml +permissions: + id-token: write # the run may mint an OIDC token + contents: read + +jobs: + deploy: + runs-on: ubuntu-latest + env: + DIDBOT_PDS: https://pds.example + steps: + - uses: actions/checkout@v4 + + - name: Sign in as the pipeline + run: | + # One token per didbot command: a token is spent the first time + # it is presented, and it must be under five minutes old. + curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \ + | jq -r .value > "$RUNNER_TEMP/id-token" + didbot oauth pending --token-file "$RUNNER_TEMP/id-token" + + - name: Create this run's agent beneath the pipeline + run: | + curl -sS -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \ + "$ACTIONS_ID_TOKEN_REQUEST_URL&audience=did:web:pds.example" \ + | jq -r .value > "$RUNNER_TEMP/id-token" + didbot register agent "run-$GITHUB_RUN_ID.pds.example" \ + --under deploy.pds.example --server pds.example \ + --token-file "$RUNNER_TEMP/id-token" +``` + +`didbot oauth` keeps the session where `DIDBOT_ACCOUNT_TOKEN_FILE` points +`didbot-oauth`; every later `didbot oauth` in the job runs as the pipeline. +A token is never an argument: `--token ` is refused so `ps` and the +job log cannot show one. + +## Where the wildcard went + +AWS sees `sub` and `aud` and matches `sub` with `StringLike`. GitHub also +puts each part of `sub` in a claim of its own, and didbot matches those +claims for equality, so what was a pattern is a choice of claim: + +| Intent | AWS `sub` condition | `--oidc https://token.actions.githubusercontent.com …` | +|---|---|---| +| Any branch, tag or environment of one repository | `repo:org/repo:*` | `repository=org/repo` | +| One branch | `repo:org/repo:ref:refs/heads/main` | `repository=org/repo ref=refs/heads/main` | +| Tags only | `repo:org/repo:ref:refs/tags/*` | `repository=org/repo ref_type=tag` | +| One environment | `repo:org/repo:environment:production` | `repository=org/repo environment=production` | +| One reusable workflow, wherever it is called | `job_workflow_ref` custom claim | `job_workflow_ref=org/workflows/.github/workflows/deploy.yml@refs/heads/main` | +| Every repository of one owner | `repo:org/*` | `repository_owner=org` — any repository anyone creates under `org`, including a fork's pull request if the workflow runs there | +| Several repositories | one `StringLike` per pattern | one pipeline account per repository, or `repository_owner`; a claim set is a conjunction | + +Two accounts at one issuer may overlap — `deploy` with `repository=org/repo` +and `deploy-prod` with `repository=org/repo environment=production`. A +production run's token matches both and is the account naming the most +claims, `deploy-prod`; a staging run's is `deploy`. Two accounts naming as +many claims as each other, both matching, is a refusal. + +A claim whose value is a number or a boolean is spelled as JSON spells it: +`email_verified=true`. A nested claim is named as a JSON pointer: +`/kubernetes.io/namespace=agents`. + +## Lifetimes, and two runs at once + +GitHub's token carries `iat` at the moment it was requested. The server +refuses a token whose `iat` is older than `[oidc] freshness_secs` (five +minutes by default), so a workflow requests the token in the step that +uses it, not in an earlier one. `exp` is checked too, and `nbf` when +present. + +A token is spent the first time it is presented, so two `didbot` commands +in one job request two tokens. Two workflows running at once each request +their own and each hold a session; a pipeline may hold `[sessions] +live_per_account` sessions (eight by default), and the one expiring +soonest makes room for the next. + +## Other platforms + +Each platform below is the `--oidc` line for `didbot operate`, the way +its token reaches the machine, and what differs from GitHub. + +**Google service accounts** (Compute Engine, Cloud Run, GKE workload +identity). `--oidc https://accounts.google.com email=web@project.iam.gserviceaccount.com`. +An instance reads its token from the metadata server with +`audience=did:web:pds.example&format=full`. The token is an hour long and +carries no `jti`; the server spends the whole token instead. A metadata +server that caches the token hands the same one out again, and the same +token presented twice is a replay; a token read more than five minutes +before it is presented is stale. Register once per boot, straight after +reading the token. Google's keys sit on +`www.googleapis.com`, not the issuer's host, and are fetched from there. + +**Kubernetes service accounts.** `--oidc sub=system:serviceaccount:agents:runner`, +or the pod by its nested claims: `/kubernetes.io/namespace=agents +/kubernetes.io/pod/name=runner-0`. The issuer is the cluster's public +one — EKS's `https://oidc.eks..amazonaws.com/id/`, GKE's +`https://container.googleapis.com/v1/projects/

/locations//clusters/` +— not `https://kubernetes.default.svc`, which resolves to a private +address the server will not fetch from. The token is a projected volume +with `audience: did:web:pds.example`; `aud` arrives as an array, which is +fine. The kubelet rewrites the file only as it nears expiry, so a pod that +registers later than five minutes after the file was written finds it +stale: set `expirationSeconds: 600` on the projection, the minimum, and +read the file at the moment of registering. + +**Azure AD / Entra workload identity.** `--oidc https://login.microsoftonline.com//v2.0 sub=`. +The `iss` of a v2 token ends in `/v2.0`; a v1 token's is +`https://sts.windows.net//` and does not match a v2 entry. Azure's +keys sit under `/discovery/v2.0/keys`, off the issuer's path, and are +fetched from there. + +**GitLab CI.** `--oidc https://gitlab.com project_path=group/project`, +with `ref_type=tag` or `environment=production` the way GitHub's are. The +token is `id_tokens:` in `.gitlab-ci.yml` with `aud: did:web:pds.example`. + +**Amazon Cognito identity pools.** `--oidc https://cognito-idp..amazonaws.com/ sub=`. +Both RSA (`RS256`) and EC (`ES256`) signatures verify; `RS512` and `PS256` +too.