v0.26.0 df1db36

signatures_e2e_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 44

↑↓ moveenter openesc close