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.

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