|
@@ -0,0 +1,244 @@
|
|
|
|
|
+// 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"
|
|
|
|
|
+ "io"
|
|
|
|
|
+ "strings"
|
|
|
|
|
+
|
|
|
|
|
+ "github.com/pkg/errors"
|
|
|
|
|
+ "golang.org/x/crypto/openpgp"
|
|
|
|
|
+ "golang.org/x/crypto/openpgp/armor"
|
|
|
|
|
+ "golang.org/x/crypto/openpgp/packet"
|
|
|
|
|
+)
|
|
|
|
|
+
|
|
|
|
|
+// 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
|
|
|
|
|
+}
|