Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
// Package agewrap holds the age recipients and identities that DateKeys wraps
// around standard age files (spec §28-§37), plus the structural stanza rules
// every DateKeys age file must satisfy.
//
// The cryptography is age, tlock and drand's BLS verification. This package
// adds only the rules of the protocol:
//
// - OUTER_TIME_AGE holds exactly one tlock stanza for the expected round and
// the pinned chain hash (spec §32, §35, §63 step 11).
// - PAYLOAD_AGE holds exactly one X25519 stanza, for R_PAYLOAD (spec §29,
// §63 step 17).
// - INNER_ACCESS_AGE holds one or more stanzas, all X25519, one per
// recipient (spec §33, §63 step 13).
//
// The rules are enforced inside Identity.Unwrap, which age calls with the
// complete set of stanzas of the file, so that no file is accepted just
// because age managed to unwrap a file key (spec §27, §63). The same checks
// are exposed for the pre-unlock inspection, which reads the stanzas through a
// probe identity without decrypting anything or touching secrets.
package agewrap
import (
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"strconv"
"strings"
"filippo.io/age"
"github.com/drand/drand/v2/common"
"github.com/drand/drand/v2/crypto"
"github.com/drand/kyber"
"github.com/drand/tlock"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/codec/bech32"
"g.activething.com/go/DateKeys/profile"
"g.activething.com/go/DateKeys/provider"
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
)
// Stanza types of V1.
const (
StanzaTLock = "tlock"
StanzaX25519 = "X25519"
)
// FileKeySize is the size of every age file key: FK_PAYLOAD, FK_ACCESS and
// FK_TIME (spec §28).
const FileKeySize = 16
// ---------------------------------------------------------------------------
// Structural rules
// CheckTimeStanzas enforces the OUTER_TIME_AGE rule: exactly one stanza, of
// type tlock, whose round is the DateKey round and whose chain hash is the one
// of the pinned profile (spec §32, §35, §63 steps 5, 8 and 11).
func CheckTimeStanzas ( stanzas [ ] * age . Stanza , p * profile . Profile , round uint64 ) error {
if len ( stanzas ) != 1 {
return fmt . Errorf ( "agewrap: OUTER_TIME_AGE has %d stanzas, want exactly one tlock stanza: %w" , len ( stanzas ) , datekeys . ErrPolicyStructureMismatch )
}
s := stanzas [ 0 ]
if s . Type != StanzaTLock {
return fmt . Errorf ( "agewrap: OUTER_TIME_AGE stanza type %q, want %q: %w" , s . Type , StanzaTLock , datekeys . ErrPolicyStructureMismatch )
}
if len ( s . Args ) != 2 {
return fmt . Errorf ( "agewrap: tlock stanza has %d arguments, want 2: %w" , len ( s . Args ) , datekeys . ErrPolicyStructureMismatch )
}
if want := strconv . FormatUint ( round , 10 ) ; s . Args [ 0 ] != want {
return fmt . Errorf ( "agewrap: tlock stanza round %q, DateKey round %s: %w" , s . Args [ 0 ] , want , datekeys . ErrRoundMismatch )
}
if want := p . ChainHashHex ( ) ; s . Args [ 1 ] != want {
return fmt . Errorf ( "agewrap: tlock stanza chain hash %q, pinned profile %s uses %s: %w" , s . Args [ 1 ] , p . ID , want , datekeys . ErrProfileMismatch )
}
return nil
}
// CheckPayloadStanzas enforces the PAYLOAD_AGE rule: exactly one stanza, of
// type X25519 (spec §29, §63 steps 6 and 17).
func CheckPayloadStanzas ( stanzas [ ] * age . Stanza ) error {
if len ( stanzas ) != 1 {
return fmt . Errorf ( "agewrap: PAYLOAD_AGE has %d stanzas, want exactly one X25519 stanza: %w" , len ( stanzas ) , datekeys . ErrPolicyStructureMismatch )
}
if t := stanzas [ 0 ] . Type ; t != StanzaX25519 {
return fmt . Errorf ( "agewrap: PAYLOAD_AGE stanza type %q, want %q: %w" , t , StanzaX25519 , datekeys . ErrPolicyStructureMismatch )
}
return nil
}
// CheckAccessStanzas enforces the INNER_ACCESS_AGE rule: one or more stanzas,
// all of type X25519 (spec §33, §36, §63 step 13). Two stanzas with the same
// ephemeral share would be two stanzas for one recipient and are rejected.
func CheckAccessStanzas ( stanzas [ ] * age . Stanza ) error {
if len ( stanzas ) == 0 {
return fmt . Errorf ( "agewrap: INNER_ACCESS_AGE has no stanzas: %w" , datekeys . ErrPolicyStructureMismatch )
}
seen := make ( map [ string ] bool , len ( stanzas ) )
for i , s := range stanzas {
if s . Type != StanzaX25519 {
return fmt . Errorf ( "agewrap: INNER_ACCESS_AGE stanza %d has type %q, want %q: %w" , i , s . Type , StanzaX25519 , datekeys . ErrPolicyStructureMismatch )
}
if len ( s . Args ) == 1 {
if seen [ s . Args [ 0 ] ] {
return fmt . Errorf ( "agewrap: INNER_ACCESS_AGE stanza %d repeats an ephemeral share: %w" , i , datekeys . ErrPolicyStructureMismatch )
}
seen [ s . Args [ 0 ] ] = true
}
}
return nil
}
// ---------------------------------------------------------------------------
// Inspection probe
var errProbe = errors . New ( "agewrap: probe finished" )
// probe records the stanzas age hands to Unwrap and stops decryption with an
// error that does not wrap age.ErrIncorrectIdentity, so age returns it as is.
type probe struct { stanzas [ ] * age . Stanza }
func ( p * probe ) Unwrap ( stanzas [ ] * age . Stanza ) ( [ ] byte , error ) {
p . stanzas = cloneStanzas ( stanzas )
return nil , errProbe
}
// Stanzas parses the age header at the start of r with age itself and returns
// its recipient stanzas. It decrypts nothing and uses no secret: the header is
// extracted with age.ExtractHeader and handed to age.DecryptHeader with a
// probe identity (spec §27, §63 steps 5 and 6).
//
// The result is structural. Its authenticity is only established when the
// header MAC is verified while opening the file (spec §27).
func Stanzas ( r io . Reader ) ( [ ] * age . Stanza , error ) {
hdr , err := age . ExtractHeader ( r )
if err != nil {
return nil , fmt . Errorf ( "agewrap: not a valid age file: %v: %w" , err , datekeys . ErrIntegrity )
}
var p probe
if _ , err := age . DecryptHeader ( hdr , & p ) ; ! errors . Is ( err , errProbe ) {
return nil , fmt . Errorf ( "agewrap: unexpected result inspecting the age header: %v: %w" , err , datekeys . ErrIntegrity )
}
return p . stanzas , nil
}
func cloneStanzas ( in [ ] * age . Stanza ) [ ] * age . Stanza {
out := make ( [ ] * age . Stanza , len ( in ) )
for i , s := range in {
out [ i ] = & age . Stanza { Type : s . Type , Args : append ( [ ] string ( nil ) , s . Args ... ) , Body : bytes . Clone ( s . Body ) }
}
return out
}
// ---------------------------------------------------------------------------
// tlock recipient and identity (OUTER_TIME_AGE)
// TimeRecipient wraps the file key with tlock for one round of a pinned
// profile and emits the stanza "tlock <round> <chainhash>", byte-compatible
// with the stanza of the tlock library and the tle CLI (spec §32, §35). It
// uses only the exported core of tlock: TimeLock and CiphertextToBytes.
type TimeRecipient struct {
chainHash string
round uint64
scheme * crypto . Scheme
key kyber . Point
}
var _ age . RecipientWithLabels = ( * TimeRecipient ) ( nil )
// NewTimeRecipient returns the tlock recipient of round under p, using only
// the pinned parameters of p (strict mode, spec §35).
func NewTimeRecipient ( p * profile . Profile , round uint64 ) ( * TimeRecipient , error ) {
scheme , key , err := pinned ( p )
if err != nil {
return nil , err
}
if round == 0 || round > p . MaxRound ( ) {
return nil , fmt . Errorf ( "agewrap: round %d outside the range of %s: %w" , round , p . ID , datekeys . ErrDateKeyInvalid )
}
return & TimeRecipient { chainHash : p . ChainHashHex ( ) , round : round , scheme : scheme , key : key } , nil
}
// Wrap implements age.Recipient.
func ( r * TimeRecipient ) Wrap ( fileKey [ ] byte ) ( [ ] * age . Stanza , error ) {
ct , err := tlock . TimeLock ( * r . scheme , r . key , r . round , fileKey )
if err != nil {
return nil , fmt . Errorf ( "agewrap: tlock: %w" , err )
}
body , err := tlock . CiphertextToBytes ( * r . scheme , ct )
if err != nil {
return nil , fmt . Errorf ( "agewrap: tlock ciphertext: %w" , err )
}
return [ ] * age . Stanza { {
Type : StanzaTLock ,
Args : [ ] string { strconv . FormatUint ( r . round , 10 ) , r . chainHash } ,
Body : body ,
} } , nil
}
// WrapWithLabels implements age.RecipientWithLabels with a random label, so
// that age refuses to mix this recipient with any other one in the same file:
// OUTER_TIME_AGE must hold exactly one tlock stanza.
func ( r * TimeRecipient ) WrapWithLabels ( fileKey [ ] byte ) ( [ ] * age . Stanza , [ ] string , error ) {
s , err := r . Wrap ( fileKey )
if err != nil {
return nil , nil , err
}
var label [ 16 ] byte
_ , _ = rand . Read ( label [ : ] ) // never fails since Go 1.24
return s , [ ] string { "datekeys-tlock-" + hex . EncodeToString ( label [ : ] ) } , nil
}
// TimeIdentity opens OUTER_TIME_AGE under the strict rules of spec §35 and
// §63 step 11. Unwrap validates the complete stanza set, verifies the release
// locally and calls tlock.TimeUnlock, which verifies the beacon again before
// decrypting. Every failure keeps its own normative error: none is turned
// into "too early".
type TimeIdentity struct {
profile * profile . Profile
round uint64
release provider . Release
scheme * crypto . Scheme
key kyber . Point
}
var _ age . Identity = ( * TimeIdentity ) ( nil )
// NewTimeIdentity returns the identity that opens OUTER_TIME_AGE for round
// with release.
func NewTimeIdentity ( p * profile . Profile , round uint64 , release provider . Release ) ( * TimeIdentity , error ) {
scheme , key , err := pinned ( p )
if err != nil {
return nil , err
}
return & TimeIdentity { profile : p . Clone ( ) , round : round , release : release , scheme : scheme , key : key } , nil
}
// Unwrap implements age.Identity.
func ( i * TimeIdentity ) Unwrap ( stanzas [ ] * age . Stanza ) ( [ ] byte , error ) {
if err := CheckTimeStanzas ( stanzas , i . profile , i . round ) ; err != nil {
return nil , err
}
if err := provider . Verify ( i . profile , provider . Condition { Round : i . round } , i . release ) ; err != nil {
return nil , err
}
ct , err := tlock . BytesToCiphertext ( * i . scheme , stanzas [ 0 ] . Body )
if err != nil {
return nil , fmt . Errorf ( "agewrap: malformed tlock stanza body: %v: %w" , err , datekeys . ErrIntegrity )
}
beacon := common . Beacon { Round : i . release . Round , Signature : i . release . Signature }
fileKey , err := tlock . TimeUnlock ( * i . scheme , i . key , beacon , ct )
if err != nil {
return nil , fmt . Errorf ( "agewrap: tlock unwrap failed: %v: %w" , err , datekeys . ErrIntegrity )
}
if len ( fileKey ) != FileKeySize {
return nil , fmt . Errorf ( "agewrap: tlock stanza wraps a %d-byte file key: %w" , len ( fileKey ) , datekeys . ErrIntegrity )
}
return fileKey , nil
}
func pinned ( p * profile . Profile ) ( * crypto . Scheme , kyber . Point , error ) {
scheme , err := p . DrandScheme ( )
if err != nil {
return nil , nil , err
}
key := scheme . KeyGroup . Point ( )
if err := key . UnmarshalBinary ( p . PublicKey ) ; err != nil {
return nil , nil , fmt . Errorf ( "agewrap: pinned public key of %s: %v: %w" , p . ID , err , datekeys . ErrUnknownProfile )
}
if key . Equal ( key . Null ( ) ) {
return nil , nil , fmt . Errorf ( "agewrap: pinned public key of %s is the identity element: %w" , p . ID , datekeys . ErrUnknownProfile )
}
return scheme , key , nil
}
// ---------------------------------------------------------------------------
// X25519 identities (PAYLOAD_AGE and INNER_ACCESS_AGE)
// PayloadIdentity opens PAYLOAD_AGE with I_PAYLOAD (spec §29, §30.1, §63 step
// 17). It rejects the file unless it holds exactly one X25519 stanza and that
// stanza is for R_PAYLOAD.
type PayloadIdentity struct {
id * age . X25519Identity
}
var _ age . Identity = ( * PayloadIdentity ) ( nil )
// NewPayloadIdentity returns the identity for the raw 32-byte I_PAYLOAD.
func NewPayloadIdentity ( raw [ ] byte ) ( * PayloadIdentity , error ) {
id , err := X25519IdentityFromRaw ( raw )
if err != nil {
return nil , err
}
return & PayloadIdentity { id : id } , nil
}
// Unwrap implements age.Identity.
func ( i * PayloadIdentity ) Unwrap ( stanzas [ ] * age . Stanza ) ( [ ] byte , error ) {
if err := CheckPayloadStanzas ( stanzas ) ; err != nil {
return nil , err
}
fileKey , err := i . id . Unwrap ( stanzas )
if errors . Is ( err , age . ErrIncorrectIdentity ) {
// CONTROL_A + PAYLOAD_AGE_B: I_PAYLOAD_A cannot unwrap FK_PAYLOAD_B (spec §30.1).
return nil , fmt . Errorf ( "agewrap: PAYLOAD_AGE is not encrypted to this control's R_PAYLOAD: %w" , datekeys . ErrIntegrity )
}
if err != nil {
return nil , fmt . Errorf ( "agewrap: malformed PAYLOAD_AGE stanza: %v: %w" , err , datekeys . ErrIntegrity )
}
return fileKey , nil
}
// AccessIdentity opens INNER_ACCESS_AGE with the caller's X25519 identities,
// including the one of a portable .dkk (spec §33, §38, §63 step 13). It
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
// validates the complete stanza set first, and rejects the file if any
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
// identity unwraps more than one stanza, which would be two stanzas for the
// same recipient.
type AccessIdentity struct {
ids [ ] age . Identity
}
var _ age . Identity = ( * AccessIdentity ) ( 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
// NewAccessIdentity returns an AccessIdentity trying every identity of ids.
// Nil entries are dropped; at least one identity is required.
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
func NewAccessIdentity ( ids ... age . Identity ) ( * AccessIdentity , error ) {
var clean [ ] age . Identity
for _ , id := range ids {
if id != nil {
clean = append ( clean , id )
}
}
if len ( clean ) == 0 {
return nil , fmt . Errorf ( "agewrap: time_and_key needs an access identity: %w" , datekeys . ErrAccessRequired )
}
return & AccessIdentity { ids : clean } , 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
// Unwrap implements age.Identity. The codes follow spec §63 step 13, in this
// order: the stanza rules (ErrPolicyStructureMismatch); a malformed X25519
// stanza, found by the first identity already, because age checks the form
// of a stanza before any key agreement (ErrIntegrity); an identity that
// unwraps more than one stanza, even if another one unwraps exactly one
// (ErrPolicyStructureMismatch); no identity that unwraps any
// (ErrAccessInvalid). Every identity is tried against every stanza, so that
// the result does not depend on the order of the identities (spec §69.1).
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
func ( a * AccessIdentity ) Unwrap ( stanzas [ ] * age . Stanza ) ( [ ] byte , error ) {
if err := CheckAccessStanzas ( stanzas ) ; err != nil {
return nil , err
}
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
var fileKey [ ] byte
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
for _ , id := range a . ids {
matches := 0
for _ , s := range stanzas {
fk , err := id . Unwrap ( [ ] * age . Stanza { s } )
if errors . Is ( err , age . ErrIncorrectIdentity ) {
continue
}
if err != nil {
return nil , fmt . Errorf ( "agewrap: malformed INNER_ACCESS_AGE stanza: %v: %w" , err , datekeys . ErrIntegrity )
}
matches ++
if fileKey == nil {
fileKey = fk
}
}
if matches > 1 {
return nil , fmt . Errorf ( "agewrap: one identity opens %d INNER_ACCESS_AGE stanzas, want one per recipient: %w" , matches , datekeys . ErrPolicyStructureMismatch )
}
}
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
if fileKey == nil {
return nil , fmt . Errorf ( "agewrap: no supplied identity is a recipient of INNER_ACCESS_AGE: %w" , datekeys . ErrAccessInvalid )
}
return fileKey , nil
Initial implementation of the DateKeys Protocol v0.8.1
Reference implementation in Go, built from the implementation plan
(milestones M0 to M5): datekey, profile, provider, codec, agewrap,
extension, capsule, accesskey, the datekeys CLI, official vectors and
fixtures, the mutation corpus, fuzz targets, interop and live tests,
CI workflows, traceability and policy documents.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2 weeks ago
}
// ---------------------------------------------------------------------------
// Raw X25519 keys
// X25519IdentityFromRaw converts 32 raw identity bytes, the canonical form
// inside CONTROL_CBOR and .dkk (spec §31, §38), to an age identity.
func X25519IdentityFromRaw ( raw [ ] byte ) ( * age . X25519Identity , error ) {
if len ( raw ) != 32 {
return nil , fmt . Errorf ( "agewrap: X25519 identity is %d bytes, want 32: %w" , len ( raw ) , datekeys . ErrIntegrity )
}
s , err := bech32 . Encode ( "AGE-SECRET-KEY-" , raw )
if err != nil {
return nil , fmt . Errorf ( "agewrap: encode identity: %v: %w" , err , datekeys . ErrIntegrity )
}
id , err := age . ParseX25519Identity ( strings . ToUpper ( s ) )
if err != nil {
return nil , fmt . Errorf ( "agewrap: parse identity: %v: %w" , err , datekeys . ErrIntegrity )
}
return id , nil
}
// RawX25519Identity returns the 32 raw bytes of an age X25519 identity.
func RawX25519Identity ( id * age . X25519Identity ) ( [ ] byte , error ) {
hrp , raw , err := bech32 . Decode ( id . String ( ) )
if err != nil || hrp != "AGE-SECRET-KEY-" || len ( raw ) != 32 {
return nil , fmt . Errorf ( "agewrap: unexpected age identity encoding" )
}
return raw , nil
}
// RawX25519Recipient returns the 32 raw bytes of an age X25519 recipient.
func RawX25519Recipient ( r * age . X25519Recipient ) ( [ ] byte , error ) {
hrp , raw , err := bech32 . Decode ( r . String ( ) )
if err != nil || hrp != "age" || len ( raw ) != 32 {
return nil , fmt . Errorf ( "agewrap: unexpected age recipient encoding" )
}
return raw , nil
}