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