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.

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 }