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 }