Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 161 additions & 5 deletions pass/pkg/gpg/gpg.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,33 @@ import (
"strings"
)

// GPG error message patterns for detecting operation cancelled errors
const (
ErrOperationCancelled = "Operation cancelled"
ErrGPGCancelled = "gpg: cancelled"
ErrNoPinentry = "gpg-agent: no pinentry"
ErrCannotOpenTTY = "cannot open '/dev/tty'"
ErrNoTTY = "No such device or address"
)

// GPGOptions contains options for GPG operations
// This allows tests to specify batch mode, passphrase, etc.
type GPGOptions struct {
BatchMode bool
Passphrase string
Recipient string
PinentryMode string // "loopback" to bypass pinentry
AllowPrompt bool // Allow interactive passphrase prompt (default: true)
RetryOnCancel bool // Retry with loopback on operation cancelled (default: true)
}

// DefaultGPGOptions returns the default GPG options
func DefaultGPGOptions() GPGOptions {
return GPGOptions{
BatchMode: false,
PinentryMode: "",
BatchMode: false,
PinentryMode: "",
AllowPrompt: true,
RetryOnCancel: true,
}
}

Expand All @@ -32,6 +45,8 @@ func BatchGPGOptions(passphrase string) GPGOptions {
BatchMode: true,
Passphrase: passphrase,
PinentryMode: "loopback",
AllowPrompt: false,
RetryOnCancel: false,
}
}

Expand Down Expand Up @@ -93,14 +108,49 @@ func EncryptFileWithOptions(srcPath, destPath string, opts GPGOptions) error {
}

// DecryptFile decrypts a GPG file and returns the plaintext content.
// GPG will prompt for passphrase if needed (handled by gpg-agent).
// GPG will automatically handle gpg-agent and passphrase prompting.
func DecryptFile(filePath string) (string, error) {
return DecryptFileWithOptions(filePath, DefaultGPGOptions())
}

// DecryptFileWithOptions decrypts a GPG file with custom options.
// This allows for batch mode, passphrase specification, etc.
// If decryption fails with "Operation cancelled" and RetryOnCancel is true,
// it automatically retries with loopback pinentry-mode.
func DecryptFileWithOptions(filePath string, opts GPGOptions) (string, error) {
// First attempt with provided options
content, err := decryptFileAttempt(filePath, opts)
if err == nil {
return content, nil
}

// Extract stderr from the error message if it contains it
stderrStr := extractStderrFromError(err)

// Check if we should retry - check both the error and stderr content
if opts.RetryOnCancel && opts.AllowPrompt && shouldRetryDecryption(err, stderrStr) {
// Retry with loopback pinentry-mode
retryOpts := opts
retryOpts.PinentryMode = "loopback"
retryOpts.BatchMode = false // Don't use batch mode for retry

content, err = decryptFileAttempt(filePath, retryOpts)
if err == nil {
return content, nil
}
}

return "", err
}

// decryptFileResult holds the result of a decryption attempt
type decryptFileResult struct {
content string
err error
stderrStr string
}

