shithub: furgit

Download patch

ref: 642b21b80bcd43ff14355ef0287d7f57457d145e
parent: 256d945a109f8d6bf6a13d0e9a93545b64680ba8
author: Runxi Yu <runxiyu@umich.edu>
date: Sun Mar 29 12:20:47 EDT 2026

object/signed/commit: Add signed commit helpers

--- /dev/null
+++ b/object/signed/commit/commit.go
@@ -1,0 +1,15 @@
+package signedcommit
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Commit represents the payload and signatures parsed from a raw comit object.
+type Commit struct {
+	body       []byte
+	payload    []byteRange
+	signatures map[objectid.Algorithm][]byteRange
+}
+
+type byteRange struct {
+	start int
+	end   int
+}
--- /dev/null
+++ b/object/signed/commit/doc.go
@@ -1,0 +1,6 @@
+// Package signedcommit extracts commit signing payloads and signatures from raw
+// commit object bodies.
+package signedcommit
+
+// TODO: Consider whether we want to fully copy the bytes into here.
+// The Append functions are a bit weird ergonomically.
--- /dev/null
+++ b/object/signed/commit/integration_test.go
@@ -1,0 +1,134 @@
+package signedcommit_test
+
+import (
+	"bytes"
+	"os"
+	"os/exec"
+	"path/filepath"
+	"testing"
+
+	"codeberg.org/lindenii/furgit/internal/testgit"
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	signedcommit "codeberg.org/lindenii/furgit/object/signed/commit"
+)
+
+func setupSSHSignedCommit(
+	t *testing.T,
+	algo objectid.Algorithm,
+) (payload []byte, allowedSignersPath string, signaturePath string) {
+	t.Helper()
+
+	testRepo := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: algo})
+
+	signDir := t.TempDir()
+	signRoot, err := os.OpenRoot(signDir)
+	if err != nil {
+		t.Fatalf("os.OpenRoot(%q): %v", signDir, err)
+	}
+
+	t.Cleanup(func() { _ = signRoot.Close() })
+
+	privateKeyPath := filepath.Join(signDir, "signing_key")
+	allowedSignersPath = filepath.Join(signDir, "allowed_signers")
+	signaturePath = filepath.Join(signDir, "commit.sig")
+
+	cmd := exec.Command( //nolint:noctx
+		"ssh-keygen",
+		"-q",
+		"-t", "ed25519",
+		"-N", "",
+		"-C", "runxiyu@umich.edu",
+		"-f", privateKeyPath,
+	) //#nosec G204
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		t.Fatalf("ssh-keygen generate failed: %v\n%s", err, out)
+	}
+
+	publicKey, err := signRoot.ReadFile("signing_key.pub")
+	if err != nil {
+		t.Fatalf("ReadFile(signing_key.pub): %v", err)
+	}
+
+	err = signRoot.WriteFile(
+		"allowed_signers",
+		append([]byte("runxiyu@umich.edu "), publicKey...),
+		0o600,
+	)
+	if err != nil {
+		t.Fatalf("WriteFile(allowed_signers): %v", err)
+	}
+
+	testRepo.Run(t, "config", "gpg.format", "ssh")
+	testRepo.Run(t, "config", "user.signingkey", privateKeyPath)
+
+	testRepo.WriteFile(t, "file.txt", []byte("signed\n"), 0o644)
+	testRepo.Run(t, "add", "file.txt")
+	testRepo.Run(t, "commit", "-S", "-m", "signed commit")
+
+	commitID := testRepo.RevParse(t, "HEAD^{commit}")
+	body := testRepo.CatFile(t, "commit", commitID)
+
+	commit, err := signedcommit.Parse(body)
+	if err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+
+	signature, ok := commit.AppendSignature(nil, algo)
+	if !ok {
+		t.Fatalf("missing %s signature", algo)
+	}
+
+	err = signRoot.WriteFile("commit.sig", signature, 0o600)
+	if err != nil {
+		t.Fatalf("WriteFile(commit.sig): %v", err)
+	}
+
+	return commit.AppendPayload(nil), allowedSignersPath, signaturePath
+}
+
+func TestSSHSignedCommitIntegration(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, algo)
+
+		cmd := exec.Command( //nolint:noctx
+			"ssh-keygen",
+			"-Y", "verify",
+			"-n", "git",
+			"-f", allowedSignersPath,
+			"-I", "runxiyu@umich.edu",
+			"-s", signaturePath,
+		) //#nosec G204
+		cmd.Stdin = bytes.NewReader(payload)
+		out, err := cmd.CombinedOutput()
+		if err != nil {
+			t.Fatalf("ssh-keygen verify failed: %v\n%s", err, out)
+		}
+	})
+}
+
+func TestSSHSignedCommitIntegrationRejectsTamperedPayload(t *testing.T) {
+	t.Parallel()
+
+	testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper
+		payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, algo)
+		payload = append([]byte(nil), payload...)
+		payload[len(payload)-2] ^= 1
+
+		cmd := exec.Command( //nolint:noctx
+			"ssh-keygen",
+			"-Y", "verify",
+			"-n", "git",
+			"-f", allowedSignersPath,
+			"-I", "runxiyu@umich.edu",
+			"-s", signaturePath,
+		) //#nosec G204
+		cmd.Stdin = bytes.NewReader(payload)
+		out, err := cmd.CombinedOutput()
+		if err == nil {
+			t.Fatalf("ssh-keygen verify unexpectedly succeeded:\n%s", out)
+		}
+	})
+}
--- /dev/null
+++ b/object/signed/commit/parse.go
@@ -1,0 +1,104 @@
+package signedcommit
+
+import (
+	"bytes"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+// Parse parses one raw commit object body for signature extraction.
+//
+// The returned Commit remains valid only while body remains unchanged.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func Parse(body []byte) (*Commit, error) {
+	commit := &Commit{
+		body:       body,
+		signatures: make(map[objectid.Algorithm][]byteRange),
+	}
+
+	payloadStart := 0
+	i := 0
+
+	for i < len(body) {
+		lineStart := i
+
+		rel := bytes.IndexByte(body[i:], '\n')
+		next := len(body)
+		lineEnd := len(body)
+		if rel >= 0 {
+			lineEnd = i + rel
+			next = lineEnd + 1
+		}
+
+		line := body[lineStart:lineEnd]
+		i = next
+
+		if len(line) == 0 {
+			commit.appendPayloadRange(payloadStart, len(body))
+
+			return commit, nil
+		}
+
+		if line[0] == ' ' {
+			continue
+		}
+
+		if !bytes.HasPrefix(line, []byte("gpgsig")) {
+			continue
+		}
+
+		commit.appendPayloadRange(payloadStart, lineStart)
+
+		key, valueStart, found := bytes.Cut(line, []byte{' '})
+		if found {
+			if algo, ok := objectid.ParseSignatureHeaderName(string(key)); ok {
+				commit.signatures[algo] = append(commit.signatures[algo], byteRange{
+					start: lineEnd - len(valueStart),
+					end:   next,
+				})
+			}
+		}
+
+		for i < len(body) {
+			rel := bytes.IndexByte(body[i:], '\n')
+			next = len(body)
+			lineEnd = len(body)
+			if rel >= 0 {
+				lineEnd = i + rel
+				next = lineEnd + 1
+			}
+
+			contStart := i
+			cont := body[contStart:lineEnd]
+			if len(cont) == 0 || cont[0] != ' ' {
+				break
+			}
+
+			if found {
+				if algo, ok := objectid.ParseSignatureHeaderName(string(key)); ok {
+					commit.signatures[algo] = append(commit.signatures[algo], byteRange{
+						start: contStart + 1,
+						end:   next,
+					})
+				}
+			}
+
+			i = next
+		}
+
+		payloadStart = i
+	}
+
+	commit.appendPayloadRange(payloadStart, len(body))
+
+	return commit, nil
+}
+
+func (commit *Commit) appendPayloadRange(start, end int) {
+	if start >= end {
+		return
+	}
+
+	commit.payload = append(commit.payload, byteRange{start: start, end: end})
+}
--- /dev/null
+++ b/object/signed/commit/payload_append.go
@@ -1,0 +1,11 @@
+package signedcommit
+
+// AppendPayload appends the commit verification payload to dst, omitting all
+// embedded signature headers.
+func (commit *Commit) AppendPayload(dst []byte) []byte {
+	for _, part := range commit.payload {
+		dst = append(dst, commit.body[part.start:part.end]...)
+	}
+
+	return dst
+}
--- /dev/null
+++ b/object/signed/commit/signature_algorithms.go
@@ -1,0 +1,16 @@
+package signedcommit
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Algorithms returns the algorithms for which the commit carries signatures.
+func (commit *Commit) Algorithms() []objectid.Algorithm {
+	var algorithms []objectid.Algorithm
+
+	for _, algo := range objectid.SupportedAlgorithms() {
+		if _, ok := commit.signatures[algo]; ok {
+			algorithms = append(algorithms, algo)
+		}
+	}
+
+	return algorithms
+}
--- /dev/null
+++ b/object/signed/commit/signature_append.go
@@ -1,0 +1,17 @@
+package signedcommit
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// AppendSignature appends the unfolded signature for algo to dst.
+func (commit *Commit) AppendSignature(dst []byte, algo objectid.Algorithm) ([]byte, bool) {
+	signature, ok := commit.signatures[algo]
+	if !ok {
+		return dst, false
+	}
+
+	for _, part := range signature {
+		dst = append(dst, commit.body[part.start:part.end]...)
+	}
+
+	return dst, true
+}
--- /dev/null
+++ b/object/signed/commit/unit_test.go
@@ -1,0 +1,166 @@
+package signedcommit_test
+
+import (
+	"slices"
+	"testing"
+
+	objectid "codeberg.org/lindenii/furgit/object/id"
+	signedcommit "codeberg.org/lindenii/furgit/object/signed/commit"
+)
+
+func TestParseUpstreamMultiplySignedCommit(t *testing.T) {
+	t.Parallel()
+
+	// t/t7510-signed-commit.sh
+	body := []byte("" +
+		"tree 0cfbf08886fca9a91cb753ec8734c84fcbe52c9f\n" +
+		"parent 9da738312d24ef0a29be2c8c2b6fc5cf7085a293\n" +
+		"author A U Thor <author@example.com> 1112912653 -0700\n" +
+		"committer C O Mitter <committer@example.com> 1112912653 -0700\n" +
+		"gpgsig -----BEGIN PGP SIGNATURE-----\n" +
+		" \n" +
+		" iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBDRYcY29tbWl0dGVy\n" +
+		" QGV4YW1wbGUuY29tAAoJEBO29R7N3kMNd+8AoK1I8mhLHviPH+q2I5fIVgPsEtYC\n" +
+		" AKCTqBh+VabJceXcGIZuF0Ry+udbBQ==\n" +
+		" =tQ0N\n" +
+		" -----END PGP SIGNATURE-----\n" +
+		"gpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n" +
+		" \n" +
+		" iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBIBYcY29tbWl0dGVy\n" +
+		" QGV4YW1wbGUuY29tAAoJEBO29R7N3kMN/NEAn0XO9RYSBj2dFyozi0JKSbssYMtO\n" +
+		" AJwKCQ1BQOtuwz//IjU8TiS+6S4iUw==\n" +
+		" =pIwP\n" +
+		" -----END PGP SIGNATURE-----\n" +
+		"\n" +
+		"second\n")
+
+	commit, err := signedcommit.Parse(body)
+	if err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+
+	gotPayload := string(commit.AppendPayload(nil))
+	wantPayload := "" +
+		"tree 0cfbf08886fca9a91cb753ec8734c84fcbe52c9f\n" +
+		"parent 9da738312d24ef0a29be2c8c2b6fc5cf7085a293\n" +
+		"author A U Thor <author@example.com> 1112912653 -0700\n" +
+		"committer C O Mitter <committer@example.com> 1112912653 -0700\n" +
+		"\n" +
+		"second\n"
+	if gotPayload != wantPayload {
+		t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload)
+	}
+
+	gotSHA1, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA1)
+	if !ok {
+		t.Fatal("missing sha1 signature")
+	}
+
+	wantSHA1 := "" +
+		"-----BEGIN PGP SIGNATURE-----\n" +
+		"\n" +
+		"iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBDRYcY29tbWl0dGVy\n" +
+		"QGV4YW1wbGUuY29tAAoJEBO29R7N3kMNd+8AoK1I8mhLHviPH+q2I5fIVgPsEtYC\n" +
+		"AKCTqBh+VabJceXcGIZuF0Ry+udbBQ==\n" +
+		"=tQ0N\n" +
+		"-----END PGP SIGNATURE-----\n"
+	if string(gotSHA1) != wantSHA1 {
+		t.Fatalf("sha1 signature mismatch:\n got: %q\nwant: %q", string(gotSHA1), wantSHA1)
+	}
+
+	gotSHA256, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA256)
+	if !ok {
+		t.Fatal("missing sha256 signature")
+	}
+
+	wantSHA256 := "" +
+		"-----BEGIN PGP SIGNATURE-----\n" +
+		"\n" +
+		"iHQEABECADQWIQRz11h0S+chaY7FTocTtvUezd5DDQUCX/uBIBYcY29tbWl0dGVy\n" +
+		"QGV4YW1wbGUuY29tAAoJEBO29R7N3kMN/NEAn0XO9RYSBj2dFyozi0JKSbssYMtO\n" +
+		"AJwKCQ1BQOtuwz//IjU8TiS+6S4iUw==\n" +
+		"=pIwP\n" +
+		"-----END PGP SIGNATURE-----\n"
+	if string(gotSHA256) != wantSHA256 {
+		t.Fatalf("sha256 signature mismatch:\n got: %q\nwant: %q", string(gotSHA256), wantSHA256)
+	}
+
+	gotAlgorithms := commit.Algorithms()
+	wantAlgorithms := []objectid.Algorithm{
+		objectid.AlgorithmSHA1,
+		objectid.AlgorithmSHA256,
+	}
+	if !slices.Equal(gotAlgorithms, wantAlgorithms) {
+		t.Fatalf("Algorithms() = %v, want %v", gotAlgorithms, wantAlgorithms)
+	}
+}
+
+func TestParseStripsUnknownGpgsigHeadersFromPayload(t *testing.T) {
+	t.Parallel()
+
+	body := []byte("" +
+		"tree deadbeef\n" +
+		"gpgsig-future header\n" +
+		" continued\n" +
+		"\n" +
+		"message\n")
+
+	commit, err := signedcommit.Parse(body)
+	if err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+
+	gotPayload := string(commit.AppendPayload(nil))
+	wantPayload := "" +
+		"tree deadbeef\n" +
+		"\n" +
+		"message\n"
+	if gotPayload != wantPayload {
+		t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload)
+	}
+
+	if gotAlgorithms := commit.Algorithms(); len(gotAlgorithms) != 0 {
+		t.Fatalf("Algorithms() = %v, want none", gotAlgorithms)
+	}
+}
+
+func TestParseAllowsDuplicateSignatureHeaders(t *testing.T) {
+	t.Parallel()
+
+	body := []byte("" +
+		"tree deadbeef\n" +
+		"gpgsig one\n" +
+		" two\n" +
+		"gpgsig three\n" +
+		" four\n" +
+		"\n" +
+		"message\n")
+
+	commit, err := signedcommit.Parse(body)
+	if err != nil {
+		t.Fatalf("Parse: %v", err)
+	}
+
+	gotPayload := string(commit.AppendPayload(nil))
+	wantPayload := "" +
+		"tree deadbeef\n" +
+		"\n" +
+		"message\n"
+	if gotPayload != wantPayload {
+		t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload)
+	}
+
+	gotSignature, ok := commit.AppendSignature(nil, objectid.AlgorithmSHA1)
+	if !ok {
+		t.Fatal("missing sha1 signature")
+	}
+
+	wantSignature := "" +
+		"one\n" +
+		"two\n" +
+		"three\n" +
+		"four\n"
+	if string(gotSignature) != wantSignature {
+		t.Fatalf("signature mismatch:\n got: %q\nwant: %q", string(gotSignature), wantSignature)
+	}
+}
--