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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Phase 4: the runner publishes its result as a signed commit

90fdaf397a1120677a1246201369e6dcc82b0aed · Keenan Nemetz · 17d ago · parent 96cbb6fa

Phase 4: the runner publishes its result as a signed commit

A finished run's result existed only in data/runs.db and the object store, so a
clone carried the commits and the workflow but not the fact that CI passed, who
said so, or the log it said it with. And the previous cut of this change had the
server commit the record — which is the box vouching for a party, the same
mistake as the instance signing key that was deleted.

The runner now publishes it. When an attempt ends the runner commits
runs/<run id>/<attempt>/ — record.md (repo, ref, sha, workflow blob, job, steps
digest, exit, finish time, signer, key) plus an LFS pointer for the log and one
per artifact — together with any release pointers the build published, and
pushes that once. One push per run: the result and the artifacts it produced are
one fact, so a release cannot carry bytes whose build has no record.

The commit is signed with the runner's SSH key, and that signature is the whole
attestation: it covers the tree, the parents and the message, so the separate
claim and signature file are gone, and there is no second statement to keep in
step with the files it describes. pre-receive checks it against an
allowed-signers file built from the pushing account's keys, so identity comes
from the key and not from a name typed into a commit.

Who may report is the repository's runner allowlist — the same list that decides
who may lease a job — and the record must describe the job the queue handed that
account: same run, same attempt, same commit, workflow, job and steps digest.
Without that an allowed runner could report a green result for a commit nobody
asked it to build, or invent attempt 99 and have the next rebuild believe it.
Records are append-only, with no admin override: an edit or a deletion is a
rewrite of evidence, and the alternative to refusing is running the job again.

runs.db keeps the queue (queued and leased jobs have no record because they have
not happened) and mirrors finished runs as derived rows, which vole index
rebuild reconstructs from the branch. The attestation row now names the commit
instead of storing a claim and a signature.

Measured: a real run finished, a bare clone carried runs/<id>/1/record.md, git
verify-commit accepted that commit under the roster and refused an unsigned one;
a writer who is not a runner was refused by pre-receive naming the allowlist;
one CI release commit carried both the record and releases/v1.0.0/tool-linux;
deleting runs.db and rebuilding restored the row, the attestation and the log.

internal/hook/records_test.go +382

