diff --git a/internal/adapters/gpg/encrypt.go b/internal/adapters/gpg/encrypt.go index b9d7aa7..2a4186c 100644 --- a/internal/adapters/gpg/encrypt.go +++ b/internal/adapters/gpg/encrypt.go @@ -11,6 +11,8 @@ import ( "time" ) +const keyserverFetchTimeout = 5 * time.Second + // ListPublicKeys lists all public keys in the keyring. func (s *service) ListPublicKeys(ctx context.Context) ([]KeyInfo, error) { cmd := exec.CommandContext(ctx, "gpg", "--list-keys", "--with-colons", "--with-fingerprint") @@ -47,6 +49,12 @@ func (s *service) FindPublicKeyByEmail(ctx context.Context, email string) (*KeyI } } + // Reserved domains are never expected to resolve through public key infrastructure. + // Avoid network-dependent lookups for test-only addresses so CI stays deterministic. + if isReservedLookupEmail(email) { + return nil, fmt.Errorf("no public key found for %s (checked local keyring only; skipped remote lookup for reserved domain)", email) + } + // Step 2: Not found locally - try to fetch from key servers if fetchErr := s.fetchKeyByEmail(ctx, email); fetchErr != nil { return nil, fmt.Errorf("no public key found for %s (checked local keyring and %d key servers): %w", @@ -71,22 +79,27 @@ func (s *service) FindPublicKeyByEmail(ctx context.Context, email string) (*KeyI // fetchKeyByEmail tries to fetch a public key by email from key servers. func (s *service) fetchKeyByEmail(ctx context.Context, email string) error { // Validate email format - if _, err := mail.ParseAddress(email); err != nil { + parsed, err := mail.ParseAddress(email) + if err != nil { return fmt.Errorf("invalid email format: %q", email) } + email = strings.ToLower(parsed.Address) var lastErr error for _, server := range KeyServers { + serverCtx, cancel := context.WithTimeout(ctx, keyserverFetchTimeout) // Use --auto-key-locate with WKD (Web Key Directory) and keyserver fallback // #nosec G204 - email is validated by mail.ParseAddress above - cmd := exec.CommandContext(ctx, "gpg", "--auto-key-locate", "wkd,keyserver", "--keyserver", server, "--locate-keys", email) + cmd := exec.CommandContext(serverCtx, "gpg", "--auto-key-locate", "wkd,keyserver", "--keyserver", server, "--locate-keys", email) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + cancel() lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err) continue } + cancel() // Success return nil } @@ -94,6 +107,31 @@ func (s *service) fetchKeyByEmail(ctx context.Context, email string) error { return fmt.Errorf("failed to fetch key for %s from any server: %w", email, lastErr) } +func isReservedLookupEmail(email string) bool { + parsed, err := mail.ParseAddress(email) + if err != nil { + return false + } + + addr := strings.ToLower(parsed.Address) + at := strings.LastIndex(addr, "@") + if at == -1 || at == len(addr)-1 { + return false + } + + return isReservedLookupDomain(addr[at+1:]) +} + +func isReservedLookupDomain(domain string) bool { + domain = strings.Trim(strings.ToLower(domain), ".") + for _, suffix := range []string{"test", "example", "invalid", "localhost"} { + if domain == suffix || strings.HasSuffix(domain, "."+suffix) { + return true + } + } + return false +} + // keyMatchesEmail checks if a key contains the given email in its UIDs. func keyMatchesEmail(key *KeyInfo, email string) bool { email = strings.ToLower(email) diff --git a/internal/adapters/gpg/encrypt_test.go b/internal/adapters/gpg/encrypt_test.go index df6f106..07f6179 100644 --- a/internal/adapters/gpg/encrypt_test.go +++ b/internal/adapters/gpg/encrypt_test.go @@ -4,6 +4,7 @@ import ( "context" "strings" "testing" + "time" ) func TestEncryptData_Validation(t *testing.T) { @@ -88,7 +89,8 @@ func TestFindPublicKeyByEmail_NotFound(t *testing.T) { t.Skip("Skipping integration test") } - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() svc := NewService() // Check if GPG is available @@ -108,6 +110,29 @@ func TestFindPublicKeyByEmail_NotFound(t *testing.T) { } } +func TestIsReservedLookupDomain(t *testing.T) { + tests := []struct { + name string + domain string + want bool + }{ + {name: "test TLD", domain: "ci.test", want: true}, + {name: "example TLD", domain: "docs.example", want: true}, + {name: "invalid TLD", domain: "bad.invalid", want: true}, + {name: "localhost", domain: "svc.localhost", want: true}, + {name: "real domain", domain: "nylas.com", want: false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isReservedLookupDomain(tt.domain) + if got != tt.want { + t.Fatalf("isReservedLookupDomain(%q) = %v, want %v", tt.domain, got, tt.want) + } + }) + } +} + func TestEncryptData_Integration(t *testing.T) { if testing.Short() { t.Skip("Skipping integration test") diff --git a/internal/adapters/gpg/service.go b/internal/adapters/gpg/service.go index 05cba83..039e8ec 100644 --- a/internal/adapters/gpg/service.go +++ b/internal/adapters/gpg/service.go @@ -325,15 +325,18 @@ func (s *service) fetchKeyFromServer(ctx context.Context, keyID string) error { var lastErr error for _, server := range KeyServers { + serverCtx, cancel := context.WithTimeout(ctx, keyserverFetchTimeout) // #nosec G204 - keyID is validated by gpgKeyIDPattern.MatchString before this function is called - cmd := exec.CommandContext(ctx, "gpg", "--keyserver", server, "--recv-keys", keyID) + cmd := exec.CommandContext(serverCtx, "gpg", "--keyserver", server, "--recv-keys", keyID) var stderr bytes.Buffer cmd.Stderr = &stderr if err := cmd.Run(); err != nil { + cancel() lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err) continue } + cancel() // Success return nil }