Go · 9616 bytes Raw Blame History
1 // SPDX-License-Identifier: AGPL-3.0-or-later
2
3 // Package gpgkey wraps OpenPGP public-key parsing, validation, and
4 // fingerprinting. Every settings handler, REST endpoint, and future
5 // import path that accepts user-supplied PGP keys goes through Parse
6 // so the algorithm whitelist + capability extraction lives in exactly
7 // one place.
8 //
9 // shithub mirrors GitHub's /user/gpg_keys response shape; the Parsed
10 // type carries all the fields that response needs so callers don't
11 // re-parse the armored block on read. Verification (S51 sub-PR 2) and
12 // rendering (S51 sub-PR 5) consume the same Parsed type via the sqlc
13 // row mapping.
14 package gpgkey
15
16 import (
17 "encoding/hex"
18 "fmt"
19 "strings"
20 "time"
21
22 "github.com/ProtonMail/go-crypto/openpgp"
23 "github.com/ProtonMail/go-crypto/openpgp/armor"
24 "github.com/ProtonMail/go-crypto/openpgp/packet"
25 )
26
27 // Armor block type constants per RFC 4880 §6.2.
28 const (
29 armorTypePublicKey = "PGP PUBLIC KEY BLOCK"
30 armorTypePrivateKey = "PGP PRIVATE KEY BLOCK"
31 armorTypeSignature = "PGP SIGNATURE"
32 )
33
34 // Parsed is the validated, ready-to-store representation of a user-
35 // supplied PGP public key. Mirrors the gh /user/gpg_keys response
36 // shape; the sqlc Insert path consumes this struct directly.
37 type Parsed struct {
38 // Name is the optional user-given title (gh's "name" field).
39 // Blank string when the user omitted it.
40 Name string
41
42 // Fingerprint is the canonical lowercase 40-hex SHA-1 fingerprint
43 // of the primary public key packet.
44 Fingerprint string
45
46 // KeyID is the lower 64 bits of the fingerprint, lowercase hex
47 // (16 chars). Stored denormalized for log-line lookups.
48 KeyID string
49
50 // Armored is the ASCII-armored block exactly as uploaded, round-
51 // trippable. Stored verbatim so the REST `public_key` / `raw_key`
52 // fields can be served without re-armoring.
53 Armored string
54
55 // Primary-key capability flags decoded from the primary identity's
56 // self-signature. Split per RFC 4880 §5.2.3.21 to match gh's
57 // can_encrypt_comms / can_encrypt_storage shape.
58 CanSign bool
59 CanEncryptComms bool
60 CanEncryptStorage bool
61 CanCertify bool
62 CanAuthenticate bool
63
64 // UIDs are the email addresses parsed from the entity's
65 // identities. May be empty strings for identities without an email
66 // component (gh tolerates these; we surface them as "" entries).
67 UIDs []string
68
69 // Subkeys is the per-subkey metadata used both for the user_gpg_
70 // subkeys table inserts and for the REST nested-subkeys response
71 // shape.
72 Subkeys []ParsedSubkey
73
74 // PrimaryAlgo is a short ASCII description like "ed25519" or
75 // "rsa4096". For UI display only.
76 PrimaryAlgo string
77
78 // ExpiresAt is the primary key's expiration timestamp, nil for
79 // keys that never expire.
80 ExpiresAt *time.Time
81 }
82
83 // ParsedSubkey carries per-subkey metadata for the user_gpg_subkeys
84 // table + REST response.
85 type ParsedSubkey struct {
86 Fingerprint string
87 KeyID string
88 CanSign bool
89 CanEncryptComms bool
90 CanEncryptStorage bool
91 CanCertify bool
92 ExpiresAt *time.Time
93 }
94
95 // Parse validates a user-supplied armored OpenPGP public-key block.
96 // Returns ErrPrivateKeyBlock / ErrSignatureBlock when the user pasted
97 // the wrong block type; ErrUnparseable for any other parse failure;
98 // the algorithm / expiry / no-uids errors as appropriate; or *Parsed
99 // on success.
100 //
101 // Encryption-only keys (no signing capability on the primary or any
102 // subkey) are ACCEPTED — gh parity. Surface can_sign=false in the
103 // REST response; clients can filter on the flag.
104 func Parse(name, armored string) (*Parsed, error) {
105 trimmedName := strings.TrimSpace(name)
106 if len(trimmedName) > 80 {
107 return nil, ErrNameTooLong
108 }
109 if hasControlChars(trimmedName) {
110 return nil, ErrNameControl
111 }
112
113 // Peek at the armor block type so we can produce a precise error
114 // for the private-key / signature mistakes (the most common user
115 // errors). openpgp.ReadArmoredKeyRing rejects both with a generic
116 // "no public keys found" error otherwise.
117 armored = strings.TrimLeft(armored, "\r\n\t ")
118 block, err := armor.Decode(strings.NewReader(armored))
119 if err != nil {
120 return nil, ErrUnparseable
121 }
122 switch block.Type {
123 case armorTypePublicKey:
124 // OK; parse as a key block.
125 case armorTypePrivateKey:
126 return nil, ErrPrivateKeyBlock
127 case armorTypeSignature:
128 return nil, ErrSignatureBlock
129 default:
130 return nil, ErrUnparseable
131 }
132
133 // We've consumed the armor reader above. Reparse from the original
134 // string via ReadArmoredKeyRing so we get a populated EntityList
135 // without re-implementing the packet walker.
136 entities, err := openpgp.ReadArmoredKeyRing(strings.NewReader(armored))
137 if err != nil || len(entities) == 0 {
138 return nil, ErrUnparseable
139 }
140 if len(entities) > 1 {
141 return nil, ErrMultipleEntities
142 }
143 e := entities[0]
144
145 if e.PrimaryKey == nil {
146 return nil, ErrUnparseable
147 }
148 if len(e.Identities) == 0 {
149 return nil, ErrNoIdentities
150 }
151
152 primaryAlgo, ok := algoLabel(e.PrimaryKey)
153 if !ok {
154 return nil, ErrUnsupportedAlgo
155 }
156 if e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSA ||
157 e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSASignOnly ||
158 e.PrimaryKey.PubKeyAlgo == packet.PubKeyAlgoRSAEncryptOnly {
159 bits, err := e.PrimaryKey.BitLength()
160 if err != nil {
161 return nil, ErrUnparseable
162 }
163 if int(bits) < MinRSABits {
164 return nil, ErrRSATooShort
165 }
166 }
167
168 primaryID := e.PrimaryIdentity()
169 if primaryID == nil || primaryID.SelfSignature == nil {
170 return nil, ErrUnparseable
171 }
172
173 // Expiry: a primary's KeyLifetimeSecs lives on the primary
174 // identity's self-signature. nil => never expires.
175 primaryExpires := keyExpiry(e.PrimaryKey.CreationTime, primaryID.SelfSignature.KeyLifetimeSecs)
176 if primaryExpires != nil && primaryExpires.Before(time.Now()) {
177 return nil, ErrExpired
178 }
179
180 // Capability flags decoded from the primary self-sig. When the
181 // flags subpacket is absent (some very old keys), the boolean
182 // fields on the signature default to false — gh interprets the
183 // same way so we don't need to special-case.
184 canSign, canEncComms, canEncStorage, canCertify, canAuth := capabilityFlags(primaryID.SelfSignature)
185
186 parsed := &Parsed{
187 Name: trimmedName,
188 Fingerprint: hex.EncodeToString(e.PrimaryKey.Fingerprint),
189 KeyID: fmt.Sprintf("%016x", e.PrimaryKey.KeyId),
190 Armored: strings.TrimRight(armored, "\r\n\t ") + "\n",
191 CanSign: canSign,
192 CanEncryptComms: canEncComms,
193 CanEncryptStorage: canEncStorage,
194 CanCertify: canCertify,
195 CanAuthenticate: canAuth,
196 PrimaryAlgo: primaryAlgo,
197 ExpiresAt: primaryExpires,
198 }
199
200 for uidKey := range e.Identities {
201 email := e.Identities[uidKey].UserId.Email
202 parsed.UIDs = append(parsed.UIDs, email)
203 }
204 if parsed.UIDs == nil {
205 parsed.UIDs = []string{}
206 }
207
208 for i := range e.Subkeys {
209 sk := &e.Subkeys[i]
210 if sk.PublicKey == nil || sk.Sig == nil {
211 continue
212 }
213 skCanSign, skCanEncComms, skCanEncStorage, skCanCertify, _ := capabilityFlags(sk.Sig)
214 parsed.Subkeys = append(parsed.Subkeys, ParsedSubkey{
215 Fingerprint: hex.EncodeToString(sk.PublicKey.Fingerprint),
216 KeyID: fmt.Sprintf("%016x", sk.PublicKey.KeyId),
217 CanSign: skCanSign,
218 CanEncryptComms: skCanEncComms,
219 CanEncryptStorage: skCanEncStorage,
220 CanCertify: skCanCertify,
221 ExpiresAt: keyExpiry(sk.PublicKey.CreationTime, sk.Sig.KeyLifetimeSecs),
222 })
223 }
224 if parsed.Subkeys == nil {
225 parsed.Subkeys = []ParsedSubkey{}
226 }
227
228 return parsed, nil
229 }
230
231 // algoLabel returns a short UI-friendly label for the key algorithm.
232 // Returns (label, true) when the algorithm is accepted; ("", false)
233 // to reject (DSA, Elgamal-only).
234 func algoLabel(pk *packet.PublicKey) (string, bool) {
235 switch pk.PubKeyAlgo {
236 case packet.PubKeyAlgoRSA, packet.PubKeyAlgoRSASignOnly, packet.PubKeyAlgoRSAEncryptOnly:
237 bits, _ := pk.BitLength()
238 return fmt.Sprintf("rsa%d", bits), true
239 case packet.PubKeyAlgoEdDSA:
240 return "ed25519", true
241 case packet.PubKeyAlgoECDSA:
242 return "ecdsa", true
243 case packet.PubKeyAlgoECDH:
244 // Encryption-capable elliptic. Accept; surface honestly.
245 return "ecdh", true
246 case packet.PubKeyAlgoDSA:
247 return "", false
248 case packet.PubKeyAlgoElGamal:
249 return "", false
250 }
251 return "", false
252 }
253
254 // capabilityFlags decodes the can_sign / can_encrypt_* / can_certify /
255 // can_authenticate flags from a self-signature or subkey-binding
256 // signature. The ProtonMail/go-crypto package surfaces these as
257 // individual booleans on the Signature struct.
258 func capabilityFlags(sig *packet.Signature) (canSign, canEncComms, canEncStorage, canCertify, canAuth bool) {
259 if sig == nil {
260 return
261 }
262 // When FlagsValid is false the flag subpacket was absent. RFC 4880
263 // then says implementations should infer capabilities from the key
264 // algorithm; we follow gh's behavior of treating absent flags as
265 // "no explicit capabilities asserted" and surfacing all false.
266 if !sig.FlagsValid {
267 return
268 }
269 return sig.FlagSign, sig.FlagEncryptCommunications, sig.FlagEncryptStorage, sig.FlagCertify, sig.FlagAuthenticate
270 }
271
272 // keyExpiry computes an absolute expiration time from a creation time
273 // plus an optional lifetime-in-seconds (the self-sig subpacket).
274 // Returns nil for keys that never expire.
275 func keyExpiry(creation time.Time, lifetimeSecs *uint32) *time.Time {
276 if lifetimeSecs == nil || *lifetimeSecs == 0 {
277 return nil
278 }
279 t := creation.Add(time.Duration(*lifetimeSecs) * time.Second)
280 return &t
281 }
282
283 func hasControlChars(s string) bool {
284 for _, r := range s {
285 if r < 0x20 || r == 0x7f {
286 return true
287 }
288 }
289 return false
290 }
291