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