OXYGENHUB
# 244724133fed oxygenhub / docs / keyless.md
8.6 KiB · Markdown · last changed The hub countersigns updates by keys it certified (keyless step 4c) 18 days ago renderedsource blame history raw
1
# Keyless signing: short-lived keys from an OIDC identity
2
3
Design for oxygenhub#241, written 2026-10-17. **Status: accepted** (Andy,
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
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
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.
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
166
## Decisions (taken 2026-10-17)
167
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
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.