check / check (push) Waiting to run
check and fetch --require-signature compared the required fingerprint with the first key in the manifest's embedded public key block, while gpg accepted a good signature by any key in that block. Loading a signed manifest now refuses one whose embedded block holds more than one primary key, counted as gpg reads the block, or whose signer field is not the primary key fingerprint gpg reports for the signature. --require-signature compares with the signer field, which loading has checked. Signing names and embeds the key gpg reports it signed with, so a key ID matching several keys still writes a manifest that loads. docs/FORMAT.md states what a verifier checks. Model: opus-5-5
349 lines
11 KiB
Go
349 lines
11 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
// gpgTimeout bounds every gpg run, which can otherwise wait forever on
|
|
// a passphrase prompt or a stalled gpg-agent. A minute leaves a person
|
|
// time to type a passphrase or touch a smartcard.
|
|
gpgTimeout = time.Minute
|
|
|
|
// gpgWaitDelay is how long a gpg run keeps waiting for gpg's stdout
|
|
// and stderr to close once gpg has been killed or has exited. Reading
|
|
// what gpg itself wrote takes far less; only a process gpg left behind
|
|
// holds them open longer.
|
|
gpgWaitDelay = time.Second
|
|
|
|
// privateDirPerms is the permission mode for temporary GPG home
|
|
// directories.
|
|
privateDirPerms os.FileMode = 0o700
|
|
|
|
// privateFilePerms is the permission mode for temporary key,
|
|
// signature, and data files.
|
|
privateFilePerms os.FileMode = 0o600
|
|
|
|
// gpgFingerprintField is the record type tag for fingerprint lines
|
|
// in gpg --with-colons output.
|
|
gpgFingerprintField = "fpr"
|
|
|
|
// gpgFingerprintMinFields is the minimum number of colon-separated
|
|
// fields in a gpg fingerprint record (the fingerprint is field 10).
|
|
gpgFingerprintMinFields = 10
|
|
|
|
// gpgStatusPrefix starts each status line gpg writes to the file
|
|
// descriptor named by --status-fd.
|
|
gpgStatusPrefix = "[GNUPG:]"
|
|
|
|
// gpg option names used from more than one call site.
|
|
gpgOptArmor = "--armor"
|
|
gpgOptHomedir = "--homedir"
|
|
gpgOptStatusFD = "--status-fd"
|
|
gpgOptVerify = "--verify"
|
|
)
|
|
|
|
var (
|
|
errGPGKeyNotFound = errors.New("gpg key not found")
|
|
errFingerprintNotFound = errors.New("fingerprint not found for key")
|
|
errSigningKeyCount = errors.New(
|
|
"embedded public key block must hold exactly one key")
|
|
errNotOneGoodSignature = errors.New(
|
|
"gpg did not report exactly one good signature")
|
|
errSigningKeyNotReported = errors.New(
|
|
"gpg did not report the key that made the signature")
|
|
)
|
|
|
|
// GPGKeyID represents a GPG key identifier (fingerprint or key ID).
|
|
type GPGKeyID string
|
|
|
|
// SigningOptions contains options for GPG signing.
|
|
type SigningOptions struct {
|
|
KeyID GPGKeyID
|
|
}
|
|
|
|
// gpgArgs builds a gpg argument list from opts followed by positional
|
|
// arguments, separated by an explicit "--" end-of-options marker.
|
|
//
|
|
// This matters because key IDs reach gpg as bare positional arguments
|
|
// (from --sign-key / MFER_SIGN_KEY) and gpg would otherwise parse a value
|
|
// beginning with "-" as one of its own options. Callers must route every
|
|
// non-option argument through here.
|
|
func gpgArgs(opts []string, positional ...string) []string {
|
|
args := make([]string, 0, len(opts)+1+len(positional))
|
|
args = append(args, opts...)
|
|
args = append(args, "--")
|
|
args = append(args, positional...)
|
|
|
|
return args
|
|
}
|
|
|
|
// runGPG runs the gpg binary in batch mode with the given arguments and
|
|
// optional stdin, returning captured stdout and stderr. gpg is killed when
|
|
// ctx ends or gpgTimeout passes, whichever comes first.
|
|
func runGPG(
|
|
ctx context.Context, stdin io.Reader, args ...string,
|
|
) (*bytes.Buffer, *bytes.Buffer, error) {
|
|
// exec.CommandContext kills only gpg itself. A gpg-agent that gpg
|
|
// starts runs detached and holds none of gpg's output, but another
|
|
// process gpg leaves behind (a wrapper script that runs the real gpg
|
|
// without exec, for example) can keep gpg's stdout or stderr open, and
|
|
// Run would wait for it to exit. WaitDelay stops that wait
|
|
// gpgWaitDelay after the kill; that process is left running.
|
|
ctx, cancel := context.WithTimeout(ctx, gpgTimeout)
|
|
defer cancel()
|
|
|
|
fullArgs := append([]string{"--batch", "--no-tty"}, args...)
|
|
|
|
// G204: the executable name is a compile-time constant. The arguments
|
|
// are not, so the guarantee that matters is placement: every
|
|
// caller-supplied value is passed either as the value of a named
|
|
// option or after the "--" end-of-options marker inserted by gpgArgs,
|
|
// and therefore cannot be reinterpreted by gpg as an option.
|
|
cmd := exec.CommandContext( //nolint:gosec // G204: see comment above
|
|
ctx, "gpg", fullArgs...)
|
|
cmd.WaitDelay = gpgWaitDelay
|
|
cmd.Stdin = stdin
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
if err != nil && ctx.Err() != nil {
|
|
// gpg was killed because ctx ended, which Run reports only as
|
|
// "signal: killed"; return the reason instead.
|
|
err = ctx.Err()
|
|
if errors.Is(err, context.DeadlineExceeded) {
|
|
err = fmt.Errorf("gpg timed out: %w", err)
|
|
}
|
|
}
|
|
|
|
return &stdout, &stderr, err
|
|
}
|
|
|
|
// parseFingerprint extracts the first fingerprint from gpg --with-colons
|
|
// output, or returns ok=false if none is present.
|
|
func parseFingerprint(colonOutput string) (string, bool) {
|
|
for line := range strings.SplitSeq(colonOutput, "\n") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= gpgFingerprintMinFields &&
|
|
fields[0] == gpgFingerprintField {
|
|
return fields[9], true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// parseStatusLine returns the arguments of the status line for keyword in
|
|
// gpg --status-fd output, or ok=false unless there is exactly one such line
|
|
// and it has arguments.
|
|
func parseStatusLine(statusOutput, keyword string) ([]string, bool) {
|
|
var found [][]string
|
|
|
|
for line := range strings.SplitSeq(statusOutput, "\n") {
|
|
fields := strings.Fields(line)
|
|
if len(fields) > 2 && fields[0] == gpgStatusPrefix && fields[1] == keyword {
|
|
found = append(found, fields[2:])
|
|
}
|
|
}
|
|
|
|
if len(found) != 1 {
|
|
return nil, false
|
|
}
|
|
|
|
return found[0], true
|
|
}
|
|
|
|
// gpgSign creates an armored detached signature of data with the key gpg
|
|
// picks for keyID, and returns it with the fingerprint of the key that made
|
|
// it, which is a subkey's when gpg signed with a subkey.
|
|
func gpgSign(
|
|
ctx context.Context, data []byte, keyID GPGKeyID,
|
|
) ([]byte, string, error) {
|
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-sign-*")
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to create temp dir: %w", err)
|
|
}
|
|
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
sigFile := filepath.Join(tmpDir, "signature.asc")
|
|
|
|
// The signature goes to sigFile, so --status-fd 1 can send gpg's status
|
|
// lines to stdout; its messages go to stderr.
|
|
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
|
"--detach-sign",
|
|
gpgOptArmor,
|
|
"--output", sigFile,
|
|
gpgOptStatusFD, "1",
|
|
"--local-user", string(keyID),
|
|
)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
|
}
|
|
|
|
// The last argument of SIG_CREATED is the fingerprint of the key that
|
|
// made the signature.
|
|
created, ok := parseStatusLine(stdout.String(), "SIG_CREATED")
|
|
if !ok {
|
|
return nil, "", fmt.Errorf("%w: %s", errSigningKeyNotReported, stderr.String())
|
|
}
|
|
|
|
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("failed to read signature: %w", err)
|
|
}
|
|
|
|
return sig, created[len(created)-1], nil
|
|
}
|
|
|
|
// gpgExportPublicKey exports the public key for the specified key ID.
|
|
// Returns the armored public key.
|
|
func gpgExportPublicKey(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
|
|
}
|
|
|
|
if stdout.Len() == 0 {
|
|
return nil, fmt.Errorf("%w: %s", errGPGKeyNotFound, keyID)
|
|
}
|
|
|
|
return stdout.Bytes(), nil
|
|
}
|
|
|
|
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
|
func gpgGetKeyFingerprint(ctx context.Context, keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf(
|
|
"gpg fingerprint lookup failed: %w: %s", err, stderr.String(),
|
|
)
|
|
}
|
|
|
|
fpr, ok := parseFingerprint(stdout.String())
|
|
if !ok {
|
|
return nil, fmt.Errorf("%w: %s", errFingerprintNotFound, keyID)
|
|
}
|
|
|
|
return []byte(fpr), nil
|
|
}
|
|
|
|
// gpgImportOneKey imports the public key block in pubKeyFile into the
|
|
// keyring in gpgHome. The block must hold exactly one primary key.
|
|
func gpgImportOneKey(ctx context.Context, gpgHome, pubKeyFile string) error {
|
|
// --status-fd 1 sends gpg's status lines to stdout, which importing
|
|
// otherwise leaves empty; its messages go to stderr.
|
|
importStdout, importStderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
|
|
pubKeyFile)...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to import public key: %w: %s", err, importStderr.String(),
|
|
)
|
|
}
|
|
|
|
// The first argument of IMPORT_RES counts the primary keys gpg read
|
|
// from the block, those it then skipped (one with no user ID, for
|
|
// example) included.
|
|
result, ok := parseStatusLine(importStdout.String(), "IMPORT_RES")
|
|
if !ok {
|
|
return fmt.Errorf("%w, gpg reported no count", errSigningKeyCount)
|
|
}
|
|
|
|
if result[0] != "1" {
|
|
return fmt.Errorf("%w, found %s", errSigningKeyCount, result[0])
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// gpgVerify verifies a detached signature against data using the provided
|
|
// public key, imported into a temporary keyring, and returns the
|
|
// fingerprint of the primary key that made the signature. The public key
|
|
// must hold exactly one primary key, so that a good signature can come
|
|
// from no other key.
|
|
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) (string, error) {
|
|
// Create temporary directory for GPG operations
|
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-verify-*")
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
|
}
|
|
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Set restrictive permissions
|
|
err = os.Chmod(tmpDir, privateDirPerms)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to set temp dir permissions: %w", err)
|
|
}
|
|
|
|
// Write public key to temp file
|
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
|
|
|
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write public key: %w", err)
|
|
}
|
|
|
|
// Write signature to temp file
|
|
sigFile := filepath.Join(tmpDir, "signature.asc")
|
|
|
|
err = os.WriteFile(sigFile, signature, privateFilePerms)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write signature: %w", err)
|
|
}
|
|
|
|
// Write data to temp file
|
|
dataFile := filepath.Join(tmpDir, "data")
|
|
|
|
err = os.WriteFile(dataFile, data, privateFilePerms)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to write data: %w", err)
|
|
}
|
|
|
|
err = gpgImportOneKey(ctx, tmpDir, pubKeyFile)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// --status-fd 1 sends gpg's status lines to stdout, which verifying a
|
|
// detached signature otherwise leaves empty; its messages go to stderr.
|
|
verifyStdout, verifyStderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
|
|
sigFile, dataFile)...,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
|
)
|
|
}
|
|
|
|
// gpg writes a VALIDSIG line for each good signature. Its first
|
|
// argument is the fingerprint of the key that made the signature,
|
|
// which may be a subkey; its last is that of the primary key.
|
|
valid, ok := parseStatusLine(verifyStdout.String(), "VALIDSIG")
|
|
if !ok {
|
|
return "", errNotOneGoodSignature
|
|
}
|
|
|
|
return valid[len(valid)-1], nil
|
|
}
|