diff options
| author | 2026-06-07 17:42:46 +0000 | |
|---|---|---|
| committer | 2026-06-07 17:42:46 +0000 | |
| commit | def3e4fef842bf6d359027dd73cf7e02b70ed823 (patch) | |
| tree | a6655a85741a19b6c248098f65cf925d23b61e6f | |
| parent | internal/testgit: Add -s to tag (diff) | |
| signature | No signature | |
| -rw-r--r-- | object/signed/tag/doc.go | 4 | ||||
| -rw-r--r-- | object/signed/tag/parse.go | 145 | ||||
| -rw-r--r-- | object/signed/tag/parse_test.go | 263 | ||||
| -rw-r--r-- | object/signed/tag/payload.go | 11 | ||||
| -rw-r--r-- | object/signed/tag/signature.go | 18 | ||||
| -rw-r--r-- | object/signed/tag/tag.go | 30 | ||||
| -rw-r--r-- | object/signed/tag/verify_test.go | 188 |
7 files changed, 659 insertions, 0 deletions
diff --git a/object/signed/tag/doc.go b/object/signed/tag/doc.go new file mode 100644 index 00000000..f9beb39c --- /dev/null +++ b/object/signed/tag/doc.go @@ -0,0 +1,4 @@ +// Package tag extracts +// tag signing payloads and signatures +// from raw tag object bodies. +package tag diff --git a/object/signed/tag/parse.go b/object/signed/tag/parse.go new file mode 100644 index 00000000..f82e0fe4 --- /dev/null +++ b/object/signed/tag/parse.go @@ -0,0 +1,145 @@ +package tag + +import ( + "bytes" + "slices" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signed" +) + +var signatureBeginLines = [][]byte{ //nolint:gochecknoglobals + []byte("-----BEGIN PGP SIGNATURE-----"), + []byte("-----BEGIN PGP MESSAGE-----"), + []byte("-----BEGIN SSH SIGNATURE-----"), + []byte("-----BEGIN SIGNED MESSAGE-----"), +} + +// Parse parses one raw tag object body for signature extraction. +// +// Git stores the signature for storageFormat +// as an in-body ASCII-armored trailer, +// and may store additional signatures for other object formats +// in gpgsig* headers. +// +// The returned Tag remains valid only while body remains unchanged. +// +// Labels: Deps-Borrowed, Life-Parent. +func Parse(body []byte, storageFormat id.ObjectFormat) (*Tag, error) { + tag := &Tag{ + body: body, + signatures: make(map[id.ObjectFormat][]byteRange), + } + + signatureStart := len(body) + for i := 0; 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] + if slices.ContainsFunc(signatureBeginLines, func(begin []byte) bool { + return bytes.HasPrefix(line, begin) + }) { + signatureStart = lineStart + } + + i = next + } + + payloadStart := 0 + + payloadEnd := signatureStart + if signatureStart == len(body) { + payloadEnd = len(body) + } + + for i := 0; i < payloadEnd; { + lineStart := i + rel := bytes.IndexByte(body[i:payloadEnd], '\n') + next := payloadEnd + + lineEnd := payloadEnd + if rel >= 0 { + lineEnd = i + rel + next = lineEnd + 1 + } + + line := body[lineStart:lineEnd] + i = next + + if len(line) == 0 { + break + } + + if line[0] == ' ' { + continue + } + + key, valueStart, found := bytes.Cut(line, []byte{' '}) + if !found { + continue + } + + objectFormat, ok := signed.ParseSignatureHeaderName(string(key)) + if !ok { + continue + } + + tag.appendPayloadRange(payloadStart, lineStart) + tag.signatures[objectFormat] = append(tag.signatures[objectFormat], byteRange{ + start: lineEnd - len(valueStart), + end: next, + }) + + for i < payloadEnd { + rel := bytes.IndexByte(body[i:payloadEnd], '\n') + next = payloadEnd + + lineEnd = payloadEnd + if rel >= 0 { + lineEnd = i + rel + next = lineEnd + 1 + } + + cont := body[i:lineEnd] + if len(cont) == 0 || cont[0] != ' ' { + break + } + + tag.signatures[objectFormat] = append(tag.signatures[objectFormat], byteRange{ + start: i + 1, + end: next, + }) + + i = next + } + + payloadStart = i + } + + tag.appendPayloadRange(payloadStart, payloadEnd) + + if signatureStart != len(body) { + tag.signatures[storageFormat] = append(tag.signatures[storageFormat], byteRange{ + start: signatureStart, + end: len(body), + }) + } + + return tag, nil +} + +func (tag *Tag) appendPayloadRange(start, end int) { + if start >= end { + return + } + + tag.payload = append(tag.payload, byteRange{start: start, end: end}) +} diff --git a/object/signed/tag/parse_test.go b/object/signed/tag/parse_test.go new file mode 100644 index 00000000..0f77f135 --- /dev/null +++ b/object/signed/tag/parse_test.go @@ -0,0 +1,263 @@ +package tag_test + +import ( + "slices" + "testing" + + "lindenii.org/go/furgit/object/id" + "lindenii.org/go/furgit/object/signed/tag" +) + +func TestParseSignedTag(t *testing.T) { + t.Parallel() + + body := []byte("" + + "object 04b871796dc0420f8e7561a895b52484b701d51a\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger C O Mitter <committer@example.com> 1465981006 +0000\n" + + "gpgsig-sha256 -----BEGIN PGP SIGNATURE-----\n" + + " Version: GnuPG v1\n" + + " \n" + + " header-signature\n" + + " -----END PGP SIGNATURE-----\n" + + "\n" + + "signed tag\n" + + "\n" + + "signed tag message body\n" + + "-----BEGIN PGP SIGNATURE-----\n" + + "Version: GnuPG v1\n" + + "\n" + + "body-signature\n" + + "-----END PGP SIGNATURE-----\n") + + parsed, err := tag.Parse(body, id.ObjectFormatSHA1) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "object 04b871796dc0420f8e7561a895b52484b701d51a\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger C O Mitter <committer@example.com> 1465981006 +0000\n" + + "\n" + + "signed tag\n" + + "\n" + + "signed tag message body\n" + if gotPayload != wantPayload { + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) + } + + gotObjectFormats := parsed.ObjectFormats() + + wantObjectFormats := []id.ObjectFormat{ + id.ObjectFormatSHA1, + id.ObjectFormatSHA256, + } + if !slices.Equal(gotObjectFormats, wantObjectFormats) { + t.Fatalf("ObjectFormats() = %v, want %v", gotObjectFormats, wantObjectFormats) + } + + gotSignature, ok := parsed.AppendSignature(nil, id.ObjectFormatSHA1) + if !ok { + t.Fatal("missing sha1 signature") + } + + wantSignature := "" + + "-----BEGIN PGP SIGNATURE-----\n" + + "Version: GnuPG v1\n" + + "\n" + + "body-signature\n" + + "-----END PGP SIGNATURE-----\n" + if string(gotSignature) != wantSignature { + t.Fatalf("signature mismatch:\n got: %q\nwant: %q", string(gotSignature), wantSignature) + } + + gotHeaderSignature, ok := parsed.AppendSignature(nil, id.ObjectFormatSHA256) + if !ok { + t.Fatal("missing sha256 signature") + } + + wantHeaderSignature := "" + + "-----BEGIN PGP SIGNATURE-----\n" + + "Version: GnuPG v1\n" + + "\n" + + "header-signature\n" + + "-----END PGP SIGNATURE-----\n" + if string(gotHeaderSignature) != wantHeaderSignature { + t.Fatalf("header signature mismatch:\n got: %q\nwant: %q", string(gotHeaderSignature), wantHeaderSignature) + } +} + +func TestParseHeaderOnlyTagStripsHeaderAndKeepsHeaderSignature(t *testing.T) { + t.Parallel() + + body := []byte("" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "gpgsig-sha256 header\n" + + " continued\n" + + "\n" + + "message\n") + + parsed, err := tag.Parse(body, id.ObjectFormatSHA1) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "\n" + + "message\n" + if gotPayload != wantPayload { + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) + } + + gotSignature, ok := parsed.AppendSignature(nil, id.ObjectFormatSHA256) + if !ok { + t.Fatal("missing sha256 signature") + } + + wantSignature := "" + + "header\n" + + "continued\n" + if string(gotSignature) != wantSignature { + t.Fatalf("signature mismatch:\n got: %q\nwant: %q", string(gotSignature), wantSignature) + } + + if _, ok := parsed.AppendSignature(nil, id.ObjectFormatSHA1); ok { + t.Fatal("unexpected sha1 signature") + } +} + +func TestParseKeepsUnknownHeaderSignatureTextInPayload(t *testing.T) { + t.Parallel() + + body := []byte("" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "gpgsig-future header\n" + + " continued\n" + + "\n" + + "message line\n" + + "-----BEGIN PGP SIGNATURE-----\n" + + "body-signature\n" + + "-----END PGP SIGNATURE-----\n") + + parsed, err := tag.Parse(body, id.ObjectFormatSHA1) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "gpgsig-future header\n" + + " continued\n" + + "\n" + + "message line\n" + if gotPayload != wantPayload { + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) + } +} + +func TestParseKeepsMessageGpgsigTextInPayload(t *testing.T) { + t.Parallel() + + body := []byte("" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "\n" + + "message line\n" + + "gpgsig-future header\n" + + " continued\n" + + "-----BEGIN PGP SIGNATURE-----\n" + + "body-signature\n" + + "-----END PGP SIGNATURE-----\n") + + parsed, err := tag.Parse(body, id.ObjectFormatSHA1) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "\n" + + "message line\n" + + "gpgsig-future header\n" + + " continued\n" + if gotPayload != wantPayload { + t.Fatalf("payload mismatch:\n got: %q\nwant: %q", gotPayload, wantPayload) + } +} + +func TestParseUsesLastSignatureBeginByPrefix(t *testing.T) { + t.Parallel() + + body := []byte("" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "\n" + + "message\n" + + "-----BEGIN PGP SIGNATURE----- stray\n" + + "still message\n" + + "-----BEGIN PGP SIGNATURE----- trailing\n" + + "body-signature\n") + + parsed, err := tag.Parse(body, id.ObjectFormatSHA1) + if err != nil { + t.Fatalf("Parse: %v", err) + } + + gotPayload := string(parsed.AppendPayload(nil)) + + wantPayload := "" + + "object deadbeef\n" + + "type commit\n" + + "tag signedtag\n" + + "tagger T A Gger <tagger@example.com> 1465981006 +0000\n" + + "\n" + + "message\n" + + "-----BEGIN PGP SIGNATURE----- stray\n" + + "still 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 signature") + } + + wantSignature := "" + + "-----BEGIN PGP SIGNATURE----- trailing\n" + + "body-signature\n" + if string(gotSignature) != wantSignature { + t.Fatalf("signature mismatch:\n got: %q\nwant: %q", string(gotSignature), wantSignature) + } +} diff --git a/object/signed/tag/payload.go b/object/signed/tag/payload.go new file mode 100644 index 00000000..3516a96e --- /dev/null +++ b/object/signed/tag/payload.go @@ -0,0 +1,11 @@ +package tag + +// AppendPayload appends the tag verification payload to dst, +// omitting all embedded signatures. +func (tag *Tag) AppendPayload(dst []byte) []byte { + for _, part := range tag.payload { + dst = append(dst, tag.body[part.start:part.end]...) + } + + return dst +} diff --git a/object/signed/tag/signature.go b/object/signed/tag/signature.go new file mode 100644 index 00000000..9622b76e --- /dev/null +++ b/object/signed/tag/signature.go @@ -0,0 +1,18 @@ +package tag + +import "lindenii.org/go/furgit/object/id" + +// AppendSignature appends the signature for objectFormat to dst, +// and reports whether the tag carries a signature for objectFormat. +func (tag *Tag) AppendSignature(dst []byte, objectFormat id.ObjectFormat) ([]byte, bool) { + signature, ok := tag.signatures[objectFormat] + if !ok { + return dst, false + } + + for _, part := range signature { + dst = append(dst, tag.body[part.start:part.end]...) + } + + return dst, true +} diff --git a/object/signed/tag/tag.go b/object/signed/tag/tag.go new file mode 100644 index 00000000..f68b6dc5 --- /dev/null +++ b/object/signed/tag/tag.go @@ -0,0 +1,30 @@ +package tag + +import "lindenii.org/go/furgit/object/id" + +// Tag represents the payload and signatures +// parsed from a raw tag object. +type Tag struct { + body []byte + payload []byteRange + signatures map[id.ObjectFormat][]byteRange +} + +// ObjectFormats returns the object formats +// for which the tag carries signatures. +func (tag *Tag) ObjectFormats() []id.ObjectFormat { + var objectFormats []id.ObjectFormat + + for _, objectFormat := range id.SupportedObjectFormats() { + if _, ok := tag.signatures[objectFormat]; ok { + objectFormats = append(objectFormats, objectFormat) + } + } + + return objectFormats +} + +type byteRange struct { + start int + end int +} diff --git a/object/signed/tag/verify_test.go b/object/signed/tag/verify_test.go new file mode 100644 index 00000000..aea21b40 --- /dev/null +++ b/object/signed/tag/verify_test.go @@ -0,0 +1,188 @@ +package tag_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/tag" + "lindenii.org/go/furgit/object/typ" +) + +const signerPrincipal = "signer@example.org" + +func setupSSHSignedTag( + 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, "tag.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: "base commit", + }) + if err != nil { + t.Fatalf("CommitTree: %v", err) + } + + tagID, err := repo.TagAnnotated(t, "signed-tag", commitID, testgit.TagAnnotatedOptions{ + Message: "signed tag", + Sign: true, + }) + if err != nil { + t.Fatalf("TagAnnotated: %v", err) + } + + body, err := repo.CatFile(t, typ.TypeTag, tagID) + if err != nil { + t.Fatalf("CatFile: %v", err) + } + + parsed, err := tag.Parse(body, objectFormat) + 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("tag.sig", signature, 0o600) + if err != nil { + t.Fatalf("WriteFile(tag.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 TestSSHSignedTagVerifies(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + payload, allowedSignersPath, signaturePath := setupSSHSignedTag(t, objectFormat) + + out, err := sshVerify(t, payload, allowedSignersPath, signaturePath) + if err != nil { + t.Fatalf("ssh-keygen verify failed: %v\n%s", err, out) + } + }) + } +} + +func TestSSHSignedTagRejectsTamperedPayload(t *testing.T) { + t.Parallel() + + for _, objectFormat := range id.SupportedObjectFormats() { + t.Run(objectFormat.String(), func(t *testing.T) { + t.Parallel() + + payload, allowedSignersPath, signaturePath := setupSSHSignedTag(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) + } + }) + } +} |
