// Copyright 2025 The Gogs Authors. All rights reserved. // Use of this source code is governed by a MIT-style // license that can be found in the LICENSE file. package gpgutil import ( "bytes" "encoding/hex" "strings" "github.com/ProtonMail/go-crypto/openpgp" "github.com/ProtonMail/go-crypto/openpgp/armor" "github.com/ProtonMail/go-crypto/openpgp/packet" "github.com/pkg/errors" ) // CommitVerification represents the verification status of a Git commit signature. type CommitVerification struct { Verified bool // Whether the signature is valid Reason string // Reason for verification failure (if any) SignerKey string // Key ID or fingerprint of the signer SignerUID string // User ID (name + email) of the signer } // KeyInfo contains parsed information from a GPG public key. type KeyInfo struct { KeyID string // Short key ID (8 or 16 chars) Fingerprint string // Full 40-character fingerprint Emails []string // Email addresses in the key CanSign bool // Whether the key can sign CanEncrypt bool // Whether the key can encrypt ExpiredUnix int64 // Unix timestamp when key expires (0 if never) } // ParsePublicKey parses an ASCII-armored GPG public key and extracts key information. func ParsePublicKey(armoredKey string) (*KeyInfo, *openpgp.Entity, error) { reader := strings.NewReader(armoredKey) block, err := armor.Decode(reader) if err != nil { return nil, nil, errors.Wrap(err, "decode armored key") } keyReader := packet.NewReader(block.Body) entity, err := openpgp.ReadEntity(keyReader) if err != nil { return nil, nil, errors.Wrap(err, "read entity") } // Extract key ID and fingerprint fingerprint := hex.EncodeToString(entity.PrimaryKey.Fingerprint[:]) keyID := strings.ToUpper(fingerprint[len(fingerprint)-16:]) // Last 16 chars (8 bytes) // Extract email addresses from identities var emails []string for _, identity := range entity.Identities { if identity.UserId != nil && identity.UserId.Email != "" { emails = append(emails, identity.UserId.Email) } } // Check key capabilities canSign := entity.PrimaryKey.PubKeyAlgo.CanSign() canEncrypt := entity.PrimaryKey.PubKeyAlgo.CanEncrypt() // Check expiration var expiredUnix int64 if entity.PrimaryKey.KeyLifetimeSecs != nil && *entity.PrimaryKey.KeyLifetimeSecs > 0 { expiredUnix = entity.PrimaryKey.CreationTime.Unix() + int64(*entity.PrimaryKey.KeyLifetimeSecs) } info := &KeyInfo{ KeyID: keyID, Fingerprint: strings.ToUpper(fingerprint), Emails: emails, CanSign: canSign, CanEncrypt: canEncrypt, ExpiredUnix: expiredUnix, } return info, entity, nil } // VerifyCommitSignature verifies a Git commit signature against a keyring. func VerifyCommitSignature(signature, content string, keyring openpgp.EntityList) (*CommitVerification, error) { // Decode the signature sigReader := strings.NewReader(signature) block, err := armor.Decode(sigReader) if err != nil { return &CommitVerification{ Verified: false, Reason: "failed to decode signature", }, nil } // Verify the signature contentReader := strings.NewReader(content) signer, err := openpgp.CheckDetachedSignature(keyring, contentReader, block.Body, nil) if err != nil { return &CommitVerification{ Verified: false, Reason: "signature verification failed: " + err.Error(), }, nil } // Extract signer information var signerUID string for _, identity := range signer.Identities { if identity.UserId != nil { signerUID = identity.UserId.Name if identity.UserId.Email != "" { signerUID += " <" + identity.UserId.Email + ">" } break } } fingerprint := hex.EncodeToString(signer.PrimaryKey.Fingerprint[:]) keyID := strings.ToUpper(fingerprint[len(fingerprint)-16:]) return &CommitVerification{ Verified: true, Reason: "signature verified", SignerKey: keyID, SignerUID: signerUID, }, nil } // CreateKeyring creates an openpgp keyring from a list of ASCII-armored public keys. func CreateKeyring(armoredKeys []string) (openpgp.EntityList, error) { var keyring openpgp.EntityList for _, armoredKey := range armoredKeys { reader := strings.NewReader(armoredKey) block, err := armor.Decode(reader) if err != nil { continue // Skip invalid keys } entityList, err := openpgp.ReadKeyRing(block.Body) if err != nil { continue // Skip invalid keys } keyring = append(keyring, entityList...) } return keyring, nil } // ExtractSignature extracts the GPG signature from a Git commit object. // Git commit format includes an optional "gpgsig" header with the signature. func ExtractSignature(commitContent string) (signature string, payload string, hasSig bool) { // Look for the gpgsig header if !strings.Contains(commitContent, "gpgsig ") { return "", commitContent, false } lines := strings.Split(commitContent, "\n") var sigLines []string var payloadLines []string inSignature := false skipNext := false for i, line := range lines { if skipNext { skipNext = false continue } if strings.HasPrefix(line, "gpgsig ") { inSignature = true // First line of signature (without "gpgsig " prefix) sigLines = append(sigLines, strings.TrimPrefix(line, "gpgsig ")) // Don't include this line in payload continue } else if inSignature && strings.HasPrefix(line, " ") { // Continuation of signature (remove leading space) sigLines = append(sigLines, strings.TrimPrefix(line, " ")) continue } else if inSignature { // End of signature inSignature = false } // Add to payload payloadLines = append(payloadLines, line) // Reconstruct the commit without the gpgsig for verification if i == 0 || !strings.HasPrefix(lines[i-1], "gpgsig") { // This is a normal line } } signature = strings.Join(sigLines, "\n") payload = strings.Join(payloadLines, "\n") return signature, payload, len(sigLines) > 0 } // SerializePublicKey converts an openpgp.Entity to an ASCII-armored string. func SerializePublicKey(entity *openpgp.Entity) (string, error) { var buf bytes.Buffer w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil) if err != nil { return "", errors.Wrap(err, "armor encode") } err = entity.Serialize(w) if err != nil { _ = w.Close() return "", errors.Wrap(err, "serialize entity") } err = w.Close() if err != nil { return "", errors.Wrap(err, "close armor writer") } return buf.String(), nil } // ValidatePublicKey checks if the provided string is a valid ASCII-armored GPG public key. func ValidatePublicKey(armoredKey string) error { _, _, err := ParsePublicKey(armoredKey) return err } // CreateEntityList creates an EntityList from a single armored key string. func CreateEntityListFromKey(armoredKey string) (openpgp.EntityList, error) { reader := strings.NewReader(armoredKey) block, err := armor.Decode(reader) if err != nil { return nil, errors.Wrap(err, "decode armored key") } entityList, err := openpgp.ReadKeyRing(block.Body) if err != nil { return nil, errors.Wrap(err, "read keyring") } return entityList, nil }