// decryptFileAttempt performs a single decryption attempt with given options
func decryptFileAttempt(filePath string, opts GPGOptions) (string, error) {
args := []string{"--decrypt"}

// Add batch mode if requested
Expand Down Expand Up @@ -156,6 +206,109 @@ func DecryptFileWithOptions(filePath string, opts GPGOptions) (string, error) {
return output, nil
}

// shouldRetryDecryption checks if we should retry decryption with loopback pinentry-mode
// It examines both the error and stderr content to determine if a retry is appropriate
func shouldRetryDecryption(err error, stderrStr string) bool {
if err == nil {
return false
}

// Check the error message for known retryable conditions
errStr := err.Error()
if strings.Contains(errStr, ErrOperationCancelled) ||
strings.Contains(errStr, ErrGPGCancelled) ||
strings.Contains(errStr, ErrNoPinentry) ||
strings.Contains(errStr, ErrCannotOpenTTY) ||
strings.Contains(errStr, ErrNoTTY) {
return true
}

// Also check the stderr content for retryable conditions
if strings.Contains(stderrStr, ErrOperationCancelled) ||
strings.Contains(stderrStr, ErrGPGCancelled) ||
strings.Contains(stderrStr, ErrNoPinentry) ||
strings.Contains(stderrStr, ErrCannotOpenTTY) ||
strings.Contains(stderrStr, ErrNoTTY) ||
strings.Contains(stderrStr, "cannot open") ||
strings.Contains(stderrStr, "No such device or address") {
return true
}

// Also check for exit status 2 which often indicates TTY/pinentry issues
if strings.Contains(errStr, "exit status 2") {
return true
}

return false
}

// CheckGPGAgent checks if gpg-agent is running and accessible
func CheckGPGAgent() bool {
cmd := exec.Command("gpgconf", "--list-dirs", "agent-socket")
var stdout bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = nil

if err := cmd.Run(); err != nil {
return false
}

socketPath := strings.TrimSpace(stdout.String())
if socketPath == "" {
return false
}

// Check if socket file exists
if _, err := os.Stat(socketPath); err != nil {
return false
}

return true
}

// EnsureGPGAgent ensures gpg-agent is running by starting it if needed
func EnsureGPGAgent() error {
if CheckGPGAgent() {
return nil
}

// Start gpg-agent
cmd := exec.Command("gpgconf", "--launch", "gpg-agent")
if err := cmd.Run(); err != nil {
return fmt.Errorf("failed to start gpg-agent: %v", err)
}

// Verify it started
if !CheckGPGAgent() {
return fmt.Errorf("gpg-agent did not start")
}

return nil
}

// extractStderrFromError extracts stderr content from an error message
func extractStderrFromError(err error) string {
if err == nil {
return ""
}

errStr := err.Error()
// Look for "(stderr: " in the error message
if idx := strings.Index(errStr, "(stderr: "); idx != -1 {
// Extract everything after "(stderr: " until the end
stderrStart := idx + len("(stderr: ")
if stderrStart < len(errStr) {
// Remove trailing parenthesis if present
stderrContent := errStr[stderrStart:]
if strings.HasSuffix(stderrContent, ")") {
stderrContent = strings.TrimSuffix(stderrContent, ")")
}
return stderrContent
}
}
return ""
}

// extractGPGError extracts a clean error message from GPG stderr output
func extractGPGError(stderr string) string {
// Split by newlines and find the most relevant error line
Expand All @@ -168,7 +321,10 @@ func extractGPGError(stderr string) string {
if strings.Contains(line, "decryption failed") ||
strings.Contains(line, "No secret key") ||
strings.Contains(line, "bad passphrase") ||
strings.Contains(line, "not a detached signature") {
strings.Contains(line, "not a detached signature") ||
strings.Contains(line, ErrOperationCancelled) ||
strings.Contains(line, ErrCannotOpenTTY) ||
strings.Contains(line, ErrNoTTY) {
return line
}
}
Expand Down
135 changes: 135 additions & 0 deletions pass/pkg/gpg/gpg_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package gpg

import (
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -127,6 +128,11 @@ func TestExtractGPGError(t *testing.T) {
input: "",
expected: "",
},
{
name: "operation cancelled",
input: "gpg: decryption failed: Operation cancelled",
expected: "gpg: decryption failed: Operation cancelled",
},
}

for _, tt := range tests {
Expand All @@ -138,3 +144,132 @@ func TestExtractGPGError(t *testing.T) {
})
}
}

func TestShouldRetryDecryption(t *testing.T) {
tests := []struct {
name string
err error
stderr string
expected bool
}{
{
name: "operation cancelled",
err: fmt.Errorf("gpg: decryption failed: Operation cancelled"),
stderr: "",
expected: true,
},
{
name: "gpg cancelled",
err: fmt.Errorf("gpg: cancelled"),
stderr: "",
expected: true,
},
{
name: "no pinentry",
err: fmt.Errorf("gpg-agent: no pinentry"),
stderr: "",
expected: true,
},
{
name: "no secret key",
err: fmt.Errorf("gpg: No secret key"),
stderr: "",
expected: false,
},
{
name: "bad passphrase",
err: fmt.Errorf("gpg: Bad passphrase"),
stderr: "",
expected: false,
},
{
name: "nil error",
err: nil,
stderr: "",
expected: false,
},
{
name: "other error",
err: fmt.Errorf("some other error"),
stderr: "",
expected: false,
},
{
name: "exit status 2",
err: fmt.Errorf("pass: GPG decryption failed: exit status 2"),
stderr: "",
expected: true,
},
{
name: "tty error in stderr",
err: fmt.Errorf("pass: GPG decryption failed: exit status 2"),
stderr: "gpg: cannot open '/dev/tty': No such device or address",
expected: true,
},
{
name: "operation cancelled in message",
err: fmt.Errorf("pass: decryption failed: gpg: decryption failed: Operation cancelled"),
stderr: "",
expected: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := shouldRetryDecryption(tt.err, tt.stderr)
if result != tt.expected {
t.Errorf("shouldRetryDecryption(%v, %q) = %v, want %v", tt.err, tt.stderr, result, tt.expected)
}
})
}
}

func TestCheckGPGAgent(t *testing.T) {
// Test with current environment
isRunning := CheckGPGAgent()
t.Logf("gpg-agent is running: %v", isRunning)

// If running, we can test the positive case
// If not running, we can only test the negative case
if !isRunning {
// This is expected in some test environments
t.Log("gpg-agent is not running in test environment (expected)")
}
}

func TestDefaultGPGOptions(t *testing.T) {
opts := DefaultGPGOptions()

if opts.BatchMode {
t.Error("BatchMode should be false by default")
}
if opts.PinentryMode != "" {
t.Errorf("PinentryMode should be empty by default, got %q", opts.PinentryMode)
}
if !opts.AllowPrompt {
t.Error("AllowPrompt should be true by default")
}
if !opts.RetryOnCancel {
t.Error("RetryOnCancel should be true by default")
}
}

func TestBatchGPGOptions(t *testing.T) {
opts := BatchGPGOptions("test-passphrase")

if !opts.BatchMode {
t.Error("BatchMode should be true for batch options")
}
if opts.Passphrase != "test-passphrase" {
t.Errorf("Passphrase should be 'test-passphrase', got %q", opts.Passphrase)
}
if opts.PinentryMode != "loopback" {
t.Errorf("PinentryMode should be 'loopback' for batch options, got %q", opts.PinentryMode)
}
if opts.AllowPrompt {
t.Error("AllowPrompt should be false for batch options")
}
if opts.RetryOnCancel {
t.Error("RetryOnCancel should be false for batch options")
}
}
Loading
Loading