Update golangci-lint to v2.12.2 with canonical config (closes #60)
check / check (push) Has been cancelled
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.
This commit was merged in pull request #59.
This commit is contained in:
+149
-85
@@ -2,13 +2,45 @@ 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
|
||||
|
||||
@@ -17,22 +49,69 @@ type SigningOptions struct {
|
||||
KeyID GPGKeyID
|
||||
}
|
||||
|
||||
// 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) {
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--detach-sign",
|
||||
"--armor",
|
||||
"--local-user", string(keyID),
|
||||
)
|
||||
// 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...)
|
||||
|
||||
cmd.Stdin = bytes.NewReader(data)
|
||||
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
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -42,22 +121,15 @@ func gpgSign(data []byte, keyID GPGKeyID) ([]byte, error) {
|
||||
// gpgExportPublicKey exports the public key for the specified key ID.
|
||||
// Returns the armored public key.
|
||||
func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--export",
|
||||
"--armor",
|
||||
string(keyID),
|
||||
stdout, stderr, err := runGPG(nil,
|
||||
gpgArgs([]string{"--export", gpgOptArmor}, string(keyID))...,
|
||||
)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gpg export failed: %w: %s", err, stderr.String())
|
||||
}
|
||||
|
||||
if stdout.Len() == 0 {
|
||||
return nil, fmt.Errorf("gpg key not found: %s", keyID)
|
||||
return nil, fmt.Errorf("%w: %s", errGPGKeyNotFound, keyID)
|
||||
}
|
||||
|
||||
return stdout.Bytes(), nil
|
||||
@@ -65,30 +137,21 @@ func gpgExportPublicKey(keyID GPGKeyID) ([]byte, error) {
|
||||
|
||||
// gpgGetKeyFingerprint gets the full fingerprint for a key ID.
|
||||
func gpgGetKeyFingerprint(keyID GPGKeyID) ([]byte, error) {
|
||||
cmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
string(keyID),
|
||||
stdout, stderr, err := runGPG(nil,
|
||||
gpgArgs([]string{"--with-colons", "--fingerprint"}, string(keyID))...,
|
||||
)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, fmt.Errorf("gpg fingerprint lookup failed: %w: %s", err, stderr.String())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf(
|
||||
"gpg fingerprint lookup failed: %w: %s", err, stderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the colon-delimited output to find the fingerprint
|
||||
lines := strings.Split(stdout.String(), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||
return []byte(fields[9]), nil
|
||||
}
|
||||
fpr, ok := parseFingerprint(stdout.String())
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %s", errFingerprintNotFound, keyID)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("fingerprint not found for key: %s", keyID)
|
||||
return []byte(fpr), nil
|
||||
}
|
||||
|
||||
// gpgExtractPubKeyFingerprint imports a public key into a temporary keyring
|
||||
@@ -100,54 +163,51 @@ func gpgExtractPubKeyFingerprint(pubKey []byte) (string, error) {
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Set restrictive permissions
|
||||
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||
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")
|
||||
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||
|
||||
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
|
||||
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--import",
|
||||
pubKeyFile,
|
||||
_, importStderr, err := runGPG(nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
var importStderr bytes.Buffer
|
||||
importCmd.Stderr = &importStderr
|
||||
if err := importCmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// List keys to get fingerprint
|
||||
listCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
listStdout, listStderr, err := runGPG(nil,
|
||||
"--homedir", tmpDir,
|
||||
"--with-colons",
|
||||
"--fingerprint",
|
||||
)
|
||||
var listStdout, listStderr bytes.Buffer
|
||||
listCmd.Stdout = &listStdout
|
||||
listCmd.Stderr = &listStderr
|
||||
if err := listCmd.Run(); err != nil {
|
||||
return "", fmt.Errorf("failed to list keys: %w: %s", err, listStderr.String())
|
||||
if err != nil {
|
||||
return "", fmt.Errorf(
|
||||
"failed to list keys: %w: %s", err, listStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// Parse the colon-delimited output to find the fingerprint
|
||||
lines := strings.Split(listStdout.String(), "\n")
|
||||
for _, line := range lines {
|
||||
fields := strings.Split(line, ":")
|
||||
if len(fields) >= 10 && fields[0] == "fpr" {
|
||||
return fields[9], nil
|
||||
}
|
||||
fpr, ok := parseFingerprint(listStdout.String())
|
||||
if !ok {
|
||||
return "", errImportedFPRNotFound
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("fingerprint not found in imported key")
|
||||
return fpr, nil
|
||||
}
|
||||
|
||||
// gpgVerify verifies a detached signature against data using the provided public key.
|
||||
@@ -158,54 +218,58 @@ func gpgVerify(data, signature, pubKey []byte) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
defer func() { _ = os.RemoveAll(tmpDir) }()
|
||||
|
||||
// Set restrictive permissions
|
||||
if err := os.Chmod(tmpDir, 0o700); err != nil {
|
||||
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")
|
||||
if err := os.WriteFile(pubKeyFile, pubKey, 0o600); err != nil {
|
||||
|
||||
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")
|
||||
if err := os.WriteFile(sigFile, signature, 0o600); err != nil {
|
||||
|
||||
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")
|
||||
if err := os.WriteFile(dataFile, data, 0o600); err != nil {
|
||||
|
||||
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
|
||||
importCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--import",
|
||||
pubKeyFile,
|
||||
_, importStderr, err := runGPG(nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, "--import"}, pubKeyFile)...,
|
||||
)
|
||||
var importStderr bytes.Buffer
|
||||
importCmd.Stderr = &importStderr
|
||||
if err := importCmd.Run(); err != nil {
|
||||
return fmt.Errorf("failed to import public key: %w: %s", err, importStderr.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"failed to import public key: %w: %s", err, importStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
// Verify the signature
|
||||
verifyCmd := exec.Command("gpg", "--batch", "--no-tty",
|
||||
"--homedir", tmpDir,
|
||||
"--verify",
|
||||
sigFile,
|
||||
dataFile,
|
||||
_, verifyStderr, err := runGPG(nil,
|
||||
gpgArgs([]string{gpgOptHomedir, tmpDir, gpgOptVerify},
|
||||
sigFile, dataFile)...,
|
||||
)
|
||||
var verifyStderr bytes.Buffer
|
||||
verifyCmd.Stderr = &verifyStderr
|
||||
if err := verifyCmd.Run(); err != nil {
|
||||
return fmt.Errorf("signature verification failed: %w: %s", err, verifyStderr.String())
|
||||
if err != nil {
|
||||
return fmt.Errorf(
|
||||
"signature verification failed: %w: %s", err, verifyStderr.String(),
|
||||
)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user