1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3package sigverify
4
5import (
6 "bytes"
7 "crypto"
8 "errors"
9 "fmt"
10 "io"
11 "strings"
12 "time"
13
14 "github.com/ProtonMail/go-crypto/openpgp"
15 "github.com/ProtonMail/go-crypto/openpgp/armor"
16 pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
17 "github.com/ProtonMail/go-crypto/openpgp/packet"
18
19 "bnema.dev/ermite/internal/domain"
20 "bnema.dev/ermite/internal/ports"
21)
22
23// acceptedHashes are the digest algorithms of the signatures GPG accepts. The
24// library's own reject lists (packet.Config.RejectMessageHashAlgorithms) are
25// not applied to detached signatures, so the allowlist is enforced explicitly:
26// a signature over MD5, SHA-1, RIPEMD-160 or SHA-224 is a bad signature.
27var acceptedHashes = []crypto.Hash{crypto.SHA256, crypto.SHA384, crypto.SHA512, crypto.SHA3_256, crypto.SHA3_512}
28
29// GPG verifies OpenPGP detached signatures. It ignores the trusted keys that
30// are not GPG keys.
31//
32// Time policy: the committer date is not used. Key, subkey and signature
33// expiry are judged at the creation time of the signature, which the
34// signature authenticates, so a key that has expired since still verifies
35// what it signed while it was valid. Revocation is judged now: a signature by
36// a key that is revoked today (key, signing subkey or primary user ID) is
37// reported as revoked, whenever it was made.
38type GPG struct{}
39
40var _ ports.SignatureVerifier = GPG{}
41
42// Verify implements ports.SignatureVerifier. signedAt is ignored: see the time
43// policy of GPG.
44func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
45 v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}
46 defer func() { // a parser bug must not take the server down
47 if recover() != nil {
48 v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
49 }
50 }()
51 if len(signature) > maxSignatureBytes {
52 return v
53 }
54 block, err := armor.Decode(bytes.NewReader(signature))
55 if err != nil || block.Type != openpgp.SignatureType {
56 return v
57 }
58 body, err := io.ReadAll(io.LimitReader(block.Body, maxSignatureBytes))
59 if err != nil {
60 return v
61 }
62 sig, ok := onlySignature(body)
63 if !ok {
64 return v
65 }
66 issuer := *sig.IssuerKeyId
67 v.Fingerprint = fmt.Sprintf("%016X", issuer)
68
69 ring, byFingerprint := gpgKeyring(keys)
70 created := sig.CreationTime
71 cfg := &packet.Config{Time: func() time.Time { return created }}
72 _, signer, err := openpgp.VerifyDetachedSignatureAndHash(ring, bytes.NewReader(payload), bytes.NewReader(body), acceptedHashes, cfg)
73 if signer == nil && !errors.Is(err, pgperrors.ErrUnknownIssuer) {
74 // The check failed before naming the signer: look it up for the fingerprint.
75 if known := ring.KeysById(issuer); len(known) > 0 {
76 signer = known[0].Entity
77 }
78 }
79 if signer != nil {
80 v.Fingerprint = fingerprintHex(signer)
81 }
82
83 if signer != nil && (err == nil || errors.Is(err, pgperrors.ErrKeyExpired) || errors.Is(err, pgperrors.ErrSignatureExpired)) &&
84 revokedNow(ring.KeysById(issuer), signer) {
85 // The library judged revocation at the signature date: a revocation
86 // published since is only seen with the current time.
87 v.Status = domain.StatusRevoked
88 return v
89 }
90
91 switch {
92 case err == nil:
93 case errors.Is(err, pgperrors.ErrUnknownIssuer):
94 v.Status = domain.StatusUnknownKey
95 return v
96 case errors.Is(err, pgperrors.ErrKeyExpired), errors.Is(err, pgperrors.ErrSignatureExpired):
97 v.Status = domain.StatusExpired
98 return v
99 case errors.Is(err, pgperrors.ErrKeyRevoked):
100 v.Status = domain.StatusRevoked
101 return v
102 default:
103 return v
104 }
105 if key, ok := byFingerprint[v.Fingerprint]; ok {
106 return trusted(v, key, committerEmail)
107 }
108 v.Status = domain.StatusUnknownKey
109 return v
110}
111
112// onlySignature returns the signature packet of body when body holds exactly
113// one packet, a signature with an issuer key id and a creation time. One
114// packet only: the library verifies the first packet whose issuer it knows,
115// so with several the clock and the revocation check could be taken from a
116// packet other than the one that verified.
117func onlySignature(body []byte) (*packet.Signature, bool) {
118 r := bytes.NewReader(body)
119 p, err := packet.Read(r)
120 if err != nil {
121 return nil, false
122 }
123 if _, err := packet.Read(r); !errors.Is(err, io.EOF) {
124 return nil, false
125 }
126 sig, ok := p.(*packet.Signature)
127 if !ok || sig.IssuerKeyId == nil || sig.CreationTime.IsZero() {
128 return nil, false
129 }
130 return sig, true
131}
132
133// revokedNow reports whether the entity, the signing key among keys (the
134// primary key or a subkey) or the primary user ID is revoked at the current
135// time.
136func revokedNow(keys []openpgp.Key, signer *openpgp.Entity) bool {
137 now := time.Now()
138 if signer.Revoked(now) {
139 return true
140 }
141 if _, id := signer.PrimarySelfSignature(); id != nil && id.Revoked(now) {
142 return true
143 }
144 for _, k := range keys {
145 if k.Entity == signer && k.Revoked(now) {
146 return true
147 }
148 }
149 return false
150}
151
152// gpgKeyring builds the keyring of the trusted GPG keys, and indexes the
153// trusted keys by the primary fingerprint. Unreadable keys and private keys
154// are skipped.
155func gpgKeyring(keys []domain.TrustedKey) (openpgp.EntityList, map[string]domain.TrustedKey) {
156 var ring openpgp.EntityList
157 byFingerprint := make(map[string]domain.TrustedKey, len(keys))
158 for _, k := range keys {
159 if k.Format != domain.FormatGPG || len(k.PublicKey) > domain.MaxTrustedKeyBytes {
160 continue
161 }
162 entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(k.PublicKey))
163 if err != nil {
164 continue
165 }
166 for _, e := range entities {
167 if hasPrivateKey(e) {
168 continue
169 }
170 ring = append(ring, e)
171 byFingerprint[fingerprintHex(e)] = k
172 }
173 }
174 return ring, byFingerprint
175}