@@ -0,0 +1,382 @@
1 package hook
2
3 // Who may report a run result, from the gate's side: the record is the runner's, so
4 // the questions are whether the account may report at all, whether the commit is
5 // signed by that account's key, and whether the record describes the job it was handed.
6
7 import (
8 "os"
9 "os/exec"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/rodent-software/vole/internal/gitx"
16 "github.com/rodent-software/vole/internal/id"
17 "github.com/rodent-software/vole/internal/identity"
18 "github.com/rodent-software/vole/internal/issues"
19 "github.com/rodent-software/vole/internal/repo"
20 "github.com/rodent-software/vole/internal/runs"
21 "golang.org/x/crypto/ssh"
22 )
23
24 // recordFixture is everything a record push needs: a repository, a roster with real
25 // keys, an ACL naming one allowed runner, and a queue with a leased job to report on.
26 type recordFixture struct {
27 t *testing.T
28 dir string
29 repo *repo.Meta
30 usersDB string
31 runsDB string
32 runnerID string
33 otherID string
34 runKey string // private key path; the .pub sits beside it
35 otherKey string
36 run *runs.Run
37 current string // the metadata branch's tip, as the fixture has pushed it
38 }
39
40 func newRecordFixture(t *testing.T) *recordFixture {
41 t.Helper()
42 dir := t.TempDir()
43 usersDB := filepath.Join(dir, "users.db")
44 if err := identity.Init(usersDB); err != nil {
45 t.Fatal(err)
46 }
47 ident, err := identity.Open(usersDB)
48 if err != nil {
49 t.Fatal(err)
50 }
51 defer ident.Close()
52
53 f := &recordFixture{t: t, dir: dir, usersDB: usersDB,
54 runsDB: filepath.Join(dir, "runs.db")}
55 for _, u := range []struct {
56 name string
57 into *string
58 key *string
59 }{
60 {"runner", &f.runnerID, &f.runKey},
61 {"other", &f.otherID, &f.otherKey},
62 } {
63 acct, err := ident.Create(u.name, "test")
64 if err != nil {
65 t.Fatal(err)
66 }
67 keyPath, pub := writeKey(t, filepath.Join(dir, u.name))
68 if _, err := ident.AddKey(acct.ID, pub, "test"); err != nil {
69 t.Fatal(err)
70 }
71 *u.into, *u.key = acct.ID, keyPath
72 }
73
74 st := repo.New(filepath.Join(dir, "repos"), "/bin/true")
75 m, err := st.Create("demo", f.runnerID, "")
76 if err != nil {
77 t.Fatal(err)
78 }
79 a, err := m.ACL()
80 if err != nil {
81 t.Fatal(err)
82 }
83 if err := a.SetEntry(f.otherID, "write"); err != nil {
84 t.Fatal(err)
85 }
86 a.SetRunners([]string{f.runnerID})
87 if err := m.SetACL(a, "test"); err != nil {
88 t.Fatal(err)
89 }
90 f.repo = m
91
92 queue, err := runs.Open(f.runsDB)
93 if err != nil {
94 t.Fatal(err)
95 }
96 defer queue.Close()
97 runID, err := id.New(time.Now().UTC())
98 if err != nil {
99 t.Fatal(err)
100 }
101 run := runs.Run{
102 ID: runID, Repo: "demo", Ref: "refs/heads/main", Branch: "main",
103 SHA: strings.Repeat("ab", 20), Workflow: ".vole/workflows/ci.yaml",
104 WorkflowSHA: strings.Repeat("cd", 20), Job: "build",
105 Steps: []runs.Step{{Name: "test", Run: "true"}}, Created: time.Now().UTC(),
106 }
107 if _, err := queue.Enqueue(run); err != nil {
108 t.Fatal(err)
109 }
110 // The runner holds it, which is the only state in which a result may be reported.
111 leased, err := queue.Lease(f.runnerID, []string{"demo"}, time.Minute, time.Now().UTC())
112 if err != nil || leased == nil {
113 t.Fatalf("leasing: %v %v", leased, err)
114 }
115 f.run = leased
116 return f
117 }
118
119 // writeKey makes an OpenSSH key pair and returns the private path and the public line.
120 func writeKey(t *testing.T, base string) (string, string) {
121 t.Helper()
122 if out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "test",
123 "-f", base).CombinedOutput(); err != nil {
124 t.Fatalf("ssh-keygen: %v: %s", err, out)
125 }
126 pub, err := os.ReadFile(base + ".pub")
127 if err != nil {
128 t.Fatal(err)
129 }
130 return base, strings.TrimSpace(string(pub))
131 }
132
133 // record is a well-formed record for the fixture's leased job, with the attempt and
134 // signer the caller asks for.
135 func (f *recordFixture) record(attempt int, signer, keyPath string) runs.Record {
136 f.t.Helper()
137 line, err := os.ReadFile(keyPath + ".pub")
138 if err != nil {
139 f.t.Fatal(err)
140 }
141 pub, _, _, _, err := ssh.ParseAuthorizedKey(line)
142 if err != nil {
143 f.t.Fatal(err)
144 }
145 return runs.Record{
146 RunID: f.run.ID, Attempt: attempt, Repo: "demo", Ref: f.run.Ref, SHA: f.run.SHA,
147 Workflow: f.run.Workflow, WorkflowSHA: f.run.WorkflowSHA, Job: f.run.Job,
148 StepsSHA: f.run.StepsSHA, Exit: 0, Finished: time.Now().UTC(),
149 Signer: signer, Key: ssh.FingerprintSHA256(pub),
150 }
151 }
152
153 // push writes a record on top of the branch and returns the new tip.
154 func (f *recordFixture) push(rec runs.Record, signingKey string) string {
155 f.t.Helper()
156 g := f.repo.Git()
157 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", signingKey, nil)
158 if err != nil {
159 f.t.Fatalf("writing the record: %v", err)
160 }
161 if err := g.UpdateRef(issues.Ref, commit); err != nil {
162 f.t.Fatal(err)
163 }
164 old := f.current
165 f.current = commit
166 return old
167 }
168
169 // preReceive runs the gate for a push of the metadata branch by one account.
170 func (f *recordFixture) preReceive(actor, actorID, old, new string) (int, string) {
171 f.t.Helper()
172 if old == "" {
173 old = gitx.ZeroSHA
174 }
175 var out strings.Builder
176 code := PreReceive(Config{
177 RepoDir: f.repo.Dir,
178 Actor: actor,
179 ActorID: actorID,
180 UsersDB: f.usersDB,
181 RunsDB: f.runsDB,
182 In: strings.NewReader(old + " " + new + " " + issues.Ref + "\n"),
183 Out: &out,
184 })
185 return code, out.String()
186 }
187
188 func TestOnlyAnAllowedRunnerMayReportAResult(t *testing.T) {
189 f := newRecordFixture(t)
190
191 // The allowed runner reports its attempt: accepted, because the record is signed by
192 // its own key and describes the job it was leased.
193 old := f.push(f.record(1, f.runnerID, f.runKey), f.runKey+".pub")
194 if code, out := f.preReceive("runner", f.runnerID, old, f.current); code != 0 {
195 t.Fatalf("an allowed runner's record was refused:\n%s", out)
196 }
197
198 // A writer who is not on the runners list cannot report at all, even with a record
199 // of its own making: whose build the host believes is the allowlist's question.
200 other := f.record(2, f.otherID, f.otherKey)
201 old = f.push(other, f.otherKey+".pub")
202 code, out := f.preReceive("other", f.otherID, old, f.current)
203 if code == 0 {
204 t.Fatalf("a non-runner's record was accepted:\n%s", out)
205 }
206 if !strings.Contains(out, "not a runner") || !strings.Contains(out, "acl runners") {
207 t.Fatalf("the refusal should say who may report and how to allow them:\n%s", out)
208 }
209 }
210
211 func TestARecordMustBeSignedByTheAccountThatReportsIt(t *testing.T) {
212 f := newRecordFixture(t)
213
214 // Signed by the other account's key while claiming to be the runner: the signature
215 // is the attestation, so a record whose signer cannot prove it is refused.
216 g := f.repo.Git()
217 rec := f.record(1, f.runnerID, f.otherKey)
218 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", f.otherKey+".pub", nil)
219 if err != nil {
220 t.Fatal(err)
221 }
222 if err := g.UpdateRef(issues.Ref, commit); err != nil {
223 t.Fatal(err)
224 }
225 code, out := f.preReceive("runner", f.runnerID, "", commit)
226 if code == 0 {
227 t.Fatalf("a record signed by another account was accepted:\n%s", out)
228 }
229 if !strings.Contains(out, "not signed by a key of") {
230 t.Fatalf("the refusal should name the signature:\n%s", out)
231 }
232
233 // A record that names somebody else as its reporter, pushed by the runner, is the
234 // same forgery from the other side.
235 f2 := newRecordFixture(t)
236 g2 := f2.repo.Git()
237 rec2 := f2.record(1, f2.otherID, f2.runKey)
238 commit2, err := runs.WriteRecord(g2, issues.Ref, rec2, "runner", f2.runKey+".pub", nil)
239 if err != nil {
240 t.Fatal(err)
241 }
242 if err := g2.UpdateRef(issues.Ref, commit2); err != nil {
243 t.Fatal(err)
244 }
245 code, out = f2.preReceive("runner", f2.runnerID, "", commit2)
246 if code == 0 {
247 t.Fatalf("a record naming another account was accepted:\n%s", out)
248 }
249 if !strings.Contains(out, "pushed it") {
250 t.Fatalf("the refusal should say who pushed it:\n%s", out)
251 }
252 }
253
254 func TestAnUnsignedRecordIsRefused(t *testing.T) {
255 f := newRecordFixture(t)
256 g := f.repo.Git()
257 files, err := f.record(1, f.runnerID, f.runKey).Files()
258 if err != nil {
259 t.Fatal(err)
260 }
261 tree, err := gitx.WriteFlatTree(g, files)
262 if err != nil {
263 t.Fatal(err)
264 }
265 commit, err := g.CommitTreeAs(tree, "", "runs: a result nobody signed", "runner")
266 if err != nil {
267 t.Fatal(err)
268 }
269 if err := g.UpdateRef(issues.Ref, commit); err != nil {
270 t.Fatal(err)
271 }
272 code, out := f.preReceive("runner", f.runnerID, "", commit)
273 if code == 0 {
274 t.Fatalf("an unsigned record was accepted:\n%s", out)
275 }
276 if !strings.Contains(out, "is not signed") {
277 t.Fatalf("the refusal should say the commit is unsigned:\n%s", out)
278 }
279 }
280
281 func TestARecordIsAppendOnlyAtTheGate(t *testing.T) {
282 f := newRecordFixture(t)
283 g := f.repo.Git()
284 f.push(f.record(1, f.runnerID, f.runKey), f.runKey+".pub")
285 oldTip := f.current
286
287 // Editing the record of an attempt is a rewrite of evidence.
288 files, err := g.FlatTree(f.current)
289 if err != nil {
290 t.Fatal(err)
291 }
292 path := runs.RecordPath(f.run.ID, 1) + "/record.md"
293 // The edit has to change the blob: a record that says the same thing in the same
294 // bytes would be an empty commit, which git would refuse to make anyway.
295 files[path] = []byte(strings.Replace(string(files[path]), "exit: 0", "exit: 0\nnote: edited", 1))
296 tree, err := gitx.WriteFlatTree(g, files)
297 if err != nil {
298 t.Fatal(err)
299 }
300 edited, err := g.CommitTreeAs(tree, f.current, "runs: an edit", "runner")
301 if err != nil {
302 t.Fatal(err)
303 }
304 code, out := f.preReceive("runner", f.runnerID, oldTip, edited)
305 if code == 0 {
306 t.Fatalf("an edited record was accepted:\n%s", out)
307 }
308 if !strings.Contains(out, "never rewritten or deleted") {
309 t.Fatalf("the refusal should say records are append-only:\n%s", out)
310 }
311 }
312
313 func TestARecordMustDescribeTheLeasedJob(t *testing.T) {
314 f := newRecordFixture(t)
315
316 // An attempt number the queue is not on: a runner cannot invent attempt 99 for a
317 // finished run and have the next rebuild believe it.
318 g := f.repo.Git()
319 rec := f.record(99, f.runnerID, f.runKey)
320 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", f.runKey+".pub", nil)
321 if err != nil {
322 t.Fatal(err)
323 }
324 if err := g.UpdateRef(issues.Ref, commit); err != nil {
325 t.Fatal(err)
326 }
327 code, out := f.preReceive("runner", f.runnerID, "", commit)
328 if code == 0 {
329 t.Fatalf("a record for another attempt was accepted:\n%s", out)
330 }
331 if !strings.Contains(out, "attempt") {
332 t.Fatalf("the refusal should name the attempt:\n%s", out)
333 }
334 }
335
336 // A record added in a signed commit and then edited by an unsigned one inside the same
337 // push must be refused. The bytes at the tip are what a reader trusts, so the commit
338 // that *last touched* them is the one whose signature has to verify — an edit hidden
339 // behind a signed add is exactly the forgery a signature is supposed to stop.
340 func TestARecordEditedAfterASignedAddIsRefused(t *testing.T) {
341 f := newRecordFixture(t)
342 g := f.repo.Git()
343 rec := f.record(1, f.runnerID, f.runKey)
344 files, err := rec.Files()
345 if err != nil {
346 t.Fatal(err)
347 }
348 tree, err := gitx.WriteFlatTree(g, files)
349 if err != nil {
350 t.Fatal(err)
351 }
352 signedAdd, err := g.CommitTreeAsSigned(tree, "", "runs: run "+rec.RunID+" attempt 1: success",
353 "runner", f.runKey+".pub")
354 if err != nil {
355 t.Fatal(err)
356 }
357 // The same record, rewritten by an unsigned commit that says the job failed.
358 edited := map[string][]byte{}
359 for path, content := range files {
360 edited[path] = content
361 }
362 recordPath := runs.RecordPath(rec.RunID, 1) + "/record.md"
363 edited[recordPath] = []byte(strings.Replace(string(edited[recordPath]), "exit: 0", "exit: 1", 1))
364 editedTree, err := gitx.WriteFlatTree(g, edited)
365 if err != nil {
366 t.Fatal(err)
367 }
368 unsignedEdit, err := g.CommitTreeAs(editedTree, signedAdd, "runs: an edit", "runner")
369 if err != nil {
370 t.Fatal(err)
371 }
372 if err := g.UpdateRef(issues.Ref, unsignedEdit); err != nil {
373 t.Fatal(err)
374 }
375 code, out := f.preReceive("runner", f.runnerID, "", unsignedEdit)
376 if code == 0 {
377 t.Fatalf("an unsigned edit behind a signed add was accepted:\n%s", out)
378 }
379 if !strings.Contains(out, "is not signed") {
380 t.Fatalf("the refusal should say the commit that carries the record is unsigned:\n%s", out)
381 }
382 }