Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

One trust model: attribution at the boundary, and a log of what it decided

f50f6fabc7de2f2d33d22a4f2a8e5518ddb5becb · Keenan Nemetz · 16d ago · parent 671f2aa7

One trust model: attribution at the boundary, and a log of what it decided

The system had two answers to "who did this". Runner results were attributed
by a commit signature; issues, comments, approvals and every push were
attributed by the account the SSH handshake resolved. One was cryptographic
and lived in the object, three were boundary-based and lived in the
instance's word.

Keeping both meant keeping allowed_signers generation, ssh-keygen
-Y find-principals semantics, a --key contract in the runner, a pre-flight key
check before every build, a key column in the queue, and the standing
obligation to keep all of it agreeing with the mechanism used everywhere
else. The signature lost on three counts: it restated the handshake (a runner
signs with the key it already pushes with, so the only thing it added was
portability nobody consumes), it proved attribution rather than truth (a
runner can sign a lie), and an assurance mechanism nobody verifies is a
liability, because it looks like security.

So the signature is gone, and what replaced it is the thing a trust-the-box
model actually requires: a record. post-receive now writes one line per
accepted ref update — ref, old, new, account, repo — into the same
append-only log as the privileged acts. In post-receive rather than
pre-receive because pre-receive returning 0 is a permission and this is the
event, and it can never fail a push: the update has already landed, and
reporting it as rejected would make a client re-send something that worked.

Attribution is unchanged in substance — a record must still name the account
that pushed it, which is the rule an issue's author and an approval's owner
already follow — and the gate's three checks simplify to two, since "the
commit must be signed by that account's key" and "the signature's principal
must be that account" both went with the signature. What was a signed record
is now a plain commit on the metadata branch; the run page says "reported by"
and links the commit that carries it.

Deletions, net 467 lines: CommitFilesSigned and CommitTreeAsSigned collapse
into the unsigned forms, VerifyCommitSignature and signedByRE go,
allowedSigners/rosterKeys go with them, the runner loses loadSigningKey,
loadPrivateKey, publicKeyFile, keyFromSSHCommand and checkSigningKey along
with the --key flag and the ping round-trip that validated a key before work,
and Record.Key, Attestation.Fingerprint and their usage go from the record
format and the queue.

The docs now state the model once, in one place — README "The trust model"
and roadmap "The trust model" — including the cost, which is real: a clone
can no longer show who pushed what, and that is the wrong trade for an
instance mirrored outside its operator's control. If that arrives, the answer
is per-repository mandatory signing implemented once, not a second mechanism
kept warm in the meantime. Slices 4 and 6 of Phase 4 are marked removed with
their reasoning kept, because the proposal will come back.

Measured: TestPostReceiveRecordsThePush (both ref updates logged with actor
and account, a deletion logged as "-> none", the CI spool still written) and
TestPostReceiveSurvivesAnUnwritableLog (a full disk cannot fail a push);
TestARecordCannotBeAttributedToAnotherAccount and TestARecordNeedsNoSignature
replace the three signature tests; TestARecordMustDescribeTheLeasedJob stays,
because "the record must describe the job you were given" is a boundary check
and not a crypto one.

README.md +42−31

