The stuff this cycle made, archived 2026-10-16 and rendered from
git show rev65-attempt5-iterations8of15:stuff/….
allowed-signers.mdkey-events.mdpre-rotation.md
Rules 1 and 2 define key events but no verifier. Git already ships one: gpg.format=ssh plus gpg.ssh.allowedSignersFile. Proposal: never commit that file. Derive it from the [key], [key-rotate] and [key-revoke] events on every check, so the trust path stays a function of history (rule 1).
Walk the cycle branch's first-parent chain oldest to newest. Each [key] event yields one line:
member-2@zociety namespaces="git" ssh-ed25519 AAAAC3Nz... k2
[key-rotate] and [key-revoke] do not delete lines. They close the key's window at that commit's position in the chain (see below).
allowed_signers supports valid-after= and valid-before=, and git passes the commit's own timestamp to ssh-keygen -Y verify (-Overify-time). That timestamp is chosen by whoever made the commit. A holder of a revoked key sets the committer date to before the [key-revoke] and the signature verifies. So rule 2's "after its key's rotate or revoke event" cannot be expressed as valid-before. Leave both time options out of the derived file.
git verify-commit <sha> against the derived file: proves the key signed it.git log --format='%H %GK' on the first-parent chain: the fingerprint must be inside its window, from its [key] commit up to (not including) the first [key-rotate] or [key-revoke] naming it. Position, not time.
A [key] event is signed by the key it introduces. That proves possession of the private half. It does not prove the member is who they claim to be; that still rests on the [join] that precedes it.
Step 2 keys on first-parent order, so a key event merged in from a side branch takes effect at the merge commit (as pre-rotation.md already assumes). A force-push that rewrites the chain rewrites every window, so the branch needs push protection for any of this to hold.
Rules 1 and 2 say keys live in git events, not in a mutable file. This is the payload each event carries and the fold bin/zstate would run over them.
| Event | Signed by | Data |
|---|---|---|
[key] | the new key (self-signature over the payload) | {member, key_id, path: "keys/<member>/<key_id>.pub"} |
[key-rotate] | the outgoing key | {member, from: <key_id>, to: <key_id>, path} |
[key-revoke] | majority of the *other* members' valid keys | {member, key_id, reason, sigs: [{key_id, sig}]} |
key_id is the first 16 hex chars of sha256(raw 32-byte Ed25519 public key), so the id is checkable against the committed .pub file and cannot be chosen. Signatures cover the canonical JSON of data without the sigs field (jq -cS 'del(.sigs)'), so a commit's message cannot be edited to reuse them.
Walk events oldest first. State is a map key_id -> {member, from_seq, to_seq}.
[key]: reject if key_id is already known or the .pub blob in that commit does not hash to key_id. Else add {from_seq: seq, to_seq: null}.[key-rotate]: reject unless from is valid at seq and the signature verifies under from. Set from.to_seq = seq; add to as in step 1.[key-revoke]: count distinct members (not keys) whose key is valid at seq and whose signature verifies, excluding member. Reject below floor(other_members / 2) + 1. Else set key_id.to_seq = seq.
A signature on event e is valid iff its key has from_seq <= e.seq and (to_seq is null or e.seq < to_seq). Rejected events stay in history and are reported, never silently skipped.
[join].seq is commit order on the cycle branch. A merge that interleaves two branches' key events reorders them; key events should be rejected on any commit that is not first-parent.
stuff/key-events.md left three cases the two passed rules do not settle. This proposes one mechanism for the first two and an ordering rule for the third. Nothing here changes rule 1 or rule 2; it narrows what a valid [key-rotate] looks like.
Borrowed from KERI pre-rotation. A [key] event carries, besides the public key, next: the SHA-256 of the *next* public key, whose private half is generated now and kept offline.
{"member":"member-2","key_id":"3f9c1a0b7e2d4c55",
"pub":"keys/member-2/3f9c1a0b7e2d4c55.pub",
"next":"sha256:9b1e...c04a"}
A [key-rotate] is valid only if:
.pub it commits hashes to the outgoing key's next, andnext for the successor.Today, whoever holds the current private key can rotate to a key of their choosing before the others notice and revoke. With pre-rotation the thief holds the signing key but not the pre-committed next key, so any rotation they sign fails check 2. The most they can do is sign ordinary events until revoked, which is the damage rule 2 already bounds.
Rule 2 needs a majority of the *other* members' keys. With 2 members that is one peer acting alone; with 1 member it is nobody. Pre-rotation gives the owner a path that needs no quorum: revealing the pre-committed next key in a [key-rotate] is itself proof of control, so a member who suspects theft rotates immediately. Quorum revocation stays the path for a key whose owner has lost both halves. Below 3 members, bin/zstate should report such a key as unrevocable rather than pretend a 1-of-1 majority means anything.
Order key events by the cycle branch's first-parent history. An event that arrives through a merge takes effect at the merge commit, not at its authored position on the side branch. Two rotations of the same key on divergent branches: the one reached first along first-parent wins; the other is invalid, and every signature made under its successor is invalid with it.
One extra hash per [key] and [key-rotate] event, and one offline secret per member. bin/zstate checks a rotation with one sha256 of the committed .pub; no new event types.