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
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)
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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[:])
|
||||
}
|
||||
@ -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})
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
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…
Reference in new issue