@@ -10,7 +10,8 @@ Vole turns a git repo into a fully functional software forge.
10 10 - Replicated issues, change requests, and ACLs stored as git objects
11 11 - Integrated LFS support for release artifacts and CI / CD logs
12 12 - Every repo is a hosted website for landing pages and docs
13 - Asymmetric keys and digital signatures keep every change auditable
13 - One auditable path: keys authenticate every push, one gate decides, one log records what
14 it decided
14 15
15 16 ## Roadmap
16 17
@@ -49,7 +50,7 @@ Working today:
49 50 page with its patch, and a change request with the diff that would land
50 51 - CI: workflows in `.vole/workflows/*.yaml`, validated by the push that introduces
51 52 them. A push queues jobs; `vole runner` leases one over SSH with the same key, runs
52 the shell steps, uploads the attempt's log and signs the result. A job can declare `artifacts:`
53 the shell steps, uploads the attempt's log and commits its record. A job can declare `artifacts:`
53 54 (path patterns), which the runner uploads and the run page serves. Protected
54 55 branches can require green checks as well as approvals. One runner pool serves
55 56 every repository on the instance, an idle runner parks on the queue instead of
@@ -72,15 +73,30 @@ Working today:
72 73 `<repo>.<pages_domain>`, straight out of git — no export step, ETags from blob
73 74 shas, `304`s for unchanged pages. `vole pages deploy <dir>` publishes a build
74 75 directory as a snapshot, `status` reports what is live, `unpublish` takes it down
75 - One secret on disk: the SSH host key. There is no instance signing key, because a
76 statement only the box can check is worth nothing to a mirror — a run's result is
77 signed by the runner that produced it
76 - One secret on disk: the SSH host key. Nothing the instance writes is signed, because a
77 statement only the box can check is worth nothing to anyone else
78 78
79 Not yet: LFS locking, and retention for run artifacts (they live as long as the run
80 does).
81 Signature verification is deferred,
82 not planned: the SSH handshake already establishes who pushed, so signing approvals
83 would restate it for a verifier this deployment does not have (see the roadmap).
79 Not yet: retention for run artifacts (they live as long as the run does).
80
81 ### The trust model
82
83 One model, everywhere: **identity is established cryptographically at the SSH handshake
84 and resolved to an account by the roster; every decision about that account is made at one
85 boundary (`pre-receive`) and written to one append-only log (`data/audit.log`).** Nothing
86 else in the system claims to prove anything.
87
88 So `git commit -S` is not required and not checked: a signature would restate the
89 handshake, it cannot bind a signature to an author (git's verifier reports a principal, not
90 a committer), and making it mandatory would tax every contributor to authenticate the least
91 contentious artefact in the system. The same goes for approvals, comments and CI results —
92 each is attributed by the account whose key pushed it.
93
94 What that buys is coherence rather than offline proof: a clone of a repository cannot, by
95 itself, show who pushed what. What the instance owes in exchange is a complete record, so
96 every accepted push is logged with its actor, and so is every privileged act —
97 force-pushes, lock overrides, release rewrites, merges through review and roster changes.
98 The trade is written down in the roadmap, and it is the wrong one for an instance that gets
99 mirrored somewhere its operator does not control.
84 100
85 101 ## Try it
86 102
@@ -445,24 +461,19 @@ server wrote, and the runner is the party that can be held to it. `/r/<repo>/run
445 461 serves it, `?attempt=N` picks one attempt out of a retried job, and an attempt whose
446 462 runner was killed has no log at all.
447 463
448 A runner publishes every result it reports, as a signed commit. When an attempt ends
449 the runner commits `runs/<run id>/<attempt>/` — the record (repo, ref, SHA, the
450 workflow blob, the digest of the pinned steps, the exit code, the finish time) plus a
464 A runner publishes every result it reports. When an attempt ends the runner commits
465 `runs/<run id>/<attempt>/` — the record (repo, ref, SHA, the workflow blob, the digest of
466 the pinned steps, the exit code, the finish time, who reported it and the machine) plus a
451 467 pointer to the log and one per artifact — together with any release pointers the build
452 published, and pushes that once. `git commit -S` signs it with the runner's key, so the
453 signature covers the record and the objects it names, and the push is what
454 `pre-receive` checks: the account must be on the repository's runner allowlist
455 (`vole acl runners`), the commit must be signed by that account's key, and the record
456 must describe the job that account was leased. The server writes none of it — a record
457 it created would be the box vouching for a party — so nothing here can be forged by the
458 instance, and a clone can verify the result without asking it. `vole runner --key`
459 defaults to the `-i` of `$VOLE_SSH_COMMAND`/`$GIT_SSH_COMMAND`, else
460 `~/.ssh/id_ed25519`, and the runner refuses to start if that key is not one its account
461 holds. The run page names the account, the key and the commit.
462
463 What a signature does not buy is truth: a runner can still lie about an exit code.
464 It buys accountability — the record is attributable to a party other than the
465 instance, which an instance-signed record never could be.
468 published, and pushes that once. The push is what `pre-receive` checks: the account must
469 be on the repository's runner allowlist (`vole acl runners`), the record must name that
470 account, and it must describe the job that account was leased. The run page names the
471 account and links the commit that carries the record.
472
473 So the record is not a signed statement, and it does not need to be: attribution in this
474 forge is established at the push boundary, once, the same way an issue's author or an
475 approval's owner is — see [the trust model](#the-trust-model). What the instance owes in
476 exchange is the record of its own decision, which the push log keeps.
466 477
467 478 A result counts only if the host allowed the account that reported it.
468 479 `vole acl runners <demo> ci-runner-1` names the accounts whose builds this repository
@@ -690,10 +701,10 @@ the instance is running; copying `users.db` by hand can miss rows the database h
690 701 not checkpointed yet. Losing the roster means re-adding keys, not losing
691 702 repositories — nothing in it is secret, so a copied file leaks nothing.
692 703
693 The host key is the only secret the instance holds. There is no instance signing key:
694 nothing an instance writes is signed by the instance, because a signature that can only
695 be checked by trusting the machine that made it is not evidence. Results are signed by
696 the runner that ran them, and they verify against keys people hold.
704 The host key is the only secret the instance holds. Nothing is signed: not results, not
705 approvals, not pushes. Attribution comes from the SSH handshake, enforcement from
706 `pre-receive`, and evidence from `data/audit.log` — one of each, deliberately, so there is
707 no second path that can drift from the first.
697 708
698 709 Run everything before pushing:
699 710