krz/gitbay
A CLI-first git forge.
clone: git clone https://gitbay.org/krz/gitbay.git
main: internal/sig/verify.go · raw
1package sig
2
3import (
4 "bytes"
5 "slices"
6 "time"
7
8 "github.com/ProtonMail/go-crypto/openpgp"
9 "github.com/ProtonMail/go-crypto/openpgp/packet"
10)
11
12type State string
13
14const (
15 Verified State = "verified"
16 SignedUnknownKey State = "signed_unknown_key"
17 SignedEmailMismatch State = "signed_email_mismatch"
18 SignedKeyExpired State = "signed_key_expired"
19 SignedKeyRevoked State = "signed_key_revoked"
20 BadSignature State = "bad_signature"
21 Unsigned State = "unsigned"
22)
23
24type Result struct {
25 State State
26 SignerUserID int64 // 0 when unknown
27 KeyFingerprint string
28}
29
30// PGPKeyInfo is a registered OpenPGP key as the verifier needs it.
31type PGPKeyInfo struct {
32 UserID int64
33 Armored string
34 ExpiresAt *time.Time
35 RevokedAt *time.Time
36}
37
38// SSHKeyInfo is a registered SSH key as the verifier needs it.
39type SSHKeyInfo struct {
40 UserID int64
41 Fingerprint string
42}
43
44// DB is the store surface the verifier depends on. Implemented by
45// store.SigDB.
46type DB interface {
47 // PGPKeyByIssuer finds a registered key whose fingerprint ends with the
48 // issuer key id (16 hex chars, lowercase).
49 PGPKeyByIssuer(keyIDHex string) (PGPKeyInfo, string, bool, error) // info, fingerprint, found
50 SSHSignerByFingerprint(fp string) (SSHKeyInfo, bool, error)
51 VerifiedEmails(userID int64) ([]string, error)
52}
53
54// VerifyCommit classifies one parsed commit. The commit's author email
55// drives the identity check, per the plan.
56func VerifyCommit(db DB, c *Commit) (Result, error) {
57 switch KindOf(c.Signature) {
58 case SigNone:
59 return Result{State: Unsigned}, nil
60 case SigOpenPGP:
61 return verifyOpenPGP(db, c)
62 case SigSSH:
63 return verifySSH(db, c)
64 default:
65 return Result{State: BadSignature}, nil
66 }
67}
68
69func verifyOpenPGP(db DB, c *Commit) (Result, error) {
70 issuer, sigTime, ok := openpgpIssuer(c.Signature)
71 if !ok {
72 return Result{State: BadSignature}, nil
73 }
74 key, fpr, found, err := db.PGPKeyByIssuer(issuer)
75 if err != nil {
76 return Result{}, err
77 }
78 if !found {
79 return Result{State: SignedUnknownKey, KeyFingerprint: issuer}, nil
80 }
81 res := Result{SignerUserID: key.UserID, KeyFingerprint: fpr}
82
83 // Key-state policy comes before cryptography: a revoked or expired key
84 // invalidates the trust claim no matter what the signature says, and
85 // hard revocations make the library's own verdict on such keys
86 // unpredictable.
87 now := time.Now()
88 if key.RevokedAt != nil && key.RevokedAt.Before(now) {
89 res.State = SignedKeyRevoked
90 return res, nil
91 }
92 if key.ExpiresAt != nil && key.ExpiresAt.Before(now) {
93 res.State = SignedKeyExpired
94 return res, nil
95 }
96
97 ring, err := openpgp.ReadArmoredKeyRing(bytes.NewReader([]byte(key.Armored)))
98 if err != nil {
99 return Result{}, err
100 }
101 // Verify the cryptography at the signature's own creation time: key
102 // expiry is our policy decision (above), not the library's.
103 cfg := &packet.Config{Time: func() time.Time { return sigTime }}
104 signer, err := openpgp.CheckArmoredDetachedSignature(
105 ring, bytes.NewReader(c.Payload), bytes.NewReader(c.Signature), cfg)
106 if err != nil || signer == nil {
107 res.State = BadSignature
108 return res, nil
109 }
110
111 // Author email must appear in a UID on the signing key AND be a
112 // verified address on the owning account.
113 uidMatch := false
114 for _, id := range signer.Identities {
115 if id.UserId != nil && id.UserId.Email == c.AuthorEmail {
116 uidMatch = true
117 break
118 }
119 }
120 verified, err := db.VerifiedEmails(key.UserID)
121 if err != nil {
122 return Result{}, err
123 }
124 if !uidMatch || !slices.Contains(verified, c.AuthorEmail) {
125 res.State = SignedEmailMismatch
126 return res, nil
127 }
128 res.State = Verified
129 return res, nil
130}
131
132// openpgpIssuer extracts the issuer key id (lowercase hex) and creation time
133// from an armored signature without verifying it.
134func openpgpIssuer(armored []byte) (string, time.Time, bool) {
135 block, err := decodeArmor(armored, "PGP SIGNATURE")
136 if err != nil {
137 return "", time.Time{}, false
138 }
139 p, err := packet.Read(bytes.NewReader(block))
140 if err != nil {
141 return "", time.Time{}, false
142 }
143 sig, ok := p.(*packet.Signature)
144 if !ok || sig.IssuerKeyId == nil {
145 return "", time.Time{}, false
146 }
147 return keyIDHex(*sig.IssuerKeyId), sig.CreationTime, true
148}