diff --git a/.golangci.yaml b/.golangci.yaml index 92f6c9c..f533108 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -4,6 +4,12 @@ linters: disable: - depguard - wsl + exclusions: + # Integration-test harnesses shell out to git/gpg and rely on patterns the + # production-oriented linters flag (subprocess exec, package-level test + # state, long table-driven tests). Exempt them from linting. + paths: + - conformance_.*_test\.go settings: gomoddirectives: replace-local: true diff --git a/plugin/objectverifier/gpg/conformance_commit_test.go b/plugin/objectverifier/gpg/conformance_commit_test.go new file mode 100644 index 0000000..e5fa163 --- /dev/null +++ b/plugin/objectverifier/gpg/conformance_commit_test.go @@ -0,0 +1,191 @@ +//go:build linux + +package gpg_test + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" +) + +func TestCommitVerifyAlignment(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping commit verify") + } + + if gnupg == nil { + t.Error("gpg not available") + t.FailNow() + } + + cases := []struct { + build func() []byte + postSign func(signed []byte, sig string) []byte + name string + }{ + { + name: "valid", + build: func() []byte { + h, m := canonicalCommit() + + return assembleCommit(h, m) + }, + }, + { + name: "duplicate-tree", + build: func() []byte { + h, m := canonicalCommit() + dup := append([]string{ + h[0], + "tree 5555555555555555555555555555555555555555", + }, h[1:]...) + + return assembleCommit(dup, m) + }, + }, + { + name: "duplicate-author", + build: func() []byte { + h, m := canonicalCommit() + dup := []string{ + h[0], + h[1], + h[2], + "author Override Author 1700000001 +0000", + h[3], + } + + return assembleCommit(dup, m) + }, + }, + { + name: "duplicate-committer", + build: func() []byte { + h, m := canonicalCommit() + dup := []string{ + h[0], h[1], h[2], h[3], + "committer Override Committer 1700000001 +0000", + } + + return assembleCommit(dup, m) + }, + }, + { + name: "misplaced-parent-after-committer", + build: func() []byte { + h, m := canonicalCommit() + dup := []string{ + h[0], h[1], h[2], h[3], + "parent 2222222222222222222222222222222222222222", + } + + return assembleCommit(dup, m) + }, + }, + { + // Inject the same signature again as a second gpgsig + // header. Both occurrences sit before the blank line; both + // signers strip them when computing the payload. + name: "double-signature", + build: func() []byte { + h, m := canonicalCommit() + + return assembleCommit(h, m) + }, + postSign: injectGpgSig, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.name == "double-signature" && os.Getenv("GIT_VERSION") == "v2.11.0" { + t.Skip("multi-signature rejection not yet established in Git 2.11.0") + } + + repo := initRepo(t) + unsigned := tc.build() + sig := gpgSign(t, unsigned) + + signed := injectGpgSig(unsigned, sig) + if tc.postSign != nil { + signed = tc.postSign(signed, sig) + } + + hash := writeLooseObject(t, repo, "commit", signed) + gitErr := gitVerifyCommit(t, repo, hash) + + r, err := git.PlainOpen(repo) + require.NoError(t, err) + + defer func() { _ = r.Close() }() + + commit, err := r.CommitObject(hash) + + ggErr := err + if ggErr == nil { + _, ggErr = commit.Verify(context.Background(), object.WithVerifier(newVerifier(t))) + } + + assertSameVerdict(t, "git verify-commit", gitErr, "go-git Verify", ggErr) + }) + } +} + +// canonicalCommit returns the byte-exact unsigned commit body produced by +// upstream `git commit-tree` for a single-parent commit. Used as the +// baseline; individual scenarios mutate the header lines around it. +func canonicalCommit() (headers []string, message string) { + return []string{ + "tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904", + "parent 1111111111111111111111111111111111111111", + "author Test Author 1700000000 +0000", + "committer Test Committer 1700000000 +0000", + }, + "signed commit message\n" +} + +func assembleCommit(headers []string, message string) []byte { + var buf bytes.Buffer + for _, h := range headers { + buf.WriteString(h) + buf.WriteByte('\n') + } + + buf.WriteByte('\n') + buf.WriteString(message) + + return buf.Bytes() +} + +// assertSameVerdict fails the test unless both verifiers agree on +// success-or-failure. It does not compare error messages, only verdicts. +func assertSameVerdict(t *testing.T, leftLabel string, leftErr error, rightLabel string, rightErr error) { + t.Helper() + + leftOK := leftErr == nil + + rightOK := rightErr == nil + if leftOK == rightOK { + if !leftOK { + t.Logf("both verifiers rejected (expected for malformed input)\n %s: %v\n %s: %v", + leftLabel, leftErr, rightLabel, rightErr) + } + + return + } + + assert.Failf(t, "verifier verdicts diverge", + "%s succeeded=%v err=%v\n%s succeeded=%v err=%v", + leftLabel, leftOK, leftErr, rightLabel, rightOK, rightErr) +} diff --git a/plugin/objectverifier/gpg/conformance_main_test.go b/plugin/objectverifier/gpg/conformance_main_test.go new file mode 100644 index 0000000..3afaab6 --- /dev/null +++ b/plugin/objectverifier/gpg/conformance_main_test.go @@ -0,0 +1,449 @@ +//go:build linux + +package gpg_test + +import ( + "bytes" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v6/plumbing" + "github.com/go-git/go-git/v6/plumbing/object" + "github.com/go-git/go-git/v6/x/plugin" + + "github.com/go-git/x/plugin/objectverifier/gpg" +) + +// gpgEnv is the isolated GnuPG home created once per package run. The key +// is generated on demand at TestMain start and torn down at exit. +type gpgEnv struct { + home string + keyID string + pubKey string +} + +var gnupg *gpgEnv + +func TestMain(m *testing.M) { + os.Exit(runMain(m)) +} + +// newVerifier builds a gpg verifier trusting the test key. +func newVerifier(t *testing.T) plugin.Verifier { + t.Helper() + + keyring, err := openpgp.ReadArmoredKeyRing(strings.NewReader(gnupg.pubKey)) + require.NoError(t, err) + v, err := gpg.FromKeyRing(keyring) + require.NoError(t, err) + + return v +} + +// TestMatchTestBehaviourWithGit verifies that current ways of signing objects +// didn't change in newer Git versions. +func TestMatchTestBehaviourWithGit(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping injection oracle") + } + + if gnupg == nil { + t.Error("gpg not available") + t.FailNow() + } + + repo := initRepo(t) + configureRepoSigning(t, repo, gnupg.keyID) + treeHash := gitWriteEmptyTree(t, repo) + + commitEnv := []string{ + "GIT_AUTHOR_NAME=Author", + "GIT_AUTHOR_EMAIL=author@example.local", + "GIT_AUTHOR_DATE=1700000000 +0000", + "GIT_COMMITTER_NAME=Author", + "GIT_COMMITTER_EMAIL=author@example.local", + "GIT_COMMITTER_DATE=1700000000 +0000", + } + + t.Run("commit", func(t *testing.T) { + t.Parallel() + + commitHash := gitCommitTreeSigned(t, repo, treeHash, "msg\n", commitEnv) + gitRaw := readObjectBytes(t, repo, "commit", commitHash) + + c := decodeCommit(t, gitRaw) + require.NotEmpty(t, c.Signature, "git commit-tree -S did not emit a gpgsig header") + unsigned := encodeWithoutSignature(t, c) + + oursRaw := injectGpgSig(unsigned, string(c.Signature)) + + require.Equal(t, gitRaw, oursRaw, + "injectGpgSig output diverges from `git commit-tree -S`") + }) + + t.Run("tag", func(t *testing.T) { + t.Parallel() + + commitHash := gitCommitTreeSigned(t, repo, treeHash, "msg for tag\n", commitEnv) + tagHash := gitTagSigned(t, repo, "v0", commitHash, "tag msg\n", commitEnv) + gitRaw := readObjectBytes(t, repo, "tag", tagHash) + + tag := decodeTag(t, gitRaw) + require.NotEmpty(t, tag.Signature, "git tag -s did not emit an inline signature") + unsigned := encodeTagWithoutSignature(t, tag) + + oursRaw := append(append([]byte{}, unsigned...), []byte(tag.Signature)...) + + require.Equal(t, gitRaw, oursRaw, + "tag append output diverges from `git tag -s`") + }) +} + +func runMain(m *testing.M) int { + cleanup, ok := setupGPG() + if cleanup != nil { + defer cleanup() + } + + if !ok { + // Tests will skip individually when gnupg is nil. + return m.Run() + } + + return m.Run() +} + +func decodeCommit(t *testing.T, raw []byte) *object.Commit { + t.Helper() + + obj := &plumbing.MemoryObject{} + obj.SetType(plumbing.CommitObject) + _, err := obj.Write(raw) + require.NoError(t, err) + + c := &object.Commit{} + require.NoError(t, c.Decode(obj)) + + return c +} + +func decodeTag(t *testing.T, raw []byte) *object.Tag { + t.Helper() + + obj := &plumbing.MemoryObject{} + obj.SetType(plumbing.TagObject) + _, err := obj.Write(raw) + require.NoError(t, err) + + tag := &object.Tag{} + require.NoError(t, tag.Decode(obj)) + + return tag +} + +func encodeWithoutSignature(t *testing.T, c *object.Commit) []byte { + t.Helper() + + r, err := c.EncodeWithoutSignature() + require.NoError(t, err) + + b, err := io.ReadAll(r) + require.NoError(t, err) + + return b +} + +func encodeTagWithoutSignature(t *testing.T, tag *object.Tag) []byte { + t.Helper() + + r, err := tag.EncodeWithoutSignature() + require.NoError(t, err) + + b, err := io.ReadAll(r) + require.NoError(t, err) + + return b +} + +func configureRepoSigning(t *testing.T, repo, keyID string) { + t.Helper() + // gpg.format is forced to openpgp because the host's global git + // config may set it to ssh, which would reinterpret user.signingkey + // as a path on disk. + for _, kv := range [][2]string{ + {"user.signingkey", keyID}, + {"user.email", "author@example.local"}, + {"user.name", "Author"}, + {"gpg.format", "openpgp"}, + {"gpg.program", "gpg"}, + {"commit.gpgsign", "false"}, + {"tag.gpgsign", "false"}, + } { + out, err := exec.Command("git", "-C", repo, "config", "--local", kv[0], kv[1]).CombinedOutput() + require.NoErrorf(t, err, "git config %s: %s", kv[0], out) + } +} + +func gitWriteEmptyTree(t *testing.T, repo string) plumbing.Hash { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "write-tree") + out, err := cmd.Output() + require.NoError(t, err, "git write-tree") + + return plumbing.NewHash(strings.TrimSpace(string(out))) +} + +func gitCommitTreeSigned(t *testing.T, repo string, tree plumbing.Hash, msg string, envOverrides []string) plumbing.Hash { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "commit-tree", "-S", "-m", strings.TrimRight(msg, "\n"), tree.String()) + + cmd.Env = append(os.Environ(), envOverrides...) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + out, err := cmd.Output() + require.NoErrorf(t, err, "git commit-tree -S: %s", stderr.String()) + + return plumbing.NewHash(strings.TrimSpace(string(out))) +} + +func gitTagSigned(t *testing.T, repo, name string, target plumbing.Hash, msg string, envOverrides []string) plumbing.Hash { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "tag", "-s", "-m", strings.TrimRight(msg, "\n"), name, target.String()) + + cmd.Env = append(os.Environ(), envOverrides...) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + require.NoErrorf(t, cmd.Run(), "git tag -s: %s", stderr.String()) + + rev, err := exec.Command("git", "-C", repo, "rev-parse", name).Output() + require.NoError(t, err) + + return plumbing.NewHash(strings.TrimSpace(string(rev))) +} + +func readObjectBytes(t *testing.T, repo, kind string, h plumbing.Hash) []byte { + t.Helper() + + out, err := exec.Command("git", "-C", repo, "cat-file", kind, h.String()).Output() + require.NoError(t, err, "git cat-file %s %s", kind, h.String()) + + return out +} + +func setupGPG() (func(), bool) { + if _, err := exec.LookPath("gpg"); err != nil { + log.Printf("objectverify: skipping (gpg not in PATH): %v", err) + + return nil, false + } + + if _, err := exec.LookPath("git"); err != nil { + log.Printf("objectverify: skipping (git not in PATH): %v", err) + + return nil, false + } + + home, err := os.MkdirTemp("", "gpg-objectverify-*") + if err != nil { + log.Printf("objectverify: tempdir: %v", err) + + return nil, false + } + + if err := os.Chmod(home, 0o700); err != nil { + log.Printf("objectverify: chmod home: %v", err) + + _ = os.RemoveAll(home) + + return nil, false + } + + if err := os.Setenv("GNUPGHOME", home); err != nil { + _ = os.RemoveAll(home) + + return nil, false + } + + cleanup := func() { + _ = exec.Command("gpgconf", "--kill", "all").Run() + _ = os.RemoveAll(home) + _ = os.Unsetenv("GNUPGHOME") + } + + if err := generateGPGKey(); err != nil { + log.Printf("objectverify: generate key: %v", err) + + return cleanup, false + } + + keyID, err := readGPGKeyID() + if err != nil { + log.Printf("objectverify: read key id: %v", err) + + return cleanup, false + } + + pub, err := exportArmoredPublicKey(keyID) + if err != nil { + log.Printf("objectverify: export key: %v", err) + + return cleanup, false + } + + gnupg = &gpgEnv{home: home, keyID: keyID, pubKey: pub} + + return cleanup, true +} + +func generateGPGKey() error { + cmd := exec.Command("gpg", + "--batch", "--pinentry-mode", "loopback", "--passphrase", "", + "--quick-generate-key", + "go-git verify ", + "default", "default", "never") + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("gpg --quick-generate-key: %w: %s", err, out) + } + + return nil +} + +func readGPGKeyID() (string, error) { + out, err := exec.Command("gpg", "--list-secret-keys", "--with-colons").Output() + if err != nil { + return "", err + } + + for line := range strings.SplitSeq(string(out), "\n") { + fields := strings.Split(line, ":") + if len(fields) > 9 && fields[0] == "fpr" { + return fields[9], nil + } + } + + return "", errors.New("no fingerprint in gpg --list-secret-keys output") +} + +func exportArmoredPublicKey(keyID string) (string, error) { + out, err := exec.Command("gpg", "--armor", "--export", keyID).Output() + if err != nil { + return "", err + } + + return string(out), nil +} + +// gpgSign produces an ASCII-armored detached signature over payload using +// the test key. +func gpgSign(t *testing.T, payload []byte) string { + t.Helper() + + cmd := exec.Command("gpg", + "--batch", "--pinentry-mode", "loopback", "--passphrase", "", + "--armor", "--detach-sign", "--local-user", gnupg.keyID) + cmd.Stdin = bytes.NewReader(payload) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + out, err := cmd.Output() + require.NoErrorf(t, err, "gpg --detach-sign: %s", stderr.String()) + + return string(out) +} + +func initRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + out, err := exec.Command("git", "-C", dir, "init", "--quiet").CombinedOutput() + require.NoErrorf(t, err, "git init: %s", out) + + return dir +} + +func writeLooseObject(t *testing.T, repo, objType string, content []byte) plumbing.Hash { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "hash-object", "-w", "--literally", "-t", objType, "--stdin") + cmd.Stdin = bytes.NewReader(content) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + out, err := cmd.Output() + require.NoErrorf(t, err, "git hash-object: %s", stderr.String()) + + return plumbing.NewHash(strings.TrimSpace(string(out))) +} + +func gitVerifyCommit(t *testing.T, repo string, h plumbing.Hash) error { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "verify-commit", h.String()) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return fmt.Errorf("%w: %s", err, stderr.String()) + } + + return nil +} + +func gitVerifyTag(t *testing.T, repo string, h plumbing.Hash) error { + t.Helper() + + cmd := exec.Command("git", "-C", repo, "verify-tag", h.String()) + + var stderr bytes.Buffer + + cmd.Stderr = &stderr + err := cmd.Run() + if err != nil { + return fmt.Errorf("%w: %s", err, stderr.String()) + } + + return nil +} + +func injectGpgSig(unsigned []byte, sig string) []byte { + sep := []byte("\n\n") + + idx := bytes.Index(unsigned, sep) + if idx < 0 { + panic("injectGpgSig: no header/body separator") + } + + sig = strings.TrimSuffix(sig, "\n") + indented := strings.ReplaceAll(sig, "\n", "\n ") + headerLine := "gpgsig " + indented + "\n" + + var buf bytes.Buffer + buf.Write(unsigned[:idx+1]) // up to and including the \n that ends the last header + buf.WriteString(headerLine) // gpgsig + indented sig + closing \n + buf.Write(unsigned[idx+1:]) // empty-line \n + body + + return buf.Bytes() +} diff --git a/plugin/objectverifier/gpg/conformance_tag_test.go b/plugin/objectverifier/gpg/conformance_tag_test.go new file mode 100644 index 0000000..75f21fa --- /dev/null +++ b/plugin/objectverifier/gpg/conformance_tag_test.go @@ -0,0 +1,149 @@ +//go:build linux + +package gpg_test + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v6" + "github.com/go-git/go-git/v6/plumbing/object" +) + +func TestTagVerifyAlignment(t *testing.T) { + t.Parallel() + + if testing.Short() { + t.Skip("skipping tag verify") + } + + if gnupg == nil { + t.Error("gpg not available") + t.FailNow() + } + + cases := []struct { + build func() []byte + postSign func(t *testing.T, signed []byte) []byte + name string + }{ + { + name: "valid", + build: func() []byte { + h, m := canonicalTag() + + return assembleTag(h, m) + }, + }, + { + name: "duplicate-tag", + build: func() []byte { + h, m := canonicalTag() + dup := []string{ + h[0], h[1], h[2], + "tag v1-override", + h[3], + } + + return assembleTag(dup, m) + }, + }, + { + name: "duplicate-tagger", + build: func() []byte { + h, m := canonicalTag() + dup := []string{ + h[0], h[1], h[2], h[3], + "tagger Override Tagger 1700000001 +0000", + } + + return assembleTag(dup, m) + }, + }, + { + name: "double-signature", + build: func() []byte { + h, m := canonicalTag() + + return assembleTag(h, m) + }, + postSign: func(t *testing.T, signed []byte) []byte { + // Append a second inline signature over the + // already-signed bytes. parse_signature in + // upstream and parseSignedBytes in go-git both + // pick the last PGP block, so the signed + // payload is the original tag plus the first + // signature. + sig2 := gpgSign(t, signed) + + return append(append([]byte{}, signed...), []byte(sig2)...) + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + if tc.name == "double-signature" && os.Getenv("GIT_VERSION") == "v2.11.0" { + t.Skip("multi-signature rejection not yet established in Git 2.11.0") + } + + repo := initRepo(t) + unsigned := tc.build() + sig := gpgSign(t, unsigned) + + signed := append(append([]byte{}, unsigned...), []byte(sig)...) + if tc.postSign != nil { + signed = tc.postSign(t, signed) + } + + hash := writeLooseObject(t, repo, "tag", signed) + gitErr := gitVerifyTag(t, repo, hash) + + r, err := git.PlainOpen(repo) + require.NoError(t, err) + + defer func() { _ = r.Close() }() + + tag, err := r.TagObject(hash) + + ggErr := err + if ggErr == nil { + _, ggErr = tag.Verify(context.Background(), object.WithVerifier(newVerifier(t))) + } + + assertSameVerdict(t, "git verify-tag", gitErr, "go-git Verify", ggErr) + }) + } +} + +// canonicalTag returns the byte-exact unsigned annotated-tag body +// produced by upstream `git tag -a` on a commit. Individual scenarios +// mutate the header lines around it. +func canonicalTag() (headers []string, message string) { + return []string{ + "object 1eca38290a3131d0c90709496a9b2207a872631e", + "type commit", + "tag v1", + "tagger Test Tagger 1700000000 +0000", + }, + "signed annotated tag\n" +} + +func assembleTag(headers []string, message string) []byte { + var buf bytes.Buffer + for _, h := range headers { + buf.WriteString(h) + buf.WriteByte('\n') + } + + buf.WriteByte('\n') + buf.WriteString(message) + + return buf.Bytes() +} diff --git a/plugin/objectverifier/gpg/go.mod b/plugin/objectverifier/gpg/go.mod new file mode 100644 index 0000000..76b2903 --- /dev/null +++ b/plugin/objectverifier/gpg/go.mod @@ -0,0 +1,29 @@ +module github.com/go-git/x/plugin/objectverifier/gpg + +go 1.25.0 + +require ( + github.com/ProtonMail/go-crypto v1.4.1 + // Unmerged Verifier work: https://github.com/go-git/go-git/pull/2235 + github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 // indirect + github.com/kevinburke/ssh_config v1.6.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/net v0.56.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugin/objectverifier/gpg/go.sum b/plugin/objectverifier/gpg/go.sum new file mode 100644 index 0000000..b4e88a0 --- /dev/null +++ b/plugin/objectverifier/gpg/go.sum @@ -0,0 +1,63 @@ +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= +github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1 h1:gmqi2jvsreu0s8JMLylYDFq4sbjHwwlhktMw0DUg3mA= +github.com/go-git/go-git-fixtures/v6 v6.0.0-alpha.1/go.mod h1:ECf1MqJlBdYpKggBrOXjo/0EnvRZx6D++I86UYjPgAQ= +github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e h1:j3s6MXE4TXGep6vv/yby6v69S2bERvLjt+e7j9Dr0bs= +github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e/go.mod h1:fcojZwaQcv2jtxniIuTPStnZMdfaNlz2wTAohqC8UQ0= +github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY= +github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugin/objectverifier/gpg/gpg.go b/plugin/objectverifier/gpg/gpg.go new file mode 100644 index 0000000..65a5a89 --- /dev/null +++ b/plugin/objectverifier/gpg/gpg.go @@ -0,0 +1,101 @@ +// Package gpg provides a GPG-based object verifier that checks armored +// OpenPGP detached signatures against a keyring. +package gpg + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + + "github.com/ProtonMail/go-crypto/openpgp" + + "github.com/go-git/go-git/v6/x/plugin" +) + +// Sentinel errors. +var ( + // ErrNilKeyRing is returned when the keyring provided is nil. + ErrNilKeyRing = errors.New("keyring is nil") + // ErrNilMessage is returned when a nil message is passed to Verify. + ErrNilMessage = errors.New("message is nil") + // ErrMultipleSignatures is returned when the signature carries more than + // one armored block. Mirrors upstream Git's gpg-interface, which rejects + // multi-signature payloads because their provenance cannot be reduced to a + // single authoritative signer. + ErrMultipleSignatures = errors.New("multiple signatures") +) + +// FromKeyRing creates a GPG verifier that checks armored OpenPGP detached +// signatures against the provided keyring. On success the returned +// plugin.Verification carries the signing key fingerprint in Signer and the +// *openpgp.Entity in Details. +// +//nolint:revive // returning unexported *verifier is intentional; callers use it via interface inference +func FromKeyRing(keyring openpgp.EntityList) (*verifier, error) { + if keyring == nil { + return nil, ErrNilKeyRing + } + + return &verifier{keyring: keyring}, nil +} + +type verifier struct { + keyring openpgp.EntityList +} + +// Verify reads message and checks signature against the verifier's keyring. +// The context is accepted for interface uniformity across verifiers; OpenPGP +// verification is purely local and does not consult it. +func (v *verifier) Verify(_ context.Context, message io.Reader, signature []byte) (*plugin.Verification, error) { + if message == nil { + return nil, ErrNilMessage + } + + if countSignatureBlocks(signature) > 1 { + return nil, ErrMultipleSignatures + } + + entity, err := openpgp.CheckArmoredDetachedSignature( + v.keyring, message, bytes.NewReader(signature), nil) + if err != nil { + return nil, fmt.Errorf("verifying: %w", err) + } + + return &plugin.Verification{ + Signer: fmt.Sprintf("%X", entity.PrimaryKey.Fingerprint), + Method: plugin.SignatureTypeOpenPGP, + Details: entity, + }, nil +} + +// countSignatureBlocks reports how many OpenPGP signature blocks start at a +// line boundary in data. +func countSignatureBlocks(data []byte) int { + // begins are the armored headers that start an OpenPGP signature. + begins := [][]byte{ + []byte("-----BEGIN PGP SIGNATURE-----"), + []byte("-----BEGIN PGP MESSAGE-----"), + } + + pos, count := 0, 0 + for pos < len(data) { + for _, begin := range begins { + if bytes.HasPrefix(data[pos:], begin) { + count++ + + break + } + } + + eol := bytes.IndexByte(data[pos:], '\n') + if eol < 0 { + break + } + + pos += eol + 1 + } + + return count +} diff --git a/plugin/objectverifier/gpg/gpg_test.go b/plugin/objectverifier/gpg/gpg_test.go new file mode 100644 index 0000000..cbb6251 --- /dev/null +++ b/plugin/objectverifier/gpg/gpg_test.go @@ -0,0 +1,156 @@ +package gpg_test + +import ( + "context" + "strings" + "testing" + + "github.com/ProtonMail/go-crypto/openpgp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/go-git/go-git/v6/x/plugin" + + "github.com/go-git/x/plugin/objectverifier/gpg" +) + +// message and signature are the byte-exact payload and detached signature of a +// real tag object produced by upstream git. The signature covers message +// verbatim (the tag bytes with the trailing signature block removed). +const message = `object f6685df0aac4b5adf9eeb760e6d447145c5d0b56 +type commit +tag v1.5 +tagger Máximo Cuadros 1618566233 +0200 + +signed tag +` + +const signature = `-----BEGIN PGP SIGNATURE----- + +iQGzBAABCAAdFiEE/h5sbbqJFh9j1AdUSqtFFGopTmwFAmB5XFkACgkQSqtFFGop +TmxvgAv+IPjX5WCLFUIMx8hquMZp1VkhQrseE7rljUYaYpga8gZ9s4kseTGhy7Un +61U3Ro6cTPEiQF/FkAGzSdPuGqv0ARBqHDX2tUI9+Zs/K8aG8tN+JTaof0gBcTyI +BLbZVYDTxbS9whxSDewQd0OvBG1m9ISLUhjXo6mbaVvrKXNXTHg40MPZ8ZxjR/vN +hxXXoUVnFyEDo+v6nK56mYtapThDaQQHHzD6D3VaCq3Msog7qAh9/ZNBmgb88aQ3 +FoK8PHMyr5elsV3mE9bciZBUc+dtzjOvp94uQ5ZKUXaPusXaYXnKpVnzhyer6RBI +gJLWtPwAinqmN41rGJ8jDAGrpPNjaRrMhGtbyVUPUf19OxuUIroe77sIIKTP0X2o +Wgp56dYpTst0JcGv/FYCeau/4pTRDfwHAOcDiBQ/0ag9IrZp9P8P9zlKmzNPEraV +pAe1/EFuhv2UDLucAiWM8iDZIcw8iN0OYMOGUmnk0WuGIo7dzLeqMGY+ND5n5Z8J +sZC//k6m +=VhHy +-----END PGP SIGNATURE----- +` + +const keyRing = ` +-----BEGIN PGP PUBLIC KEY BLOCK----- + +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 +=oYTT +-----END PGP PUBLIC KEY BLOCK----- +` + +func testKeyRing(t *testing.T) openpgp.EntityList { + t.Helper() + + el, err := openpgp.ReadArmoredKeyRing(strings.NewReader(keyRing)) + require.NoError(t, err) + + return el +} + +func TestFromKeyRing(t *testing.T) { + t.Parallel() + + verifier, err := gpg.FromKeyRing(nil) + require.ErrorIs(t, err, gpg.ErrNilKeyRing) + require.Nil(t, verifier) + + verifier, err = gpg.FromKeyRing(testKeyRing(t)) + require.NoError(t, err) + require.NotNil(t, verifier) +} + +func TestVerify(t *testing.T) { + t.Parallel() + + verifier, err := gpg.FromKeyRing(testKeyRing(t)) + require.NoError(t, err) + + got, err := verifier.Verify(context.Background(), strings.NewReader(message), []byte(signature)) + require.NoError(t, err) + + assert.Equal(t, plugin.SignatureTypeOpenPGP, got.Method) + assert.NotEmpty(t, got.Signer) + + entity, ok := got.Details.(*openpgp.Entity) + require.True(t, ok, "Details must be an *openpgp.Entity") + _, ok = entity.Identities["go-git contributor "] + assert.True(t, ok, "verified entity must carry the signing identity") +} + +func TestVerifyRejectsTamperedMessage(t *testing.T) { + t.Parallel() + + verifier, err := gpg.FromKeyRing(testKeyRing(t)) + require.NoError(t, err) + + _, err = verifier.Verify(context.Background(), strings.NewReader(message+"tampered"), []byte(signature)) + assert.Error(t, err) +} + +func TestVerifyRejectsMultipleSignatures(t *testing.T) { + t.Parallel() + + verifier, err := gpg.FromKeyRing(testKeyRing(t)) + require.NoError(t, err) + + block := "-----BEGIN PGP SIGNATURE-----\n\nabc\n-----END PGP SIGNATURE-----\n" + _, err = verifier.Verify(context.Background(), strings.NewReader(message), []byte(block+block)) + assert.ErrorIs(t, err, gpg.ErrMultipleSignatures) +} + +func TestVerifyNilMessage(t *testing.T) { + t.Parallel() + + verifier, err := gpg.FromKeyRing(testKeyRing(t)) + require.NoError(t, err) + + _, err = verifier.Verify(context.Background(), nil, []byte(signature)) + assert.ErrorIs(t, err, gpg.ErrNilMessage) +} diff --git a/plugin/objectverifier/ssh/go.mod b/plugin/objectverifier/ssh/go.mod new file mode 100644 index 0000000..7601ea3 --- /dev/null +++ b/plugin/objectverifier/ssh/go.mod @@ -0,0 +1,20 @@ +module github.com/go-git/x/plugin/objectverifier/ssh + +go 1.25.0 + +require ( + // Unmerged Verifier work: https://github.com/go-git/go-git/pull/2235 + github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e + github.com/hiddeco/sshsig v0.2.0 + github.com/stretchr/testify v1.11.1 + golang.org/x/crypto v0.53.0 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/go-git/gcfg/v2 v2.0.2 // indirect + github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/sys v0.46.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/plugin/objectverifier/ssh/go.sum b/plugin/objectverifier/ssh/go.sum new file mode 100644 index 0000000..90cf26b --- /dev/null +++ b/plugin/objectverifier/ssh/go.sum @@ -0,0 +1,28 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-git/gcfg/v2 v2.0.2 h1:MY5SIIfTGGEMhdA7d7JePuVVxtKL7Hp+ApGDJAJ7dpo= +github.com/go-git/gcfg/v2 v2.0.2/go.mod h1:/lv2NsxvhepuMrldsFilrgct6pxzpGdSRC13ydTLSLs= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6 h1:AaQOU2NVLxnBGWkv5YSoxomcDCqlaqfCW0t00pNKtnk= +github.com/go-git/go-billy/v6 v6.0.0-alpha.1.0.20260519112248-0095b064a6c6/go.mod h1:eaCUpHbedW7//EwcYmUDfJe2N6sJC9O12AT0OTqJR1E= +github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e h1:j3s6MXE4TXGep6vv/yby6v69S2bERvLjt+e7j9Dr0bs= +github.com/go-git/go-git/v6 v6.0.0-alpha.4.0.20260626131229-c31b1f53b87e/go.mod h1:fcojZwaQcv2jtxniIuTPStnZMdfaNlz2wTAohqC8UQ0= +github.com/hiddeco/sshsig v0.2.0 h1:gMWllgKCITXdydVkDL+Zro0PU96QI55LwUwebSwNTSw= +github.com/hiddeco/sshsig v0.2.0/go.mod h1:nJc98aGgiH6Yql2doqH4CTBVHexQA40Q+hMMLHP4EqE= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugin/objectverifier/ssh/ssh.go b/plugin/objectverifier/ssh/ssh.go new file mode 100644 index 0000000..982833a --- /dev/null +++ b/plugin/objectverifier/ssh/ssh.go @@ -0,0 +1,71 @@ +// Package ssh provides an SSH-based object verifier that checks armored SSH +// signatures using the sshsig protocol, as defined at: +// https://github.com/openssh/openssh-portable/blob/V_10_2/PROTOCOL.sshsig +package ssh + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/hiddeco/sshsig" + gossh "golang.org/x/crypto/ssh" + + "github.com/go-git/go-git/v6/x/plugin" +) + +// namespace is the sshsig namespace used for git object signing. +const namespace = "git" + +// Sentinel errors. +var ( + // ErrNilKey is returned when the public key provided is nil. + ErrNilKey = errors.New("public key is nil") + // ErrNilMessage is returned when a nil message is passed to Verify. + ErrNilMessage = errors.New("message is nil") +) + +// FromKey creates an SSH verifier that checks armored SSH signatures against +// the provided trusted public key, in the "git" namespace. On success the +// returned plugin.Verification carries the key's SHA256 fingerprint in Signer +// and the ssh.PublicKey in Details. +// +//nolint:revive // returning unexported *verifier is intentional; callers use it via interface inference +func FromKey(pub gossh.PublicKey) (*verifier, error) { + if pub == nil { + return nil, ErrNilKey + } + + return &verifier{pub: pub}, nil +} + +type verifier struct { + pub gossh.PublicKey +} + +// Verify reads message and checks the armored SSH signature against the +// verifier's trusted public key. The hash algorithm is taken from the +// signature. The context is accepted for interface uniformity across +// verifiers; SSH verification is purely local and does not consult it. +func (v *verifier) Verify(_ context.Context, message io.Reader, signature []byte) (*plugin.Verification, error) { + if message == nil { + return nil, ErrNilMessage + } + + sig, err := sshsig.Unarmor(signature) + if err != nil { + return nil, fmt.Errorf("parsing signature: %w", err) + } + + err = sshsig.Verify(message, sig, v.pub, sig.HashAlgorithm, namespace) + if err != nil { + return nil, fmt.Errorf("verifying: %w", err) + } + + return &plugin.Verification{ + Signer: gossh.FingerprintSHA256(v.pub), + Method: plugin.SignatureTypeSSH, + Details: v.pub, + }, nil +} diff --git a/plugin/objectverifier/ssh/ssh_test.go b/plugin/objectverifier/ssh/ssh_test.go new file mode 100644 index 0000000..08e5e2a --- /dev/null +++ b/plugin/objectverifier/ssh/ssh_test.go @@ -0,0 +1,111 @@ +package ssh_test + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "strings" + "testing" + + "github.com/hiddeco/sshsig" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + gossh "golang.org/x/crypto/ssh" + + "github.com/go-git/go-git/v6/x/plugin" + + "github.com/go-git/x/plugin/objectverifier/ssh" +) + +const message = "signed commit message\n" + +//nolint:ireturn // gossh.NewSignerFromSigner returns gossh.Signer (interface); no concrete type is accessible +func generateSigner(t *testing.T) gossh.Signer { + t.Helper() + + _, priv, err := ed25519.GenerateKey(rand.Reader) + require.NoError(t, err) + signer, err := gossh.NewSignerFromSigner(priv) + require.NoError(t, err) + + return signer +} + +func armoredSignature(t *testing.T, signer gossh.Signer, msg string, h sshsig.HashAlgorithm) []byte { + t.Helper() + + sig, err := sshsig.Sign(strings.NewReader(msg), signer, h, "git") + require.NoError(t, err) + + return sshsig.Armor(sig) +} + +func TestFromKey(t *testing.T) { + t.Parallel() + + verifier, err := ssh.FromKey(nil) + require.ErrorIs(t, err, ssh.ErrNilKey) + require.Nil(t, verifier) + + signer := generateSigner(t) + verifier, err = ssh.FromKey(signer.PublicKey()) + require.NoError(t, err) + require.NotNil(t, verifier) +} + +func TestVerify(t *testing.T) { + t.Parallel() + + signer := generateSigner(t) + verifier, err := ssh.FromKey(signer.PublicKey()) + require.NoError(t, err) + + for _, h := range []sshsig.HashAlgorithm{sshsig.HashSHA256, sshsig.HashSHA512} { + sig := armoredSignature(t, signer, message, h) + + got, err := verifier.Verify(context.Background(), strings.NewReader(message), sig) + require.NoError(t, err) + + assert.Equal(t, plugin.SignatureTypeSSH, got.Method) + assert.Equal(t, gossh.FingerprintSHA256(signer.PublicKey()), got.Signer) + assert.NotNil(t, got.Details) + } +} + +func TestVerifyRejectsTamperedMessage(t *testing.T) { + t.Parallel() + + signer := generateSigner(t) + sig := armoredSignature(t, signer, message, sshsig.HashSHA512) + + v, err := ssh.FromKey(signer.PublicKey()) + require.NoError(t, err) + + _, err = v.Verify(context.Background(), strings.NewReader(message+"tampered"), sig) + assert.Error(t, err) +} + +func TestVerifyRejectsUntrustedKey(t *testing.T) { + t.Parallel() + + signer := generateSigner(t) + sig := armoredSignature(t, signer, message, sshsig.HashSHA512) + + other := generateSigner(t) + v, err := ssh.FromKey(other.PublicKey()) + require.NoError(t, err) + + _, err = v.Verify(context.Background(), strings.NewReader(message), sig) + assert.Error(t, err, "signature from an untrusted key must not verify") +} + +func TestVerifyNilMessage(t *testing.T) { + t.Parallel() + + signer := generateSigner(t) + v, err := ssh.FromKey(signer.PublicKey()) + require.NoError(t, err) + + _, err = v.Verify(context.Background(), nil, []byte("ignored")) + assert.ErrorIs(t, err, ssh.ErrNilMessage) +}