check / check (push) Waiting to run
Every gpg run now has a one-minute deadline (gpgTimeout) on top of its caller's context and is killed when either ends. A timeout is reported as "gpg timed out" under the failing operation instead of "signal: killed". Only gpg itself is killed; WaitDelay (one second) stops the run from waiting on a process gpg left behind that still holds its output, such as a wrapper script that does not exec the real gpg. Builder.Build and Checker.ExtractEmbeddedSigningKeyFP take a context, so ToManifest's context now reaches signing and the contextcheck suppression calling signing non-cancellable is gone. Manifest loading takes no context, so its signature check is bounded by the timeout alone. Model: opus-5-5
310 lines
9.2 KiB
Go
310 lines
9.2 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
|
|
|
|
// gpg option names used from more than one call site.
|
|
gpgOptArmor = "--armor"
|
|
gpgOptHomedir = "--homedir"
|
|
gpgOptVerify = "--verify"
|
|
)
|
|
|
|
var (
|
|
errGPGKeyNotFound = errors.New("gpg key not found")
|
|
errFingerprintNotFound = errors.New("fingerprint not found for key")
|
|
errImportedFPRNotFound = errors.New("fingerprint not found in imported key")
|
|
)
|
|
|
|
// 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.Split(colonOutput, "\n") {
|
|
fields := strings.Split(line, ":")
|
|
if len(fields) >= gpgFingerprintMinFields &&
|
|
fields[0] == gpgFingerprintField {
|
|
return fields[9], true
|
|
}
|
|
}
|
|
|
|
return "", false
|
|
}
|
|
|
|
// gpgSign creates a detached signature of the data using the specified key.
|
|
// Returns the armored detached signature.
|
|
func gpgSign(ctx context.Context, data []byte, keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(ctx, bytes.NewReader(data),
|
|
"--detach-sign",
|
|
gpgOptArmor,
|
|
"--local-user", string(keyID),
|
|
)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("gpg sign failed: %w: %s", err, stderr.String())
|
|
}
|
|
|
|
return stdout.Bytes(), 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
|
|
}
|
|
|
|
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
|
// and extracts its fingerprint. This verifies the key is valid and returns
|
|
// the actual fingerprint from the key material.
|
|
func gpgExtractPubKeyFingerprint(ctx context.Context, pubKey []byte) (string, error) {
|
|
// Create temporary directory for GPG operations
|
|
tmpDir, err := os.MkdirTemp("", "mfer-gpg-fingerprint-*")
|
|
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)
|
|
}
|
|
|
|
// Import the public key into the temporary keyring
|
|
_, importStderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to import public key: %w: %s", err, importStderr.String(),
|
|
)
|
|
}
|
|
|
|
// List keys to get fingerprint
|
|
listStdout, listStderr, err := runGPG(ctx, nil,
|
|
"--homedir", tmpDir,
|
|
"--with-colons",
|
|
"--fingerprint",
|
|
)
|
|
if err != nil {
|
|
return "", fmt.Errorf(
|
|
"failed to list keys: %w: %s", err, listStderr.String(),
|
|
)
|
|
}
|
|
|
|
fpr, ok := parseFingerprint(listStdout.String())
|
|
if !ok {
|
|
return "", errImportedFPRNotFound
|
|
}
|
|
|
|
return fpr, nil
|
|
}
|
|
|
|
// gpgVerify verifies a detached signature against data using the provided public key.
|
|
// It creates a temporary keyring to import the public key for verification.
|
|
func gpgVerify(ctx context.Context, data, signature, pubKey []byte) 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)
|
|
}
|
|
|
|
// Import the public key into the temporary keyring
|
|
_, importStderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"failed to import public key: %w: %s", err, importStderr.String(),
|
|
)
|
|
}
|
|
|
|
// Verify the signature
|
|
_, verifyStderr, err := runGPG(ctx, nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
|
sigFile, dataFile)...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|