ref: 934865f2de5ecdf160d1162607743865b7944369
parent: d0505e1e9ada529331e964664afe6effad8c3140
author: Runxi Yu <runxiyu@umich.edu>
date: Sun Mar 29 13:19:35 EDT 2026
object/signed/tag: Add signed tag thingy
--- /dev/null
+++ b/object/signed/tag/doc.go
@@ -1,0 +1,3 @@
+// Package signedtag extracts tag signing payloads and signatures from raw tag
+// object bodies.
+package signedtag
--- /dev/null
+++ b/object/signed/tag/integration_test.go
@@ -1,0 +1,139 @@
+package signedtag_test
+
+import (
+ "bytes"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "testing"
+
+ "codeberg.org/lindenii/furgit/internal/testgit"
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ signedtag "codeberg.org/lindenii/furgit/object/signed/tag"
+)
+
+func setupSSHSignedTag(
+ 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, "tag.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", "-m", "base commit")
+ testRepo.Run(t, "tag", "-s", "-m", "signed tag", "signed-tag")
+
+ tagID := testRepo.RevParse(t, "signed-tag^{tag}")+ body := testRepo.CatFile(t, "tag", tagID)
+
+ tag, err := signedtag.Parse(body, algo)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ signature, ok := tag.AppendSignature(nil, algo)
+ if !ok {+ t.Fatal("missing signature")+ }
+
+ err = signRoot.WriteFile("tag.sig", signature, 0o600)+ if err != nil {+ t.Fatalf("WriteFile(tag.sig): %v", err)+ }
+
+ return tag.AppendPayload(nil), allowedSignersPath, signaturePath
+}
+
+func TestSSHSignedTagIntegration(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ payload, allowedSignersPath, signaturePath := setupSSHSignedTag(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 TestSSHSignedTagIntegrationRejectsTamperedPayload(t *testing.T) {+ t.Parallel()
+
+ testgit.ForEachAlgorithm(t, func(t *testing.T, algo objectid.Algorithm) { //nolint:thelper+ payload, allowedSignersPath, signaturePath := setupSSHSignedTag(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)+ }
+ })
+}
--- /dev/null
+++ b/object/signed/tag/parse.go
@@ -1,0 +1,141 @@
+package signedtag
+
+import (
+ "bytes"
+ "slices"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+)
+
+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 storageAlgo as an in-body ASCII-armored
+// trailer, and may store additional signatures for other algorithms in
+// gpgsig* headers.
+//
+// The returned Tag remains valid only while body remains unchanged.
+//
+// Labels: Deps-Borrowed, Life-Parent.
+func Parse(body []byte, storageAlgo objectid.Algorithm) (*Tag, error) {+ tag := &Tag{+ body: body,
+ signatures: make(map[objectid.Algorithm][]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
+ }
+
+ algo, ok := objectid.ParseSignatureHeaderName(string(key))
+ if !ok {+ continue
+ }
+
+ tag.appendPayloadRange(payloadStart, lineStart)
+ tag.signatures[algo] = append(tag.signatures[algo], 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[algo] = append(tag.signatures[algo], byteRange{+ start: i + 1,
+ end: next,
+ })
+
+ i = next
+ }
+
+ payloadStart = i
+ }
+
+ tag.appendPayloadRange(payloadStart, payloadEnd)
+ if signatureStart != len(body) {+ tag.signatures[storageAlgo] = append(tag.signatures[storageAlgo], 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})+}
--- /dev/null
+++ b/object/signed/tag/payload_append.go
@@ -1,0 +1,11 @@
+package signedtag
+
+// 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
+}
--- /dev/null
+++ b/object/signed/tag/signature_algorithms.go
@@ -1,0 +1,16 @@
+package signedtag
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Algorithms returns the algorithms for which the tag carries signatures.
+func (tag *Tag) Algorithms() []objectid.Algorithm {+ var algorithms []objectid.Algorithm
+
+ for _, algo := range objectid.SupportedAlgorithms() {+ if _, ok := tag.signatures[algo]; ok {+ algorithms = append(algorithms, algo)
+ }
+ }
+
+ return algorithms
+}
--- /dev/null
+++ b/object/signed/tag/signature_append.go
@@ -1,0 +1,17 @@
+package signedtag
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// AppendSignature appends the signature for algo to dst.
+func (tag *Tag) AppendSignature(dst []byte, algo objectid.Algorithm) ([]byte, bool) {+ signature, ok := tag.signatures[algo]
+ if !ok {+ return dst, false
+ }
+
+ for _, part := range signature {+ dst = append(dst, tag.body[part.start:part.end]...)
+ }
+
+ return dst, true
+}
--- /dev/null
+++ b/object/signed/tag/tag.go
@@ -1,0 +1,15 @@
+package signedtag
+
+import objectid "codeberg.org/lindenii/furgit/object/id"
+
+// Tag represents the payload and signatures parsed from a raw tag object.
+type Tag struct {+ body []byte
+ payload []byteRange
+ signatures map[objectid.Algorithm][]byteRange
+}
+
+type byteRange struct {+ start int
+ end int
+}
--- /dev/null
+++ b/object/signed/tag/unit_test.go
@@ -1,0 +1,257 @@
+package signedtag_test
+
+import (
+ "testing"
+
+ objectid "codeberg.org/lindenii/furgit/object/id"
+ signedtag "codeberg.org/lindenii/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")
+
+ tag, err := signedtag.Parse(body, objectid.AlgorithmSHA1)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ gotPayload := string(tag.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)+ }
+
+ gotAlgorithms := tag.Algorithms()
+ if len(gotAlgorithms) != 2 || gotAlgorithms[0] != objectid.AlgorithmSHA1 || gotAlgorithms[1] != objectid.AlgorithmSHA256 {+ t.Fatalf("algorithms mismatch: got %v", gotAlgorithms)+ }
+
+ gotSignature, ok := tag.AppendSignature(nil, objectid.AlgorithmSHA1)
+ 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 := tag.AppendSignature(nil, objectid.AlgorithmSHA256)
+ 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")
+
+ tag, err := signedtag.Parse(body, objectid.AlgorithmSHA1)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ gotPayload := string(tag.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 := tag.AppendSignature(nil, objectid.AlgorithmSHA256)
+ 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 := tag.AppendSignature(nil, objectid.AlgorithmSHA1); 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")
+
+ tag, err := signedtag.Parse(body, objectid.AlgorithmSHA1)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ gotPayload := string(tag.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")
+
+ tag, err := signedtag.Parse(body, objectid.AlgorithmSHA1)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ gotPayload := string(tag.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")
+
+ tag, err := signedtag.Parse(body, objectid.AlgorithmSHA1)
+ if err != nil {+ t.Fatalf("Parse: %v", err)+ }
+
+ gotPayload := string(tag.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 := tag.AppendSignature(nil, objectid.AlgorithmSHA1)
+ 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)+ }
+}
--
⑨