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.
465 lines
16 KiB
465 lines
16 KiB
package accesskey_test
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"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/extension"
|
|
"g.activething.com/go/DateKeys/internal/cbortest"
|
|
"g.activething.com/go/DateKeys/internal/testkit"
|
|
"g.activething.com/go/DateKeys/profile"
|
|
)
|
|
|
|
const fixtures = "../testdata/fixtures"
|
|
|
|
func loadDKK(t *testing.T, name string) ([]byte, testkit.DKKFixture) {
|
|
t.Helper()
|
|
var f testkit.DKKFixture
|
|
if err := testkit.ReadJSON(filepath.Join(fixtures, name+".dkk.json"), &f); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b, err := os.ReadFile(filepath.Join(fixtures, f.File))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b, f
|
|
}
|
|
|
|
var fixtureNames = []string{"time_and_key_portable", "time_and_key_recipients", "time_and_key_portable_extension"}
|
|
|
|
// fixtureExts lists extensions as the fixtures record them, with the exact
|
|
// data bytes.
|
|
func fixtureExts(k *accesskey.AccessKey) []testkit.FixtureExt {
|
|
var out []testkit.FixtureExt
|
|
for i, e := range append(slices.Clone(k.Critical), k.Noncritical...) {
|
|
out = append(out, testkit.FixtureExt{Critical: i < len(k.Critical), ID: e.ID, Version: e.Version, Data: hex.EncodeToString(e.Data)})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Spec §68: parse, validate and use the official .dkk fixtures.
|
|
func TestFixtures(t *testing.T) {
|
|
withData := 0
|
|
for _, name := range fixtureNames {
|
|
t.Run(name, func(t *testing.T) {
|
|
b, f := loadDKK(t, name)
|
|
k, err := accesskey.Decode(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if hex.EncodeToString(k.CredentialID[:]) != f.CredentialID || hex.EncodeToString(k.CapsuleID[:]) != f.CapsuleID ||
|
|
k.Type != f.AccessType || hex.EncodeToString(k.Material) != f.Material ||
|
|
k.Verification == nil || hex.EncodeToString(k.Verification.CapsuleDigest) != f.CapsuleDigest {
|
|
t.Fatalf("decoded values differ from the fixture: %v", k)
|
|
}
|
|
if got := fixtureExts(k); !reflect.DeepEqual(got, f.Extensions) {
|
|
t.Fatalf("extensions differ:\n got %+v\nwant %+v", got, f.Extensions)
|
|
}
|
|
for _, e := range f.Extensions {
|
|
if e.Data != "" {
|
|
withData++
|
|
}
|
|
}
|
|
// Encode(Decode(x)) == x.
|
|
var out bytes.Buffer
|
|
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), b) {
|
|
t.Fatal("re-encoding differs from the fixture bytes")
|
|
}
|
|
// Expected result: the identity opens the INNER_ACCESS_AGE of its capsule.
|
|
var cf testkit.DKCFixture
|
|
if err := testkit.ReadJSON(filepath.Join(fixtures, strings.TrimSuffix(f.Capsule, ".dkc")+".json"), &cf); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dkc, _ := os.ReadFile(filepath.Join(fixtures, f.Capsule))
|
|
parts, _ := testkit.Split(dkc)
|
|
timeID, _ := agewrap.NewTimeIdentity(testkitProfile(), cf.Release.Round, testkit.Release(cf.Release.Round))
|
|
inner := mustDecrypt(t, parts.Sealed, timeID)
|
|
id, err := k.Identity()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
acc, _ := agewrap.NewAccessIdentity(id)
|
|
if control := mustDecrypt(t, inner, acc); hex.EncodeToString(control) != cf.ControlCBOR {
|
|
t.Fatal("the .dkk does not yield the expected CONTROL_CBOR")
|
|
}
|
|
})
|
|
}
|
|
// Spec §68: at least one official .dkk carries an extension with data.
|
|
if withData == 0 {
|
|
t.Fatal("no .dkk fixture carries extension data")
|
|
}
|
|
}
|
|
|
|
// The .dkk with an extension is the credential of time_and_key_portable.dkk
|
|
// plus one noncritical extension, whose data the base protocol never decodes.
|
|
func TestFixtureWithExtension(t *testing.T) {
|
|
src, _ := loadDKK(t, "time_and_key_portable")
|
|
b, _ := loadDKK(t, "time_and_key_portable_extension")
|
|
k, err := accesskey.Decode(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(k.Critical) != 0 || len(k.Noncritical) != 1 || k.Noncritical[0].ID != "org.example.delivery" || k.Noncritical[0].Version != 1 ||
|
|
hex.EncodeToString(k.Noncritical[0].Data) != "a1006468616e64" {
|
|
t.Fatalf("extension %+v", k.Noncritical)
|
|
}
|
|
k.Noncritical = nil
|
|
var out bytes.Buffer
|
|
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), src) {
|
|
t.Fatal("without its extension the .dkk differs from time_and_key_portable.dkk")
|
|
}
|
|
}
|
|
|
|
func TestSecretsAreNotPrinted(t *testing.T) {
|
|
b, f := loadDKK(t, "time_and_key_portable")
|
|
k, _ := accesskey.Decode(bytes.NewReader(b))
|
|
for _, format := range []string{"%v", "%+v", "%#v", "%s"} {
|
|
for _, v := range []any{k, *k} {
|
|
if s := fmt.Sprintf(format, v); strings.Contains(s, f.Material) || !strings.Contains(s, "REDACTED") {
|
|
t.Fatalf("%s leaks or hides nothing: %s", format, s)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func frame(body []byte) []byte {
|
|
pre := make([]byte, accesskey.PreludeSize)
|
|
copy(pre, accesskey.Magic)
|
|
pre[4] = accesskey.FramingVersion
|
|
binary.BigEndian.PutUint32(pre[8:], uint32(len(body)))
|
|
return append(pre, body...)
|
|
}
|
|
|
|
func TestDecodeRejects(t *testing.T) {
|
|
good, _ := loadDKK(t, "time_and_key_portable")
|
|
body := good[accesskey.PreludeSize:]
|
|
w, err := cbortest.UnmarshalMap(body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
with := func(edit func(m map[uint64]any)) []byte {
|
|
m := map[uint64]any{}
|
|
for k, v := range w {
|
|
m[k] = v
|
|
}
|
|
edit(m)
|
|
b, err := cbortest.Marshal(m)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return frame(b)
|
|
}
|
|
set := func(b []byte, i int, v byte) []byte { c := bytes.Clone(b); c[i] = v; return c }
|
|
bigLen := bytes.Clone(good)
|
|
binary.BigEndian.PutUint32(bigLen[8:], accesskey.MaxBodyLen+1)
|
|
for _, tc := range []struct {
|
|
name string
|
|
in []byte
|
|
want error
|
|
}{
|
|
{"magic", set(good, 3, '2'), datekeys.ErrInvalidMagic},
|
|
{"a .dkc", append([]byte("DKC1"), good[4:]...), datekeys.ErrInvalidMagic},
|
|
{"empty", nil, datekeys.ErrInvalidMagic},
|
|
{"framing version", set(good, 4, 2), datekeys.ErrUnsupportedVersion},
|
|
{"flags", set(good, 5, 1), datekeys.ErrInvalidFlags},
|
|
{"reserved", set(good, 7, 1), datekeys.ErrInvalidFlags},
|
|
{"body length above the limit", bigLen, datekeys.ErrIntegrity},
|
|
{"truncated prelude", good[:10], datekeys.ErrIntegrity},
|
|
{"truncated body", good[:len(good)-1], datekeys.ErrIntegrity},
|
|
{"trailing data", append(bytes.Clone(good), 0), datekeys.ErrIntegrity},
|
|
{"schema version", with(func(m map[uint64]any) { m[1] = uint64(2) }), datekeys.ErrUnsupportedVersion},
|
|
{"type tag", with(func(m map[uint64]any) { m[0] = "datekeycap" }), datekeys.ErrNonCanonicalCBOR},
|
|
{"empty verification map", with(func(m map[uint64]any) { m[6] = map[uint64]any{} }), datekeys.ErrNonCanonicalCBOR},
|
|
{"empty extension array", with(func(m map[uint64]any) { m[8] = []any{} }), datekeys.ErrNonCanonicalCBOR},
|
|
{"null verification", with(func(m map[uint64]any) { m[6] = nil }), datekeys.ErrNonCanonicalCBOR},
|
|
{"unknown key", with(func(m map[uint64]any) { m[9] = "x" }), datekeys.ErrNonCanonicalCBOR},
|
|
{"short capsule_id", with(func(m map[uint64]any) { m[3] = make([]byte, 15) }), datekeys.ErrNonCanonicalCBOR},
|
|
{"short digest", with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 31)} }), datekeys.ErrNonCanonicalCBOR},
|
|
{"unknown access type", with(func(m map[uint64]any) { m[4] = "mlkem768" }), datekeys.ErrAccessInvalid},
|
|
{"short material", with(func(m map[uint64]any) { m[5] = make([]byte, 31) }), datekeys.ErrAccessInvalid},
|
|
{"null material", with(func(m map[uint64]any) { m[5] = nil }), datekeys.ErrNonCanonicalCBOR},
|
|
{"non-canonical body", frame(append([]byte{0xb9, 0x00, 0x07}, body[1:]...)), datekeys.ErrNonCanonicalCBOR},
|
|
} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if _, err := accesskey.Decode(bytes.NewReader(tc.in)); !errors.Is(err, tc.want) {
|
|
t.Fatalf("got %v, want %v", err, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestEncodeRejectsAbsenceAsEmptyMap(t *testing.T) {
|
|
id, _ := age.GenerateX25519Identity()
|
|
raw, _ := agewrap.RawX25519Identity(id)
|
|
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: raw, Verification: &accesskey.Verification{}}
|
|
if err := accesskey.Encode(io.Discard, k); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
|
t.Fatalf("empty verification map encoded: %v", err)
|
|
}
|
|
k.Verification = nil
|
|
k.Noncritical = []extension.Extension{{ID: "org.example.delivery", Version: 1}}
|
|
var b bytes.Buffer
|
|
if err := accesskey.Encode(&b, k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
back, err := accesskey.Decode(&b)
|
|
if err != nil || back.Verification != nil || len(back.Noncritical) != 1 {
|
|
t.Fatalf("%v %v", back, err)
|
|
}
|
|
}
|
|
|
|
func TestIdentityWipeAndEncodeErrors(t *testing.T) {
|
|
b, _ := loadDKK(t, "time_and_key_portable")
|
|
k, _ := accesskey.Decode(bytes.NewReader(b))
|
|
other := *k
|
|
other.Type = "mlkem768"
|
|
if _, err := other.Identity(); !errors.Is(err, datekeys.ErrAccessInvalid) {
|
|
t.Fatalf("unsupported type: %v", err)
|
|
}
|
|
dup := []extension.Extension{{ID: "a", Version: 1}, {ID: "a", Version: 2}}
|
|
for name, edit := range map[string]func(k *accesskey.AccessKey){
|
|
"short material": func(k *accesskey.AccessKey) { k.Material = k.Material[:31] },
|
|
"repeated critical": func(k *accesskey.AccessKey) { k.Critical = dup },
|
|
"repeated noncritical": func(k *accesskey.AccessKey) { k.Noncritical = dup },
|
|
"both arrays": func(k *accesskey.AccessKey) { k.Critical, k.Noncritical = dup[:1], dup[1:] },
|
|
} {
|
|
c := *k
|
|
c.Material = bytes.Clone(k.Material)
|
|
edit(&c)
|
|
if err := accesskey.Encode(io.Discard, &c); err == nil {
|
|
t.Errorf("%s: encoded", name)
|
|
}
|
|
}
|
|
if err := accesskey.Encode(failingWriter{}, k); err == nil {
|
|
t.Fatal("write error ignored")
|
|
}
|
|
k.Wipe()
|
|
if !bytes.Equal(k.Material, make([]byte, 32)) {
|
|
t.Fatal("material not wiped")
|
|
}
|
|
}
|
|
|
|
// retainingWriter keeps the slices it is given, which io.Writer forbids, so
|
|
// that a test can see what the writer's caller leaves in them.
|
|
type retainingWriter struct{ writes [][]byte }
|
|
|
|
func (w *retainingWriter) Write(p []byte) (int, error) {
|
|
w.writes = append(w.writes, p)
|
|
return len(p), nil
|
|
}
|
|
|
|
// retainingReader delivers data a few bytes at a time and keeps every slice
|
|
// it is given, up to its capacity, so that a test can see what the reader's
|
|
// caller leaves in them.
|
|
type retainingReader struct {
|
|
data []byte
|
|
chunk int
|
|
seen [][]byte
|
|
}
|
|
|
|
func (r *retainingReader) Read(p []byte) (int, error) {
|
|
r.seen = append(r.seen, p[:cap(p)])
|
|
if len(r.data) == 0 {
|
|
return 0, io.EOF
|
|
}
|
|
n := copy(p[:min(len(p), r.chunk)], r.data)
|
|
r.data = r.data[n:]
|
|
return n, nil
|
|
}
|
|
|
|
// Encode and Decode leave no copy of access_material in the buffers they
|
|
// allocate, including the ones Decode outgrows while it reads the body.
|
|
func TestEncodeAndDecodeLeaveNoStaleMaterial(t *testing.T) {
|
|
material := bytes.Repeat([]byte{0xab}, 32)
|
|
big, err := extension.New("org.example.big", 1, make([]byte, 4096))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k := &accesskey.AccessKey{Type: accesskey.TypeX25519, Material: bytes.Clone(material), Noncritical: []extension.Extension{big}}
|
|
w := &retainingWriter{}
|
|
var dkk bytes.Buffer
|
|
if err := accesskey.Encode(io.MultiWriter(&dkk, w), k); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, p := range w.writes {
|
|
if bytes.Contains(p, material) {
|
|
t.Errorf("Encode: slice %d written still holds the material", i)
|
|
}
|
|
}
|
|
if !bytes.Equal(k.Material, material) {
|
|
t.Fatal("Encode wiped the material of its argument")
|
|
}
|
|
if !bytes.Contains(dkk.Bytes(), material) {
|
|
t.Fatal("the .dkk does not hold the material")
|
|
}
|
|
|
|
r := &retainingReader{data: dkk.Bytes(), chunk: 100}
|
|
back, err := accesskey.Decode(r)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(back.Material, material) {
|
|
t.Fatal("material not decoded")
|
|
}
|
|
back.Wipe()
|
|
for i, p := range r.seen {
|
|
if bytes.Contains(p, material[:8]) {
|
|
t.Errorf("Decode: read buffer %d of %d bytes still holds the material", i, len(p))
|
|
}
|
|
}
|
|
|
|
// A truncated body is wiped too.
|
|
r = &retainingReader{data: dkk.Bytes()[:dkk.Len()-10], chunk: 100}
|
|
if _, err := accesskey.Decode(r); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("truncated body: %v", err)
|
|
}
|
|
for i, p := range r.seen {
|
|
if bytes.Contains(p, material[:8]) {
|
|
t.Errorf("Decode of a truncated body: read buffer %d still holds the material", i)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A short .dkk that declares the largest BODY_LEN is rejected without an
|
|
// allocation of that size: the body buffer grows with the data read.
|
|
func TestDecodeShortBodyAllocatesLittle(t *testing.T) {
|
|
var pre [accesskey.PreludeSize]byte
|
|
copy(pre[:], accesskey.Magic)
|
|
pre[4] = accesskey.FramingVersion
|
|
binary.BigEndian.PutUint32(pre[8:12], accesskey.MaxBodyLen)
|
|
r := &retainingReader{data: append(pre[:], make([]byte, 5000)...), chunk: 1000}
|
|
if _, err := accesskey.Decode(r); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("short body: %v", err)
|
|
}
|
|
for _, p := range r.seen {
|
|
if len(p) > 16<<10 {
|
|
t.Fatalf("a read buffer of %d bytes for 5000 bytes of body", len(p))
|
|
}
|
|
}
|
|
}
|
|
|
|
// Spec §57: the DKK BODY limit binds the encoder and every decoder entry
|
|
// point, whatever the framing says, with the code of a frame violation.
|
|
func TestBodyLimit(t *testing.T) {
|
|
if _, err := accesskey.DecodeBody(make([]byte, accesskey.MaxBodyLen+1)); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("DecodeBody above 16 MiB: %v", err)
|
|
}
|
|
b, _ := loadDKK(t, "time_and_key_portable")
|
|
k, err := accesskey.Decode(bytes.NewReader(b))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
big, err := extension.New("org.example.big", 1, make([]byte, accesskey.MaxBodyLen))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
k.Noncritical = []extension.Extension{big}
|
|
if err := accesskey.Encode(io.Discard, k); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("body above 16 MiB encoded: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeBodyExtensionRules(t *testing.T) {
|
|
good, _ := loadDKK(t, "time_and_key_portable")
|
|
m, err := cbortest.UnmarshalMap(good[accesskey.PreludeSize:])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
ext := func(id string, v uint64) map[uint64]any { return map[uint64]any{0: id, 1: v} }
|
|
for name, edit := range map[string]func(m map[uint64]any){
|
|
"critical out of order": func(m map[uint64]any) { m[7] = []any{ext("b", 1), ext("a", 1)} },
|
|
"noncritical repeated": func(m map[uint64]any) { m[8] = []any{ext("a", 1), ext("a", 2)} },
|
|
"both arrays": func(m map[uint64]any) { m[7] = []any{ext("a", 1)}; m[8] = []any{ext("a", 1)} },
|
|
// Spec §54: data is absent or a non-empty byte string in its
|
|
// shortest encoding; the extension map rejects anything else.
|
|
"data length not in shortest form": func(m map[uint64]any) {
|
|
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbortest.Raw{0x58, 0x01, 0x00}}}
|
|
},
|
|
"data of type text": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: "x"}} },
|
|
"data of type unsigned": func(m map[uint64]any) {
|
|
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbortest.Raw{0x18, 0x01}}}
|
|
},
|
|
"empty data": func(m map[uint64]any) { m[7] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: []byte{}}} },
|
|
"null data": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: nil}} },
|
|
"version 2^32": func(m map[uint64]any) { m[8] = []any{ext("a", 1<<32)} },
|
|
"65 extensions": func(m map[uint64]any) {
|
|
var exts []any
|
|
for i := range 65 {
|
|
exts = append(exts, ext(fmt.Sprintf("x.%02d", i), 1))
|
|
}
|
|
m[8] = exts
|
|
},
|
|
"empty critical array": func(m map[uint64]any) { m[7] = []any{} },
|
|
"extension id not string": func(m map[uint64]any) { m[8] = []any{map[uint64]any{0: uint64(1), 1: uint64(1)}} },
|
|
} {
|
|
c := map[uint64]any{}
|
|
for k, v := range m {
|
|
c[k] = v
|
|
}
|
|
edit(c)
|
|
b, err := cbortest.Marshal(c)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := accesskey.DecodeBody(b); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
type failingWriter struct{}
|
|
|
|
func (failingWriter) Write([]byte) (int, error) { return 0, errors.New("disk full") }
|
|
|
|
func FuzzDecode(f *testing.F) {
|
|
for _, name := range fixtureNames {
|
|
b, err := os.ReadFile(filepath.Join(fixtures, name+".dkk"))
|
|
if err == nil {
|
|
f.Add(b)
|
|
}
|
|
}
|
|
f.Add([]byte("DKK1\x01\x00\x00\x00\x00\x00\x00\x01\xa0"))
|
|
f.Fuzz(func(t *testing.T, in []byte) {
|
|
k, err := accesskey.Decode(bytes.NewReader(in))
|
|
if err != nil {
|
|
if datekeys.Code(err) == "" {
|
|
t.Fatalf("error without a normative code: %v", err)
|
|
}
|
|
return
|
|
}
|
|
var out bytes.Buffer
|
|
if err := accesskey.Encode(&out, k); err != nil || !bytes.Equal(out.Bytes(), in) {
|
|
t.Fatal("accepted a .dkk that does not re-encode to its input")
|
|
}
|
|
})
|
|
}
|
|
|
|
func testkitProfile() *profile.Profile { return profile.Quicknet() }
|
|
|
|
func mustDecrypt(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
|
|
}
|