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.
internal/server/integration_test.go +11−45
| @@ -2701,15 +2701,15 @@ jobs: | |||
| 2701 | 2701 | t.Fatalf("the served log hashes to %s, but the run names %s: the object is not the runner's bytes", sum, done.LogOID) | |
| 2702 | 2702 | } | |
| 2703 | 2703 | ||
| 2704 | // The result is a signed commit the runner pushed, and it is checkable afterwards: | ||
| 2705 | // the record names this job, its log and its outcome, and the commit verifies | ||
| 2706 | // under the account that ran it. The page says who, and it is the runner rather | ||
| 2707 | // than the instance. | ||
| 2704 | // The result is a commit the runner pushed: the record names this job, its log and | ||
| 2705 | // its outcome, and the queue mirrors who reported it and which commit carries it. | ||
| 2706 | // Attribution is the boundary's — the push had to come from that account — and it | ||
| 2707 | // is recorded once, in the push log, rather than carried as a signature. | ||
| 2708 | 2708 | att, err := f.srv.Runs.Attestation(done.ID) | |
| 2709 | 2709 | if err != nil || att == nil { | |
| 2710 | 2710 | t.Fatalf("no attestation on the run: %v", err) | |
| 2711 | 2711 | } | |
| 2712 | if att.Signer == "" || att.Fingerprint == "" || att.CommitID == "" { | ||
| 2712 | if att.Signer == "" || att.CommitID == "" { | ||
| 2713 | 2713 | t.Fatalf("attestation is not attributed: %+v", att) | |
| 2714 | 2714 | } | |
| 2715 | 2715 | m, err := f.srv.Repos.Open(f.repo) | |
| @@ -2723,10 +2723,9 @@ jobs: | |||
| 2723 | 2723 | if rec.Log == nil || rec.Log.OID != done.LogOID || rec.Exit != 0 || rec.Job != done.Job { | |
| 2724 | 2724 | t.Fatalf("the record does not describe the run: %+v", rec) | |
| 2725 | 2725 | } | |
| 2726 | // The signature is the attestation: verify the commit the way a reader would. | ||
| 2727 | verifyCommit(t, f, m, att.CommitID, att.Signer) | ||
| 2728 | if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "attested by") { | ||
| 2729 | t.Fatalf("the run page does not say who attested it:\n%s", page) | ||
| 2726 | // Attribution is the boundary's, and the page says who it accepted the result from. | ||
| 2727 | if _, page := f.get("/r/" + f.repo + "/runs/" + done.ID); !strings.Contains(page, "reported by") { | ||
| 2728 | t.Fatalf("the run page does not say who reported the result:\n%s", page) | ||
| 2730 | 2729 | } | |
| 2731 | 2730 | if done.SHA != mustSHA(t, work) { | |
| 2732 | 2731 | t.Fatalf("run recorded sha %s, want the pushed commit", done.SHA) | |
| @@ -2804,14 +2803,13 @@ jobs: | |||
| 2804 | 2803 | t.Fatalf("no attestation in the queue: %v", err) | |
| 2805 | 2804 | } | |
| 2806 | 2805 | // The queue mirrors the record, and the record is the one on the branch: same | |
| 2807 | // attempt, same signer, same key, same commit. | ||
| 2808 | if rec.Signer != att.Signer || rec.Key != att.Fingerprint || rec.Attempt != done.Attempts { | ||
| 2806 | // attempt, same reporter, same commit. | ||
| 2807 | if rec.Signer != att.Signer || rec.Attempt != done.Attempts { | ||
| 2809 | 2808 | t.Fatalf("record = %+v, attestation = %+v", rec, att) | |
| 2810 | 2809 | } | |
| 2811 | 2810 | if rec.Commit != att.CommitID { | |
| 2812 | 2811 | t.Fatalf("the record is in commit %q but the queue says %q", rec.Commit, att.CommitID) | |
| 2813 | 2812 | } | |
| 2814 | verifyCommit(t, f, m, rec.Commit, rec.Signer) | ||
| 2815 | 2813 | ||
| 2816 | 2814 | // Portability, measured: a clone carries the record with it. | |
| 2817 | 2815 | clone := filepath.Join(t.TempDir(), "carried.git") | |
| @@ -2868,8 +2866,7 @@ jobs: | |||
| 2868 | 2866 | if err != nil || restoredAtt == nil { | |
| 2869 | 2867 | t.Fatalf("the attestation was not restored: %v", err) | |
| 2870 | 2868 | } | |
| 2871 | if restoredAtt.Signer != att.Signer || restoredAtt.CommitID != att.CommitID || | ||
| 2872 | restoredAtt.Fingerprint != att.Fingerprint { | ||
| 2869 | if restoredAtt.Signer != att.Signer || restoredAtt.CommitID != att.CommitID { | ||
| 2873 | 2870 | t.Fatalf("restored attestation = %+v", restoredAtt) | |
| 2874 | 2871 | } | |
| 2875 | 2872 | ||
| @@ -3050,37 +3047,6 @@ func (f *fixture) runOnceAs(t *testing.T, keyPath string) { | |||
| 3050 | 3047 | // signature has to verify under a key of the account that reported the result, and the | |
| 3051 | 3048 | // principal git names has to be that account. It is the same check `pre-receive` makes, | |
| 3052 | 3049 | // run from the reading side. | |
| 3053 | func verifyCommit(t *testing.T, f *fixture, m *repo.Meta, commit, accountID string) { | ||
| 3054 | t.Helper() | ||
| 3055 | acct, err := f.srv.Identity.Find(accountID) | ||
| 3056 | if err != nil || acct == nil { | ||
| 3057 | t.Fatalf("no account %s: %v", accountID, err) | ||
| 3058 | } | ||
| 3059 | var b strings.Builder | ||
| 3060 | for _, k := range acct.Keys { | ||
| 3061 | fields := strings.Fields(k.Line) | ||
| 3062 | if len(fields) >= 2 { | ||
| 3063 | fmt.Fprintf(&b, "%s %s %s\n", accountID, fields[0], fields[1]) | ||
| 3064 | } | ||
| 3065 | } | ||
| 3066 | path := filepath.Join(t.TempDir(), "allowed") | ||
| 3067 | if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { | ||
| 3068 | t.Fatal(err) | ||
| 3069 | } | ||
| 3070 | principal, fingerprint, err := m.Git().VerifyCommitSignature(commit, path) | ||
| 3071 | if err != nil { | ||
| 3072 | t.Fatalf("the record's commit is not signed by %s: %v", accountID, err) | ||
| 3073 | } | ||
| 3074 | if principal != accountID { | ||
| 3075 | t.Fatalf("signed by principal %q, want %q", principal, accountID) | ||
| 3076 | } | ||
| 3077 | if !strings.HasPrefix(fingerprint, "SHA256:") { | ||
| 3078 | t.Fatalf("fingerprint = %q", fingerprint) | ||
| 3079 | } | ||
| 3080 | } | ||
| 3081 | |||
| 3082 | // cfgFile writes a config that points at this fixture's data directory, so a test can | ||
| 3083 | // run the CLI the way an operator would next to the running server. | ||
| 3084 | 3050 | func (f *fixture) cfgFile(t *testing.T) string { | |
| 3085 | 3051 | t.Helper() | |
| 3086 | 3052 | path := filepath.Join(t.TempDir(), "vole.toml") | |