OXYGENHUB

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.