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

285 lines
10 KiB

package accesskey_test
import (
"bytes"
"encoding/binary"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"testing"
"filippo.io/age"
"github.com/fxamacker/cbor/v2"
datekeys "g.activething.com/go/DateKeys"
"g.activething.com/go/DateKeys/accesskey"
"g.activething.com/go/DateKeys/agewrap"
"g.activething.com/go/DateKeys/codec"
"g.activething.com/go/DateKeys/extension"
"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
}
// Spec §68: parse, validate and use the official .dkk fixtures.
func TestFixtures(t *testing.T) {
for _, name := range []string{"time_and_key_portable", "time_and_key_recipients"} {
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 ||
len(k.Critical)+len(k.Noncritical) != len(f.Extensions) {
t.Fatalf("decoded values differ from the fixture: %v", k)
}
// 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, name+".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")
}
})
}
}
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:]
var w map[uint64]any
if err := codec.Unmarshal(body, &w); 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 := codec.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},
{"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")
}
}
func TestDecodeBodyExtensionRules(t *testing.T) {
good, _ := loadDKK(t, "time_and_key_portable")
var m map[uint64]any
if err := codec.Unmarshal(good[accesskey.PreludeSize:], &m); 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)} },
// Raw data is copied verbatim by the outer re-encoding, so the
// extension layer must reject 1 encoded in two bytes on its own.
"non-canonical ext data": func(m map[uint64]any) {
m[8] = []any{map[uint64]any{0: "a", 1: uint64(1), 2: cbor.RawMessage{0x18, 0x01}}}
},
"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 := codec.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 []string{"time_and_key_portable", "time_and_key_recipients"} {
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.