Go · 10844 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 "strings"
12 "time"
13
14 "github.com/ProtonMail/go-crypto/openpgp"
15 "github.com/ProtonMail/go-crypto/openpgp/armor"
16 "github.com/ProtonMail/go-crypto/openpgp/packet"
17 )
18
19 // Verify resolves the verification state of a single commit object.
20 // It reads the commit body via `git cat-file -p`, splits out the
21 // gpgsig header, looks up the signing subkey via lookups, performs
22 // the cryptographic check, and finally cross-checks the signer's
23 // email against the user's verified emails (when applicable).
24 //
25 // Never returns ReasonMalformedSignature or ReasonInvalid as errors —
26 // those are part of the Result. Returns an error only when the
27 // underlying git or DB call fails (i.e. the verification couldn't be
28 // attempted at all). Callers should record the verification result
29 // to the cache table even on error states; the error path is for
30 // "we don't know yet, retry later" situations.
31 func Verify(ctx context.Context, gitDir, commitOID string, lookups Lookups) (Result, error) {
32 body, err := catFile(ctx, gitDir, commitOID)
33 if err != nil {
34 return Result{}, fmt.Errorf("sigverify: cat-file %s: %w", commitOID, err)
35 }
36 return verifyObject(ctx, body, lookups, KindCommit)
37 }
38
39 // VerifyTag is the annotated-tag variant. The commit-object splitter
40 // already handles both header-form and inline-form signatures, so
41 // the orchestration is the same — only the cache `kind` discriminator
42 // differs.
43 func VerifyTag(ctx context.Context, gitDir, tagOID string, lookups Lookups) (Result, error) {
44 body, err := catFile(ctx, gitDir, tagOID)
45 if err != nil {
46 return Result{}, fmt.Errorf("sigverify: cat-file %s: %w", tagOID, err)
47 }
48 return verifyObject(ctx, body, lookups, KindTag)
49 }
50
51 // verifyObject is the shared implementation. _ Kind is currently
52 // unused (the Result doesn't carry it; the caller stamps Kind into
53 // the cache row themselves), kept on the signature in case future
54 // branching needs to inspect it.
55 func verifyObject(ctx context.Context, body []byte, lookups Lookups, _ Kind) (Result, error) {
56 payload, armored, signed := splitSignedObject(body)
57 if !signed {
58 return unsignedResult(), nil
59 }
60
61 // Parse the signature packet to learn which subkey signed.
62 sigPkt, err := readSignaturePacket(armored)
63 if err != nil {
64 return Result{
65 Verified: false,
66 Reason: ReasonMalformedSignature,
67 Signature: armored,
68 VerifiedAt: time.Now(),
69 }, nil
70 }
71
72 // Resolve the signing subkey. RFC 4880 supports both an
73 // IssuerKeyId subpacket (lower 64 bits of the fingerprint) and
74 // an IssuerFingerprint subpacket (the full 40-hex). Modern git/
75 // gpg emits both; we prefer the fingerprint when present because
76 // 64-bit key ids can collide.
77 var fingerprintHex string
78 if len(sigPkt.IssuerFingerprint) > 0 {
79 fingerprintHex = hex.EncodeToString(sigPkt.IssuerFingerprint)
80 }
81 if fingerprintHex == "" {
82 // IssuerKeyId fallback: we can't do a precise lookup without
83 // the full fingerprint. Mark unknown so the user can re-sign
84 // with a modern gpg client. (gh produces the same outcome
85 // in this rare case.)
86 return Result{
87 Verified: false,
88 Reason: ReasonUnknownKey,
89 Signature: armored,
90 Payload: payload,
91 VerifiedAt: time.Now(),
92 }, nil
93 }
94
95 subkey, found, err := lookups.SubkeyByFingerprint(ctx, fingerprintHex)
96 if err != nil {
97 return Result{}, fmt.Errorf("sigverify: lookup subkey: %w", err)
98 }
99 if !found {
100 return Result{
101 Verified: false,
102 Reason: ReasonUnknownKey,
103 Signature: armored,
104 Payload: payload,
105 VerifiedAt: time.Now(),
106 }, nil
107 }
108
109 // Load the parent gpg-key so we can construct the openpgp.Entity
110 // from its armored block. The cryptographic check needs the
111 // actual public-key material, not just the fingerprint.
112 gpgKey, found, err := lookups.GPGKeyByID(ctx, subkey.GPGKeyID)
113 if err != nil {
114 return Result{}, fmt.Errorf("sigverify: lookup parent gpg key: %w", err)
115 }
116 if !found {
117 // Parent gone (revoked between subkey lookup and parent
118 // lookup). Surface as unknown_key from the user's
119 // perspective.
120 return Result{
121 Verified: false,
122 Reason: ReasonUnknownKey,
123 Signature: armored,
124 Payload: payload,
125 VerifiedAt: time.Now(),
126 }, nil
127 }
128
129 entity, err := openpgp.ReadArmoredKeyRing(strings.NewReader(gpgKey.Armored))
130 if err != nil || len(entity) == 0 {
131 // Corrupted at-rest — surface as malformed so the cache row
132 // makes the issue visible; rendering UI treats this as
133 // unverified.
134 return Result{
135 Verified: false,
136 Reason: ReasonMalformedSignature,
137 Signature: armored,
138 Payload: payload,
139 SignerUserID: gpgKey.UserID,
140 SignerSubkeyID: subkey.ID,
141 VerifiedAt: time.Now(),
142 }, nil
143 }
144
145 // Capability + expiry checks happen BEFORE the cryptographic
146 // check. Reason: openpgp.CheckArmoredDetachedSignature does its
147 // own expiry check using time.Now() and folds the result into a
148 // generic error; running our checks first lets us return the
149 // precise gh enum reason (expired_key, not_signing_key).
150 if !subkey.CanSign {
151 return Result{
152 Verified: false,
153 Reason: ReasonNotSigningKey,
154 Signature: armored,
155 Payload: payload,
156 SignerUserID: gpgKey.UserID,
157 SignerSubkeyID: subkey.ID,
158 VerifiedAt: time.Now(),
159 }, nil
160 }
161 if !subkey.ExpiresAt.IsZero() && sigPkt.CreationTime.After(subkey.ExpiresAt) {
162 // Signature was made AFTER the key expired — not valid.
163 // Sigs made before expiry remain valid even when the key
164 // later expires (gh's behavior).
165 return Result{
166 Verified: false,
167 Reason: ReasonExpiredKey,
168 Signature: armored,
169 Payload: payload,
170 SignerUserID: gpgKey.UserID,
171 SignerSubkeyID: subkey.ID,
172 VerifiedAt: time.Now(),
173 }, nil
174 }
175
176 // Cryptographic check. Pass Config.Time = sig creation time so
177 // the openpgp library treats the key as live-at-sig-time (we've
178 // already run the explicit expiry check above; the library's
179 // re-check would just cause false negatives).
180 cfg := &packet.Config{Time: func() time.Time { return sigPkt.CreationTime }}
181 signer, err := openpgp.CheckArmoredDetachedSignature(
182 entity,
183 bytes.NewReader(payload),
184 strings.NewReader(armored),
185 cfg,
186 )
187 if err != nil || signer == nil {
188 return Result{
189 Verified: false,
190 Reason: ReasonInvalid,
191 Signature: armored,
192 Payload: payload,
193 SignerUserID: gpgKey.UserID,
194 SignerSubkeyID: subkey.ID,
195 VerifiedAt: time.Now(),
196 }, nil
197 }
198
199 // Email cross-check. Pull the signer email from the signature
200 // packet's UID embedding when present, otherwise from the
201 // primary identity of the parent gpg key.
202 signerEmail := extractSignerEmail(sigPkt, entity[0])
203
204 if signerEmail == "" {
205 // No email to cross-check. gh treats this as valid since
206 // the cryptography succeeded — we follow suit.
207 return Result{
208 Verified: true,
209 Reason: ReasonValid,
210 Signature: armored,
211 Payload: payload,
212 SignerUserID: gpgKey.UserID,
213 SignerSubkeyID: subkey.ID,
214 VerifiedAt: time.Now(),
215 }, nil
216 }
217
218 emails, err := lookups.UserEmailsByUserID(ctx, gpgKey.UserID)
219 if err != nil {
220 return Result{}, fmt.Errorf("sigverify: lookup user emails: %w", err)
221 }
222 emailVerifiedState, claimed := claimEmailLookup(emails, signerEmail)
223 switch {
224 case !claimed:
225 return Result{
226 Verified: false,
227 Reason: ReasonBadEmail,
228 Signature: armored,
229 Payload: payload,
230 SignerUserID: gpgKey.UserID,
231 SignerSubkeyID: subkey.ID,
232 SignerEmail: signerEmail,
233 VerifiedAt: time.Now(),
234 }, nil
235 case !emailVerifiedState:
236 return Result{
237 Verified: false,
238 Reason: ReasonUnverifiedEmail,
239 Signature: armored,
240 Payload: payload,
241 SignerUserID: gpgKey.UserID,
242 SignerSubkeyID: subkey.ID,
243 SignerEmail: signerEmail,
244 VerifiedAt: time.Now(),
245 }, nil
246 }
247
248 return Result{
249 Verified: true,
250 Reason: ReasonValid,
251 Signature: armored,
252 Payload: payload,
253 SignerUserID: gpgKey.UserID,
254 SignerSubkeyID: subkey.ID,
255 SignerEmail: signerEmail,
256 VerifiedAt: time.Now(),
257 }, nil
258 }
259
260 // catFile shells out to `git cat-file -p <oid>` and returns the
261 // object body. We trim the trailing newline that git always emits.
262 func catFile(ctx context.Context, gitDir, oid string) ([]byte, error) {
263 cmd := exec.CommandContext(ctx, "git", "-C", gitDir, "cat-file", "-p", oid)
264 out, err := cmd.Output()
265 if err != nil {
266 return nil, err
267 }
268 return out, nil
269 }
270
271 // readSignaturePacket parses the first signature packet out of an
272 // armored block. Used to extract the issuer fingerprint + creation
273 // time + UID embedding without re-doing the full cryptographic check.
274 func readSignaturePacket(armored string) (*packet.Signature, error) {
275 block, err := armor.Decode(strings.NewReader(armored))
276 if err != nil {
277 return nil, err
278 }
279 if block.Type != "PGP SIGNATURE" {
280 return nil, fmt.Errorf("sigverify: expected PGP SIGNATURE block, got %q", block.Type)
281 }
282 pkt, err := packet.Read(block.Body)
283 if err != nil {
284 return nil, err
285 }
286 sig, ok := pkt.(*packet.Signature)
287 if !ok {
288 return nil, fmt.Errorf("sigverify: first packet is not a Signature")
289 }
290 return sig, nil
291 }
292
293 // extractSignerEmail returns the email used to sign — preferring the
294 // signature packet's UID embedding (RFC 4880 §5.2.3.28) when present,
295 // otherwise the primary UID of the signing entity.
296 func extractSignerEmail(sig *packet.Signature, e *openpgp.Entity) string {
297 if sig.SignerUserId != nil && *sig.SignerUserId != "" {
298 // SignerUserId is the full UID string ("Alice <alice@x>");
299 // crack out the email part.
300 return parseEmailFromUID(*sig.SignerUserId)
301 }
302 if e != nil {
303 if id := e.PrimaryIdentity(); id != nil && id.UserId != nil {
304 return id.UserId.Email
305 }
306 }
307 return ""
308 }
309
310 // parseEmailFromUID pulls the email from a UID string of the form
311 // "Name (Comment) <email@host>" or just "email@host". Falls back to
312 // the raw string when no angle brackets are present.
313 func parseEmailFromUID(uid string) string {
314 if i := strings.LastIndex(uid, "<"); i >= 0 {
315 if j := strings.LastIndex(uid, ">"); j > i {
316 return uid[i+1 : j]
317 }
318 }
319 if strings.Contains(uid, "@") {
320 return strings.TrimSpace(uid)
321 }
322 return ""
323 }
324
325 // claimEmailLookup walks the user's emails, returning (verified, true)
326 // when the email is claimed (case-insensitive match) and (false, false)
327 // when it isn't claimed at all.
328 func claimEmailLookup(emails []UserEmail, signerEmail string) (verified, claimed bool) {
329 se := strings.ToLower(signerEmail)
330 for _, e := range emails {
331 if strings.ToLower(e.Email) == se {
332 return e.Verified, true
333 }
334 }
335 return false, false
336 }
337