check / check (push) Waiting to run
mfer ran the gpg binary to sign, export keys and verify, so signing and loading signed manifests failed wherever gpg is missing. It now uses github.com/ProtonMail/go-crypto/openpgp. --sign-key and MFER_SIGN_KEY name a file holding one version 4 OpenPGP secret key; a protected key's passphrase comes from MFER_SIGN_KEY_PASSPHRASE or a terminal prompt. gen and freshen check that the key can sign before they read any file. Verification keeps the rules of the --require-signature fix: one primary key in the embedded block, counted from its packets, exactly one signature, made by that key or a subkey, and signer equal to its fingerprint. A DSA key is refused, and so is an armored field that is not one well-formed block. Model: opus-5-5
364 lines
10 KiB
Go
364 lines
10 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/ProtonMail/go-crypto/openpgp"
|
|
"github.com/ProtonMail/go-crypto/openpgp/armor"
|
|
pgperrors "github.com/ProtonMail/go-crypto/openpgp/errors"
|
|
"github.com/ProtonMail/go-crypto/openpgp/packet"
|
|
)
|
|
|
|
const (
|
|
// The tags of OpenPGP signature, key and subkey packets (RFC 9580,
|
|
// section 5). Subkeys have tags of their own, so each secret or public
|
|
// key packet is one primary key.
|
|
signaturePacketTag = 2
|
|
secretKeyPacketTag = 5
|
|
publicKeyPacketTag = 6
|
|
secretSubkeyPacketTag = 7
|
|
publicSubkeyPacketTag = 14
|
|
|
|
// In the body of a key or subkey packet the algorithm octet follows
|
|
// the version octet and the four-octet creation time, and from version
|
|
// 5 on a four-octet length as well (RFC 9580, section 5.5.2).
|
|
keyAlgorithmOffset = 5
|
|
firstKeyVersionWithLength = 5
|
|
keyAlgorithmOffsetAfterLength = 9
|
|
|
|
// signingKeyVersion is the only OpenPGP key version mfer signs with.
|
|
// Its fingerprints are 40 hex characters, the length
|
|
// --require-signature takes.
|
|
signingKeyVersion = 4
|
|
|
|
// armorBegin and armorEnd start the lines that begin and end an
|
|
// armored block.
|
|
armorBegin = "-----BEGIN "
|
|
armorEnd = "-----END "
|
|
)
|
|
|
|
var (
|
|
errKeyCount = errors.New("must hold exactly one key")
|
|
errDSAKey = errors.New("must not hold a DSA key")
|
|
errNoSecretKey = errors.New("signing key file holds no secret key")
|
|
errNotV4Key = errors.New("signing key must be an OpenPGP version 4 key, " +
|
|
"the only kind whose fingerprint --require-signature takes")
|
|
errNoPassphrase = errors.New(
|
|
"signing key is protected and no passphrase was given")
|
|
errNotOneSignature = errors.New(
|
|
"signature must hold exactly one signature")
|
|
errNotOneArmoredBlock = errors.New(
|
|
"must be exactly one armored block and nothing else")
|
|
errMalformedArmor = errors.New("armor is malformed")
|
|
)
|
|
|
|
// SigningOptions holds the key a manifest is signed with.
|
|
type SigningOptions struct {
|
|
// SecretKey is an OpenPGP secret key, armored or binary, as
|
|
// gpg --export-secret-keys writes it. It must hold one primary key.
|
|
SecretKey []byte
|
|
// Passphrase unlocks SecretKey when it is protected.
|
|
Passphrase []byte
|
|
}
|
|
|
|
// SecretKeyIsProtected reports whether the OpenPGP secret key secretKey,
|
|
// armored or binary, needs a passphrase to sign. It fails unless
|
|
// secretKey holds one version 4 primary key with its secret key.
|
|
func SecretKeyIsProtected(secretKey []byte) (bool, error) {
|
|
key, err := readSecretKey(secretKey)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
return isProtected(key), nil
|
|
}
|
|
|
|
// CheckSigningKey fails unless opts can sign now: opts.SecretKey must hold
|
|
// one version 4 primary key with a secret key that may sign and has not
|
|
// expired or been revoked, and opts.Passphrase must unlock it when it is
|
|
// protected. It lets a caller find a key that cannot sign before it builds
|
|
// a manifest.
|
|
func CheckSigningKey(opts *SigningOptions) error {
|
|
key, err := readSigningKey(opts)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Signing nothing fails wherever signing the manifest would.
|
|
err = openpgp.DetachSign(io.Discard, key, bytes.NewReader(nil), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("signing key cannot sign: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// readSigningKey returns the key in opts.SecretKey, unlocked with
|
|
// opts.Passphrase if it is protected.
|
|
func readSigningKey(opts *SigningOptions) (*openpgp.Entity, error) {
|
|
key, err := readSecretKey(opts.SecretKey)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if !isProtected(key) {
|
|
return key, nil
|
|
}
|
|
|
|
if len(opts.Passphrase) == 0 {
|
|
return nil, errNoPassphrase
|
|
}
|
|
|
|
err = key.DecryptPrivateKeys(opts.Passphrase)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("unlock signing key: %w", err)
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// readSecretKey returns the one key in secretKey, armored or binary,
|
|
// which must be a version 4 key and include its secret key.
|
|
func readSecretKey(secretKey []byte) (*openpgp.Entity, error) {
|
|
key, err := readOneKey(secretKey, "signing key file")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if key.PrimaryKey.Version != signingKeyVersion {
|
|
return nil, fmt.Errorf("%w; this key is version %d",
|
|
errNotV4Key, key.PrimaryKey.Version)
|
|
}
|
|
|
|
if key.PrivateKey == nil {
|
|
return nil, errNoSecretKey
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
// readOneKey returns the key in data, armored or binary, which must hold
|
|
// exactly one primary key and no DSA key or subkey. what names data in
|
|
// errors.
|
|
func readOneKey(data []byte, what string) (*openpgp.Entity, error) {
|
|
packets, err := dearmor(data)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
|
}
|
|
|
|
keys, err := countPackets(packets, secretKeyPacketTag, publicKeyPacketTag)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
|
}
|
|
|
|
if keys != 1 {
|
|
return nil, fmt.Errorf("%s %w, found %d", what, errKeyCount, keys)
|
|
}
|
|
|
|
// openpgp.ReadKeyRing checks every self-signature, and a DSA key with
|
|
// very large numbers makes each check take seconds to minutes.
|
|
dsa, err := holdsDSAKey(packets)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
|
}
|
|
|
|
if dsa {
|
|
return nil, fmt.Errorf("%s %w", what, errDSAKey)
|
|
}
|
|
|
|
keyring, err := openpgp.ReadKeyRing(bytes.NewReader(packets))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read %s: %w", what, err)
|
|
}
|
|
|
|
// openpgp.ReadKeyRing also reads a subkey packet at the start as a
|
|
// primary key.
|
|
if len(keyring) != 1 {
|
|
return nil, fmt.Errorf("%s %w, found %d", what, errKeyCount, len(keyring))
|
|
}
|
|
|
|
return keyring[0], nil
|
|
}
|
|
|
|
// isProtected reports whether any secret key in key needs a passphrase.
|
|
func isProtected(key *openpgp.Entity) bool {
|
|
if key.PrivateKey.Encrypted {
|
|
return true
|
|
}
|
|
|
|
for _, subkey := range key.Subkeys {
|
|
if subkey.PrivateKey != nil && subkey.PrivateKey.Encrypted {
|
|
return true
|
|
}
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
// armoredPublicKey returns the public part of key, armored.
|
|
func armoredPublicKey(key *openpgp.Entity) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
|
|
w, err := armor.Encode(&buf, openpgp.PublicKeyType, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
err = key.Serialize(w)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("write public key: %w", err)
|
|
}
|
|
|
|
err = w.Close()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
// fingerprint returns the fingerprint of key's primary key in upper-case
|
|
// hex, as gpg prints it.
|
|
func fingerprint(key *openpgp.Entity) string {
|
|
return strings.ToUpper(hex.EncodeToString(key.PrimaryKey.Fingerprint))
|
|
}
|
|
|
|
// verifySignature checks that signature is one good OpenPGP signature
|
|
// over data, made by the one primary key in pubKey or one of its subkeys,
|
|
// and returns that primary key's fingerprint. signature and pubKey may each
|
|
// be armored or binary.
|
|
func verifySignature(data, signature, pubKey []byte) (string, error) {
|
|
key, err := readOneKey(pubKey, "embedded public key block")
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
sigData, err := dearmor(signature)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read signature: %w", err)
|
|
}
|
|
|
|
sigs, err := countPackets(sigData, signaturePacketTag)
|
|
if err != nil {
|
|
return "", fmt.Errorf("read signature: %w", err)
|
|
}
|
|
|
|
if sigs != 1 {
|
|
return "", fmt.Errorf("%w, found %d", errNotOneSignature, sigs)
|
|
}
|
|
|
|
_, err = openpgp.CheckDetachedSignature(openpgp.EntityList{key},
|
|
bytes.NewReader(data), bytes.NewReader(sigData), nil)
|
|
// A manifest outlives its signing key, so a signature by a key that
|
|
// has expired since is still good.
|
|
if err != nil && !errors.Is(err, pgperrors.ErrKeyExpired) {
|
|
return "", fmt.Errorf("verify signature: %w", err)
|
|
}
|
|
|
|
return fingerprint(key), nil
|
|
}
|
|
|
|
// dearmor returns the binary OpenPGP data in data: data itself when it is
|
|
// not armored, or else the body of its armored block. Armored data must be
|
|
// one block and nothing else: its first line is the only BEGIN line and
|
|
// its last line the only END line, white space around them aside.
|
|
// armor.Decode skips any text before a BEGIN line and reads only the first
|
|
// block, so without this a second key or signature would go unseen.
|
|
func dearmor(data []byte) ([]byte, error) {
|
|
if !bytes.Contains(data, []byte(armorBegin)) {
|
|
return data, nil
|
|
}
|
|
|
|
text := bytes.TrimSpace(data)
|
|
lastLine := text[bytes.LastIndexByte(text, '\n')+1:]
|
|
|
|
if !bytes.HasPrefix(text, []byte(armorBegin)) ||
|
|
bytes.Count(text, []byte(armorBegin)) != 1 ||
|
|
!bytes.HasPrefix(lastLine, []byte(armorEnd)) ||
|
|
bytes.Count(text, []byte(armorEnd)) != 1 {
|
|
return nil, errNotOneArmoredBlock
|
|
}
|
|
|
|
// armor.Decode passes over a block it cannot read, such as one with a
|
|
// header line that has no colon, and returns io.EOF on finding no
|
|
// other.
|
|
block, err := armor.Decode(bytes.NewReader(text))
|
|
if err != nil {
|
|
return nil, errMalformedArmor
|
|
}
|
|
|
|
body, err := io.ReadAll(block.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("%w: %w", errMalformedArmor, err)
|
|
}
|
|
|
|
return body, nil
|
|
}
|
|
|
|
// countPackets returns how many packets in the binary OpenPGP data have
|
|
// one of tags. It reads only each packet's header, so it also counts
|
|
// packets that openpgp.ReadKeyRing skips, such as a key with no user ID
|
|
// or of an algorithm it does not know.
|
|
func countPackets(data []byte, tags ...uint8) (int, error) {
|
|
packets := packet.NewOpaqueReader(bytes.NewReader(data))
|
|
count := 0
|
|
|
|
for {
|
|
p, err := packets.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return count, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if slices.Contains(tags, p.Tag) {
|
|
count++
|
|
}
|
|
}
|
|
}
|
|
|
|
// holdsDSAKey reports whether any key or subkey packet in the binary
|
|
// OpenPGP data holds a DSA key. It reads each packet's algorithm octet
|
|
// rather than parsing the packet, since parsing a secret key packet checks
|
|
// its numbers, which for a DSA key with very large numbers is as slow as
|
|
// checking a self-signature.
|
|
func holdsDSAKey(data []byte) (bool, error) {
|
|
packets := packet.NewOpaqueReader(bytes.NewReader(data))
|
|
|
|
for {
|
|
p, err := packets.Next()
|
|
if errors.Is(err, io.EOF) {
|
|
return false, nil
|
|
}
|
|
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
|
|
if !slices.Contains([]uint8{
|
|
secretKeyPacketTag, publicKeyPacketTag,
|
|
secretSubkeyPacketTag, publicSubkeyPacketTag,
|
|
}, p.Tag) {
|
|
continue
|
|
}
|
|
|
|
offset := keyAlgorithmOffset
|
|
if len(p.Contents) > 0 && p.Contents[0] >= firstKeyVersionWithLength {
|
|
offset = keyAlgorithmOffsetAfterLength
|
|
}
|
|
|
|
if len(p.Contents) > offset &&
|
|
packet.PublicKeyAlgorithm(p.Contents[offset]) == packet.PubKeyAlgoDSA {
|
|
return true, nil
|
|
}
|
|
}
|
|
}
|