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