diff options
Diffstat (limited to 'object/signed/tag')
| -rw-r--r-- | object/signed/tag/doc.go | 3 | ||||
| -rw-r--r-- | object/signed/tag/integration_test.go | 139 | ||||
| -rw-r--r-- | object/signed/tag/parse.go | 141 | ||||
| -rw-r--r-- | object/signed/tag/payload_append.go | 11 | ||||
| -rw-r--r-- | object/signed/tag/signature_algorithms.go | 16 | ||||
| -rw-r--r-- | object/signed/tag/signature_append.go | 17 | ||||
| -rw-r--r-- | object/signed/tag/tag.go | 15 | ||||
| -rw-r--r-- | object/signed/tag/unit_test.go | 257 |
8 files changed, 599 insertions, 0 deletions
diff --git a/object/signed/tag/doc.go b/object/signed/tag/doc.go new file mode 100644 index 00000000..22b1098a --- /dev/null +++ b/object/signed/tag/doc.go @@ -0,0 +1,3 @@ +// Package signedtag extracts tag signing payloads and signatures from raw tag +// object bodies. +package signedtag diff --git a/object/signed/tag/integration_test.go b/object/signed/tag/integration_test.go new file mode 100644 index 00000000..af32aa02 --- /dev/null +++ b/object/signed/tag/integration_test.go @@ -0,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) + } + }) +} diff --git a/object/signed/tag/parse.go b/object/signed/tag/parse.go new file mode 100644 index 00000000..e2556355 --- /dev/null +++ b/object/signed/tag/parse.go @@ -0,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}) +} diff --git a/object/signed/tag/payload_append.go b/object/signed/tag/payload_append.go new file mode 100644 index 00000000..dae29dd8 --- /dev/null +++ b/object/signed/tag/payload_append.go @@ -0,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 +} diff --git a/object/signed/tag/signature_algorithms.go b/object/signed/tag/signature_algorithms.go new file mode 100644 index 00000000..bc178bce --- /dev/null +++ b/object/signed/tag/signature_algorithms.go @@ -0,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 +} diff --git a/object/signed/tag/signature_append.go b/object/signed/tag/signature_append.go new file mode 100644 index 00000000..101816eb --- /dev/null +++ b/object/signed/tag/signature_append.go @@ -0,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 +} diff --git a/object/signed/tag/tag.go b/object/signed/tag/tag.go new file mode 100644 index 00000000..2ebf9369 --- /dev/null +++ b/object/signed/tag/tag.go @@ -0,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 +} diff --git a/object/signed/tag/unit_test.go b/object/signed/tag/unit_test.go new file mode 100644 index 00000000..dd4ae66f --- /dev/null +++ b/object/signed/tag/unit_test.go @@ -0,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) + } +} |
