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/accesskey/accesskey_test.go

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
}

Powered by TurnKey Linux.