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"
|
|
|
|
|
"errors"
|
|
|
|
|
"io"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"filippo.io/age"
|
|
|
|
|
|
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
|
|
|
"g.activething.com/go/DateKeys/accesskey"
|
|
|
|
|
"g.activething.com/go/DateKeys/capsule"
|
|
|
|
|
"g.activething.com/go/DateKeys/datekey"
|
|
|
|
|
"g.activething.com/go/DateKeys/extension"
|
|
|
|
|
"g.activething.com/go/DateKeys/internal/testkit"
|
|
|
|
|
"g.activething.com/go/DateKeys/profile"
|
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 past(t *testing.T, round uint64) capsule.EncryptOptions {
|
|
|
|
|
t.Helper()
|
|
|
|
|
p := profile.Quicknet()
|
|
|
|
|
unlock, err := datekey.RoundTime(p, round)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
return capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func open(t *testing.T, dkc []byte, o capsule.OpenOptions) ([]byte, error) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
_, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc), o)
|
|
|
|
|
return out.Bytes(), err
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func defaultOpen(round uint64) capsule.OpenOptions {
|
|
|
|
|
return capsule.OpenOptions{Registry: testkit.Registry(), Source: testkit.NewSource(testkit.Release(round)), Now: time.Now}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEncryptRoundTripBothPolicies(t *testing.T) {
|
|
|
|
|
msg := strings.Repeat("0123456789abcdef", 20000) // several STREAM chunks
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
setup func(o *capsule.EncryptOptions) []age.Identity
|
|
|
|
|
}{
|
|
|
|
|
{"time_only", func(o *capsule.EncryptOptions) []age.Identity { return nil }},
|
|
|
|
|
{"time_and_key, portable", func(o *capsule.EncryptOptions) []age.Identity {
|
|
|
|
|
o.Policy, o.NewPortableKey = capsule.TimeAndKey, true
|
|
|
|
|
return nil
|
|
|
|
|
}},
|
|
|
|
|
{"time_and_key, three recipients", func(o *capsule.EncryptOptions) []age.Identity {
|
|
|
|
|
o.Policy = capsule.TimeAndKey
|
|
|
|
|
var ids []age.Identity
|
|
|
|
|
for range 3 {
|
|
|
|
|
id, _ := age.GenerateX25519Identity()
|
|
|
|
|
o.Recipients = append(o.Recipients, id.Recipient())
|
|
|
|
|
ids = append(ids, id)
|
|
|
|
|
}
|
|
|
|
|
return ids
|
|
|
|
|
}},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
opts := past(t, 1000)
|
|
|
|
|
ids := tc.setup(&opts)
|
|
|
|
|
var dkc bytes.Buffer
|
|
|
|
|
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if res.DateKey.Round != 1000 || !res.UnlockAt.Equal(opts.UnlockAt) {
|
|
|
|
|
t.Fatalf("result %+v", res)
|
|
|
|
|
}
|
|
|
|
|
o := defaultOpen(1000)
|
|
|
|
|
o.Identities = ids
|
|
|
|
|
if res.PortableKey != nil {
|
|
|
|
|
o.AccessKey = res.PortableKey
|
|
|
|
|
}
|
|
|
|
|
got, err := open(t, dkc.Bytes(), o)
|
|
|
|
|
if err != nil || string(got) != msg {
|
|
|
|
|
t.Fatalf("open: %v", err)
|
|
|
|
|
}
|
|
|
|
|
for i, id := range ids {
|
|
|
|
|
o := defaultOpen(1000)
|
|
|
|
|
o.Identities = []age.Identity{id}
|
|
|
|
|
if got, err := open(t, dkc.Bytes(), o); err != nil || string(got) != msg {
|
|
|
|
|
t.Fatalf("recipient %d: %v", i, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEncryptRejectsInvalidOptions(t *testing.T) {
|
|
|
|
|
x, _ := age.GenerateX25519Identity()
|
|
|
|
|
scrypt, _ := age.NewScryptRecipient("pw")
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
name string
|
|
|
|
|
edit func(o *capsule.EncryptOptions)
|
|
|
|
|
}{
|
|
|
|
|
{"no profile", func(o *capsule.EncryptOptions) { o.Profile = nil }},
|
|
|
|
|
{"no clock", func(o *capsule.EncryptOptions) { o.Now = nil }},
|
|
|
|
|
{"unlock time in the past", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt.Add(time.Second)) }},
|
|
|
|
|
{"unlock time equal to now", func(o *capsule.EncryptOptions) { o.Now = testkit.Fixed(o.UnlockAt) }},
|
|
|
|
|
{"time_only with recipients", func(o *capsule.EncryptOptions) { o.Recipients = []age.Recipient{x.Recipient()} }},
|
|
|
|
|
{"time_only with a portable key", func(o *capsule.EncryptOptions) { o.NewPortableKey = true }},
|
|
|
|
|
{"time_and_key without recipients", func(o *capsule.EncryptOptions) { o.Policy = capsule.TimeAndKey }},
|
|
|
|
|
{"non-X25519 recipient", func(o *capsule.EncryptOptions) {
|
|
|
|
|
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{scrypt}
|
|
|
|
|
}},
|
|
|
|
|
{"recipient listed twice", func(o *capsule.EncryptOptions) {
|
|
|
|
|
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{x.Recipient(), x.Recipient()}
|
|
|
|
|
}},
|
|
|
|
|
{"unknown policy", func(o *capsule.EncryptOptions) { o.Policy = 7 }},
|
|
|
|
|
{"invalid profile", func(o *capsule.EncryptOptions) { o.Profile.ChainHash[0] ^= 1 }},
|
|
|
|
|
{"duplicate header extension", func(o *capsule.EncryptOptions) {
|
|
|
|
|
o.Noncritical = []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
|
|
|
|
|
}},
|
|
|
|
|
{"extension both critical and noncritical", func(o *capsule.EncryptOptions) {
|
|
|
|
|
o.ControlCritical = []extension.Extension{{ID: "a", Version: 1}}
|
|
|
|
|
o.ControlNoncritical = []extension.Extension{{ID: "a", Version: 1}}
|
|
|
|
|
}},
|
|
|
|
|
} {
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
opts := past(t, 1000)
|
|
|
|
|
tc.edit(&opts)
|
|
|
|
|
var dkc bytes.Buffer
|
|
|
|
|
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil {
|
|
|
|
|
t.Fatal("accepted")
|
|
|
|
|
}
|
|
|
|
|
if dkc.Len() != 0 {
|
|
|
|
|
t.Fatal("wrote output before validating the options")
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Spec §38: an I_ACCESS is generated for one capsule only and never reused.
|
|
|
|
|
func TestPortableKeysAreNeverReused(t *testing.T) {
|
|
|
|
|
var dkcs [2][]byte
|
|
|
|
|
var keys [2]*accesskey.AccessKey
|
|
|
|
|
for i := range 2 {
|
|
|
|
|
opts := past(t, 1000)
|
|
|
|
|
opts.Policy, opts.NewPortableKey = capsule.TimeAndKey, true
|
|
|
|
|
var b bytes.Buffer
|
|
|
|
|
res, err := capsule.Encrypt(&b, strings.NewReader("x"), opts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
dkcs[i], keys[i] = b.Bytes(), res.PortableKey
|
|
|
|
|
}
|
|
|
|
|
if bytes.Equal(keys[0].Material, keys[1].Material) || keys[0].CredentialID == keys[1].CredentialID || keys[0].CapsuleID == keys[1].CapsuleID {
|
|
|
|
|
t.Fatal("two capsules share an I_ACCESS, credential_id or capsule_id")
|
|
|
|
|
}
|
|
|
|
|
// The .dkk of capsule A is refused for capsule B before any request, and
|
|
|
|
|
// its identity cannot open B's access layer either.
|
|
|
|
|
o := defaultOpen(1000)
|
|
|
|
|
o.AccessKey = keys[0]
|
|
|
|
|
src := testkit.NewSource(testkit.Release(1000))
|
|
|
|
|
o.Source = src
|
|
|
|
|
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) || src.Calls != 0 {
|
|
|
|
|
t.Fatalf("foreign .dkk: %v (requests: %d)", err, src.Calls)
|
|
|
|
|
}
|
|
|
|
|
id, _ := keys[0].Identity()
|
|
|
|
|
o = defaultOpen(1000)
|
|
|
|
|
o.Identities = []age.Identity{id}
|
|
|
|
|
if _, err := open(t, dkcs[1], o); !errors.Is(err, datekeys.ErrAccessInvalid) {
|
|
|
|
|
t.Fatalf("foreign identity: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestFutureCapsuleStaysLockedWithoutRequests(t *testing.T) {
|
|
|
|
|
p := profile.Quicknet()
|
|
|
|
|
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
|
|
|
|
|
opts := capsule.EncryptOptions{Profile: p, UnlockAt: now.Add(time.Hour), Now: testkit.Fixed(now)}
|
|
|
|
|
var dkc bytes.Buffer
|
|
|
|
|
res, err := capsule.Encrypt(&dkc, strings.NewReader("secret"), opts)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if res.UnlockAt.Before(opts.UnlockAt) || res.UnlockAt.Sub(opts.UnlockAt) >= p.Period {
|
|
|
|
|
t.Fatalf("unsafe rounding: %s for %s", res.UnlockAt, opts.UnlockAt)
|
|
|
|
|
}
|
|
|
|
|
src := testkit.NewSource()
|
|
|
|
|
o := capsule.OpenOptions{Registry: testkit.Registry(), Source: src, Now: testkit.Fixed(now)}
|
|
|
|
|
if _, err := open(t, dkc.Bytes(), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) || src.Calls != 0 {
|
|
|
|
|
t.Fatalf("locked capsule: %v (requests: %d)", err, src.Calls)
|
|
|
|
|
}
|
|
|
|
|
// Inspection works on a locked capsule and reports its condition.
|
|
|
|
|
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
|
|
|
|
|
if err != nil || in.Header.DateKey != res.DateKey || !in.UnlockAt.Equal(res.UnlockAt) {
|
|
|
|
|
t.Fatalf("inspect: %+v %v", in, err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestExtensionsRoundTrip(t *testing.T) {
|
|
|
|
|
hExt, _ := extension.New("org.example.public", 1, []any{"a", uint64(1)})
|
|
|
|
|
cExt, _ := extension.New("org.example.sealed", 3, map[string]any{"k": []byte{1, 2}})
|
|
|
|
|
opts := past(t, 1000)
|
|
|
|
|
opts.Noncritical = []extension.Extension{hExt}
|
|
|
|
|
opts.ControlNoncritical = []extension.Extension{cExt}
|
|
|
|
|
var dkc bytes.Buffer
|
|
|
|
|
if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
in, _ := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: testkit.Registry()})
|
|
|
|
|
if len(in.Header.Noncritical) != 1 || !bytes.Equal(in.Header.Noncritical[0].Data, hExt.Data) {
|
|
|
|
|
t.Fatal("header extension lost")
|
|
|
|
|
}
|
|
|
|
|
var out bytes.Buffer
|
|
|
|
|
opened, err := capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), defaultOpen(1000))
|
|
|
|
|
if err != nil || len(opened.ControlNoncritical) != 1 || !bytes.Equal(opened.ControlNoncritical[0].Data, cExt.Data) {
|
|
|
|
|
t.Fatalf("control extension lost: %v", err)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestOpenRequiresOptions(t *testing.T) {
|
|
|
|
|
f := loadFixture(t, "time_only")
|
|
|
|
|
for name, o := range map[string]capsule.OpenOptions{
|
|
|
|
|
"no source": {Registry: testkit.Registry(), Now: time.Now},
|
|
|
|
|
"no clock": {Registry: testkit.Registry(), Source: testkit.NewSource()},
|
|
|
|
|
"no registry": {Source: testkit.NewSource(), Now: time.Now},
|
|
|
|
|
} {
|
|
|
|
|
if _, err := capsule.Open(context.Background(), io.Discard, bytes.NewReader(f.dkc), o); err == nil {
|
|
|
|
|
t.Errorf("%s: accepted", name)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEncryptWriteError(t *testing.T) {
|
|
|
|
|
opts := past(t, 1000)
|
|
|
|
|
if _, err := capsule.Encrypt(failingWriter{}, strings.NewReader("x"), opts); err == nil {
|
|
|
|
|
t.Fatal("write error ignored")
|
|
|
|
|
}
|
|
|
|
|
if _, err := capsule.Encrypt(io.Discard, failingReader{}, opts); err == nil {
|
|
|
|
|
t.Fatal("read error ignored")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type failingWriter struct{}
|
|
|
|
|
|
|
|
|
|
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
|
|
|
|
|
|
|
|
|
|
type failingReader struct{}
|
|
|
|
|
|
|
|
|
|
func (failingReader) Read([]byte) (int, error) { return 0, errors.New("read error") }
|