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/sshserver/attest_test.go +8−22

@@ -9,7 +9,6 @@ package sshserver
9 9
10 10 import (
11 11 "encoding/json"
12 "os"
13 12 "strings"
14 13 "testing"
15 14 "time"
@@ -62,20 +61,6 @@ func (c *runnerConn) call(req runnerRequest) runnerReply {
62 61 return rep
63 62 }
64 63
65 // fingerprintOf is the key id a record names, read from the .pub the harness wrote.
66 func fingerprintOf(t *testing.T, keyPath string) string {
67 t.Helper()
68 line, err := os.ReadFile(keyPath + ".pub")
69 if err != nil {
70 t.Fatal(err)
71 }
72 pub, _, _, _, err := ssh.ParseAuthorizedKey(line)
73 if err != nil {
74 t.Fatal(err)
75 }
76 return ssh.FingerprintSHA256(pub)
77 }
78
79 64 // leaseRun enqueues one job and leases it, returning the queue's view of the run and
80 65 // the job the runner was handed.
81 66 func (h *harness) leaseRun(t *testing.T, c *runnerConn, jobName string) (*runs.Run, *runnerJob) {
@@ -125,9 +110,9 @@ func (h *harness) publishRecord(t *testing.T, job *runnerJob, attempt int, signe
125 110 RunID: job.ID, Attempt: attempt, Repo: h.repoName, Ref: job.Ref,
126 111 SHA: job.SHA, Workflow: job.Workflow, WorkflowSHA: job.WorkflowSHA,
127 112 Job: job.Job, StepsSHA: job.StepsSHA, Exit: 0, Finished: time.Now().UTC(),
128 Signer: signer, Key: fingerprintOf(t, keyPath),
113 Signer: signer,
129 114 }
130 commit, err := runs.WriteRecord(m.Git(), issues.Ref, rec, "writer", keyPath+".pub", nil)
115 commit, err := runs.WriteRecord(m.Git(), issues.Ref, rec, "writer", nil)
131 116 if err != nil {
132 117 t.Fatalf("publishing the record: %v", err)
133 118 }
@@ -141,10 +126,10 @@ func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) {
141 126 h := newHarness(t)
142 127 c := h.runner(t, h.keyPath)
143 128
144 // Who am I: the reply names the account and its keys, so a runner can check the
145 // key it intends to sign with before it starts work.
129 // Who am I: the reply names the account the connection authenticated as, so a runner
130 // can say whose results it will report.
146 131 who := c.call(runnerRequest{Op: "ping"})
147 if !who.OK || who.Account != h.writerID || len(who.Keys) == 0 {
132 if !who.OK || who.Account != h.writerID || who.Name == "" {
148 133 t.Fatalf("ping = %+v", who)
149 134 }
150 135
@@ -202,10 +187,11 @@ func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) {
202 187 if err != nil || att == nil {
203 188 t.Fatalf("no attestation: %v", err)
204 189 }
205 if att.Signer != h.writerID || att.Fingerprint != fingerprintOf(t, h.keyPath) {
190 if att.Signer != h.writerID {
206 191 t.Fatalf("attestation = %+v", att)
207 192 }
208 // The mirrored row points back at the commit, which is where the proof lives.
193 // The mirrored row points back at the commit, which is what a reader follows to the
194 // bytes.
209 195 if att.CommitID != commit {
210 196 t.Fatalf("attestation names commit %q, want %q", att.CommitID, commit)
211 197 }