diff options
| author | 2026-06-07 17:31:44 +0000 | |
|---|---|---|
| committer | 2026-06-07 17:31:44 +0000 | |
| commit | 2ee31d8aa0d9cbd1211b0de053ef32322e54f323 (patch) | |
| tree | 4102f042e3bd4734d202396c29365951d6a6e095 | |
| parent | internal/testgit: Add -S to CommitTree (diff) | |
| signature | No signature | |
object/signed{,/commit}: Add
| -rw-r--r-- | object/signed/commit/commit.go | 30 | ||||
| -rw-r--r-- | object/signed/commit/doc.go | 4 | ||||
| -rw-r--r-- | object/signed/commit/parse.go | 108 | ||||
| -rw-r--r-- | object/signed/commit/parse_test.go | 170 | ||||
| -rw-r--r-- | object/signed/commit/payload.go | 11 | ||||
| -rw-r--r-- | object/signed/commit/signature.go | 18 | ||||
| -rw-r--r-- | object/signed/commit/verify_test.go | 181 | ||||
| -rw-r--r-- | object/signed/doc.go | 8 | ||||
| -rw-r--r-- | object/signed/signature_header.go | 42 |
9 files changed, 572 insertions, 0 deletions
diff --git a/object/signed/commit/commit.go b/object/signed/commit/commit.go new file mode 100644 index 00000000..f3e0a021 --- /dev/null +++ b/object/signed/commit/commit.go @@ -0,0 +1,30 @@ +package commit + +import "lindenii.org/go/furgit/object/id" + +// Commit represents the payload and signatures +// parsed from a raw commit object. +type Commit struct { + body []byte + payload []byteRange + signatures map[id.ObjectFormat][]byteRange +} + +// ObjectFormats returns the object formats +// for which the commit carries signatures. +func (commit *Commit) ObjectFormats() []id.ObjectFormat { + var objectFormats []id.ObjectFormat + + for _, objectFormat := range id.SupportedObjectFormats() { + if _, ok := commit.signatures[objectFormat]; ok { + objectFormats = append(objectFormats, objectFormat) + } + } + + return objectFormats +} + +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..44a8e986 --- /dev/null +++ b/object/signed/commit/doc.go @@ -0,0 +1,4 @@ +// Package commit extracts +// commit signing payloads and signatures +// from raw commit object bodies. +package commit diff --git a/object/signed/commit/parse.go b/object/signed/commit/parse.go new file mode 100644 index 00000000..dddd454e --- /dev/null +++ b/object/signed/commit/parse.go @@ -0,0 +1,108 @@ +package commit + +import ( + "bytes" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signed" +) + +// 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[id.ObjectFormat][]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 objectFormat, ok := signed.ParseSignatureHeaderName(string(key)); ok { + commit.signatures[objectFormat] = append(commit.signatures[objectFormat], 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 objectFormat, ok := signed.ParseSignatureHeaderName(string(key)); ok { + commit.signatures[objectFormat] = append(commit.signatures[objectFormat], 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/parse_test.go b/object/signed/commit/parse_test.go new file mode 100644 index 00000000..c0e86e47 --- /dev/null +++ b/object/signed/commit/parse_test.go @@ -0,0 +1,170 @@ +package commit_test + +import ( + "slices" + "testing" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/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") + + parsed, err := commit.Parse(body) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.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 := parsed.AppendSignature(nil, id.ObjectFormatSHA1) + 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 := parsed.AppendSignature(nil, id.ObjectFormatSHA256) + 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) + } + + gotObjectFormats := parsed.ObjectFormats() + + wantObjectFormats := []id.ObjectFormat{ + id.ObjectFormatSHA1, + id.ObjectFormatSHA256, + } + if !slices.Equal(gotObjectFormats, wantObjectFormats) { + t.Fatalf("ObjectFormats() = %v, want %v", gotObjectFormats, wantObjectFormats) + } +} + +func TestParseStripsUnknownGpgsigHeadersFromPayload(t *testing.T) { + t.Parallel() + + body := []byte("" + + "tree deadbeef\n" + + "gpgsig-future header\n" + + " continued\n" + + "\n" + + "message\n") + + parsed, err := commit.Parse(body) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "tree deadbeef\n" + + "\n" + + "message\n" + if gotPayload != wantPayload { + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) + } + + if gotObjectFormats := parsed.ObjectFormats(); len(gotObjectFormats) != 0 { + t.Fatalf("ObjectFormats() = %v, want none", gotObjectFormats) + } +} + +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") + + parsed, err := commit.Parse(body) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.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 := parsed.AppendSignature(nil, id.ObjectFormatSHA1) + 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) + } +} diff --git a/object/signed/commit/payload.go b/object/signed/commit/payload.go new file mode 100644 index 00000000..a01dd8f6 --- /dev/null +++ b/object/signed/commit/payload.go @@ -0,0 +1,11 @@ +package commit + +// 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.go b/object/signed/commit/signature.go new file mode 100644 index 00000000..4dc47be5 --- /dev/null +++ b/object/signed/commit/signature.go @@ -0,0 +1,18 @@ +package commit + +import "lindenii.org/go/furgit/object/id" + +// AppendSignature appends the unfolded signature for objectFormat to dst, +// and reports whether the commit carries a signature for objectFormat. +func (commit *Commit) AppendSignature(dst []byte, objectFormat id.ObjectFormat) ([]byte, bool) { + signature, ok := commit.signatures[objectFormat] + 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/verify_test.go b/object/signed/commit/verify_test.go new file mode 100644 index 00000000..fa7dea2f --- /dev/null +++ b/object/signed/commit/verify_test.go @@ -0,0 +1,181 @@ +package commit_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "lindenii.org/go/furgit/internal/testgit" + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signed/commit" + "lindenii.org/go/furgit/object/typ" +) + +const signerPrincipal = "signer@example.org" + +func setupSSHSignedCommit( + t *testing.T, + objectFormat id.ObjectFormat, +) (payload []byte, allowedSignersPath string, signaturePath string) { + t.Helper() + + repo, err := testgit.NewRepo(t, testgit.RepoOptions{ObjectFormat: objectFormat}) + if err != nil { + t.Fatalf("NewRepo: %v", err) + } + + 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.CommandContext( + t.Context(), + "ssh-keygen", + "-q", + "-t", "ed25519", + "-N", "", + "-C", signerPrincipal, + "-f", privateKeyPath, + ) //#nosec G204 + + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("ssh-keygen: %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(signerPrincipal+" "), publicKey...), + 0o600, + ) + if err != nil { + t.Fatalf("WriteFile(allowed_signers): %v", err) + } + + err = repo.ConfigSet(t, "gpg.format", "ssh") + if err != nil { + t.Fatalf("ConfigSet(gpg.format): %v", err) + } + + err = repo.ConfigSet(t, "user.signingkey", privateKeyPath) + if err != nil { + t.Fatalf("ConfigSet(user.signingkey): %v", err) + } + + blobID, err := repo.HashObject(t, typ.TypeBlob, strings.NewReader("signed\n")) + if err != nil { + t.Fatalf("HashObject(blob): %v", err) + } + + treeID, err := repo.MkTree(t, []testgit.TreeEntry{ + {Mode: "100644", Type: typ.TypeBlob, OID: blobID, Name: "file.txt"}, + }) + if err != nil { + t.Fatalf("MkTree: %v", err) + } + + commitID, err := repo.CommitTree(t, treeID, testgit.CommitTreeOptions{ + Message: "signed commit", + Sign: true, + }) + if err != nil { + t.Fatalf("CommitTree: %v", err) + } + + body, err := repo.CatFile(t, typ.TypeCommit, commitID) + if err != nil { + t.Fatalf("CatFile: %v", err) + } + + parsed, err := commit.Parse(body) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + signature, ok := parsed.AppendSignature(nil, objectFormat) + if !ok { + t.Fatalf("missing %s signature", objectFormat) + } + + err = signRoot.WriteFile("commit.sig", signature, 0o600) + if err != nil { + t.Fatalf("WriteFile(commit.sig): %v", err) + } + + return parsed.AppendPayload(nil), allowedSignersPath, signaturePath +} + +func sshVerify( + t *testing.T, + payload []byte, + allowedSignersPath string, + signaturePath string, +) ([]byte, error) { + t.Helper() + + cmd := exec.CommandContext( + t.Context(), + "ssh-keygen", + "-Y", "verify", + "-n", "git", + "-f", allowedSignersPath, + "-I", signerPrincipal, + "-s", signaturePath, + ) //#nosec G204 + cmd.Stdin = bytes.NewReader(payload) + + return cmd.CombinedOutput() //nolint:wrapcheck +} + +func TestSSHSignedCommitVerifies(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, objectFormat) + + out, err := sshVerify(t, payload, allowedSignersPath, signaturePath) + if err != nil { + t.Fatalf("ssh-keygen verify failed: %v\n%s", err, out) + } + }) + } +} + +func TestSSHSignedCommitRejectsTamperedPayload(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + payload, allowedSignersPath, signaturePath := setupSSHSignedCommit(t, objectFormat) + payload = append([]byte(nil), payload...) + payload[len(payload)-2] ^= 1 + + out, err := sshVerify(t, payload, allowedSignersPath, signaturePath) + if err == nil { + t.Fatalf("ssh-keygen verify unexpectedly succeeded:\n%s", out) + } + }) + } +} diff --git a/object/signed/doc.go b/object/signed/doc.go new file mode 100644 index 00000000..b6a1fe7c --- /dev/null +++ b/object/signed/doc.go @@ -0,0 +1,8 @@ +// Package signed encapsulates raw signed-object processing. +// +// Its subpackages extract verification payloads and embedded signatures +// from raw commit and tag object bodies, +// without depending on the parsed object models +// in [lindenii.org/go/furgit/object/commit] +// and [lindenii.org/go/furgit/object/tag]. +package signed diff --git a/object/signed/signature_header.go b/object/signed/signature_header.go new file mode 100644 index 00000000..c34c050c --- /dev/null +++ b/object/signed/signature_header.go @@ -0,0 +1,42 @@ +package signed + +import "lindenii.org/go/furgit/object/id" + +// signatureHeaderNames maps each object format +// to the commit and tag signature header name +// that carries its signature, +// such as "gpgsig" for SHA-1 +// and "gpgsig-sha256" for SHA-256. +// +//nolint:gochecknoglobals +var signatureHeaderNames = map[id.ObjectFormat]string{ + id.ObjectFormatSHA1: "gpgsig", + id.ObjectFormatSHA256: "gpgsig-sha256", +} + +//nolint:gochecknoglobals +var objectFormatBySignatureHeaderName = map[string]id.ObjectFormat{} + +func init() { //nolint:gochecknoinits + for objectFormat, name := range signatureHeaderNames { + objectFormatBySignatureHeaderName[name] = objectFormat + } +} + +// SignatureHeaderName returns the signature header name for objectFormat, +// such as "gpgsig" for SHA-1 +// or "gpgsig-sha256" for SHA-256. +func SignatureHeaderName(objectFormat id.ObjectFormat) (string, bool) { + name, ok := signatureHeaderNames[objectFormat] + + return name, ok +} + +// ParseSignatureHeaderName parses one canonical signature header name +// such as "gpgsig" or "gpgsig-sha256" +// into its object format. +func ParseSignatureHeaderName(name string) (id.ObjectFormat, bool) { + objectFormat, ok := objectFormatBySignatureHeaderName[name] + + return objectFormat, ok +} |
