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/gitx/gitx.go +92−9

@@ -10,6 +10,7 @@ import (
10 10 "io"
11 11 "os"
12 12 "os/exec"
13 "regexp"
13 14 "sort"
14 15 "strconv"
15 16 "strings"
@@ -17,6 +18,12 @@ import (
17 18
18 19 const ZeroSHA = "0000000000000000000000000000000000000000"
19 20
21 // signedByRE reads `git verify-commit`'s success line, which is the only place it
22 // says which principal and key answered for a signature:
23 //
24 // Good "git" signature for keenan with ED25519 key SHA256:abc…
25 var signedByRE = regexp.MustCompile(`Good ".*" signature for (\S+) with \S+ key (\S+)`)
26
20 27 // Entry is one record from `git ls-tree` or one line of `git mktree` input.
21 28 type Entry struct {
22 29 Mode string
@@ -262,7 +269,30 @@ func (r *Repo) HashObject(payload []byte) (string, error) {
262 269 // CommitTreeAs commits with a specific author, which is the audit record for a
263 270 // metadata change.
264 271 func (r *Repo) CommitTreeAs(tree, parent, msg, author string) (string, error) {
265 args := []string{"commit-tree", tree}
272 return r.commitTree(tree, parent, msg, author, "")
273 }
274
275 // CommitTreeAsSigned is CommitTreeAs with an SSH signature: git writes it into the
276 // commit object's gpgsig header, made with signingKey (an OpenSSH *public* key file;
277 // the private half has to be where ssh-keygen finds it).
278 //
279 // The commit then *is* the attestation. Its signature covers the tree — every file of
280 // the record — the parents and the message, so one signature carries a run's result
281 // and the artifacts it published, with no second claim to keep in step. `pre-receive`
282 // verifies it against the roster, so nothing else has to know what a claim was.
283 func (r *Repo) CommitTreeAsSigned(tree, parent, msg, author, signingKey string) (string, error) {
284 return r.commitTree(tree, parent, msg, author, signingKey)
285 }
286
287 func (r *Repo) commitTree(tree, parent, msg, author, signingKey string) (string, error) {
288 args := []string{}
289 if signingKey != "" {
290 // -c options must precede the subcommand.
291 args = append(args, "-c", "gpg.format=ssh", "-c", "user.signingkey="+signingKey,
292 "commit-tree", "-S", tree)
293 } else {
294 args = append(args, "commit-tree", tree)
295 }
266 296 if parent != "" {
267 297 args = append(args, "-p", parent)
268 298 }
@@ -281,15 +311,68 @@ func (r *Repo) CommitTreeAs(tree, parent, msg, author string) (string, error) {
281 311 }
282 312
283 313 func (r *Repo) CommitTree(tree, parent, msg string) (string, error) {
284 args := []string{"commit-tree", tree}
285 if parent != "" {
286 args = append(args, "-p", parent)
287 }
288 out, errs, err := r.run([]byte(msg), args...)
289 if err != nil {
290 return "", fmt.Errorf("commit-tree: %w: %s", err, errs)
314 return r.commitTree(tree, parent, msg, "", "")
315 }
316
317 // VerifyCommitSignature checks an SSH-signed commit against an allowed-signers file
318 // and returns the principal the file matched and the fingerprint of the signing key.
319 // An unsigned commit, or one signed by a key the file does not list, is an error
320 // carrying git's own explanation.
321 func (r *Repo) VerifyCommitSignature(commit, allowedSigners string) (principal, fingerprint string, err error) {
322 out, errs, runErr := r.run(nil, "-c", "gpg.format=ssh",
323 "-c", "gpg.ssh.allowedSignersFile="+allowedSigners, "verify-commit", commit)
324 text := strings.TrimSpace(string(out) + string(errs))
325 if runErr != nil {
326 if text == "" {
327 text = "not signed (git verify-commit said nothing, which is what an unsigned commit looks like)"
328 }
329 return "", "", fmt.Errorf("%s", text)
330 }
331 // Good "git" signature for <principal> with ED25519 key SHA256:<fp>
332 if m := signedByRE.FindStringSubmatch(text); m != nil {
333 return m[1], m[2], nil
334 }
335 return "", "", fmt.Errorf("%s", text)
336 }
337
338 // LastTouchedByPath reports, for every path under dir reachable from rev, the newest
339 // commit that touched it. That commit is the one whose signature covers the bytes at
340 // the tip, which is what makes it the right commit to verify.
341 //
342 // The first answer wins, not the last: `git log` walks newest first, and a path added,
343 // deleted and re-added inside one push has several commits that "added" it. Verifying
344 // the oldest of those would check a signature over bytes that are no longer there.
345 //
346 // One `git log` answers for a whole subtree, so a record's attestation costs one
347 // process for all of them rather than one per file.
348 func (r *Repo) LastTouchedByPath(rev, dir string) (map[string]string, error) {
349 out, errs, err := r.run(nil, "log", "--format=%x00%H", "--name-only", rev, "--", dir)
350 if err != nil {
351 return nil, fmt.Errorf("log %s: %w: %s", dir, err, errs)
352 }
353 touched := map[string]string{}
354 // Each record starts with a NUL and the commit id, so paths that share a commit
355 // are attributed without guessing where one commit's list ends.
356 for _, chunk := range bytes.Split(out, []byte{0})[1:] {
357 lines := strings.Split(strings.TrimSpace(string(chunk)), "\n")
358 if len(lines) == 0 {
359 continue
360 }
361 sha := strings.TrimSpace(lines[0])
362 if sha == "" {
363 continue
364 }
365 for _, line := range lines[1:] {
366 path := strings.TrimSpace(line)
367 if path == "" {
368 continue
369 }
370 if _, seen := touched[path]; !seen {
371 touched[path] = sha
372 }
373 }
291 374 }
292 return strings.TrimSpace(string(out)), nil
375 return touched, nil
293 376 }
294 377
295 378 // UpdateRefCAS moves ref from old to new. Returns false when another writer won