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