a73x

docs/superpowers/specs/2026-08-18-delegate-certificates-design.md

Ref:   Size: 9.4 KiB   History

# Delegate Certificates — Design

Date: 2026-08-18
Status: Draft. Extends `2026-08-09-repo-governance-design.md`; governed servers
only.

## Purpose

Separate core users from their delegates.

A key in `keydir/` is a person: enrolled by a config push, holding whatever the
rules grant, revoked by `git rm`. An agent credential is not a person. It is
short-lived, numerous, and should never appear in the roster — enrolling one
means a config push per agent per rotation, and its grant sits in
`access.conf` looking exactly like a person's.

The delegate model collapses this to two sentences:

> A key in `keydir/` is a person. A certificate is a delegate of the person it
> names, and may write `refs/collab/*` and nothing else.

`access.conf` never mentions delegates. There is no delegate rule syntax, no
ceiling stanza, and no per-person configuration. The dangerous grants are not
reachable by any config path: no line an operator could add would let a
delegate move a branch, create a repository, or push `settings`.

The README's own argument for why this loses nothing:

> A contributor needs write access to `refs/collab/*` and to nothing else —
> patches travel as collab refs, so no setting anywhere grants an agent
> credential the ability to move a branch.

## Design test

Passes. A certificate is standard OpenSSH client configuration; `git` and
`ssh` handle it natively and the client learns nothing about how the server
authorizes it. The server-side check is a doorman — it refuses pushes, it
never rewrites them. On any other host the same cert simply authenticates as
its key, and the collab layer works unchanged.

Issuer-neutral by the same principle: a delegate CA is any OpenSSH CA key.
`ssh-keygen -s` is a complete issuer; a secrets engine (openbao et al.) is one
that happens to rotate keys and enforce TTLs. Nothing server-side knows or
cares which minted a cert.

## Threat model

Same adversary as governance: a compromised delegate credential. It can open
SSH connections and push until the cert expires. It must not be able to move
canonical state, widen its access, create repositories, or outlive its
person's enrolment.

New surface: a compromised **CA private key**. It can mint delegates — but
only for the names it is enrolled under in `cadir/`, only within the collab
ceiling, and only until its `cadir/` entry is removed. It cannot mint people,
because a cert's principal must already exist in `keydir/`: the CA delegates
authority, it never creates it.

## Trust layout

```text
settings.git
├── conf/access.conf
├── keydir/                who you are
│   ├── charizard/xanderle.pub      basename = the principal the key IS
│   └── xps14/xanderle.pub
└── cadir/                 who may act as you
    ├── openbao/xanderle.pub        basename = the principal this CA may
    └── laptop-ca/alice.pub         mint delegates OF
```

`cadir/` follows `keydir/`'s convention — basename is the name, directories
ignored, one trailing `.pub` stripped, non-`.pub` files silently skipped, same
name character-set validation.

One deliberate rule difference. In `keydir/`, one key under two names is a
hard error: a fingerprint resolving to either name depending on iteration
order is an authorization coin-flip. In `cadir/`, the same key under two names
is **allowed and meaningful**: the cert names its principal, so there is
nothing to flip — the lookup is "is this fingerprint enrolled for the name the
cert claims." That is also how a shared CA works: enrol the same key as
`cadir/openbao/xanderle.pub` and `cadir/openbao/alice.pub`, and each person's
opt-in is an explicit file in a reviewable diff. A global CA is a usage
pattern, not a trust decision baked into the design.

With no `cadir/`, or no entry for a cert's principal, certificates are
rejected and nothing changes. Ungoverned servers reject certificates outright:
there is no roster to tie a principal to.

## Authentication

New `auth_openssh_certificate` handler beside `auth_publickey`. russh calls it
only after verifying the client's signature, so possession of the cert's
private key is already proven. The handler checks what russh deliberately does
not:

1. **CA trust and validity window** — `ssh_key::Certificate::validate_at`
   against the fingerprints enrolled in `cadir/` *for the cert's principal*.
   The crate checks: signature verifies, signing key's fingerprint is in the
   provided set, timestamp within `valid_after..valid_before`.
