repo-sync/mirror/signature.go
James Coleman 2889766a20
Some checks failed
Go package / build (push) Has been cancelled
feat(mirror): verify repository OpenPGP signatures
- Reject RPM, Debian, and Arch metadata that fails the configured signature policy before trusting its checksums or package paths.
- Preserve the last verified repository generation when signed metadata rotates inconsistently or required files remain unavailable.
- Let operators pin signer keyrings or retrieve unknown issuers from configured keyservers, including archived GnuPG v1 RSA signatures.
- Gate cached server entry points and package misses until a crawl completes under the active signature policy.
2026-09-01 18:29:37 -05:00

366 lines
12 KiB
Go

package mirror
import (
"bytes"
"context"
"crypto"
"crypto/rsa"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/ProtonMail/go-crypto/openpgp"
pgparmor "github.com/ProtonMail/go-crypto/openpgp/armor"
"github.com/ProtonMail/go-crypto/openpgp/clearsign"
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
"github.com/ProtonMail/go-crypto/openpgp/packet"
"github.com/grmrgecko/repo-sync/fetch"
)
// SignatureMode controls OpenPGP verification of repository metadata.
type SignatureMode string
const (
// SignatureOff mirrors signatures without checking them.
SignatureOff SignatureMode = "off"
// SignatureIfPresent accepts unsigned repositories but verifies every
// signature the upstream publishes.
SignatureIfPresent SignatureMode = "if-present"
// SignatureRequired refuses repository metadata without a valid signature.
SignatureRequired SignatureMode = "required"
)
// ParseSignatureMode validates a signature policy name.
func ParseSignatureMode(value string) (SignatureMode, error) {
mode := SignatureMode(strings.ToLower(strings.TrimSpace(value)))
switch mode {
case SignatureOff, SignatureIfPresent, SignatureRequired:
return mode, nil
default:
return "", fmt.Errorf("unsupported signature mode %q", value)
}
}
// signatureVerifier verifies detached signatures with local, repository, and
// optionally keyserver-provided public keys.
type signatureVerifier struct {
keys openpgp.EntityList
keyservers []string
pinned bool
}
// legacySignature is the parsed subset of an OpenPGP version 3 detached RSA
// signature needed by CentOS 7-era repositories.
type legacySignature struct {
issuer uint64
hash crypto.Hash
hashTag [2]byte
hashed []byte
signature []byte
}
// newSignatureVerifier loads the operator-configured public keys.
func newSignatureVerifier(opts *Options) (*signatureVerifier, error) {
v := &signatureVerifier{keyservers: opts.Keyservers, pinned: len(opts.GPGKeys) > 0 || len(opts.GPGKeyData) > 0}
for _, data := range opts.GPGKeyData {
keys, err := readPublicKeys(data)
if err != nil {
return nil, fmt.Errorf("parse configured GPG key: %w", err)
}
v.keys = append(v.keys, keys...)
}
for _, name := range opts.GPGKeys {
data, err := os.ReadFile(name)
if err != nil {
return nil, fmt.Errorf("read GPG key %s: %w", name, err)
}
keys, err := readPublicKeys(data)
if err != nil {
return nil, fmt.Errorf("parse GPG key %s: %w", name, err)
}
v.keys = append(v.keys, keys...)
}
return v, nil
}
// readPublicKeys accepts armored and binary OpenPGP public keyrings.
func readPublicKeys(data []byte) (openpgp.EntityList, error) {
if bytes.HasPrefix(bytes.TrimSpace(data), []byte("-----BEGIN PGP")) {
return openpgp.ReadArmoredKeyRing(bytes.NewReader(data))
}
return openpgp.ReadKeyRing(bytes.NewReader(data))
}
// decodedSignature returns the packet stream from an armored or binary
// detached signature.
func decodedSignature(data []byte) ([]byte, error) {
if !bytes.HasPrefix(bytes.TrimSpace(data), []byte("-----BEGIN PGP")) {
return data, nil
}
block, err := pgparmor.Decode(bytes.NewReader(data))
if err != nil {
return nil, err
}
if block.Type != openpgp.SignatureType {
return nil, fmt.Errorf("armor contains %q instead of a signature", block.Type)
}
return io.ReadAll(block.Body)
}
// parseLegacySignature parses the version 3 signature packet format emitted
// by GnuPG v1. Modern signature packets return ok=false for normal handling.
func parseLegacySignature(data []byte) (sig legacySignature, ok bool, err error) {
opaque, err := packet.NewOpaqueReader(bytes.NewReader(data)).Next()
if err != nil {
return sig, false, err
}
if opaque.Tag != 2 || len(opaque.Contents) == 0 || opaque.Contents[0] != 3 {
return sig, false, nil
}
body := opaque.Contents
if len(body) < 21 || body[1] != 5 {
return sig, true, errors.New("invalid OpenPGP version 3 signature packet")
}
if body[2] != byte(packet.SigTypeBinary) {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 signature type %d", body[2])
}
if body[15] != byte(packet.PubKeyAlgoRSA) && body[15] != byte(packet.PubKeyAlgoRSASignOnly) {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 public key algorithm %d", body[15])
}
hash, supported := openpgp.HashIdToHash(body[16])
if !supported || !hash.Available() {
return sig, true, fmt.Errorf("unsupported OpenPGP version 3 hash algorithm %d", body[16])
}
bits := int(binary.BigEndian.Uint16(body[19:21]))
bytesLen := (bits + 7) / 8
if bytesLen == 0 || len(body) != 21+bytesLen {
return sig, true, errors.New("invalid OpenPGP version 3 RSA signature")
}
sig.issuer = binary.BigEndian.Uint64(body[7:15])
sig.hash = hash
copy(sig.hashTag[:], body[17:19])
sig.hashed = append([]byte(nil), body[2:7]...)
sig.signature = append([]byte(nil), body[21:]...)
return sig, true, nil
}
// verifyLegacySignature verifies a version 3 binary-document RSA signature.
func verifyLegacySignature(keys openpgp.EntityList, data []byte, sig legacySignature) (*openpgp.Entity, error) {
h := sig.hash.New()
if _, err := h.Write(data); err != nil {
return nil, err
}
if _, err := h.Write(sig.hashed); err != nil {
return nil, err
}
digest := h.Sum(nil)
if !bytes.Equal(digest[:2], sig.hashTag[:]) {
return nil, errors.New("OpenPGP signature hash tag does not match")
}
for _, entity := range keys {
publicKeys := []*packet.PublicKey{entity.PrimaryKey}
for _, subkey := range entity.Subkeys {
publicKeys = append(publicKeys, subkey.PublicKey)
}
for _, key := range publicKeys {
if key == nil || key.KeyId != sig.issuer {
continue
}
pub, ok := key.PublicKey.(*rsa.PublicKey)
if !ok {
continue
}
signature := sig.signature
if size := pub.Size(); len(signature) < size {
padded := make([]byte, size)
copy(padded[size-len(signature):], signature)
signature = padded
}
if err := rsa.VerifyPKCS1v15(pub, sig.hash, digest, signature); err == nil {
return entity, nil
}
}
}
return nil, pgperrors.ErrUnknownIssuer
}
// signatureLookup identifies the signer requested by the first signature
// packet. Full fingerprints are preferred over 64-bit key IDs.
func signatureLookup(data []byte) (string, error) {
decoded, err := decodedSignature(data)
if err != nil {
return "", err
}
legacy, ok, err := parseLegacySignature(decoded)
if err != nil {
return "", err
}
if ok {
return fmt.Sprintf("%016X", legacy.issuer), nil
}
p, err := packet.NewReader(bytes.NewReader(decoded)).Next()
if err != nil {
return "", err
}
sig, ok := p.(*packet.Signature)
if !ok {
return "", errors.New("detached signature contains no signature packet")
}
if len(sig.IssuerFingerprint) > 0 {
return strings.ToUpper(hex.EncodeToString(sig.IssuerFingerprint)), nil
}
if sig.IssuerKeyId != nil {
return fmt.Sprintf("%016X", *sig.IssuerKeyId), nil
}
return "", errors.New("detached signature has no issuer")
}
// keyserverLookupURL builds a Hockeypuck-compatible exact key lookup.
func keyserverLookupURL(base, issuer string) (string, error) {
u, err := url.Parse(base)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return "", fmt.Errorf("invalid keyserver URL %q", base)
}
u.Path = strings.TrimRight(u.Path, "/") + "/pks/lookup"
q := u.Query()
q.Set("op", "get")
q.Set("options", "mr")
q.Set("search", "0x"+issuer)
u.RawQuery = q.Encode()
return u.String(), nil
}
// retrieveSigner asks configured keyservers for the signature issuer and
// ignores responses whose keys do not verify the pair.
func (v *signatureVerifier) retrieveSigner(
ctx context.Context,
signature []byte,
base openpgp.EntityList,
verify func(openpgp.EntityList) (*openpgp.Entity, error),
) (*openpgp.Entity, error) {
issuer, err := signatureLookup(signature)
if err != nil {
return nil, err
}
var errs []error
for _, server := range v.keyservers {
lookup, err := keyserverLookupURL(server, issuer)
if err != nil {
errs = append(errs, err)
continue
}
data, err := fetch.ReadURL(ctx, lookup)
if err != nil {
errs = append(errs, fmt.Errorf("fetch key %s from %s: %w", issuer, server, err))
continue
}
keys, err := readPublicKeys(data)
if err != nil {
errs = append(errs, fmt.Errorf("parse key %s from %s: %w", issuer, server, err))
continue
}
candidate := append(append(openpgp.EntityList(nil), base...), keys...)
signer, err := verify(candidate)
if err != nil {
errs = append(errs, fmt.Errorf("verify key %s from %s: %w", issuer, server, err))
continue
}
v.keys = append(v.keys, keys...)
return signer, nil
}
if len(errs) == 0 {
return nil, fmt.Errorf("signature issuer %s is unknown; configure a GPG key or keyserver", issuer)
}
return nil, errors.Join(errs...)
}
// verifyDetachedBytes verifies a detached signature over data and returns the
// matching primary-key fingerprint.
func (v *signatureVerifier) verifyDetachedBytes(ctx context.Context, data, signature, repoKey []byte) (string, error) {
keys := append(openpgp.EntityList(nil), v.keys...)
if len(repoKey) > 0 && !v.pinned {
repoKeys, err := readPublicKeys(repoKey)
if err != nil {
return "", fmt.Errorf("parse repository GPG key: %w", err)
}
keys = append(keys, repoKeys...)
}
decoded, err := decodedSignature(signature)
if err != nil {
return "", err
}
legacy, legacyOK, err := parseLegacySignature(decoded)
if err != nil {
return "", err
}
verify := func(keyring openpgp.EntityList) (*openpgp.Entity, error) {
if legacyOK {
return verifyLegacySignature(keyring, data, legacy)
}
_, signer, err := openpgp.VerifyDetachedSignature(keyring, bytes.NewReader(data), bytes.NewReader(decoded), nil)
return signer, err
}
signer, err := verify(keys)
if errors.Is(err, pgperrors.ErrUnknownIssuer) && len(v.keyservers) > 0 && !v.pinned {
signer, err = v.retrieveSigner(ctx, signature, keys, verify)
}
if err != nil {
if errors.Is(err, pgperrors.ErrUnknownIssuer) {
issuer, lookupErr := signatureLookup(signature)
if lookupErr == nil {
if v.pinned {
return "", fmt.Errorf("signature issuer %s is not present in the configured GPG keys", issuer)
}
return "", fmt.Errorf("signature issuer %s is unknown; configure a GPG key or keyserver", issuer)
}
}
return "", err
}
return strings.ToUpper(hex.EncodeToString(signer.PrimaryKey.Fingerprint)), nil
}
// verifyDetached verifies sigPath against the exact bytes at dataPath and
// returns the matching primary-key fingerprint.
func (v *signatureVerifier) verifyDetached(ctx context.Context, dataPath, sigPath, repoKeyPath string) (string, error) {
data, err := os.ReadFile(dataPath)
if err != nil {
return "", err
}
signature, err := os.ReadFile(sigPath)
if err != nil {
return "", err
}
var repoKey []byte
if repoKeyPath != "" {
repoKey, err = os.ReadFile(repoKeyPath)
if err != nil {
return "", err
}
}
return v.verifyDetachedBytes(ctx, data, signature, repoKey)
}
// verifyClearsigned verifies an OpenPGP cleartext message and returns its
// authenticated plaintext and signer fingerprint.
func (v *signatureVerifier) verifyClearsigned(ctx context.Context, data []byte) ([]byte, string, error) {
block, rest := clearsign.Decode(data)
if block == nil || len(bytes.TrimSpace(rest)) != 0 {
return nil, "", errors.New("invalid OpenPGP clearsigned message")
}
signature, err := io.ReadAll(block.ArmoredSignature.Body)
if err != nil {
return nil, "", err
}
fingerprint, err := v.verifyDetachedBytes(ctx, block.Bytes, signature, nil)
if err != nil {
return nil, "", err
}
return block.Plaintext, fingerprint, nil
}