aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--object/signed/commit/commit.go15
-rw-r--r--object/signed/commit/doc.go6
-rw-r--r--object/signed/commit/integration_test.go134
-rw-r--r--object/signed/commit/parse.go104
-rw-r--r--object/signed/commit/payload_append.go11
-rw-r--r--object/signed/commit/signature_algorithms.go16
-rw-r--r--object/signed/commit/signature_append.go17
-rw-r--r--object/signed/commit/unit_test.go166
8 files changed, 469 insertions, 0 deletions
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 <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)
+ }
+}