aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGravatar Runxi Yu2026-06-07 17:42:46 +0000
committerGravatar Runxi Yu2026-06-07 17:42:46 +0000
commitdef3e4fef842bf6d359027dd73cf7e02b70ed823 (patch)
treea6655a85741a19b6c248098f65cf925d23b61e6f
parentinternal/testgit: Add -s to tag (diff)
signatureNo signature
object/signed/tag: Add
-rw-r--r--object/signed/tag/doc.go4
-rw-r--r--object/signed/tag/parse.go145
-rw-r--r--object/signed/tag/parse_test.go263
-rw-r--r--object/signed/tag/payload.go11
-rw-r--r--object/signed/tag/signature.go18
-rw-r--r--object/signed/tag/tag.go30
-rw-r--r--object/signed/tag/verify_test.go188
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)
+ }
+ })
+ }
+}