Spec v0.8.2 refinements: error precedence, trust model, strict order
Approved refinements, each recorded with its reproducible case in the
§76 v0.8.2 subsection:
- §69.1: layered error model with normative precedence (frame, type tag
and version, CBOR profile and CDDL, then fields with their own code in
ascending key order; across steps the §63 order decides), with a scope
paragraph for the optional steps 5, 6 and 8.
- §55.1: normative trust table per section (who can write it, from which
step it is bound, what it never proves); §72: security-relevant claims
go in CONTROL_CBOR or under a signature, .dkk data is advisory.
- §31/§54: extension arrays in strictly ascending unsigned byte order of
extension_id (one rule for order and uniqueness).
- Gaps a second implementation needed: §28.1 malformed age headers,
§15/§19 latest unlock time and dk1_ reading rules, §22/§23/§57 length
lower bounds, §63 step 8 tlock argument comparison and step 9 order,
§12.1 profile validation with the drand chain-hash formula, §74 table
of implementation limits.
Reference alignment: .dkk errors only at step 9.a (new
OpenOptions.AccessKeyFile, used by the CLI), CR/LF in dk1_ is
ERR_DATEKEY_INVALID, BODY_LEN 0 is ERR_INTEGRITY, nil identities are not
credentials, and AccessIdentity tries every identity on every stanza so
its verdict does not depend on their order. dk1.json gains three
vectors; every other testdata file is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
package profile_test
import (
"bytes"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"errors"
"maps"
"math/big"
"strings"
"testing"
"time"
"github.com/drand/drand/v2/crypto"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/profile"
)
// Spec §12.1, §69.1: a Provider Profile reports the code of its first failing
// layer: the type tag and the schema version, then the CDDL with the
// implementation limit on period (ERR_NON_CANONICAL_CBOR), then the rules
// of its fields (ERR_UNKNOWN_PROFILE), and last the chain-hash self-check,
// which relates several keys (ERR_PROFILE_MISMATCH).
func TestDecodePrecedence ( t * testing . T ) {
b , _ := profile . Quicknet ( ) . CanonicalCBOR ( )
m , err := cbortest . UnmarshalMap ( b )
if err != nil {
t . Fatal ( err )
}
encode := func ( edit func ( c map [ uint64 ] any ) ) [ ] byte {
c := maps . Clone ( m )
edit ( c )
out , err := cbortest . Marshal ( c )
if err != nil {
t . Fatal ( err )
}
return out
}
chain := func ( c map [ uint64 ] any ) { c [ 5 ] = make ( [ ] byte , 32 ) }
for _ , tc := range [ ] struct {
name string
edit func ( c map [ uint64 ] any )
want error
} {
{ "type tag of another schema and version 2" , func ( c map [ uint64 ] any ) { c [ 0 ] , c [ 1 ] = "datekeycap" , uint64 ( 2 ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "version 2, unknown key and invalid provider" , func ( c map [ uint64 ] any ) { c [ 1 ] , c [ 11 ] , c [ 3 ] = uint64 ( 2 ) , uint64 ( 0 ) , "Drand" } , datekeys . ErrUnsupportedVersion } ,
{ "period above the implementation limit and invalid provider" , func ( c map [ uint64 ] any ) { c [ 7 ] , c [ 3 ] = uint64 ( 86401 ) , "Drand" } , datekeys . ErrNonCanonicalCBOR } ,
{ "network of another CBOR type" , func ( c map [ uint64 ] any ) { c [ 4 ] = uint64 ( 7 ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "public key of another CBOR type and chain hash" , func ( c map [ uint64 ] any ) { c [ 6 ] = "key" ; chain ( c ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "invalid provider and chain hash" , func ( c map [ uint64 ] any ) { c [ 3 ] = "Drand" ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "public key above 1024 bytes and chain hash" , func ( c map [ uint64 ] any ) { c [ 6 ] = make ( [ ] byte , 1025 ) ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "genesis_time 0 and chain hash" , func ( c map [ uint64 ] any ) { c [ 8 ] = uint64 ( 0 ) ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "genesis_time 9999-12-31T23:59:59Z and chain hash" , func ( c map [ uint64 ] any ) { c [ 8 ] = uint64 ( profile . MaxUnixTime ) ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "scheme without tlock support and chain hash" , func ( c map [ uint64 ] any ) { c [ 9 ] = "pedersen-bls-chained" ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "public key not a point and chain hash" , func ( c map [ uint64 ] any ) { c [ 6 ] = bytes . Repeat ( [ ] byte { 0xff } , 96 ) ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "public key the identity and chain hash" , func ( c map [ uint64 ] any ) { k := make ( [ ] byte , 96 ) ; k [ 0 ] = 0xc0 ; c [ 6 ] = k ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
// Spec §12.1 rule 2: the key is a point of the prime-order subgroup.
// pedersen-bls-unchained has its keys on G1 (48 bytes); its generator
// passes rule 2 and only the chain hash fails, while a point of the
// curve outside the subgroup fails rule 2.
{ "G1 generator and chain hash" , func ( c map [ uint64 ] any ) { c [ 9 ] , c [ 6 ] = "pedersen-bls-unchained" , g1Generator ( t ) ; chain ( c ) } , datekeys . ErrProfileMismatch } ,
{ "G1 point outside the prime-order subgroup and chain hash" , func ( c map [ uint64 ] any ) { c [ 9 ] , c [ 6 ] = "pedersen-bls-unchained" , offSubgroupG1 ( t ) ; chain ( c ) } , datekeys . ErrUnknownProfile } ,
{ "chain hash alone" , chain , datekeys . ErrProfileMismatch } ,
} {
if _ , err := profile . Decode ( encode ( tc . edit ) ) ; ! errors . Is ( err , tc . want ) {
t . Errorf ( "%s: got %v, want %v" , tc . name , err , tc . want )
}
}
}
// Spec §12.1: chain_hash is the drand chain-info hash (drand specification,
// Root of trust):
//
// SHA-256(uint32_be(period) || int64_be(genesis_time) || public_key ||
// genesis_seed || network)
//
// with network left out when it is "default". Computed here without drand.
func TestChainHashFormula ( t * testing . T ) {
Spec v0.8.2 amendment: canonical point encoding; no library error text
Amendment of the unreleased v0.8.2, recorded in §76 with its case: the
second implementation's phase-2 research found that tlock-js over
@noble/curves 1.9.7 accepts U re-encoded as c0 + p and a signature
x + p and returns the same file key, while the reference rejects both
(noble 1.9.7 differed from kilic on 5,615 of 41,686 encodings), and the
spec did not say which encodings are valid.
- §12.2 defines the canonical encoding of a BLS12-381 point (drand's
compressed ZCash form) and requires decoders to reject every other
byte string; §12.1 applies it to public_key.
- §63 step 10 applies it to the release signature (ERR_RELEASE_INVALID)
and step 11 defines the tlock stanza body U || V || W (96 + 16 + 16
bytes for Quicknet) with a canonical, non-infinity U (ERR_INTEGRITY).
- §64 gains ten mutations, exported to mutations.json (65 cases). The
signature x + p case uses published Quicknet round 1004, the first
after 1000 whose x allows x + p < 2^381. The reference already gave
every stated code and step.
Errors no longer copy text from tlock, kyber, age, drand or
kyber-bls12381. kyber's IBE error carried the candidate plaintext and r,
and with one bit of W flipped the message disclosed the real tlock file
key with that bit flipped. Every such place now uses a fixed reason with
its normative sentinel; TestTlockFailureDiagnosticsCarryNoSecrets fails
with the old wrapping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
sum := chainHashFormula
Spec v0.8.2 refinements: error precedence, trust model, strict order
Approved refinements, each recorded with its reproducible case in the
§76 v0.8.2 subsection:
- §69.1: layered error model with normative precedence (frame, type tag
and version, CBOR profile and CDDL, then fields with their own code in
ascending key order; across steps the §63 order decides), with a scope
paragraph for the optional steps 5, 6 and 8.
- §55.1: normative trust table per section (who can write it, from which
step it is bound, what it never proves); §72: security-relevant claims
go in CONTROL_CBOR or under a signature, .dkk data is advisory.
- §31/§54: extension arrays in strictly ascending unsigned byte order of
extension_id (one rule for order and uniqueness).
- Gaps a second implementation needed: §28.1 malformed age headers,
§15/§19 latest unlock time and dk1_ reading rules, §22/§23/§57 length
lower bounds, §63 step 8 tlock argument comparison and step 9 order,
§12.1 profile validation with the drand chain-hash formula, §74 table
of implementation limits.
Reference alignment: .dkk errors only at step 9.a (new
OpenOptions.AccessKeyFile, used by the CLI), CR/LF in dk1_ is
ERR_DATEKEY_INVALID, BODY_LEN 0 is ERR_INTEGRITY, nil identities are not
credentials, and AccessIdentity tries every identity on every stanza so
its verdict does not depend on their order. dk1.json gains three
vectors; every other testdata file is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
q := profile . Quicknet ( )
if got := hex . EncodeToString ( sum ( q ) ) ; got != profile . QuicknetChainHash {
t . Fatalf ( "formula gives %s, Quicknet chain_hash is %s" , got , profile . QuicknetChainHash )
}
if err := q . Validate ( ) ; err != nil {
t . Fatal ( err )
}
// The network "default" is not hashed.
d := profile . Quicknet ( )
d . Network = "default"
copy ( d . ChainHash [ : ] , sum ( d ) )
if err := d . Validate ( ) ; err != nil {
t . Fatalf ( "network default: %v" , err )
}
d . Network = "defaults"
if err := d . Validate ( ) ; ! errors . Is ( err , datekeys . ErrProfileMismatch ) {
t . Fatalf ( "network defaults hashed like default: %v" , err )
}
}
Spec v0.8.2 amendment: canonical point encoding; no library error text
Amendment of the unreleased v0.8.2, recorded in §76 with its case: the
second implementation's phase-2 research found that tlock-js over
@noble/curves 1.9.7 accepts U re-encoded as c0 + p and a signature
x + p and returns the same file key, while the reference rejects both
(noble 1.9.7 differed from kilic on 5,615 of 41,686 encodings), and the
spec did not say which encodings are valid.
- §12.2 defines the canonical encoding of a BLS12-381 point (drand's
compressed ZCash form) and requires decoders to reject every other
byte string; §12.1 applies it to public_key.
- §63 step 10 applies it to the release signature (ERR_RELEASE_INVALID)
and step 11 defines the tlock stanza body U || V || W (96 + 16 + 16
bytes for Quicknet) with a canonical, non-infinity U (ERR_INTEGRITY).
- §64 gains ten mutations, exported to mutations.json (65 cases). The
signature x + p case uses published Quicknet round 1004, the first
after 1000 whose x allows x + p < 2^381. The reference already gave
every stated code and step.
Errors no longer copy text from tlock, kyber, age, drand or
kyber-bls12381. kyber's IBE error carried the candidate plaintext and r,
and with one bit of W flipped the message disclosed the real tlock file
key with that bit flipped. Every such place now uses a fixed reason with
its normative sentinel; TestTlockFailureDiagnosticsCarryNoSecrets fails
with the old wrapping.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
// chainHashFormula is the chain_hash of p by the formula of spec §12.1,
// computed without drand, over the exact bytes of its public key.
func chainHashFormula ( p * profile . Profile ) [ ] byte {
var n [ 12 ] byte
binary . BigEndian . PutUint32 ( n [ : 4 ] , uint32 ( p . Period / time . Second ) )
binary . BigEndian . PutUint64 ( n [ 4 : ] , uint64 ( p . GenesisTime ) )
h := sha256 . New ( )
h . Write ( n [ : ] )
h . Write ( p . PublicKey )
h . Write ( p . GenesisSeed [ : ] )
if p . Network != "default" {
h . Write ( [ ] byte ( p . Network ) )
}
return h . Sum ( nil )
}
Spec v0.8.2 refinements: error precedence, trust model, strict order
Approved refinements, each recorded with its reproducible case in the
§76 v0.8.2 subsection:
- §69.1: layered error model with normative precedence (frame, type tag
and version, CBOR profile and CDDL, then fields with their own code in
ascending key order; across steps the §63 order decides), with a scope
paragraph for the optional steps 5, 6 and 8.
- §55.1: normative trust table per section (who can write it, from which
step it is bound, what it never proves); §72: security-relevant claims
go in CONTROL_CBOR or under a signature, .dkk data is advisory.
- §31/§54: extension arrays in strictly ascending unsigned byte order of
extension_id (one rule for order and uniqueness).
- Gaps a second implementation needed: §28.1 malformed age headers,
§15/§19 latest unlock time and dk1_ reading rules, §22/§23/§57 length
lower bounds, §63 step 8 tlock argument comparison and step 9 order,
§12.1 profile validation with the drand chain-hash formula, §74 table
of implementation limits.
Reference alignment: .dkk errors only at step 9.a (new
OpenOptions.AccessKeyFile, used by the CLI), CR/LF in dk1_ is
ERR_DATEKEY_INVALID, BODY_LEN 0 is ERR_INTEGRITY, nil identities are not
credentials, and AccessIdentity tries every identity on every stanza so
its verdict does not depend on their order. dk1.json gains three
vectors; every other testdata file is byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 weeks ago
// g1Generator is the compressed generator of G1, in the encoding of drand.
func g1Generator ( t * testing . T ) [ ] byte {
t . Helper ( )
s , err := crypto . SchemeFromName ( crypto . UnchainedSchemeID )
if err != nil {
t . Fatal ( err )
}
b , err := s . KeyGroup . Point ( ) . Base ( ) . MarshalBinary ( )
if err != nil || len ( b ) != 48 {
t . Fatalf ( "G1 generator: %d bytes, %v" , len ( b ) , err )
}
return b
}
// offSubgroupG1 returns the compressed encoding (48 bytes, flag 0x80) of the
// point of the BLS12-381 curve y^2 = x^3 + 4 over Fp with the smallest x >= 1.
// It is on the curve but not in the prime-order subgroup, whose cofactor is
// about 2^126; the decoder of drand says so.
func offSubgroupG1 ( t * testing . T ) [ ] byte {
t . Helper ( )
p , _ := new ( big . Int ) . SetString ( "1a0111ea397fe69a4b1ba7b6434bacd764774b84f38512bf6730d2a0f6b0f6241eabfffeb153ffffb9feffffffffaaab" , 16 )
half := new ( big . Int ) . Rsh ( new ( big . Int ) . Sub ( p , big . NewInt ( 1 ) ) , 1 )
for x := int64 ( 1 ) ; x < 100 ; x ++ {
rhs := new ( big . Int ) . Exp ( big . NewInt ( x ) , big . NewInt ( 3 ) , p )
rhs . Add ( rhs , big . NewInt ( 4 ) ) . Mod ( rhs , p )
if new ( big . Int ) . Exp ( rhs , half , p ) . Cmp ( big . NewInt ( 1 ) ) != 0 {
continue // x^3 + 4 is not a square: no point with this x
}
b := make ( [ ] byte , 48 )
big . NewInt ( x ) . FillBytes ( b )
b [ 0 ] |= 0x80
s , err := crypto . SchemeFromName ( crypto . UnchainedSchemeID )
if err != nil {
t . Fatal ( err )
}
if err := s . KeyGroup . Point ( ) . UnmarshalBinary ( b ) ; err == nil || ! strings . Contains ( err . Error ( ) , "subgroup" ) {
t . Fatalf ( "x = %d: want a curve point outside the subgroup, got %v" , x , err )
}
return b
}
t . Fatal ( "no curve point with a small x" )
return nil
}
// Spec §12.1, §69.1: a profile gets the same code when it is pinned
// (NewRegistry), validated as a value (Validate) or decoded from its
// encoding (Decode): the schema first, with the period limit of the
// reference, then the field rules, then the chain hash, and last the pinned
// profile_hash.
func TestPinPathMatchesDecode ( t * testing . T ) {
chain := func ( p * profile . Profile ) { p . ChainHash = [ 32 ] byte { } }
for _ , tc := range [ ] struct {
name string
edit func ( p * profile . Profile )
want error
} {
{ "period of one day and one second" , func ( p * profile . Profile ) { p . Period = 86401 * time . Second } , datekeys . ErrNonCanonicalCBOR } ,
{ "period of one day and one second and provider Drand" , func ( p * profile . Profile ) { p . Period , p . Provider = 86401 * time . Second , "Drand" } , datekeys . ErrNonCanonicalCBOR } ,
{ "sub-second period and provider Drand" , func ( p * profile . Profile ) { p . Period , p . Provider = 1500 * time . Millisecond , "Drand" } , datekeys . ErrNonCanonicalCBOR } ,
{ "genesis_time 2^53 and provider Drand" , func ( p * profile . Profile ) { p . GenesisTime , p . Provider = 1 << 53 , "Drand" } , datekeys . ErrNonCanonicalCBOR } ,
{ "negative genesis_time" , func ( p * profile . Profile ) { p . GenesisTime = - 1 } , datekeys . ErrNonCanonicalCBOR } ,
{ "network not valid UTF-8 and chain hash" , func ( p * profile . Profile ) { p . Network = "quick\xffnet" ; chain ( p ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "provider Drand and chain hash" , func ( p * profile . Profile ) { p . Provider = "Drand" ; chain ( p ) } , datekeys . ErrUnknownProfile } ,
{ "genesis_time 9999-12-31T23:59:59Z and chain hash" , func ( p * profile . Profile ) { p . GenesisTime = profile . MaxUnixTime ; chain ( p ) } , datekeys . ErrUnknownProfile } ,
{ "chain hash alone" , chain , datekeys . ErrProfileMismatch } ,
} {
p := profile . Quicknet ( )
tc . edit ( p )
if err := p . Validate ( ) ; ! errors . Is ( err , tc . want ) {
t . Errorf ( "%s: Validate: got %v, want %v" , tc . name , err , tc . want )
}
if _ , err := profile . NewRegistry ( profile . Pin { Profile : p } ) ; ! errors . Is ( err , tc . want ) {
t . Errorf ( "%s: NewRegistry: got %v, want %v" , tc . name , err , tc . want )
}
if b , err := p . CanonicalCBOR ( ) ; err != nil {
if ! errors . Is ( err , tc . want ) {
t . Errorf ( "%s: CanonicalCBOR: got %v, want %v" , tc . name , err , tc . want )
}
} else if _ , err := profile . Decode ( b ) ; ! errors . Is ( err , tc . want ) {
t . Errorf ( "%s: Decode: got %v, want %v" , tc . name , err , tc . want )
}
}
// Rule 4 comes last: a valid profile with another pinned hash.
if _ , err := profile . NewRegistry ( profile . Pin { Profile : profile . Quicknet ( ) } ) ; ! errors . Is ( err , datekeys . ErrProfileMismatch ) {
t . Errorf ( "another pinned profile_hash: %v" , err )
}
}