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.

One trust model: attribution at the boundary, and a log of what it decided

f50f6fabc7de2f2d33d22a4f2a8e5518ddb5becb · Keenan Nemetz · 17d ago · parent 671f2aa7

One trust model: attribution at the boundary, and a log of what it decided

The system had two answers to "who did this". Runner results were attributed
by a commit signature; issues, comments, approvals and every push were
attributed by the account the SSH handshake resolved. One was cryptographic
and lived in the object, three were boundary-based and lived in the
instance's word.

Keeping both meant keeping allowed_signers generation, ssh-keygen
-Y find-principals semantics, a --key contract in the runner, a pre-flight key
check before every build, a key column in the queue, and the standing
obligation to keep all of it agreeing with the mechanism used everywhere
else. The signature lost on three counts: it restated the handshake (a runner
signs with the key it already pushes with, so the only thing it added was
portability nobody consumes), it proved attribution rather than truth (a
runner can sign a lie), and an assurance mechanism nobody verifies is a
liability, because it looks like security.

So the signature is gone, and what replaced it is the thing a trust-the-box
model actually requires: a record. post-receive now writes one line per
accepted ref update — ref, old, new, account, repo — into the same
append-only log as the privileged acts. In post-receive rather than
pre-receive because pre-receive returning 0 is a permission and this is the
event, and it can never fail a push: the update has already landed, and
reporting it as rejected would make a client re-send something that worked.

Attribution is unchanged in substance — a record must still name the account
that pushed it, which is the rule an issue's author and an approval's owner
already follow — and the gate's three checks simplify to two, since "the
commit must be signed by that account's key" and "the signature's principal
must be that account" both went with the signature. What was a signed record
is now a plain commit on the metadata branch; the run page says "reported by"
and links the commit that carries it.

Deletions, net 467 lines: CommitFilesSigned and CommitTreeAsSigned collapse
into the unsigned forms, VerifyCommitSignature and signedByRE go,
allowedSigners/rosterKeys go with them, the runner loses loadSigningKey,
loadPrivateKey, publicKeyFile, keyFromSSHCommand and checkSigningKey along
with the --key flag and the ping round-trip that validated a key before work,
and Record.Key, Attestation.Fingerprint and their usage go from the record
format and the queue.

The docs now state the model once, in one place — README "The trust model"
and roadmap "The trust model" — including the cost, which is real: a clone
can no longer show who pushed what, and that is the wrong trade for an
instance mirrored outside its operator's control. If that arrives, the answer
is per-repository mandatory signing implemented once, not a second mechanism
kept warm in the meantime. Slices 4 and 6 of Phase 4 are marked removed with
their reasoning kept, because the proposal will come back.

Measured: TestPostReceiveRecordsThePush (both ref updates logged with actor
and account, a deletion logged as "-> none", the CI spool still written) and
TestPostReceiveSurvivesAnUnwritableLog (a full disk cannot fail a push);
TestARecordCannotBeAttributedToAnotherAccount and TestARecordNeedsNoSignature
replace the three signature tests; TestARecordMustDescribeTheLeasedJob stays,
because "the record must describe the job you were given" is a boundary check
and not a crypto one.

internal/hook/records_test.go +39−124

