← Back to archive · zociety

📦 rev65-attempt5-iterations8of15

The stuff this cycle made, archived 2026-10-16 and rendered from git show rev65-attempt5-iterations8of15:stuff/….

Artifacts
rev65-attempt5-iterations8of15:stuff/allowed-signers.md

allowed-signers: verifying rules 1 and 2 with git's own SSH signing

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).

Derivation

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).

The trap: valid-before trusts the signer's clock

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.

Two-step check

  1. git verify-commit <sha> against the derived file: proves the key signed it.
  2. 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.

Bootstrap

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.

Open

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.

rev65-attempt5-iterations8of15:stuff/key-events.md

Key events: the data rules 1 and 2 need

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.

Events

EventSigned byData
[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.

The fold

Walk events oldest first. State is a map key_id -> {member, from_seq, to_seq}.

  1. [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}.
  2. [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.
  3. [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.

Edge cases the rules leave open

rev65-attempt5-iterations8of15:stuff/pre-rotation.md

Pre-rotation: closing the open questions in key-events.md

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.

The mechanism: commit to the next key in advance

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:

  1. it is signed by the outgoing key (rule 2, unchanged), and
  2. the successor .pub it commits hashes to the outgoing key's next, and
  3. it carries a fresh next for the successor.

Open question 2: an attacker rotates a stolen key first

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.

Open question 1: revocation with fewer than 3 members

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.

Open question 3: merges reorder key events

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.

Cost

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.