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.
260 lines
8.9 KiB
260 lines
8.9 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"
|
|
"time"
|
|
|
|
"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. 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.
|
|
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. Every key is required.
|
|
type wire struct {
|
|
Type string `cbor:"0,keyasint"`
|
|
Version uint64 `cbor:"1,keyasint"`
|
|
ID string `cbor:"2,keyasint"`
|
|
Provider string `cbor:"3,keyasint"`
|
|
Network string `cbor:"4,keyasint"`
|
|
ChainHash []byte `cbor:"5,keyasint"`
|
|
PublicKey []byte `cbor:"6,keyasint"`
|
|
Period uint64 `cbor:"7,keyasint"`
|
|
GenesisTime int64 `cbor:"8,keyasint"`
|
|
Scheme string `cbor:"9,keyasint"`
|
|
GenesisSeed []byte `cbor:"10,keyasint"`
|
|
}
|
|
|
|
// 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", p.Period)
|
|
}
|
|
return codec.Marshal(wire{
|
|
Type: TypeTag,
|
|
Version: SchemaVersion,
|
|
ID: p.ID,
|
|
Provider: p.Provider,
|
|
Network: p.Network,
|
|
ChainHash: p.ChainHash[:],
|
|
PublicKey: p.PublicKey,
|
|
Period: uint64(p.Period / time.Second),
|
|
GenesisTime: p.GenesisTime,
|
|
Scheme: p.Scheme,
|
|
GenesisSeed: p.GenesisSeed[:],
|
|
})
|
|
}
|
|
|
|
// 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); err != nil {
|
|
return nil, fmt.Errorf("profile: %w", err)
|
|
}
|
|
if len(w.ChainHash) != 32 || len(w.GenesisSeed) != 32 {
|
|
return nil, fmt.Errorf("profile: chain hash and genesis seed must be 32 bytes: %w", datekeys.ErrNonCanonicalCBOR)
|
|
}
|
|
if w.Period == 0 || 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: 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 checks the syntax of every field and, for drand profiles, that the
|
|
// scheme is supported, that the public key is a valid group element and that
|
|
// the chain hash is the drand chain-info hash of the other parameters. The
|
|
// last check is the self-verification kept from the prototype: a profile whose
|
|
// parameters do not produce its own chain hash is rejected.
|
|
func (p *Profile) Validate() error {
|
|
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.Period <= 0 || p.Period > maxPeriod || p.Period%time.Second != 0 {
|
|
return fmt.Errorf("profile %s: invalid period %s: %w", p.ID, p.Period, 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
|
|
}
|
|
switch scheme.Name {
|
|
case crypto.SigsOnG1ID, crypto.UnchainedSchemeID, crypto.ShortSigSchemeID:
|
|
default:
|
|
return fmt.Errorf("profile %s: scheme %q is not supported by tlock: %w", p.ID, scheme.Name, datekeys.ErrUnknownProfile)
|
|
}
|
|
key := scheme.KeyGroup.Point()
|
|
if err := key.UnmarshalBinary(p.PublicKey); err != nil {
|
|
return fmt.Errorf("profile %s: public key is not a %s group element: %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: %v: %w", p.ID, err, 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.
|
|
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') }
|