2. **Type** — `CertType::User`. Host certs rejected.
3. **Principal** — exactly one, and it must be enrolled in `keydir/`. Zero
   principals (OpenSSH's "valid for anyone") rejected. Two or more rejected:
   a delegate acts for one person.
4. **Critical options** — empty, or containing only options this server
   recognizes (initially: none recognized, so any critical option rejects —
   per PROTOCOL.certkeys, unknown critical options MUST refuse). Extensions
   ignored.

On success the session's principal is the person's name, with a delegate
marker carrying the cert's key ID for attribution. The cert's embedded key is
never enrolled anywhere — that is the point.

As with `keydir/` today, the config is re-read per request: enrolment is
checked when the connection authenticates and the regime is re-derived for
every command it issues afterwards.

## Authorization

One hard-coded rule, no config surface. A delegate session:

- **reads** whatever its person may read — it cannot prepare a patch without
  fetching;
- **writes** only under `refs/collab/`, regardless of what the rules grant
  the person. Enforced in the update hook — the existing doorman, which
  already answers per-ref questions — via a new `GIT_COLLAB_DELEGATE=<key-id>`
  environment variable on the `git-receive-pack` child. Exec-level write
  authorization ("may push at all") still requires the person to hold write
  on the repository;
- may **not create** repositories. `Access::Create` is denied before the
  person's rules are consulted, so `record_creator` never stamps a delegate
  and `RW+ = CREATOR` never resolves to one.

This includes collab refs on `settings` itself, which is safe: the live
configuration is a branch under `refs/heads/*`, which no delegate can touch.

The ceiling is deliberately not configurable. A delegate that needs more than
collab refs — pushing a release tag, moving a branch — is out of scope, and
adding it later means revisiting this design, not extending a config file.

## Attribution

Logs and any recorded actor read `<person> (via <key-id>)`. The rules, the
creator file, and `access.conf` only ever see the person's name. Collab-event
authorship is unchanged — events are signed with the ed25519 collab key, a
separate concern from transport identity.

## Revocation

Three independent mechanisms, none new:

| act | effect |
|---|---|
| cert expires | that delegate dies; TTL is the issuer's choice (minutes, for agents) |
| `git rm cadir/<ca>/<person>.pub` | every delegate that CA minted for that person dies on their next command |
| `git rm keydir/**/<person>.pub` | the person and all their delegates die on their next command |

The roster is the revocation list; no KRL. The cascade works because
principal enrolment is re-checked per request, and because `cadir/` cannot
create identity — only lend `keydir/`'s.

## Push validation

`cadir/` joins the settings-push checks: every `.pub` file must parse as an
OpenSSH public key and carry a valid name, or the push is refused and the
previous config keeps governing. The "roster with no rules" check treats
`cadir/` like `keydir/`: a tree with CA enrolments but no `conf/access.conf`
governs nothing and is refused. The "somebody must still be able to push the
config afterwards" check is unaffected — delegates cannot push it regardless.

## Testing

The issuer and oracle is `ssh-keygen`, not our code: the harness generates a
CA keypair and mints real certs with `ssh-keygen -s -I <key-id> -n <principal>
-V <window>`, so expectations come from the same tool operators will use.
(External-oracle lesson: expectations produced by our own code path only prove
agreement.)

Behavioral tests through the real server, extending `governance_test.rs`'s
harness:

- delegate pushes a collab ref → accepted; the event lands and attribution
  logs the key ID
- same delegate pushes a branch its person holds `RW+` on → refused at the
  hook, person's own key still can
- delegate attempts repo creation where the person holds `C` → refused
- cert signed by a CA not in `cadir/` → auth rejected
- cert whose CA is enrolled for a *different* name than the cert's principal
  → auth rejected
- expired cert → auth rejected
- principal not in `keydir/` → auth rejected
- zero and two-principal certs → auth rejected
- host cert → auth rejected
- unknown critical option → auth rejected
- `git rm` of the CA entry, then the same delegate reconnects → rejected
- `git rm` of the person's keys, then the delegate issues a command → refused
- certificate presented to an ungoverned server → rejected
- settings push containing a malformed `cadir/` file → push refused, previous
  config keeps governing

## Non-goals

- Issuer tooling: no `git-collab-server` command mints, rotates, or stores CA
  keys. Enrolment is a file in a config push, like every key today.
- Key revocation lists. The roster and TTLs are the revocation story.
- Configurable delegate ceilings, per-person or global.
- Delegates on ungoverned servers.
- Any client-side change. `git-collab` and `git` need none.