You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DateKeys/capsule/encrypt_test.go

371 lines
13 KiB

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/agewrap"
"g.activething.com/go/DateKeys/capsule"
"g.activething.com/go/DateKeys/codec/bech32"
"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"
)
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()), TestVectors: true}
}
// encrypt runs capsule.Encrypt with content as its source, of the length it
// declares.
func encrypt(t *testing.T, dst io.Writer, content string, opts capsule.EncryptOptions) (*capsule.Result, error) {
t.Helper()
opts.Length = int64(len(content))
return capsule.Encrypt(dst, strings.NewReader(content), opts)
}
// recipientFromRaw returns the age X25519 recipient of 32 raw bytes, as age
// parses it: without checking that they are a canonical public key.
func recipientFromRaw(t *testing.T, raw []byte) *age.X25519Recipient {
t.Helper()
s, err := bech32.Encode("age", raw)
if err != nil {
t.Fatal(err)
}
r, err := age.ParseX25519Recipient(s)
if err != nil {
t.Fatal(err)
}
return r
}
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 := encrypt(t, &dkc, 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)
}
}
})
}
}
// Spec §37, §39, §62.1: the writer rejects options that would make a capsule
// nobody opens, anybody opens, or one that reveals more than it should,
// before it writes anything.
func TestEncryptRejectsInvalidOptions(t *testing.T) {
x, _ := age.GenerateX25519Identity()
scrypt, _ := age.NewScryptRecipient("pw")
seventeen := make([]age.Recipient, 17)
for i := range seventeen {
id, _ := age.GenerateX25519Identity()
seventeen[i] = id.Recipient()
}
// p = 2^255 - 19 in little-endian, and a public key with bit 255 set.
p := append([]byte{0xed}, bytes.Repeat([]byte{0xff}, 30)...)
p = append(p, 0x7f)
bit255, _ := agewrap.RawX25519Recipient(x.Recipient())
bit255[31] |= 0x80
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}}
}},
// Format 2 (spec §29.1, §39, §62.1).
{"negative length", func(o *capsule.EncryptOptions) { o.Length = -1 }},
{"length above L_MAX", func(o *capsule.EncryptOptions) { o.Length = capsule.MaxPayloadLength + 1 }},
{"padding code 3", func(o *capsule.EncryptOptions) { o.Padding = 3 }},
{"17 recipients", func(o *capsule.EncryptOptions) { o.Policy, o.Recipients = capsule.TimeAndKey, seventeen }},
{"16 recipients and a portable key", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients, o.NewPortableKey = capsule.TimeAndKey, seventeen[:16], true
}},
{"recipient with bit 255 set", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{recipientFromRaw(t, bit255)}
}},
{"recipient u = p", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{recipientFromRaw(t, p)}
}},
{"recipient of low order, u = 0", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{recipientFromRaw(t, make([]byte, 32))}
}},
{"recipient of low order, u = 1", func(o *capsule.EncryptOptions) {
o.Policy, o.Recipients = capsule.TimeAndKey, []age.Recipient{recipientFromRaw(t, append([]byte{1}, make([]byte, 31)...))}
}},
} {
t.Run(tc.name, func(t *testing.T) {
opts := past(t, 1000)
opts.Length = 1
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 := encrypt(t, &b, "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), TestVectors: true}
var dkc bytes.Buffer
res, err := encrypt(t, &dkc, "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, []byte("public"))
cExt, _ := extension.New("org.example.sealed", 3, []byte{0xa1, 0x00, 0x42, 0x01, 0x02})
opts := past(t, 1000)
opts.Noncritical = []extension.Extension{hExt}
opts.ControlNoncritical = []extension.Extension{cExt}
var dkc bytes.Buffer
if _, err := encrypt(t, &dkc, "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)
opts.Length = 1
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")
}
}
// Spec §39, §62.1 rule 3: from 1 to 16 credentials; 16 of them leave no
// dummy, and each opens the capsule on its own.
func TestCredentialBounds(t *testing.T) {
opts := past(t, 1000)
opts.Policy, opts.NewPortableKey = capsule.TimeAndKey, true
var ids []age.Identity
for range 15 {
id, _ := age.GenerateX25519Identity()
opts.Recipients = append(opts.Recipients, id.Recipient())
ids = append(ids, id)
}
var dkc bytes.Buffer
res, err := encrypt(t, &dkc, "sixteen", opts)
if err != nil {
t.Fatal(err)
}
k, _ := res.PortableKey.Identity()
for i, id := range append(ids, k) {
o := defaultOpen(1000)
o.Identities = []age.Identity{id}
if got, err := open(t, dkc.Bytes(), o); err != nil || string(got) != "sixteen" {
t.Fatalf("credential %d: %v", i, err)
}
}
}
// Spec §62.1 rules 6 and 9: L is known before the control is sealed, and a
// source that delivers another number of bytes is an error, not a capsule
// that would fail at step 17 after the date.
func TestEncryptSourceLength(t *testing.T) {
for _, tc := range []struct {
name string
content string
length int64
ok bool
}{
{"exact", "content", 7, true},
{"empty", "", 0, true},
{"one byte short", "content", 8, false},
{"one byte more", "content", 6, false},
{"length not set", "content", 0, false},
} {
opts := past(t, 1000)
opts.Length = tc.length
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader(tc.content), opts)
if (err == nil) != tc.ok {
t.Fatalf("%s: %v", tc.name, err)
}
if !tc.ok {
continue
}
if res.Format != capsule.Format2 || res.Length != uint64(tc.length) || res.Padding != capsule.Reforzado || res.PaddedLength != 256 {
t.Fatalf("%s: result %+v", tc.name, res)
}
if got, err := open(t, dkc.Bytes(), defaultOpen(1000)); err != nil || string(got) != tc.content {
t.Fatalf("%s: open: %q, %v", tc.name, got, err)
}
}
}
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") }

Powered by TurnKey Linux.