Some checks failed
Go package / build (push) Has been cancelled
- 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.
366 lines
12 KiB
Go
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
|
|
}
|