Skip to content

Commit e4438af

Browse files
authored
TW-4830 Bound GPG keyserver lookups during public key discovery (#50)
1 parent 2380d9a commit e4438af

3 files changed

Lines changed: 70 additions & 4 deletions

File tree

internal/adapters/gpg/encrypt.go

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import (
1111
"time"
1212
)
1313

14+
const keyserverFetchTimeout = 5 * time.Second
15+
1416
// ListPublicKeys lists all public keys in the keyring.
1517
func (s *service) ListPublicKeys(ctx context.Context) ([]KeyInfo, error) {
1618
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
4749
}
4850
}
4951

52+
// Reserved domains are never expected to resolve through public key infrastructure.
53+
// Avoid network-dependent lookups for test-only addresses so CI stays deterministic.
54+
if isReservedLookupEmail(email) {
55+
return nil, fmt.Errorf("no public key found for %s (checked local keyring only; skipped remote lookup for reserved domain)", email)
56+
}
57+
5058
// Step 2: Not found locally - try to fetch from key servers
5159
if fetchErr := s.fetchKeyByEmail(ctx, email); fetchErr != nil {
5260
return nil, fmt.Errorf("no public key found for %s (checked local keyring and %d key servers): %w",
@@ -71,29 +79,59 @@ func (s *service) FindPublicKeyByEmail(ctx context.Context, email string) (*KeyI
7179
// fetchKeyByEmail tries to fetch a public key by email from key servers.
7280
func (s *service) fetchKeyByEmail(ctx context.Context, email string) error {
7381
// Validate email format
74-
if _, err := mail.ParseAddress(email); err != nil {
82+
parsed, err := mail.ParseAddress(email)
83+
if err != nil {
7584
return fmt.Errorf("invalid email format: %q", email)
7685
}
86+
email = strings.ToLower(parsed.Address)
7787

7888
var lastErr error
7989
for _, server := range KeyServers {
90+
serverCtx, cancel := context.WithTimeout(ctx, keyserverFetchTimeout)
8091
// Use --auto-key-locate with WKD (Web Key Directory) and keyserver fallback
8192
// #nosec G204 - email is validated by mail.ParseAddress above
82-
cmd := exec.CommandContext(ctx, "gpg", "--auto-key-locate", "wkd,keyserver", "--keyserver", server, "--locate-keys", email)
93+
cmd := exec.CommandContext(serverCtx, "gpg", "--auto-key-locate", "wkd,keyserver", "--keyserver", server, "--locate-keys", email)
8394
var stderr bytes.Buffer
8495
cmd.Stderr = &stderr
8596

8697
if err := cmd.Run(); err != nil {
98+
cancel()
8799
lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err)
88100
continue
89101
}
102+
cancel()
90103
// Success
91104
return nil
92105
}
93106

94107
return fmt.Errorf("failed to fetch key for %s from any server: %w", email, lastErr)
95108
}
96109

110+
func isReservedLookupEmail(email string) bool {
111+
parsed, err := mail.ParseAddress(email)
112+
if err != nil {
113+
return false
114+
}
115+
116+
addr := strings.ToLower(parsed.Address)
117+
at := strings.LastIndex(addr, "@")
118+
if at == -1 || at == len(addr)-1 {
119+
return false
120+
}
121+
122+
return isReservedLookupDomain(addr[at+1:])
123+
}
124+
125+
func isReservedLookupDomain(domain string) bool {
126+
domain = strings.Trim(strings.ToLower(domain), ".")
127+
for _, suffix := range []string{"test", "example", "invalid", "localhost"} {
128+
if domain == suffix || strings.HasSuffix(domain, "."+suffix) {
129+
return true
130+
}
131+
}
132+
return false
133+
}
134+
97135
// keyMatchesEmail checks if a key contains the given email in its UIDs.
98136
func keyMatchesEmail(key *KeyInfo, email string) bool {
99137
email = strings.ToLower(email)

internal/adapters/gpg/encrypt_test.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"context"
55
"strings"
66
"testing"
7+
"time"
78
)
89

910
func TestEncryptData_Validation(t *testing.T) {
@@ -88,7 +89,8 @@ func TestFindPublicKeyByEmail_NotFound(t *testing.T) {
8889
t.Skip("Skipping integration test")
8990
}
9091

91-
ctx := context.Background()
92+
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
93+
defer cancel()
9294
svc := NewService()
9395

9496
// Check if GPG is available
@@ -108,6 +110,29 @@ func TestFindPublicKeyByEmail_NotFound(t *testing.T) {
108110
}
109111
}
110112

113+
func TestIsReservedLookupDomain(t *testing.T) {
114+
tests := []struct {
115+
name string
116+
domain string
117+
want bool
118+
}{
119+
{name: "test TLD", domain: "ci.test", want: true},
120+
{name: "example TLD", domain: "docs.example", want: true},
121+
{name: "invalid TLD", domain: "bad.invalid", want: true},
122+
{name: "localhost", domain: "svc.localhost", want: true},
123+
{name: "real domain", domain: "nylas.com", want: false},
124+
}
125+
126+
for _, tt := range tests {
127+
t.Run(tt.name, func(t *testing.T) {
128+
got := isReservedLookupDomain(tt.domain)
129+
if got != tt.want {
130+
t.Fatalf("isReservedLookupDomain(%q) = %v, want %v", tt.domain, got, tt.want)
131+
}
132+
})
133+
}
134+
}
135+
111136
func TestEncryptData_Integration(t *testing.T) {
112137
if testing.Short() {
113138
t.Skip("Skipping integration test")

internal/adapters/gpg/service.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -325,15 +325,18 @@ func (s *service) fetchKeyFromServer(ctx context.Context, keyID string) error {
325325
var lastErr error
326326

327327
for _, server := range KeyServers {
328+
serverCtx, cancel := context.WithTimeout(ctx, keyserverFetchTimeout)
328329
// #nosec G204 - keyID is validated by gpgKeyIDPattern.MatchString before this function is called
329-
cmd := exec.CommandContext(ctx, "gpg", "--keyserver", server, "--recv-keys", keyID)
330+
cmd := exec.CommandContext(serverCtx, "gpg", "--keyserver", server, "--recv-keys", keyID)
330331
var stderr bytes.Buffer
331332
cmd.Stderr = &stderr
332333

333334
if err := cmd.Run(); err != nil {
335+
cancel()
334336
lastErr = fmt.Errorf("failed to fetch from %s: %w", server, err)
335337
continue
336338
}
339+
cancel()
337340
// Success
338341
return nil
339342
}

0 commit comments

Comments
 (0)