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.
DateKeys/datekey/datekey.go

259 lines
8.8 KiB

// Package datekey implements DateKeys (spec §14-§19): the local resolution of
// an instant to a provider condition and the canonical dk1_ representation.
//
// A DateKey is public. It is not a symmetric key, not a private key, not a
// .dkk and not a secret (spec §14).
package datekey
import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/profile"
)
// Prefix and JSON version of the V1 representation (spec §18).
const (
Prefix = "dk1_"
Version = 1
)
// MaxRound is the largest round accepted in a dk1_ string, 2^53-1, so that
// every implementation, including JSON parsers that use IEEE 754 doubles,
// reads the same integer. Profiles impose a lower bound through
// profile.Profile.MaxRound.
const MaxRound = 1<<53 - 1
// MaxEncodedLen bounds the input accepted by Parse, checked before decoding.
const MaxEncodedLen = 256
// DateKey is the public descriptor of a time condition: a profile and, for
// Quicknet, a round (spec §9, §14).
type DateKey struct {
ProfileID string
Round uint64
}
// Resolve returns the DateKey of the first round whose round time is at or
// after at (spec §15). The comparison uses the full precision of at: an
// instant one nanosecond after a round boundary resolves to the next round.
// Rounding backwards never happens.
//
// Resolve needs no network. It accepts past instants, which is useful for
// lookups; callers creating new capsules must require a future instant.
func Resolve(p *profile.Profile, at time.Time) (DateKey, error) {
period := int64(p.Period / time.Second)
if period <= 0 || p.Period%time.Second != 0 {
return DateKey{}, fmt.Errorf("datekey: profile %s has no whole-second period: %w", p.ID, datekeys.ErrUnknownProfile)
}
secs := at.Unix()
if secs < p.GenesisTime {
return DateKey{}, fmt.Errorf("datekey: %s is before the genesis of %s: %w",
at.UTC().Format(time.RFC3339Nano), p.ID, datekeys.ErrDateKeyInvalid)
}
if secs > profile.MaxUnixTime {
return DateKey{}, fmt.Errorf("datekey: %s is after 9999-12-31T23:59:59Z: %w",
at.UTC().Format(time.RFC3339Nano), datekeys.ErrDateKeyInvalid)
}
delta := secs - p.GenesisTime
// candidate = floor((timestamp - genesis_time) / period) + 1
candidate := uint64(delta/period) + 1
// if round_time(candidate) < requested_unlock_at: candidate++
// round_time(candidate) is a whole second <= secs, so it is earlier than at
// unless it equals secs and at has no fractional part.
if delta%period != 0 || at.Nanosecond() != 0 {
candidate++
}
d := DateKey{ProfileID: p.ID, Round: candidate}
if err := d.Validate(p); err != nil {
return DateKey{}, err
}
return d, nil
}
// RoundTime returns round_time(r) = genesis_time + (r - 1) * period (spec §15).
func RoundTime(p *profile.Profile, round uint64) (time.Time, error) {
if round == 0 || round > p.MaxRound() {
return time.Time{}, fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
}
period := int64(p.Period / time.Second)
return time.Unix(p.GenesisTime+int64(round-1)*period, 0).UTC(), nil
}
// Validate checks that d belongs to p and that its round is in p's range.
func (d DateKey) Validate(p *profile.Profile) error {
if d.ProfileID != p.ID {
return fmt.Errorf("datekey: profile %q, expected %q: %w", d.ProfileID, p.ID, datekeys.ErrProfileMismatch)
}
if d.Round == 0 || d.Round > p.MaxRound() || d.Round > MaxRound {
return fmt.Errorf("datekey: round %d outside 1..%d of %s: %w", d.Round, p.MaxRound(), p.ID, datekeys.ErrDateKeyInvalid)
}
return nil
}
// UnlockAt returns the effective unlock time of d under p, or the zero time if
// d is not valid for p.
func (d DateKey) UnlockAt(p *profile.Profile) time.Time {
if d.Validate(p) != nil {
return time.Time{}
}
t, _ := RoundTime(p, d.Round)
return t
}
// CanonicalJSON returns the canonical JSON payload of spec §18, for example
// {"version":1,"network":"datekeys:quicknet:v1","round":66884212}, or nil if
// d is not syntactically valid.
func (d DateKey) CanonicalJSON() []byte {
if !d.valid() {
return nil
}
// ProfileID is restricted to [a-z0-9:._-], so no JSON escaping is needed.
return fmt.Appendf(nil, `{"version":%d,"network":"%s","round":%d}`, Version, d.ProfileID, d.Round)
}
// Compact returns the canonical dk1_ string (spec §18), or "" if d is not
// syntactically valid.
func (d DateKey) Compact() string {
j := d.CanonicalJSON()
if j == nil {
return ""
}
return Prefix + base64.RawURLEncoding.EncodeToString(j)
}
// String returns Compact.
func (d DateKey) String() string { return d.Compact() }
func (d DateKey) valid() bool {
return profile.ValidID(d.ProfileID) && d.Round >= 1 && d.Round <= MaxRound
}
// Parse accepts only the unique canonical dk1_ string of a DateKey (spec §19):
// it decodes Base64URL, parses the JSON, validates the fields, re-emits the
// canonical JSON and dk1_ string and compares them byte for byte with s.
//
// Input that cannot be decoded or holds invalid fields fails with
// ErrDateKeyInvalid; a valid DateKey in any other encoding fails with
// ErrDateKeyNonCanonical. Parse does not check that the profile is known;
// callers look it up in their profile.Registry.
func Parse(s string) (DateKey, error) {
if len(s) > MaxEncodedLen {
return DateKey{}, fmt.Errorf("datekey: input longer than %d bytes: %w", MaxEncodedLen, datekeys.ErrDateKeyInvalid)
}
payload, ok := strings.CutPrefix(s, Prefix)
if !ok {
return DateKey{}, fmt.Errorf("datekey: missing %q prefix: %w", Prefix, datekeys.ErrDateKeyInvalid)
}
raw, err := decodeBase64(payload)
if err != nil {
return DateKey{}, fmt.Errorf("datekey: payload is not Base64URL: %w", datekeys.ErrDateKeyInvalid)
}
d, err := parseJSON(raw)
if err != nil {
return DateKey{}, err
}
if d.Compact() != s {
return DateKey{}, fmt.Errorf("datekey: not the canonical encoding %s: %w", d.Compact(), datekeys.ErrDateKeyNonCanonical)
}
return d, nil
}
// decodeBase64 decodes unpadded Base64URL (spec §18). Padded and standard
// alphabet variants are decoded too, so that they are reported as
// non-canonical rather than invalid; the final comparison rejects them.
func decodeBase64(s string) ([]byte, error) {
var firstErr error
for _, enc := range []*base64.Encoding{base64.RawURLEncoding, base64.URLEncoding, base64.RawStdEncoding, base64.StdEncoding} {
b, err := enc.DecodeString(s)
if err == nil {
return b, nil
}
if firstErr == nil {
firstErr = err
}
}
return nil, firstErr
}
func parseJSON(raw []byte) (DateKey, error) {
invalid := func(format string, a ...any) error {
return fmt.Errorf("datekey: "+format+": %w", append(a, datekeys.ErrDateKeyInvalid)...)
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var obj map[string]any
if err := dec.Decode(&obj); err != nil || obj == nil {
return DateKey{}, invalid("payload is not a JSON object")
}
if err := dec.Decode(new(any)); !errors.Is(err, io.EOF) {
return DateKey{}, invalid("trailing data after the JSON object")
}
if len(obj) != 3 {
return DateKey{}, invalid("expected exactly the fields version, network and round")
}
version, ok := jsonUint(obj["version"])
if !ok || version != Version {
return DateKey{}, invalid("unsupported version %v", obj["version"])
}
network, ok := obj["network"].(string)
if !ok || !profile.ValidID(network) {
return DateKey{}, invalid("invalid network %v", obj["network"])
}
round, ok := jsonUint(obj["round"])
if !ok || round == 0 || round > MaxRound {
return DateKey{}, invalid("invalid round %v", obj["round"])
}
return DateKey{ProfileID: network, Round: round}, nil
}
// jsonUint returns the value of a JSON number if it is a non-negative integer
// that fits in uint64, whatever its spelling: 1000, 1000.0, 1e3 and 10E2 all
// yield 1000. Non-canonical spellings are rejected later by the byte
// comparison, as spec §19 prescribes.
func jsonUint(v any) (uint64, bool) {
n, ok := v.(json.Number)
if !ok {
return 0, false
}
lit := string(n)
neg := strings.HasPrefix(lit, "-")
lit = strings.TrimPrefix(lit, "-")
mantissa, exp, hasExp := strings.Cut(strings.ToLower(lit), "e")
intPart, frac, _ := strings.Cut(mantissa, ".")
digits := strings.TrimLeft(intPart+frac, "0")
if digits == "" {
return 0, true // zero, including -0, 0.0 and 0e99999
}
e := int64(0)
if hasExp {
var err error
if e, err = strconv.ParseInt(exp, 10, 16); err != nil {
return 0, false // |exponent| >= 32768 with non-zero digits
}
}
if neg {
return 0, false
}
e -= int64(len(frac))
// digits * 10^e, keeping only integral values.
digits = strings.TrimLeft(digits, "0")
for e < 0 && strings.HasSuffix(digits, "0") {
digits = digits[:len(digits)-1]
e++
}
if e < 0 || int64(len(digits))+e > 20 {
return 0, false
}
u, err := strconv.ParseUint(digits+strings.Repeat("0", int(e)), 10, 64)
return u, err == nil
}

Powered by TurnKey Linux.