1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package app
4
5import (
6 "net/http"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11 "testing"
12
13 "connectrpc.com/connect"
14 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"
16
17 pb "bnema.dev/ermite-api/gen/ermite"
18 "bnema.dev/ermite-api/gen/ermite/ermiteconnect"
19 "bnema.dev/ermite/internal/domain"
20)
21
22func needTool(t *testing.T, name string) {
23 t.Helper()
24 if _, err := exec.LookPath(name); err != nil {
25 if os.Getenv("CI") != "" {
26 t.Fatalf("%s is needed", name)
27 }
28 t.Skipf("%s not installed", name)
29 }
30}
31
32// ADR 0030 end to end: commits pushed signed with SSH and OpenPGP keys show
33// their protocol and state over the API and on the web, and trusting a key
34// turns them verified.
35func TestCommitSignaturesEndToEnd(t *testing.T) {
36 needTool(t, "ssh-keygen")
37 needTool(t, "gpg")
38 h := newHarness(t, harnessOpts{})
39 ctx := h.ctx
40 h.seed("sig", domain.VisibilityPublic)
41 wt := h.workTree("main")
42 keys := t.TempDir()
43
44 // SSH key and commit.
45 sshKey := filepath.Join(keys, "id")
46 out, err := exec.Command("ssh-keygen", "-q", "-t", "ed25519", "-N", "", "-C", "c", "-f", sshKey).CombinedOutput()
47 require.NoError(t, err, string(out))
48 h.writeFile(wt, "a.txt", []byte("a\n"))
49 h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit")
50 sshCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
51
52 // OpenPGP key and commit.
53 // A short home: the agent socket path must fit sun_path.
54 gnupg, err := os.MkdirTemp("", "gpg")
55 require.NoError(t, err)
56 t.Cleanup(func() {
57 _ = exec.Command("gpgconf", "--homedir", gnupg, "--kill", "all").Run()
58 _ = os.RemoveAll(gnupg)
59 })
60 gpg := func(args ...string) string {
61 cmd := exec.Command("gpg", append([]string{"--batch", "--homedir", gnupg, "--pinentry-mode", "loopback", "--passphrase", ""}, args...)...)
62 b, err := cmd.CombinedOutput()
63 require.NoError(t, err, string(b))
64 return string(b)
65 }
66 gpg("--quick-gen-key", "T <t@example.test>", "ed25519", "sign", "never")
67 wrapper := filepath.Join(keys, "gpg.sh")
68 require.NoError(t, os.WriteFile(wrapper, []byte("#!/bin/sh\nexec gpg --homedir '"+gnupg+"' --batch --pinentry-mode loopback --passphrase '' \"$@\"\n"), 0o700))
69 h.writeFile(wt, "b.txt", []byte("b\n"))
70 h.mustGit(wt, "-c", "gpg.program="+wrapper, "-c", "user.signingkey=t@example.test", "commit", "-q", "--amend", "-S", "--no-edit")
71 gpgCommit := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
72 h.writeFile(wt, "c.txt", []byte("c\n"))
73 unsigned := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
74 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "main")
75
76 root := h.api(adminToken)
77 trust := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{secret: adminToken, next: http.DefaultTransport}}, h.url("/-/api"))
78 states := func() map[string]*pb.CommitVerification {
79 res, err := root.git.ListCommits(ctx, connect.NewRequest(&pb.ListCommitsRequest{Repo: "sig"}))
80 require.NoError(t, err)
81 m := map[string]*pb.CommitVerification{}
82 for _, c := range res.Msg.GetCommits() {
83 m[c.GetId()] = c.GetVerification()
84 }
85 return m
86 }
87
88 v, err := root.server.GetVersion(ctx, connect.NewRequest(&pb.GetVersionRequest{}))
89 require.NoError(t, err)
90 assert.Contains(t, v.Msg.GetFeatures(), "commit_signatures")
91
92 s := states()
93 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_SSH, s[sshCommit].GetFormat())
94 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[sshCommit].GetStatus())
95 assert.Equal(t, pb.SignatureFormat_SIGNATURE_FORMAT_GPG, s[gpgCommit].GetFormat())
96 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, s[gpgCommit].GetStatus())
97 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNSIGNED, s[unsigned].GetStatus())
98
99 // Trust both keys.
100 pub, err := os.ReadFile(sshKey + ".pub")
101 require.NoError(t, err)
102 _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{
103 Format: pb.SignatureFormat_SIGNATURE_FORMAT_SSH, Title: "laptop", PublicKey: string(pub), Emails: []string{"T@example.test"},
104 }))
105 require.NoError(t, err)
106 _, err = trust.AddTrustedKey(ctx, connect.NewRequest(&pb.AddTrustedKeyRequest{
107 Format: pb.SignatureFormat_SIGNATURE_FORMAT_GPG, Title: "gpg", PublicKey: gpg("--armor", "--export", "t@example.test"), Emails: []string{"t@example.test"},
108 }))
109 require.NoError(t, err)
110
111 s = states()
112 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[sshCommit].GetStatus())
113 assert.Equal(t, "laptop", s[sshCommit].GetKeyTitle())
114 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, s[gpgCommit].GetStatus())
115
116 list, err := trust.ListTrustedKeys(ctx, connect.NewRequest(&pb.ListTrustedKeysRequest{}))
117 require.NoError(t, err)
118 require.Len(t, list.Msg.GetKeys(), 3)
119 assert.True(t, list.Msg.GetKeys()[0].GetBuiltin(), "the instance key comes first")
120
121 // A token without owner rights cannot change the list.
122 weak := ermiteconnect.NewTrustedKeyServiceClient(&http.Client{Transport: bearerTransport{
123 secret: h.token(scopes(domain.ScopeRepoRead)), next: http.DefaultTransport,
124 }}, h.url("/-/api"))
125 _, err = weak.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
126 assert.Equal(t, "permission_denied", codeName(err))
127
128 // The web shows the protocol and the state.
129 code, _, body := h.get("/sig/commit/" + sshCommit)
130 require.Equal(t, http.StatusOK, code)
131 assert.Contains(t, body, "sig-ok")
132 assert.Contains(t, body, "SSH")
133 code, _, body = h.get("/sig/commit/" + gpgCommit)
134 require.Equal(t, http.StatusOK, code)
135 assert.Contains(t, body, "GPG")
136 assert.Contains(t, body, "sig-ok")
137
138 // A merge request lists its commits with their signature state, over the
139 // API and on its page.
140 h.mustGit(wt, "checkout", "-q", "-b", "feat")
141 h.writeFile(wt, "d.txt", []byte("d\n"))
142 h.mustGit(wt, "-c", "gpg.format=ssh", "-c", "user.signingkey="+sshKey+".pub", "commit", "-q", "--amend", "-S", "--no-edit")
143 feat := strings.TrimSpace(h.mustGit(wt, "rev-parse", "HEAD"))
144 h.mustGit(wt, "push", "-q", h.urlAuth(adminToken, "/sig.git"), "feat")
145 _, err = root.mr.CreateMergeRequest(ctx, connect.NewRequest(&pb.CreateMergeRequestRequest{Repo: "sig", Title: "d", SourceBranch: "feat"}))
146 require.NoError(t, err)
147 mr, err := root.mr.GetMergeRequest(ctx, connect.NewRequest(&pb.GetMergeRequestRequest{Repo: "sig", Number: 1}))
148 require.NoError(t, err)
149 require.Len(t, mr.Msg.GetCommits(), 1)
150 assert.Equal(t, feat, mr.Msg.GetCommits()[0].GetId())
151 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_VERIFIED, mr.Msg.GetCommits()[0].GetVerification().GetStatus())
152 code, _, body = h.get("/sig/merge-requests/1")
153 require.Equal(t, http.StatusOK, code)
154 assert.Contains(t, body, `<span class="pill sig sig-ok"`)
155
156 // Removing the key turns the commit unknown again.
157 _, err = trust.RemoveTrustedKey(ctx, connect.NewRequest(&pb.RemoveTrustedKeyRequest{Id: list.Msg.GetKeys()[1].GetId()}))
158 require.NoError(t, err)
159 assert.Equal(t, pb.SignatureStatus_SIGNATURE_STATUS_UNKNOWN_KEY, states()[sshCommit].GetStatus())
160}