check / check (push) Has been cancelled
Adopts golangci-lint v2.12.2 and the canonical .golangci.yml (default: all), and fixes all resulting findings across the tree. Two intended behavior changes: absent MFFilePath.Mtime is handled explicitly in freshen, list and export rather than dereferenced (main panicked); gpg positional key IDs now follow an explicit -- end-of-options marker. All twelve reworded user-visible error messages restored to byte-identical parity with main and pinned by tests.
277 lines
7.8 KiB
Go
277 lines
7.8 KiB
Go
package mfer
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
// 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.
|
|
func runGPG(stdin io.Reader, args ...string) (*bytes.Buffer, *bytes.Buffer, error) {
|
|
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
|
|
context.Background(), "gpg", fullArgs...)
|
|
cmd.Stdin = stdin
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
err := cmd.Run()
|
|
|
|
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(data []byte, keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(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(keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(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(keyID GPGKeyID) ([]byte, error) {
|
|
stdout, stderr, err := runGPG(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(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(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(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(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(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(nil,
|
|
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
|
sigFile, dataFile)...,
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(
|
|
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
|
)
|
|
}
|
|
|
|
return nil
|
|
}
|