a73x

5233eabb

Design: delegate certificates — people in keydir, delegates by cert

a73x   2026-08-18 15:29

Commit message
Design: delegate certificates — people in keydir, delegates by cert

A certificate is a delegate of the person it names, may write
refs/collab/* and nothing else, and never appears in the roster.
cadir/ holds per-person CA enrolment: the same basename convention as
keydir/, but one key under two names is allowed because the cert binds
the principal. Revocation is the roster plus TTLs; no KRL, no config
surface, no client-side change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

docs/superpowers/specs/2026-08-18-delegate-certificates-design.md
Old New
@@ -0,0 +1,208 @@
1 # Delegate Certificates — Design
2
3 Date: 2026-08-18
4 Status: Draft. Extends `2026-08-09-repo-governance-design.md`; governed servers
5 only.
6
7 ## Purpose
8
9 Separate core users from their delegates.
10
11 A key in `keydir/` is a person: enrolled by a config push, holding whatever the
12 rules grant, revoked by `git rm`. An agent credential is not a person. It is
13 short-lived, numerous, and should never appear in the roster — enrolling one
14 means a config push per agent per rotation, and its grant sits in
15 `access.conf` looking exactly like a person's.
16
17 The delegate model collapses this to two sentences:
18
19 > A key in `keydir/` is a person. A certificate is a delegate of the person it
20 > names, and may write `refs/collab/*` and nothing else.
21
22 `access.conf` never mentions delegates. There is no delegate rule syntax, no
23 ceiling stanza, and no per-person configuration. The dangerous grants are not
24 reachable by any config path: no line an operator could add would let a
25 delegate move a branch, create a repository, or push `settings`.
26
27 The README's own argument for why this loses nothing:
28
29 > A contributor needs write access to `refs/collab/*` and to nothing else —
30 > patches travel as collab refs, so no setting anywhere grants an agent
31 > credential the ability to move a branch.
32
33 ## Design test
34
35 Passes. A certificate is standard OpenSSH client configuration; `git` and
36 `ssh` handle it natively and the client learns nothing about how the server
37 authorizes it. The server-side check is a doorman — it refuses pushes, it
38 never rewrites them. On any other host the same cert simply authenticates as
39 its key, and the collab layer works unchanged.
40
41 Issuer-neutral by the same principle: a delegate CA is any OpenSSH CA key.
42 `ssh-keygen -s` is a complete issuer; a secrets engine (openbao et al.) is one
43 that happens to rotate keys and enforce TTLs. Nothing server-side knows or
44 cares which minted a cert.
45
46 ## Threat model
47
48 Same adversary as governance: a compromised delegate credential. It can open
49 SSH connections and push until the cert expires. It must not be able to move
50 canonical state, widen its access, create repositories, or outlive its
51 person's enrolment.
52
53 New surface: a compromised **CA private key**. It can mint delegates — but
54 only for the names it is enrolled under in `cadir/`, only within the collab
55 ceiling, and only until its `cadir/` entry is removed. It cannot mint people,
56 because a cert's principal must already exist in `keydir/`: the CA delegates
57 authority, it never creates it.
58
59 ## Trust layout
60
61 ```text
62 settings.git
63 ├── conf/access.conf
64 ├── keydir/ who you are
65 │ ├── charizard/xanderle.pub basename = the principal the key IS
66 │ └── xps14/xanderle.pub
67 └── cadir/ who may act as you
68 ├── openbao/xanderle.pub basename = the principal this CA may
69 └── laptop-ca/alice.pub mint delegates OF
70 ```
71
72 `cadir/` follows `keydir/`'s convention — basename is the name, directories
73 ignored, one trailing `.pub` stripped, non-`.pub` files silently skipped, same
74 name character-set validation.
75
76 One deliberate rule difference. In `keydir/`, one key under two names is a
77 hard error: a fingerprint resolving to either name depending on iteration
78 order is an authorization coin-flip. In `cadir/`, the same key under two names
79 is **allowed and meaningful**: the cert names its principal, so there is
80 nothing to flip — the lookup is "is this fingerprint enrolled for the name the
81 cert claims." That is also how a shared CA works: enrol the same key as
82 `cadir/openbao/xanderle.pub` and `cadir/openbao/alice.pub`, and each person's
83 opt-in is an explicit file in a reviewable diff. A global CA is a usage
84 pattern, not a trust decision baked into the design.
85
86 With no `cadir/`, or no entry for a cert's principal, certificates are
87 rejected and nothing changes. Ungoverned servers reject certificates outright:
88 there is no roster to tie a principal to.
89
90 ## Authentication
91
92 New `auth_openssh_certificate` handler beside `auth_publickey`. russh calls it
93 only after verifying the client's signature, so possession of the cert's
94 private key is already proven. The handler checks what russh deliberately does
95 not:
96
97 1. **CA trust and validity window** — `ssh_key::Certificate::validate_at`
98 against the fingerprints enrolled in `cadir/` *for the cert's principal*.
99 The crate checks: signature verifies, signing key's fingerprint is in the
100 provided set, timestamp within `valid_after..valid_before`.
101 2. **Type** — `CertType::User`. Host certs rejected.
102 3. **Principal** — exactly one, and it must be enrolled in `keydir/`. Zero
103 principals (OpenSSH's "valid for anyone") rejected. Two or more rejected:
104 a delegate acts for one person.
105 4. **Critical options** — empty, or containing only options this server
106 recognizes (initially: none recognized, so any critical option rejects —
107 per PROTOCOL.certkeys, unknown critical options MUST refuse). Extensions
108 ignored.
109
110 On success the session's principal is the person's name, with a delegate
111 marker carrying the cert's key ID for attribution. The cert's embedded key is
112 never enrolled anywhere — that is the point.
113
114 As with `keydir/` today, the config is re-read per request: enrolment is
115 checked when the connection authenticates and the regime is re-derived for
116 every command it issues afterwards.
117
118 ## Authorization
119
120 One hard-coded rule, no config surface. A delegate session:
121
122 - **reads** whatever its person may read — it cannot prepare a patch without
123 fetching;
124 - **writes** only under `refs/collab/`, regardless of what the rules grant
125 the person. Enforced in the update hook — the existing doorman, which
126 already answers per-ref questions — via a new `GIT_COLLAB_DELEGATE=<key-id>`
127 environment variable on the `git-receive-pack` child. Exec-level write
128 authorization ("may push at all") still requires the person to hold write
129 on the repository;
130 - may **not create** repositories. `Access::Create` is denied before the
131 person's rules are consulted, so `record_creator` never stamps a delegate
132 and `RW+ = CREATOR` never resolves to one.
133
134 This includes collab refs on `settings` itself, which is safe: the live
135 configuration is a branch under `refs/heads/*`, which no delegate can touch.
136
137 The ceiling is deliberately not configurable. A delegate that needs more than
138 collab refs — pushing a release tag, moving a branch — is out of scope, and
139 adding it later means revisiting this design, not extending a config file.
140
141 ## Attribution
142
143 Logs and any recorded actor read `<person> (via <key-id>)`. The rules, the
144 creator file, and `access.conf` only ever see the person's name. Collab-event
145 authorship is unchanged — events are signed with the ed25519 collab key, a
146 separate concern from transport identity.
147
148 ## Revocation
149
150 Three independent mechanisms, none new:
151
152 | act | effect |
153 |---|---|
154 | cert expires | that delegate dies; TTL is the issuer's choice (minutes, for agents) |
155 | `git rm cadir/<ca>/<person>.pub` | every delegate that CA minted for that person dies on their next command |
156 | `git rm keydir/**/<person>.pub` | the person and all their delegates die on their next command |
157
158 The roster is the revocation list; no KRL. The cascade works because
159 principal enrolment is re-checked per request, and because `cadir/` cannot
160 create identity — only lend `keydir/`'s.
161
162 ## Push validation
163
164 `cadir/` joins the settings-push checks: every `.pub` file must parse as an
165 OpenSSH public key and carry a valid name, or the push is refused and the
166 previous config keeps governing. The "roster with no rules" check treats
167 `cadir/` like `keydir/`: a tree with CA enrolments but no `conf/access.conf`
168 governs nothing and is refused. The "somebody must still be able to push the
169 config afterwards" check is unaffected — delegates cannot push it regardless.
170
171 ## Testing
172
173 The issuer and oracle is `ssh-keygen`, not our code: the harness generates a
174 CA keypair and mints real certs with `ssh-keygen -s -I <key-id> -n <principal>
175 -V <window>`, so expectations come from the same tool operators will use.
176 (External-oracle lesson: expectations produced by our own code path only prove
177 agreement.)
178
179 Behavioral tests through the real server, extending `governance_test.rs`'s
180 harness:
181
182 - delegate pushes a collab ref → accepted; the event lands and attribution
183 logs the key ID
184 - same delegate pushes a branch its person holds `RW+` on → refused at the
185 hook, person's own key still can
186 - delegate attempts repo creation where the person holds `C` → refused
187 - cert signed by a CA not in `cadir/` → auth rejected
188 - cert whose CA is enrolled for a *different* name than the cert's principal
189 → auth rejected
190 - expired cert → auth rejected
191 - principal not in `keydir/` → auth rejected
192 - zero and two-principal certs → auth rejected
193 - host cert → auth rejected
194 - unknown critical option → auth rejected
195 - `git rm` of the CA entry, then the same delegate reconnects → rejected
196 - `git rm` of the person's keys, then the delegate issues a command → refused
197 - certificate presented to an ungoverned server → rejected
198 - settings push containing a malformed `cadir/` file → push refused, previous
199 config keeps governing
200
201 ## Non-goals
202
203 - Issuer tooling: no `git-collab-server` command mints, rotates, or stores CA
204 keys. Enrolment is a file in a config push, like every key today.
205 - Key revocation lists. The roster and TTLs are the revocation story.
206 - Configurable delegate ceilings, per-person or global.
207 - Delegates on ungoverned servers.
208 - Any client-side change. `git-collab` and `git` need none.