From 642b21b80bcd43ff14355ef0287d7f57457d145e Mon Sep 17 00:00:00 2001 From: Runxi Yu Date: Sun, 29 Mar 2026 16:20:47 +0000 Subject: object/signed/commit: Add signed commit helpers --- object/signed/commit/commit.go | 15 +++ object/signed/commit/doc.go | 6 + object/signed/commit/integration_test.go | 134 +++++++++++++++++++++ object/signed/commit/parse.go | 104 +++++++++++++++++ object/signed/commit/payload_append.go | 11 ++ object/signed/commit/signature_algorithms.go | 16 +++ object/signed/commit/signature_append.go | 17 +++ object/signed/commit/unit_test.go | 166 +++++++++++++++++++++++++++ 8 files changed, 469 insertions(+) create mode 100644 object/signed/commit/commit.go create mode 100644 object/signed/commit/doc.go create mode 100644 object/signed/commit/integration_test.go create mode 100644 object/signed/commit/parse.go create mode 100644 object/signed/commit/payload_append.go create mode 100644 object/signed/commit/signature_algorithms.go create mode 100644 object/signed/commit/signature_append.go create mode 100644 object/signed/commit/unit_test.go diff --git a/object/signed/commit/commit.go b/object/signed/commit/commit.go new file mode 100644 index 00000000..cd0ff197 --- /dev/null +++ b/object/signed/commit/commit.go @@ -0,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 +} diff --git a/object/signed/commit/doc.go b/object/signed/commit/doc.go new file mode 100644 index 00000000..91da6fa8 --- /dev/null +++ b/object/signed/commit/doc.go @@ -0,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. diff --git a/object/signed/commit/integration_test.go b/object/signed/commit/integration_test.go new file mode 100644 index 00000000..2a8d6a4c --- /dev/null +++ b/object/signed/commit/integration_test.go @@ -0,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) + } + }) +} diff --git a/object/signed/commit/parse.go b/object/signed/commit/parse.go new file mode 100644 index 00000000..1714327f --- /dev/null +++ b/object/signed/commit/parse.go @@ -0,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}) +} diff --git a/object/signed/commit/payload_append.go b/object/signed/commit/payload_append.go new file mode 100644 index 00000000..c261910a --- /dev/null +++ b/object/signed/commit/payload_append.go @@ -0,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 +} diff --git a/object/signed/commit/signature_algorithms.go b/object/signed/commit/signature_algorithms.go new file mode 100644 index 00000000..ac763706 --- /dev/null +++ b/object/signed/commit/signature_algorithms.go @@ -0,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 +} diff --git a/object/signed/commit/signature_append.go b/object/signed/commit/signature_append.go new file mode 100644 index 00000000..7f9144b7 --- /dev/null +++ b/object/signed/commit/signature_append.go @@ -0,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 +} diff --git a/object/signed/commit/unit_test.go b/object/signed/commit/unit_test.go new file mode 100644 index 00000000..6bbd6cd0 --- /dev/null +++ b/object/signed/commit/unit_test.go @@ -0,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 1112912653 -0700\n" + + "committer C O Mitter 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 1112912653 -0700\n" + + "committer C O Mitter 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) + } +} -- cgit v1.3.1-10-gc9f91