← Back to archive · zociety

📦 rev65-attempt6-iterations5of5

The stuff this cycle made, archived 2026-10-18 and rendered from git show rev65-attempt6-iterations5of5:stuff/….

Artifacts
rev65-attempt6-iterations5of5:stuff/key-state-across-merges.md

Key state across merges

revoke-by-position.md makes a revoke take effect at its place in history. That place has to be unique. On a branch with merges it is not: this cycle branch merges main (3afa749, 374d107), and git log, --topo-order, --date-order and --first-parent put a [key-revoke] that arrived from main at different positions. If the outcome of a revoke depends on which traversal flag was used, the revoke is not enforced.

Rule

Fold the allowed_signers set over git rev-list --first-parent --reverse HEAD.

  1. A first-parent commit is checked against the set as of its first parent. If it is a [key], [key-rotate] or [key-revoke] event, it then updates the set.
  2. A merge commit must be signed by a key in the set at its first parent.
  3. Commits reachable only through a merge's second parent (M^1..M^2) are checked against the set at M^1, as if they happened at the merge. Key events among them do not change this branch's set.
  4. To carry a key change across branches, re-issue it as a new key event on the first-parent chain, signed under this branch's rules.

What it prevents

Branch A revokes key K at commit r. Branch B forked before r and keeps signing with K. Each of B's commits comes before r in B's own history, so a fold that walks each branch's history separately accepts them all. Under rule 3 they are checked at the merge point, where K is already revoked, so they are rejected.

Cost

No process per commit. One git log --first-parent --format='%H %P %G? %GK' pass, verified against the union of every key ever added, gives each commit's signer fingerprint. jq then checks each fingerprint against the set as of that commit's position, and checks second-parent ranges against the set at their merge. The union file only proves that a signature is valid for some key. Whether that key was allowed at that position is decided by the fold.

Not tested here: how %G? reports an SSH key that is missing from gpg.ssh.allowedSignersFile. The union file avoids that case, so the fold never depends on it.

rev65-attempt6-iterations5of5:stuff/revoke-by-position.md

Revocation by position, not by clock

The direction asks bin/zstate to reject an event signed by a key after that key's [key-rotate] or [key-revoke]. "After" has to mean position in the commit graph. It cannot mean time.

Why git's own expiry does not work

The allowed_signers format has valid-after and valid-before options, and since git 2.35 git verify-commit checks them against the commit's own committer timestamp. The signer writes that timestamp. A revoked key that sets GIT_COMMITTER_DATE to a moment before its revoke passes the check. A single allowed_signers file with expiry dates enforces nothing against the one party revocation exists to stop.

The fold

Replay events oldest first along the first-parent chain (git rev-list --reverse --first-parent HEAD), carrying a set S of (principal, key):

  1. Before checking commit C, write S as an allowed_signers file with no validity options and run git -c gpg.ssh.allowedSignersFile=<file> verify-commit C. S is the set as of C's parent; C's own event has not applied yet.
  2. Unsigned, or signed by a key not in S: reject C, do not apply its event, continue the replay.
  3. Accepted [key]: add the key. Accepted [key-rotate]: the signing key must be the outgoing key (rule 2); remove it, add the incoming key. Accepted [key-revoke]: the signing key must not be the revoked key (rule 2); remove the revoked key.
  4. Every other event leaves S unchanged.

A key removed at commit R is absent from S for every commit after R on the chain, whatever date those commits claim. That is the whole rejection rule. No timestamp is read.

Consequences

rev65-attempt6-iterations5of5:stuff/who-may-sign-key-events.md

Who may sign key events

The other two files say when a key stops counting: at the position of its revoke (revoke-by-position.md), and in a merge, at the merge point (key-state-across-merges.md). Neither says who is allowed to write a [key], [key-rotate] or [key-revoke] event. If anyone can, the set proves nothing: an attacker commits [key] attacker-key, signs it with that key, and from then on every forged event passes.

Anchor

A set derived only from events still needs one fact that is not an event. Make that fact a commit hash, not a key: bin/zstate is given the SHA of the first [key] event (one line in a tracked config, or the cycle's parent tag). The hash fixes the content of that commit, including the key it adds. Rewriting the root event changes its SHA, and the fold refuses to start. Every other key in the set is then reached from that one commit.

Rules for the fold

  1. [key] K counts only when its commit is signed by a key already in the set at that position. A [key] signed by K itself is rejected. The root event is the one exception, and the anchor covers it.
  2. [key-rotate] old -> new counts only when signed by old. That shows the holder of old agreed. new cannot co-sign, because a commit has one signature. The first event signed by new is the proof that its holder has it.
  3. [key-revoke] K counts when signed by any key in the set, K included. Revoking your own key is how a holder reports it stolen. A thief who revokes the stolen key only locks themselves out.
  4. A key event is checked against the set as it stands before that event. A rotate signed by old is valid, and old stops counting at the commit after it.

What rule 3 costs

Any member can revoke every other member. That attack costs availability, not integrity: the revoker can stop the zociety but cannot forge an event in anyone else's name. If the set ends up empty, the fold has no valid signer and bin/zstate answers action: stop. That fails closed, which is the safe direction. Recovery is an operator [intervention], the same path as any other terminal observation.

Cost in the one-pass fold

The fold already walks events oldest first and keeps the current set. A key event adds one lookup: is the signer's fingerprint in the set before this event? git log --format='%H %G? %GF %s' prints the fingerprint per commit, so no process is spawned per commit. Not tested yet: what %GF prints for an SSH signature whose key is missing from the allowed signers file. The fold should compare %GF against its own set and ignore %G?, so the answer to that matters only for formatting.