shithub: furgit

Download patch

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)
+	}
+}
--