Release object, release in hand and step 9.c option B (spec v0.15 draft)

The release of a round becomes a file, .dkr: a release object in
deterministic CBOR, {0: "datekeys-release", 1: 1, 2: chain_hash, 3: round,
4: signature}, which provider.EncodeRelease writes and DecodeRelease reads
with its layers (size, type and version, schema). provider.ParseRelease
also reads drand's JSON as the input of the caller. Verify checks the chain
hash a release names before its round and its signature, with
ERR_PROFILE_MISMATCH. provider.Archive reads a local release archive, the
informative format of the draft.

capsule.OpenOptions.Release takes a release in hand, a provider.Supplier,
exclusive with Source: Open does not compare it with the clock (step 9.c,
option B) and reports a clock behind it in Opened.ClockBehind; a network
source is still never asked before the round time. The CLI gains
decrypt -release FILE (.dkr, drand's JSON or a local archive),
decrypt -save-release FILE.dkr and the command release, which fetches,
verifies and saves the .dkr without opening the capsule.

Test data: vectors/release.json, releases/<round>.dkr for rounds 1000,
1001, 1004 and 2000, and a local archive of rounds 1000 to 1004. In
mutations.json every case says its source, "supplied" or "network"; the
case "round not reached yet", a release in hand, now opens, and four cases
are added: the same with a network source, a release of another round from
a network source, and two release objects of another chain. SpecVersion
stays 0.14 until the author approves the draft.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
v0.15
dev 24 hours ago
parent 22f184c2e7
commit 2eeca40d63

@ -0,0 +1,119 @@
package capsule_test
import (
"bytes"
"context"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// supplierFunc adapts a function to provider.Supplier.
type supplierFunc func(*profile.Profile, provider.Condition) ([]byte, error)
func (f supplierFunc) Supply(p *profile.Profile, c provider.Condition) ([]byte, error) {
return f(p, c)
}
func readRelease(t *testing.T, round uint64) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("../testdata/releases", testkit.ReleaseFileName(round)))
if err != nil {
t.Fatal(err)
}
return b
}
// Spec v0.15, §63 step 9.c: a release in hand is not compared with the
// clock. The capsule opens with a clock before the round time, Opened says
// the clock is behind, and Opened.Release encodes to the official .dkr.
func TestReleaseInHand(t *testing.T) {
f := loadFixture(t, "format3_time_and_key_portable")
dkr := readRelease(t, 1000)
for _, tc := range []struct {
name string
now time.Time
behind bool
}{
{"after the round time", f.unlock(t), false},
{"a clock one nanosecond behind", f.unlock(t).Add(-1), true},
{"a clock years behind", testkit.Genesis(), true},
} {
o := f.openOptions(t)
o.Source, o.Release, o.Now = nil, provider.Encoded(dkr), testkit.Fixed(tc.now)
out, err := capsule.Open(context.Background(), nil, bytes.NewReader(f.dkc), o)
if err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
if out.ClockBehind != tc.behind {
t.Fatalf("%s: ClockBehind %v", tc.name, out.ClockBehind)
}
saved, err := provider.EncodeRelease(out.Release)
if err != nil || !bytes.Equal(saved, dkr) {
t.Fatalf("%s: the release saved again is %x, %v", tc.name, saved, err)
}
}
}
// A release in hand keeps the order of step 9: the credentials first (9.a,
// 9.b), with no request; then the release, whose failure to be supplied is
// ErrReleaseUnavailable alone at step 9; then step 10 with its codes, the
// layers of the object first.
func TestReleaseInHandErrors(t *testing.T) {
f := loadFixture(t, "format3_time_and_key_portable")
other := profile.Quicknet().ChainHash
other[5] ^= 1
otherChain, err := provider.EncodeRelease(provider.Release{Round: 1001, Signature: testkit.Release(1001).Signature, ChainHash: other[:]})
if err != nil {
t.Fatal(err)
}
for _, tc := range []struct {
name string
supply func() ([]byte, error)
noAccess bool
want *datekeys.Error
step int
calls int
}{
{"no credential, before any supply", func() ([]byte, error) { return readRelease(t, 1000), nil }, true, datekeys.ErrAccessRequired, 9, 0},
{"nothing supplied", func() ([]byte, error) { return nil, fmt.Errorf("none: %w", datekeys.ErrReleaseUnavailable) }, false, datekeys.ErrReleaseUnavailable, 9, 1},
{"a supplier failing with another code", func() ([]byte, error) { return nil, fmt.Errorf("odd: %w", datekeys.ErrIntegrity) }, false, datekeys.ErrReleaseUnavailable, 9, 1},
{"an empty object", func() ([]byte, error) { return nil, nil }, false, datekeys.ErrNonCanonicalCBOR, 10, 1},
{"another chain and another round", func() ([]byte, error) { return otherChain, nil }, false, datekeys.ErrProfileMismatch, 10, 1},
{"another round", func() ([]byte, error) { return readRelease(t, 1001), nil }, false, datekeys.ErrRoundMismatch, 10, 1},
{"drand's JSON of another round", func() ([]byte, error) {
return []byte(`{"round":1001,"signature":"00"}`), nil
}, false, datekeys.ErrRoundMismatch, 10, 1},
} {
calls := 0
o := f.openOptions(t)
o.Source, o.Now = nil, testkit.Fixed(testkit.Genesis())
o.Release = supplierFunc(func(*profile.Profile, provider.Condition) ([]byte, error) { calls++; return tc.supply() })
if tc.noAccess {
o.AccessKey = nil
}
step, err := openAt(t, f.dkc, o)
if !onlyCode(err, tc.want) || step != tc.step || calls != tc.calls {
t.Errorf("%s: got %v at step %d after %d supplies, want %v at step %d after %d", tc.name, err, step, calls, tc.want, tc.step, tc.calls)
}
}
o := f.openOptions(t)
o.Release = provider.Encoded(readRelease(t, 1000))
if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(f.dkc), o); err == nil || datekeys.Code(err) != "" {
t.Fatalf("Source and Release together: %v", err)
}
o.Source, o.Release = nil, nil
if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(f.dkc), o); err == nil || errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("no source: %v", err)
}
}

@ -96,7 +96,7 @@ func TestExportedMutationCorpus(t *testing.T) {
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got.DKC, want.DKC) || !bytes.Equal(got.DKK, want.DKK) {
if !bytes.Equal(got.DKC, want.DKC) || !bytes.Equal(got.DKK, want.DKK) || got.NetworkSource != want.NetworkSource {
t.Fatal("the exported capsule or .dkk differs from the one the mutation derives")
}
})

