// Package drand fetches Quicknet releases directly from public drand relays // (spec §48, §49). HTTP is an untrusted transport: every response is verified // locally with provider.Verify against the pinned profile before it is // returned, and authenticity comes from the BLS signature, never from the // hostname (spec §48, §52). package drand import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "strings" "time" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/provider" ) // Limits of a single relay exchange. const ( DefaultTimeout = 6 * time.Second maxResponseSize = 8 << 10 ) // DefaultRelays returns the public drand relays used when none are given. func DefaultRelays() []string { return []string{"https://api.drand.sh", "https://api2.drand.sh", "https://api3.drand.sh"} } // Client races independent relays and returns the first release that passes // local verification. It implements provider.ReleaseSource. type Client struct { http *http.Client relays []string timeout time.Duration } var _ provider.ReleaseSource = (*Client)(nil) // New returns a client for the given relay base URLs, or DefaultRelays. // Redirects are not followed. func New(relays ...string) *Client { return NewWithHTTPClient(&http.Client{ Timeout: DefaultTimeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, relays...) } // NewWithHTTPClient is New with a caller-supplied HTTP client. func NewWithHTTPClient(hc *http.Client, relays ...string) *Client { if len(relays) == 0 { relays = DefaultRelays() } return &Client{http: hc, relays: append([]string(nil), relays...), timeout: DefaultTimeout} } // Fetch implements provider.ReleaseSource. Only a cryptographically valid // release for exactly the requested round wins the race. func (c *Client) Fetch(ctx context.Context, p *profile.Profile, cond provider.Condition) (provider.Release, error) { if p.Provider != profile.ProviderDrand { return provider.Release{}, fmt.Errorf("drand: profile %s is not a drand profile: %w", p.ID, datekeys.ErrUnknownProfile) } if cond.Round == 0 || cond.Round > p.MaxRound() { return provider.Release{}, fmt.Errorf("drand: round %d outside the range of %s: %w", cond.Round, p.ID, datekeys.ErrDateKeyInvalid) } ctx, cancel := context.WithTimeout(ctx, c.timeout) defer cancel() type result struct { release provider.Release err error } ch := make(chan result, len(c.relays)) for _, relay := range c.relays { go func(relay string) { r, err := c.fetch(ctx, relay, p, cond) ch <- result{r, err} }(relay) } var failures []error for range c.relays { select { case <-ctx.Done(): return provider.Release{}, fmt.Errorf("drand: %w: %w", datekeys.ErrReleaseUnavailable, ctx.Err()) case r := <-ch: if r.err == nil { return r.release, nil } failures = append(failures, r.err) } } return provider.Release{}, fmt.Errorf("drand: no relay returned a verified release for round %d: %w: %w", cond.Round, datekeys.ErrReleaseUnavailable, errors.Join(failures...)) } func (c *Client) fetch(ctx context.Context, relay string, p *profile.Profile, cond provider.Condition) (provider.Release, error) { url := strings.TrimRight(relay, "/") + "/v2/chains/" + p.ChainHashHex() + "/rounds/" + strconv.FormatUint(cond.Round, 10) req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) if err != nil { return provider.Release{}, fmt.Errorf("%s: %w", relay, err) } req.Header.Set("Accept", "application/json") res, err := c.http.Do(req) if err != nil { return provider.Release{}, fmt.Errorf("%s: %w", relay, err) } defer res.Body.Close() if res.StatusCode != http.StatusOK { return provider.Release{}, fmt.Errorf("%s: HTTP %d", relay, res.StatusCode) } b, err := io.ReadAll(io.LimitReader(res.Body, maxResponseSize+1)) if err != nil { return provider.Release{}, fmt.Errorf("%s: %w", relay, err) } if len(b) > maxResponseSize { return provider.Release{}, fmt.Errorf("%s: response larger than %d bytes: %w", relay, maxResponseSize, datekeys.ErrReleaseInvalid) } var wire struct { Round uint64 `json:"round"` Signature string `json:"signature"` Randomness string `json:"randomness"` } if err := json.Unmarshal(b, &wire); err != nil { return provider.Release{}, fmt.Errorf("%s: malformed response: %w", relay, datekeys.ErrReleaseInvalid) } sig, err := hex.DecodeString(wire.Signature) if err != nil { return provider.Release{}, fmt.Errorf("%s: signature is not hex: %w", relay, datekeys.ErrReleaseInvalid) } release := provider.Release{Round: wire.Round, Signature: sig} if err := provider.Verify(p, cond, release); err != nil { return provider.Release{}, fmt.Errorf("%s: %w", relay, err) } // The v2 API omits randomness; if a relay supplies it, it must be // SHA-256 of the verified signature. if wire.Randomness != "" { sum := sha256.Sum256(sig) if !strings.EqualFold(wire.Randomness, hex.EncodeToString(sum[:])) { return provider.Release{}, fmt.Errorf("%s: randomness does not match the signature: %w", relay, datekeys.ErrReleaseInvalid) } } return release, nil }