Go · 11560 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package gpgkey
4
5 import (
6 "bytes"
7 "errors"
8 "os"
9 "strings"
10 "testing"
11 "time"
12
13 "github.com/ProtonMail/go-crypto/openpgp"
14 "github.com/ProtonMail/go-crypto/openpgp/armor"
15 "github.com/ProtonMail/go-crypto/openpgp/packet"
16 )
17
18 // ─── fixture helpers ────────────────────────────────────────────────
19 //
20 // We synthesize all test fixtures in-memory via ProtonMail/go-crypto
21 // rather than shipping committed .asc files. Tests then exercise the
22 // real codec end-to-end (serialize → parse → assert) without depending
23 // on a system gpg binary.
24
25 // newEd25519 returns a freshly-generated ed25519 entity with a single
26 // UID. ProtonMail's nil-config default is RSA-2048; we have to ask
27 // for EdDSA explicitly.
28 func newEd25519(t *testing.T, email string) *openpgp.Entity {
29 t.Helper()
30 e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
31 Algorithm: packet.PubKeyAlgoEdDSA,
32 })
33 if err != nil {
34 t.Fatalf("NewEntity ed25519: %v", err)
35 }
36 return e
37 }
38
39 // newRSA returns an RSA entity at the requested bit size.
40 func newRSA(t *testing.T, email string, bits int) *openpgp.Entity {
41 t.Helper()
42 e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
43 Algorithm: packet.PubKeyAlgoRSA,
44 RSABits: bits,
45 })
46 if err != nil {
47 t.Fatalf("NewEntity rsa%d: %v", bits, err)
48 }
49 return e
50 }
51
52 // armoredPublic serializes an entity's public-key block as ASCII armor.
53 func armoredPublic(t *testing.T, e *openpgp.Entity) string {
54 t.Helper()
55 var buf bytes.Buffer
56 w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
57 if err != nil {
58 t.Fatalf("armor.Encode: %v", err)
59 }
60 if err := e.Serialize(w); err != nil {
61 t.Fatalf("entity.Serialize: %v", err)
62 }
63 if err := w.Close(); err != nil {
64 t.Fatalf("armor close: %v", err)
65 }
66 return buf.String()
67 }
68
69 // armoredPrivate serializes the SECRET key block — the "user uploaded
70 // their private key by mistake" fixture.
71 func armoredPrivate(t *testing.T, e *openpgp.Entity) string {
72 t.Helper()
73 var buf bytes.Buffer
74 w, err := armor.Encode(&buf, "PGP PRIVATE KEY BLOCK", nil)
75 if err != nil {
76 t.Fatalf("armor.Encode private: %v", err)
77 }
78 if err := e.SerializePrivate(w, nil); err != nil {
79 t.Fatalf("entity.SerializePrivate: %v", err)
80 }
81 if err := w.Close(); err != nil {
82 t.Fatalf("armor close: %v", err)
83 }
84 return buf.String()
85 }
86
87 // armoredDetachedSig returns an armored detached signature over a
88 // small payload — the "user uploaded a signature by mistake" fixture.
89 func armoredDetachedSig(t *testing.T, e *openpgp.Entity) string {
90 t.Helper()
91 var buf bytes.Buffer
92 w, err := armor.Encode(&buf, "PGP SIGNATURE", nil)
93 if err != nil {
94 t.Fatalf("armor.Encode sig: %v", err)
95 }
96 if err := openpgp.DetachSign(w, e, strings.NewReader("hello"), nil); err != nil {
97 t.Fatalf("DetachSign: %v", err)
98 }
99 if err := w.Close(); err != nil {
100 t.Fatalf("armor close: %v", err)
101 }
102 return buf.String()
103 }
104
105 // ─── happy-path tests ───────────────────────────────────────────────
106
107 func TestParse_Ed25519(t *testing.T) {
108 e := newEd25519(t, "alice@shithub.test")
109 armored := armoredPublic(t, e)
110
111 got, err := Parse("My laptop", armored)
112 if err != nil {
113 t.Fatalf("Parse: %v", err)
114 }
115
116 if got.Name != "My laptop" {
117 t.Errorf("Name: got %q, want %q", got.Name, "My laptop")
118 }
119 if len(got.Fingerprint) != 40 {
120 t.Errorf("Fingerprint length: got %d, want 40", len(got.Fingerprint))
121 }
122 if !isHex(got.Fingerprint) {
123 t.Errorf("Fingerprint not hex: %q", got.Fingerprint)
124 }
125 if len(got.KeyID) != 16 || !isHex(got.KeyID) {
126 t.Errorf("KeyID malformed: %q", got.KeyID)
127 }
128 if !strings.HasSuffix(got.Fingerprint, got.KeyID) {
129 t.Errorf("KeyID should be lower 16 of fingerprint: fp=%s key_id=%s", got.Fingerprint, got.KeyID)
130 }
131 if got.PrimaryAlgo != "ed25519" {
132 t.Errorf("PrimaryAlgo: got %q, want ed25519", got.PrimaryAlgo)
133 }
134 if !got.CanSign {
135 t.Error("expected CanSign=true for default ed25519 primary")
136 }
137 if !got.CanCertify {
138 t.Error("expected CanCertify=true for default ed25519 primary")
139 }
140 if got.ExpiresAt != nil {
141 t.Errorf("expected ExpiresAt=nil for default no-expiry key; got %v", got.ExpiresAt)
142 }
143 if len(got.UIDs) != 1 || got.UIDs[0] != "alice@shithub.test" {
144 t.Errorf("UIDs: got %v, want [alice@shithub.test]", got.UIDs)
145 }
146 // Default openpgp.NewEntity creates one encryption subkey.
147 if len(got.Subkeys) < 1 {
148 t.Errorf("expected at least one subkey; got %d", len(got.Subkeys))
149 }
150 }
151
152 func TestParse_RSA4096(t *testing.T) {
153 e := newRSA(t, "bob@shithub.test", 4096)
154 armored := armoredPublic(t, e)
155 got, err := Parse("", armored)
156 if err != nil {
157 t.Fatalf("Parse: %v", err)
158 }
159 if got.PrimaryAlgo != "rsa4096" {
160 t.Errorf("PrimaryAlgo: got %q, want rsa4096", got.PrimaryAlgo)
161 }
162 if !got.CanSign || !got.CanCertify {
163 t.Errorf("expected sign+certify on RSA primary; got sign=%t certify=%t", got.CanSign, got.CanCertify)
164 }
165 }
166
167 func TestParse_EncryptOnly_Accepted(t *testing.T) {
168 // Build an entity, then strip its primary's sign+certify flags so
169 // it's encrypt-only. Re-issue the self-signature so the modified
170 // flags persist through Serialize.
171 e := newRSA(t, "encryptonly@shithub.test", 2048)
172 for _, id := range e.Identities {
173 id.SelfSignature.FlagSign = false
174 id.SelfSignature.FlagCertify = false
175 id.SelfSignature.FlagEncryptCommunications = true
176 id.SelfSignature.FlagEncryptStorage = true
177 // Re-sign with the modified flags.
178 if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, nil); err != nil {
179 t.Fatalf("re-sign identity: %v", err)
180 }
181 }
182 armored := armoredPublic(t, e)
183
184 got, err := Parse("encryption only key", armored)
185 if err != nil {
186 t.Fatalf("Parse should accept encryption-only keys (gh parity); got: %v", err)
187 }
188 if got.CanSign {
189 t.Error("CanSign: got true, want false on encryption-only primary")
190 }
191 if !got.CanEncryptComms && !got.CanEncryptStorage {
192 t.Error("expected at least one encrypt-* flag true")
193 }
194 }
195
196 func TestParse_MultiSubkey(t *testing.T) {
197 e := newEd25519(t, "multi@shithub.test")
198 // Add an extra signing subkey. ProtonMail/go-crypto's AddSigningSubkey
199 // requires a Config to specify the algorithm.
200 if err := e.AddSigningSubkey(nil); err != nil {
201 t.Fatalf("AddSigningSubkey: %v", err)
202 }
203 armored := armoredPublic(t, e)
204 got, err := Parse("", armored)
205 if err != nil {
206 t.Fatalf("Parse: %v", err)
207 }
208 if len(got.Subkeys) < 2 {
209 t.Errorf("expected >=2 subkeys (one encryption from default, one we added); got %d", len(got.Subkeys))
210 }
211 // At least one subkey should have can_sign.
212 anySigning := false
213 for _, sk := range got.Subkeys {
214 if sk.CanSign {
215 anySigning = true
216 break
217 }
218 }
219 if !anySigning {
220 t.Error("expected at least one signing subkey")
221 }
222 }
223
224 // ─── rejection tests ────────────────────────────────────────────────
225
226 func TestParse_PrivateKeyBlock(t *testing.T) {
227 e := newEd25519(t, "private@shithub.test")
228 armored := armoredPrivate(t, e)
229 _, err := Parse("", armored)
230 if !errors.Is(err, ErrPrivateKeyBlock) {
231 t.Errorf("err: got %v, want ErrPrivateKeyBlock", err)
232 }
233 }
234
235 func TestParse_SignatureBlock(t *testing.T) {
236 e := newEd25519(t, "sig@shithub.test")
237 armored := armoredDetachedSig(t, e)
238 _, err := Parse("", armored)
239 if !errors.Is(err, ErrSignatureBlock) {
240 t.Errorf("err: got %v, want ErrSignatureBlock", err)
241 }
242 }
243
244 func TestParse_Expired(t *testing.T) {
245 // Create an entity with a backdated creation time + a short lifetime
246 // so the key is already expired by `time.Now()`.
247 past := time.Now().Add(-48 * time.Hour)
248 cfg := &packet.Config{
249 Time: func() time.Time { return past },
250 }
251 e, err := openpgp.NewEntity("shithub-expired", "", "expired@shithub.test", cfg)
252 if err != nil {
253 t.Fatalf("NewEntity: %v", err)
254 }
255 // 1-hour lifetime from "past" → expired ~47 hours ago.
256 oneHour := uint32(3600)
257 for _, id := range e.Identities {
258 id.SelfSignature.KeyLifetimeSecs = &oneHour
259 if err := id.SelfSignature.SignUserId(id.UserId.Id, e.PrimaryKey, e.PrivateKey, cfg); err != nil {
260 t.Fatalf("re-sign for expiry: %v", err)
261 }
262 }
263 armored := armoredPublic(t, e)
264 _, err = Parse("", armored)
265 if !errors.Is(err, ErrExpired) {
266 t.Errorf("err: got %v, want ErrExpired", err)
267 }
268 }
269
270 func TestParse_RSATooShort(t *testing.T) {
271 e := newRSA(t, "short@shithub.test", 1024)
272 armored := armoredPublic(t, e)
273 _, err := Parse("", armored)
274 if !errors.Is(err, ErrRSATooShort) {
275 t.Errorf("err: got %v, want ErrRSATooShort", err)
276 }
277 }
278
279 func TestParse_Garbage(t *testing.T) {
280 _, err := Parse("", "not a key at all, just garbage")
281 if !errors.Is(err, ErrUnparseable) {
282 t.Errorf("err: got %v, want ErrUnparseable", err)
283 }
284 }
285
286 func TestParse_Empty(t *testing.T) {
287 _, err := Parse("", "")
288 if !errors.Is(err, ErrUnparseable) {
289 t.Errorf("err: got %v, want ErrUnparseable", err)
290 }
291 }
292
293 func TestParse_LeadingWhitespaceTolerated(t *testing.T) {
294 e := newEd25519(t, "ws@shithub.test")
295 armored := "\n\n \t" + armoredPublic(t, e)
296 if _, err := Parse("", armored); err != nil {
297 t.Errorf("Parse should trim leading whitespace; got %v", err)
298 }
299 }
300
301 // ─── name-validation tests ──────────────────────────────────────────
302
303 func TestParse_NameTooLong(t *testing.T) {
304 e := newEd25519(t, "n@shithub.test")
305 armored := armoredPublic(t, e)
306 long := strings.Repeat("x", 81)
307 _, err := Parse(long, armored)
308 if !errors.Is(err, ErrNameTooLong) {
309 t.Errorf("err: got %v, want ErrNameTooLong", err)
310 }
311 }
312
313 func TestParse_NameControlChars(t *testing.T) {
314 e := newEd25519(t, "n@shithub.test")
315 armored := armoredPublic(t, e)
316 _, err := Parse("bad\x00name", armored)
317 if !errors.Is(err, ErrNameControl) {
318 t.Errorf("err: got %v, want ErrNameControl", err)
319 }
320 }
321
322 // ─── regression baseline: real gpg-produced fixtures ──────────────
323 //
324 // Exercises codec compatibility with output from `gpg (GnuPG)` 2.5+.
325 // See testdata/README.md for the generation recipe.
326
327 func TestParse_RealGPGFixtures(t *testing.T) {
328 cases := []struct {
329 name string
330 path string
331 algo string
332 }{
333 {"ed25519", "testdata/ed25519.asc", "ed25519"},
334 {"rsa4096", "testdata/rsa4096.asc", "rsa4096"},
335 }
336 for _, tc := range cases {
337 tc := tc
338 t.Run(tc.name, func(t *testing.T) {
339 blob, err := os.ReadFile(tc.path)
340 if err != nil {
341 t.Fatalf("ReadFile %s: %v", tc.path, err)
342 }
343 got, err := Parse("", string(blob))
344 if err != nil {
345 t.Fatalf("Parse %s: %v", tc.path, err)
346 }
347 if got.PrimaryAlgo != tc.algo {
348 t.Errorf("PrimaryAlgo: got %q, want %q", got.PrimaryAlgo, tc.algo)
349 }
350 if !got.CanSign && !got.CanCertify {
351 t.Errorf("expected sign or certify on real gpg primary; got both false")
352 }
353 if len(got.UIDs) == 0 {
354 t.Error("expected at least one UID")
355 }
356 if len(got.Fingerprint) != 40 || !isHex(got.Fingerprint) {
357 t.Errorf("Fingerprint malformed: %q", got.Fingerprint)
358 }
359 })
360 }
361 }
362
363 // ─── helpers ────────────────────────────────────────────────────────
364
365 func isHex(s string) bool {
366 for _, r := range s {
367 switch {
368 case r >= '0' && r <= '9':
369 case r >= 'a' && r <= 'f':
370 default:
371 return false
372 }
373 }
374 return true
375 }
376