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}