| 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 |