5233eabb
Design: delegate certificates — people in keydir, delegates by cert
a73x 2026-08-18 15:29
Commit message
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. | ||