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 signs the result it reports
22528ac3da576387a8311969641a7b5783b04924 · Keenan Nemetz · 17d ago · parent 86fe5bb4
Phase 4: the runner signs the result it reports
An instance-key signature is the server witnessing itself. It cannot be checked
without trusting the box that produced it, and it says nothing about the party that
ran the steps — so a record signed that way would have been paperwork. The runner
already holds a key, the one it connects with, so the runner is what signs.
At finish the runner signs a claim: run, repo, ref, SHA, workflow blob, the digest of
the steps pinned when the job was queued, exit code, the log's {oid, size}, the
artifacts it published, and its own finishing time. The server rebuilds the same claim
from its own row plus what the runner reports, verifies the signature, and stores the
payload and signature verbatim — so a verifier re-checks the bytes that were signed
rather than re-deriving them from a format that might have drifted.
Who the signer is comes from the channel, not the claim: the account is the one the
SSH key resolved to, and the signature must verify under one of that account's keys.
A runner may therefore sign with a different key from the one it connected with — an
agent-held key to connect, a file to sign — but not as somebody else.
Refusals, all before anything is recorded, because half a record is worse than none:
an unsigned finish (an unattributed result is what the queue already had), a signature
by another account's key, and a signature over a different claim. The run page shows
`attested by <name> · key <fingerprint>`, and `vole runner` now pings first and refuses
to start work when the key it will sign with is not one its account holds — discovering
that after a ten-minute build is a wasted build.
`steps_sha` is recorded at enqueue and travels with the lease, so the claim covers the
steps that ran and not merely the workflow file they came from.
What this does not buy: truth. A runner can still lie about an exit code. It buys
accountability — the record is attributable to a party other than the instance.
Whether a required check must be attested by a particular runner (an ACL key defaulting
to the instance's own) is the next policy question and is deliberately not answered
here; any admin of the repository may still supply the signature.
Verified: TestAFinishedRunBelongsToTheRunnerThatSignsIt drives the real protocol over
SSH — the lease carries what the claim names, ping names the account and its keys,
unsigned and wrong-account and wrong-claim finishes are all refused with nothing
recorded, the correct one is accepted, and the stored payload still verifies under the
stored signature; TestClaimRendersOneWay pins the byte form (including artifact
ordering and an empty log) and TestClaimRefusesToForgeItself refuses a field carrying a
newline; TestStepDigestFollowsTheSteps shows the digest covering the steps themselves;
the cmd tests cover key discovery from VOLE_SSH_COMMAND/GIT_SSH_COMMAND, the missing-key
message, a non-key file, and the pre-work refusal; and the end-to-end test that runs the
real binary asserts the claim names the run, its log and its exit, and that the run page
says who attested it. scripts/ci.sh green (gofmt, vet, build, markdown links, go test
-race ./...).
internal/sshserver/attest_test.go +214
| @@ -0,0 +1,214 @@ | |||
| 1 | package sshserver | ||
| 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. | ||
| 8 | |||
| 9 | import ( | ||
| 10 | "crypto/ed25519" | ||
| 11 | "encoding/json" | ||
| 12 | "os" | ||
| 13 | "strings" | ||
| 14 | "testing" | ||
| 15 | "time" | ||
| 16 | |||
| 17 | "github.com/rodent-software/vole/internal/runs" | ||
| 18 | "golang.org/x/crypto/ssh" | ||
| 19 | ) | ||
| 20 | |||
| 21 | // runnerConn is the wire, as a runner would see it. | ||
| 22 | type runnerConn struct { | ||
| 23 | t *testing.T | ||
| 24 | sess *ssh.Session | ||
| 25 | enc *json.Encoder | ||
| 26 | dec *json.Decoder | ||
| 27 | } | ||
| 28 | |||
| 29 | func (h *harness) runner(t *testing.T, keyPath string) *runnerConn { | ||
| 30 | t.Helper() | ||
| 31 | client := h.dial(t, keyPath) | ||
| 32 | sess, err := client.NewSession() | ||
| 33 | if err != nil { | ||
| 34 | t.Fatalf("new session: %v", err) | ||
| 35 | } | ||
| 36 | t.Cleanup(func() { sess.Close() }) | ||
| 37 | stdin, err := sess.StdinPipe() | ||
| 38 | if err != nil { | ||
| 39 | t.Fatalf("stdin: %v", err) | ||
| 40 | } | ||
| 41 | stdout, err := sess.StdoutPipe() | ||
| 42 | if err != nil { | ||
| 43 | t.Fatalf("stdout: %v", err) | ||
| 44 | } | ||
| 45 | if err := sess.Start("vole-runner"); err != nil { | ||
| 46 | t.Fatalf("start vole-runner: %v", err) | ||
| 47 | } | ||
| 48 | return &runnerConn{t: t, sess: sess, enc: json.NewEncoder(stdin), dec: json.NewDecoder(stdout)} | ||
| 49 | } | ||
| 50 | |||
| 51 | func (c *runnerConn) call(req runnerRequest) runnerReply { | ||
| 52 | c.t.Helper() | ||
| 53 | if err := c.enc.Encode(req); err != nil { | ||
| 54 | c.t.Fatalf("%s: %v", req.Op, err) | ||
| 55 | } | ||
| 56 | var rep runnerReply | ||
| 57 | if err := c.dec.Decode(&rep); err != nil { | ||
| 58 | c.t.Fatalf("%s: reading the reply: %v", req.Op, err) | ||
| 59 | } | ||
| 60 | return rep | ||
| 61 | } | ||
| 62 | |||
| 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 | t.Helper() | ||
| 66 | data, err := readFile(path) | ||
| 67 | if err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | raw, err := ssh.ParseRawPrivateKey(data) | ||
| 71 | if err != nil { | ||
| 72 | t.Fatal(err) | ||
| 73 | } | ||
| 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 | ||
| 82 | } | ||
| 83 | |||
| 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() | ||
| 89 | run := runs.Run{ | ||
| 90 | ID: "01M5N3M0TQNJZEYZHVTH7T6P7C", | ||
| 91 | Repo: "demo", | ||
| 92 | Ref: "refs/heads/main", | ||
| 93 | Branch: "main", | ||
| 94 | SHA: strings.Repeat("ab", 20), | ||
| 95 | Workflow: ".vole/workflows/ci.yaml", | ||
| 96 | WorkflowSHA: strings.Repeat("cd", 20), | ||
| 97 | Job: "build", | ||
| 98 | Steps: []runs.Step{{Name: "test", Run: "true"}}, | ||
| 99 | Created: now, | ||
| 100 | } | ||
| 101 | if _, err := h.runs.Enqueue(run); err != nil { | ||
| 102 | t.Fatal(err) | ||
| 103 | } | ||
| 104 | |||
| 105 | c := h.runner(t, h.keyPath) | ||
| 106 | |||
| 107 | // Who am I: the reply names the account and its keys, so a runner can check the | ||
| 108 | // key it intends to sign with before it starts work. | ||
| 109 | who := c.call(runnerRequest{Op: "ping"}) | ||
| 110 | if !who.OK || who.Account != h.writerID || len(who.Keys) == 0 { | ||
| 111 | t.Fatalf("ping = %+v", who) | ||
| 112 | } | ||
| 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 | ||
| 119 | // 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) | ||
| 139 | } | ||
| 140 | key := signingKey(t, h.keyPath) | ||
| 141 | |||
| 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) | ||
| 147 | } | ||
| 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) | ||
| 156 | } | ||
| 157 | |||
| 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) | ||
| 170 | } | ||
| 171 | |||
| 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") | ||
| 175 | } | ||
| 176 | |||
| 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)}) | ||
| 180 | if rep.Error != "" || !rep.OK { | ||
| 181 | t.Fatalf("a signed finish was refused: %+v", rep) | ||
| 182 | } | ||
| 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) | ||
| 186 | } | ||
| 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) | ||
| 191 | if err != nil || att == nil { | ||
| 192 | t.Fatalf("no attestation: %v", err) | ||
| 193 | } | ||
| 194 | if att.Signer != h.writerID || att.Fingerprint == "" { | ||
| 195 | t.Fatalf("attestation = %+v", att) | ||
| 196 | } | ||
| 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) | ||
| 213 | } | ||
| 214 | } | ||