@@ -19,11 +19,10 @@ import (
19 19 "github.com/rodent-software/vole/internal/issues"
20 20 "github.com/rodent-software/vole/internal/repo"
21 21 "github.com/rodent-software/vole/internal/runs"
22 "golang.org/x/crypto/ssh"
23 22 )
24 23
25 // recordFixture is everything a record push needs: a repository, a roster with real
26 // keys, an ACL naming one allowed runner, and a queue with a leased job to report on.
24 // recordFixture is everything a record push needs: a repository, a roster, an ACL naming
25 // one allowed runner, and a queue with a leased job to report on.
27 26 type recordFixture struct {
28 27 t *testing.T
29 28 dir string
@@ -32,8 +31,6 @@ type recordFixture struct {
32 31 runsDB string
33 32 runnerID string
34 33 otherID string
35 runKey string // private key path; the .pub sits beside it
36 otherKey string
37 34 run *runs.Run
38 35 current string // the metadata branch's tip, as the fixture has pushed it
39 36 }
@@ -56,20 +53,22 @@ func newRecordFixture(t *testing.T) *recordFixture {
56 53 for _, u := range []struct {
57 54 name string
58 55 into *string
59 key *string
60 56 }{
61 {"runner", &f.runnerID, &f.runKey},
62 {"other", &f.otherID, &f.otherKey},
57 {"runner", &f.runnerID},
58 {"other", &f.otherID},
63 59 } {
64 60 acct, err := ident.Create(u.name, "test")
65 61 if err != nil {
66 62 t.Fatal(err)
67 63 }
68 keyPath, pub := writeKey(t, filepath.Join(dir, u.name))
64 // A key is still needed for the account to be a plausible pusher; nothing signs
65 // with it, but the roster refuses an account with no key at some call sites and
66 // the fixture should look like an instance.
67 _, pub := writeKey(t, filepath.Join(dir, u.name))
69 68 if _, err := ident.AddKey(acct.ID, pub, "test"); err != nil {
70 69 t.Fatal(err)
71 70 }
72 *u.into, *u.key = acct.ID, keyPath
71 *u.into = acct.ID
73 72 }
74 73
75 74 acls, err := access.Open(usersDB)
@@ -139,29 +138,21 @@ func writeKey(t *testing.T, base string) (string, string) {
139 138
140 139 // record is a well-formed record for the fixture's leased job, with the attempt and
141 140 // signer the caller asks for.
142 func (f *recordFixture) record(attempt int, signer, keyPath string) runs.Record {
141 func (f *recordFixture) record(attempt int, signer string) runs.Record {
143 142 f.t.Helper()
144 line, err := os.ReadFile(keyPath + ".pub")
145 if err != nil {
146 f.t.Fatal(err)
147 }
148 pub, _, _, _, err := ssh.ParseAuthorizedKey(line)
149 if err != nil {
150 f.t.Fatal(err)
151 }
152 143 return runs.Record{
153 144 RunID: f.run.ID, Attempt: attempt, Repo: "demo", Ref: f.run.Ref, SHA: f.run.SHA,
154 145 Workflow: f.run.Workflow, WorkflowSHA: f.run.WorkflowSHA, Job: f.run.Job,
155 146 StepsSHA: f.run.StepsSHA, Exit: 0, Finished: time.Now().UTC(),
156 Signer: signer, Key: ssh.FingerprintSHA256(pub),
147 Signer: signer,
157 148 }
158 149 }
159 150
160 151 // push writes a record on top of the branch and returns the new tip.
161 func (f *recordFixture) push(rec runs.Record, signingKey string) string {
152 func (f *recordFixture) push(rec runs.Record) string {
162 153 f.t.Helper()
163 154 g := f.repo.Git()
164 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", signingKey, nil)
155 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", nil)
165 156 if err != nil {
166 157 f.t.Fatalf("writing the record: %v", err)
167 158 }
@@ -196,17 +187,17 @@ func TestOnlyAnAllowedRunnerMayReportAResult(t *testing.T) {
196 187 t.Parallel()
197 188 f := newRecordFixture(t)
198 189
199 // The allowed runner reports its attempt: accepted, because the record is signed by
200 // its own key and describes the job it was leased.
201 old := f.push(f.record(1, f.runnerID, f.runKey), f.runKey+".pub")
190 // The allowed runner reports its attempt: accepted, because it names the account
191 // that pushed it and describes the job it was leased.
192 old := f.push(f.record(1, f.runnerID))
202 193 if code, out := f.preReceive("runner", f.runnerID, old, f.current); code != 0 {
203 194 t.Fatalf("an allowed runner's record was refused:\n%s", out)
204 195 }
205 196
206 197 // A writer who is not on the runners list cannot report at all, even with a record
207 198 // of its own making: whose build the host believes is the allowlist's question.
208 other := f.record(2, f.otherID, f.otherKey)
209 old = f.push(other, f.otherKey+".pub")
199 other := f.record(2, f.otherID)
200 old = f.push(other)
210 201 code, out := f.preReceive("other", f.otherID, old, f.current)
211 202 if code == 0 {
212 203 t.Fatalf("a non-runner's record was accepted:\n%s", out)
@@ -216,42 +207,17 @@ func TestOnlyAnAllowedRunnerMayReportAResult(t *testing.T) {
216 207 }
217 208 }
218 209
219 func TestARecordMustBeSignedByTheAccountThatReportsIt(t *testing.T) {
210 // The record has to name the account that pushed it. That is the whole attribution rule
211 // now — the same one an issue's author and an approval's owner follow — so a record
212 // re-attributing itself to somebody else is refused at the boundary, and there is no
213 // second mechanism to disagree with it later.
214 func TestARecordCannotBeAttributedToAnotherAccount(t *testing.T) {
220 215 t.Parallel()
221 216 f := newRecordFixture(t)
222 217
223 // Signed by the other account's key while claiming to be the runner: the signature
224 // is the attestation, so a record whose signer cannot prove it is refused.
225 g := f.repo.Git()
226 rec := f.record(1, f.runnerID, f.otherKey)
227 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", f.otherKey+".pub", nil)
228 if err != nil {
229 t.Fatal(err)
230 }
231 if err := g.UpdateRef(issues.Ref, commit); err != nil {
232 t.Fatal(err)
233 }
234 code, out := f.preReceive("runner", f.runnerID, "", commit)
235 if code == 0 {
236 t.Fatalf("a record signed by another account was accepted:\n%s", out)
237 }
238 if !strings.Contains(out, "not signed by a key of") {
239 t.Fatalf("the refusal should name the signature:\n%s", out)
240 }
241
242 // A record that names somebody else as its reporter, pushed by the runner, is the
243 // same forgery from the other side.
244 f2 := newRecordFixture(t)
245 g2 := f2.repo.Git()
246 rec2 := f2.record(1, f2.otherID, f2.runKey)
247 commit2, err := runs.WriteRecord(g2, issues.Ref, rec2, "runner", f2.runKey+".pub", nil)
248 if err != nil {
249 t.Fatal(err)
250 }
251 if err := g2.UpdateRef(issues.Ref, commit2); err != nil {
252 t.Fatal(err)
253 }
254 code, out = f2.preReceive("runner", f2.runnerID, "", commit2)
218 rec := f.record(1, f.otherID)
219 old := f.push(rec)
220 code, out := f.preReceive("runner", f.runnerID, old, f.current)
255 221 if code == 0 {
256 222 t.Fatalf("a record naming another account was accepted:\n%s", out)
257 223 }
@@ -260,11 +226,13 @@ func TestARecordMustBeSignedByTheAccountThatReportsIt(t *testing.T) {
260 226 }
261 227 }
262 228
263 func TestAnUnsignedRecordIsRefused(t *testing.T) {
229 // An unsigned record is now simply a record: attribution is the boundary's, and there is
230 // no signature anywhere else in the system for it to be inconsistent with.
231 func TestARecordNeedsNoSignature(t *testing.T) {
264 232 t.Parallel()
265 233 f := newRecordFixture(t)
266 234 g := f.repo.Git()
267 files, err := f.record(1, f.runnerID, f.runKey).Files()
235 files, err := f.record(1, f.runnerID).Files()
268 236 if err != nil {
269 237 t.Fatal(err)
270 238 }
@@ -272,19 +240,15 @@ func TestAnUnsignedRecordIsRefused(t *testing.T) {
272 240 if err != nil {
273 241 t.Fatal(err)
274 242 }
275 commit, err := g.CommitTreeAs(tree, "", "runs: a result nobody signed", "runner")
243 commit, err := g.CommitTreeAs(tree, "", "runs: run "+f.run.ID+" attempt 1: success", "runner")
276 244 if err != nil {
277 245 t.Fatal(err)
278 246 }
279 247 if err := g.UpdateRef(issues.Ref, commit); err != nil {
280 248 t.Fatal(err)
281 249 }
282 code, out := f.preReceive("runner", f.runnerID, "", commit)
283 if code == 0 {
284 t.Fatalf("an unsigned record was accepted:\n%s", out)
285 }
286 if !strings.Contains(out, "is not signed") {
287 t.Fatalf("the refusal should say the commit is unsigned:\n%s", out)
250 if code, out := f.preReceive("runner", f.runnerID, "", commit); code != 0 {
251 t.Fatalf("an unsigned record was refused:\n%s", out)
288 252 }
289 253 }
290 254
@@ -292,7 +256,7 @@ func TestARecordIsAppendOnlyAtTheGate(t *testing.T) {
292 256 t.Parallel()
293 257 f := newRecordFixture(t)
294 258 g := f.repo.Git()
295 f.push(f.record(1, f.runnerID, f.runKey), f.runKey+".pub")
259 f.push(f.record(1, f.runnerID))
296 260 oldTip := f.current
297 261
298 262 // Editing the record of an attempt is a rewrite of evidence.
@@ -321,15 +285,15 @@ func TestARecordIsAppendOnlyAtTheGate(t *testing.T) {
321 285 }
322 286 }
323 287
288 // The record has to describe the job the queue handed *that* account. Without this an
289 // allowed runner could invent attempt 99 for a run that already finished and have the
290 // next rebuild believe it.
324 291 func TestARecordMustDescribeTheLeasedJob(t *testing.T) {
325 292 t.Parallel()
326 293 f := newRecordFixture(t)
327
328 // An attempt number the queue is not on: a runner cannot invent attempt 99 for a
329 // finished run and have the next rebuild believe it.
330 294 g := f.repo.Git()
331 rec := f.record(99, f.runnerID, f.runKey)
332 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", f.runKey+".pub", nil)
295 rec := f.record(99, f.runnerID)
296 commit, err := runs.WriteRecord(g, issues.Ref, rec, "runner", nil)
333 297 if err != nil {
334 298 t.Fatal(err)
335 299 }
@@ -344,52 +308,3 @@ func TestARecordMustDescribeTheLeasedJob(t *testing.T) {
344 308 t.Fatalf("the refusal should name the attempt:\n%s", out)
345 309 }
346 310 }
347
348 // A record added in a signed commit and then edited by an unsigned one inside the same
349 // push must be refused. The bytes at the tip are what a reader trusts, so the commit
350 // that *last touched* them is the one whose signature has to verify — an edit hidden
351 // behind a signed add is exactly the forgery a signature is supposed to stop.
352 func TestARecordEditedAfterASignedAddIsRefused(t *testing.T) {
353 t.Parallel()
354 f := newRecordFixture(t)
355 g := f.repo.Git()
356 rec := f.record(1, f.runnerID, f.runKey)
357 files, err := rec.Files()
358 if err != nil {
359 t.Fatal(err)
360 }
361 tree, err := gitx.WriteFlatTree(g, files)
362 if err != nil {
363 t.Fatal(err)
364 }
365 signedAdd, err := g.CommitTreeAsSigned(tree, "", "runs: run "+rec.RunID+" attempt 1: success",
366 "runner", f.runKey+".pub")
367 if err != nil {
368 t.Fatal(err)
369 }
370 // The same record, rewritten by an unsigned commit that says the job failed.
371 edited := map[string][]byte{}
372 for path, content := range files {
373 edited[path] = content
374 }
375 recordPath := runs.RecordPath(rec.RunID, 1) + "/record.md"
376 edited[recordPath] = []byte(strings.Replace(string(edited[recordPath]), "exit: 0", "exit: 1", 1))
377 editedTree, err := gitx.WriteFlatTree(g, edited)
378 if err != nil {
379 t.Fatal(err)
380 }
381 unsignedEdit, err := g.CommitTreeAs(editedTree, signedAdd, "runs: an edit", "runner")
382 if err != nil {
383 t.Fatal(err)
384 }
385 if err := g.UpdateRef(issues.Ref, unsignedEdit); err != nil {
386 t.Fatal(err)
387 }
388 code, out := f.preReceive("runner", f.runnerID, "", unsignedEdit)
389 if code == 0 {
390 t.Fatalf("an unsigned edit behind a signed add was accepted:\n%s", out)
391 }
392 if !strings.Contains(out, "is not signed") {
393 t.Fatalf("the refusal should say the commit that carries the record is unsigned:\n%s", out)
394 }
395 }