178 lines
3.6 KiB
Go
178 lines
3.6 KiB
Go
// ©AngelaMos | 2026
|
|
// client.go
|
|
|
|
package hibp
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"crypto/sha1"
|
|
"fmt"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
lru "github.com/hashicorp/golang-lru/v2"
|
|
"github.com/sony/gobreaker/v2"
|
|
"golang.org/x/time/rate"
|
|
)
|
|
|
|
type Result struct {
|
|
Breached bool
|
|
Count int
|
|
}
|
|
|
|
type Client struct {
|
|
httpClient *http.Client
|
|
baseURL string
|
|
cache *lru.Cache[string, Result]
|
|
breaker *gobreaker.CircuitBreaker[Result]
|
|
limiter *rate.Limiter
|
|
}
|
|
|
|
func NewClient() *Client {
|
|
cache, err := lru.New[string, Result](10000)
|
|
if err != nil {
|
|
panic(fmt.Errorf("create HIBP cache: %w", err))
|
|
}
|
|
|
|
breaker := gobreaker.NewCircuitBreaker[Result](
|
|
gobreaker.Settings{
|
|
Name: "hibp",
|
|
MaxRequests: 3,
|
|
Interval: 30 * time.Second,
|
|
Timeout: 60 * time.Second,
|
|
ReadyToTrip: func(
|
|
counts gobreaker.Counts,
|
|
) bool {
|
|
return counts.ConsecutiveFailures > 5
|
|
},
|
|
},
|
|
)
|
|
|
|
return &Client{
|
|
httpClient: &http.Client{
|
|
Timeout: 10 * time.Second,
|
|
},
|
|
baseURL: "https://api.pwnedpasswords.com/range/",
|
|
cache: cache,
|
|
breaker: breaker,
|
|
limiter: rate.NewLimiter(rate.Every(200*time.Millisecond), 5),
|
|
}
|
|
}
|
|
|
|
func (c *Client) Check(
|
|
ctx context.Context, secret string,
|
|
) (Result, error) {
|
|
hash := sha1Hash(secret)
|
|
prefix := hash[:5]
|
|
suffix := hash[5:]
|
|
|
|
if cached, ok := c.cache.Get(hash); ok {
|
|
return cached, nil
|
|
}
|
|
|
|
if err := c.limiter.Wait(ctx); err != nil {
|
|
return Result{}, fmt.Errorf("rate limit: %w", err)
|
|
}
|
|
|
|
result, err := c.breaker.Execute(func() (Result, error) {
|
|
return c.queryAPI(ctx, prefix, suffix)
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
|
|
c.cache.Add(hash, result)
|
|
return result, nil
|
|
}
|
|
|
|
const maxRetries = 3
|
|
|
|
func (c *Client) queryAPI(
|
|
ctx context.Context, prefix, suffix string,
|
|
) (Result, error) {
|
|
url := c.baseURL + prefix
|
|
|
|
for attempt := range maxRetries {
|
|
if attempt > 0 {
|
|
backoff := time.Duration(attempt) * 2 * time.Second
|
|
select {
|
|
case <-ctx.Done():
|
|
return Result{}, ctx.Err()
|
|
case <-time.After(backoff):
|
|
}
|
|
}
|
|
|
|
result, retry, err := c.doQuery(ctx, url, suffix)
|
|
if !retry {
|
|
return result, err
|
|
}
|
|
}
|
|
|
|
return Result{}, fmt.Errorf("HIBP API: retries exhausted (429)")
|
|
}
|
|
|
|
func (c *Client) doQuery(
|
|
ctx context.Context, url, suffix string,
|
|
) (Result, bool, error) {
|
|
req, err := http.NewRequestWithContext(
|
|
ctx, http.MethodGet, url, nil,
|
|
)
|
|
if err != nil {
|
|
return Result{}, false,
|
|
fmt.Errorf("create request: %w", err)
|
|
}
|
|
req.Header.Set("User-Agent", "portia-secrets-scanner/1.0")
|
|
req.Header.Set("Add-Padding", "true")
|
|
|
|
resp, err := c.httpClient.Do(req) //nolint:gosec
|
|
if err != nil {
|
|
return Result{}, false,
|
|
fmt.Errorf("HIBP request: %w", err)
|
|
}
|
|
defer resp.Body.Close() //nolint:errcheck
|
|
|
|
if resp.StatusCode == http.StatusTooManyRequests {
|
|
return Result{}, true, nil
|
|
}
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Result{}, false, fmt.Errorf(
|
|
"HIBP API status: %d", resp.StatusCode,
|
|
)
|
|
}
|
|
|
|
scanner := bufio.NewScanner(resp.Body)
|
|
upperSuffix := strings.ToUpper(suffix)
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
parts := strings.SplitN(line, ":", 2)
|
|
if len(parts) != 2 {
|
|
continue
|
|
}
|
|
if parts[0] == upperSuffix {
|
|
trimmed := strings.TrimSpace(parts[1])
|
|
count, err := strconv.Atoi(trimmed)
|
|
if err != nil {
|
|
return Result{}, false, fmt.Errorf(
|
|
"parse breach count %q: %w",
|
|
trimmed,
|
|
err,
|
|
)
|
|
}
|
|
return Result{Breached: true, Count: count},
|
|
false, nil
|
|
}
|
|
}
|
|
|
|
return Result{Breached: false, Count: 0}, false, nil
|
|
}
|
|
|
|
func sha1Hash(s string) string {
|
|
h := sha1.New()
|
|
h.Write([]byte(s))
|
|
return fmt.Sprintf("%X", h.Sum(nil))
|
|
}
|