check / check (push) Waiting to run
Error messages in mfer/ and internal/cli/ are lowercase except names and acronyms, carry no "failed to" or command-name prefix, and each wrap names only the operation and thing the wrapped error does not already name, so a stacked message names what failed once. Wraps around errors that already name their operation and path (os and afero path errors, url.Error, the builder's path errors, the gpg helpers' own errors) are dropped. gpg's stderr is appended to a gpg failure, and to the error for a signing key gpg did not report, only when gpg wrote some. errHTTPStatus reads "unexpected HTTP status"; both inner-not-set sentinels read "inner message not set". No sentinel, errors.Is result or exit status changes. Model: opus-5-5
359 lines
11 KiB
Go
359 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. If gpg fails, the
|
|
// error ends with what gpg wrote to 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("timed out: %w", err)
|
|
}
|
|
}
|
|
|
|
if err != nil {
|
|
err = withStderr(err, &stderr)
|
|
}
|
|
|
|
return &stdout, &stderr, err
|
|
}
|
|
|
|
// withStderr returns err followed by what gpg wrote to stderr, or err alone
|
|
// when gpg wrote nothing.
|
|
func withStderr(err error, stderr *bytes.Buffer) error {
|
|
messages := strings.TrimSpace(stderr.String())
|
|
if messages == "" {
|
|
return err
|
|
}
|
|
|
|
return fmt.Errorf("%w: %s", err, messages)
|
|
}
|
|
|
|
// 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, "", 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: %w", err)
|
|
}
|
|
|
|
// 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, "", withStderr(errSigningKeyNotReported, stderr)
|
|
}
|
|
|
|
sig, err := os.ReadFile(sigFile) //nolint:gosec // G304: inside tmpDir, made above
|
|
if err != nil {
|
|
return nil, "", 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, _, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gpg export: %w", err)
|
|
}
|
|
|
|
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, _, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gpg fingerprint lookup: %w", err)
|
|
}
|
|
|
|
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, _, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, gpgHome, gpgOptStatusFD, "1", "--import"},
|
|
pubKeyFile)...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("gpg import: %w", err)
|
|
}
|
|
|
|
// 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 "", err
|
|
}
|
|
|
|
defer func() { _ = os.RemoveAll(tmpDir) }()
|
|
|
|
// Set restrictive permissions
|
|
err = os.Chmod(tmpDir, privateDirPerms)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Write public key to temp file
|
|
pubKeyFile := filepath.Join(tmpDir, "pubkey.asc")
|
|
|
|
err = os.WriteFile(pubKeyFile, pubKey, privateFilePerms)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Write signature to temp file
|
|
sigFile := filepath.Join(tmpDir, "signature.asc")
|
|
|
|
err = os.WriteFile(sigFile, signature, privateFilePerms)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
|
|
// Write data to temp file
|
|
dataFile := filepath.Join(tmpDir, "data")
|
|
|
|
err = os.WriteFile(dataFile, data, privateFilePerms)
|
|
if err != nil {
|
|
return "", 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, _, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptStatusFD, "1", gpgOptVerify},
|
|
sigFile, dataFile)...,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf("gpg verify: %w", err)
|
|
}
|
|
|
|
// 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
|
|
}
|