1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify_test
4
5import (
6 "bytes"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strconv"
11 "strings"
12 "testing"
13 "time"
14
15 "github.com/stretchr/testify/require"
16
17 "bnema.dev/ermite/internal/domain"
18)
19
20// need skips the test when a tool is missing, except in CI where it must exist.
21func need(t *testing.T, tool string) string {
22 t.Helper()
23 path, err := exec.LookPath(tool)
24 if err != nil {
25 if os.Getenv("CI") != "" {
26 t.Fatalf("%s not found: CI must have it", tool)
27 }
28 t.Skipf("%s not found: install it to check signatures against real tools", tool)
29 }
30 return path
31}
32
33// run runs a command and returns its stdout; a failure fails the test with
34// its output.
35func run(t *testing.T, dir string, env []string, stdin string, name string, args ...string) string {
36 t.Helper()
37 cmd := exec.Command(name, args...)
38 cmd.Dir = dir
39 cmd.Env = append(os.Environ(), env...)
40 cmd.Stdin = strings.NewReader(stdin)
41 var stdout, stderr bytes.Buffer
42 cmd.Stdout, cmd.Stderr = &stdout, &stderr
43 require.NoError(t, cmd.Run(), "%s %v: %s", name, args, stderr.String())
44 return stdout.String()
45}
46
47// signedCommit is a commit as the verifier sees it.
48type signedCommit struct {
49 payload, signature []byte
50 signedAt int64
51 email string
52}
53
54// newRepo makes an empty Git repository with a committer identity.
55func newRepo(t *testing.T) string {
56 t.Helper()
57 need(t, "git")
58 dir := t.TempDir()
59 run(t, dir, nil, "", "git", "init", "-q")
60 return dir
61}
62
63// commit makes a signed empty commit in dir, with extra git config, and
64// returns it split the way ermite reads it.
65func commit(t *testing.T, dir, email string, env []string, config ...string) signedCommit {
66 t.Helper()
67 now := time.Now().Unix()
68 args := []string{"-c", "user.name=Test", "-c", "user.email=" + email}
69 for _, c := range config {
70 args = append(args, "-c", c)
71 }
72 args = append(args, "commit", "-q", "--allow-empty", "-S", "-m", "signed commit")
73 env = append(env,
74 "GIT_COMMITTER_DATE="+strconv.FormatInt(now, 10)+" +0000",
75 "GIT_AUTHOR_DATE="+strconv.FormatInt(now, 10)+" +0000",
76 )
77 run(t, dir, env, "", "git", args...)
78 raw := run(t, dir, nil, "", "git", "cat-file", "commit", "HEAD")
79 payload, signature, ok := domain.SplitCommitSignature([]byte(raw))
80 require.True(t, ok, "the commit must be signed:\n%s", raw)
81 return signedCommit{payload: payload, signature: signature, signedAt: now, email: email}
82}
83
84// sshKey makes an SSH key pair and returns the private key path and the
85// public key line.
86func sshKey(t *testing.T, typ string, extra ...string) (private, public string) {
87 t.Helper()
88 need(t, "ssh-keygen")
89 private = filepath.Join(t.TempDir(), "key")
90 args := append([]string{"-q", "-t", typ, "-N", "", "-C", "test comment", "-f", private}, extra...)
91 run(t, "", nil, "", "ssh-keygen", args...)
92 pub, err := os.ReadFile(private + ".pub")
93 require.NoError(t, err)
94 return private, strings.TrimSpace(string(pub))
95}
96
97// sshCommit makes a commit signed by an SSH private key.
98func sshCommit(t *testing.T, private, email string) signedCommit {
99 t.Helper()
100 return commit(t, newRepo(t), email, nil, "gpg.format=ssh", "user.signingkey="+private)
101}
102
103// gpgHome is a throw-away GnuPG home directory. It lives in /tmp: the agent
104// socket path is limited to 108 bytes.
105type gpgHome struct {
106 t *testing.T
107 home string
108}
109
110func newGPGHome(t *testing.T) gpgHome {
111 t.Helper()
112 need(t, "gpg")
113 home, err := os.MkdirTemp("", "sigverify-gpg")
114 require.NoError(t, err)
115 require.NoError(t, os.Chmod(home, 0o700))
116 t.Cleanup(func() {
117 _ = exec.Command("gpgconf", "--homedir", home, "--kill", "all").Run()
118 _ = os.RemoveAll(home)
119 })
120 return gpgHome{t: t, home: home}
121}
122
123func (g gpgHome) gpg(stdin string, args ...string) string {
124 g.t.Helper()
125 return run(g.t, "", nil, stdin, "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...)
126}
127
128// generate makes a key without passphrase and returns its fingerprint. expire
129// is a gpg expiration like "never" or "2d".
130func (g gpgHome) generate(uid, algo, expire string) string {
131 g.t.Helper()
132 g.gpg("", "--passphrase", "", "--quick-gen-key", uid, algo, "sign", expire)
133 return g.fingerprint(uid)
134}
135
136// fingerprint returns the primary key fingerprint of the key of uid.
137func (g gpgHome) fingerprint(uid string) string {
138 g.t.Helper()
139 out := g.gpg("", "--with-colons", "--fingerprint", "--list-keys", uid)
140 for _, l := range strings.Split(out, "\n") {
141 if strings.HasPrefix(l, "fpr:") {
142 return strings.Split(l, ":")[9]
143 }
144 }
145 g.t.Fatalf("no fingerprint in %q", out)
146 return ""
147}
148
149// public exports the public keys, in one armor block.
150func (g gpgHome) public(fpr ...string) string {
151 g.t.Helper()
152 return g.gpg("", append([]string{"--armor", "--export"}, fpr...)...)
153}
154
155func (g gpgHome) private(fpr string) string {
156 g.t.Helper()
157 return g.gpg("", "--armor", "--export-secret-keys", fpr)
158}
159
160// revoke publishes a revocation certificate of the key in the keyring.
161func (g gpgHome) revoke(fpr string) {
162 g.t.Helper()
163 cert := filepath.Join(g.home, "revoke.asc")
164 // --gen-revoke asks questions: it cannot run in batch mode.
165 run(g.t, "", nil, "y\n0\n\ny\n", "gpg", "--homedir", g.home, "--no-tty", "--yes", "--command-fd", "0", "--output", cert, "--gen-revoke", fpr)
166 g.gpg("", "--import", cert)
167}
168
169// detachSign signs data with the key (armored detached signature). extra are
170// gpg options such as "--digest-algo", "SHA1" or "--faked-system-time", "<t>!".
171func (g gpgHome) detachSign(fpr string, data []byte, extra ...string) []byte {
172 g.t.Helper()
173 args := append(append([]string{}, extra...), "--armor", "--detach-sign", "-u", fpr)
174 return []byte(run(g.t, "", nil, string(data), "gpg", append([]string{"--homedir", g.home, "--batch", "--yes", "--no-tty"}, args...)...))
175}
176
177// fakedTime formats a time for gpg's --faked-system-time.
178func fakedTime(t time.Time) string { return t.UTC().Format("20060102T150405") + "!" }
179
180// commit makes a commit signed by the key.
181func (g gpgHome) commit(fpr, email string) signedCommit {
182 g.t.Helper()
183 return commit(g.t, newRepo(g.t), email, []string{"GNUPGHOME=" + g.home}, "gpg.format=openpgp", "user.signingkey="+fpr)
184}
185
186func trustedSSH(public string, emails ...string) domain.TrustedKey {
187 return domain.TrustedKey{ID: "ssh-id", Format: domain.FormatSSH, Title: "ssh key", PublicKey: public, Emails: emails}
188}
189
190func trustedGPG(public string, emails ...string) domain.TrustedKey {
191 return domain.TrustedKey{ID: "gpg-id", Format: domain.FormatGPG, Title: "gpg key", PublicKey: public, Emails: emails}
192}