// 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" "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, 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 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') }