Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
// 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"
|
|
|
|
|
"unicode/utf8"
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
|
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
|
|
|
"g.activething.com/go/DateKeys/profile"
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// 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
|
Spec v0.8.2 refinements: error precedence, trust model, strict order
Approved refinements, each recorded with its reproducible case in the
§76 v0.8.2 subsection:
- §69.1: layered error model with normative precedence (frame, type tag
and version, CBOR profile and CDDL, then fields with their own code in
ascending key order; across steps the §63 order decides), with a scope
paragraph for the optional steps 5, 6 and 8.
- §55.1: normative trust table per section (who can write it, from which
step it is bound, what it never proves); §72: security-relevant claims
go in CONTROL_CBOR or under a signature, .dkk data is advisory.
- §31/§54: extension arrays in strictly ascending unsigned byte order of
extension_id (one rule for order and uniqueness).
- Gaps a second implementation needed: §28.1 malformed age headers,
§15/§19 latest unlock time and dk1_ reading rules, §22/§23/§57 length
lower bounds, §63 step 8 tlock argument comparison and step 9 order,
§12.1 profile validation with the drand chain-hash formula, §74 table
of implementation limits.
Reference alignment: .dkk errors only at step 9.a (new
OpenOptions.AccessKeyFile, used by the CLI), CR/LF in dk1_ is
ERR_DATEKEY_INVALID, BODY_LEN 0 is ERR_INTEGRITY, nil identities are not
credentials, and AccessIdentity tries every identity on every stanza so
its verdict does not depend on their order. dk1.json gains three
vectors; every other testdata file is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
|
|
|
// alphabet variants, and non-zero trailing bits, are decoded too, so that
|
|
|
|
|
// they are reported as non-canonical rather than invalid; the final
|
|
|
|
|
// comparison rejects them (spec §19). CR and LF are rejected first: the Go
|
|
|
|
|
// decoders would skip them, and spec §19 allows no character outside the
|
|
|
|
|
// alphabet.
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
func decodeBase64(s string) ([]byte, error) {
|
Spec v0.8.2 refinements: error precedence, trust model, strict order
Approved refinements, each recorded with its reproducible case in the
§76 v0.8.2 subsection:
- §69.1: layered error model with normative precedence (frame, type tag
and version, CBOR profile and CDDL, then fields with their own code in
ascending key order; across steps the §63 order decides), with a scope
paragraph for the optional steps 5, 6 and 8.
- §55.1: normative trust table per section (who can write it, from which
step it is bound, what it never proves); §72: security-relevant claims
go in CONTROL_CBOR or under a signature, .dkk data is advisory.
- §31/§54: extension arrays in strictly ascending unsigned byte order of
extension_id (one rule for order and uniqueness).
- Gaps a second implementation needed: §28.1 malformed age headers,
§15/§19 latest unlock time and dk1_ reading rules, §22/§23/§57 length
lower bounds, §63 step 8 tlock argument comparison and step 9 order,
§12.1 profile validation with the drand chain-hash formula, §74 table
of implementation limits.
Reference alignment: .dkk errors only at step 9.a (new
OpenOptions.AccessKeyFile, used by the CLI), CR/LF in dk1_ is
ERR_DATEKEY_INVALID, BODY_LEN 0 is ERR_INTEGRITY, nil identities are not
credentials, and AccessIdentity tries every identity on every stanza so
its verdict does not depend on their order. dk1.json gains three
vectors; every other testdata file is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
|
|
|
if strings.ContainsAny(s, "\r\n") {
|
|
|
|
|
return nil, errors.New("CR or LF in the Base64 payload")
|
|
|
|
|
}
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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)...)
|
|
|
|
|
}
|
|
|
|
|
// Spec §19 step 2: invalid UTF-8 fails the step. encoding/json would
|
|
|
|
|
// replace it with U+FFFD inside strings, so a member later overwritten
|
|
|
|
|
// by a repeated name could otherwise reach the canonical comparison.
|
|
|
|
|
if !utf8.Valid(raw) {
|
|
|
|
|
return DateKey{}, invalid("payload is not valid UTF-8")
|
|
|
|
|
}
|
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
|
|
|
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
|
|
|
|
|
}
|