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.
150 lines
5.1 KiB
150 lines
5.1 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).
|
|
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
|
|
}
|