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
42 changes: 40 additions & 2 deletions internal/adapters/gpg/encrypt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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",
Expand All @@ -71,29 +79,59 @@ 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
}

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)
Expand Down
27 changes: 26 additions & 1 deletion internal/adapters/gpg/encrypt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"strings"
"testing"
"time"
)

func TestEncryptData_Validation(t *testing.T) {
Expand Down Expand Up @@ -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
Expand All @@ -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")
Expand Down
5 changes: 4 additions & 1 deletion internal/adapters/gpg/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
Loading