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 capsule_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"context"
|
|
|
|
|
"crypto/sha256"
|
|
|
|
|
"encoding/hex"
|
|
|
|
|
"io"
|
|
|
|
|
"os"
|
|
|
|
|
"path/filepath"
|
|
|
|
|
"reflect"
|
|
|
|
|
"slices"
|
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
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"filippo.io/age"
|
|
|
|
|
|
|
|
|
|
"g.activething.com/go/DateKeys/accesskey"
|
|
|
|
|
"g.activething.com/go/DateKeys/agewrap"
|
|
|
|
|
"g.activething.com/go/DateKeys/capsule"
|
|
|
|
|
"g.activething.com/go/DateKeys/extension"
|
|
|
|
|
"g.activething.com/go/DateKeys/internal/testkit"
|
|
|
|
|
"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
|
|
|
)
|
|
|
|
|
|
|
|
|
|
const fixtureDir = "../testdata/fixtures"
|
|
|
|
|
|
|
|
|
|
var fixtureNames = []string{"time_only", "time_only_extensions", "time_and_key_portable", "time_and_key_recipients", "empty_payload"}
|
|
|
|
|
|
|
|
|
|
type fixture struct {
|
|
|
|
|
testkit.DKCFixture
|
|
|
|
|
dkc []byte
|
|
|
|
|
plaintext []byte
|
|
|
|
|
release provider.Release
|
|
|
|
|
dkk *accesskey.AccessKey
|
|
|
|
|
ids []age.Identity
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadFixture(t testing.TB, name string) *fixture {
|
|
|
|
|
t.Helper()
|
|
|
|
|
f := &fixture{}
|
|
|
|
|
if err := testkit.ReadJSON(filepath.Join(fixtureDir, name+".json"), &f.DKCFixture); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
var err error
|
|
|
|
|
if f.dkc, err = os.ReadFile(filepath.Join(fixtureDir, f.File)); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if f.plaintext, err = os.ReadFile(filepath.Join(fixtureDir, f.PlaintextFile)); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
sig, err := hex.DecodeString(f.Release.Signature)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
f.release = provider.Release{Round: f.Release.Round, Signature: sig}
|
|
|
|
|
if f.AccessKeyFile != "" {
|
|
|
|
|
b, err := os.ReadFile(filepath.Join(fixtureDir, f.AccessKeyFile))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if f.dkk, err = accesskey.Decode(bytes.NewReader(b)); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, s := range f.Identities {
|
|
|
|
|
id, err := age.ParseX25519Identity(s)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
f.ids = append(f.ids, id)
|
|
|
|
|
}
|
|
|
|
|
return f
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fixture) unlock(t testing.TB) time.Time {
|
|
|
|
|
u, err := time.Parse(time.RFC3339, f.UnlockAt)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return u
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (f *fixture) openOptions(t testing.TB) capsule.OpenOptions {
|
|
|
|
|
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(f.release), Now: testkit.Fixed(f.unlock(t))}
|
|
|
|
|
if f.AccessPolicy == "time_and_key" {
|
|
|
|
|
o.AccessKey = f.dkk
|
|
|
|
|
}
|
|
|
|
|
return o
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func decryptAge(t *testing.T, file []byte, id age.Identity) []byte {
|
|
|
|
|
t.Helper()
|
|
|
|
|
r, err := age.Decrypt(bytes.NewReader(file), id)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
b, err := io.ReadAll(r)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return b
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func stanzaList(in []capsule.StanzaInfo) []testkit.FixtureStanza {
|
|
|
|
|
out := make([]testkit.FixtureStanza, len(in))
|
|
|
|
|
for i, s := range in {
|
|
|
|
|
out[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spec §67: conformance is shown by decrypting and verifying each official
|
|
|
|
|
// fixture and comparing every intermediate value and the plaintext.
|
|
|
|
|
func TestConformanceFixtures(t *testing.T) {
|
|
|
|
|
p := profile.Quicknet()
|
|
|
|
|
for _, name := range fixtureNames {
|
|
|
|
|
t.Run(name, func(t *testing.T) {
|
|
|
|
|
f := loadFixture(t, name)
|
|
|
|
|
if sum := sha256.Sum256(f.dkc); hex.EncodeToString(sum[:]) != f.SHA256 {
|
|
|
|
|
t.Fatal("fixture bytes changed")
|
|
|
|
|
}
|
|
|
|
|
if sum := sha256.Sum256(f.plaintext); hex.EncodeToString(sum[:]) != f.PlaintextSHA256 {
|
|
|
|
|
t.Fatal("plaintext file changed")
|
|
|
|
|
}
|
|
|
|
|
if err := provider.Verify(p, provider.Condition{Round: f.release.Round}, f.release); err != nil {
|
|
|
|
|
t.Fatalf("embedded release: %v", err)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PRELUDE, PUBLIC_HEADER and the pre-unlock view (steps 1 to 8).
|
|
|
|
|
parts, err := testkit.Split(f.dkc)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if hex.EncodeToString(parts.Prelude) != f.Prelude || hex.EncodeToString(parts.Header) != f.PublicHeader {
|
|
|
|
|
t.Fatal("PRELUDE or PUBLIC_HEADER differ")
|
|
|
|
|
}
|
|
|
|
|
in, err := capsule.Inspect(bytes.NewReader(f.dkc), capsule.InspectOptions{Registry: testkit.Registry()})
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if in.Header.DateKey.Compact() != f.DateKey || in.Header.CapsuleIDHex() != f.CapsuleID ||
|
|
|
|
|
in.Header.Policy.String() != f.AccessPolicy || in.UnlockAt.Format(time.RFC3339) != f.UnlockAt {
|
|
|
|
|
t.Fatalf("inspection differs: %+v", in.Header)
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(stanzaList(in.OuterStanzas), f.OuterStanzas) || !reflect.DeepEqual(stanzaList(in.PayloadStanzas), f.PayloadStanzas) {
|
|
|
|
|
t.Fatal("stanzas differ")
|
|
|
|
|
}
|
|
|
|
|
for _, c := range in.Checks {
|
|
|
|
|
if !c.OK || c.Step > 8 {
|
|
|
|
|
t.Fatalf("unexpected inspection check %+v", c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
var pre [capsule.PreludeSize]byte
|
|
|
|
|
copy(pre[:], parts.Prelude)
|
|
|
|
|
if b := capsule.HeaderBinding(pre, parts.Header); hex.EncodeToString(b[:]) != f.HeaderBinding {
|
|
|
|
|
t.Fatal("header_binding differs")
|
|
|
|
|
}
|
|
|
|
|
h, err := capsule.DecodeHeader(parts.Header)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if re, _ := capsule.EncodeHeader(h); !bytes.Equal(re, parts.Header) {
|
|
|
|
|
t.Fatal("EncodeHeader(DecodeHeader(x)) != x")
|
|
|
|
|
}
|
|
|
|
|
if got := fixtureExts(h.Critical, h.Noncritical); !reflect.DeepEqual(got, f.HeaderExtensions) {
|
|
|
|
|
t.Fatalf("header extensions differ:\n got %+v\nwant %+v", got, f.HeaderExtensions)
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Opening layer by layer: OUTER_TIME_AGE, INNER_ACCESS_AGE, CONTROL_CBOR.
|
|
|
|
|
timeID, _ := agewrap.NewTimeIdentity(p, f.release.Round, f.release)
|
|
|
|
|
inner := decryptAge(t, parts.Sealed, timeID)
|
|
|
|
|
control := inner
|
|
|
|
|
if f.Structure == "time_and_key" {
|
|
|
|
|
st, err := agewrap.Stanzas(bytes.NewReader(inner))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
got := make([]testkit.FixtureStanza, len(st))
|
|
|
|
|
for i, s := range st {
|
|
|
|
|
got[i] = testkit.FixtureStanza{Type: s.Type, Args: s.Args}
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(got, f.InnerStanzas) {
|
|
|
|
|
t.Fatal("INNER_ACCESS_AGE stanzas differ")
|
|
|
|
|
}
|
|
|
|
|
kid, err := f.dkk.Identity()
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
acc, _ := agewrap.NewAccessIdentity(kid)
|
|
|
|
|
control = decryptAge(t, inner, acc)
|
|
|
|
|
}
|
|
|
|
|
if hex.EncodeToString(control) != f.ControlCBOR {
|
|
|
|
|
t.Fatal("CONTROL_CBOR differs")
|
|
|
|
|
}
|
|
|
|
|
ctrl, err := capsule.DecodeControl(control)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if hex.EncodeToString(ctrl.PayloadIdentity[:]) != f.PayloadIdentity || hex.EncodeToString(ctrl.HeaderBinding[:]) != f.HeaderBinding {
|
|
|
|
|
t.Fatal("I_PAYLOAD or header_binding in CONTROL_CBOR differ")
|
|
|
|
|
}
|
|
|
|
|
if re, _ := capsule.EncodeControl(ctrl); !bytes.Equal(re, control) {
|
|
|
|
|
t.Fatal("EncodeControl(DecodeControl(x)) != x")
|
|
|
|
|
}
|
|
|
|
|
if got := fixtureExts(ctrl.Critical, ctrl.Noncritical); !reflect.DeepEqual(got, f.ControlExt) {
|
|
|
|
|
t.Fatalf("control extensions differ:\n got %+v\nwant %+v", got, f.ControlExt)
|
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
|
|
|
}
|
|
|
|
|
payloadID, _ := agewrap.NewPayloadIdentity(ctrl.PayloadIdentity[:])
|
|
|
|
|
if got := decryptAge(t, parts.Payload, payloadID); !bytes.Equal(got, f.plaintext) {
|
|
|
|
|
t.Fatal("PAYLOAD_AGE plaintext differs")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The complete flow through the public API, stage by stage.
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if !bytes.Equal(out.Bytes(), f.plaintext) {
|
|
|
|
|
t.Fatal("plaintext differs")
|
|
|
|
|
}
|
|
|
|
|
var stages []testkit.FixtureStage
|
|
|
|
|
for _, c := range opened.Inspection.Checks {
|
|
|
|
|
stages = append(stages, testkit.FixtureStage{Step: c.Step, Name: c.Name, OK: c.OK, Error: c.Error})
|
|
|
|
|
}
|
|
|
|
|
if !reflect.DeepEqual(stages, f.Stages) {
|
|
|
|
|
t.Fatalf("stages differ:\n got %+v\nwant %+v", stages, f.Stages)
|
|
|
|
|
}
|
|
|
|
|
if got := fixtureExts(opened.ControlCritical, opened.ControlNoncritical); !reflect.DeepEqual(got, f.ControlExt) {
|
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
|
|
|
t.Fatal("Open does not report the control extensions")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fixtureExts lists extensions as the fixtures record them, with the exact
|
|
|
|
|
// data bytes (spec §67, §68).
|
|
|
|
|
func fixtureExts(critical, noncritical []extension.Extension) []testkit.FixtureExt {
|
|
|
|
|
var out []testkit.FixtureExt
|
|
|
|
|
for i, e := range append(slices.Clone(critical), noncritical...) {
|
|
|
|
|
out = append(out, testkit.FixtureExt{Critical: i < len(critical), ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The extension data of time_only_extensions, restated independently of its
|
|
|
|
|
// JSON: raw UTF-8 bytes in PUBLIC_HEADER, and CBOR in the profile of spec §58
|
|
|
|
|
// in CONTROL_CBOR. The base protocol decodes neither.
|
|
|
|
|
func TestExtensionFixtureData(t *testing.T) {
|
|
|
|
|
f := loadFixture(t, "time_only_extensions")
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), f.openOptions(t))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
h := opened.Inspection.Header
|
|
|
|
|
if len(h.Critical) != 0 || len(h.Noncritical) != 1 || h.Noncritical[0].ID != "org.example.label" || h.Noncritical[0].Version != 1 ||
|
|
|
|
|
string(h.Noncritical[0].Data) != "public label" {
|
|
|
|
|
t.Fatalf("header extension %+v", h.Noncritical)
|
|
|
|
|
}
|
|
|
|
|
c := opened.ControlNoncritical
|
|
|
|
|
if len(opened.ControlCritical) != 0 || len(c) != 1 || c[0].ID != "org.example.note" || c[0].Version != 2 ||
|
|
|
|
|
hex.EncodeToString(c[0].Data) != "a2000701667365616c6564" {
|
|
|
|
|
t.Fatalf("control extension %+v", c)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func loadAccessKey(t *testing.T, name string) *accesskey.AccessKey {
|
|
|
|
|
t.Helper()
|
|
|
|
|
b, err := os.ReadFile(filepath.Join(fixtureDir, name+".dkk"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
k, err := accesskey.Decode(bytes.NewReader(b))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return k
|
|
|
|
|
}
|
|
|
|
|
|
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
|
|
|
// Every known recipient of a multi-recipient fixture opens it on its own.
|
|
|
|
|
func TestFixtureRecipients(t *testing.T) {
|
|
|
|
|
f := loadFixture(t, "time_and_key_recipients")
|
|
|
|
|
if len(f.ids) != 2 {
|
|
|
|
|
t.Fatal("fixture should have two known recipients")
|
|
|
|
|
}
|
|
|
|
|
for i, id := range f.ids {
|
|
|
|
|
o := f.openOptions(t)
|
|
|
|
|
o.AccessKey = nil
|
|
|
|
|
o.Identities = []age.Identity{id}
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
if _, err := capsule.Open(context.Background(), &out, bytes.NewReader(f.dkc), o); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
|
|
|
|
|
t.Fatalf("recipient %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// A non-seekable reader works too: only the capsule_digest shortcut is skipped.
|
|
|
|
|
func TestOpenFromPlainReader(t *testing.T) {
|
|
|
|
|
for _, name := range []string{"time_only", "time_and_key_portable"} {
|
|
|
|
|
f := loadFixture(t, name)
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
r := struct{ io.Reader }{bytes.NewReader(f.dkc)}
|
|
|
|
|
if _, err := capsule.Open(context.Background(), &out, r, f.openOptions(t)); err != nil || !bytes.Equal(out.Bytes(), f.plaintext) {
|
|
|
|
|
t.Fatalf("%s: %v", name, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|