// 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 }