You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
157 lines
5.3 KiB
157 lines
5.3 KiB
// 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). A response that fails is discarded; when no relay
|
|
// returns a valid release, the error of Fetch wraps
|
|
// datekeys.ErrReleaseUnavailable, the code of spec §63 step 9, and no other
|
|
// normative error: the failure of each relay is kept in its text only, for
|
|
// diagnosis.
|
|
package drand
|
|
|
|
import (
|
|
"context"
|
|
"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
|
|
wait:
|
|
for range c.relays {
|
|
select {
|
|
case <-ctx.Done():
|
|
break wait
|
|
case r := <-ch:
|
|
if r.err == nil {
|
|
return r.release, nil
|
|
}
|
|
failures = append(failures, r.err)
|
|
}
|
|
}
|
|
err := fmt.Errorf("drand: no relay returned a verified release for round %d%s: %w",
|
|
cond.Round, reasons(failures), datekeys.ErrReleaseUnavailable)
|
|
if ctx.Err() != nil {
|
|
// The context ended, before or after the relays failed because of
|
|
// it: that stays detectable with errors.Is.
|
|
return provider.Release{}, fmt.Errorf("%w: %w", err, ctx.Err())
|
|
}
|
|
return provider.Release{}, err
|
|
}
|
|
|
|
// reasons keeps the failure of each relay as text only. A relay's answer may
|
|
// break a rule of spec §63 step 10, whose code its error wraps, but Fetch
|
|
// reports ErrReleaseUnavailable alone: every error of this module wraps
|
|
// exactly one normative code.
|
|
func reasons(failures []error) string {
|
|
if len(failures) == 0 {
|
|
return ""
|
|
}
|
|
s := make([]string, len(failures))
|
|
for i, err := range failures {
|
|
s[i] = err.Error()
|
|
}
|
|
return ": " + strings.Join(s, "; ")
|
|
}
|
|
|
|
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)
|
|
}
|
|
// The answer is read as a release the caller gives, with the strict
|
|
// rules of spec v0.16, §47.1, randomness included.
|
|
release, err := provider.ParseDrandJSON(b)
|
|
if err != nil {
|
|
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
|
}
|
|
if err := provider.Verify(p, cond, release); err != nil {
|
|
return provider.Release{}, fmt.Errorf("%s: %w", relay, err)
|
|
}
|
|
return release, nil
|
|
}
|