detached 388d3be

gpg_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify_test
4
5import (
6	"bytes"
7	"io"
8	"strings"
9	"testing"
10	"time"
11
12	"github.com/ProtonMail/go-crypto/openpgp"
13	"github.com/ProtonMail/go-crypto/openpgp/armor"
14	"github.com/stretchr/testify/assert"
15	"github.com/stretchr/testify/require"
16
17	"bnema.dev/ermite/internal/adapters/sigverify"
18	"bnema.dev/ermite/internal/domain"
19)
20
21func TestGPGVerify(t *testing.T) {
22	const email = "dev@example.org"
23	g := newGPGHome(t)
24	for name, algo := range map[string]string{"ed25519": "ed25519", "rsa": "rsa2048"} {
25		fpr := g.generate(name+" <"+email+">", algo, "never")
26		pub := g.public(fpr)
27		otherFpr := g.generate("other-"+name+" <other@example.org>", "ed25519", "never")
28		otherPub := g.public(otherFpr)
29		c := g.commit(fpr, email)
30
31		t.Run(name, func(t *testing.T) {
32			tamperedPayload := append(bytes.Clone(c.payload), '!')
33			tests := []struct {
34				name      string
35				payload   []byte
36				signature []byte
37				keys      []domain.TrustedKey
38				email     string
39				want      domain.SignatureStatus
40				wantFpr   string
41				wantKey   bool
42			}{
43				{"verified", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true},
44				{"email case is ignored", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, "Dev@EXAMPLE.org", domain.StatusVerified, fpr, true},
45				{"verified among other keys", c.payload, c.signature, []domain.TrustedKey{trustedGPG("garbage", email), {ID: "s", Format: domain.FormatSSH, PublicKey: "x"}, trustedGPG(otherPub, email), trustedGPG(pub, email)}, email, domain.StatusVerified, fpr, true},
46				{"email mismatch", c.payload, c.signature, []domain.TrustedKey{trustedGPG(pub, "other@example.org")}, email, domain.StatusEmailMismatch, fpr, true},
47				{"unknown key", c.payload, c.signature, []domain.TrustedKey{trustedGPG(otherPub, email)}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
48				{"no trusted key", c.payload, c.signature, nil, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
49				{"ssh keys are ignored", c.payload, c.signature, []domain.TrustedKey{{ID: "x", Format: domain.FormatSSH, PublicKey: pub, Emails: []string{email}}}, email, domain.StatusUnknownKey, strings.ToUpper(fpr[len(fpr)-16:]), false},
50				{"tampered payload", tamperedPayload, c.signature, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, fpr, false},
51				{"empty signature", c.payload, nil, []domain.TrustedKey{trustedGPG(pub, email)}, email, domain.StatusBadSignature, "", false},
52			}
53			for _, tt := range tests {
54				t.Run(tt.name, func(t *testing.T) {
55					v := sigverify.GPG{}.Verify(tt.payload, tt.signature, tt.email, c.signedAt, tt.keys)
56					assert.Equal(t, domain.FormatGPG, v.Format)
57					assert.Equal(t, tt.want, v.Status)
58					assert.Equal(t, tt.wantFpr, v.Fingerprint)
59					if tt.wantKey {
60						assert.Equal(t, "gpg-id", v.KeyID)
61						assert.Equal(t, "gpg key", v.KeyTitle)
62					} else {
63						assert.Empty(t, v.KeyID)
64						assert.Empty(t, v.KeyTitle)
65					}
66				})
67			}
68		})
69	}
70}
71
72// TestGPGVerifyExpiredKey checks that expiry is judged at the creation time of
73// the signature, not at the committer date nor now.
74func TestGPGVerifyExpiredKey(t *testing.T) {
75	const email = "dev@example.org"
76	g := newGPGHome(t)
77	day := 24 * time.Hour
78	now := time.Now()
79	// The key is created 10 days ago, signs 8 and 2 days ago, then gets an
80	// expiry 3 days after its creation (published 9 days ago): it expired
81	// 7 days ago, between the two signatures.
82	fakedGPG := func(at time.Time, args ...string) {
83		g.gpg("", append([]string{"--faked-system-time", fakedTime(at), "--passphrase", ""}, args...)...)
84	}
85	fakedGPG(now.Add(-10*day), "--quick-gen-key", "expiring <"+email+">", "ed25519", "sign", "never")
86	fpr := g.fingerprint(email)
87	payload := []byte("payload")
88	validThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-8*day)))
89	expiredThen := g.detachSign(fpr, payload, "--faked-system-time", fakedTime(now.Add(-2*day)))
90	fakedGPG(now.Add(-9*day), "--quick-set-expire", fpr, "3d")
91	keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
92
93	tests := []struct {
94		name string
95		sig  []byte
96		want domain.SignatureStatus
97	}{
98		{"signed while the key was valid, key expired since", validThen, domain.StatusVerified},
99		{"signed after the key expired", expiredThen, domain.StatusExpired},
100	}
101	for _, tt := range tests {
102		t.Run(tt.name, func(t *testing.T) {
103			// The committer date is irrelevant: any value gives the same status.
104			for _, committed := range []int64{0, now.Add(-9 * day).Unix(), now.Unix(), now.Add(30 * day).Unix()} {
105				v := sigverify.GPG{}.Verify(payload, tt.sig, email, committed, keys)
106				assert.Equal(t, tt.want, v.Status, "committer date %d", committed)
107				assert.Equal(t, fpr, v.Fingerprint)
108			}
109		})
110	}
111}
112
113// TestGPGVerifyIgnoresCommitterDate: a signature that is valid and a key that
114// never expires verify whatever the committer date says, including a date
115// before the signature was made.
116func TestGPGVerifyIgnoresCommitterDate(t *testing.T) {
117	const email = "dev@example.org"
118	g := newGPGHome(t)
119	fpr := g.generate("dates <"+email+">", "ed25519", "never")
120	keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
121	c := g.commit(fpr, email)
122	for _, delta := range []int64{-365 * 24 * 3600, -3600, 0, 3600} {
123		v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt+delta, keys)
124		assert.Equal(t, domain.StatusVerified, v.Status, "committer date %+d s", delta)
125	}
126}
127
128// TestGPGVerifyWeakHash: the digest algorithm allowlist is enforced; the
129// OpenPGP library does not reject a SHA-1 detached signature by itself.
130func TestGPGVerifyWeakHash(t *testing.T) {
131	const email = "dev@example.org"
132	g := newGPGHome(t)
133	payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n")
134	for name, algo := range map[string]string{"rsa": "rsa2048", "ed25519": "ed25519"} {
135		fpr := g.generate("weak-"+name+" <"+email+">", algo, "never")
136		keys := []domain.TrustedKey{trustedGPG(g.public(fpr), email)}
137		now := time.Now().Unix()
138		tests := []struct {
139			digest string
140			want   domain.SignatureStatus
141		}{
142			{"SHA1", domain.StatusBadSignature},
143			{"RIPEMD160", domain.StatusBadSignature},
144			{"SHA224", domain.StatusBadSignature},
145			{"SHA256", domain.StatusVerified},
146			{"SHA384", domain.StatusVerified},
147			{"SHA512", domain.StatusVerified},
148		}
149		for _, tt := range tests {
150			t.Run(name+"/"+tt.digest, func(t *testing.T) {
151				sig := g.detachSign(fpr, payload, "--digest-algo", tt.digest)
152				v := sigverify.GPG{}.Verify(payload, sig, email, now, keys)
153				assert.Equal(t, tt.want, v.Status)
154				if tt.want != domain.StatusVerified {
155					assert.Empty(t, v.KeyID, "a weak signature never names a trusted key")
156				}
157			})
158		}
159	}
160}
161
162// Several signature packets are refused: the library would verify the first
163// one whose issuer it knows, while the clock and the revocation check would
164// come from another one.
165func TestGPGVerifyRefusesSeveralPackets(t *testing.T) {
166	const email = "dev@example.org"
167	g := newGPGHome(t)
168	payload := []byte("tree 4b825dc642cb6eb9a060e54bf8d69288fbee4904\n\nmessage\n")
169	unknown := g.generate("unknown <other@example.org>", "ed25519", "never")
170	trusted := g.generate("trusted <"+email+">", "ed25519", "never")
171	keys := []domain.TrustedKey{trustedGPG(g.public(trusted), email)}
172	binary := func(fpr string) []byte {
173		block, err := armor.Decode(bytes.NewReader(g.detachSign(fpr, payload)))
174		require.NoError(t, err)
175		b, err := io.ReadAll(block.Body)
176		require.NoError(t, err)
177		return b
178	}
179	var both bytes.Buffer
180	w, err := armor.Encode(&both, openpgp.SignatureType, nil)
181	require.NoError(t, err)
182	_, _ = w.Write(binary(unknown))
183	_, _ = w.Write(binary(trusted))
184	require.NoError(t, w.Close())
185
186	single := sigverify.GPG{}.Verify(payload, g.detachSign(trusted, payload), email, 0, keys)
187	require.Equal(t, domain.StatusVerified, single.Status, "control: one packet verifies")
188	v := sigverify.GPG{}.Verify(payload, both.Bytes(), email, 0, keys)
189	assert.Equal(t, domain.StatusBadSignature, v.Status)
190	assert.Empty(t, v.KeyID)
191}
192
193func TestGPGVerifyRevokedKey(t *testing.T) {
194	const email = "dev@example.org"
195	g := newGPGHome(t)
196	fpr := g.generate("revoked <"+email+">", "ed25519", "never")
197	c := g.commit(fpr, email)
198	g.revoke(fpr)
199	pub := g.public(fpr) // carries the revocation
200
201	v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(pub, email)})
202	assert.Equal(t, domain.StatusRevoked, v.Status)
203	assert.Equal(t, fpr, v.Fingerprint)
204}
205
206func TestGPGVerifySubkey(t *testing.T) {
207	const email = "dev@example.org"
208	g := newGPGHome(t)
209	fpr := g.generate("subkey <"+email+">", "ed25519", "never")
210	// A signing subkey; the primary key is certify-only for gpg when asked so.
211	g.gpg("", "--passphrase", "", "--quick-add-key", fpr, "ed25519", "sign", "never")
212	out := g.gpg("", "--with-colons", "--list-keys", "--with-subkey-fingerprints", fpr)
213	var sub string
214	var next bool
215	for _, l := range strings.Split(out, "\n") {
216		if strings.HasPrefix(l, "sub:") {
217			next = true
218		} else if next && strings.HasPrefix(l, "fpr:") {
219			sub = strings.Split(l, ":")[9]
220			break
221		}
222	}
223	require.NotEmpty(t, sub)
224	c := g.commit(sub+"!", email)
225
226	v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.public(fpr), email)})
227	assert.Equal(t, domain.StatusVerified, v.Status)
228	assert.Equal(t, fpr, v.Fingerprint, "the fingerprint is the one of the primary key")
229	assert.Equal(t, "gpg-id", v.KeyID)
230}
231
232func TestGPGVerifyHostileInput(t *testing.T) {
233	const email = "dev@example.org"
234	g := newGPGHome(t)
235	fpr := g.generate("hostile <"+email+">", "ed25519", "never")
236	pub := g.public(fpr)
237	c := g.commit(fpr, email)
238	keys := []domain.TrustedKey{trustedGPG(pub, email)}
239
240	huge := []byte("-----BEGIN PGP SIGNATURE-----\n\n" + strings.Repeat("AAAA\n", 20<<10) + "-----END PGP SIGNATURE-----\n")
241	tests := []struct {
242		name string
243		sig  []byte
244	}{
245		{"not armored", []byte("hello")},
246		{"wrong armor type", []byte(strings.ReplaceAll(string(c.signature), "PGP SIGNATURE", "PGP MESSAGE"))},
247		{"empty armor", []byte("-----BEGIN PGP SIGNATURE-----\n\n-----END PGP SIGNATURE-----\n")},
248		{"a public key instead", []byte(pub)},
249		{"larger than 64 KiB", huge},
250	}
251	for _, tt := range tests {
252		t.Run(tt.name, func(t *testing.T) {
253			var v domain.CommitVerification
254			require.NotPanics(t, func() { v = sigverify.GPG{}.Verify(c.payload, tt.sig, email, c.signedAt, keys) })
255			assert.Equal(t, domain.StatusBadSignature, v.Status)
256			assert.Equal(t, domain.FormatGPG, v.Format)
257		})
258	}
259
260	t.Run("every truncation of a valid signature", func(t *testing.T) {
261		for i := range c.signature {
262			require.NotPanics(t, func() { sigverify.GPG{}.Verify(c.payload, c.signature[:i], email, c.signedAt, keys) })
263		}
264	})
265}
266
267// TestGPGVerifyIgnoresPrivateKeys: a private key stored as trusted (it cannot
268// be added through the inspector) does not take part in verification.
269func TestGPGVerifyIgnoresPrivateKeys(t *testing.T) {
270	const email = "dev@example.org"
271	g := newGPGHome(t)
272	fpr := g.generate("private <"+email+">", "ed25519", "never")
273	c := g.commit(fpr, email)
274
275	v := sigverify.GPG{}.Verify(c.payload, c.signature, email, c.signedAt, []domain.TrustedKey{trustedGPG(g.private(fpr), email)})
276	assert.Equal(t, domain.StatusUnknownKey, v.Status)
277}

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 4

Tags 45

↑↓ moveenter openesc close