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.
285 lines
10 KiB
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 "github.com/datekeys/datekeys-go"
|
|
"github.com/datekeys/datekeys-go/accesskey"
|
|
"github.com/datekeys/datekeys-go/agewrap"
|
|
"github.com/datekeys/datekeys-go/codec"
|
|
"github.com/datekeys/datekeys-go/extension"
|
|
"github.com/datekeys/datekeys-go/internal/testkit"
|
|
"github.com/datekeys/datekeys-go/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
|
|
}
|