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.
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/sshserver/attest_test.go +106−109
| @@ -1,19 +1,21 @@ | |||
| 1 | 1 | package sshserver | |
| 2 | 2 | ||
| 3 | // A finished run is only recorded if the party that did the work signs it. These | ||
| 4 | // tests speak the runner protocol over a real SSH channel, because the property is | ||
| 5 | // about the boundary between two parties, not about a function: the account comes | ||
| 6 | // from the key that opened the connection, the signature comes from the runner, and | ||
| 7 | // neither alone is enough to record a result. | ||
| 3 | // A finished run is recorded because the runner published a signed commit, and the | ||
| 4 | // server creates nothing. These tests speak the runner protocol over a real SSH | ||
| 5 | // channel and put a record on the branch the way a runner does, because the property | ||
| 6 | // is about the boundary between two parties: the account comes from the key that | ||
| 7 | // opened the connection, the record comes from the push, and the finish only closes | ||
| 8 | // the lease and mirrors what the branch already says. | ||
| 8 | 9 | ||
| 9 | 10 | import ( | |
| 10 | "crypto/ed25519" | ||
| 11 | 11 | "encoding/json" | |
| 12 | 12 | "os" | |
| 13 | 13 | "strings" | |
| 14 | 14 | "testing" | |
| 15 | 15 | "time" | |
| 16 | 16 | ||
| 17 | "github.com/rodent-software/vole/internal/id" | ||
| 18 | "github.com/rodent-software/vole/internal/issues" | ||
| 17 | 19 | "github.com/rodent-software/vole/internal/runs" | |
| 18 | 20 | "golang.org/x/crypto/ssh" | |
| 19 | 21 | ) | |
| @@ -60,48 +62,82 @@ func (c *runnerConn) call(req runnerRequest) runnerReply { | |||
| 60 | 62 | return rep | |
| 61 | 63 | } | |
| 62 | 64 | ||
| 63 | // signingKey reads one of the harness's private keys, the way a runner reads its own. | ||
| 64 | func signingKey(t *testing.T, path string) ed25519.PrivateKey { | ||
| 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 { | ||
| 65 | 67 | t.Helper() | |
| 66 | data, err := readFile(path) | ||
| 68 | line, err := os.ReadFile(keyPath + ".pub") | ||
| 67 | 69 | if err != nil { | |
| 68 | 70 | t.Fatal(err) | |
| 69 | 71 | } | |
| 70 | raw, err := ssh.ParseRawPrivateKey(data) | ||
| 72 | pub, _, _, _, err := ssh.ParseAuthorizedKey(line) | ||
| 71 | 73 | if err != nil { | |
| 72 | 74 | t.Fatal(err) | |
| 73 | 75 | } | |
| 74 | switch k := raw.(type) { | ||
| 75 | case *ed25519.PrivateKey: | ||
| 76 | return *k | ||
| 77 | case ed25519.PrivateKey: | ||
| 78 | return k | ||
| 79 | } | ||
| 80 | t.Fatalf("%s is %T", path, raw) | ||
| 81 | return nil | ||
| 76 | return ssh.FingerprintSHA256(pub) | ||
| 82 | 77 | } | |
| 83 | 78 | ||
| 84 | func readFile(path string) ([]byte, error) { return os.ReadFile(path) } | ||
| 85 | |||
| 86 | func TestAFinishedRunBelongsToTheRunnerThatSignsIt(t *testing.T) { | ||
| 87 | h := newHarness(t) | ||
| 88 | now := time.Now().UTC() | ||
| 79 | // leaseRun enqueues one job and leases it, returning the queue's view of the run and | ||
| 80 | // the job the runner was handed. | ||
| 81 | func (h *harness) leaseRun(t *testing.T, c *runnerConn, jobName string) (*runs.Run, *runnerJob) { | ||
| 82 | t.Helper() | ||
| 83 | runID, err := id.New(time.Now().UTC()) | ||
| 84 | if err != nil { | ||
| 85 | t.Fatal(err) | ||
| 86 | } | ||
| 89 | 87 | run := runs.Run{ | |
| 90 | ID: "01M5N3M0TQNJZEYZHVTH7T6P7C", | ||
| 91 | Repo: "demo", | ||
| 88 | ID: runID, | ||
| 89 | Repo: h.repoName, | ||
| 92 | 90 | Ref: "refs/heads/main", | |
| 93 | 91 | Branch: "main", | |
| 94 | 92 | SHA: strings.Repeat("ab", 20), | |
| 95 | 93 | Workflow: ".vole/workflows/ci.yaml", | |
| 96 | 94 | WorkflowSHA: strings.Repeat("cd", 20), | |
| 97 | Job: "build", | ||
| 95 | Job: jobName, | ||
| 98 | 96 | Steps: []runs.Step{{Name: "test", Run: "true"}}, | |
| 99 | Created: now, | ||
| 97 | Created: time.Now().UTC(), | ||
| 100 | 98 | } | |
| 99 | // The unique index on (repo, ref, sha, workflow, job) is what makes ingestion | ||
| 100 | // idempotent, so each case below is its own job. | ||
| 101 | 101 | if _, err := h.runs.Enqueue(run); err != nil { | |
| 102 | 102 | t.Fatal(err) | |
| 103 | 103 | } | |
| 104 | lease := c.call(runnerRequest{Op: "lease"}) | ||
| 105 | if lease.Run == nil { | ||
| 106 | t.Fatalf("no job was leased for %s: %+v", jobName, lease) | ||
| 107 | } | ||
| 108 | got, err := h.runs.Get(lease.Run.ID) | ||
| 109 | if err != nil || got == nil { | ||
| 110 | t.Fatalf("the leased run is not in the queue: %v", err) | ||
| 111 | } | ||
| 112 | return got, lease.Run | ||
| 113 | } | ||
| 114 | |||
| 115 | // publishRecord puts a record on the branch the way a runner does: one signed commit, | ||
| 116 | // written straight into the server's repository so the test does not have to drive a | ||
| 117 | // second push protocol. | ||
| 118 | func (h *harness) publishRecord(t *testing.T, job *runnerJob, attempt int, signer, keyPath string) string { | ||
| 119 | t.Helper() | ||
| 120 | m, err := h.repos.Open(h.repoName) | ||
| 121 | if err != nil { | ||
| 122 | t.Fatal(err) | ||
| 123 | } | ||
| 124 | rec := runs.Record{ | ||
| 125 | RunID: job.ID, Attempt: attempt, Repo: h.repoName, Ref: job.Ref, | ||
| 126 | SHA: job.SHA, Workflow: job.Workflow, WorkflowSHA: job.WorkflowSHA, | ||
| 127 | Job: job.Job, StepsSHA: job.StepsSHA, Exit: 0, Finished: time.Now().UTC(), | ||
| 128 | Signer: signer, Key: fingerprintOf(t, keyPath), | ||
| 129 | } | ||
| 130 | commit, err := runs.WriteRecord(m.Git(), issues.Ref, rec, "writer", keyPath+".pub", nil) | ||
| 131 | if err != nil { | ||
| 132 | t.Fatalf("publishing the record: %v", err) | ||
| 133 | } | ||
| 134 | return commit | ||
| 135 | } | ||
| 104 | 136 | ||
| 137 | // A finish cannot invent a result: without a record on the branch it is refused, and | ||
| 138 | // with one it mirrors what the runner already published. | ||
| 139 | func TestAFinishMirrorsTheRecordAndCreatesNothing(t *testing.T) { | ||
| 140 | h := newHarness(t) | ||
| 105 | 141 | c := h.runner(t, h.keyPath) | |
| 106 | 142 | ||
| 107 | 143 | // Who am I: the reply names the account and its keys, so a runner can check the | |
| @@ -110,105 +146,66 @@ func TestAFinishedRunBelongsToTheRunnerThatSignsIt(t *testing.T) { | |||
| 110 | 146 | if !who.OK || who.Account != h.writerID || len(who.Keys) == 0 { | |
| 111 | 147 | t.Fatalf("ping = %+v", who) | |
| 112 | 148 | } | |
| 113 | lease := c.call(runnerRequest{Op: "lease"}) | ||
| 114 | if lease.Run == nil { | ||
| 115 | t.Fatal("no job was leased") | ||
| 116 | } | ||
| 117 | job := lease.Run | ||
| 118 | // The lease has to carry everything the claim names: a runner cannot sign what | ||
| 149 | |||
| 150 | // The lease has to carry everything the record names: a runner cannot report what | ||
| 119 | 151 | // it was never told. | |
| 120 | if job.Ref != run.Ref || job.StepsSHA == "" || job.WorkflowSHA != run.WorkflowSHA { | ||
| 121 | t.Fatalf("lease = %+v", job) | ||
| 122 | } | ||
| 123 | |||
| 124 | claim := runs.Claim{ | ||
| 125 | RunID: job.ID, | ||
| 126 | Repo: job.Repo, | ||
| 127 | Ref: job.Ref, | ||
| 128 | SHA: job.SHA, | ||
| 129 | Workflow: job.Workflow, | ||
| 130 | WorkflowSHA: job.WorkflowSHA, | ||
| 131 | Job: job.Job, | ||
| 132 | StepsSHA: job.StepsSHA, | ||
| 133 | Exit: 0, | ||
| 134 | Finished: now.Unix(), | ||
| 135 | } | ||
| 136 | payload, err := claim.Canonical() | ||
| 137 | if err != nil { | ||
| 138 | t.Fatal(err) | ||
| 152 | run, job := h.leaseRun(t, c, "build") | ||
| 153 | if job.Ref != run.Ref || job.StepsSHA == "" || job.WorkflowSHA != run.WorkflowSHA || | ||
| 154 | job.Attempt != run.Attempts { | ||
| 155 | t.Fatalf("lease = %+v, run = %+v", job, run) | ||
| 139 | 156 | } | |
| 140 | key := signingKey(t, h.keyPath) | ||
| 141 | 157 | ||
| 142 | // Unsigned: refused, and nothing recorded. An unattributed result is what the | ||
| 143 | // queue already had. | ||
| 144 | rep := c.call(runnerRequest{Op: "finish", Run: job.ID, Exit: 0, Finished: now.Unix()}) | ||
| 145 | if rep.Error == "" || !strings.Contains(rep.Error, "signed") { | ||
| 146 | t.Fatalf("an unsigned finish was accepted: %+v", rep) | ||
| 158 | // 1. Nothing on the branch: refused, and nothing recorded. | ||
| 159 | rep := c.call(runnerRequest{Op: "finish", Run: job.ID}) | ||
| 160 | if rep.Error == "" || !strings.Contains(rep.Error, "no record") { | ||
| 161 | t.Fatalf("a finish with no record was accepted: %+v", rep) | ||
| 147 | 162 | } | |
| 148 | |||
| 149 | // Signed by a key of a different account: refused. The channel decides who the | ||
| 150 | // runner is; a signature cannot claim to be somebody else. | ||
| 151 | other := signingKey(t, h.roKey) | ||
| 152 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Exit: 0, Finished: now.Unix(), | ||
| 153 | Signature: ed25519.Sign(other, payload)}) | ||
| 154 | if rep.Error == "" || !strings.Contains(rep.Error, "does not verify") { | ||
| 155 | t.Fatalf("a signature by another account was accepted: %+v", rep) | ||
| 163 | if got, _ := h.runs.Get(run.ID); got.Status != runs.StatusRunning { | ||
| 164 | t.Fatalf("a refused finish changed the run: %+v", got) | ||
| 156 | 165 | } | |
| 157 | 166 | ||
| 158 | // Signed by the runner's key over a claim that says something else: refused. The | ||
| 159 | // signature covers the outcome, not just the identity. | ||
| 160 | lie := claim | ||
| 161 | lie.Exit = 1 | ||
| 162 | liePayload, err := lie.Canonical() | ||
| 163 | if err != nil { | ||
| 164 | t.Fatal(err) | ||
| 165 | } | ||
| 166 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Exit: 0, Finished: now.Unix(), | ||
| 167 | Signature: ed25519.Sign(key, liePayload)}) | ||
| 168 | if rep.Error == "" || !strings.Contains(rep.Error, "does not verify") { | ||
| 169 | t.Fatalf("a signature over a different result was accepted: %+v", rep) | ||
| 167 | // 2. A record that names another account is refused: the channel decides who is | ||
| 168 | // finishing, and the record has to be that account's. | ||
| 169 | _, job2 := h.leaseRun(t, c, "check") | ||
| 170 | h.publishRecord(t, job2, 1, "01M5READER0000000000000000Z", h.keyPath) | ||
| 171 | rep = c.call(runnerRequest{Op: "finish", Run: job2.ID}) | ||
| 172 | if rep.Error == "" || !strings.Contains(rep.Error, "another account") { | ||
| 173 | t.Fatalf("a record naming another account was accepted: %+v", rep) | ||
| 170 | 174 | } | |
| 171 | 175 | ||
| 172 | // Nothing above may have left a mark on the run. | ||
| 173 | if got, _ := h.runs.Get(job.ID); got.Status == runs.StatusSuccess { | ||
| 174 | t.Fatal("a refused finish recorded a result") | ||
| 176 | // 3. A record for an attempt the queue is not on is refused. Replay is how a | ||
| 177 | // runner would try to rewrite the history of an attempt it never ran. | ||
| 178 | _, job3 := h.leaseRun(t, c, "audit") | ||
| 179 | h.publishRecord(t, job3, 7, h.writerID, h.keyPath) | ||
| 180 | rep = c.call(runnerRequest{Op: "finish", Run: job3.ID}) | ||
| 181 | if rep.Error == "" || !strings.Contains(rep.Error, "attempt 7") { | ||
| 182 | t.Fatalf("a record for another attempt was accepted: %+v", rep) | ||
| 175 | 183 | } | |
| 176 | 184 | ||
| 177 | // The real thing. | ||
| 178 | rep = c.call(runnerRequest{Op: "finish", Run: job.ID, Exit: 0, Finished: now.Unix(), | ||
| 179 | Signature: ed25519.Sign(key, payload)}) | ||
| 185 | // 4. The real thing: the runner published attempt 1, signed, and the finish mirrors | ||
| 186 | // it into the queue. | ||
| 187 | run4, job4 := h.leaseRun(t, c, "release") | ||
| 188 | commit := h.publishRecord(t, job4, run4.Attempts, h.writerID, h.keyPath) | ||
| 189 | rep = c.call(runnerRequest{Op: "finish", Run: job4.ID}) | ||
| 180 | 190 | if rep.Error != "" || !rep.OK { | |
| 181 | t.Fatalf("a signed finish was refused: %+v", rep) | ||
| 191 | t.Fatalf("a finish with a signed record was refused: %+v", rep) | ||
| 182 | 192 | } | |
| 183 | got, err := h.runs.Get(job.ID) | ||
| 184 | if err != nil || got.Status != runs.StatusSuccess || got.ExitCode != 0 { | ||
| 185 | t.Fatalf("run = %+v, %v", got, err) | ||
| 193 | got, err := h.runs.Get(run4.ID) | ||
| 194 | if err != nil || got == nil { | ||
| 195 | t.Fatal(err) | ||
| 186 | 196 | } | |
| 187 | |||
| 188 | // The record keeps the exact bytes that were signed, attributed to the account | ||
| 189 | // and the key, and the signature still verifies against the roster afterwards. | ||
| 190 | att, err := h.runs.Attestation(job.ID) | ||
| 197 | if got.Status != runs.StatusSuccess || got.ExitCode != 0 { | ||
| 198 | t.Fatalf("run = %+v", got) | ||
| 199 | } | ||
| 200 | att, err := h.runs.Attestation(run4.ID) | ||
| 191 | 201 | if err != nil || att == nil { | |
| 192 | 202 | t.Fatalf("no attestation: %v", err) | |
| 193 | 203 | } | |
| 194 | if att.Signer != h.writerID || att.Fingerprint == "" { | ||
| 204 | if att.Signer != h.writerID || att.Fingerprint != fingerprintOf(t, h.keyPath) { | ||
| 195 | 205 | t.Fatalf("attestation = %+v", att) | |
| 196 | 206 | } | |
| 197 | acct, err := h.ident.Find(h.writerID) | ||
| 198 | if err != nil { | ||
| 199 | t.Fatal(err) | ||
| 200 | } | ||
| 201 | if acct.Keys[0].Fingerprint != att.Fingerprint { | ||
| 202 | t.Fatalf("fingerprint = %q, want the key that signed", att.Fingerprint) | ||
| 203 | } | ||
| 204 | pub, ok := key.Public().(ed25519.PublicKey) | ||
| 205 | if !ok { | ||
| 206 | t.Fatal("not an ed25519 public key") | ||
| 207 | } | ||
| 208 | if !ed25519.Verify(pub, att.Payload, att.Signature) { | ||
| 209 | t.Fatal("the stored payload does not verify under the stored signature: the record is not checkable later") | ||
| 210 | } | ||
| 211 | if !strings.Contains(string(att.Payload), "run "+job.ID) || !strings.Contains(string(att.Payload), "exit 0") { | ||
| 212 | t.Fatalf("the claim does not describe the run:\n%s", att.Payload) | ||
| 207 | // The mirrored row points back at the commit, which is where the proof lives. | ||
| 208 | if att.CommitID != commit { | ||
| 209 | t.Fatalf("attestation names commit %q, want %q", att.CommitID, commit) | ||
| 213 | 210 | } | |
| 214 | 211 | } | |