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.
372 lines
12 KiB
372 lines
12 KiB
// Package profile implements Provider Profiles (spec §10-§13): their
|
|
// Deterministic CBOR encoding, profile_hash, validation and the locally
|
|
// pinned registry that forms the client's root of trust.
|
|
package profile
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/drand/drand/v2/common/chain"
|
|
"github.com/drand/drand/v2/crypto"
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
"g.activething.com/go/DateKeys/codec"
|
|
)
|
|
|
|
// Schema constants of the Provider Profile CBOR map (spec §11).
|
|
const (
|
|
TypeTag = "datekeys-provider-profile"
|
|
SchemaVersion = 1
|
|
)
|
|
|
|
// ProviderDrand is the only provider implemented by this module (spec §12).
|
|
const ProviderDrand = "drand"
|
|
|
|
// MaxUnixTime is 9999-12-31T23:59:59Z, the last representable instant of
|
|
// spec §15. Round times beyond it are rejected so that every effective time
|
|
// stays representable in RFC 3339 and every round computation stays within
|
|
// int64.
|
|
const MaxUnixTime int64 = 253402300799
|
|
|
|
// Field limits enforced by Validate. They are implementation limits; spec §74
|
|
// leaves the definitive field limits open. maxPeriod also keeps period within
|
|
// the 32 bits the chain-info hash gives it, the normative bound of spec
|
|
// §12.1.
|
|
const (
|
|
maxIDLen = 128
|
|
maxNameLen = 64
|
|
maxPublicKeyLen = 1024
|
|
maxPeriod = 24 * time.Hour
|
|
)
|
|
|
|
// Profile is an immutable Provider Profile (spec §10). Treat values as
|
|
// read-only; registries hand out copies.
|
|
type Profile struct {
|
|
ID string // key 2, profile_id, for example "datekeys:quicknet:v1"
|
|
Provider string // key 3, for example "drand"
|
|
Network string // key 4, provider network identifier, for example "quicknet"
|
|
ChainHash [32]byte // key 5
|
|
PublicKey []byte // key 6, provider group public key
|
|
Period time.Duration // key 7, encoded as whole seconds
|
|
GenesisTime int64 // key 8, Unix seconds
|
|
Scheme string // key 9, for example "bls-unchained-g1-rfc9380"
|
|
GenesisSeed [32]byte // key 10
|
|
}
|
|
|
|
// wire is the CBOR map of spec §11, keys 2 to 10; keys 0 and 1 are the
|
|
// constants TypeTag and SchemaVersion. Every key is required.
|
|
type wire struct {
|
|
ID string // key 2
|
|
Provider string // key 3
|
|
Network string // key 4
|
|
ChainHash []byte // key 5
|
|
PublicKey []byte // key 6
|
|
Period uint64 // key 7, 1..2^53-1
|
|
GenesisTime uint64 // key 8, 0..2^53-1
|
|
Scheme string // key 9
|
|
GenesisSeed []byte // key 10
|
|
}
|
|
|
|
// wireKeys is the number of keys of the map, all required.
|
|
const wireKeys = 11
|
|
|
|
// unbounded bounds a field only by the input: its rule carries its own error
|
|
// code (spec §57) and Validate checks it after decoding.
|
|
const unbounded = math.MaxInt
|
|
|
|
func (w *wire) encode(e *codec.Encoder) {
|
|
e.Map(wireKeys)
|
|
e.Uint(0)
|
|
e.Text(TypeTag)
|
|
e.Uint(1)
|
|
e.Uint(SchemaVersion)
|
|
e.Uint(2)
|
|
e.Text(w.ID)
|
|
e.Uint(3)
|
|
e.Text(w.Provider)
|
|
e.Uint(4)
|
|
e.Text(w.Network)
|
|
e.Uint(5)
|
|
e.Bstr(w.ChainHash)
|
|
e.Uint(6)
|
|
e.Bstr(w.PublicKey)
|
|
e.Uint(7)
|
|
e.Uint(w.Period)
|
|
e.Uint(8)
|
|
e.Uint(w.GenesisTime)
|
|
e.Uint(9)
|
|
e.Text(w.Scheme)
|
|
e.Uint(10)
|
|
e.Bstr(w.GenesisSeed)
|
|
}
|
|
|
|
// decode reads the map with every CDDL rule whose violation is
|
|
// ErrNonCanonicalCBOR; the names and the public key are left to Validate.
|
|
func (w *wire) decode(d *codec.Decoder) error {
|
|
pairs, err := d.Map(wireKeys)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if pairs != wireKeys {
|
|
return fmt.Errorf("%d keys, want all %d: %w", pairs, wireKeys, datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
for want := range uint64(wireKeys) {
|
|
k, err := d.Key()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if k != want {
|
|
return fmt.Errorf("key %d where key %d was expected: %w", k, want, datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
switch k {
|
|
case 0:
|
|
_, err = d.Text(len(TypeTag))
|
|
case 1:
|
|
_, err = d.Uint(SchemaVersion)
|
|
case 2:
|
|
w.ID, err = d.Text(unbounded)
|
|
case 3:
|
|
w.Provider, err = d.Text(unbounded)
|
|
case 4:
|
|
w.Network, err = d.Text(unbounded)
|
|
case 5:
|
|
w.ChainHash, err = d.Bstr(32, 32)
|
|
case 6:
|
|
w.PublicKey, err = d.Bstr(0, unbounded)
|
|
case 7:
|
|
// Spec §11: period in 1..2^53-1.
|
|
if w.Period, err = d.Uint(codec.MaxSafeUint); err == nil && w.Period == 0 {
|
|
err = fmt.Errorf("period 0: %w", datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
case 8:
|
|
// Spec §11: genesis_time in 0..2^53-1, unsigned.
|
|
w.GenesisTime, err = d.Uint(codec.MaxSafeUint)
|
|
case 9:
|
|
w.Scheme, err = d.Text(unbounded)
|
|
case 10:
|
|
w.GenesisSeed, err = d.Bstr(32, 32)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("key %d: %w", k, err)
|
|
}
|
|
}
|
|
return d.EndMap()
|
|
}
|
|
|
|
// Clone returns a deep copy of p.
|
|
func (p *Profile) Clone() *Profile {
|
|
c := *p
|
|
c.PublicKey = bytes.Clone(p.PublicKey)
|
|
return &c
|
|
}
|
|
|
|
// CanonicalCBOR returns the exact Deterministic CBOR bytes of spec §11.
|
|
func (p *Profile) CanonicalCBOR() ([]byte, error) {
|
|
if p.Period <= 0 || p.Period%time.Second != 0 {
|
|
return nil, fmt.Errorf("profile: period %s is not a positive whole number of seconds: %w", p.Period, datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
// Spec §11: genesis_time in 0..2^53-1. The period needs no such check:
|
|
// a time.Duration holds at most about 9.2e9 seconds.
|
|
if p.GenesisTime < 0 || p.GenesisTime > codec.MaxSafeUint {
|
|
return nil, fmt.Errorf("profile: genesis time %d outside 0..%d: %w", p.GenesisTime, int64(codec.MaxSafeUint), datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
w := wire{
|
|
ID: p.ID,
|
|
Provider: p.Provider,
|
|
Network: p.Network,
|
|
ChainHash: p.ChainHash[:],
|
|
PublicKey: p.PublicKey,
|
|
Period: uint64(p.Period / time.Second),
|
|
GenesisTime: uint64(p.GenesisTime),
|
|
Scheme: p.Scheme,
|
|
GenesisSeed: p.GenesisSeed[:],
|
|
}
|
|
var e codec.Encoder
|
|
w.encode(&e)
|
|
return e.Out()
|
|
}
|
|
|
|
// Hash returns profile_hash = SHA-256(exact_deterministic_cbor_bytes) (spec §11).
|
|
//
|
|
// A profile_hash declared by a remote party has no security value; security
|
|
// comes from the profile pinned locally (spec §11, §13).
|
|
func (p *Profile) Hash() ([32]byte, error) {
|
|
b, err := p.CanonicalCBOR()
|
|
if err != nil {
|
|
return [32]byte{}, err
|
|
}
|
|
return sha256.Sum256(b), nil
|
|
}
|
|
|
|
// Decode parses the Deterministic CBOR encoding of a Provider Profile and
|
|
// validates it. It does not make the profile trusted: only a Registry built by
|
|
// the caller does (spec §13).
|
|
func Decode(b []byte) (*Profile, error) {
|
|
if err := codec.CheckSchema(b, TypeTag, SchemaVersion); err != nil {
|
|
return nil, fmt.Errorf("profile: %w", err)
|
|
}
|
|
var w wire
|
|
if err := codec.Unmarshal(b, w.decode, w.encode); err != nil {
|
|
return nil, fmt.Errorf("profile: %w", err)
|
|
}
|
|
if w.Period > uint64(maxPeriod/time.Second) {
|
|
return nil, fmt.Errorf("profile: period %d s out of range: %w", w.Period, datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
p := &Profile{
|
|
ID: w.ID,
|
|
Provider: w.Provider,
|
|
Network: w.Network,
|
|
PublicKey: w.PublicKey,
|
|
Period: time.Duration(w.Period) * time.Second,
|
|
GenesisTime: int64(w.GenesisTime),
|
|
Scheme: w.Scheme,
|
|
}
|
|
copy(p.ChainHash[:], w.ChainHash)
|
|
copy(p.GenesisSeed[:], w.GenesisSeed)
|
|
if err := p.Validate(); err != nil {
|
|
return nil, err
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
// Validate applies rules 1 to 3 of spec §12.1 to a Profile value, in their
|
|
// order, so that it reports the code Decode reports for the encoding of the
|
|
// value (spec §69.1):
|
|
//
|
|
// 1. the schema rules a value can break (ErrNonCanonicalCBOR): a period
|
|
// that is not a whole number of seconds in 1..86400, the implementation
|
|
// limit of spec §74; a genesis time outside 0..2^53-1; a name that is not
|
|
// valid UTF-8;
|
|
// 2. the rules of each field (ErrUnknownProfile): the name alphabets and
|
|
// lengths, the public key length, genesis_time in 1..253402300798, the
|
|
// provider drand, a scheme tlock supports and a public key in the key
|
|
// group of the scheme;
|
|
// 3. the chain-hash self-check (ErrProfileMismatch): the chain hash is the
|
|
// drand chain-info hash of the other parameters, so that a profile whose
|
|
// parameters do not produce its own chain hash is rejected.
|
|
func (p *Profile) Validate() error {
|
|
if p.Period <= 0 || p.Period > maxPeriod || p.Period%time.Second != 0 {
|
|
return fmt.Errorf("profile %q: period %s is not a whole number of seconds in 1..%d: %w", p.ID, p.Period, int64(maxPeriod/time.Second), datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
if p.GenesisTime < 0 || p.GenesisTime > codec.MaxSafeUint {
|
|
return fmt.Errorf("profile %q: genesis time %d outside 0..%d: %w", p.ID, p.GenesisTime, int64(codec.MaxSafeUint), datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
for _, s := range []string{p.ID, p.Provider, p.Network, p.Scheme} {
|
|
if !utf8.ValidString(s) {
|
|
return fmt.Errorf("profile %q: name %q is not valid UTF-8: %w", p.ID, s, datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
}
|
|
if !ValidID(p.ID) {
|
|
return fmt.Errorf("profile: invalid profile_id %q: %w", p.ID, datekeys.ErrUnknownProfile)
|
|
}
|
|
if !validName(p.Provider) || !validName(p.Network) || !validName(p.Scheme) {
|
|
return fmt.Errorf("profile %s: invalid provider, network or scheme name: %w", p.ID, datekeys.ErrUnknownProfile)
|
|
}
|
|
if len(p.PublicKey) == 0 || len(p.PublicKey) > maxPublicKeyLen {
|
|
return fmt.Errorf("profile %s: invalid public key length %d: %w", p.ID, len(p.PublicKey), datekeys.ErrUnknownProfile)
|
|
}
|
|
if p.GenesisTime <= 0 || p.GenesisTime >= MaxUnixTime {
|
|
return fmt.Errorf("profile %s: invalid genesis time %d: %w", p.ID, p.GenesisTime, datekeys.ErrUnknownProfile)
|
|
}
|
|
if p.Provider != ProviderDrand {
|
|
return fmt.Errorf("profile %s: unsupported provider %q: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
|
|
}
|
|
return p.validateDrand()
|
|
}
|
|
|
|
func (p *Profile) validateDrand() error {
|
|
scheme, err := p.DrandScheme()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// Spec v0.14, §12.1: the one scheme whose release and tlock decryption
|
|
// the specification writes byte for byte.
|
|
if scheme.Name != crypto.SigsOnG1ID {
|
|
return fmt.Errorf("profile %s: scheme %q is not %s, the only scheme of V1: %w", p.ID, scheme.Name, crypto.SigsOnG1ID, datekeys.ErrUnknownProfile)
|
|
}
|
|
key := scheme.KeyGroup.Point()
|
|
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
|
|
return fmt.Errorf("profile %s: public key is not the canonical encoding of a point of the key group of %s: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
|
|
}
|
|
if key.Equal(key.Null()) {
|
|
return fmt.Errorf("profile %s: public key is the identity element: %w", p.ID, datekeys.ErrUnknownProfile)
|
|
}
|
|
info := chain.Info{
|
|
PublicKey: key,
|
|
ID: p.Network,
|
|
Period: p.Period,
|
|
Scheme: p.Scheme,
|
|
GenesisTime: p.GenesisTime,
|
|
GenesisSeed: p.GenesisSeed[:],
|
|
}
|
|
if !bytes.Equal(info.Hash(), p.ChainHash[:]) {
|
|
return fmt.Errorf("profile %s: parameters hash to chain %s, not the pinned %s: %w",
|
|
p.ID, info.HashString(), hex.EncodeToString(p.ChainHash[:]), datekeys.ErrProfileMismatch)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// DrandScheme returns a fresh drand scheme object for p. Fresh objects avoid
|
|
// sharing mutable kyber state between callers.
|
|
func (p *Profile) DrandScheme() (*crypto.Scheme, error) {
|
|
if p.Provider != ProviderDrand {
|
|
return nil, fmt.Errorf("profile %s: provider %q is not drand: %w", p.ID, p.Provider, datekeys.ErrUnknownProfile)
|
|
}
|
|
scheme, err := crypto.SchemeFromName(p.Scheme)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("profile %s: %q is not a drand scheme: %w", p.ID, p.Scheme, datekeys.ErrUnknownProfile)
|
|
}
|
|
return scheme, nil
|
|
}
|
|
|
|
// ChainHashHex returns the lowercase hexadecimal chain hash, the form used in
|
|
// tlock stanzas and drand relay URLs.
|
|
func (p *Profile) ChainHashHex() string { return hex.EncodeToString(p.ChainHash[:]) }
|
|
|
|
// MaxRound is the last round whose round time is not after MaxUnixTime
|
|
// (spec §15), or 0 when there is none.
|
|
func (p *Profile) MaxRound() uint64 {
|
|
period := int64(p.Period / time.Second)
|
|
if period <= 0 || p.GenesisTime > MaxUnixTime {
|
|
return 0
|
|
}
|
|
return uint64((MaxUnixTime-p.GenesisTime)/period) + 1
|
|
}
|
|
|
|
// ValidID reports whether s is a syntactically valid profile_id: 1 to 128
|
|
// characters from [a-z0-9:._-], starting with a letter or digit. The restricted
|
|
// alphabet keeps the dk1_ JSON form free of escapes (spec §18, §19).
|
|
func ValidID(s string) bool {
|
|
if len(s) == 0 || len(s) > maxIDLen || !alnum(s[0]) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !alnum(c) && c != ':' && c != '.' && c != '_' && c != '-' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func validName(s string) bool {
|
|
if len(s) == 0 || len(s) > maxNameLen || !alnum(s[0]) {
|
|
return false
|
|
}
|
|
for i := 0; i < len(s); i++ {
|
|
c := s[i]
|
|
if !alnum(c) && c != '.' && c != '_' && c != '-' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func alnum(c byte) bool { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') }
|