v0.26.1 dfd6f0b

gpg.go RawHistoryFolder

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.
44//
45//hexcheck:ignore suspicious-business-logic-in-adapter this is the OpenPGP protocol check the port delegates to the adapter; the trust and email rules stay in domain
46func (GPG) Verify(payload, signature []byte, committerEmail string, _ int64, keys []domain.TrustedKey) (v domain.CommitVerification) {
47	v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature}
48	defer func() { // a parser bug must not take the server down
49		if recover() != nil {
50			v = domain.CommitVerification{Format: domain.FormatGPG, Status: domain.StatusBadSignature, Fingerprint: v.Fingerprint}
51		}
52	}()
53	if len(signature) > maxSignatureBytes {
54		return v
55	}
56	block, err := armor.Decode(bytes.NewReader(signature))
57	if err != nil || block.Type != openpgp.SignatureType {
58		return v
59	}
60	body, err := io.ReadAll(io.LimitReader(block.Body, maxSignatureBytes))
61	if err != nil {
62		return v
63	}
64	sig, ok := onlySignature(body)
65	if !ok {
66		return v
67	}
68	issuer := *sig.IssuerKeyId
69	v.Fingerprint = fmt.Sprintf("%016X", issuer)
70
71	ring, byFingerprint := gpgKeyring(keys)
72	created := sig.CreationTime
73	cfg := &packet.Config{Time: func() time.Time { return created }}
74	_, signer, err := openpgp.VerifyDetachedSignatureAndHash(ring, bytes.NewReader(payload), bytes.NewReader(body), acceptedHashes, cfg)
75	if signer == nil && !errors.Is(err, pgperrors.ErrUnknownIssuer) {
76		// The check failed before naming the signer: look it up for the fingerprint.
77		if known := ring.KeysById(issuer); len(known) > 0 {
78			signer = known[0].Entity
79		}
80	}
81	if signer != nil {
82		v.Fingerprint = fingerprintHex(signer)
83	}
84
85	if signer != nil && (err == nil || errors.Is(err, pgperrors.ErrKeyExpired) || errors.Is(err, pgperrors.ErrSignatureExpired)) &&
86		revokedNow(ring.KeysById(issuer), signer) {
87		// The library judged revocation at the signature date: a revocation
88		// published since is only seen with the current time.
89		v.Status = domain.StatusRevoked
90		return v
91	}
92
93	switch {
94	case err == nil:
95	case errors.Is(err, pgperrors.ErrUnknownIssuer):
96		v.Status = domain.StatusUnknownKey
97		return v
98	case errors.Is(err, pgperrors.ErrKeyExpired), errors.Is(err, pgperrors.ErrSignatureExpired):
99		v.Status = domain.StatusExpired
100		return v
101	case errors.Is(err, pgperrors.ErrKeyRevoked):
102		v.Status = domain.StatusRevoked
103		return v
104	default:
105		return v
106	}
107	if key, ok := byFingerprint[v.Fingerprint]; ok {
108		return trusted(v, key, committerEmail)
109	}
110	v.Status = domain.StatusUnknownKey
111	return v
112}
113
114// onlySignature returns the signature packet of body when body holds exactly
115// one packet, a signature with an issuer key id and a creation time. One
116// packet only: the library verifies the first packet whose issuer it knows,
117// so with several the clock and the revocation check could be taken from a
118// packet other than the one that verified.
119func onlySignature(body []byte) (*packet.Signature, bool) {
120	r := bytes.NewReader(body)
121	p, err := packet.Read(r)
122	if err != nil {
123		return nil, false
124	}
125	if _, err := packet.Read(r); !errors.Is(err, io.EOF) {
126		return nil, false
127	}
128	sig, ok := p.(*packet.Signature)
129	if !ok || sig.IssuerKeyId == nil || sig.CreationTime.IsZero() {
130		return nil, false
131	}
132	return sig, true
133}
134
135// revokedNow reports whether the entity, the signing key among keys (the
136// primary key or a subkey) or the primary user ID is revoked at the current
137// time.
138func revokedNow(keys []openpgp.Key, signer *openpgp.Entity) bool {
139	now := time.Now()
140	if signer.Revoked(now) {
141		return true
142	}
143	if _, id := signer.PrimarySelfSignature(); id != nil && id.Revoked(now) {
144		return true
145	}
146	for _, k := range keys {
147		if k.Entity == signer && k.Revoked(now) {
148			return true
149		}
150	}
151	return false
152}
153
154// gpgKeyring builds the keyring of the trusted GPG keys, and indexes the
155// trusted keys by the primary fingerprint. Unreadable keys and private keys
156// are skipped.
157func gpgKeyring(keys []domain.TrustedKey) (openpgp.EntityList, map[string]domain.TrustedKey) {
158	var ring openpgp.EntityList
159	byFingerprint := make(map[string]domain.TrustedKey, len(keys))
160	for _, k := range keys {
161		if k.Format != domain.FormatGPG || len(k.PublicKey) > domain.MaxTrustedKeyBytes {
162			continue
163		}
164		entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(k.PublicKey))
165		if err != nil {
166			continue
167		}
168		for _, e := range entities {
169			if hasPrivateKey(e) {
170				continue
171			}
172			ring = append(ring, e)
173			byFingerprint[fingerprintHex(e)] = k
174		}
175	}
176	return ring, byFingerprint
177}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 2

Tags 45

↑↓ moveenter openesc close