Go · 17713 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 package sigverify
4
5 import (
6 "bytes"
7 "context"
8 "encoding/hex"
9 "fmt"
10 "os/exec"
11 "path/filepath"
12 "strings"
13 "testing"
14 "time"
15
16 "github.com/ProtonMail/go-crypto/openpgp"
17 "github.com/ProtonMail/go-crypto/openpgp/armor"
18 "github.com/ProtonMail/go-crypto/openpgp/packet"
19 )
20
21 // ─── fixture helpers ────────────────────────────────────────────────
22
23 // newTestRepo creates a fresh bare git repo in a t.TempDir and returns
24 // its gitDir.
25 func newTestRepo(t *testing.T) string {
26 t.Helper()
27 dir := t.TempDir()
28 cmd := exec.Command("git", "init", "--quiet", "--bare", dir)
29 if err := cmd.Run(); err != nil {
30 t.Fatalf("git init: %v", err)
31 }
32 return dir
33 }
34
35 // hashObject pipes commitBody through `git hash-object -w -t commit
36 // --stdin` and returns the resulting OID. The body is stored as a
37 // commit object in the bare repo; we don't update any ref (the
38 // orchestrator addresses objects by OID directly, no ref needed).
39 func hashObject(t *testing.T, gitDir string, commitBody []byte) string {
40 t.Helper()
41 cmd := exec.Command("git", "-C", gitDir, "hash-object", "-w", "-t", "commit", "--stdin")
42 cmd.Stdin = bytes.NewReader(commitBody)
43 out, err := cmd.Output()
44 if err != nil {
45 t.Fatalf("hash-object: %v", err)
46 }
47 return strings.TrimSpace(string(out))
48 }
49
50 // signedCommit constructs a git commit object body signed by entity.
51 // The tree OID is the well-known empty-tree OID; we don't care about
52 // tree validity since verification only reads the commit object body.
53 //
54 // signedAt sets the signature creation time (useful for the expiry
55 // test which needs a sig made before the key's expiry timestamp).
56 func signedCommit(t *testing.T, entity *openpgp.Entity, message string, signedAt time.Time) []byte {
57 t.Helper()
58 const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
59 authorTime := signedAt.Unix()
60 body := fmt.Sprintf(
61 "tree %s\nauthor Alice <alice@shithub.test> %d +0000\ncommitter Alice <alice@shithub.test> %d +0000\n\n%s\n",
62 emptyTree, authorTime, authorTime, message,
63 )
64
65 var sigBuf bytes.Buffer
66 armorWriter, err := armor.Encode(&sigBuf, "PGP SIGNATURE", nil)
67 if err != nil {
68 t.Fatalf("armor.Encode: %v", err)
69 }
70 cfg := &packet.Config{Time: func() time.Time { return signedAt }}
71 if err := openpgp.DetachSign(armorWriter, entity, strings.NewReader(body), cfg); err != nil {
72 t.Fatalf("DetachSign: %v", err)
73 }
74 if err := armorWriter.Close(); err != nil {
75 t.Fatalf("armor close: %v", err)
76 }
77
78 // Reformat the signature block as a gpgsig header (each line
79 // after the first prefixed with a single space).
80 sigStr := strings.TrimRight(sigBuf.String(), "\n")
81 sigLines := strings.Split(sigStr, "\n")
82 indented := []string{"gpgsig " + sigLines[0]}
83 for _, line := range sigLines[1:] {
84 indented = append(indented, " "+line)
85 }
86 gpgsigHeader := strings.Join(indented, "\n")
87
88 // Insert the gpgsig header between the committer line and the
89 // blank-line + message.
90 signedBody := strings.Replace(
91 body,
92 fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n\n", authorTime),
93 fmt.Sprintf("committer Alice <alice@shithub.test> %d +0000\n%s\n\n", authorTime, gpgsigHeader),
94 1,
95 )
96 return []byte(signedBody)
97 }
98
99 // unsignedCommit builds a commit object body with no gpgsig header.
100 func unsignedCommit(t *testing.T, message string) []byte {
101 t.Helper()
102 const emptyTree = "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
103 now := time.Now().Unix()
104 return []byte(fmt.Sprintf(
105 "tree %s\nauthor Alice <alice@shithub.test> %d +0000\ncommitter Alice <alice@shithub.test> %d +0000\n\n%s\n",
106 emptyTree, now, now, message,
107 ))
108 }
109
110 // armoredPublic returns the entity's armored public-key block.
111 func armoredPublic(t *testing.T, e *openpgp.Entity) string {
112 t.Helper()
113 var buf bytes.Buffer
114 w, err := armor.Encode(&buf, "PGP PUBLIC KEY BLOCK", nil)
115 if err != nil {
116 t.Fatalf("armor.Encode: %v", err)
117 }
118 if err := e.Serialize(w); err != nil {
119 t.Fatalf("Serialize: %v", err)
120 }
121 w.Close()
122 return buf.String()
123 }
124
125 // signingSubkey returns the first subkey of an entity that carries
126 // the sign flag, falling back to the primary key if no subkey is
127 // signing-capable.
128 func signingSubkey(t *testing.T, e *openpgp.Entity) *packet.PublicKey {
129 t.Helper()
130 for i := range e.Subkeys {
131 sk := &e.Subkeys[i]
132 if sk.Sig != nil && sk.Sig.FlagSign {
133 return sk.PublicKey
134 }
135 }
136 return e.PrimaryKey
137 }
138
139 // fakeLookups is a minimal Lookups implementation for tests. The
140 // orchestrator only calls SubkeyByFingerprint / GPGKeyByID /
141 // UserEmailsByUserID, and we set each return value explicitly per
142 // test.
143 type fakeLookups struct {
144 subkey Subkey
145 subkeyFound bool
146 gpgKey GPGKey
147 gpgKeyFound bool
148 emails []UserEmail
149 emailsErr error
150 subkeyErr error
151 gpgKeyErr error
152 }
153
154 func (f *fakeLookups) SubkeyByFingerprint(_ context.Context, fp string) (Subkey, bool, error) {
155 if f.subkeyErr != nil {
156 return Subkey{}, false, f.subkeyErr
157 }
158 if !f.subkeyFound {
159 return Subkey{}, false, nil
160 }
161 if !strings.EqualFold(fp, f.subkey.Fingerprint) {
162 return Subkey{}, false, nil
163 }
164 return f.subkey, true, nil
165 }
166
167 func (f *fakeLookups) GPGKeyByID(_ context.Context, id int64) (GPGKey, bool, error) {
168 if f.gpgKeyErr != nil {
169 return GPGKey{}, false, f.gpgKeyErr
170 }
171 if !f.gpgKeyFound {
172 return GPGKey{}, false, nil
173 }
174 if f.gpgKey.ID != id {
175 return GPGKey{}, false, nil
176 }
177 return f.gpgKey, true, nil
178 }
179
180 func (f *fakeLookups) UserEmailsByUserID(_ context.Context, _ int64) ([]UserEmail, error) {
181 return f.emails, f.emailsErr
182 }
183
184 // lookupsForEntity wires the in-memory entity into a fakeLookups so
185 // that SubkeyByFingerprint and GPGKeyByID return matching records.
186 // signingFP is the fingerprint of the subkey that was used to sign;
187 // some tests want to verify against a non-signing subkey (the
188 // not_signing_key case), so the caller supplies which subkey.
189 func lookupsForEntity(t *testing.T, entity *openpgp.Entity, signerPK *packet.PublicKey, canSign bool, expiresAt time.Time, emails []UserEmail) *fakeLookups {
190 t.Helper()
191 return &fakeLookups{
192 subkey: Subkey{
193 ID: 42,
194 GPGKeyID: 99,
195 Fingerprint: hex.EncodeToString(signerPK.Fingerprint),
196 KeyID: fmt.Sprintf("%016x", signerPK.KeyId),
197 CanSign: canSign,
198 ExpiresAt: expiresAt,
199 },
200 subkeyFound: true,
201 gpgKey: GPGKey{
202 ID: 99,
203 UserID: 7,
204 Fingerprint: hex.EncodeToString(entity.PrimaryKey.Fingerprint),
205 KeyID: fmt.Sprintf("%016x", entity.PrimaryKey.KeyId),
206 Armored: armoredPublic(t, entity),
207 },
208 gpgKeyFound: true,
209 emails: emails,
210 }
211 }
212
213 // ─── tests ──────────────────────────────────────────────────────────
214
215 // makeEntity builds an ed25519 entity for tests. ProtonMail's default
216 // is RSA-2048; we ask for EdDSA explicitly.
217 func makeEntity(t *testing.T, email string) *openpgp.Entity {
218 t.Helper()
219 e, err := openpgp.NewEntity("shithub-test", "", email, &packet.Config{
220 Algorithm: packet.PubKeyAlgoEdDSA,
221 })
222 if err != nil {
223 t.Fatalf("NewEntity: %v", err)
224 }
225 return e
226 }
227
228 func TestVerify_Unsigned(t *testing.T) {
229 gitDir := newTestRepo(t)
230 oid := hashObject(t, gitDir, unsignedCommit(t, "hello world"))
231
232 got, err := Verify(context.Background(), gitDir, oid, &fakeLookups{})
233 if err != nil {
234 t.Fatalf("Verify: %v", err)
235 }
236 if got.Reason != ReasonUnsigned {
237 t.Errorf("reason: got %q, want unsigned", got.Reason)
238 }
239 if got.Verified {
240 t.Error("expected Verified=false on unsigned commit")
241 }
242 }
243
244 func TestVerify_Valid(t *testing.T) {
245 entity := makeEntity(t, "alice@shithub.test")
246 gitDir := newTestRepo(t)
247 body := signedCommit(t, entity, "valid sig", time.Now())
248 oid := hashObject(t, gitDir, body)
249
250 signer := signingSubkey(t, entity)
251 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
252 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
253
254 got, err := Verify(context.Background(), gitDir, oid, lookups)
255 if err != nil {
256 t.Fatalf("Verify: %v", err)
257 }
258 if got.Reason != ReasonValid {
259 t.Fatalf("reason: got %q (sig=%q), want valid", got.Reason, got.Signature)
260 }
261 if !got.Verified {
262 t.Error("expected Verified=true")
263 }
264 if got.SignerUserID != 7 {
265 t.Errorf("SignerUserID: got %d, want 7", got.SignerUserID)
266 }
267 if got.SignerEmail != "alice@shithub.test" {
268 t.Errorf("SignerEmail: got %q, want alice@shithub.test", got.SignerEmail)
269 }
270 }
271
272 func TestVerify_UnknownKey(t *testing.T) {
273 entity := makeEntity(t, "alice@shithub.test")
274 gitDir := newTestRepo(t)
275 body := signedCommit(t, entity, "unknown key sig", time.Now())
276 oid := hashObject(t, gitDir, body)
277
278 // Lookups returns no subkey for any fingerprint.
279 got, err := Verify(context.Background(), gitDir, oid, &fakeLookups{})
280 if err != nil {
281 t.Fatalf("Verify: %v", err)
282 }
283 if got.Reason != ReasonUnknownKey {
284 t.Errorf("reason: got %q, want unknown_key", got.Reason)
285 }
286 }
287
288 func TestVerify_BadEmail(t *testing.T) {
289 entity := makeEntity(t, "alice@shithub.test")
290 gitDir := newTestRepo(t)
291 body := signedCommit(t, entity, "bad email sig", time.Now())
292 oid := hashObject(t, gitDir, body)
293
294 signer := signingSubkey(t, entity)
295 // User registered some other email, never claimed alice@shithub.test.
296 emails := []UserEmail{{Email: "bob@shithub.test", Verified: true}}
297 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
298
299 got, err := Verify(context.Background(), gitDir, oid, lookups)
300 if err != nil {
301 t.Fatalf("Verify: %v", err)
302 }
303 if got.Reason != ReasonBadEmail {
304 t.Errorf("reason: got %q, want bad_email", got.Reason)
305 }
306 }
307
308 func TestVerify_UnverifiedEmail(t *testing.T) {
309 entity := makeEntity(t, "alice@shithub.test")
310 gitDir := newTestRepo(t)
311 body := signedCommit(t, entity, "unverified email sig", time.Now())
312 oid := hashObject(t, gitDir, body)
313
314 signer := signingSubkey(t, entity)
315 // Email claimed but unverified.
316 emails := []UserEmail{{Email: "alice@shithub.test", Verified: false}}
317 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
318
319 got, err := Verify(context.Background(), gitDir, oid, lookups)
320 if err != nil {
321 t.Fatalf("Verify: %v", err)
322 }
323 if got.Reason != ReasonUnverifiedEmail {
324 t.Errorf("reason: got %q, want unverified_email", got.Reason)
325 }
326 }
327
328 func TestVerify_NotSigningKey(t *testing.T) {
329 entity := makeEntity(t, "alice@shithub.test")
330 gitDir := newTestRepo(t)
331 body := signedCommit(t, entity, "not signing key", time.Now())
332 oid := hashObject(t, gitDir, body)
333
334 signer := signingSubkey(t, entity)
335 // Look up the same fingerprint but with CanSign=false.
336 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
337 lookups := lookupsForEntity(t, entity, signer, false /* canSign */, time.Time{}, emails)
338
339 got, err := Verify(context.Background(), gitDir, oid, lookups)
340 if err != nil {
341 t.Fatalf("Verify: %v", err)
342 }
343 if got.Reason != ReasonNotSigningKey {
344 t.Errorf("reason: got %q, want not_signing_key", got.Reason)
345 }
346 }
347
348 func TestVerify_ExpiredKey(t *testing.T) {
349 entity := makeEntity(t, "alice@shithub.test")
350 gitDir := newTestRepo(t)
351 // Sign with a creation time of "now"; pretend the subkey expired
352 // an hour ago.
353 signedAt := time.Now()
354 body := signedCommit(t, entity, "expired key sig", signedAt)
355 oid := hashObject(t, gitDir, body)
356
357 signer := signingSubkey(t, entity)
358 expired := signedAt.Add(-time.Hour)
359 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
360 lookups := lookupsForEntity(t, entity, signer, true, expired, emails)
361
362 got, err := Verify(context.Background(), gitDir, oid, lookups)
363 if err != nil {
364 t.Fatalf("Verify: %v", err)
365 }
366 if got.Reason != ReasonExpiredKey {
367 t.Errorf("reason: got %q, want expired_key", got.Reason)
368 }
369 }
370
371 func TestVerify_Invalid(t *testing.T) {
372 entity := makeEntity(t, "alice@shithub.test")
373 gitDir := newTestRepo(t)
374
375 // Build commit A (the one we'll write) and capture its gpgsig
376 // header. Build commit B with a DIFFERENT message; capture its
377 // gpgsig header. Then write commit A's body but with B's
378 // signature embedded — the armor still parses cleanly (no
379 // malformed_signature), but the cryptographic check fails
380 // because the signature was made over commit B's payload, not
381 // commit A's. This is the canonical "invalid" branch.
382 bodyA := signedCommit(t, entity, "commit A message", time.Now())
383 bodyB := signedCommit(t, entity, "commit B different message", time.Now())
384
385 sigA := extractGpgsig(t, bodyA)
386 sigB := extractGpgsig(t, bodyB)
387
388 mismatched := bytes.Replace(bodyA, sigA, sigB, 1)
389 oid := hashObject(t, gitDir, mismatched)
390
391 signer := signingSubkey(t, entity)
392 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
393 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
394
395 got, err := Verify(context.Background(), gitDir, oid, lookups)
396 if err != nil {
397 t.Fatalf("Verify: %v", err)
398 }
399 if got.Reason != ReasonInvalid {
400 t.Errorf("reason: got %q, want invalid", got.Reason)
401 }
402 }
403
404 // extractGpgsig returns the full gpgsig header block (header label +
405 // continuation lines, trailing newline) from a commit body.
406 func extractGpgsig(t *testing.T, body []byte) []byte {
407 t.Helper()
408 idx := bytes.Index(body, []byte("\ngpgsig "))
409 if idx < 0 {
410 t.Fatalf("commit body has no gpgsig header")
411 }
412 start := idx + 1 // skip the preceding newline
413 // Walk to the first non-continuation line (no leading space).
414 cursor := start
415 for cursor < len(body) {
416 nl := bytes.IndexByte(body[cursor:], '\n')
417 if nl < 0 {
418 break
419 }
420 lineEnd := cursor + nl + 1
421 // Peek at the next line's first byte; if it's not a
422 // continuation (" "), the header block ends here.
423 if lineEnd >= len(body) || body[lineEnd] != ' ' {
424 return body[start:lineEnd]
425 }
426 cursor = lineEnd
427 }
428 return body[start:]
429 }
430
431 func TestVerify_MalformedSignature(t *testing.T) {
432 entity := makeEntity(t, "alice@shithub.test")
433 gitDir := newTestRepo(t)
434 body := signedCommit(t, entity, "malformed", time.Now())
435
436 // Replace "-----BEGIN PGP SIGNATURE-----" with "-----BEGIN PGP
437 // XYZSIGNATURE-----" — the armor parser will refuse the unknown
438 // block type.
439 malformed := bytes.Replace(body, []byte("BEGIN PGP SIGNATURE"), []byte("BEGIN PGP XYZSIGNATURE"), 1)
440 malformed = bytes.Replace(malformed, []byte("END PGP SIGNATURE"), []byte("END PGP XYZSIGNATURE"), 1)
441 oid := hashObject(t, gitDir, malformed)
442
443 signer := signingSubkey(t, entity)
444 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
445 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
446
447 got, err := Verify(context.Background(), gitDir, oid, lookups)
448 if err != nil {
449 t.Fatalf("Verify: %v", err)
450 }
451 if got.Reason != ReasonMalformedSignature {
452 t.Errorf("reason: got %q, want malformed_signature", got.Reason)
453 }
454 }
455
456 // TestVerify_GitCatFileError exercises the error-return path
457 // (gitDir unreadable / OID nonexistent → orchestrator returns an
458 // error rather than a Result).
459 func TestVerify_GitCatFileError(t *testing.T) {
460 gitDir := newTestRepo(t)
461 _, err := Verify(context.Background(), gitDir, strings.Repeat("0", 40), &fakeLookups{})
462 if err == nil {
463 t.Error("expected error for nonexistent OID; got nil")
464 }
465 }
466
467 // TestVerifyTag walks the same valid-sig path against a tag object.
468 // We construct the tag object body with the inline trailing-signature
469 // form (legacy git tag -s convention).
470 func TestVerifyTag_ValidInline(t *testing.T) {
471 entity := makeEntity(t, "alice@shithub.test")
472 gitDir := newTestRepo(t)
473
474 // Build the tag object payload (everything except the signature)
475 // and sign it.
476 const emptyCommit = "0000000000000000000000000000000000000000"
477 tagPayload := fmt.Sprintf(
478 "object %s\ntype commit\ntag v1.0.0\ntagger Alice <alice@shithub.test> %d +0000\n\nrelease notes\n",
479 emptyCommit, time.Now().Unix(),
480 )
481 var sigBuf bytes.Buffer
482 armorWriter, err := armor.Encode(&sigBuf, "PGP SIGNATURE", nil)
483 if err != nil {
484 t.Fatalf("armor.Encode: %v", err)
485 }
486 if err := openpgp.DetachSign(armorWriter, entity, strings.NewReader(tagPayload), nil); err != nil {
487 t.Fatalf("DetachSign: %v", err)
488 }
489 armorWriter.Close()
490
491 tagBody := []byte(tagPayload + sigBuf.String())
492
493 cmd := exec.Command("git", "-C", gitDir, "hash-object", "-w", "-t", "tag", "--stdin")
494 cmd.Stdin = bytes.NewReader(tagBody)
495 out, err := cmd.Output()
496 if err != nil {
497 t.Fatalf("hash-object tag: %v", err)
498 }
499 oid := strings.TrimSpace(string(out))
500
501 signer := signingSubkey(t, entity)
502 emails := []UserEmail{{Email: "alice@shithub.test", Verified: true}}
503 lookups := lookupsForEntity(t, entity, signer, true, time.Time{}, emails)
504
505 got, err := VerifyTag(context.Background(), gitDir, oid, lookups)
506 if err != nil {
507 t.Fatalf("VerifyTag: %v", err)
508 }
509 if got.Reason != ReasonValid {
510 t.Errorf("reason: got %q, want valid", got.Reason)
511 }
512 }
513
514 // ─── splitSignedObject unit tests ──────────────────────────────────
515
516 func TestSplitSignedObject_Unsigned(t *testing.T) {
517 body := []byte("tree abc\nauthor X <x@x> 0 +0000\ncommitter X <x@x> 0 +0000\n\nmessage\n")
518 payload, sig, signed := splitSignedObject(body)
519 if signed {
520 t.Error("expected signed=false")
521 }
522 if !bytes.Equal(payload, body) {
523 t.Errorf("payload mismatch")
524 }
525 if sig != "" {
526 t.Errorf("sig: got %q, want empty", sig)
527 }
528 }
529
530 // ─── helpers ────────────────────────────────────────────────────────
531
532 // suppress unused-import warning for filepath if it's not referenced.
533 var _ = filepath.Join
534