docs/keyless.md: short-lived keys from an OIDC identity, proposed 52ae6422e5a1
A design for #241: a hub-issued certificate object binding a fresh key to an OIDC subject for minutes, a certify endpoint, Flow jobs and agents signing with keys that die with them, policy rules naming subjects from an issuer, and trusted time from the hub's acceptance until the transparency log replaces it. Decisions for Andy at the end. Refs #241
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
Claude Code <[email protected]> · delegate for changes, until 2026-11-13 19:00Z · key ea5f78160088
- When
- · 2026-10-17 03:47 UTC
- Signature
- ✔ signed by Claude Code
- Hash
52ae6422e5a190e914027a5c1b94675509b34e9556bb092951eb88a4849fe9e1- Depends on
- 93be9ce4653e
- Depended on by
- 8aa5ef384ef1 3eb491e9489b 6ba07678ae86
- In bookmarks
- api-unknown-fields cancel-landed-runs diffusion-pin-bookmark draft-ready main oh-comment-body-file proposal-list-fast
- In tags
- v0.6.0 v0.6.1 v0.7.0 v0.8.0 v0.9.0 v0.9.1 v0.9.11 v0.9.15 v0.9.16 v0.9.19 v0.9.2 v0.9.20 v0.9.21 v0.9.22 v0.9.23 v0.9.24 v0.9.3 v0.9.4 v0.9.5 v0.9.6 v0.9.7 v0.9.8 v0.9.9
- Edits
- 1 atom, 1 tree operation, 1 file
Browse the tree at this change
added docs/keyless.md +179 −0
| 1 | + | # Keyless signing: short-lived keys from an OIDC identity |
|
| 2 | + | ||
| 3 | + | Design for oxygenhub#241, written 2026-10-17. **Status: proposed.** Nothing |
|
| 4 | + | here is built yet; it is for Andy to read and decide. It builds on the hub's |
|
| 5 | + | OIDC issuer (hub spec §13.5) and on the transparency log |
|
| 6 | + | (docs/transparency.md, #238/#240). |
|
| 7 | + | ||
| 8 | + | ## Why |
|
| 9 | + | ||
| 10 | + | Every signing key in Oxygen lives long: a runner's `runner.key`, an |
|
| 11 | + | agent's key in its home (`~/.oxy-agents/claude/keys`), a person's key. A |
|
| 12 | + | delegation can already expire in minutes (`oxy identity delegate add |
|
| 13 | + | --expires 30m`), but the key it names stays on disk afterwards, and each |
|
| 14 | + | grant re-issues the owner's identity by hand. A stolen key works until |
|
| 15 | + | someone notices and revokes it. |
|
| 16 | + | ||
| 17 | + | Sigstore's answer (Fulcio) is a key that lives only for one job: the |
|
| 18 | + | signer proves who it is with an OIDC token, gets a certificate for a fresh |
|
| 19 | + | key valid for minutes, signs, and throws the key away. Nothing long-lived |
|
| 20 | + | sits on the runner or in the agent's home, and a policy can name **who** |
|
| 21 | + | may sign ("this pipeline on main", "Claude, for andymccall") rather than |
|
| 22 | + | which key. |
|
| 23 | + | ||
| 24 | + | ## What exists already |
|
| 25 | + | ||
| 26 | + | - **Delegation** (spec §14.2, §14.5): an identity lists delegates with a |
|
| 27 | + | scope (`changes`, `refs`, `seal`, `all`) and an expiry, checked against |
|
| 28 | + | the time the signed object claims. The identity issues it itself; no |
|
| 29 | + | other key can. |
|
| 30 | + | - **The hub's OIDC issuer** (hub spec §13.5, `oidc.rs`): a Flow job that |
|
| 31 | + | declares `identity "AUD"` gets an RS256 token with `sub` |
|
| 32 | + | `repo:OWNER/NAME:bookmark:B`, the environment and its approval, expiring |
|
| 33 | + | with the job (at most 6 h). The JWKS is at `/.well-known/jwks.json`. |
|
| 34 | + | The hub already uses these tokens for keyless backups to GCS and S3. |
|
| 35 | + | - **The hub's key** (hub spec §15.5) signs web edits, wiki changes, |
|
| 36 | + | Diffusion bumps, swarm tickets and SLSA provenance. |
|
| 37 | + | ||
| 38 | + | What is missing is a delegation **issued by someone else** (a certificate |
|
| 39 | + | authority), policy rules that name identities rather than keys, and time |
|
| 40 | + | a verifier can trust once the certificate has long expired. |
|
| 41 | + | ||
| 42 | + | ## The certificate |
|
| 43 | + | ||
| 44 | + | A new object, `certificate`, issued and signed by a hub's key (its |
|
| 45 | + | composite Ed25519 + ML-DSA-65 key, so certificates stay post-quantum): |
|
| 46 | + | ||
| 47 | + | ``` |
|
| 48 | + | Certificate { |
|
| 49 | + | version [0]: 1 |
|
| 50 | + | key [1]: Identity // the fresh key pair's public identity |
|
| 51 | + | issuer [2]: KeyId // the hub key that signs it |
|
| 52 | + | subject [3]: text // who proved it: "repo:andymccall/oxygen:bookmark:main", "agent:claude@andymccall" |
|
| 53 | + | claims [4]: map text->text // what else the token said: environment, approval, run, job |
|
| 54 | + | scope [5]: Scope // changes, refs, seal |
|
| 55 | + | not_before [6]: u64 // milliseconds |
|
| 56 | + | expires [7]: u64 // not_before + at most 10 minutes for jobs |
|
| 57 | + | } |
|
| 58 | + | ``` |
|
| 59 | + | ||
| 60 | + | It travels with what it signed, like an identity: a push carries the |
|
| 61 | + | certificates of the keys that signed its changes and attestations, and a |
|
| 62 | + | fetch brings them. |
|
| 63 | + | ||
| 64 | + | ## Getting one |
|
| 65 | + | ||
| 66 | + | ``` |
|
| 67 | + | POST /api/v1/keyless/certify |
|
| 68 | + | { "token": "<OIDC token>", "identity": "<fresh identity, CBOR, base64>", |
|
| 69 | + | "proof": "<the fresh key's signature over the hub's challenge>", "scope": "refs" } |
|
| 70 | + | → { "certificate": "<CBOR, base64>" } |
|
| 71 | + | ``` |
|
| 72 | + | ||
| 73 | + | The hub checks: |
|
| 74 | + | - the token: its own issuer to begin with (others later, see the |
|
| 75 | + | decisions); |
|
| 76 | + | - that the proof shows the caller holds the fresh key (the challenge |
|
| 77 | + | signatures of spec §14.3); |
|
| 78 | + | - that the scope asked is one the subject may have. |
|
| 79 | + | ||
| 80 | + | It then signs a certificate expiring at the earlier of ten minutes and |
|
| 81 | + | the token's expiry, and logs it (below). |
|
| 82 | + | ||
| 83 | + | - **A Flow job** gets a token as it does today, generates a key in memory, |
|
| 84 | + | certifies it, signs (a receipt, a tag, an attestation of a release), and |
|
| 85 | + | the key dies with the job. `runner.key` remains only to authenticate the |
|
| 86 | + | runner to the hub, not to sign what jobs made. |
|
| 87 | + | - **An agent** has no OIDC token of its own. The hub gives it one when its |
|
| 88 | + | owner says so: `oh agent grant claude --scope changes --for 8h` on |
|
| 89 | + | Andy's side, or a hub page with a button, makes an agent token. The |
|
| 90 | + | agent exchanges that token for certificates as it needs them (each ten |
|
| 91 | + | minutes). The owner revokes the grant on the hub, and no key is left |
|
| 92 | + | anywhere. The agent's home holds only the grant token, which signs |
|
| 93 | + | nothing by itself. |
|
| 94 | + | - **People** keep their own keys; passkeys (#237) are the separate answer |
|
| 95 | + | for them. |
|
| 96 | + | ||
| 97 | + | ## Policy names identities |
|
| 98 | + | ||
| 99 | + | A rule today lists keys. It may also list **subjects from an issuer**: |
|
| 100 | + | ||
| 101 | + | ``` |
|
| 102 | + | rule "main" { |
|
| 103 | + | key 4cd9916c… // Andy, as today |
|
| 104 | + | subject "repo:andymccall/oxygen:bookmark:main" issuer HUB scope refs |
|
| 105 | + | subject "agent:claude@andymccall" issuer HUB scope changes |
|
| 106 | + | } |
|
| 107 | + | ``` |
|
| 108 | + | ||
| 109 | + | `HUB` is the hub key's KeyId, so the policy says which CA it trusts for |
|
| 110 | + | which subjects. A pattern may end in `*`. The policy's roots still sign |
|
| 111 | + | the policy, as now. |
|
| 112 | + | ||
| 113 | + | ## Verifying later |
|
| 114 | + | ||
| 115 | + | A certificate is valid for ten minutes. A verifier checking a signature a |
|
| 116 | + | year later must know **the signature was made inside those ten minutes**, |
|
| 117 | + | and the signer's own claim of time is not enough (that is #240's |
|
| 118 | + | problem). Two sources, in order of strength: |
|
| 119 | + | ||
| 120 | + | 1. **The transparency log** (docs/transparency.md): the hub logs every |
|
| 121 | + | certificate it issues and every object signed by a certified key. An |
|
| 122 | + | object integrated (witnessed) before its certificate's `expires` was |
|
| 123 | + | signed in time. This is exactly how Sigstore uses Rekor with Fulcio. |
|
| 124 | + | 2. **The hub's acceptance**, until the log exists: when a push carries a |
|
| 125 | + | certified signature, the hub countersigns its acceptance time on the |
|
| 126 | + | object (a small attestation, kind `accepted`). That trusts the hub's |
|
| 127 | + | clock alone, which the log later removes. |
|
| 128 | + | ||
| 129 | + | A signature whose certificate has expired, with neither proof, is |
|
| 130 | + | invalid. |
|
| 131 | + | ||
| 132 | + | ## Revocation |
|
| 133 | + | ||
| 134 | + | Short certificates mostly replace revocation: a stolen key is useless ten |
|
| 135 | + | minutes later. What can still be revoked: |
|
| 136 | + | - **the grant** an agent holds (on the hub, at once); |
|
| 137 | + | - **the issuer** itself (a policy without the hub's key trusts none of |
|
| 138 | + | its certificates); |
|
| 139 | + | - **one certificate** by its hash (a revocation listed in the policy, as |
|
| 140 | + | keys are now), for the ten minutes it matters. |
|
| 141 | + | ||
| 142 | + | ## Order of work |
|
| 143 | + | ||
| 144 | + | 1. **The certificate object** in oxy-core: encoding, verification (issuer |
|
| 145 | + | signature, scope, expiry against a given trusted time), carried in |
|
| 146 | + | bundles like identities. Spec §14. About three days. |
|
| 147 | + | 2. **The certify endpoint** on the hub: token check, proof of |
|
| 148 | + | possession, issuing, logging. About two days. |
|
| 149 | + | 3. **Flow jobs:** the runner certifies a fresh key per job and signs |
|
| 150 | + | receipts with it; the hub checks receipts by certificate as well as by |
|
| 151 | + | `flow_runner_keys`. About two days. |
|
| 152 | + | 4. **Policy subjects:** rules naming issuers and subject patterns; `oxy |
|
| 153 | + | policy grant --subject`. About two days. |
|
| 154 | + | 5. **Agent grants:** `oh agent grant`, the hub's grant tokens, and oxy |
|
| 155 | + | certifying on demand when `author.signer` is `keyless`. About three days. |
|
| 156 | + | 6. **Trusted time:** the acceptance attestation now; the log's integrated |
|
| 157 | + | time when #238 lands. |
|
| 158 | + | ||
| 159 | + | ## Decisions for Andy |
|
| 160 | + | ||
| 161 | + | 1. **Certificate format:** |
|
| 162 | + | - Our own CBOR object, composite-signed: post-quantum, and small. |
|
| 163 | + | - X.509, as Fulcio issues: readable by Sigstore's tools, but classical |
|
| 164 | + | only. |
|
| 165 | + | - Recommendation: **our own**, with an X.509 export later if a |
|
| 166 | + | customer needs Sigstore's tools to read them. |
|
| 167 | + | 2. **Build before or after the transparency log?** Certificates are only |
|
| 168 | + | as good as the proof of when they were used. |
|
| 169 | + | - Recommendation: **build steps 1 to 4 now with the hub's acceptance |
|
| 170 | + | attestation**, and move to the log's time when #238 step 1 lands. |
|
| 171 | + | 3. **Agents:** a grant on the hub that the agent exchanges for ten-minute |
|
| 172 | + | certificates, replacing the long-lived agent keys and the 8 h |
|
| 173 | + | delegations re-issued by hand. |
|
| 174 | + | - Recommendation: **yes**, and retire `~/.oxy-agents/*/keys` once it |
|
| 175 | + | works. |
|
| 176 | + | 4. **Other OIDC issuers** (GitHub Actions, GitLab CI, Google service |
|
| 177 | + | accounts) as subjects, for repositories mirrored or built elsewhere: |
|
| 178 | + | - Recommendation: **the hub's own issuer first**; others as a site |
|
| 179 | + | setting listing trusted issuers, later. |