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 capsule_test
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
"filippo.io/age"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/datekey"
"g.activething.com/go/DateKeys/extension"
"g.activething.com/go/DateKeys/internal/cbortest"
"g.activething.com/go/DateKeys/internal/testkit"
"g.activething.com/go/DateKeys/profile"
)
// The tests of this file check the precedence of errors of spec §69.1 and
// the refinements of v0.8.2 recorded in spec §76: within one object the code
// of the first failing layer, across objects and steps the order of §63.
// failedStep returns the step of the last check, which failed, or 0 when
// every check passed.
func failedStep ( t * testing . T , checks [ ] capsule . CheckResult , err error ) int {
t . Helper ( )
if err == nil {
return 0
}
if len ( checks ) == 0 || checks [ len ( checks ) - 1 ] . OK {
t . Fatalf ( "failure without a failed step: %v" , err )
}
return checks [ len ( checks ) - 1 ] . Step
}
// inspectStep runs steps 1 to 8 with the default registry.
func inspectStep ( t * testing . T , dkc [ ] byte , exts extension . Registry ) ( int , error ) {
t . Helper ( )
in , err := capsule . Inspect ( bytes . NewReader ( dkc ) , capsule . InspectOptions { Registry : testkit . Registry ( ) , Extensions : exts } )
return failedStep ( t , in . Checks , err ) , err
}
// openStep runs the whole flow, with every release testkit knows, and also
// returns the number of release requests.
func openStep ( t * testing . T , dkc [ ] byte , o capsule . OpenOptions ) ( int , int , error ) {
t . Helper ( )
src := testkit . NewSource ( )
for _ , r := range testkit . Rounds {
src . Releases [ r ] = testkit . Release ( r )
}
o . Source = src
if o . Registry == nil {
o . Registry = testkit . Registry ( )
}
if o . Now == nil {
o . Now = testkit . Fixed ( testkit . Genesis ( ) . AddDate ( 1 , 0 , 0 ) )
}
out , err := capsule . Open ( context . Background ( ) , discardWriter { } , bytes . NewReader ( dkc ) , o )
if out == nil {
t . Fatalf ( "Open returned no result: %v" , err )
}
return failedStep ( t , out . Inspection . Checks , err ) , src . Calls , err
}
type discardWriter struct { }
func ( discardWriter ) Write ( p [ ] byte ) ( int , error ) { return len ( p ) , nil }
func expectStep ( t * testing . T , name string , step int , err error , code error , wantStep int ) {
t . Helper ( )
if ! errors . Is ( err , code ) || step != wantStep {
t . Errorf ( "%s: got %v at step %d, want %s at step %d" , name , err , step , datekeys . Code ( code ) , wantStep )
}
}
// parts splits an official fixture and decodes its PUBLIC_HEADER map.
func parts ( t * testing . T , name string ) ( testkit . Parts , map [ uint64 ] any ) {
t . Helper ( )
p , err := testkit . Split ( loadFixture ( t , name ) . dkc )
if err != nil {
t . Fatal ( err )
}
h , err := cbortest . UnmarshalMap ( p . Header )
if err != nil {
t . Fatal ( err )
}
return p , h
}
// rewriteHeader replaces the age header at the start of file with one built
// from edit(stanzas). The MAC is computed with an unrelated key: steps 5, 6
// and 8 never verify it (spec §27).
func rewriteHeader ( t * testing . T , file [ ] byte , edit func ( [ ] * age . Stanza ) [ ] * age . Stanza ) [ ] byte {
t . Helper ( )
stanzas , err := agewrap . Stanzas ( bytes . NewReader ( file ) )
if err != nil {
t . Fatal ( err )
}
n , err := testkit . HeaderLen ( file )
if err != nil {
t . Fatal ( err )
}
hdr , err := testkit . MarshalHeader ( edit ( stanzas ) , make ( [ ] byte , 16 ) )
if err != nil {
t . Fatal ( err )
}
return append ( hdr , file [ n : ] ... )
}
// noStanzas replaces the age header at the start of file with the intro line
// and the MAC line only: a header without recipient stanzas, which the age
// grammar does not allow (header = v1-line 1*stanza end).
func noStanzas ( t * testing . T , file [ ] byte ) [ ] byte {
t . Helper ( )
n , err := testkit . HeaderLen ( file )
if err != nil {
t . Fatal ( err )
}
hdr := "age-encryption.org/v1\n--- " + strings . Repeat ( "A" , 43 ) + "\n"
return append ( [ ] byte ( hdr ) , file [ n : ] ... )
}
// Spec §69.1, layers 2 to 4 of PUBLIC_HEADER at step 4: the type tag before
// the version, the version before the CDDL, the CDDL before the fields with
// codes of their own, and those in ascending key order: the DateKey (key 3)
// and its pinned profile, then the critical extensions (key 5), where an
// unknown one comes before invalid data.
func TestPrecedenceWithinPublicHeader ( t * testing . T ) {
p , h := parts ( t , "time_only" )
unknown := [ ] any { ext ( "com.example.unknown" , 1 ) }
unpinned := datekey . DateKey { ProfileID : "datekeys:other:v1" , Round : 1000 } . Compact ( )
for _ , tc := range [ ] struct {
name string
edit func ( m map [ uint64 ] any )
want error
} {
{ "type tag of another schema and version 2" , func ( m map [ uint64 ] any ) { m [ 0 ] , m [ 1 ] = capsule . ControlTypeTag , uint64 ( 2 ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "version 2, unknown key and invalid DateKey" , func ( m map [ uint64 ] any ) { m [ 1 ] , m [ 3 ] , m [ 9 ] = uint64 ( 2 ) , "dk1_x" , uint64 ( 0 ) } , datekeys . ErrUnsupportedVersion } ,
{ "undefined access_policy and unknown critical extension" , func ( m map [ uint64 ] any ) { m [ 4 ] , m [ 5 ] = uint64 ( 2 ) , unknown } , datekeys . ErrNonCanonicalCBOR } ,
{ "DateKey as a byte string" , func ( m map [ uint64 ] any ) { m [ 3 ] = [ ] byte ( h [ 3 ] . ( string ) ) } , datekeys . ErrNonCanonicalCBOR } ,
{ "invalid DateKey and unknown critical extension" , func ( m map [ uint64 ] any ) { m [ 3 ] , m [ 5 ] = "dk1_x" , unknown } , datekeys . ErrDateKeyInvalid } ,
{ "unpinned profile and unknown critical extension" , func ( m map [ uint64 ] any ) { m [ 3 ] , m [ 5 ] = unpinned , unknown } , datekeys . ErrUnknownProfile } ,
{ "invalid data before an unknown extension" , func ( m map [ uint64 ] any ) {
m [ 5 ] = [ ] any { extData ( "org.example.a" , [ ] byte ( "ko" ) ) , ext ( "zz.unknown" , 1 ) }
} , datekeys . ErrExtensionCriticalUnknown } ,
{ "invalid data alone" , func ( m map [ uint64 ] any ) { m [ 5 ] = [ ] any { extData ( "org.example.a" , [ ] byte ( "ko" ) ) } } , datekeys . ErrExtensionDataInvalid } ,
} {
dkc := testkit . Reframe ( p . Prelude , marshal ( t , with ( h , tc . edit ) ) , p . Sealed , p . Payload )
step , err := inspectStep ( t , dkc , strictRegistry { } )
expectStep ( t , tc . name , step , err , tc . want , 4 )
}
// The frame comes first, whatever the object holds (spec §57).
big := append ( marshal ( t , with ( h , func ( m map [ uint64 ] any ) { m [ 1 ] = uint64 ( 2 ) } ) ) , make ( [ ] byte , capsule . MaxPublicHeaderLen ) ... )
if _ , err := capsule . DecodeHeader ( big ) ; ! errors . Is ( err , datekeys . ErrIntegrity ) {
t . Errorf ( "PUBLIC_HEADER above 1 MiB with version 2: %v" , err )
}
}
// Spec §63, §69.1: across objects and steps the first step that fails
// decides, and a check that relates an object to another belongs to its
// step, not to the object's layer 4.
func TestPrecedenceAcrossSteps ( t * testing . T ) {
p , h := parts ( t , "time_only" )
q := profile . Quicknet ( )
beyond := datekey . DateKey { ProfileID : profile . QuicknetID , Round : q . MaxRound ( ) + 1 } . Compact ( )
largest := datekey . DateKey { ProfileID : profile . QuicknetID , Round : datekey . MaxRound } . Compact ( )
withDateKey := func ( dk string ) [ ] byte { return marshal ( t , with ( h , func ( m map [ uint64 ] any ) { m [ 3 ] = dk } ) ) }
badPolicy := marshal ( t , with ( h , func ( m map [ uint64 ] any ) { m [ 4 ] = uint64 ( 2 ) } ) )
twoStanzas := rewriteHeader ( t , p . Payload , func ( s [ ] * age . Stanza ) [ ] * age . Stanza { return append ( s , s [ 0 ] ) } )
otherRound := rewriteHeader ( t , p . Sealed , func ( s [ ] * age . Stanza ) [ ] * age . Stanza { s [ 0 ] . Args [ 0 ] = "1001" ; return s } )
for _ , tc := range [ ] struct {
name string
dkc [ ] byte
want error
step int
} {
{ "header fault and truncated SEALED_CONTROL" ,
testkit . Reframe ( p . Prelude , badPolicy , p . Sealed , nil ) [ : capsule . PreludeSize + len ( badPolicy ) + len ( p . Sealed ) / 2 ] ,
datekeys . ErrNonCanonicalCBOR , 4 } ,
// Spec §15: the round time of a DateKey is at most
// 9999-12-31T23:59:59Z, checked against the profile at step 7.
{ "round after 9999-12-31T23:59:59Z" , testkit . Reframe ( p . Prelude , withDateKey ( beyond ) , p . Sealed , p . Payload ) , datekeys . ErrDateKeyInvalid , 7 } ,
{ "round 2^53-1 passes step 4 and fails step 7" , testkit . Reframe ( p . Prelude , withDateKey ( largest ) , p . Sealed , p . Payload ) , datekeys . ErrDateKeyInvalid , 7 } ,
{ "round beyond the profile and malformed PAYLOAD_AGE" , testkit . Reframe ( p . Prelude , withDateKey ( beyond ) , p . Sealed , [ ] byte ( "not age" ) ) , datekeys . ErrIntegrity , 6 } ,
{ "tlock round mismatch and two PAYLOAD_AGE stanzas" , testkit . Reframe ( p . Prelude , p . Header , otherRound , twoStanzas ) , datekeys . ErrPolicyStructureMismatch , 6 } ,
{ "tlock round mismatch alone" , testkit . Reframe ( p . Prelude , p . Header , otherRound , p . Payload ) , datekeys . ErrRoundMismatch , 8 } ,
} {
step , err := inspectStep ( t , tc . dkc , nil )
expectStep ( t , tc . name , step , err , tc . want , tc . step )
}
// The largest round of the profile is still valid (spec §15).
last := datekey . DateKey { ProfileID : profile . QuicknetID , Round : q . MaxRound ( ) }
if u , err := datekey . RoundTime ( q , last . Round ) ; err != nil || u . Unix ( ) > profile . MaxUnixTime || u . Unix ( ) + 3 <= profile . MaxUnixTime {
t . Fatalf ( "last round %d at %v: %v" , last . Round , u , err )
}
Implement capsule format 2 of spec v0.9
The reference moves to the DateKeys Protocol Specification v0.9, approved
by its author on 29 September 2026. Encrypt writes capsule format 2 only;
Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the
verdict v0.8.2 gave it.
Format 2 (spec §22, §29.1, §31, §39):
- VERSION in the PRELUDE is the capsule format, capsule.Format; any other
value is ERR_UNSUPPORTED_VERSION at step 2.
- CONTROL_CBOR has the schema version of its format. Version 2 adds key 6,
payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and
key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without
extensions, whatever L.
- The payload is the content padded with zeros to P = rule(L). Step 17
checks the length and the zeros, and Open writes only the first L bytes.
- INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials,
and a dummy in each slot left, in a uniformly random order.
Writer rules (spec §62.1): EncryptOptions.Length is required and the
source must deliver exactly that many bytes; recipients that are not
canonical or of low order are rejected (agewrap.CheckX25519Recipient);
self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE.
The CLI measures its input, takes -padding and reports the format.
Test data: seven format 2 fixtures, padding vectors checked against
math/big, format 2 CBOR vectors, and the mutation corpus in both formats
with the 22 cases of the third list of spec §64, built without randomness
by sealing the fixtures again with their known keys and nonces. The
format 1 fixtures are kept byte for byte and never regenerated; the
differential corpus keeps its 1825 cases and adds a block per format 2
fixture. The spec copy loses its "to be implemented" markers, and the
READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// The examples of spec §69.1 for format 2.
precedenceFormat2 ( t )
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
}
// Spec §22, §57: PUBLIC_HEADER_LEN and SEALED_CONTROL_LEN are at least 1, and
// PAYLOAD_AGE, which has no length field, is at least a well-formed age
// header.
func TestFrameLengthLowerBounds ( t * testing . T ) {
p , _ := parts ( t , "time_only" )
lengths := func ( headerLen , sealedLen uint32 , flags byte ) [ ] byte {
pre := bytes . Clone ( p . Prelude )
pre [ 5 ] = flags
binary . BigEndian . PutUint32 ( pre [ 8 : 12 ] , headerLen )
binary . BigEndian . PutUint32 ( pre [ 12 : 16 ] , sealedLen )
return testkit . Join ( pre , p . Header , p . Sealed , p . Payload )
}
for _ , tc := range [ ] struct {
name string
dkc [ ] byte
want error
step int
} {
{ "PUBLIC_HEADER_LEN 0" , lengths ( 0 , uint32 ( len ( p . Sealed ) ) , 0 ) , datekeys . ErrIntegrity , 2 } ,
{ "SEALED_CONTROL_LEN 0" , lengths ( uint32 ( len ( p . Header ) ) , 0 , 0 ) , datekeys . ErrIntegrity , 2 } ,
{ "both 0 and FLAGS 1" , lengths ( 0 , 0 , 1 ) , datekeys . ErrInvalidFlags , 2 } ,
{ "empty PAYLOAD_AGE" , testkit . Join ( p . Prelude , p . Header , p . Sealed ) , datekeys . ErrIntegrity , 6 } ,
} {
step , err := inspectStep ( t , tc . dkc , nil )
expectStep ( t , tc . name , step , err , tc . want , tc . step )
}
}
// Spec §28.1: an age file whose header does not follow the age grammar,
// including a header without stanzas, is malformed: ERR_INTEGRITY at step 5
// or 6. A well-formed header with the wrong stanzas is
// ERR_POLICY_STRUCTURE_MISMATCH. The plaintext of OUTER_TIME_AGE is judged
// against access_policy at step 12 (spec §36).
func TestMalformedAgeHeaders ( t * testing . T ) {
p , _ := parts ( t , "time_only" )
for _ , tc := range [ ] struct {
name string
sealed , payload [ ] byte
want error
step int
} {
{ "OUTER_TIME_AGE without stanzas" , noStanzas ( t , p . Sealed ) , p . Payload , datekeys . ErrIntegrity , 5 } ,
{ "PAYLOAD_AGE without stanzas" , p . Sealed , noStanzas ( t , p . Payload ) , datekeys . ErrIntegrity , 6 } ,
{ "two spaces between tlock arguments" , bytes . Replace ( p . Sealed , [ ] byte ( "-> tlock 1000 " ) , [ ] byte ( "-> tlock 1000 " ) , 1 ) , p . Payload , datekeys . ErrIntegrity , 5 } ,
{ "CR at the end of the intro line" , bytes . Replace ( p . Sealed , [ ] byte ( "v1\n" ) , [ ] byte ( "v1\r\n" ) , 1 ) , p . Payload , datekeys . ErrIntegrity , 5 } ,
{ "padding in the MAC line" , bytes . Replace ( p . Sealed , [ ] byte ( "\n--- " ) , [ ] byte ( "\n--- =" ) , 1 ) , p . Payload , datekeys . ErrIntegrity , 5 } ,
{ "extra well-formed stanza in OUTER_TIME_AGE" , rewriteHeader ( t , p . Sealed , func ( s [ ] * age . Stanza ) [ ] * age . Stanza { return append ( s , s [ 0 ] ) } ) , p . Payload , datekeys . ErrPolicyStructureMismatch , 5 } ,
} {
step , err := inspectStep ( t , testkit . Reframe ( p . Prelude , p . Header , tc . sealed , tc . payload ) , nil )
expectStep ( t , tc . name , step , err , tc . want , tc . step )
}
// INNER_ACCESS_AGE is the authenticated plaintext of OUTER_TIME_AGE: a
// header without stanzas there does not match time_and_key (step 12),
// and an age file of any form does not match time_only.
stranger := testkit . Stranger ( )
empty := func ( fk [ ] byte , s [ ] * age . Stanza ) [ ] * age . Stanza { return nil }
for _ , tc := range [ ] struct {
name string
declared capsule . Policy
} {
{ "time_and_key sealing an age header without stanzas" , capsule . TimeAndKey } ,
{ "time_only sealing an age header without stanzas" , capsule . TimeOnly } ,
} {
b , err := testkit . Build { Declared : tc . declared , Structure : capsule . TimeAndKey ,
AccessRecipients : [ ] age . Recipient { stranger . Recipient ( ) } , EditInner : empty } . Make ( )
if err != nil {
t . Fatal ( err )
}
step , calls , err := openStep ( t , b . DKC , capsule . OpenOptions { Identities : [ ] age . Identity { stranger } } )
expectStep ( t , tc . name , step , err , datekeys . ErrPolicyStructureMismatch , 12 )
if calls != 1 {
t . Errorf ( "%s: %d release requests" , tc . name , calls )
}
}
// time_only: a plaintext that is not an age file is read as CONTROL_CBOR
// at step 14.
rec , err := agewrap . NewTimeRecipient ( profile . Quicknet ( ) , 1000 )
if err != nil {
t . Fatal ( err )
}
sealed , _ , err := testkit . Encrypt ( [ ] byte { 0xff } , rec )
if err != nil {
t . Fatal ( err )
}
step , _ , err := openStep ( t , testkit . Reframe ( p . Prelude , p . Header , sealed , p . Payload ) , capsule . OpenOptions { } )
expectStep ( t , "time_only sealing bytes that are not CBOR" , step , err , datekeys . ErrNonCanonicalCBOR , 14 )
}
// Spec §63 step 8: the tlock stanza has exactly two arguments; the first is
// the canonical decimal of DateKey.round, the second the pinned chain_hash in
// lowercase hexadecimal, both compared as strings and never parsed.
func TestTlockStanzaArgumentComparison ( t * testing . T ) {
p , _ := parts ( t , "time_only" )
chain := profile . Quicknet ( ) . ChainHashHex ( )
for _ , tc := range [ ] struct {
name string
args [ ] string
want error
} {
{ "canonical" , [ ] string { "1000" , chain } , nil } ,
{ "plus sign" , [ ] string { "+1000" , chain } , datekeys . ErrRoundMismatch } ,
{ "leading zero" , [ ] string { "01000" , chain } , datekeys . ErrRoundMismatch } ,
{ "exponent" , [ ] string { "1e3" , chain } , datekeys . ErrRoundMismatch } ,
{ "uppercase chain hash" , [ ] string { "1000" , strings . ToUpper ( chain ) } , datekeys . ErrProfileMismatch } ,
{ "prefixed chain hash" , [ ] string { "1000" , "0x" + chain } , datekeys . ErrProfileMismatch } ,
{ "round and chain hash both wrong" , [ ] string { "1001" , strings . Repeat ( "0" , 64 ) } , datekeys . ErrRoundMismatch } ,
{ "one argument" , [ ] string { "1000" } , datekeys . ErrPolicyStructureMismatch } ,
{ "three arguments, wrong round" , [ ] string { "1001" , chain , "x" } , datekeys . ErrPolicyStructureMismatch } ,
} {
sealed := rewriteHeader ( t , p . Sealed , func ( s [ ] * age . Stanza ) [ ] * age . Stanza { s [ 0 ] . Args = tc . args ; return s } )
step , err := inspectStep ( t , testkit . Reframe ( p . Prelude , p . Header , sealed , p . Payload ) , nil )
if tc . want == nil {
if err != nil {
t . Errorf ( "%s: %v" , tc . name , err )
}
continue
}
expectStep ( t , tc . name , step , err , tc . want , 8 )
}
}
// Spec §63 step 9, §69.1: a .dkk offered for a time_and_key capsule is
// checked as an object first, access_type and access_material (keys 4 and
// 5) and then its critical extensions (key 7), and only then bound to the
// capsule: capsule_id (key 3), then capsule_digest (key 6). No release is
// requested.
func TestAccessKeyCheckOrder ( t * testing . T ) {
f := loadFixture ( t , "time_and_key_portable" )
other := loadFixture ( t , "time_and_key_recipients" )
unknown := [ ] extension . Extension { { ID : "com.example.unknown" , Version : 1 } }
wrongDigest := & accesskey . Verification { CapsuleDigest : make ( [ ] byte , 32 ) }
for _ , tc := range [ ] struct {
name string
edit func ( k * accesskey . AccessKey )
want error
} {
{ "another capsule and an unknown critical extension" , func ( k * accesskey . AccessKey ) { * k = * other . dkk ; k . Critical = unknown } , datekeys . ErrExtensionCriticalUnknown } ,
{ "unknown critical extension and capsule_digest mismatch" , func ( k * accesskey . AccessKey ) { k . Critical , k . Verification = unknown , wrongDigest } , datekeys . ErrExtensionCriticalUnknown } ,
{ "unsupported access_type and an unknown critical extension" , func ( k * accesskey . AccessKey ) { k . Type , k . Critical = "mlkem768" , unknown } , datekeys . ErrAccessInvalid } ,
{ "another capsule" , func ( k * accesskey . AccessKey ) { * k = * other . dkk } , datekeys . ErrAccessInvalid } ,
{ "capsule_digest mismatch" , func ( k * accesskey . AccessKey ) { k . Verification = wrongDigest } , datekeys . ErrAccessInvalid } ,
} {
k := * f . dkk
tc . edit ( & k )
step , calls , err := openStep ( t , f . dkc , capsule . OpenOptions { AccessKey : & k , Now : testkit . Fixed ( f . unlock ( t ) ) } )
expectStep ( t , tc . name , step , err , tc . want , 9 )
if calls != 0 {
t . Errorf ( "%s: %d release requests" , tc . name , calls )
}
}
// Step 9.b before 9.c: without a credential the clock is not even
// consulted, and a nil identity is not a credential.
early := testkit . Fixed ( f . unlock ( t ) . Add ( - time . Second ) )
for _ , tc := range [ ] struct {
name string
ids [ ] age . Identity
} {
{ "no credential and the round time not reached" , nil } ,
{ "a nil identity and the round time not reached" , [ ] age . Identity { nil } } ,
} {
step , calls , err := openStep ( t , f . dkc , capsule . OpenOptions { Identities : tc . ids , Now : early } )
expectStep ( t , tc . name , step , err , datekeys . ErrAccessRequired , 9 )
if calls != 0 {
t . Errorf ( "%s: %d release requests" , tc . name , calls )
}
}
// time_only: the credentials play no part (step 9).
g := loadFixture ( t , "time_only" )
k := * f . dkk
k . Type , k . Critical = "mlkem768" , unknown
o := g . openOptions ( t )
o . AccessKey = & k
if got , err := open ( t , g . dkc , o ) ; err != nil || ! bytes . Equal ( got , g . plaintext ) {
t . Fatalf ( "time_only with an invalid .dkk: %v" , err )
}
}
// Spec §63 step 9.a, §69.1: a .dkk handed over still encoded is decoded at
// step 9.a, so that its errors, framing included, come after those of steps
// 1 to 8 and never for a time_only capsule, whatever the reader does first.
func TestAccessKeyFileAtStep9 ( t * testing . T ) {
f := loadFixture ( t , "time_and_key_portable" )
raw , err := os . ReadFile ( filepath . Join ( fixtureDir , f . AccessKeyFile ) )
if err != nil {
t . Fatal ( err )
}
notDKK := [ ] byte ( "not a .dkk" )
badVersion := bytes . Clone ( raw )
badVersion [ 4 ] = 2
now := testkit . Fixed ( f . unlock ( t ) )
for _ , tc := range [ ] struct {
name string
dkk [ ] byte
want error
} {
{ "not a .dkk" , notDKK , datekeys . ErrInvalidMagic } ,
{ "framing version 2" , badVersion , datekeys . ErrUnsupportedVersion } ,
{ "truncated body" , raw [ : len ( raw ) - 1 ] , datekeys . ErrIntegrity } ,
} {
step , calls , err := openStep ( t , f . dkc , capsule . OpenOptions { AccessKeyFile : bytes . NewReader ( tc . dkk ) , Now : now } )
expectStep ( t , tc . name , step , err , tc . want , 9 )
if calls != 0 {
t . Errorf ( "%s: %d release requests" , tc . name , calls )
}
}
// A failure of steps 1 to 8 comes first.
broken := bytes . Clone ( f . dkc )
broken [ 5 ] = 1
step , _ , err := openStep ( t , broken , capsule . OpenOptions { AccessKeyFile : bytes . NewReader ( notDKK ) , Now : now } )
expectStep ( t , "FLAGS 1 and not a .dkk" , step , err , datekeys . ErrInvalidFlags , 2 )
// time_only never reads it.
g := loadFixture ( t , "time_only" )
o := g . openOptions ( t )
o . AccessKeyFile = bytes . NewReader ( notDKK )
if got , err := open ( t , g . dkc , o ) ; err != nil || ! bytes . Equal ( got , g . plaintext ) {
t . Fatalf ( "time_only with bytes that are not a .dkk: %v" , err )
}
// The fixture key, still encoded, opens its capsule.
o = f . openOptions ( t )
o . AccessKey , o . Identities = nil , nil
o . AccessKeyFile = bytes . NewReader ( raw )
if got , err := open ( t , f . dkc , o ) ; err != nil || ! bytes . Equal ( got , f . plaintext ) {
t . Fatalf ( "encoded fixture .dkk: %v" , err )
}
o . AccessKey = f . dkk
if _ , err := open ( t , f . dkc , o ) ; err == nil || datekeys . Code ( err ) != "" {
t . Fatalf ( "both AccessKey and AccessKeyFile: %v" , err )
}
}
// Spec §63 steps 14 and 15: the critical extensions of CONTROL_CBOR are part
// of the object (step 14); header_binding binds it to PUBLIC_HEADER at step
// 15.
func TestControlCriticalBeforeHeaderBinding ( t * testing . T ) {
Implement capsule format 2 of spec v0.9
The reference moves to the DateKeys Protocol Specification v0.9, approved
by its author on 29 September 2026. Encrypt writes capsule format 2 only;
Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the
verdict v0.8.2 gave it.
Format 2 (spec §22, §29.1, §31, §39):
- VERSION in the PRELUDE is the capsule format, capsule.Format; any other
value is ERR_UNSUPPORTED_VERSION at step 2.
- CONTROL_CBOR has the schema version of its format. Version 2 adds key 6,
payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and
key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without
extensions, whatever L.
- The payload is the content padded with zeros to P = rule(L). Step 17
checks the length and the zeros, and Open writes only the first L bytes.
- INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials,
and a dummy in each slot left, in a uniformly random order.
Writer rules (spec §62.1): EncryptOptions.Length is required and the
source must deliver exactly that many bytes; recipients that are not
canonical or of low order are rejected (agewrap.CheckX25519Recipient);
self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE.
The CLI measures its input, takes -padding and reports the format.
Test data: seven format 2 fixtures, padding vectors checked against
math/big, format 2 CBOR vectors, and the mutation corpus in both formats
with the 22 cases of the third list of spec §64, built without randomness
by sealing the fixtures again with their known keys and nonces. The
format 1 fixtures are kept byte for byte and never regenerated; the
differential corpus keeps its 1825 cases and adds a block per format 2
fixture. The spec copy loses its "to be implemented" markers, and the
READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
for _ , c := range [ ] struct {
fixture string
format capsule . Format
} { { "time_only" , capsule . Format1 } , { "format2_time_only" , capsule . Format2 } } {
p , _ := parts ( t , c . fixture )
b , err := testkit . Build { Format : c . format , ControlCritical : [ ] extension . Extension { { ID : "com.example.unknown" , Version : 1 } } } . Make ( )
if err != nil {
t . Fatal ( err )
}
// The control of b is bound to b's header, not to the fixture's.
dkc := testkit . Reframe ( p . Prelude , p . Header , b . Sealed , b . Payload )
step , _ , err := openStep ( t , dkc , capsule . OpenOptions { } )
expectStep ( t , c . fixture + ": unknown critical extension and header_binding mismatch" , step , err , datekeys . ErrExtensionCriticalUnknown , 14 )
step , _ , err = openStep ( t , dkc , capsule . OpenOptions { Extensions : extension . Set { "com.example.unknown" : { 1 } } } )
expectStep ( t , c . fixture + ": header_binding mismatch alone" , step , err , datekeys . ErrHeaderBinding , 15 )
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
}
// Within CONTROL_CBOR, the CDDL comes before the critical extensions.
c := map [ uint64 ] any { 0 : capsule . ControlTypeTag , 1 : uint64 ( 1 ) , 2 : make ( [ ] byte , 32 ) , 3 : make ( [ ] byte , 32 ) , 4 : [ ] any { ext ( "com.example.unknown" , 1 ) } , 6 : uint64 ( 0 ) }
Implement capsule format 2 of spec v0.9
The reference moves to the DateKeys Protocol Specification v0.9, approved
by its author on 29 September 2026. Encrypt writes capsule format 2 only;
Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the
verdict v0.8.2 gave it.
Format 2 (spec §22, §29.1, §31, §39):
- VERSION in the PRELUDE is the capsule format, capsule.Format; any other
value is ERR_UNSUPPORTED_VERSION at step 2.
- CONTROL_CBOR has the schema version of its format. Version 2 adds key 6,
payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and
key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without
extensions, whatever L.
- The payload is the content padded with zeros to P = rule(L). Step 17
checks the length and the zeros, and Open writes only the first L bytes.
- INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials,
and a dummy in each slot left, in a uniformly random order.
Writer rules (spec §62.1): EncryptOptions.Length is required and the
source must deliver exactly that many bytes; recipients that are not
canonical or of low order are rejected (agewrap.CheckX25519Recipient);
self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE.
The CLI measures its input, takes -padding and reports the format.
Test data: seven format 2 fixtures, padding vectors checked against
math/big, format 2 CBOR vectors, and the mutation corpus in both formats
with the 22 cases of the third list of spec §64, built without randomness
by sealing the fixtures again with their known keys and nonces. The
format 1 fixtures are kept byte for byte and never regenerated; the
differential corpus keeps its 1825 cases and adds a block per format 2
fixture. The spec copy loses its "to be implemented" markers, and the
READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
if _ , err := capsule . DecodeControl ( marshal ( t , c ) , capsule . Format1 ) ; ! errors . Is ( err , datekeys . ErrNonCanonicalCBOR ) {
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
t . Errorf ( "unknown key 6 and an unknown critical extension: %v" , err )
}
}
Implement capsule format 2 of spec v0.9
The reference moves to the DateKeys Protocol Specification v0.9, approved
by its author on 29 September 2026. Encrypt writes capsule format 2 only;
Open and Inspect read formats 1 and 2, and a format 1 capsule keeps the
verdict v0.8.2 gave it.
Format 2 (spec §22, §29.1, §31, §39):
- VERSION in the PRELUDE is the capsule format, capsule.Format; any other
value is ERR_UNSUPPORTED_VERSION at step 2.
- CONTROL_CBOR has the schema version of its format. Version 2 adds key 6,
payload_length (8 bytes, big-endian, at most L_MAX = 2^53 - 2^46), and
key 7, padding (1 bloque256, 2 reforzado); it is 103 bytes without
extensions, whatever L.
- The payload is the content padded with zeros to P = rule(L). Step 17
checks the length and the zeros, and Open writes only the first L bytes.
- INNER_ACCESS_AGE holds exactly 16 X25519 stanzas: 1 to 16 credentials,
and a dummy in each slot left, in a uniformly random order.
Writer rules (spec §62.1): EncryptOptions.Length is required and the
source must deliver exactly that many bytes; recipients that are not
canonical or of low order are rejected (agewrap.CheckX25519Recipient);
self-checks of the header, the control, INNER_ACCESS_AGE and PAYLOAD_AGE.
The CLI measures its input, takes -padding and reports the format.
Test data: seven format 2 fixtures, padding vectors checked against
math/big, format 2 CBOR vectors, and the mutation corpus in both formats
with the 22 cases of the third list of spec §64, built without randomness
by sealing the fixtures again with their known keys and nonces. The
format 1 fixtures are kept byte for byte and never regenerated; the
differential corpus keeps its 1825 cases and adds a block per format 2
fixture. The spec copy loses its "to be implemented" markers, and the
READMEs, CHANGELOG, traceability and testdata/README.md follow v0.9.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 week ago
// precedenceFormat2 checks the examples of spec §69.1 for format 2, as part
// of TestPrecedenceAcrossSteps. Each capsule derives from a format 2 fixture,
// sealed again with its known file keys, so that only the faults the example
// names are present.
func precedenceFormat2 ( t * testing . T ) {
to , err := testkit . LoadFixture ( fixtureDir , "format2_time_only_extensions" )
if err != nil {
t . Fatal ( err )
}
tk , err := testkit . LoadFixture ( fixtureDir , "format2_time_and_key_portable" )
if err != nil {
t . Fatal ( err )
}
unknown := [ ] any { ext ( "com.example.unknown" , 1 ) }
control := func ( edit func ( m map [ uint64 ] any ) ) testkit . Parts {
t . Helper ( )
in , err := to . WithControl ( edit )
if err != nil {
t . Fatal ( err )
}
p , err := testkit . Split ( in . DKC )
if err != nil {
t . Fatal ( err )
}
return p
}
// badPadding is the PAYLOAD_AGE of to with its last padding byte 0x01.
content , err := os . ReadFile ( filepath . Join ( fixtureDir , to . PlaintextFile ) )
if err != nil {
t . Fatal ( err )
}
padded := append ( bytes . Clone ( content ) , make ( [ ] byte , to . PaddedLength - to . PayloadLength ) ... )
padded [ len ( padded ) - 1 ] = 0x01
in , err := to . WithPayloadPlaintext ( padded )
if err != nil {
t . Fatal ( err )
}
withBadPadding , err := testkit . Split ( in . DKC )
if err != nil {
t . Fatal ( err )
}
badPadding := withBadPadding . Payload
// otherHeader is the PUBLIC_HEADER of to with another capsule_id: as
// valid, and bound to no control.
h , err := cbortest . UnmarshalMap ( to . Parts . Header )
if err != nil {
t . Fatal ( err )
}
h [ 2 ] = bytes . Repeat ( [ ] byte { 0x5a } , capsule . CapsuleIDSize )
otherHeader := marshal ( t , h )
code3 := control ( func ( m map [ uint64 ] any ) { m [ 7 ] = uint64 ( 3 ) } )
for _ , tc := range [ ] struct {
name string
p testkit . Parts
want error
step int
} {
{ "CONTROL_CBOR of version 1 in a format 2 capsule, with an unknown key" , control ( func ( m map [ uint64 ] any ) { m [ 1 ] , m [ 9 ] = uint64 ( 1 ) , uint64 ( 0 ) } ) , datekeys . ErrUnsupportedVersion , 14 } ,
{ "padding code 3 and an unknown critical extension in CONTROL_CBOR" , control ( func ( m map [ uint64 ] any ) { m [ 7 ] , m [ 4 ] = uint64 ( 3 ) , unknown } ) , datekeys . ErrNonCanonicalCBOR , 14 } ,
{ "padding code 3 and the header_binding of another header" , testkit . Parts { Prelude : code3 . Prelude , Header : otherHeader , Sealed : code3 . Sealed , Payload : code3 . Payload } , datekeys . ErrNonCanonicalCBOR , 14 } ,
{ "unknown critical CONTROL_CBOR extension and a padding byte other than 0x00" , func ( ) testkit . Parts {
p := control ( func ( m map [ uint64 ] any ) { m [ 4 ] = unknown } )
p . Payload = badPadding
return p
} ( ) , datekeys . ErrExtensionCriticalUnknown , 14 } ,
{ "a padding byte other than 0x00 alone" , withBadPadding , datekeys . ErrIntegrity , 17 } ,
{ "a padding byte other than 0x00 and an extra PAYLOAD_AGE stanza" , testkit . Parts { Prelude : to . Parts . Prelude , Header : to . Parts . Header , Sealed : to . Parts . Sealed ,
Payload : rewriteHeader ( t , badPadding , func ( s [ ] * age . Stanza ) [ ] * age . Stanza { return append ( s , s [ 0 ] ) } ) } , datekeys . ErrPolicyStructureMismatch , 6 } ,
} {
dkc := testkit . Reframe ( tc . p . Prelude , tc . p . Header , tc . p . Sealed , tc . p . Payload )
step , _ , err := openStep ( t , dkc , capsule . OpenOptions { Now : testkit . Fixed ( to . Unlock ) } )
expectStep ( t , tc . name , step , err , tc . want , tc . step )
}
// time_and_key: 15 stanzas and an identity that opens one fail at step
// 12; a format 1 capsule relabeled 2 with its .dkk fails at step 9.a,
// where its capsule_digest no longer matches.
fifteen , err := tk . WithInnerStanzas ( func ( _ [ ] byte , s [ ] * age . Stanza ) ( [ ] * age . Stanza , error ) {
i := ( * tk . AccessKeyStanza + 1 ) % len ( s )
return append ( s [ : i : i ] , s [ i + 1 : ] ... ) , nil
} )
if err != nil {
t . Fatal ( err )
}
id , err := age . ParseX25519Identity ( fifteen . Identities [ 0 ] )
if err != nil {
t . Fatal ( err )
}
step , _ , err := openStep ( t , fifteen . DKC , capsule . OpenOptions { Identities : [ ] age . Identity { id } , Now : testkit . Fixed ( tk . Unlock ) } )
expectStep ( t , "a format 2 capsule with 15 INNER_ACCESS_AGE stanzas and an identity that opens one" , step , err , datekeys . ErrPolicyStructureMismatch , 12 )
f1 := loadFixture ( t , "time_and_key_portable" )
relabeled := bytes . Clone ( f1 . dkc )
relabeled [ 4 ] = 2
step , calls , err := openStep ( t , relabeled , capsule . OpenOptions { AccessKey : f1 . dkk , Now : testkit . Fixed ( f1 . unlock ( t ) ) } )
expectStep ( t , "a format 1 time_and_key capsule relabeled 2 and its .dkk, with capsule_digest" , step , err , datekeys . ErrAccessInvalid , 9 )
if calls != 0 {
t . Errorf ( "%d release requests" , calls )
}
}