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>
2 days ago
|
|
|
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
|
Spec v0.15 draft: remove the .dkr file
The author's decision of 7 October 2026. A release saved next to a capsule
cannot exist when the capsule is made, and once the date comes the capsule
can be opened: such a file only opens it again and does not cover the real
case, someone opening it decades later when drand is gone and nobody saved
anything. Long-term recovery rests instead on archives and cache services
that keep the releases of all rounds; a reader asks for its round and
verifies the signature against the pinned key.
Spec: the .dkr extension (section 20, back to v0.14), sections 1, 4, 8, 45,
47.1, 49, 50 (rewritten), 53, 62.1 (rule 28 removed, rule 26 reworded),
63, 70, 73, 74 (the datekeys.release .dkk extension dropped too), 76
(the v0.15 block, with the discarded design) and the annex 79. The
release object, the chain hash at step 10, step 9.c option B and the
archive format stay.
Code: decrypt -save-release and the command datekeys release are gone,
with writeRelease and their tests; decrypt -release FILE stays. The
release objects of testdata/releases are now <round>.cbor, and
TestVectorFilesAreCurrent fails on a file the generator no longer writes.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 days ago
|
|
|
// entry is decoded and verified at step 10 of spec §63 like any release
|
|
|
|
|
// object. 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.
|
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>
2 days ago
|
|
|
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})
|
|
|
|
|
}
|