@ -27,7 +27,7 @@ type OpenOptions struct {
// Extensions lists the extensions the application implements; see
// InspectOptions.Extensions.
Extensions extension.Registry
// Source fetches the release. Required. Its answer is always verified
// Source fetches the release, unless Release is set. Its answer is always verified
// locally, at step 10. A source that fetches releases over a network,
// like provider/drand.Client, verifies each response itself and discards
// the invalid ones, so that Open reports ErrReleaseUnavailable at step 9
@ -37,6 +37,17 @@ type OpenOptions struct {
// code: the text of an error that carries another code, or none, is
// kept, not its code.
Source provider.ReleaseSource
// Release is a release the caller has in hand, the alternative to
// Source (spec v0.15, §63 step 9.c): a .dkr file or drand's JSON read
// with provider.Encoded, or a local archive with provider.Archive. It
// makes no network request, so Open asks it for the release without
// comparing Now with the round time, and reports in
// Opened.ClockBehind a clock that is behind it. An error of Release is
// reported at step 9 with ErrReleaseUnavailable as its only code, as one
// of Source; the release it supplies is decoded and verified at step 10,
// with the codes of that step. Exactly one of Source and Release must be
// set.
Release provider.Supplier
// Identities are the caller's own X25519 identities, for time_and_key
// capsules encrypted to known recipients. Nil entries are ignored.
Identities []age.Identity
@ -53,7 +64,7 @@ type OpenOptions struct {
AccessKeyFile io.Reader
// Now is the clock. Required: no package of this module reads the wall
// clock on its own. Open does not ask Source for a round whose time has
// not been reached.
// not been reached; it does not compare it with a Release in hand.
Now func() time.Time
// Sink receives the files of a format 3 capsule; dst receives the
// content of formats 1 and 2. Open fails right after step 2 with
@ -74,7 +85,16 @@ type OpenOptions struct {
// Opened describes a capsule that Open decrypted completely.
type Opened struct {
Inspection *Inspection
// Release is the release that opened the capsule, verified at step 10,
// with the chain hash of the pinned profile: provider.EncodeRelease
// gives the .dkr that a reader saves next to the capsule (spec v0.15,
// §62.1).
Release provider.Release
// ClockBehind reports that the release was in the caller's hand and that
// Now was before the round time of the DateKey: the release proves the
// round was published, so the clock is probably behind, which a reader
// may say (spec v0.15, §63 step 9.c).
ClockBehind bool
// Format is the format of the capsule (spec §22). A caller should show
// it: format 1 does not hide the number of credentials or the exact
// length of the content (spec §55.2, §70).
@ -127,8 +147,8 @@ type Opened struct {
// discarded, and that must not be presented as valid: write to a temporary
// file and publish it only on success (spec §56), as the datekeys CLI does.
func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*Opened, error) {
if opts.Source == nil {
return nil, errors.New("capsule: OpenOptions.Source is required")
if (opts.Source == nil) == (opts.Release == nil) {
return nil, errors.New("capsule: set exactly one of OpenOptions.Source and OpenOptions.Release")
}
if opts.Now == nil {
return nil, errors.New("capsule: OpenOptions.Now is required")
@ -201,26 +221,50 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O
in.pass(9, "access credential", fmt.Sprintf("%d identities to try%s", len(ids), unusable(out.UnusableAccessKeyExtensions)))
}
// Step 9: obtain the release, never before its round time. A network
// source has verified each response with the rules of step 10 and
// discarded the invalid ones. Whatever the failure of the source, none
// valid included, it is ErrReleaseUnavailable here.
// Step 9: obtain the release. A network source is never asked before the
// round time (9.c); a release in hand is not compared with the clock,
// whose being behind it is only reported (spec v0.15). A network source
// has verified each response with the rules of step 10 and discarded the
// invalid ones. Whatever the failure of the source, none valid included,
// it is ErrReleaseUnavailable here.
cond := provider.Condition{Round: h.DateKey.Round}
if now := opts.Now(); now.Before(in.UnlockAt) {
now := opts.Now()
var release provider.Release
if opts.Release != nil {
encoded, err := opts.Release.Supply(p, cond)
if err != nil {
return out, in.fail(9, "release", sourceFailure(err))
}
out.ClockBehind = now.Before(in.UnlockAt)
detail := "release supplied by the caller"
if out.ClockBehind {
detail = fmt.Sprintf("release supplied by the caller; round %d is published at %s and the clock says %s: it may be behind",
cond.Round, in.UnlockAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339))
}
in.pass(9, "release", detail)
// Step 10 starts with the layers of the release object.
if release, err = provider.ParseRelease(encoded); err != nil {
return out, in.fail(10, "release verification", err)
}
} else {
if now.Before(in.UnlockAt) {
err := fmt.Errorf("capsule: round %d is published at %s, it is %s: %w", cond.Round,
in.UnlockAt.Format(time.RFC3339), now.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
return out, in.fail(9, "release", err)
}
release, err := opts.Source.Fetch(ctx, p, cond)
if err != nil {
var err error
if release, err = opts.Source.Fetch(ctx, p, cond); err != nil {
return out, in.fail(9, "release", sourceFailure(err))
}
in.pass(9, "release", fmt.Sprintf("round %d obtained", release.Round))
}
// Step 10: verify the release locally.
// Step 10: verify the release locally: the chain it names, its round and
// its signature.
if err := provider.Verify(p, cond, release); err != nil {
return out, in.fail(10, "release verification", err)
}
release.ChainHash = bytes.Clone(p.ChainHash[:])
out.Release = release
in.pass(10, "release verification", "BLS signature valid under the pinned key")

@ -7,12 +7,15 @@
// datekeys author keygen -out autor.key -pass-file clave.txt
// datekeys encrypt -at 2030-01-01T00:00:00Z -in carta.txt -sign autor.key -sign-pass-file clave.txt -out carta.dkc
// datekeys decrypt -in regalo.dkc -out regalo [-dkk key.dkk] [-identity key.txt] [-words-file palabras.txt]
// datekeys decrypt -in regalo.dkc -out regalo -release regalo.dkr
// datekeys release -in regalo.dkc -out regalo.dkr
// datekeys datekey resolve -at 2030-01-01T00:00:00Z
// datekeys profile hash
// datekeys version
//
// Encryption never touches the network. Decryption fetches the release from
// public drand relays and verifies it locally. Outputs are written to a
// public drand relays, or reads the one the person has in hand with -release,
// and verifies it locally. Outputs are written to a
// temporary file in the destination directory and published only when
// complete; existing files are never overwritten. The files of a format 3
// capsule go to a new folder, staged inside it and moved into place only
@ -43,13 +46,15 @@ import (
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/internal/inspectview"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
"g.activething.com/go/DateKeys/provider/drand"
"g.activething.com/go/DateKeys/wordkey"
)
const usage = `usage:
datekeys encrypt -at TIME -in FILE|FOLDER... -out FILE.dkc [-comment TEXT] [-author TEXT] [-no-mtime] [-policy time_only|time_and_key] [-recipient age1...]... [-dkk FILE.dkk] [-words TEXT|-words-file FILE] [-padding reforzado|bloque256] [-note TEXT] [-sign KEY [-sign-pass-file FILE]] [-large-area]
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]...
datekeys decrypt -in FILE.dkc -out PATH [-dkk FILE.dkk] [-identity FILE]... [-words TEXT|-words-file FILE] [-expect-author dkauthor1...] [-relay URL]... [-release FILE] [-save-release FILE.dkr]
datekeys release -in FILE.dkc -out FILE.dkr [-relay URL]...
datekeys author keygen -out FILE (-pass-file FILE|-plain)
datekeys author public -key FILE [-pass-file FILE]
datekeys inspect -in FILE.dkc [-json]
@ -65,6 +70,16 @@ capsule holds 16 slots, from 1 to 16 credentials and a dummy in each slot
left (spec §29, §39). decrypt writes the files of a format 3 capsule to the
new folder PATH, and the content of formats 1 and 2 to the new file PATH.
decrypt fetches the release of the round from drand relays, never before the
round time. -release FILE gives it instead, without any network request: a
.dkr file, drand's JSON or a local release archive. It is verified like one
from a relay, and the clock does not stop it: a valid release proves that the
round was published (spec v0.15, §63 step 9.c). -save-release keeps the
release that opened the capsule in a new .dkr file, to keep next to the .dkc:
in decades the relays may no longer serve the round. release fetches,
verifies and saves that .dkr without opening the capsule; the request tells
the relay the round, as opening does.
-words and -words-file give a key of words to a time_and_key capsule: at
least 6 different words of 3 or more letters that open it with decrypt,
instead of a .dkk
@ -123,6 +138,8 @@ func run(args []string, stdout, stderr io.Writer, now func() time.Time) error {
return encrypt(args[1:], stderr, now)
case "decrypt":
return decrypt(args[1:], stdout, stderr, now)
case "release":
return saveRelease(args[1:], stderr, now)
case "author":
return author(args[1:], stdout, stderr, stdin)
case "inspect":
@ -311,12 +328,22 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
words := fs.String("words", "", "the words of a key of words; they stay in the shell history")
wordsFile := fs.String("words-file", "", "file with the words of a key of words")
expect := fs.String("expect-author", "", "fail unless the capsule is signed with this public key, dkauthor1...")
release := fs.String("release", "", "the release in hand: a .dkr, drand's JSON or a local release archive; no network request")
keep := fs.String("save-release", "", "new .dkr file for the release that opened the capsule; never overwritten")
if err := parse(fs, args); err != nil {
return err
}
if *in == "" || *out == "" {
return errors.New("decrypt: -in and -out are required")
}
if *release != "" && len(relays) > 0 {
return errors.New("decrypt: -release and -relay are exclusive")
}
if *keep != "" {
if err := checkNew(*keep); err != nil {
return err
}
}
var expected []byte
if *expect != "" {
k, err := authorkey.ParsePublic(*expect)
@ -329,7 +356,17 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
if err != nil {
return err
}
opts := capsule.OpenOptions{Registry: reg, Source: drand.New(relays...), Now: now}
opts := capsule.OpenOptions{Registry: reg, Now: now}
if *release != "" {
supplier, closer, err := releaseInHand(*release)
if err != nil {
return err
}
defer closer.Close()
opts.Release = supplier
} else {
opts.Source = drand.New(relays...)
}
if expected != nil {
// The expected key is not a key that the person saved, with a label
// she gave it: a signature with it is F4, which shows the whole key. A
@ -423,6 +460,16 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
}
fmt.Fprintf(stderr, "Decrypted capsule %s (round %d, unlocked at %s); release verified locally\n",
opened.Inspection.Header.CapsuleIDHex(), opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339))
if opened.ClockBehind {
fmt.Fprintf(stderr, "warning: the release proves that round %d was published at %s, and this clock says %s: it may be behind\n",
opened.Release.Round, opened.Inspection.UnlockAt.Format(time.RFC3339), now().UTC().Format(time.RFC3339))
}
if *keep != "" {
if err := writeRelease(*keep, opened.Release); err != nil {
return err
}
fmt.Fprintf(stderr, " release saved to %s: keep it next to the capsule\n", *keep)
}
if opened.Format == capsule.Format3 {
fmt.Fprintf(stderr, " format 3, %d files\n", len(opened.Head.Files))
if len(opened.Head.Files) == 0 {
@ -443,6 +490,107 @@ func decrypt(args []string, stdout, stderr io.Writer, now func() time.Time) erro
return nil
}
// releaseInHand reads the release that the person has in hand, from path: a
// local release archive, which is read when Open asks for the round, or a
// .dkr file or drand's JSON, which Open decodes at step 10. A file larger
// than any of them is cut where it can no longer be valid, so that step 10
// rejects it with the code of its size.
func releaseInHand(path string) (provider.Supplier, io.Closer, error) {
f, err := os.Open(path)
if err != nil {
return nil, nil, err
}
info, err := f.Stat()
if err != nil {
f.Close()
return nil, nil, err
}
head := make([]byte, min(info.Size(), 64))
if _, err := io.ReadFull(f, head); err != nil {
f.Close()
return nil, nil, err
}
if provider.IsArchive(head) {
return provider.NewArchive(f, info.Size()), f, nil
}
defer f.Close()
rest, err := io.ReadAll(io.LimitReader(f, provider.MaxReleaseJSONSize+1-int64(len(head))))
if err != nil {
return nil, nil, err
}
return provider.Encoded(append(head, rest...)), io.NopCloser(nil), nil
}
// writeRelease writes the release object of r, a verified release, to the
// new file path (spec v0.15, §47.1).
func writeRelease(path string, r provider.Release) error {
b, err := provider.EncodeRelease(r)
if err != nil {
return err
}
return writeAtomic(path, func(w io.Writer) error {
_, err := w.Write(b)
return err
})
}
// saveRelease fetches the release of the round of a capsule from drand
// relays, verifies it and saves it as a new .dkr file, without opening the
// capsule (spec v0.15, §62.1). Steps 1 to 8 come first, and no request is
// made before the round time.
func saveRelease(args []string, stderr io.Writer, now func() time.Time) error {
fs := newFlags("release")
in := fs.String("in", "", ".dkc file")
out := fs.String("out", "", "new .dkr file; never overwritten")
timeout := fs.Duration("timeout", 30*time.Second, "release request timeout")
var relays multi
fs.Var(&relays, "relay", "drand relay base URL (repeatable); default: public relays")
if err := parse(fs, args); err != nil {
return err
}
if *in == "" || *out == "" {
return errors.New("release: -in and -out are required")
}
if err := checkNew(*out); err != nil {
return err
}
reg, err := profile.Default()
if err != nil {
return err
}
src, err := os.Open(*in)
if err != nil {
return err
}
defer src.Close()
insp, err := capsule.Inspect(src, capsule.InspectOptions{Registry: reg})
if err != nil {
return err
}
cond := provider.Condition{Round: insp.Header.DateKey.Round}
if t := now(); t.Before(insp.UnlockAt) {
return fmt.Errorf("release: round %d is published at %s, it is %s: %w", cond.Round,
insp.UnlockAt.Format(time.RFC3339), t.UTC().Format(time.RFC3339), datekeys.ErrReleaseUnavailable)
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
// The relays verify each answer and discard the invalid ones; it is
// verified again here, as Open does at step 10.
r, err := drand.New(relays...).Fetch(ctx, insp.Profile, cond)
if err != nil {
return err
}
if err := provider.Verify(insp.Profile, cond, r); err != nil {
return err
}
r.ChainHash = insp.Profile.ChainHash[:]
if err := writeRelease(*out, r); err != nil {
return err
}
fmt.Fprintf(stderr, "Saved the release of round %d, verified locally, to %s: keep it next to %s\n", cond.Round, *out, *in)
return nil
}
// wordsText is the text of the words of -words or of -words-file, at most
// 4 KiB, or "" when neither is given.
func wordsText(cmd, words, file string) (string, error) {

@ -0,0 +1,145 @@
package main
import (
"bytes"
"encoding/hex"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/testkit"
)
const releases = "../../testdata/releases"
// unlock1000 is the round time of round 1000, that of time_only.dkc.
var unlock1000 = time.Date(2023, 8, 23, 15, 59, 24, 0, time.UTC)
// Spec v0.15, §63 step 9.c: a release in hand opens the capsule without any
// network request, even with a clock behind the round time, which decrypt
// only warns about; -save-release keeps it as a .dkr, the same bytes as the
// official one.
func TestDecryptWithReleaseInHand(t *testing.T) {
dir := t.TempDir()
want, err := os.ReadFile(filepath.Join(fixtures, "time_only.plaintext"))
if err != nil {
t.Fatal(err)
}
jsonFile := filepath.Join(dir, "1000.json")
if err := os.WriteFile(jsonFile, []byte(`{"round":1000,"signature":"`+hex.EncodeToString(testkit.Release(1000).Signature)+`"}`), 0o644); err != nil {
t.Fatal(err)
}
for i, c := range []struct {
name, release string
now time.Time
}{
{".dkr", filepath.Join(releases, "1000.dkr"), later},
{".dkr and a clock behind", filepath.Join(releases, "1000.dkr"), unlock1000.Add(-time.Hour)},
{"drand's JSON", jsonFile, later},
{"a local archive", filepath.Join(releases, testkit.ArchiveFile), unlock1000.Add(-time.Nanosecond)},
} {
t.Run(c.name, func(t *testing.T) {
out := filepath.Join(dir, c.name)
saved := filepath.Join(dir, "saved"+string(rune('a'+i))+".dkr")
_, stderr, err := cli(t, c.now, "decrypt", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", out, "-release", c.release, "-save-release", saved)
if err != nil {
t.Fatal(err)
}
if got, _ := os.ReadFile(out); !bytes.Equal(got, want) {
t.Fatal("wrong content")
}
if behind := strings.Contains(stderr, "may be behind"); behind != c.now.Before(unlock1000) {
t.Fatalf("warning of a clock behind: %v, in %q", behind, stderr)
}
official, _ := os.ReadFile(filepath.Join(releases, "1000.dkr"))
if got, _ := os.ReadFile(saved); !bytes.Equal(got, official) {
t.Fatalf("saved .dkr %x, want %x", got, official)
}
})
}
}
// A release in hand that step 10 rejects gives the code of step 10, and one
// the archive lacks is ErrReleaseUnavailable; nothing is written.
func TestDecryptWithBadReleaseInHand(t *testing.T) {
dir := t.TempDir()
write := func(name string, b []byte) string {
p := filepath.Join(dir, name)
if err := os.WriteFile(p, b, 0o644); err != nil {
t.Fatal(err)
}
return p
}
r1001, _ := os.ReadFile(filepath.Join(releases, "1001.dkr"))
r1000, _ := os.ReadFile(filepath.Join(releases, "1000.dkr"))
v2 := bytes.Clone(r1000)
v2[bytes.Index(v2, []byte{0x01, 0x01})+1] = 2
for _, c := range []struct {
name string
release string
capsule string
want *datekeys.Error
}{
{"another round", write("1001.dkr", r1001), "time_only", datekeys.ErrRoundMismatch},
{"version 2", write("v2.dkr", v2), "time_only", datekeys.ErrUnsupportedVersion},
{"a byte after it", write("long.dkr", append(bytes.Clone(r1000), 0)), "time_only", datekeys.ErrNonCanonicalCBOR},
{"a large file", write("large.dkr", append(bytes.Clone(r1000), make([]byte, 20000)...)), "time_only", datekeys.ErrNonCanonicalCBOR},
{"bad JSON", write("bad.json", []byte(`{"round":1000}`)), "time_only", datekeys.ErrReleaseInvalid},
{"a round the archive lacks", filepath.Join(releases, testkit.ArchiveFile), "format2_time_and_key_sixteen", datekeys.ErrReleaseUnavailable},
} {
t.Run(c.name, func(t *testing.T) {
out := filepath.Join(dir, c.name)
args := []string{"decrypt", "-in", filepath.Join(fixtures, c.capsule+".dkc"), "-out", out, "-release", c.release}
if c.capsule == "format2_time_and_key_sixteen" {
args = append(args, "-identity", filepath.Join(dir, "none"))
// The capsule needs a credential before the release: give it
// one that is never reached.
write("none", []byte(testkit.Stranger().String()+"\n"))
}
_, _, err := cli(t, later, args...)
if !errors.Is(err, c.want) {
t.Fatalf("got %v, want %v", err, c.want)
}
if _, err := os.Stat(out); !os.IsNotExist(err) {
t.Fatal("output written")
}
})
}
if _, _, err := cli(t, later, "decrypt", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", filepath.Join(dir, "x"), "-release", "a.dkr", "-relay", "http://127.0.0.1:1"); err == nil {
t.Fatal("-release and -relay together")
}
}
// A network source is still never asked before the round time.
func TestDecryptFromRelayBeforeTheRoundTime(t *testing.T) {
_, _, err := cli(t, unlock1000.Add(-time.Second), "decrypt", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", filepath.Join(t.TempDir(), "x"), "-relay", "http://127.0.0.1:1")
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("got %v", err)
}
}
// datekeys release fetches, verifies and saves the .dkr of a capsule
// without opening it, and never before the round time.
func TestReleaseCommand(t *testing.T) {
dir := t.TempDir()
url := relay(t)
out := filepath.Join(dir, "carta.dkr")
if _, _, err := cli(t, later, "release", "-in", filepath.Join(fixtures, "format3_time_and_key_portable.dkc"), "-out", out, "-relay", url); err != nil {
t.Fatal(err)
}
official, _ := os.ReadFile(filepath.Join(releases, "1000.dkr"))
if got, _ := os.ReadFile(out); !bytes.Equal(got, official) {
t.Fatalf("saved .dkr %x, want %x", got, official)
}
if _, _, err := cli(t, later, "release", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", out, "-relay", url); err == nil {
t.Fatal("an existing .dkr was overwritten")
}
_, _, err := cli(t, unlock1000.Add(-time.Second), "release", "-in", filepath.Join(fixtures, "time_only.dkc"), "-out", filepath.Join(dir, "early.dkr"), "-relay", "http://127.0.0.1:1")
if !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("before the round time: %v", err)
}
}

@ -17,6 +17,10 @@ type FixtureStanza struct {
type FixtureRelease struct {
Round uint64 `json:"round"`
Signature string `json:"signature"`
// ChainHash is the chain a release object names (spec v0.15, §47.1),
// when it is not the chain of the pinned Quicknet profile; absent
// otherwise.
ChainHash string `json:"chain_hash,omitempty"`
}
// FixtureStage is the expected result of one step of spec §63.

@ -157,9 +157,39 @@ func vectors(dir string) error {
if err := testkit.WriteJSON(filepath.Join(dir, "tlock_ibe.json"), iv); err != nil {
return err
}
if err := releaseVectors(dir); err != nil {
return err
}
return format3Vectors(dir)
}
// releaseVectors writes the release objects (spec v0.15, §47.1):
// vectors/release.json, and in releases/ the .dkr of each published round of
// the tests and a local archive, the informative format of §50.
func releaseVectors(dir string) error {
rv, err := testkit.ReleaseVectors()
if err != nil {
return err
}
if err := testkit.WriteJSON(filepath.Join(dir, "release.json"), rv); err != nil {
return err
}
files, err := testkit.ReleaseFiles()
if err != nil {
return err
}
out := filepath.Join(filepath.Dir(dir), "releases")
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
for name, b := range files {
if err := os.WriteFile(filepath.Join(out, name), b, 0o644); err != nil {
return err
}
}
return nil
}
// format3Vectors writes the vectors of format 3 (spec §67): the paths, the
// keys of R7, the heads and security.
func format3Vectors(dir string) error {

@ -58,6 +58,10 @@ type Mutation struct {
// opens without a code: the mutations of security, which never decides
// the opening (spec §29.3, §64). Want is then nil and Step 0.
Verdicts *capsule.Verdicts
// Opens reports a case of format 1 or 2 that opens, such as a release in
// hand with a clock behind it (spec v0.15, §63 step 9.c). Want is then
// nil and Step 0.
Opens bool
Make func(e *MutationEnv) (*MutationInput, error)
}
@ -84,6 +88,13 @@ type MutationInput struct {
// Release is the only release the source knows: it answers every request
// with it. Nil means that no release is available.
Release *provider.Release
// NetworkSource makes the source a network source, which is not asked
// before the round time and discards a release that breaks the rules of
// step 10 (spec §63 step 9). Otherwise the release is in the caller's
// hand, a release object that the reader decodes and verifies at step
// 10, and the clock is not compared with the round time (spec v0.15,
// step 9.c).
NetworkSource bool
Now time.Time
// EmptyRegistry pins no profile; otherwise profile.Default is used.
EmptyRegistry bool
@ -128,20 +139,39 @@ func (k KnownExtensions) ValidateData(e extension.Extension) error {
}
// singleSource answers every request with one release, or with
// ErrReleaseUnavailable, and counts the requests.
// ErrReleaseUnavailable, and counts the requests. As a network source,
// Fetch verifies the release and discards it when it breaks a rule of step
// 10 (spec §63 step 9); as a release in hand, Supply hands over its release
// object, with the chain of the pinned profile unless the release names
// another.
type singleSource struct {
release *provider.Release
calls int
}
func (s *singleSource) Fetch(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
func (s *singleSource) Fetch(_ context.Context, p *profile.Profile, c provider.Condition) (provider.Release, error) {
s.calls++
if s.release == nil {
return provider.Release{}, fmt.Errorf("testkit: no release: %w", datekeys.ErrReleaseUnavailable)
}
if err := provider.Verify(p, c, *s.release); err != nil {
return provider.Release{}, fmt.Errorf("testkit: the release is discarded: %v: %w", err, datekeys.ErrReleaseUnavailable)
}
return *s.release, nil
}
func (s *singleSource) Supply(p *profile.Profile, _ provider.Condition) ([]byte, error) {
s.calls++
if s.release == nil {
return nil, fmt.Errorf("testkit: no release: %w", datekeys.ErrReleaseUnavailable)
}
r := *s.release
if r.ChainHash == nil {
r.ChainHash = p.ChainHash[:]
}
return provider.EncodeRelease(r)
}
// Verdict is how a reader rejected a capsule.
type Verdict struct {
Err error
@ -163,7 +193,12 @@ func (in *MutationInput) Open() (Verdict, error) {
}
}
src := &singleSource{release: in.Release}
o := capsule.OpenOptions{Registry: reg, Source: src, Now: Fixed(in.Now)}
o := capsule.OpenOptions{Registry: reg, Now: Fixed(in.Now)}
if in.NetworkSource {
o.Source = src
} else {
o.Release = src
}
if in.Extensions != nil {
o.Extensions = in.Extensions
}
@ -527,7 +562,52 @@ func Mutations() []Mutation {
out = append(out, specMutations(capsule.Format2)...)
out = append(out, format2Mutations()...)
out = append(out, specMutations(capsule.Format3)...)
return append(out, format3Mutations()...)
out = append(out, format3Mutations()...)
return append(out, sourceMutations()...)
}
// sourceMutations returns the cases of spec v0.15 on the source of the
// release, on the format 1 fixture time_only: a release in the caller's
// hand is not compared with the clock and gets the codes of step 10, and a
// network source is not asked before the round time and discards what step
// 10 rejects, ErrReleaseUnavailable at step 9 (spec §63 steps 9 and 10,
// §69.1). The case "round not reached yet", among the further cases, is the
// same capsule and clock with a release in hand: it opens.
func sourceMutations() []Mutation {
otherChain := func(i int, bit byte) []byte {
h := profile.Quicknet().ChainHash
h[i] ^= bit
return h[:]
}
return []Mutation{
{Name: "round not reached yet, from a network source", Want: datekeys.ErrReleaseUnavailable, Step: 9,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Now, in.NetworkSource = e.TimeOnly.Unlock.Add(-1), true
return in, nil
}},
{Name: "release of another round, from a network source", Want: datekeys.ErrReleaseUnavailable, Step: 9, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Release, in.NetworkSource = &provider.Release{Round: 1001, Signature: Release(1001).Signature}, true
return in, nil
}},
{Name: "release object of another chain", Want: datekeys.ErrProfileMismatch, Step: 10, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
r := *in.Release
r.ChainHash = otherChain(31, 1)
in.Release = &r
return in, nil
}},
{Name: "release object of another chain and another round, with a clock behind", Want: datekeys.ErrProfileMismatch, Step: 10, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Release = &provider.Release{Round: 1001, Signature: Release(1001).Signature, ChainHash: otherChain(0, 0x80)}
in.Now = e.TimeOnly.Unlock.Add(-1)
return in, nil
}},
}
}
// specMutations returns the thirty-three mutations of the first two lists of
@ -901,7 +981,10 @@ func furtherMutations() []Mutation {
in.DKK, in.Identities = nil, []string{Stranger().String()}
return in, nil
}},
{Name: "round not reached yet", Want: datekeys.ErrReleaseUnavailable, Step: 9,
// Spec v0.15, §63 step 9.c: a release in hand is not compared with the
// clock. The same case with a network source is among the cases of
// sourceMutations.
{Name: "round not reached yet", Opens: true, Network: true,
Make: func(e *MutationEnv) (*MutationInput, error) {
in := e.TimeOnly.input(e.TimeOnly.DKC)
in.Now = e.TimeOnly.Unlock.Add(-1)
@ -1154,6 +1237,12 @@ func (m Mutation) Check(in *MutationInput) error {
}
func (m Mutation) checkVerdict(v Verdict) error {
if m.Opens {
if v.Err != nil {
return fmt.Errorf("got %v, want the capsule to open", v.Err)
}
return nil
}
if m.Verdicts != nil {
switch {
case v.Err != nil:
@ -1179,6 +1268,12 @@ func (m Mutation) checkVerdict(v Verdict) error {
// ---------------------------------------------------------------------------
// Exported corpus: testdata/vectors/mutations.json
// The sources of a case of the corpus (spec v0.15, §63 step 9).
const (
SourceSupplied = "supplied"
SourceNetwork = "network"
)
// MutationFile is testdata/vectors/mutations.json.
type MutationFile struct {
Spec string `json:"spec"`
@ -1198,6 +1293,10 @@ type MutationCase struct {
// Release is the only release the source serves, for any requested
// round; null when no release is available.
Release *FixtureRelease `json:"release"`
// Source is "supplied", a release in the caller's hand, given to the
// reader as a release object, or "network", a network source (spec
// v0.15, §63 step 9).
Source string `json:"source"`
// Now is the reader's clock, RFC 3339.
Now string `json:"now"`
// Registry is "default" (the Quicknet profile pinned) or "empty".
@ -1263,6 +1362,18 @@ func (c *MutationCase) Input(dir string) (*MutationInput, error) {
return nil, err
}
in.Release = &provider.Release{Round: c.Release.Round, Signature: sig}
if c.Release.ChainHash != "" {
if in.Release.ChainHash, err = hex.DecodeString(c.Release.ChainHash); err != nil {
return nil, err
}
}
}
switch c.Source {
case SourceSupplied:
case SourceNetwork:
in.NetworkSource = true
default:
return nil, fmt.Errorf("testkit: unknown source %q", c.Source)
}
if in.Now, err = time.Parse(time.RFC3339Nano, c.Now); err != nil {
return nil, err
@ -1304,6 +1415,10 @@ func (m Mutation) record(in *MutationInput, dir string, v Verdict) (MutationCase
c := MutationCase{
Name: m.Name, Spec: m.Spec, Identities: in.Identities, Now: in.Now.UTC().Format(time.RFC3339Nano),
Registry: "default", Network: m.Network, Frozen: m.Random, Error: Result(v.Err), Step: v.Step,
Source: SourceSupplied,
}
if in.NetworkSource {
c.Source = SourceNetwork
}
if in.EmptyRegistry {
c.Registry = "empty"
@ -1327,6 +1442,9 @@ func (m Mutation) record(in *MutationInput, dir string, v Verdict) (MutationCase
}
if in.Release != nil {
c.Release = &FixtureRelease{Round: in.Release.Round, Signature: hex.EncodeToString(in.Release.Signature)}
if in.Release.ChainHash != nil {
c.Release.ChainHash = hex.EncodeToString(in.Release.ChainHash)
}
}
for _, x := range in.Extensions {
c.Extensions = append(c.Extensions, KnownExtensionRecord{ID: x.ID, Version: x.Version, ValidData: hex.EncodeToString(x.ValidData)})

@ -0,0 +1,331 @@
package testkit
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// ReleaseVectorFile is testdata/vectors/release.json: release objects, the
// content of a .dkr file (spec v0.15, §47.1), and drand's JSON, each checked
// as step 10 of spec §63 checks a release that the caller supplies, against
// the pinned Quicknet profile and the round of a DateKey; and the lookups of
// a local release archive, an informative format (spec v0.15, §50).
type ReleaseVectorFile struct {
Spec string `json:"spec"`
Description string `json:"description"`
Profile string `json:"profile"`
Objects []ReleaseVector `json:"objects"`
JSON []ReleaseVector `json:"json"`
Archive ArchiveVectors `json:"archive"`
}
// ReleaseVector is an input, in hexadecimal for a release object and as text
// for drand's JSON, the round of the DateKey it is checked against, and the
// result: ResultOK or the code, and the text of the error of the reference.
// When the input decodes, Release is what it says.
type ReleaseVector struct {
Name string `json:"name"`
Encoding string `json:"encoding,omitempty"`
Input string `json:"input,omitempty"`
Round uint64 `json:"round"`
Release *FixtureRelease `json:"release,omitempty"`
Result string `json:"result"`
Text string `json:"text,omitempty"`
}
// ArchiveVectors is the local archive of testdata/releases and what it
// supplies for each round looked up: the release object of the round, or
// ErrReleaseUnavailable.
type ArchiveVectors struct {
File string `json:"file"`
Header string `json:"header"`
Lookups []ArchiveLookup `json:"lookups"`
}
// ArchiveLookup is a round looked up in the archive.
type ArchiveLookup struct {
Round uint64 `json:"round"`
Encoding string `json:"encoding,omitempty"`
Result string `json:"result"`
}
// The local archive of testdata/releases: rounds 1000 to 1004, with 1002
// and 1003, whose signatures the tests do not hold, written as zeros.
const (
ArchiveFile = "archive_1000_1004.bin"
archiveFirst = 1000
archiveCount = 5
)
// ReleaseFileName is the name of the .dkr of round in testdata/releases.
func ReleaseFileName(round uint64) string { return strconv.FormatUint(round, 10) + ".dkr" }
// releaseObject returns the release object of r with the Quicknet chain
// hash, or with chain when it is not nil.
func releaseObject(r provider.Release, chain []byte) []byte {
if chain == nil {
chain = profile.Quicknet().ChainHash[:]
}
r.ChainHash = chain
b, err := provider.EncodeRelease(r)
if err != nil {
panic(err)
}
return b
}
// ReleaseFiles computes the files of testdata/releases: the .dkr of each
// published round of the tests, and the local archive ArchiveFile.
func ReleaseFiles() (map[string][]byte, error) {
files := map[string][]byte{}
for _, round := range Rounds {
files[ReleaseFileName(round)] = releaseObject(Release(round), nil)
}
p := profile.Quicknet()
b, err := provider.EncodeArchiveHeader(p.ChainHash[:], archiveFirst, archiveCount)
if err != nil {
return nil, err
}
for round := uint64(archiveFirst); round < archiveFirst+archiveCount; round++ {
if _, ok := signatures[round]; ok {
b = append(b, Release(round).Signature...)
} else {
b = append(b, make([]byte, 48)...)
}
}
files[ArchiveFile] = b
return files, nil
}
// checkRelease is what step 10 of spec §63 does with a release that the
// caller supplies, for a DateKey of round: decode it, then verify it against
// the pinned Quicknet profile.
func checkRelease(b []byte, round uint64) (*provider.Release, error) {
r, err := provider.ParseRelease(b)
if err != nil {
return nil, err
}
return &r, provider.Verify(profile.Quicknet(), provider.Condition{Round: round}, r)
}
// ReleaseVectors computes testdata/vectors/release.json, and fails if a
// vector does not get the result it is written for.
func ReleaseVectors() (ReleaseVectorFile, error) {
p := profile.Quicknet()
f := ReleaseVectorFile{
Spec: SpecVersion,
Description: "The release object, the content of a .dkr file (spec v0.15, §47.1), and drand's JSON as the input of the caller, " +
"each checked against the pinned Quicknet profile and the round of a DateKey as step 10 of spec §63 checks a release that the caller supplies; " +
"and the lookups of a local release archive (spec v0.15, §50). See testdata/README.md.",
Profile: p.ID,
}
var errs []error
ok := ResultOK
nonCanonical, unsupported := datekeys.ErrNonCanonicalCBOR.Code(), datekeys.ErrUnsupportedVersion.Code()
mismatch, roundMismatch, invalid := datekeys.ErrProfileMismatch.Code(), datekeys.ErrRoundMismatch.Code(), datekeys.ErrReleaseInvalid.Code()
r1000, r1001 := Release(1000), Release(1001)
other := p.ChainHash
other[31] ^= 1
xPlusP, err := AddModulus(Release(XPlusPRound).Signature, 0)
if err != nil {
return f, err
}
// raw writes a release object field by field, with the encoder of the
// codec, so that it may break the schema.
raw := func(fields ...func(e *codec.Encoder)) []byte {
var e codec.Encoder
e.Map(len(fields))
for _, w := range fields {
w(&e)
}
b, err := e.Out()
if err != nil {
panic(err)
}
return b
}
key := func(k uint64, w func(e *codec.Encoder)) func(e *codec.Encoder) {
return func(e *codec.Encoder) { e.Uint(k); w(e) }
}
text := func(s string) func(e *codec.Encoder) { return func(e *codec.Encoder) { e.Text(s) } }
uint_ := func(v uint64) func(e *codec.Encoder) { return func(e *codec.Encoder) { e.Uint(v) } }
bstr := func(b []byte) func(e *codec.Encoder) { return func(e *codec.Encoder) { e.Bstr(b) } }
fields := func(tag string, version uint64, chain []byte, round uint64, sig []byte) []func(e *codec.Encoder) {
return []func(e *codec.Encoder){key(0, text(tag)), key(1, uint_(version)), key(2, bstr(chain)), key(3, uint_(round)), key(4, bstr(sig))}
}
good := func(round uint64) []func(e *codec.Encoder) {
return fields(provider.ReleaseTypeTag, 1, p.ChainHash[:], round, Release(round).Signature)
}
obj1000 := releaseObject(r1000, nil)
pad := func(b []byte, n int) []byte { return append(bytes.Clone(b), make([]byte, n-len(b))...) }
withSig := func(sig []byte) []byte { return raw(fields(provider.ReleaseTypeTag, 1, p.ChainHash[:], 1000, sig)...) }
objects := []struct {
name string
enc []byte
round uint64
want string
}{
{"round 1000", obj1000, 1000, ok},
{"round 1001", releaseObject(r1001, nil), 1001, ok},
{"round 1004", releaseObject(Release(1004), nil), 1004, ok},
{"round 2000", releaseObject(Release(2000), nil), 2000, ok},
// Size: the object has no frame, and is checked before it is decoded.
{"no byte", []byte{}, 1000, nonCanonical},
{"1025 bytes: the object of round 1000 followed by zeros", pad(obj1000, 1025), 1000, nonCanonical},
{"1025 bytes of an object of version 2: the size first", pad(raw(key(0, text(provider.ReleaseTypeTag)), key(1, uint_(2))), 1025), 1000, nonCanonical},
// Type and version (spec §69.1, layer 2).
{"type tag of the Provider Profile, version 2", raw(fields(profile.TypeTag, 2, p.ChainHash[:], 1000, r1000.Signature)...), 1000, nonCanonical},
{"type tag in upper case", raw(fields(strings.ToUpper(provider.ReleaseTypeTag), 1, p.ChainHash[:], 1000, r1000.Signature)...), 1000, nonCanonical},
{"version 2", raw(fields(provider.ReleaseTypeTag, 2, p.ChainHash[:], 1000, r1000.Signature)...), 1000, unsupported},
{"version 2, an unknown key and another chain", raw(append(fields(provider.ReleaseTypeTag, 2, other[:], 1000, r1000.Signature), key(5, uint_(0)))...), 1000, unsupported},
{"version 0", raw(fields(provider.ReleaseTypeTag, 0, p.ChainHash[:], 1000, r1000.Signature)...), 1000, unsupported},
{"keys 0 and 1 swapped", raw(key(1, uint_(1)), key(0, text(provider.ReleaseTypeTag)), key(2, bstr(p.ChainHash[:])), key(3, uint_(1000)), key(4, bstr(r1000.Signature))), 1000, nonCanonical},
// Encoding and schema (layer 3).
{"a byte after the object", append(bytes.Clone(obj1000), 0), 1000, nonCanonical},
{"without the signature", raw(good(1000)[:4]...), 1000, nonCanonical},
{"a key 5", raw(append(good(1000), key(5, uint_(0)))...), 1000, nonCanonical},
{"chain hash of 31 bytes", raw(fields(provider.ReleaseTypeTag, 1, p.ChainHash[:31], 1000, r1000.Signature)...), 1000, nonCanonical},
{"chain hash as text", raw(key(0, text(provider.ReleaseTypeTag)), key(1, uint_(1)), key(2, text(p.ChainHashHex())), key(3, uint_(1000)), key(4, bstr(r1000.Signature))), 1000, nonCanonical},
{"round 0", raw(fields(provider.ReleaseTypeTag, 1, p.ChainHash[:], 0, r1000.Signature)...), 1000, nonCanonical},
{"round 2^53", raw(fields(provider.ReleaseTypeTag, 1, p.ChainHash[:], 1<<53, r1000.Signature)...), 1000, nonCanonical},
{"round 1000 in four bytes", longRound(obj1000, len(raw(good(1000)[:3]...))), 1000, nonCanonical},
{"signature of no byte", withSig([]byte{}), 1000, nonCanonical},
{"signature of 97 bytes", withSig(append(bytes.Repeat(r1000.Signature, 2), 0)), 1000, nonCanonical},
{"map of indefinite length", append(append([]byte{0xbf}, obj1000[1:]...), 0xff), 1000, nonCanonical},
// The step: chain hash, round, signature, in this order.
{"another chain", releaseObject(r1000, other[:]), 1000, mismatch},
{"another chain, another round and a signature of no point", raw(fields(provider.ReleaseTypeTag, 1, other[:], 1001, make([]byte, 48))...), 1000, mismatch},
{"round 1001 for a DateKey of round 1000", releaseObject(r1001, nil), 1000, roundMismatch},
{"another round and a signature of 47 bytes", withSigRound(raw, fields, p.ChainHash[:], 1001, r1001.Signature[:47]), 1000, roundMismatch},
{"the signature of round 1001 as round 1000", withSig(r1001.Signature), 1000, invalid},
{"signature of 47 bytes", withSig(r1000.Signature[:47]), 1000, invalid},
{"signature of 96 bytes, the size of G2", withSig(bytes.Repeat(r1000.Signature, 2)), 1000, invalid},
{"signature of zeros", withSig(make([]byte, 48)), 1000, invalid},
{"signature the point at infinity", withSig(Infinity(48)), 1000, invalid},
{"signature with the infinity flag and a payload", withSig(InfinityWithPayload(r1000.Signature)), 1000, invalid},
{"signature negated", withSig(Negated(r1000.Signature)), 1000, invalid},
{"signature of round 1004 with x + p", withSigRound(raw, fields, p.ChainHash[:], XPlusPRound, xPlusP), XPlusPRound, invalid},
}
for _, c := range objects {
v := ReleaseVector{Name: c.name, Encoding: hex.EncodeToString(c.enc), Round: c.round}
r, err := checkRelease(c.enc, c.round)
v.Result, v.Release = Result(err), fixtureRelease(r)
if err != nil {
v.Text = err.Error()
}
if v.Result != c.want {
errs = append(errs, fmt.Errorf("release object %q: %s, want %s (%v)", c.name, v.Result, c.want, err))
}
f.Objects = append(f.Objects, v)
}
s1000, s1001 := hex.EncodeToString(r1000.Signature), hex.EncodeToString(r1001.Signature)
jsons := []struct {
name string
input string
round uint64
want string
}{
{"the answer of a relay, API v2", `{"round":1000,"signature":"` + s1000 + `"}`, 1000, ok},
{"with randomness, API v1", `{"round":1000,"randomness":"` + randomness(r1000.Signature) + `","signature":"` + s1000 + `"}`, 1000, ok},
{"spaces and a line feed before and after", " \n" + `{"round": 1000, "signature": "` + strings.ToUpper(s1000) + `"}` + "\n", 1000, ok},
{"randomness of another signature", `{"round":1000,"randomness":"` + randomness(r1001.Signature) + `","signature":"` + s1000 + `"}`, 1000, invalid},
{"without signature", `{"round":1000}`, 1000, invalid},
{"without round", `{"signature":"` + s1000 + `"}`, 1000, invalid},
{"signature not in hexadecimal", `{"round":1000,"signature":"` + s1000[:94] + `zz"}`, 1000, invalid},
{"round as a string", `{"round":"1000","signature":"` + s1000 + `"}`, 1000, invalid},
{"not JSON", `{"round":1000,`, 1000, invalid},
{"8193 bytes", `{"round":1000,"signature":"` + s1000 + `"}` + strings.Repeat(" ", 8193-len(`{"round":1000,"signature":"`+s1000+`"}`)), 1000, invalid},
{"round 1001 for a DateKey of round 1000", `{"round":1001,"signature":"` + s1001 + `"}`, 1000, roundMismatch},
{"the signature of round 1001 as round 1000", `{"round":1000,"signature":"` + s1001 + `"}`, 1000, invalid},
}
for _, c := range jsons {
v := ReleaseVector{Name: c.name, Input: c.input, Round: c.round}
r, err := checkRelease([]byte(c.input), c.round)
v.Result, v.Release = Result(err), fixtureRelease(r)
if err != nil {
v.Text = err.Error()
}
if v.Result != c.want {
errs = append(errs, fmt.Errorf("drand JSON %q: %s, want %s (%v)", c.name, v.Result, c.want, err))
}
f.JSON = append(f.JSON, v)
}
files, err := ReleaseFiles()
if err != nil {
return f, err
}
archive := files[ArchiveFile]
header, err := provider.EncodeArchiveHeader(p.ChainHash[:], archiveFirst, archiveCount)
if err != nil {
return f, err
}
f.Archive = ArchiveVectors{File: "releases/" + ArchiveFile, Header: hex.EncodeToString(header)}
a := provider.NewArchive(bytes.NewReader(archive), int64(len(archive)))
for _, round := range []uint64{999, 1000, 1001, 1002, 1003, 1004, 1005} {
l := ArchiveLookup{Round: round}
b, err := a.Supply(p, provider.Condition{Round: round})
if err == nil {
l.Encoding = hex.EncodeToString(b)
_, err = checkRelease(b, round)
}
l.Result = Result(err)
_, known := signatures[round]
if want := map[bool]string{true: ok, false: datekeys.ErrReleaseUnavailable.Code()}[known]; l.Result != want {
errs = append(errs, fmt.Errorf("archive round %d: %s, want %s (%v)", round, l.Result, want, err))
}
if l.Result == ok && !bytes.Equal(b, files[ReleaseFileName(round)]) {
errs = append(errs, fmt.Errorf("archive round %d: another object than its .dkr", round))
}
f.Archive.Lookups = append(f.Archive.Lookups, l)
}
return f, errors.Join(errs...)
}
// longRound returns obj, a release object of round 1000 whose key 3 is at
// offset at, with the round written in four bytes instead of two.
func longRound(obj []byte, at int) []byte {
if !bytes.Equal(obj[at:at+4], []byte{0x03, 0x19, 0x03, 0xe8}) {
panic("testkit: key 3 is not round 1000 in two bytes")
}
out := append(bytes.Clone(obj[:at]), 0x03, 0x1a, 0x00, 0x00, 0x03, 0xe8)
return append(out, obj[at+4:]...)
}
// withSigRound writes a release object of the Quicknet type with the given
// chain, round and signature.
func withSigRound(raw func(...func(*codec.Encoder)) []byte, fields func(string, uint64, []byte, uint64, []byte) []func(*codec.Encoder), chain []byte, round uint64, sig []byte) []byte {
return raw(fields(provider.ReleaseTypeTag, 1, chain, round, sig)...)
}
// fixtureRelease is r as JSON, or nil.
func fixtureRelease(r *provider.Release) *FixtureRelease {
if r == nil {
return nil
}
f := &FixtureRelease{Round: r.Round, Signature: hex.EncodeToString(r.Signature)}
if r.ChainHash != nil {
f.ChainHash = hex.EncodeToString(r.ChainHash)
}
return f
}
// randomness is the randomness drand derives from a signature: its SHA-256,
// in hexadecimal.
func randomness(sig []byte) string {
sum := sha256.Sum256(sig)
return hex.EncodeToString(sum[:])
}

@ -279,6 +279,20 @@ func TestVectorFilesAreCurrent(t *testing.T) {
if err != nil {
t.Fatal(err)
}
releases, err := testkit.ReleaseVectors()
if err != nil {
t.Fatal(err)
}
files, err := testkit.ReleaseFiles()
if err != nil {
t.Fatal(err)
}
for name, want := range files {
got, err := os.ReadFile("../../testdata/releases/" + name)
if err != nil || !bytes.Equal(got, want) {
t.Errorf("testdata/releases/%s is stale: run go run ./internal/testkit/genfixtures -out testdata", name)
}
}
for _, v := range []struct {
file string
want any
@ -295,6 +309,7 @@ func TestVectorFilesAreCurrent(t *testing.T) {
{"wordkey.json", words, &testkit.WordKeyVectorFile{}},
{"resolved_ip.json", resolved, &testkit.ResolvedIPVectorFile{}},
{"tlock_steps.json", steps, &testkit.TlockStepsFile{}},
{"release.json", releases, &testkit.ReleaseVectorFile{}},
} {
if err := testkit.ReadJSON("../../testdata/vectors/"+v.file, v.got); err != nil {
t.Fatal(err)

@ -0,0 +1,175 @@
package provider
import (
"bytes"
"fmt"
"io"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/profile"
)
// Schema constants of the header of a release archive, an informative format
// (spec v0.15, §50).
const (
ArchiveTypeTag = "datekeys-release-archive"
ArchiveSchemaVersion = 1
)
// maxArchiveHeader bounds the header of an archive: its five keys take at
// most 1 + 26 + 2 + 35 + 9 + 9 bytes.
const maxArchiveHeader = 128
// archiveKeys is the number of keys of the header, all required.
const archiveKeys = 5
// archiveHeader is the CBOR map at the start of an archive.
type archiveHeader struct {
ChainHash []byte // key 2, 32 bytes
First uint64 // key 3, the first round, 1..2^53-1
Count uint64 // key 4, the number of rounds, 1..2^53-1
}
func (h *archiveHeader) encode(e *codec.Encoder) {
e.Map(archiveKeys)
e.Uint(0)
e.Text(ArchiveTypeTag)
e.Uint(1)
e.Uint(ArchiveSchemaVersion)
e.Uint(2)
e.Bstr(h.ChainHash)
e.Uint(3)
e.Uint(h.First)
e.Uint(4)
e.Uint(h.Count)
}
func (h *archiveHeader) decode(d *codec.Decoder) error {
pairs, err := d.Map(archiveKeys)
if err != nil {
return err
}
if pairs != archiveKeys {
return fmt.Errorf("%d keys, want all %d", pairs, archiveKeys)
}
for want := range uint64(archiveKeys) {
k, err := d.Key()
if err != nil {
return err
}
if k != want {
return fmt.Errorf("key %d where key %d was expected", k, want)
}
switch k {
case 0:
_, err = d.Text(len(ArchiveTypeTag))
case 1:
_, err = d.Uint(ArchiveSchemaVersion)
case 2:
h.ChainHash, err = d.Bstr(32, 32)
case 3:
h.First, err = d.Uint(codec.MaxSafeUint)
case 4:
h.Count, err = d.Uint(codec.MaxSafeUint)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
}
return d.EndMap()
}
// EncodeArchiveHeader returns the header of an archive of count rounds of
// the chain chainHash from the round first (spec v0.15, §50). The signatures
// follow it, one after another, each with the length of a signature of the
// chain, and a round the archive lacks is written as zeros.
func EncodeArchiveHeader(chainHash []byte, first, count uint64) ([]byte, error) {
if len(chainHash) != 32 || first == 0 || count == 0 || first > codec.MaxSafeUint-count+1 {
return nil, fmt.Errorf("provider: archive header: chain hash of %d bytes, rounds %d to %d + %d - 1", len(chainHash), first, first, count)
}
h := archiveHeader{ChainHash: chainHash, First: first, Count: count}
var e codec.Encoder
h.encode(&e)
return e.Out()
}
// IsArchive reports whether b, the start of a file, is the start of a
// release archive: a map whose type tag is ArchiveTypeTag.
func IsArchive(b []byte) bool {
tag, _, err := codec.Peek(b)
return err == nil && tag == ArchiveTypeTag
}
// Archive is a local release archive, the informative format of spec v0.15,
// §50: a header in deterministic CBOR, {0: "datekeys-release-archive", 1: 1,
// 2: chain_hash, 3: first round, 4: number of rounds}, followed by the
// signatures, so that the one of round r starts at the end of the header plus
// (r - first)·n, with n the length of a signature of the chain, 48 bytes in
// Quicknet. A round written as zeros is missing.
//
// A local archive is a release in hand: it implements Supplier, and its
// entry is decoded and verified at step 10 of spec §63 like a .dkr. A round
// it lacks, a header it cannot read, an archive of another chain or of
// another length are failures to supply a release, ErrReleaseUnavailable at
// step 9: the format is informative and has no codes of its own.
type Archive struct {
r io.ReaderAt
size int64
}
var _ Supplier = (*Archive)(nil)
// NewArchive returns the archive of size bytes read from r. It reads nothing
// until Supply.
func NewArchive(r io.ReaderAt, size int64) *Archive { return &Archive{r: r, size: size} }
// Supply implements Supplier: it returns the release object of the round of
// c, with the chain hash of the header of the archive.
func (a *Archive) Supply(p *profile.Profile, c Condition) ([]byte, error) {
unavailable := func(format string, args ...any) error {
return fmt.Errorf("provider: release archive: "+format+": %w", append(args, datekeys.ErrReleaseUnavailable)...)
}
head := make([]byte, min(a.size, maxArchiveHeader))
if _, err := a.r.ReadAt(head, 0); err != nil && err != io.EOF {
return nil, unavailable("%v", err)
}
if err := codec.CheckSchema(head, ArchiveTypeTag, ArchiveSchemaVersion); err != nil {
return nil, unavailable("not an archive of version %d", ArchiveSchemaVersion)
}
var h archiveHeader
d := codec.NewDecoder(head)
if err := h.decode(d); err != nil {
return nil, unavailable("its header does not decode: %v", err)
}
// The header is the deterministic encoding of what it says: its length
// is that of the encoding, and the signatures follow it.
var e codec.Encoder
h.encode(&e)
enc, err := e.Out()
if err != nil || !bytes.Equal(enc, head[:min(len(enc), len(head))]) {
return nil, unavailable("its header is not the deterministic encoding of its value")
}
if !bytes.Equal(h.ChainHash, p.ChainHash[:]) {
return nil, unavailable("archive of chain %s, the pinned profile %s is chain %s", chainHashHex(h.ChainHash), p.ID, p.ChainHashHex())
}
if h.First == 0 || h.Count == 0 || c.Round < h.First || c.Round-h.First >= h.Count {
return nil, unavailable("round %d is not in the archive, which holds %d rounds from %d", c.Round, h.Count, h.First)
}
scheme, err := p.DrandScheme()
if err != nil {
return nil, unavailable("%v", err)
}
n := uint64(scheme.SigGroup.PointLen())
if want := uint64(len(enc)) + h.Count*n; h.Count > (1<<62)/n || uint64(a.size) != want {
return nil, unavailable("%d bytes, its header announces %d rounds of %d bytes", a.size, h.Count, n)
}
sig := make([]byte, n)
if _, err := a.r.ReadAt(sig, int64(uint64(len(enc))+(c.Round-h.First)*n)); err != nil {
return nil, unavailable("%v", err)
}
if bytes.Equal(sig, make([]byte, n)) {
return nil, unavailable("round %d is missing: its entry is zeros", c.Round)
}
return EncodeRelease(Release{ChainHash: h.ChainHash, Round: c.Round, Signature: sig})
}

@ -7,8 +7,12 @@
package provider
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"strings"
"github.com/drand/drand/v2/common"
@ -26,6 +30,10 @@ type Condition struct {
type Release struct {
Round uint64
Signature []byte
// ChainHash is the chain the release names, key 2 of a release object
// (spec v0.15, §47.1), or nil when it names none, as the answer of a
// relay and drand's JSON. Verify compares it with the pinned profile.
ChainHash []byte
}
// ReleaseSource fetches the release of a condition. Callers always verify the
@ -57,12 +65,15 @@ func (f ReleaseSourceFunc) Fetch(ctx context.Context, p *profile.Profile, c Cond
}
// Verify checks a release locally against the pinned profile (spec §17, §51),
// in the order of spec §63 step 10: the expected round (ErrRoundMismatch),
// in the order of spec §63 step 10: the chain hash it names, if any, which
// must be that of the pinned profile (ErrProfileMismatch, spec v0.15); the
// expected round (ErrRoundMismatch),
// then the signature (ErrReleaseInvalid), which must be the canonical
// encoding of a point of the signature group of the scheme other than the
// point at infinity (spec §12.2), with the length of that group, and a valid
// BLS signature of the round under the pinned public key. The chain hash is
// covered because the pinned public key is bound to it by profile.Validate.
// covered too because the pinned public key is bound to it by
// profile.Validate.
//
// The BLS verification of drand decodes the signature with the canonical
// decoder of kilic/bls12-381, which rejects every other encoding, and the
@ -72,6 +83,9 @@ func Verify(p *profile.Profile, c Condition, r Release) error {
if c.Round == 0 || c.Round > p.MaxRound() {
return fmt.Errorf("provider: round %d outside the range of %s: %w", c.Round, p.ID, datekeys.ErrDateKeyInvalid)
}
if r.ChainHash != nil && !bytes.Equal(r.ChainHash, p.ChainHash[:]) {
return fmt.Errorf("provider: release of chain %s, the pinned profile %s is chain %s: %w", chainHashHex(r.ChainHash), p.ID, p.ChainHashHex(), datekeys.ErrProfileMismatch)
}
if r.Round != c.Round {
return fmt.Errorf("provider: release for round %d, expected %d: %w", r.Round, c.Round, datekeys.ErrRoundMismatch)
}
@ -92,3 +106,10 @@ func Verify(p *profile.Profile, c Condition, r Release) error {
}
return nil
}
// randomnessMatches reports whether the randomness of a drand answer, in
// hexadecimal, is SHA-256 of its signature, as drand defines it.
func randomnessMatches(randomness string, signature []byte) bool {
sum := sha256.Sum256(signature)
return strings.EqualFold(randomness, hex.EncodeToString(sum[:]))
}

@ -0,0 +1,208 @@
package provider
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/profile"
)
// Schema constants of the release object, the content of a .dkr file (spec
// v0.15, §47.1).
const (
ReleaseTypeTag = "datekeys-release"
ReleaseSchemaVersion = 1
)
// Limits of the release object (spec v0.15, §47.1). The object has no frame:
// a file or an input of more than MaxReleaseObjectSize bytes is rejected
// before it is decoded, with ErrNonCanonicalCBOR, and no valid encoding comes
// close to it. MaxSignatureLen is the longest compressed point of
// BLS12-381, one of G2; Quicknet signs with 48 bytes, a point of G1.
const (
MaxReleaseObjectSize = 1024
MaxSignatureLen = 96
)
// MaxReleaseJSONSize bounds drand's JSON, which a reader accepts too as the
// input of the caller (spec v0.15, §47.1). It is the bound of a relay
// response in provider/drand.
const MaxReleaseJSONSize = 8 << 10
// releaseKeys is the number of keys of the release object, all required.
const releaseKeys = 5
// releaseWire is the CBOR map of the release object, keys 2 to 4; keys 0 and
// 1 are the constants ReleaseTypeTag and ReleaseSchemaVersion.
type releaseWire struct {
ChainHash []byte // key 2, 32 bytes
Round uint64 // key 3, 1..2^53-1
Signature []byte // key 4, 1..MaxSignatureLen bytes
}
func (w *releaseWire) encode(e *codec.Encoder) {
e.Map(releaseKeys)
e.Uint(0)
e.Text(ReleaseTypeTag)
e.Uint(1)
e.Uint(ReleaseSchemaVersion)
e.Uint(2)
e.Bstr(w.ChainHash)
e.Uint(3)
e.Uint(w.Round)
e.Uint(4)
e.Bstr(w.Signature)
}
// decode reads the map with every CDDL rule of the release object, all of
// them ErrNonCanonicalCBOR: what each field means against the pinned profile
// and the DateKey is checked by Verify, at step 10.
func (w *releaseWire) decode(d *codec.Decoder) error {
pairs, err := d.Map(releaseKeys)
if err != nil {
return err
}
if pairs != releaseKeys {
return fmt.Errorf("%d keys, want all %d: %w", pairs, releaseKeys, datekeys.ErrNonCanonicalCBOR)
}
for want := range uint64(releaseKeys) {
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(ReleaseTypeTag))
case 1:
_, err = d.Uint(ReleaseSchemaVersion)
case 2:
w.ChainHash, err = d.Bstr(32, 32)
case 3:
if w.Round, err = d.Uint(codec.MaxSafeUint); err == nil && w.Round == 0 {
err = fmt.Errorf("round 0: %w", datekeys.ErrNonCanonicalCBOR)
}
case 4:
w.Signature, err = d.Bstr(1, MaxSignatureLen)
}
if err != nil {
return fmt.Errorf("key %d: %w", k, err)
}
}
return d.EndMap()
}
// EncodeRelease returns the release object of r, the content of a .dkr file
// (spec v0.15, §47.1): its chain hash, its round and its signature. It does
// not verify the release: Verify does, against the pinned profile.
func EncodeRelease(r Release) ([]byte, error) {
switch {
case len(r.ChainHash) != 32:
return nil, fmt.Errorf("provider: release object: chain hash of %d bytes, want 32: %w", len(r.ChainHash), datekeys.ErrNonCanonicalCBOR)
case r.Round == 0 || r.Round > codec.MaxSafeUint:
return nil, fmt.Errorf("provider: release object: round %d outside 1..%d: %w", r.Round, uint64(codec.MaxSafeUint), datekeys.ErrNonCanonicalCBOR)
case len(r.Signature) == 0 || len(r.Signature) > MaxSignatureLen:
return nil, fmt.Errorf("provider: release object: signature of %d bytes outside 1..%d: %w", len(r.Signature), MaxSignatureLen, datekeys.ErrNonCanonicalCBOR)
}
w := releaseWire{ChainHash: r.ChainHash, Round: r.Round, Signature: r.Signature}
var e codec.Encoder
w.encode(&e)
return e.Out()
}
// DecodeRelease decodes a release object (spec v0.15, §47.1) with the layers
// of spec §69.1 that it has: its size, at most MaxReleaseObjectSize bytes;
// its type and schema version (ErrNonCanonicalCBOR, then
// ErrUnsupportedVersion); its encoding and schema (ErrNonCanonicalCBOR). The
// release it returns names its chain, and Verify checks it against the pinned
// profile at step 10 of spec §63: the chain hash, the round, the signature.
func DecodeRelease(b []byte) (Release, error) {
if len(b) == 0 || len(b) > MaxReleaseObjectSize {
return Release{}, fmt.Errorf("provider: release object of %d bytes, outside 1..%d: %w", len(b), MaxReleaseObjectSize, datekeys.ErrNonCanonicalCBOR)
}
if err := codec.CheckSchema(b, ReleaseTypeTag, ReleaseSchemaVersion); err != nil {
return Release{}, fmt.Errorf("provider: release object: %w", err)
}
var w releaseWire
if err := codec.Unmarshal(b, w.decode, w.encode); err != nil {
return Release{}, fmt.Errorf("provider: release object: %w", err)
}
return Release{Round: w.Round, Signature: w.Signature, ChainHash: w.ChainHash}, nil
}
// ParseRelease reads a release that the caller supplies: drand's JSON, when
// its first byte other than a JSON space is "{", or else a release object
// (spec v0.15, §47.1), with DecodeRelease. drand's JSON is the answer of a
// relay, {"round": …, "signature": "…"}, with an optional "randomness" that
// must be SHA-256 of the signature; it does not name its chain, so the
// release has no chain hash, and any failure to read it is
// ErrReleaseInvalid. It is accepted as input, never written.
func ParseRelease(b []byte) (Release, error) {
if t := bytes.TrimLeft(b, " \t\r\n"); len(t) > 0 && t[0] == '{' {
return parseDrandJSON(b)
}
return DecodeRelease(b)
}
// parseDrandJSON reads the JSON of a drand relay.
func parseDrandJSON(b []byte) (Release, error) {
if len(b) > MaxReleaseJSONSize {
return Release{}, fmt.Errorf("provider: drand JSON of %d bytes, larger than %d: %w", len(b), MaxReleaseJSONSize, datekeys.ErrReleaseInvalid)
}
var wire struct {
Round *uint64 `json:"round"`
Signature *string `json:"signature"`
Randomness string `json:"randomness"`
}
if err := json.Unmarshal(b, &wire); err != nil || wire.Round == nil || wire.Signature == nil {
return Release{}, fmt.Errorf("provider: drand JSON: malformed, or without round or signature: %w", datekeys.ErrReleaseInvalid)
}
sig, err := hex.DecodeString(*wire.Signature)
if err != nil {
return Release{}, fmt.Errorf("provider: drand JSON: signature is not hex: %w", datekeys.ErrReleaseInvalid)
}
if wire.Randomness != "" && !randomnessMatches(wire.Randomness, sig) {
return Release{}, fmt.Errorf("provider: drand JSON: randomness does not match the signature: %w", datekeys.ErrReleaseInvalid)
}
return Release{Round: *wire.Round, Signature: sig}, nil
}
// Supplier hands over a release that the caller has in hand (spec v0.15,
// §49, §63 step 9.c): a release object read from a .dkr file, drand's JSON
// that the person saved, or an entry of a local archive. It makes no network
// request, so capsule.Open asks it for the release without comparing its
// clock with the round time: a valid signature proves that the round was
// published.
//
// Supply returns the encoding of the release of c, as it is: a release
// object or drand's JSON, which capsule.Open decodes and verifies at step 10
// with the codes of that step. Without a release for c it returns an error
// that wraps datekeys.ErrReleaseUnavailable, the code of step 9.
type Supplier interface {
Supply(p *profile.Profile, c Condition) ([]byte, error)
}
// Encoded is a release in hand, already read: the bytes of a .dkr file or of
// drand's JSON. It supplies itself whatever the condition; step 10 compares
// its round with the DateKey.
type Encoded []byte
// Supply implements Supplier.
func (e Encoded) Supply(*profile.Profile, Condition) ([]byte, error) { return e, nil }
// NewReleaseObject returns the release object of a release of the profile p,
// with the chain hash of p: what a reader saves as a .dkr after verifying the
// release (spec v0.15, §62.1).
func NewReleaseObject(p *profile.Profile, r Release) ([]byte, error) {
r.ChainHash = p.ChainHash[:]
return EncodeRelease(r)
}
// chainHashHex is the chain hash of a release in the text of an error.
func chainHashHex(b []byte) string { return hex.EncodeToString(b) }

@ -0,0 +1,187 @@
package provider_test
import (
"bytes"
"encoding/hex"
"errors"
"os"
"testing"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
)
// Spec v0.15, §47.1: every vector of release.json, replayed from the file
// alone, gets its result and its text: decoding, then step 10 against the
// pinned profile and the round of the vector.
func TestReleaseVectors(t *testing.T) {
var f testkit.ReleaseVectorFile
if err := testkit.ReadJSON("../testdata/vectors/release.json", &f); err != nil {
t.Fatal(err)
}
p := profile.Quicknet()
check := func(t *testing.T, in []byte, v testkit.ReleaseVector) {
r, err := provider.ParseRelease(in)
if err == nil {
err = provider.Verify(p, provider.Condition{Round: v.Round}, r)
}
if got := testkit.Result(err); got != v.Result {
t.Fatalf("%s, want %s (%v)", got, v.Result, err)
}
if err != nil && err.Error() != v.Text {
t.Fatalf("text %q, want %q", err.Error(), v.Text)
}
}
for _, v := range f.Objects {
t.Run(v.Name, func(t *testing.T) {
b, err := hex.DecodeString(v.Encoding)
if err != nil {
t.Fatal(err)
}
check(t, b, v)
if v.Result == testkit.ResultOK {
// A valid object is the deterministic encoding of its value.
r, _ := provider.DecodeRelease(b)
if again, err := provider.EncodeRelease(r); err != nil || !bytes.Equal(again, b) {
t.Fatalf("encoding again: %x, %v", again, err)
}
}
})
}
for _, v := range f.JSON {
t.Run("JSON "+v.Name, func(t *testing.T) { check(t, []byte(v.Input), v) })
}
if len(f.Objects) < 30 || len(f.JSON) < 10 {
t.Fatalf("%d objects and %d JSON inputs", len(f.Objects), len(f.JSON))
}
}
// The local archive of testdata supplies the .dkr of each round it holds,
// and nothing for a round it lacks or outside it, for another chain, or when
// its length is not the one its header announces.
func TestArchive(t *testing.T) {
b, err := os.ReadFile("../testdata/releases/" + testkit.ArchiveFile)
if err != nil {
t.Fatal(err)
}
if !provider.IsArchive(b) || provider.IsArchive(mustRead(t, "../testdata/releases/1000.dkr")) {
t.Fatal("IsArchive")
}
p := profile.Quicknet()
a := provider.NewArchive(bytes.NewReader(b), int64(len(b)))
for _, round := range []uint64{1000, 1001, 1004} {
got, err := a.Supply(p, provider.Condition{Round: round})
if err != nil || !bytes.Equal(got, mustRead(t, "../testdata/releases/"+testkit.ReleaseFileName(round))) {
t.Fatalf("round %d: %x, %v", round, got, err)
}
}
for _, round := range []uint64{999, 1002, 1003, 1005, 2000} {
if _, err := a.Supply(p, provider.Condition{Round: round}); !onlyUnavailable(err) {
t.Fatalf("round %d: %v", round, err)
}
}
other := p.Clone()
other.ChainHash[0] ^= 1
short := provider.NewArchive(bytes.NewReader(b[:len(b)-1]), int64(len(b)-1))
notArchive := mustRead(t, "../testdata/releases/1000.dkr")
for name, c := range map[string]struct {
a *provider.Archive
p *profile.Profile
}{
"another chain": {a, other},
"a byte missing": {short, p},
"a .dkr": {provider.NewArchive(bytes.NewReader(notArchive), int64(len(notArchive))), p},
"no byte": {provider.NewArchive(bytes.NewReader(nil), 0), p},
} {
if _, err := c.a.Supply(c.p, provider.Condition{Round: 1000}); !onlyUnavailable(err) {
t.Fatalf("%s: %v", name, err)
}
}
}
// onlyUnavailable reports whether err wraps ErrReleaseUnavailable and no
// other normative code.
func onlyUnavailable(err error) bool {
for _, e := range datekeys.All() {
if errors.Is(err, e) != (e == datekeys.ErrReleaseUnavailable) {
return false
}
}
return true
}
func mustRead(t *testing.T, path string) []byte {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return b
}
// EncodeRelease never writes an object that DecodeRelease rejects, and a
// release of the profile carries its chain hash.
func TestEncodeRelease(t *testing.T) {
p := profile.Quicknet()
r := testkit.Release(1000)
for _, bad := range []provider.Release{
r,
{Round: 0, Signature: r.Signature, ChainHash: p.ChainHash[:]},
{Round: 1 << 53, Signature: r.Signature, ChainHash: p.ChainHash[:]},
{Round: 1000, ChainHash: p.ChainHash[:]},
{Round: 1000, Signature: make([]byte, 97), ChainHash: p.ChainHash[:]},
} {
if _, err := provider.EncodeRelease(bad); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
t.Fatalf("%+v: %v", bad, err)
}
}
b, err := provider.NewReleaseObject(p, r)
if err != nil || len(b) != 111 {
t.Fatalf("%d bytes, %v", len(b), err)
}
got, err := provider.DecodeRelease(b)
if err != nil || got.Round != 1000 || !bytes.Equal(got.Signature, r.Signature) || !bytes.Equal(got.ChainHash, p.ChainHash[:]) {
t.Fatalf("%+v, %v", got, err)
}
if _, err := provider.EncodeArchiveHeader(p.ChainHash[:], 0, 1); err == nil {
t.Fatal("archive from round 0")
}
}
// FuzzDecodeRelease: whatever the input, DecodeRelease returns exactly one
// normative code, and what it accepts encodes again to the same bytes.
func FuzzDecodeRelease(f *testing.F) {
f.Add(mustReadF(f, "../testdata/releases/1000.dkr"))
f.Add([]byte(`{"round":1000,"signature":"00"}`))
f.Fuzz(func(t *testing.T, b []byte) {
r, err := provider.ParseRelease(b)
if err != nil {
n := 0
for _, e := range datekeys.All() {
if errors.Is(err, e) {
n++
}
}
if n != 1 {
t.Fatalf("%d codes: %v", n, err)
}
return
}
if r.ChainHash == nil {
return
}
if again, err := provider.EncodeRelease(r); err != nil || !bytes.Equal(again, b) {
t.Fatalf("encoding again: %x, %v", again, err)
}
})
}
func mustReadF(f *testing.F, path string) []byte {
b, err := os.ReadFile(path)
if err != nil {
f.Fatal(err)
}
return b
}

@ -19,6 +19,7 @@ targets=(
"./codec FuzzEncodeImpliesWalk"
"./extension FuzzDecodeArray"
"./profile FuzzDecode"
"./provider FuzzDecodeRelease"
"./datekey FuzzParse"
"./agewrap FuzzStanzas"
"./accesskey FuzzDecode"

119
testdata/README.md vendored

@ -22,6 +22,10 @@ committed file changes: every file below is exactly what the implementation
computes today.
The `spec` field of every file is `"0.14"`, the version this module declares.
The branch `v0.15` adds what the draft v0.15 defines, the release object and
a release in the caller's hand (spec v0.15, §47.1, §63 step 9.c), and keeps
that field until the draft is approved: `vectors/release.json`, the files of
`releases/`, and the field `source` of `mutations.json`.
What v0.12 changes from v0.11, the texts of the verdicts of a certificate and
of a seal, the profile of a certificate and the rules of the addresses and of
the padding of a locator, is in the files: the verdicts and the lines of
@ -48,6 +52,9 @@ Conventions for every file:
| `vectors/dk1.json` | canonical `dk1_` strings, and rejected encodings with their code | §18, §19, §66 |
| `vectors/cbor.json` | the CBOR profile, and one block of vectors per schema, CONTROL_CBOR in the three formats | §58, CDDL |
| `vectors/tlock_ibe.json` | H2 of the tlock IBE: the serialization of an element of GT | §63 step 11 |
| `vectors/release.json` | the release object, the content of a `.dkr` file, and drand's JSON, each with the result of step 10; the lookups of a local release archive | §47.1, §50, §63 step 10 (v0.15) |
| `releases/<round>.dkr` | the release object of each published round of the tests: 1000, 1001, 1004 and 2000 | §47.1 (v0.15) |
| `releases/archive_1000_1004.bin` | a local release archive, the informative format of §50, of rounds 1000 to 1004, two of them missing | §50 (v0.15) |
| `vectors/tlock_steps.json` | steps 10 and 11 for Quicknet value by value: the message of a round, its hash to G1, and the decryption of a tlock stanza with H2, H4, H3 and the file key | §63 steps 10 and 11 (v0.14) |
| `vectors/padding.json` | the padding of formats 2 and 3: P for each content length L, and the length of PAYLOAD_AGE | §29.1 |
| `vectors/paths.json` | the paths of a format 3 head: the rules of one entry, and those of the paths of a head | §29.5 |
@ -60,7 +67,7 @@ Conventions for every file:
| `vectors/resolved_ip.json` | the IP address a name of a locator resolves to, NAT64 included, and whether a reader may connect | §44.1 (v0.13) |
| `vectors/wordkey.json` | the key of words: the words of a text, what a writer refuses, and the identity the words derive | §38.1, §64 |
| `vectors/locator.json` | the extension `datekeys.capsule` of a `.dkk`, its envelope and its locator, and what a reader rejects and uses of them | §44.1, §64 |
| `vectors/mutations.json` | the mutation corpus: the 178 mutations of §64 and further cases | §63, §64 |
| `vectors/mutations.json` | the mutation corpus: the 178 mutations of §64 and further cases, each with the kind of its release source | §63, §64 |
| `vectors/inspect_differential.json` | 5110 mutations of fourteen fixtures with the verdict of steps 1 to 8 | §63 |
| `fixtures/<name>.dkc`, `<name>.json` | official capsules and every intermediate value | §67 |
| `fixtures/<name>.dkk`, `<name>.dkk.json` | official access keys | §68 |
@ -312,6 +319,58 @@ serialization at once. The same 576 bytes with the twelve coordinates of Fp in
reverse order, c0 first at every level as `Fp12.toBytes` of `@noble/curves`
writes them, give `0118eea9d5971745f71e3c94926f1717` and another FK_TIME.
## `vectors/release.json` and `releases/`
The release object of spec v0.15, §47.1: the release of a round as a file,
`.dkr`, that a person keeps next to the capsule. It is deterministic CBOR with
the profile of §58, a map of five keys, all required:
```text
0 → "datekeys-release"
1 → 1
2 → chain_hash (32 bytes)
3 → round (1 to 2^53 − 1)
4 → signature (1 to 96 bytes; 48 in Quicknet)
```
The object of a round above 255 measures 111 bytes. `releases/<round>.dkr` is
the object of each published round the fixtures use, 1000, 1001, 1004 and
2000, with the Quicknet chain hash: the release that opens each fixture, as a
file.
`release.json` has three lists:
- `objects`: an `encoding` in hexadecimal, the `round` of the DateKey it is
checked against, and the `result`, `ok` or a code, with the `text` of the
error of the reference. When the encoding decodes, `release` is what it
says: `round`, `signature` and `chain_hash`. The checks are those of step
10 for a release in the caller's hand (spec v0.15, §63): the size of the
object, from 1 to 1024 bytes, before anything else; its type and version;
its encoding and schema (all three `ERR_NON_CANONICAL_CBOR`, but a version
other than 1, `ERR_UNSUPPORTED_VERSION`); then, against the pinned profile
and the DateKey, the chain hash (`ERR_PROFILE_MISMATCH`), the round
(`ERR_ROUND_MISMATCH`) and the signature (`ERR_RELEASE_INVALID`), in that
order. A case with several faults gets the code of the first.
- `json`: drand's JSON, which a reader accepts too as the input of the
caller, never as the release object: an `input` whose first byte other than
a JSON space is `{`. It has `round` and `signature` in hexadecimal, of either
case, and may have `randomness`, which must then be SHA-256 of the
signature; it names no chain, so its `release` has no `chain_hash`. Any
failure to read it is `ERR_RELEASE_INVALID`, and so is one of more than
8192 bytes; then the round and the signature, as for an object.
- `archive`: the lookups of the local archive `releases/archive_1000_1004.bin`,
an informative format (spec v0.15, §50). It is the `header`, the
deterministic CBOR map `{0: "datekeys-release-archive", 1: 1, 2: chain_hash,
3: first round, 4: number of rounds}`, followed by the 48-byte signature of
each round, one after another; a round the archive lacks is 48 zero bytes.
This one holds rounds 1000 to 1004, and 1002 and 1003 are zeros. Each lookup
gives a `round` and its `result`: `ok` with the `encoding` of the release
object the archive supplies, the `.dkr` of that round, or
`ERR_RELEASE_UNAVAILABLE` for a round the archive lacks or does not cover.
The texts are those of the reference, for an implementation that wants to
match them; the codes are normative.
## `vectors/tlock_steps.json`
Steps 10 and 11 of spec §63 for Quicknet, every intermediate value written
@ -753,6 +812,7 @@ reading flow (`capsule.Open`, §63) must fail.
"spec": true,
"dkc": { "base": "time_only.dkc", "edits": [[4, 1, "04"]] },
"release": { "round": 1000, "signature": "b446…" },
"source": "supplied",
"now": "2023-08-23T15:59:24Z",
"registry": "default",
"network": false,
@ -770,8 +830,9 @@ reading flow (`capsule.Open`, §63) must fail.
named "format 2: …" (66 to 98), the 23 of the list of format 2 (99 to 121),
5 further cases (122 to 126), the same 33 on the format 3 fixtures, named
"format 3: …" (127 to 159), the 48 of the list of format 3 (160 to 207), the
8 of the list of v0.11 that a capsule can hold (208 to 215), and 3 further
cases (216 to 218). A line of the lists of §64 with several values, such as
8 of the list of v0.11 that a capsule can hold (208 to 215), 3 further
cases (216 to 218), and the 4 cases of the source of the release of v0.15
(219 to 222). A line of the lists of §64 with several values, such as
"AREA_LEN 0, 511, 513 o 66048", is one case for each.
- `dkc`: the capsule, as edits of a fixture (see above). The reader gets it as a
seekable file, so that the `capsule_digest` of an offered `.dkk` is checked
@ -784,13 +845,23 @@ reading flow (`capsule.Open`, §63) must fail.
is asked for. The reader must verify it (§51): a case may serve a release of
another round, a round with the signature of another, or a signature that is
not the canonical encoding of a point (§12.2). `null` means that no release
is available (`ERR_RELEASE_UNAVAILABLE`). The release is supplied directly,
as the caller's own, so one that breaks the rules of step 10 gets the code
of step 10; a source that fetched it over a network would have discarded
it, and the code would be `ERR_RELEASE_UNAVAILABLE` at step 9 (spec §63
steps 9 and 10).
- `now`: the reader's clock, RFC 3339. No release is requested before the round
time of the DateKey.
is available (`ERR_RELEASE_UNAVAILABLE`). `chain_hash`, present in two
cases only, is the chain the release object names when it is not the
Quicknet chain.
- `source` (v0.15): what kind of source answers, spec v0.15 §63 step 9:
- `supplied`: the release is in the caller's hand, as a `.dkr` would be.
The reader is given the release object of `release`, with the Quicknet
chain hash unless `chain_hash` says another, and decodes and verifies it
at step 10: one that breaks a rule of step 10 gets the code of step 10.
The clock is not compared with the round time (step 9.c): with a `now`
before it, the capsule opens all the same. Every case but three is
`supplied`.
- `network`: a network source, such as a drand relay. It is never asked
before the round time (step 9.c), and it verifies its answer with the
rules of step 10 and discards it when it fails, so the reader gets no
release: `ERR_RELEASE_UNAVAILABLE` at step 9.
- `now`: the reader's clock, RFC 3339. A network source is not asked before
the round time of the DateKey; a release in hand is not compared with it.
- `registry`: `default` pins exactly the Quicknet profile of
`profile_quicknet.json`; `empty` pins none.
- `extensions`: the extensions the application implements. Each entry is known
@ -803,9 +874,10 @@ reading flow (`capsule.Open`, §63) must fail.
data" and "known critical .dkk extension with invalid data", at steps 4, 14
and 9, depend on it. Absent: the application knows no extension, the state
of the base protocol V1.
- `network`: whether the failure may come after a release request. When false,
the reader must fail without requesting any release (§27, §63): every failure
of steps 1 to 8 and of step 9 before the request (9.a to 9.c).
- `network`: whether the failure may come after a release request, to a
network source or to the caller's release in hand. When false, the reader
must fail without requesting any release (§27, §63): every failure of steps
1 to 8 and of step 9 before the request (9.a to 9.c).
- `frozen`: the capsule was built once with age randomness; its bytes are kept
and never regenerated. These cases have no `base`.
- `error`, `step`: the expected code and the step of §63 that fails. For a
@ -815,6 +887,19 @@ reading flow (`capsule.Open`, §63) must fail.
format 3, and seven of the list of v0.11.
Every case reproduces offline: the recorded release stands in for the network.
What v0.15 changed in this file: every case gained `source`, `supplied` but
for three; the further case "round not reached yet", a valid release of round
1000 and a clock one nanosecond before its round time, was
`ERR_RELEASE_UNAVAILABLE` at step 9 and now opens (`ok`, step 0, with
`network` true), because the release is in the caller's hand; and four cases
were added at the end: the same capsule and clock with a network source,
still `ERR_RELEASE_UNAVAILABLE` at step 9 without any request; a release of
another round from a network source, discarded, `ERR_RELEASE_UNAVAILABLE` at
step 9, where the same release in hand is `ERR_ROUND_MISMATCH` at step 10;
and two release objects of another chain, `ERR_PROFILE_MISMATCH` at step 10,
one of them of another round too and with a clock behind. No other case
changed its result.
A reader that implements only steps 1 to 8 can replay every case whose `step` is
at most 8: 57 cases, 39 of them from §64. Steps 1 to 8 are summarised in "The
checks of steps 1 to 8" below.
@ -825,7 +910,8 @@ What happens between step 8 and the release request is spec §63 step 9: for
`time_and_key` only, an offered `.dkk` is checked as an object and then bound
to the capsule (9.a), at least one credential must be offered (9.b), and for
either policy a `now` before the round time of the DateKey fails without a
request (9.c); only then is the release requested. For `time_only` the
request to a network source (9.c), while a release in hand is not compared
with it; only then is the release requested. For `time_only` the
credentials play no part: the §64 case "access_policy=time_only with
time_and_key structure" offers a `.dkk` whose `capsule_digest` is that of the
unmutated capsule, and fails at step 12, not at step 9. The codes after the
@ -834,8 +920,9 @@ file (steps 11, 13 and 17), are those of spec §63 as well. In this corpus:
- identities are not examined before the release (spec §63 step 13);
- a `release` of `null` is `ERR_RELEASE_UNAVAILABLE` at step 9;
- every `release` is supplied directly, so an invalid one fails at step 10:
the source is not a network source, which would discard it at step 9;
- a `release` of a `supplied` case is in the caller's hand, so an invalid one
fails at step 10, and step 9.c does not apply to it; a `network` source
discards it at step 9, and is not asked before the round time;
- every `.dkk` offered decodes: the corpus checks step 9.a, not the decoding
of a `.dkk`, whose errors spec §63 also places at step 9.a.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long
Loading…
Cancel
Save

Powered by TurnKey Linux.