Step 2b: package codec is rewritten without reflection or struct tags. A strict Decoder accepts only the spec §58 profile, Unmarshal decodes, re-encodes and compares, Peek reads the type tag and version, and Walk is a bounded iterative helper for vectors and fuzzing. Every schema has its own hand-written encoder and decoder that checks all CDDL rules before the fields with their own error codes. github.com/fxamacker/cbor/v2 and github.com/x448/float16 are gone; nothing replaces them. Valid objects encode and decode exactly as before (1.34 million differential verdicts); the invalid-input differences are documented in CHANGELOG and traceability decision 12. A review found and fixed an access_policy check that truncated to uint8. Step 3: testdata gains vectors/cbor.json (generic and per-schema CBOR vectors), vectors/mutations.json (the 55-case mutation corpus, replayable offline), vectors/inspect_differential.json (1,825 fixed-seed mutations with the Go verdict) and one inspect -json golden per fixture, all regenerated byte-identically by genfixtures and documented in testdata/README.md for second implementations. Gate green with 90 s of fuzzing per target on all 15 targets; codec at 100 % coverage; pre-existing testdata byte-identical. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.8.2
parent
afb44a396e
commit
9cbcab10b2
@ -0,0 +1,66 @@
|
||||
package accesskey_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/accesskey"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
)
|
||||
|
||||
// Spec §41, §43, §58: BODY_CBOR is a closed map with strictly ascending
|
||||
// unsigned integer keys and every required key, and verification_metadata is
|
||||
// the closed map {0: capsule_digest}.
|
||||
func TestDecodeBodyStructure(t *testing.T) {
|
||||
good, _ := loadDKK(t, "time_and_key_portable")
|
||||
body, err := cbortest.UnmarshalMap(good[accesskey.PreludeSize:])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encode := func(v any) []byte {
|
||||
b, err := cbortest.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
with := func(edit func(m map[uint64]any)) []byte {
|
||||
m := maps.Clone(body)
|
||||
edit(m)
|
||||
return encode(m)
|
||||
}
|
||||
// Keys 4 and 5 in each other's place.
|
||||
var swapped cbortest.Pairs
|
||||
for _, k := range slices.Sorted(maps.Keys(body)) {
|
||||
switch k {
|
||||
case 4:
|
||||
k = 5
|
||||
case 5:
|
||||
k = 4
|
||||
}
|
||||
swapped = append(swapped, k, body[k])
|
||||
}
|
||||
a, b := []any{map[uint64]any{0: "a", 1: uint64(1)}}, []any{map[uint64]any{0: "b", 1: uint64(1)}}
|
||||
for name, in := range map[string][]byte{
|
||||
"missing credential_id": with(func(m map[uint64]any) { delete(m, 2) }),
|
||||
"missing access_material": with(func(m map[uint64]any) { delete(m, 5) }),
|
||||
"credential_id as text": with(func(m map[uint64]any) { m[2] = "x" }),
|
||||
"ten entries": with(func(m map[uint64]any) { m[7], m[8], m[9], m[10] = a, b, "x", "y" }),
|
||||
"keys 4 and 5 swapped": encode(swapped),
|
||||
"text key": encode(cbortest.Pairs{uint64(0), accesskey.TypeTag, uint64(1), uint64(1), "2", make([]byte, 16)}),
|
||||
"verification with key 1": with(func(m map[uint64]any) { m[6] = map[uint64]any{1: make([]byte, 32)} }),
|
||||
"verification with two keys": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 32), 1: uint64(0)} }),
|
||||
"verification as a byte string": with(func(m map[uint64]any) { m[6] = make([]byte, 32) }),
|
||||
"verification with a text key": with(func(m map[uint64]any) { m[6] = cbortest.Pairs{"0", make([]byte, 32)} }),
|
||||
"capsule_digest of type text": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: "digest"} }),
|
||||
"capsule_digest of 33 bytes": with(func(m map[uint64]any) { m[6] = map[uint64]any{0: make([]byte, 33)} }),
|
||||
"access_type of type byte string": with(func(m map[uint64]any) { m[4] = []byte("x25519") }),
|
||||
} {
|
||||
if _, err := accesskey.DecodeBody(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"g.activething.com/go/DateKeys/internal/testkit"
|
||||
)
|
||||
|
||||
// The differential corpus of the pre-unlock checks,
|
||||
// testdata/vectors/inspect_differential.json, replays: every mutation of an
|
||||
// official fixture gets exactly the recorded verdict of Inspect, ok or an
|
||||
// error code at a step. genfixtures regenerates the corpus, so that a change
|
||||
// of verdict also shows as a change of the file.
|
||||
func TestInspectDifferentialCorpus(t *testing.T) {
|
||||
var f testkit.DifferentialFile
|
||||
if err := testkit.ReadJSON("../testdata/vectors/inspect_differential.json", &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Mutations) < 1500 {
|
||||
t.Fatalf("%d mutations, want at least 1500", len(f.Mutations))
|
||||
}
|
||||
bases := make([][]byte, len(f.Bases))
|
||||
for i, b := range f.Bases {
|
||||
dkc, err := os.ReadFile(filepath.Join(fixtureDir, b.File))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sum := sha256.Sum256(dkc); hex.EncodeToString(sum[:]) != b.SHA256 {
|
||||
t.Fatalf("%s changed", b.File)
|
||||
}
|
||||
bases[i] = dkc
|
||||
}
|
||||
kinds := map[string]int{}
|
||||
for i, m := range f.Mutations {
|
||||
dkc, err := testkit.ApplyEdits(bases[m.Base], m.Edits)
|
||||
if err != nil {
|
||||
t.Fatalf("mutation %d: %v", i, err)
|
||||
}
|
||||
result, step := testkit.InspectVerdict(dkc)
|
||||
if result != m.Result || step != m.Step {
|
||||
t.Errorf("mutation %d (%s of %s, edits %v): got %s at step %d, want %s at step %d",
|
||||
i, m.Kind, f.Bases[m.Base].File, m.Edits, result, step, m.Result, m.Step)
|
||||
}
|
||||
kinds[m.Kind]++
|
||||
}
|
||||
for _, k := range []string{"flip", "byte", "truncate", "insert", "delete", "length", "header", "datekey", "age"} {
|
||||
if kinds[k] == 0 {
|
||||
t.Errorf("no mutation of kind %s", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,49 @@
|
||||
package capsule
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"filippo.io/age"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
)
|
||||
|
||||
// decryptAll reads the plaintext into one buffer of the ciphertext's size, so
|
||||
// that no outgrown buffer keeps a copy of I_PAYLOAD, and still reports a
|
||||
// truncated STREAM, which the age reader signals with io.ErrUnexpectedEOF.
|
||||
func TestDecryptAll(t *testing.T) {
|
||||
id, err := age.GenerateX25519Identity()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
const chunk = 64 << 10
|
||||
for _, size := range []int{0, 1, 511, 512, 513, chunk, chunk + 1, 2*chunk + 100} {
|
||||
plaintext := bytes.Repeat([]byte{0xab}, size)
|
||||
ct, err := encryptAll(plaintext, id.Recipient())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := decryptAll(ct, id)
|
||||
if err != nil || !bytes.Equal(out, plaintext) {
|
||||
t.Fatalf("%d bytes: %v", size, err)
|
||||
}
|
||||
if cap(out) != len(ct) {
|
||||
t.Errorf("%d bytes: a buffer of %d bytes for a ciphertext of %d", size, cap(out), len(ct))
|
||||
}
|
||||
if size == 0 {
|
||||
continue
|
||||
}
|
||||
// The last chunk removed: a truncation at a chunk boundary.
|
||||
last := size % chunk
|
||||
if last == 0 {
|
||||
last = chunk
|
||||
}
|
||||
for _, cut := range []int{len(ct) - 1, len(ct) - (last + 16)} {
|
||||
if _, err := decryptAll(ct[:cut], id); !errors.Is(err, datekeys.ErrIntegrity) {
|
||||
t.Errorf("%d bytes cut to %d of %d: %v", size, cut, len(ct), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,127 @@
|
||||
package capsule_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
"g.activething.com/go/DateKeys/datekey"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
)
|
||||
|
||||
// swapped encodes m with the entries of keys a and b in each other's place.
|
||||
func swapped(t *testing.T, m map[uint64]any, a, b uint64) []byte {
|
||||
t.Helper()
|
||||
var p cbortest.Pairs
|
||||
for _, k := range slices.Sorted(maps.Keys(m)) {
|
||||
switch k {
|
||||
case a:
|
||||
k = b
|
||||
case b:
|
||||
k = a
|
||||
}
|
||||
p = append(p, k, m[k])
|
||||
}
|
||||
out, err := cbortest.Marshal(p)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func with(m map[uint64]any, edit func(m map[uint64]any)) map[uint64]any {
|
||||
c := maps.Clone(m)
|
||||
edit(c)
|
||||
return c
|
||||
}
|
||||
|
||||
// Spec §58: the maps of PUBLIC_HEADER and CONTROL_CBOR are closed, their keys
|
||||
// strictly ascending unsigned integers, and every required key is present.
|
||||
func TestDecodeMapStructure(t *testing.T) {
|
||||
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}.Compact()
|
||||
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: dk, 4: uint64(0)}
|
||||
c := map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(1), 2: make([]byte, 32), 3: make([]byte, 32)}
|
||||
exts := []any{ext("a", 1)}
|
||||
for name, in := range map[string][]byte{
|
||||
"missing capsule_id": marshal(t, with(h, func(m map[uint64]any) { delete(m, 2) })),
|
||||
"missing access_policy": marshal(t, with(h, func(m map[uint64]any) { delete(m, 4) })),
|
||||
"capsule_id as text": marshal(t, with(h, func(m map[uint64]any) { m[2] = "x" })),
|
||||
"access_policy 2^53": marshal(t, with(h, func(m map[uint64]any) { m[4] = uint64(1) << 53 })),
|
||||
"unknown key 7": marshal(t, with(h, func(m map[uint64]any) { m[7] = uint64(0) })),
|
||||
"eight entries": marshal(t, with(h, func(m map[uint64]any) { m[5], m[6], m[7] = exts, []any{ext("b", 1)}, uint64(0) })),
|
||||
"keys 3 and 4 swapped": swapped(t, h, 3, 4),
|
||||
"text key": mustMarshal(t, cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), uint64(1), "2", make([]byte, 16)}),
|
||||
} {
|
||||
if _, err := capsule.DecodeHeader(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("PUBLIC_HEADER %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// access_policy is 0 or 1 (spec §25): a value whose low byte is 0 or 1
|
||||
// is not a V1 policy.
|
||||
for _, p := range []uint64{2, 255, 256, 257, 512, 65536, 65537, 1 << 32, 1<<32 + 1, 1<<53 - 256, 1<<53 - 255, 1<<53 - 1} {
|
||||
if _, err := capsule.DecodeHeader(marshal(t, with(h, func(m map[uint64]any) { m[4] = p }))); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("PUBLIC_HEADER access_policy %d: %v", p, err)
|
||||
}
|
||||
}
|
||||
for name, in := range map[string][]byte{
|
||||
"missing payload_identity": marshal(t, with(c, func(m map[uint64]any) { delete(m, 3) })),
|
||||
"seven entries": marshal(t, with(c, func(m map[uint64]any) { m[4], m[5], m[6] = exts, []any{ext("b", 1)}, uint64(0) })),
|
||||
"keys 2 and 3 swapped": swapped(t, c, 2, 3),
|
||||
"text key": mustMarshal(t, cbortest.Pairs{uint64(0), capsule.ControlTypeTag, uint64(1), uint64(1), "2", make([]byte, 32)}),
|
||||
} {
|
||||
if _, err := capsule.DecodeControl(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("CONTROL_CBOR %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, v any) []byte {
|
||||
t.Helper()
|
||||
b, err := cbortest.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Spec §70: the schema version is reported as such whatever follows it, and
|
||||
// a version that is missing or outside the profile is not a version.
|
||||
func TestDecodeSchemaVersion(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
in cbortest.Pairs
|
||||
want error
|
||||
}{
|
||||
"version 2, rest unreadable": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), uint64(2), uint64(2), cbortest.Raw{0xff}}, datekeys.ErrUnsupportedVersion},
|
||||
"version missing": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(2), make([]byte, 16)}, datekeys.ErrNonCanonicalCBOR},
|
||||
"version null": {cbortest.Pairs{uint64(0), capsule.HeaderTypeTag, uint64(1), nil}, datekeys.ErrNonCanonicalCBOR},
|
||||
"version before type": {cbortest.Pairs{uint64(1), uint64(1), uint64(0), capsule.HeaderTypeTag}, datekeys.ErrNonCanonicalCBOR},
|
||||
} {
|
||||
if _, err := capsule.DecodeHeader(mustMarshal(t, tc.in)); !errors.Is(err, tc.want) {
|
||||
t.Errorf("%s: got %v, want %v", name, err, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A PUBLIC_HEADER that breaks the CDDL and holds an invalid DateKey reports
|
||||
// the CDDL violation: the DateKey is parsed after the map (spec §57, §63
|
||||
// step 4).
|
||||
func TestDecodeHeaderReportsTheCDDLFirst(t *testing.T) {
|
||||
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(1), 2: make([]byte, 16), 3: "dk1_x", 4: uint64(0)}
|
||||
if _, err := capsule.DecodeHeader(marshal(t, h)); !errors.Is(err, datekeys.ErrDateKeyInvalid) {
|
||||
t.Fatalf("invalid DateKey alone: %v", err)
|
||||
}
|
||||
for name, edit := range map[string]func(m map[uint64]any){
|
||||
"undefined access_policy": func(m map[uint64]any) { m[4] = uint64(2) },
|
||||
"access_policy 256": func(m map[uint64]any) { m[4] = uint64(256) },
|
||||
"extensions out of order": func(m map[uint64]any) { m[6] = []any{ext("b", 1), ext("a", 1)} },
|
||||
"id in both arrays": func(m map[uint64]any) { m[5], m[6] = []any{ext("a", 1)}, []any{ext("a", 1)} },
|
||||
} {
|
||||
if _, err := capsule.DecodeHeader(marshal(t, with(h, edit))); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s and an invalid DateKey: %v", name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,144 +1,605 @@
|
||||
// Package codec implements the Deterministic CBOR rules of spec §58 and §58.1.
|
||||
// Package codec implements the CBOR profile of the DateKeys protocol (spec
|
||||
// §58, §58.1) without reflection and without dependencies.
|
||||
//
|
||||
// Encoding uses RFC 8949 §4.2.1 Core Deterministic Encoding. Decoding is
|
||||
// strict (no indefinite lengths, no tags, no duplicate keys, bounded depth and
|
||||
// sizes, valid UTF-8, no unknown struct fields) and is always followed by a
|
||||
// re-encoding that must reproduce the input byte for byte. Any difference is
|
||||
// ErrNonCanonicalCBOR. The same principle as dk1_ canonicality (spec §19):
|
||||
// canonicality does not depend on a library promising to reject every
|
||||
// non-canonical form.
|
||||
// The profile is Deterministic CBOR (RFC 8949 §4.2.1) restricted to major
|
||||
// types 0 (unsigned integer), 2 (byte string), 3 (text string), 4 (array) and
|
||||
// 5 (map), with unsigned integer map keys in strictly ascending order,
|
||||
// integers and lengths in their shortest form, definite lengths only and
|
||||
// valid UTF-8 text. Negative integers, tags, floats, simple values (false,
|
||||
// true, null, undefined), indefinite lengths and every other map key are
|
||||
// rejected with ErrNonCanonicalCBOR.
|
||||
//
|
||||
// The CBOR profile of the protocol (spec §58: major types 0, 2, 3, 4 and 5
|
||||
// only, unsigned integer map keys) is enforced by decoding into the typed
|
||||
// schemas of each package: their fields are unsigned integers, byte strings,
|
||||
// text strings, arrays and maps, so negative integers, floats and simple
|
||||
// values fail to decode, and null fails the re-encoding check.
|
||||
// Each schema writes its own encoding with an Encoder and reads it with a
|
||||
// Decoder, a strict cursor that reads exactly what the schema asks for.
|
||||
// Unmarshal runs the decoder of a schema and then re-encodes what it decoded:
|
||||
// the input must be reproduced byte for byte, or it is ErrNonCanonicalCBOR.
|
||||
// The same principle as dk1_ canonicality (spec §19): canonicality does not
|
||||
// depend on the decoder rejecting every non-canonical form.
|
||||
//
|
||||
// Peek reads the type tag and the schema version of an object before strict
|
||||
// decoding (spec §70). Walk checks that bytes are one data item of the
|
||||
// profile; it is a helper for vectors, fuzzing and diagnostics, and never
|
||||
// decides whether an object of the protocol is valid.
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
|
||||
"github.com/fxamacker/cbor/v2"
|
||||
"math"
|
||||
"unicode/utf8"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
)
|
||||
|
||||
// Decoding limits. Structural sizes are additionally bounded by the framing
|
||||
// limits of spec §57 before any CBOR is decoded.
|
||||
const (
|
||||
MaxNestedLevels = 16
|
||||
MaxArrayElements = 65536
|
||||
MaxMapPairs = 65536
|
||||
)
|
||||
|
||||
// MaxSafeUint is 2^53-1, the largest unsigned integer any schema of the
|
||||
// protocol allows, so that every integer is exact as an IEEE 754 double
|
||||
// (spec §58).
|
||||
const MaxSafeUint = 1<<53 - 1
|
||||
|
||||
var (
|
||||
encMode = must(encOptions().EncMode())
|
||||
decMode = must(decOptions(true).DecMode())
|
||||
peekMode = must(decOptions(false).DecMode())
|
||||
// MaxTypeTagLen bounds the type tag that Peek reads. Every type tag of V1 is
|
||||
// at most 25 bytes, so a longer one is of no known schema; the bound also
|
||||
// keeps an input-sized tag out of the errors. It is an implementation limit
|
||||
// (spec §74).
|
||||
const MaxTypeTagLen = 64
|
||||
|
||||
// Major types of the profile (spec §58).
|
||||
const (
|
||||
majorUint = 0
|
||||
majorBytes = 2
|
||||
majorText = 3
|
||||
majorArray = 4
|
||||
majorMap = 5
|
||||
)
|
||||
|
||||
// encOptions returns Core Deterministic Encoding options in which a nil byte
|
||||
// string, array or map encodes as an empty one, never as null: null is outside
|
||||
// the profile of spec §58, so an input null never survives the re-encoding
|
||||
// check.
|
||||
func encOptions() cbor.EncOptions {
|
||||
o := cbor.CoreDetEncOptions()
|
||||
o.NilContainers = cbor.NilContainerAsEmpty
|
||||
return o
|
||||
}
|
||||
|
||||
// decOptions returns the strict decoding options. Peek mode ignores unknown
|
||||
// map keys; the canonical mode reports them.
|
||||
func decOptions(strict bool) cbor.DecOptions {
|
||||
o := cbor.DecOptions{
|
||||
DupMapKey: cbor.DupMapKeyEnforcedAPF,
|
||||
IndefLength: cbor.IndefLengthForbidden,
|
||||
TagsMd: cbor.TagsForbidden,
|
||||
MaxNestedLevels: MaxNestedLevels,
|
||||
MaxArrayElements: MaxArrayElements,
|
||||
MaxMapPairs: MaxMapPairs,
|
||||
UTF8: cbor.UTF8RejectInvalid,
|
||||
MapKeyByteString: cbor.MapKeyByteStringAllowed,
|
||||
}
|
||||
if strict {
|
||||
o.ExtraReturnErrors = cbor.ExtraDecErrorUnknownField
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// must accepts only the static options above, which cannot be invalid.
|
||||
func must[T any](m T, err error) T {
|
||||
var majorNames = [8]string{
|
||||
"an unsigned integer", "a negative integer", "a byte string", "a text string",
|
||||
"an array", "a map", "a tag", "a float or simple value",
|
||||
}
|
||||
|
||||
// errorf returns an error that wraps ErrNonCanonicalCBOR.
|
||||
func errorf(format string, args ...any) error {
|
||||
return fmt.Errorf("codec: "+format+": %w", append(args, datekeys.ErrNonCanonicalCBOR)...)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Encoder
|
||||
|
||||
// Encoder writes the deterministic encoding of data items of the profile:
|
||||
// every integer and length in its shortest form, definite lengths only. The
|
||||
// first error is kept and later calls do nothing; Out returns it. The zero
|
||||
// value is ready to use.
|
||||
//
|
||||
// An Encoder does not know the schema: the caller writes the map keys, as
|
||||
// unsigned integers in ascending order, and as many entries and items as it
|
||||
// announced. The decoder of the schema checks both on the output (spec §72).
|
||||
//
|
||||
// An encoding may hold secrets, such as I_PAYLOAD or access_material: the
|
||||
// Encoder wipes every buffer it outgrows, so that the output is the only
|
||||
// copy, and the caller wipes the output.
|
||||
type Encoder struct {
|
||||
buf []byte
|
||||
err error
|
||||
}
|
||||
|
||||
// grow makes room for n more bytes, wiping the buffer it outgrows.
|
||||
func (e *Encoder) grow(n int) {
|
||||
if cap(e.buf)-len(e.buf) >= n {
|
||||
return
|
||||
}
|
||||
b := make([]byte, len(e.buf), 2*cap(e.buf)+n)
|
||||
copy(b, e.buf)
|
||||
clear(e.buf)
|
||||
e.buf = b
|
||||
}
|
||||
|
||||
// head appends the head of a data item: its major type and argument.
|
||||
func (e *Encoder) head(major byte, arg uint64) {
|
||||
if e.err != nil {
|
||||
return
|
||||
}
|
||||
var h [9]byte
|
||||
h[0] = major << 5
|
||||
n := 1
|
||||
switch {
|
||||
case arg < 24:
|
||||
h[0] |= byte(arg)
|
||||
case arg <= math.MaxUint8:
|
||||
h[0] |= 24
|
||||
h[1] = byte(arg)
|
||||
n = 2
|
||||
case arg <= math.MaxUint16:
|
||||
h[0] |= 25
|
||||
binary.BigEndian.PutUint16(h[1:], uint16(arg))
|
||||
n = 3
|
||||
case arg <= math.MaxUint32:
|
||||
h[0] |= 26
|
||||
binary.BigEndian.PutUint32(h[1:], uint32(arg))
|
||||
n = 5
|
||||
default:
|
||||
h[0] |= 27
|
||||
binary.BigEndian.PutUint64(h[1:], arg)
|
||||
n = 9
|
||||
}
|
||||
e.grow(n)
|
||||
e.buf = append(e.buf, h[:n]...)
|
||||
}
|
||||
|
||||
// Fail records err as the error of the encoding unless one is recorded
|
||||
// already; a nil err is ignored. Every later call does nothing, and Out
|
||||
// returns the first error. The encoder of a schema calls it when its value
|
||||
// breaks a rule of the schema, so that bytes the decoder rejects are never
|
||||
// returned.
|
||||
func (e *Encoder) Fail(err error) {
|
||||
if e.err == nil {
|
||||
e.err = err
|
||||
}
|
||||
}
|
||||
|
||||
// Map writes the head of a map of the given number of entries. The caller
|
||||
// then writes each key, with Uint, followed by its value.
|
||||
func (e *Encoder) Map(pairs int) {
|
||||
if pairs < 0 {
|
||||
e.Fail(errorf("map of %d entries", pairs))
|
||||
return
|
||||
}
|
||||
e.head(majorMap, uint64(pairs))
|
||||
}
|
||||
|
||||
// Array writes the head of an array of the given number of items. The caller
|
||||
// then writes each item.
|
||||
func (e *Encoder) Array(items int) {
|
||||
if items < 0 {
|
||||
e.Fail(errorf("array of %d items", items))
|
||||
return
|
||||
}
|
||||
e.head(majorArray, uint64(items))
|
||||
}
|
||||
|
||||
// Uint writes an unsigned integer.
|
||||
func (e *Encoder) Uint(v uint64) { e.head(majorUint, v) }
|
||||
|
||||
// Bstr writes a byte string. A nil slice is the empty byte string.
|
||||
func (e *Encoder) Bstr(b []byte) {
|
||||
e.head(majorBytes, uint64(len(b)))
|
||||
if e.err == nil {
|
||||
e.grow(len(b))
|
||||
e.buf = append(e.buf, b...)
|
||||
}
|
||||
}
|
||||
|
||||
// Text writes a text string, which must be valid UTF-8.
|
||||
func (e *Encoder) Text(s string) {
|
||||
if !utf8.ValidString(s) {
|
||||
e.Fail(errorf("text string %q is not valid UTF-8", s))
|
||||
return
|
||||
}
|
||||
e.head(majorText, uint64(len(s)))
|
||||
if e.err == nil {
|
||||
e.grow(len(s))
|
||||
e.buf = append(e.buf, s...)
|
||||
}
|
||||
}
|
||||
|
||||
// Out returns the encoding, or the first error. On error the partial output
|
||||
// is wiped.
|
||||
func (e *Encoder) Out() ([]byte, error) {
|
||||
if e.err != nil {
|
||||
clear(e.buf)
|
||||
e.buf = nil
|
||||
return nil, e.err
|
||||
}
|
||||
return e.buf, nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Decoder
|
||||
|
||||
// Decoder is a strict cursor over the encoding of one data item of the
|
||||
// profile. Each method reads one data item, or one head, and rejects with
|
||||
// ErrNonCanonicalCBOR a major type outside the profile, a major type other
|
||||
// than the one asked for, an indefinite length, an integer or length not in
|
||||
// its shortest form, a length beyond the remaining input and a value outside
|
||||
// the bounds the caller gives. Within each open map the keys are unsigned
|
||||
// integers in strictly ascending order.
|
||||
//
|
||||
// The first error is kept: every later call returns it.
|
||||
type Decoder struct {
|
||||
in []byte
|
||||
off int
|
||||
maps []openMap
|
||||
err error
|
||||
}
|
||||
|
||||
// openMap is the state of a map between Map and EndMap.
|
||||
type openMap struct {
|
||||
left uint64 // entries not read yet
|
||||
last uint64 // last key read
|
||||
started bool // at least one key was read
|
||||
}
|
||||
|
||||
// NewDecoder returns a Decoder positioned at the start of in.
|
||||
func NewDecoder(in []byte) *Decoder { return &Decoder{in: in} }
|
||||
|
||||
// fail records the first error, with the current offset, and returns it.
|
||||
func (d *Decoder) fail(format string, args ...any) error {
|
||||
if d.err == nil {
|
||||
d.err = errorf("offset %d: "+format, append([]any{d.off}, args...)...)
|
||||
}
|
||||
return d.err
|
||||
}
|
||||
|
||||
func (d *Decoder) remaining() int { return len(d.in) - d.off }
|
||||
|
||||
// head reads the head of the next data item and returns its major type and
|
||||
// argument. It rejects the major types outside the profile, reserved values,
|
||||
// indefinite lengths, arguments not in their shortest form and truncation.
|
||||
func (d *Decoder) head() (byte, uint64, error) {
|
||||
if d.err != nil {
|
||||
return 0, 0, d.err
|
||||
}
|
||||
if d.off >= len(d.in) {
|
||||
return 0, 0, d.fail("truncated input")
|
||||
}
|
||||
b := d.in[d.off]
|
||||
major, info := b>>5, b&0x1f
|
||||
switch major {
|
||||
case 1, 6, 7:
|
||||
return 0, 0, d.fail("%s (initial byte %#02x) is outside the CBOR profile", majorNames[major], b)
|
||||
}
|
||||
switch {
|
||||
case info < 24:
|
||||
d.off++
|
||||
return major, uint64(info), nil
|
||||
case info == 31:
|
||||
return 0, 0, d.fail("indefinite length (initial byte %#02x)", b)
|
||||
case info > 27:
|
||||
return 0, 0, d.fail("reserved additional information (initial byte %#02x)", b)
|
||||
}
|
||||
n := 1 << (info - 24)
|
||||
if d.remaining() < 1+n {
|
||||
return 0, 0, d.fail("truncated input")
|
||||
}
|
||||
var arg, min uint64
|
||||
p := d.in[d.off+1 : d.off+1+n]
|
||||
switch n {
|
||||
case 1:
|
||||
arg, min = uint64(p[0]), 24
|
||||
case 2:
|
||||
arg, min = uint64(binary.BigEndian.Uint16(p)), math.MaxUint8+1
|
||||
case 4:
|
||||
arg, min = uint64(binary.BigEndian.Uint32(p)), math.MaxUint16+1
|
||||
default:
|
||||
arg, min = binary.BigEndian.Uint64(p), math.MaxUint32+1
|
||||
}
|
||||
if arg < min {
|
||||
return 0, 0, d.fail("%d is not in its shortest form (initial byte %#02x)", arg, b)
|
||||
}
|
||||
d.off += 1 + n
|
||||
return major, arg, nil
|
||||
}
|
||||
|
||||
// expect reads the head of a data item of major type want.
|
||||
func (d *Decoder) expect(want byte) (uint64, error) {
|
||||
start := d.off
|
||||
major, arg, err := d.head()
|
||||
if err != nil {
|
||||
panic("codec: invalid static options: " + err.Error())
|
||||
return 0, err
|
||||
}
|
||||
if major != want {
|
||||
d.off = start
|
||||
return 0, d.fail("%s where %s was expected", majorNames[major], majorNames[want])
|
||||
}
|
||||
return m
|
||||
return arg, nil
|
||||
}
|
||||
|
||||
// Marshal returns the core deterministic CBOR encoding of v.
|
||||
func Marshal(v any) ([]byte, error) {
|
||||
b, err := encMode.Marshal(v)
|
||||
// Map reads the head of a map of at most max entries and returns the number
|
||||
// of entries. The caller reads each entry with Key and a value, then calls
|
||||
// EndMap.
|
||||
func (d *Decoder) Map(max int) (int, error) {
|
||||
n, err := d.expect(majorMap)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("codec: encode: %w", err)
|
||||
return 0, err
|
||||
}
|
||||
if max < 0 || n > uint64(max) {
|
||||
return 0, d.fail("map of %d entries, at most %d", n, max)
|
||||
}
|
||||
if n > uint64(d.remaining())/2 {
|
||||
return 0, d.fail("truncated input: map of %d entries", n)
|
||||
}
|
||||
d.maps = append(d.maps, openMap{left: n})
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Key reads the key of the next entry of the innermost open map: an unsigned
|
||||
// integer greater than the previous key of that map.
|
||||
func (d *Decoder) Key() (uint64, error) {
|
||||
if d.err != nil {
|
||||
return 0, d.err
|
||||
}
|
||||
if len(d.maps) == 0 {
|
||||
return 0, d.fail("map key outside a map")
|
||||
}
|
||||
m := &d.maps[len(d.maps)-1]
|
||||
if m.left == 0 {
|
||||
return 0, d.fail("map key after the last entry")
|
||||
}
|
||||
start := d.off
|
||||
k, err := d.expect(majorUint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if m.started && k <= m.last {
|
||||
d.off = start
|
||||
return 0, d.fail("map key %d after key %d: keys must be strictly ascending", k, m.last)
|
||||
}
|
||||
m.left--
|
||||
m.last, m.started = k, true
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// EndMap closes the innermost open map, all of whose entries must have been
|
||||
// read.
|
||||
func (d *Decoder) EndMap() error {
|
||||
if d.err != nil {
|
||||
return d.err
|
||||
}
|
||||
if len(d.maps) == 0 {
|
||||
return d.fail("end of a map outside a map")
|
||||
}
|
||||
if left := d.maps[len(d.maps)-1].left; left != 0 {
|
||||
return d.fail("%d map entries not read", left)
|
||||
}
|
||||
d.maps = d.maps[:len(d.maps)-1]
|
||||
return nil
|
||||
}
|
||||
|
||||
// Array reads the head of an array of at most max items and returns the
|
||||
// number of items, which the caller then reads.
|
||||
func (d *Decoder) Array(max int) (int, error) {
|
||||
n, err := d.expect(majorArray)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if max < 0 || n > uint64(max) {
|
||||
return 0, d.fail("array of %d items, at most %d", n, max)
|
||||
}
|
||||
if n > uint64(d.remaining()) {
|
||||
return 0, d.fail("truncated input: array of %d items", n)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Uint reads an unsigned integer of at most max.
|
||||
func (d *Decoder) Uint(max uint64) (uint64, error) {
|
||||
v, err := d.expect(majorUint)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if v > max {
|
||||
return 0, d.fail("unsigned integer %d above %d", v, max)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// content reads a string of major type want and returns its content, a
|
||||
// subslice of the input. The length is checked against the remaining input
|
||||
// and then against min and max.
|
||||
func (d *Decoder) content(want byte, min, max int) ([]byte, error) {
|
||||
n, err := d.expect(want)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if n > uint64(d.remaining()) {
|
||||
return nil, d.fail("truncated input: %s of %d bytes", majorNames[want], n)
|
||||
}
|
||||
if int(n) < min || int(n) > max {
|
||||
return nil, d.fail("%s of %d bytes outside %d..%d", majorNames[want], n, min, max)
|
||||
}
|
||||
b := d.in[d.off : d.off+int(n)]
|
||||
d.off += int(n)
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// Unmarshal decodes exactly one CBOR data item from data into v, which must be
|
||||
// a pointer, and then requires that re-encoding v reproduces data exactly.
|
||||
// Every failure wraps datekeys.ErrNonCanonicalCBOR.
|
||||
// Bstr reads a byte string of min to max bytes and returns a copy of its
|
||||
// content. The length is checked before anything is copied.
|
||||
func (d *Decoder) Bstr(min, max int) ([]byte, error) {
|
||||
b, err := d.content(majorBytes, min, max)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bytes.Clone(b), nil
|
||||
}
|
||||
|
||||
// Text reads a text string of at most max bytes of valid UTF-8.
|
||||
func (d *Decoder) Text(max int) (string, error) {
|
||||
start := d.off
|
||||
b, err := d.content(majorText, 0, max)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !utf8.Valid(b) {
|
||||
d.off = start
|
||||
return "", d.fail("text string is not valid UTF-8")
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Done checks that every map was closed and that no byte follows the data
|
||||
// item.
|
||||
func (d *Decoder) Done() error {
|
||||
if d.err != nil {
|
||||
return d.err
|
||||
}
|
||||
if len(d.maps) != 0 {
|
||||
return d.fail("%d maps not closed", len(d.maps))
|
||||
}
|
||||
if d.off != len(d.in) {
|
||||
return d.fail("%d trailing bytes", len(d.in)-d.off)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// next returns the major type of the next data item, or majorUint at the end
|
||||
// of the input, where reading it reports the truncation.
|
||||
func (d *Decoder) next() byte {
|
||||
if d.off >= len(d.in) {
|
||||
return majorUint
|
||||
}
|
||||
return d.in[d.off] >> 5
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Objects
|
||||
|
||||
// Unmarshal decodes one object from in with decode, checks that the whole
|
||||
// input was read, and re-encodes the decoded value with encode: the result
|
||||
// must reproduce in byte for byte. decode and encode are the two halves of
|
||||
// one schema and work on the same value.
|
||||
//
|
||||
// Fields of type cbor.RawMessage are copied verbatim and are NOT covered by the
|
||||
// re-encoding check; the caller must validate them.
|
||||
// Errors of the Decoder and of the re-encoding wrap ErrNonCanonicalCBOR; any
|
||||
// other error of decode is returned as it is.
|
||||
//
|
||||
// The re-encoding equals data on success, so it may hold secrets such as
|
||||
// I_PAYLOAD or access_material; it is wiped on every path. This is best
|
||||
// effort: the encoder's internal buffer may keep a copy.
|
||||
func Unmarshal(data []byte, v any) error {
|
||||
if err := decMode.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
|
||||
}
|
||||
re, err := encMode.Marshal(v)
|
||||
// The re-encoding equals in on success, so it may hold secrets such as
|
||||
// I_PAYLOAD or access_material; it is wiped on every path, and so is every
|
||||
// buffer the Encoder outgrows.
|
||||
func Unmarshal(in []byte, decode func(*Decoder) error, encode func(*Encoder)) error {
|
||||
d := NewDecoder(in)
|
||||
if err := decode(d); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := d.Done(); err != nil {
|
||||
return err
|
||||
}
|
||||
e := Encoder{buf: make([]byte, 0, len(in))}
|
||||
encode(&e)
|
||||
re, err := e.Out()
|
||||
defer clear(re)
|
||||
if err != nil || !bytes.Equal(re, data) {
|
||||
return fmt.Errorf("codec: input is not the deterministic encoding of its value: %w", datekeys.ErrNonCanonicalCBOR)
|
||||
if err != nil || !bytes.Equal(re, in) {
|
||||
return errorf("input is not the deterministic encoding of its value")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Peek decodes selected fields of a CBOR map, ignoring every other key and
|
||||
// without the canonicality check. It exists only to read a type tag and a
|
||||
// schema version before strict decoding, so that an unknown major version is
|
||||
// reported as such (spec §70). Its result must never be used as the decoded
|
||||
// object.
|
||||
func Peek(data []byte, v any) error {
|
||||
if err := peekMode.Unmarshal(data, v); err != nil {
|
||||
return fmt.Errorf("codec: decode: %v: %w", err, datekeys.ErrNonCanonicalCBOR)
|
||||
// Peek reads the type tag (key 0, a text string of at most MaxTypeTagLen
|
||||
// bytes) and the schema version (key 1, an unsigned integer) of the map at the
|
||||
// start of in, before strict decoding, so that an unknown schema version is
|
||||
// reported as such (spec §70). The map must start with keys 0 and 1, in the
|
||||
// profile; nothing after them is read. The result must never be used as the
|
||||
// decoded object.
|
||||
func Peek(in []byte) (typeTag string, version uint64, err error) {
|
||||
d := NewDecoder(in)
|
||||
// The input bounds the map.
|
||||
pairs, err := d.Map(math.MaxInt)
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return nil
|
||||
if pairs < 2 {
|
||||
return "", 0, d.fail("map without a type tag and a schema version")
|
||||
}
|
||||
for want := range uint64(2) {
|
||||
k, err := d.Key()
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
if k != want {
|
||||
return "", 0, d.fail("map key %d where key %d was expected", k, want)
|
||||
}
|
||||
if want == 0 {
|
||||
typeTag, err = d.Text(MaxTypeTagLen)
|
||||
} else {
|
||||
version, err = d.Uint(MaxSafeUint)
|
||||
}
|
||||
if err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
return typeTag, version, nil
|
||||
}
|
||||
|
||||
// CheckSchema reads key 0 (type tag) and key 1 (schema version) of a CBOR map
|
||||
// and requires the expected values. A different type tag is
|
||||
// CheckSchema reads the type tag and the schema version of an object with
|
||||
// Peek and requires the expected values. A different type tag is
|
||||
// ErrNonCanonicalCBOR; a different version is ErrUnsupportedVersion.
|
||||
func CheckSchema(data []byte, typeTag string, version uint64) error {
|
||||
var h struct {
|
||||
Type string `cbor:"0,keyasint"`
|
||||
Version uint64 `cbor:"1,keyasint"`
|
||||
}
|
||||
if err := Peek(data, &h); err != nil {
|
||||
func CheckSchema(in []byte, typeTag string, version uint64) error {
|
||||
tag, v, err := Peek(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if h.Type != typeTag {
|
||||
return fmt.Errorf("codec: type %q, want %q: %w", h.Type, typeTag, datekeys.ErrNonCanonicalCBOR)
|
||||
if tag != typeTag {
|
||||
return errorf("type %q, want %q", tag, typeTag)
|
||||
}
|
||||
if h.Version != version {
|
||||
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, h.Version, version, datekeys.ErrUnsupportedVersion)
|
||||
if v != version {
|
||||
return fmt.Errorf("codec: %s schema version %d, want %d: %w", typeTag, v, version, datekeys.ErrUnsupportedVersion)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Walk checks that in is exactly one data item of the profile, with
|
||||
// containers nested at most maxDepth deep (a scalar has depth 0) and every
|
||||
// string and container at most maxLen long. It reads iteratively, so deep
|
||||
// input cannot exhaust the stack.
|
||||
//
|
||||
// Walk is a helper for vectors, fuzzing and diagnostics, and for registered
|
||||
// extensions whose data is CBOR (spec §72). It never decides whether an
|
||||
// object of the protocol is valid: the decoder of its schema does.
|
||||
func Walk(in []byte, maxDepth, maxLen int) error {
|
||||
d := NewDecoder(in)
|
||||
var open []walkLevel
|
||||
for first := true; first || len(open) > 0; first = false {
|
||||
var err error
|
||||
if n := len(open); n > 0 && open[n-1].left == 0 {
|
||||
// The innermost container is complete.
|
||||
if open[n-1].isMap {
|
||||
err = d.EndMap()
|
||||
}
|
||||
open = open[:n-1]
|
||||
} else {
|
||||
if n > 0 {
|
||||
open[n-1].left--
|
||||
if open[n-1].isMap {
|
||||
_, err = d.Key()
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
open, err = d.walkItem(open, maxDepth, maxLen)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return d.Done()
|
||||
}
|
||||
|
||||
// walkLevel is an open container of Walk.
|
||||
type walkLevel struct {
|
||||
left int // entries of a map or items of an array not read yet
|
||||
isMap bool
|
||||
}
|
||||
|
||||
// walkItem reads one data item for Walk: a scalar, or the head of a
|
||||
// container, which it pushes on open.
|
||||
func (d *Decoder) walkItem(open []walkLevel, maxDepth, maxLen int) ([]walkLevel, error) {
|
||||
var err error
|
||||
switch major := d.next(); major {
|
||||
case majorMap, majorArray:
|
||||
if len(open) >= maxDepth {
|
||||
return open, d.fail("containers nested deeper than %d", maxDepth)
|
||||
}
|
||||
l := walkLevel{isMap: major == majorMap}
|
||||
if l.isMap {
|
||||
l.left, err = d.Map(maxLen)
|
||||
} else {
|
||||
l.left, err = d.Array(maxLen)
|
||||
}
|
||||
open = append(open, l)
|
||||
case majorBytes:
|
||||
_, err = d.content(majorBytes, 0, maxLen)
|
||||
case majorText:
|
||||
_, err = d.Text(maxLen)
|
||||
default:
|
||||
// An unsigned integer, or the error of whatever is there.
|
||||
_, err = d.Uint(math.MaxUint64)
|
||||
}
|
||||
return open, err
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package codec
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The Encoder wipes every buffer it outgrows: an encoding that holds a
|
||||
// secret leaves no stale copy behind.
|
||||
func TestEncoderWipesOutgrownBuffers(t *testing.T) {
|
||||
secret := bytes.Repeat([]byte{0xab}, 32)
|
||||
var e Encoder
|
||||
e.Bstr(secret)
|
||||
old := e.buf[:cap(e.buf)]
|
||||
e.Bstr(make([]byte, 2*cap(e.buf)))
|
||||
if bytes.Contains(old, secret[:8]) {
|
||||
t.Fatalf("outgrown buffer not wiped: %x", old)
|
||||
}
|
||||
b, err := e.Out()
|
||||
if err != nil || !bytes.Contains(b, secret) {
|
||||
t.Fatalf("output lost the data: %v", err)
|
||||
}
|
||||
// A buffer with room is not replaced.
|
||||
e = Encoder{buf: make([]byte, 0, 64)}
|
||||
e.Text("fits")
|
||||
if cap(e.buf) != 64 {
|
||||
t.Fatal("buffer replaced although it had room")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,82 @@
|
||||
package codec_test
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"math"
|
||||
"os"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/codec"
|
||||
)
|
||||
|
||||
// cborVectors is the generic part of testdata/vectors/cbor.json, which
|
||||
// internal/testkit.CBORVectors generates.
|
||||
type cborVectors struct {
|
||||
Walk struct {
|
||||
MaxDepth int `json:"max_depth"`
|
||||
MaxLen int `json:"max_len"`
|
||||
} `json:"walk"`
|
||||
Accept []struct {
|
||||
Name string `json:"name"`
|
||||
Hex string `json:"hex"`
|
||||
Value json.RawMessage `json:"value"`
|
||||
} `json:"accept"`
|
||||
Reject []struct {
|
||||
Name string `json:"name"`
|
||||
Hex string `json:"hex"`
|
||||
Error string `json:"error"`
|
||||
} `json:"reject"`
|
||||
}
|
||||
|
||||
// The shared vectors of the CBOR profile (spec §58): Walk accepts exactly the
|
||||
// accept list, with the value recorded for unsigned integers (a decimal string
|
||||
// above 2^53-1), and rejects the reject list with the recorded code.
|
||||
func TestSharedVectors(t *testing.T) {
|
||||
b, err := os.ReadFile("../testdata/vectors/cbor.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var f cborVectors
|
||||
if err := json.Unmarshal(b, &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(f.Accept) == 0 || len(f.Reject) == 0 || f.Walk.MaxDepth == 0 || f.Walk.MaxLen == 0 {
|
||||
t.Fatal("incomplete vector file")
|
||||
}
|
||||
for _, v := range f.Accept {
|
||||
in, err := hex.DecodeString(v.Hex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := codec.Walk(in, f.Walk.MaxDepth, f.Walk.MaxLen); err != nil {
|
||||
t.Errorf("%s: %v", v.Name, err)
|
||||
continue
|
||||
}
|
||||
if v.Value == nil {
|
||||
continue
|
||||
}
|
||||
n, err := codec.NewDecoder(in).Uint(math.MaxUint64)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", v.Name, err)
|
||||
}
|
||||
want := strconv.FormatUint(n, 10)
|
||||
if n > codec.MaxSafeUint {
|
||||
want = strconv.Quote(want)
|
||||
}
|
||||
if string(v.Value) != want {
|
||||
t.Errorf("%s: value %s, want %s", v.Name, v.Value, want)
|
||||
}
|
||||
}
|
||||
for _, v := range f.Reject {
|
||||
in, err := hex.DecodeString(v.Hex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := datekeys.Code(codec.Walk(in, f.Walk.MaxDepth, f.Walk.MaxLen)); got != v.Error {
|
||||
t.Errorf("%s: got %q, want %s", v.Name, got, v.Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,252 @@
|
||||
// Package cbortest encodes and decodes generic CBOR values for tests.
|
||||
//
|
||||
// It is written independently of package codec, on purpose: tests use it to
|
||||
// build inputs that codec cannot write, such as null, negative integers or a
|
||||
// non-canonical head inside an otherwise valid object, and as a second
|
||||
// reading of the CBOR profile of spec §58 to check codec against.
|
||||
//
|
||||
// It is internal and exists for tests only.
|
||||
package cbortest
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Raw is an encoded data item that Marshal writes verbatim.
|
||||
type Raw []byte
|
||||
|
||||
// Pairs is a map whose entries Marshal writes in the given order, keys and
|
||||
// values alternating. Its keys may be of any type Marshal accepts, so it
|
||||
// builds maps with keys out of order, repeated or not unsigned integers.
|
||||
type Pairs []any
|
||||
|
||||
// Marshal returns the deterministic encoding of v, which is one of:
|
||||
// uint64, uint, int or int64 (a negative value as major type 1), string,
|
||||
// []byte, bool, nil (null), []any, map[uint64]any (keys in ascending order),
|
||||
// Pairs or Raw. Strings are written as they are, even if they are not valid
|
||||
// UTF-8.
|
||||
func Marshal(v any) ([]byte, error) { return appendValue(nil, v) }
|
||||
|
||||
func appendHead(b []byte, major byte, arg uint64) []byte {
|
||||
m := major << 5
|
||||
switch {
|
||||
case arg < 24:
|
||||
return append(b, m|byte(arg))
|
||||
case arg < 1<<8:
|
||||
return append(b, m|24, byte(arg))
|
||||
case arg < 1<<16:
|
||||
return binary.BigEndian.AppendUint16(append(b, m|25), uint16(arg))
|
||||
case arg < 1<<32:
|
||||
return binary.BigEndian.AppendUint32(append(b, m|26), uint32(arg))
|
||||
}
|
||||
return binary.BigEndian.AppendUint64(append(b, m|27), arg)
|
||||
}
|
||||
|
||||
func appendInt(b []byte, v int64) []byte {
|
||||
if v < 0 {
|
||||
return appendHead(b, 1, uint64(-(v + 1)))
|
||||
}
|
||||
return appendHead(b, 0, uint64(v))
|
||||
}
|
||||
|
||||
func appendValue(b []byte, v any) ([]byte, error) {
|
||||
switch v := v.(type) {
|
||||
case nil:
|
||||
return append(b, 0xf6), nil
|
||||
case bool:
|
||||
if v {
|
||||
return append(b, 0xf5), nil
|
||||
}
|
||||
return append(b, 0xf4), nil
|
||||
case uint64:
|
||||
return appendHead(b, 0, v), nil
|
||||
case uint:
|
||||
return appendHead(b, 0, uint64(v)), nil
|
||||
case int:
|
||||
return appendInt(b, int64(v)), nil
|
||||
case int64:
|
||||
return appendInt(b, v), nil
|
||||
case []byte:
|
||||
return append(appendHead(b, 2, uint64(len(v))), v...), nil
|
||||
case string:
|
||||
return append(appendHead(b, 3, uint64(len(v))), v...), nil
|
||||
case Raw:
|
||||
return append(b, v...), nil
|
||||
case []any:
|
||||
b = appendHead(b, 4, uint64(len(v)))
|
||||
return appendAll(b, v)
|
||||
case Pairs:
|
||||
if len(v)%2 != 0 {
|
||||
return nil, fmt.Errorf("cbortest: Pairs of odd length %d", len(v))
|
||||
}
|
||||
b = appendHead(b, 5, uint64(len(v)/2))
|
||||
return appendAll(b, v)
|
||||
case map[uint64]any:
|
||||
keys := make([]uint64, 0, len(v))
|
||||
for k := range v {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
slices.Sort(keys)
|
||||
b = appendHead(b, 5, uint64(len(v)))
|
||||
for _, k := range keys {
|
||||
b = appendHead(b, 0, k)
|
||||
var err error
|
||||
if b, err = appendValue(b, v[k]); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
return nil, fmt.Errorf("cbortest: cannot encode %T", v)
|
||||
}
|
||||
|
||||
func appendAll(b []byte, vs []any) ([]byte, error) {
|
||||
for _, x := range vs {
|
||||
var err error
|
||||
if b, err = appendValue(b, x); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// MaxDepth bounds the nesting that Unmarshal follows.
|
||||
const MaxDepth = 1000
|
||||
|
||||
// Unmarshal decodes exactly one data item of the CBOR profile of spec §58
|
||||
// into uint64, []byte, string, []any and map[uint64]any. It rejects every
|
||||
// other major type, indefinite lengths, heads not in their shortest form,
|
||||
// map keys that are not unsigned integers in strictly ascending order,
|
||||
// invalid UTF-8, truncation, trailing bytes and nesting deeper than MaxDepth.
|
||||
func Unmarshal(b []byte) (any, error) {
|
||||
r := &reader{b: b}
|
||||
v, err := r.value(0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if r.off != len(b) {
|
||||
return nil, fmt.Errorf("cbortest: %d trailing bytes", len(b)-r.off)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
// UnmarshalMap is Unmarshal for an input that must hold a map.
|
||||
func UnmarshalMap(b []byte) (map[uint64]any, error) {
|
||||
v, err := Unmarshal(b)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, ok := v.(map[uint64]any)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cbortest: %T, not a map", v)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
var errTruncated = errors.New("cbortest: truncated")
|
||||
|
||||
type reader struct {
|
||||
b []byte
|
||||
off int
|
||||
}
|
||||
|
||||
func (r *reader) head() (major byte, arg uint64, err error) {
|
||||
if r.off >= len(r.b) {
|
||||
return 0, 0, errTruncated
|
||||
}
|
||||
ib := r.b[r.off]
|
||||
r.off++
|
||||
major, info := ib>>5, ib&0x1f
|
||||
if major == 1 || major >= 6 {
|
||||
return 0, 0, fmt.Errorf("cbortest: major type %d", major)
|
||||
}
|
||||
if info < 24 {
|
||||
return major, uint64(info), nil
|
||||
}
|
||||
if info > 27 {
|
||||
return 0, 0, fmt.Errorf("cbortest: additional information %d", info)
|
||||
}
|
||||
n := 1 << (info - 24)
|
||||
if len(r.b)-r.off < n {
|
||||
return 0, 0, errTruncated
|
||||
}
|
||||
for _, c := range r.b[r.off : r.off+n] {
|
||||
arg = arg<<8 | uint64(c)
|
||||
}
|
||||
r.off += n
|
||||
if (n == 1 && arg < 24) || (n > 1 && arg>>(4*n) == 0) {
|
||||
return 0, 0, fmt.Errorf("cbortest: %d not in its shortest form", arg)
|
||||
}
|
||||
return major, arg, nil
|
||||
}
|
||||
|
||||
func (r *reader) bytes(n uint64) ([]byte, error) {
|
||||
if n > uint64(len(r.b)-r.off) {
|
||||
return nil, errTruncated
|
||||
}
|
||||
s := r.b[r.off : r.off+int(n)]
|
||||
r.off += int(n)
|
||||
return append([]byte{}, s...), nil
|
||||
}
|
||||
|
||||
func (r *reader) value(depth int) (any, error) {
|
||||
major, arg, err := r.head()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch major {
|
||||
case 0:
|
||||
return arg, nil
|
||||
case 2:
|
||||
return r.bytes(arg)
|
||||
case 3:
|
||||
s, err := r.bytes(arg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !utf8.Valid(s) {
|
||||
return nil, errors.New("cbortest: invalid UTF-8")
|
||||
}
|
||||
return string(s), nil
|
||||
}
|
||||
if depth >= MaxDepth {
|
||||
return nil, errors.New("cbortest: nested too deep")
|
||||
}
|
||||
if arg > uint64(len(r.b)-r.off) {
|
||||
return nil, errTruncated
|
||||
}
|
||||
if major == 4 {
|
||||
out := make([]any, 0, arg)
|
||||
for range arg {
|
||||
v, err := r.value(depth + 1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
out := make(map[uint64]any, arg)
|
||||
var last uint64
|
||||
for i := range arg {
|
||||
km, k, err := r.head()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if km != 0 {
|
||||
return nil, fmt.Errorf("cbortest: map key of major type %d", km)
|
||||
}
|
||||
if i > 0 && k <= last {
|
||||
return nil, fmt.Errorf("cbortest: map key %d after %d", k, last)
|
||||
}
|
||||
last = k
|
||||
if out[k], err = r.value(depth + 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
package cbortest
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarshal(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
v any
|
||||
want string
|
||||
}{
|
||||
{uint64(0), "00"}, {uint(24), "1818"}, {256, "190100"}, {int64(-1), "20"}, {-500, "3901f3"},
|
||||
{uint64(1) << 32, "1b0000000100000000"}, {"a", "6161"}, {[]byte{1}, "4101"},
|
||||
{true, "f5"}, {false, "f4"}, {nil, "f6"}, {Raw{0xf9, 0x7e, 0x00}, "f97e00"},
|
||||
{[]any{uint64(1), "x"}, "82016178"}, {map[uint64]any{10: uint64(1), 2: uint64(0)}, "a202000a01"},
|
||||
{Pairs{uint64(1), uint64(0), "k", nil}, "a2010061" + "6bf6"},
|
||||
{uint64(65536), "1a00010000"},
|
||||
} {
|
||||
b, err := Marshal(tc.v)
|
||||
if err != nil || hex.EncodeToString(b) != tc.want {
|
||||
t.Errorf("%#v: %x %v, want %s", tc.v, b, err, tc.want)
|
||||
}
|
||||
}
|
||||
for _, v := range []any{Pairs{uint64(1)}, 1.5, []any{struct{}{}}, map[uint64]any{0: 1.5}, Pairs{uint64(0), 1.5}} {
|
||||
if _, err := Marshal(v); err == nil {
|
||||
t.Errorf("%#v encoded", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnmarshal(t *testing.T) {
|
||||
v, err := Unmarshal([]byte{0xa2, 0x00, 0x82, 0x40, 0x60, 0x0a, 0x1b, 0, 0, 0, 1, 0, 0, 0, 0})
|
||||
want := map[uint64]any{0: []any{[]byte{}, ""}, 10: uint64(1) << 32}
|
||||
if err != nil || !reflect.DeepEqual(v, want) {
|
||||
t.Fatalf("%#v %v", v, err)
|
||||
}
|
||||
for _, h := range []string{
|
||||
"", "20", "c101", "f5", "f6", "1c", "9f00ff", "1817", "190017", "1a0000ffff", "1b00000000ffffffff",
|
||||
"a1616100", "a201000001", "a200000001", "61ff", "0100", "1901", "4201", "8201", "a100",
|
||||
strings.Repeat("81", MaxDepth+1) + "00",
|
||||
} {
|
||||
b, _ := hex.DecodeString(h)
|
||||
if _, err := Unmarshal(b); err == nil {
|
||||
t.Errorf("%s accepted", h)
|
||||
}
|
||||
}
|
||||
if _, err := UnmarshalMap([]byte{0x80}); err == nil {
|
||||
t.Error("an array read as a map")
|
||||
}
|
||||
if _, err := UnmarshalMap([]byte{0xff}); err == nil {
|
||||
t.Error("garbage read as a map")
|
||||
}
|
||||
if m, err := UnmarshalMap([]byte{0xa1, 0x00, 0x41, 0x07}); err != nil || !bytes.Equal(m[0].([]byte), []byte{7}) {
|
||||
t.Errorf("%v %v", m, err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,64 @@
|
||||
// Package inspectview renders the result of capsule.Inspect as the
|
||||
// "datekeys inspect" command prints it. The command and the generator of the
|
||||
// frozen inspect outputs (testdata/fixtures/*.inspect.json) share it, so that
|
||||
// the frozen files are exactly what the command prints.
|
||||
package inspectview
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"time"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
)
|
||||
|
||||
// View is the result of "datekeys inspect"; "-json" prints it as JSON.
|
||||
type View struct {
|
||||
File string `json:"file"`
|
||||
CapsuleID string `json:"capsule_id,omitempty"`
|
||||
DateKey string `json:"datekey,omitempty"`
|
||||
Profile string `json:"profile,omitempty"`
|
||||
Round uint64 `json:"round,omitempty"`
|
||||
UnlockAt string `json:"unlock_at,omitempty"`
|
||||
AccessPolicy string `json:"access_policy,omitempty"`
|
||||
Valid bool `json:"valid"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Checks []capsule.CheckResult `json:"checks"`
|
||||
}
|
||||
|
||||
// New returns the view of the inspection of file: the result and the error
|
||||
// of capsule.Inspect.
|
||||
func New(file string, result *capsule.Inspection, err error) View {
|
||||
v := View{File: file, Valid: err == nil, Error: datekeys.Code(err), Checks: result.Checks}
|
||||
if h := result.Header; h != nil {
|
||||
v.CapsuleID, v.DateKey, v.Profile, v.Round, v.AccessPolicy = h.CapsuleIDHex(), h.DateKey.Compact(), h.DateKey.ProfileID, h.DateKey.Round, h.Policy.String()
|
||||
}
|
||||
if !result.UnlockAt.IsZero() {
|
||||
v.UnlockAt = result.UnlockAt.Format(time.RFC3339)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// WriteJSON writes v as indented JSON followed by a newline.
|
||||
func (v View) WriteJSON(w io.Writer) error {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(v)
|
||||
}
|
||||
|
||||
// WriteText writes v as lines of text, one per check.
|
||||
func (v View) WriteText(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s\n", v.File)
|
||||
for _, c := range v.Checks {
|
||||
mark := "ok "
|
||||
if !c.OK {
|
||||
mark = "FAIL"
|
||||
}
|
||||
fmt.Fprintf(w, " [%s] step %2d %-26s %s\n", mark, c.Step, c.Name, c.Detail)
|
||||
}
|
||||
if v.Valid {
|
||||
fmt.Fprintf(w, " valid before unlock; opens at %s (round %d, %s)\n", v.UnlockAt, v.Round, v.AccessPolicy)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,597 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/accesskey"
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
"g.activething.com/go/DateKeys/codec"
|
||||
"g.activething.com/go/DateKeys/datekey"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
)
|
||||
|
||||
// Limits with which the generic vectors of cbor.json are walked: at most three
|
||||
// nested containers, the most any object of the protocol has (object,
|
||||
// extension array, extension), and at most 64 bytes in a string and 64 items
|
||||
// or entries in a container, the size of the largest extension array.
|
||||
const (
|
||||
WalkMaxDepth = 3
|
||||
WalkMaxLen = 64
|
||||
)
|
||||
|
||||
// Schema names of the schema vectors: the object the bytes encode and the
|
||||
// decoder that reads them.
|
||||
const (
|
||||
SchemaProfile = "provider_profile"
|
||||
SchemaHeader = "public_header"
|
||||
SchemaControl = "control_cbor"
|
||||
SchemaDKKBody = "dkk_body"
|
||||
)
|
||||
|
||||
// ResultOK is the result of a vector, mutation or inspection that is accepted.
|
||||
const ResultOK = "ok"
|
||||
|
||||
// CBORVectorFile is testdata/vectors/cbor.json.
|
||||
type CBORVectorFile struct {
|
||||
Spec string `json:"spec"`
|
||||
Description string `json:"description"`
|
||||
Walk WalkLimits `json:"walk"`
|
||||
Accept []CBORVector `json:"accept"`
|
||||
Reject []CBORVector `json:"reject"`
|
||||
Schemas []SchemaVector `json:"schemas"`
|
||||
}
|
||||
|
||||
// WalkLimits are the maxDepth and maxLen arguments of codec.Walk.
|
||||
type WalkLimits struct {
|
||||
MaxDepth int `json:"max_depth"`
|
||||
MaxLen int `json:"max_len"`
|
||||
}
|
||||
|
||||
// CBORVector is one generic vector: bytes that are, or are not, exactly one
|
||||
// data item of the CBOR profile of spec §58 within the walk limits.
|
||||
type CBORVector struct {
|
||||
Name string `json:"name"`
|
||||
Hex string `json:"hex"`
|
||||
// Value is the value of an accepted unsigned integer: a JSON number up
|
||||
// to 2^53-1, a decimal string above.
|
||||
Value any `json:"value,omitempty"`
|
||||
// Error is the code of a rejected vector.
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// SchemaVector is one encoded object and the result of its decoder.
|
||||
type SchemaVector struct {
|
||||
// Block names the schema the vector exercises: one of the Schema*
|
||||
// names, "verification_metadata" (inside a .dkk body) or "extension"
|
||||
// (inside the noncritical_extensions of a PUBLIC_HEADER).
|
||||
Block string `json:"block"`
|
||||
// Schema names the object the bytes encode and the decoder to run.
|
||||
Schema string `json:"schema"`
|
||||
Name string `json:"name"`
|
||||
Hex string `json:"hex"`
|
||||
// Result is ResultOK or the normative code of the rejection.
|
||||
Result string `json:"result"`
|
||||
}
|
||||
|
||||
// DecodeSchema runs the decoder of the named schema on b, as a reader does
|
||||
// before any registry is consulted: profile.Decode, capsule.DecodeHeader,
|
||||
// capsule.DecodeControl or accesskey.DecodeBody. Secrets it decodes are
|
||||
// wiped.
|
||||
func DecodeSchema(schema string, b []byte) error {
|
||||
switch schema {
|
||||
case SchemaProfile:
|
||||
_, err := profile.Decode(b)
|
||||
return err
|
||||
case SchemaHeader:
|
||||
_, err := capsule.DecodeHeader(b)
|
||||
return err
|
||||
case SchemaControl:
|
||||
c, err := capsule.DecodeControl(b)
|
||||
if c != nil {
|
||||
clear(c.PayloadIdentity[:])
|
||||
}
|
||||
return err
|
||||
case SchemaDKKBody:
|
||||
k, err := accesskey.DecodeBody(b)
|
||||
if k != nil {
|
||||
k.Wipe()
|
||||
}
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("testkit: unknown schema %q", schema)
|
||||
}
|
||||
|
||||
// Result returns ResultOK for a nil error and its normative code otherwise.
|
||||
func Result(err error) string {
|
||||
if err == nil {
|
||||
return ResultOK
|
||||
}
|
||||
if c := datekeys.Code(err); c != "" {
|
||||
return c
|
||||
}
|
||||
return "error without a normative code: " + err.Error()
|
||||
}
|
||||
|
||||
// CBORVectors computes testdata/vectors/cbor.json with the implementation,
|
||||
// and fails if any vector does not get the result it is written for.
|
||||
func CBORVectors() (CBORVectorFile, error) {
|
||||
f := CBORVectorFile{
|
||||
Spec: SpecVersion,
|
||||
Description: "CBOR profile of spec §58 and the schemas of spec/datekeys.cddl, generated by the reference implementation. " +
|
||||
"accept and reject are walked as one data item of the profile with the limits of walk; " +
|
||||
"schemas are decoded with the decoder of their schema. See testdata/README.md.",
|
||||
Walk: WalkLimits{MaxDepth: WalkMaxDepth, MaxLen: WalkMaxLen},
|
||||
}
|
||||
var errs []error
|
||||
for _, g := range genericVectors() {
|
||||
b, err := hex.DecodeString(g.hex)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("vector %q: %w", g.name, err)
|
||||
}
|
||||
got := Result(codec.Walk(b, WalkMaxDepth, WalkMaxLen))
|
||||
v := CBORVector{Name: g.name, Hex: g.hex}
|
||||
if g.accept {
|
||||
if got != ResultOK {
|
||||
errs = append(errs, fmt.Errorf("vector %q: want accepted, got %s", g.name, got))
|
||||
}
|
||||
v.Value = uintValue(b)
|
||||
f.Accept = append(f.Accept, v)
|
||||
continue
|
||||
}
|
||||
if got != datekeys.ErrNonCanonicalCBOR.Code() {
|
||||
errs = append(errs, fmt.Errorf("vector %q: want %s, got %s", g.name, datekeys.ErrNonCanonicalCBOR.Code(), got))
|
||||
}
|
||||
v.Error = got
|
||||
f.Reject = append(f.Reject, v)
|
||||
}
|
||||
sv, err := schemaVectors()
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
for _, s := range sv {
|
||||
b, err := cbortest.Marshal(s.value)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("schema vector %s %q: %w", s.block, s.name, err)
|
||||
}
|
||||
got := Result(DecodeSchema(s.schema, b))
|
||||
if got != s.want {
|
||||
errs = append(errs, fmt.Errorf("schema vector %s %q: want %s, got %s", s.block, s.name, s.want, got))
|
||||
}
|
||||
f.Schemas = append(f.Schemas, SchemaVector{Block: s.block, Schema: s.schema, Name: s.name, Hex: hex.EncodeToString(b), Result: got})
|
||||
}
|
||||
return f, errors.Join(errs...)
|
||||
}
|
||||
|
||||
// uintValue returns the value of b when b is exactly one unsigned integer:
|
||||
// a number up to 2^53-1, a decimal string above. It returns nil otherwise.
|
||||
func uintValue(b []byte) any {
|
||||
v, err := cbortest.Unmarshal(b)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
n, ok := v.(uint64)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
if n > codec.MaxSafeUint {
|
||||
return strconv.FormatUint(n, 10)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
type genericVector struct {
|
||||
name string
|
||||
hex string
|
||||
accept bool
|
||||
}
|
||||
|
||||
func genericVectors() []genericVector {
|
||||
rep := func(s string, n int) string { return strings.Repeat(s, n) }
|
||||
// 64 map entries {0: 0, ..., 63: 0}, and one more.
|
||||
mapEntries := func(n int) string {
|
||||
var b strings.Builder
|
||||
for k := range n {
|
||||
if k < 24 {
|
||||
fmt.Fprintf(&b, "%02x00", k)
|
||||
} else {
|
||||
fmt.Fprintf(&b, "18%02x00", k)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
accept := []genericVector{
|
||||
{name: "uint 0", hex: "00"},
|
||||
{name: "uint 23 inline", hex: "17"},
|
||||
{name: "uint 24 one byte", hex: "1818"},
|
||||
{name: "uint 255 one byte", hex: "18ff"},
|
||||
{name: "uint 256 two bytes", hex: "190100"},
|
||||
{name: "uint 65535 two bytes", hex: "19ffff"},
|
||||
{name: "uint 65536 four bytes", hex: "1a00010000"},
|
||||
{name: "uint 2^32-1 four bytes", hex: "1affffffff"},
|
||||
{name: "uint 2^32 eight bytes", hex: "1b0000000100000000"},
|
||||
{name: "uint 2^53-1 eight bytes", hex: "1b001fffffffffffff"},
|
||||
{name: "uint 2^53 eight bytes", hex: "1b0020000000000000"},
|
||||
{name: "uint 2^64-1 eight bytes", hex: "1bffffffffffffffff"},
|
||||
{name: "empty bstr", hex: "40"},
|
||||
{name: "bstr of one byte", hex: "4100"},
|
||||
{name: "bstr of 23 bytes, inline length", hex: "57" + rep("ab", 23)},
|
||||
{name: "bstr of 24 bytes, one-byte length", hex: "5818" + rep("ab", 24)},
|
||||
{name: "bstr of 64 bytes, max_len", hex: "5840" + rep("ab", 64)},
|
||||
{name: "empty text", hex: "60"},
|
||||
{name: "text a", hex: "6161"},
|
||||
{name: "text with NUL", hex: "6100"},
|
||||
{name: "text with leading BOM", hex: "64efbbbf61"},
|
||||
{name: "text U+FF61", hex: "63efbda1"},
|
||||
{name: "text U+10000", hex: "64f0908080"},
|
||||
{name: "text U+10FFFF", hex: "64f48fbfbf"},
|
||||
{name: "text of 64 bytes, max_len", hex: "7840" + rep("61", 64)},
|
||||
{name: "empty array", hex: "80"},
|
||||
{name: "empty map", hex: "a0"},
|
||||
{name: "map two sorted keys", hex: "a200010101"},
|
||||
{name: "map keys 23 and 24", hex: "a21700181800"},
|
||||
{name: "map keys 255 and 256", hex: "a218ff0019010000"},
|
||||
{name: "map with text and bstr values", hex: "a20061610141" + "00"},
|
||||
{name: "array of mixed items", hex: "8500406080a0"},
|
||||
{name: "array of 64 items, max_len", hex: "9840" + rep("00", 64)},
|
||||
{name: "map of 64 entries, max_len", hex: "b840" + mapEntries(64)},
|
||||
{name: "containers nested 3 deep, max_depth", hex: "81818100"},
|
||||
{name: "map in array in map", hex: "a10081a10000"},
|
||||
}
|
||||
reject := []genericVector{
|
||||
{name: "empty input", hex: ""},
|
||||
{name: "uint 23 with one extra byte", hex: "1817"},
|
||||
{name: "uint 255 in two bytes", hex: "1900ff"},
|
||||
{name: "uint 65535 in four bytes", hex: "1a0000ffff"},
|
||||
{name: "uint 2^32-1 in eight bytes", hex: "1b00000000ffffffff"},
|
||||
{name: "bstr length not shortest", hex: "5800"},
|
||||
{name: "text length not shortest", hex: "7800"},
|
||||
{name: "array length not shortest", hex: "9800"},
|
||||
{name: "map length not shortest", hex: "b800"},
|
||||
{name: "map key not shortest", hex: "a1180000"},
|
||||
{name: "keys out of order", hex: "a201000001"},
|
||||
{name: "duplicate key", hex: "a200000001"},
|
||||
{name: "keys out of order in a nested map", hex: "a100a2010000" + "00"},
|
||||
{name: "text key", hex: "a1616100"},
|
||||
{name: "bstr key", hex: "a1416100"},
|
||||
{name: "negative integer key", hex: "a12000"},
|
||||
{name: "array key", hex: "a18000"},
|
||||
{name: "float key", hex: "a1f93c0000"},
|
||||
{name: "indefinite array", hex: "9f01ff"},
|
||||
{name: "indefinite map", hex: "bf0000ff"},
|
||||
{name: "indefinite bstr", hex: "5f4100ff"},
|
||||
{name: "indefinite text", hex: "7f6161ff"},
|
||||
{name: "break", hex: "ff"},
|
||||
{name: "tag", hex: "c101"},
|
||||
{name: "tag 24, encoded CBOR data item", hex: "d8184100"},
|
||||
{name: "tag 2, bignum", hex: "c24101"},
|
||||
{name: "float", hex: "f97e00"},
|
||||
{name: "half-precision float 1.0", hex: "f93c00"},
|
||||
{name: "single-precision float 1.0", hex: "fa3f800000"},
|
||||
{name: "double-precision float 1.0", hex: "fb3ff0000000000000"},
|
||||
{name: "false", hex: "f4"},
|
||||
{name: "true", hex: "f5"},
|
||||
{name: "null", hex: "f6"},
|
||||
{name: "undefined", hex: "f7"},
|
||||
{name: "simple value 16", hex: "f0"},
|
||||
{name: "simple value 32", hex: "f820"},
|
||||
{name: "negative int", hex: "20"},
|
||||
{name: "negative int -25", hex: "3818"},
|
||||
{name: "reserved additional information 28", hex: "1c"},
|
||||
{name: "reserved additional information 29 in a bstr head", hex: "5d"},
|
||||
{name: "reserved additional information 30 in an array head", hex: "9e"},
|
||||
{name: "truncated uint", hex: "1901"},
|
||||
{name: "truncated eight-byte uint", hex: "1b00000000"},
|
||||
{name: "truncated bstr", hex: "4200"},
|
||||
{name: "truncated text", hex: "6261"},
|
||||
{name: "truncated array", hex: "8200"},
|
||||
{name: "map without the value of its last key", hex: "a100"},
|
||||
{name: "truncated map head", hex: "b900"},
|
||||
{name: "length beyond input", hex: "5affffffff"},
|
||||
{name: "array count beyond input", hex: "9affffffff"},
|
||||
{name: "map count beyond input", hex: "baffffffff"},
|
||||
{name: "trailing byte", hex: "0100"},
|
||||
{name: "trailing byte after a map", hex: "a000"},
|
||||
{name: "invalid UTF-8", hex: "61ff"},
|
||||
{name: "overlong UTF-8", hex: "62c080"},
|
||||
{name: "overlong three-byte UTF-8", hex: "63e08080"},
|
||||
{name: "UTF-8 surrogate", hex: "63eda080"},
|
||||
{name: "truncated UTF-8 sequence", hex: "62e282"},
|
||||
{name: "UTF-8 above U+10FFFF", hex: "64f4908080"},
|
||||
{name: "invalid UTF-8 in a map value", hex: "a10061ff"},
|
||||
{name: "tag inside an array", hex: "81c101"},
|
||||
{name: "float inside a map", hex: "a100f93c00"},
|
||||
{name: "containers nested 4 deep, above max_depth", hex: "8181818100"},
|
||||
{name: "bstr of 65 bytes, above max_len", hex: "5841" + rep("ab", 65)},
|
||||
{name: "text of 65 bytes, above max_len", hex: "7841" + rep("61", 65)},
|
||||
{name: "array of 65 items, above max_len", hex: "9841" + rep("00", 65)},
|
||||
{name: "map of 65 entries, above max_len", hex: "b841" + mapEntries(65)},
|
||||
}
|
||||
for i := range accept {
|
||||
accept[i].accept = true
|
||||
}
|
||||
return append(accept, reject...)
|
||||
}
|
||||
|
||||
type schemaVector struct {
|
||||
block, schema, name string
|
||||
value any // encoded with cbortest.Marshal
|
||||
want string
|
||||
}
|
||||
|
||||
// with returns a copy of m with the given keys set; a nil value deletes the key.
|
||||
func with(m map[uint64]any, kv ...any) map[uint64]any {
|
||||
c := make(map[uint64]any, len(m)+len(kv)/2)
|
||||
for k, v := range m {
|
||||
c[k] = v
|
||||
}
|
||||
for i := 0; i < len(kv); i += 2 {
|
||||
k := uint64(kv[i].(int))
|
||||
if kv[i+1] == nil {
|
||||
delete(c, k)
|
||||
} else {
|
||||
c[k] = kv[i+1]
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// null is CBOR null, which with cannot set because nil deletes a key.
|
||||
var null = cbortest.Raw{0xf6}
|
||||
|
||||
// appendRaw returns the encoding of v followed by extra bytes.
|
||||
func appendRaw(v any, extra ...byte) cbortest.Raw {
|
||||
b, err := cbortest.Marshal(v)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return cbortest.Raw(append(b, extra...))
|
||||
}
|
||||
|
||||
func fill(b byte, n int) []byte { return bytes.Repeat([]byte{b}, n) }
|
||||
|
||||
func ext(id string, version uint64, data ...any) map[uint64]any {
|
||||
e := map[uint64]any{0: id, 1: version}
|
||||
if len(data) > 0 {
|
||||
e[2] = data[0]
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func exts(n int) []any {
|
||||
out := make([]any, n)
|
||||
for i := range out {
|
||||
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// chainHash returns the chain hash that profile.Validate requires of a
|
||||
// Provider Profile map, the drand chain-info hash: SHA-256 of period (key 7)
|
||||
// as a big-endian uint32, genesis_time (key 8) as a big-endian int64,
|
||||
// public_key (key 6), genesis_seed (key 10) and, unless it is "default",
|
||||
// network (key 4). The vectors below that change one of these keys and keep
|
||||
// the chain hash consistent test that key's own rule, not the self-check.
|
||||
func chainHash(m map[uint64]any) []byte {
|
||||
var n [12]byte
|
||||
binary.BigEndian.PutUint32(n[:4], uint32(m[7].(uint64)))
|
||||
binary.BigEndian.PutUint64(n[4:], m[8].(uint64))
|
||||
h := sha256.New()
|
||||
h.Write(n[:])
|
||||
h.Write(m[6].([]byte))
|
||||
h.Write(m[10].([]byte))
|
||||
if network := m[4].(string); network != "default" {
|
||||
h.Write([]byte(network))
|
||||
}
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func schemaVectors() ([]schemaVector, error) {
|
||||
const (
|
||||
nc = "ERR_NON_CANONICAL_CBOR"
|
||||
unsup = "ERR_UNSUPPORTED_VERSION"
|
||||
unknown = "ERR_UNKNOWN_PROFILE"
|
||||
)
|
||||
qb, err := profile.Quicknet().CanonicalCBOR()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
q, err := cbortest.UnmarshalMap(qb)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs := func(m map[uint64]any, order ...uint64) cbortest.Pairs {
|
||||
var p cbortest.Pairs
|
||||
for _, k := range order {
|
||||
p = append(p, k, m[k])
|
||||
}
|
||||
return p
|
||||
}
|
||||
if !bytes.Equal(chainHash(q), q[5].([]byte)) {
|
||||
return nil, errors.New("testkit: chainHash does not reproduce the Quicknet chain hash")
|
||||
}
|
||||
var out []schemaVector
|
||||
add := func(block, schema, name string, v any, want string) {
|
||||
out = append(out, schemaVector{block: block, schema: schema, name: name, value: v, want: want})
|
||||
}
|
||||
|
||||
// Provider Profile (spec §11): keys 0 to 10, all required.
|
||||
pp := func(name string, v any, want string) { add(SchemaProfile, SchemaProfile, name, v, want) }
|
||||
pp("Quicknet profile", q, ResultOK)
|
||||
pp("unknown key 11", with(q, 11, uint64(0)), nc)
|
||||
pp("missing key 10, genesis_seed", with(q, 10, nil), nc)
|
||||
pp("missing key 6, public_key", with(q, 6, nil), nc)
|
||||
pp("keys 2 and 3 out of order", pairs(q, 0, 1, 3, 2, 4, 5, 6, 7, 8, 9, 10), nc)
|
||||
pp("period as a text string", with(q, 7, "3"), nc)
|
||||
pp("chain_hash as a text string", with(q, 5, strings.Repeat("ab", 32)), nc)
|
||||
pp("null genesis_seed", with(q, 10, null), nc)
|
||||
pp("chain_hash of 31 bytes", with(q, 5, q[5].([]byte)[:31]), nc)
|
||||
pp("genesis_seed of 33 bytes", with(q, 10, append(bytes.Clone(q[10].([]byte)), 0)), nc)
|
||||
pp("period 0", with(q, 7, uint64(0)), nc)
|
||||
// rehashed keeps chain_hash consistent with the changed keys.
|
||||
rehashed := func(kv ...any) map[uint64]any {
|
||||
m := with(q, kv...)
|
||||
return with(m, 5, chainHash(m))
|
||||
}
|
||||
pp("network default, left out of the chain hash", rehashed(4, "default"), ResultOK)
|
||||
pp("period of one day, the implementation limit", rehashed(7, uint64(86400)), ResultOK)
|
||||
pp("period of one day and one second, above the implementation limit", rehashed(7, uint64(86401)), nc)
|
||||
pp("period 2^53", with(q, 7, uint64(1)<<53), nc)
|
||||
pp("genesis_time 2^53", with(q, 8, uint64(1)<<53), nc)
|
||||
pp("negative genesis_time", with(q, 8, -1), nc)
|
||||
pp("period not in shortest form", with(q, 7, cbortest.Raw{0x1a, 0, 0, 0, 3}), nc)
|
||||
pp("schema version 2", with(q, 1, uint64(2)), unsup)
|
||||
pp("schema version 2 and an unknown key 11: the version is read first", with(q, 1, uint64(2), 11, uint64(0)), unsup)
|
||||
pp("schema version 2^53", with(q, 1, uint64(1)<<53), nc)
|
||||
pp("type tag of PUBLIC_HEADER", with(q, 0, capsule.HeaderTypeTag), nc)
|
||||
pp("type tag of PUBLIC_HEADER and schema version 2: the type tag is checked first", with(q, 0, capsule.HeaderTypeTag, 1, uint64(2)), nc)
|
||||
pp("invalid profile_id", with(q, 2, "Datekeys:quicknet:v1"), unknown)
|
||||
pp("network changed: the chain hash no longer matches", with(q, 4, "quicknet2"), "ERR_PROFILE_MISMATCH")
|
||||
pp("network changed with its chain hash", rehashed(4, "quicknet2"), ResultOK)
|
||||
pp("empty public_key", with(q, 6, []byte{}), unknown)
|
||||
pp("public_key of 1025 bytes", rehashed(6, fill(0xaa, 1025)), unknown)
|
||||
pp("public_key that is not a group element", rehashed(6, fill(0x00, 96)), unknown)
|
||||
pp("public_key the identity element", rehashed(6, append([]byte{0xc0}, fill(0x00, 95)...)), unknown)
|
||||
pp("provider other than drand", rehashed(3, "drand2"), unknown)
|
||||
pp("unknown scheme", rehashed(9, "bls-unknown"), unknown)
|
||||
pp("scheme pedersen-bls-chained, not supported by tlock", rehashed(9, "pedersen-bls-chained"), unknown)
|
||||
pp("genesis_time 0", rehashed(8, uint64(0)), unknown)
|
||||
pp("genesis_time 253402300798, 9999-12-31T23:59:58Z", rehashed(8, uint64(profile.MaxUnixTime-1)), ResultOK)
|
||||
pp("genesis_time 253402300799, 9999-12-31T23:59:59Z", rehashed(8, uint64(profile.MaxUnixTime)), unknown)
|
||||
|
||||
// PUBLIC_HEADER (spec §24): keys 0 to 4, optional 5 and 6.
|
||||
id := []byte{0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f}
|
||||
dk := datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
|
||||
h := map[uint64]any{0: capsule.HeaderTypeTag, 1: uint64(capsule.HeaderVersion), 2: id, 3: dk.Compact(), 4: uint64(capsule.TimeOnly)}
|
||||
dkJSON := func(s string) string { return datekey.Prefix + base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||
ph := func(name string, v any, want string) { add(SchemaHeader, SchemaHeader, name, v, want) }
|
||||
ph("minimal header", h, ResultOK)
|
||||
ph("time_and_key policy", with(h, 4, uint64(capsule.TimeAndKey)), ResultOK)
|
||||
ph("both extension arrays", with(h, 5, []any{ext("org.example.a", 1)}, 6, []any{ext("org.example.b", 1, []byte{0})}), ResultOK)
|
||||
ph("DateKey of a profile that is not pinned: the registry decides", with(h, 3, datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}.Compact()), ResultOK)
|
||||
ph("unknown key 7", with(h, 7, uint64(0)), nc)
|
||||
ph("missing key 2, capsule_id", with(h, 2, nil), nc)
|
||||
ph("missing key 4, access_policy", with(h, 4, nil), nc)
|
||||
ph("keys 3 and 4 out of order", pairs(h, 0, 1, 2, 4, 3), nc)
|
||||
ph("capsule_id as a text string", with(h, 2, "0123456789abcdef"), nc)
|
||||
ph("DateKey as a byte string", with(h, 3, []byte(dk.Compact())), nc)
|
||||
ph("capsule_id of 15 bytes", with(h, 2, id[:15]), nc)
|
||||
ph("capsule_id of 17 bytes", with(h, 2, append(bytes.Clone(id), 0x10)), nc)
|
||||
ph("access_policy 2", with(h, 4, uint64(2)), nc)
|
||||
ph("access_policy 256", with(h, 4, uint64(256)), nc)
|
||||
ph("access_policy not in shortest form", with(h, 4, cbortest.Raw{0x18, 0x00}), nc)
|
||||
ph("null access_policy", with(h, 4, null), nc)
|
||||
ph("schema version 2", with(h, 1, uint64(2)), unsup)
|
||||
ph("schema version 2 and a trailing byte: the version is read first", appendRaw(with(h, 1, uint64(2)), 0x00), unsup)
|
||||
ph("schema version 2^53", with(h, 1, uint64(1)<<53), nc)
|
||||
ph("schema version 2^64-1", with(h, 1, uint64(math.MaxUint64)), nc)
|
||||
ph("type tag of CONTROL_CBOR", with(h, 0, capsule.ControlTypeTag), nc)
|
||||
ph("type tag of CONTROL_CBOR and schema version 2: the type tag is checked first", with(h, 0, capsule.ControlTypeTag, 1, uint64(2)), nc)
|
||||
ph("empty critical_extensions", with(h, 5, []any{}), nc)
|
||||
ph("same extension_id in both arrays", with(h, 5, []any{ext("org.example.a", 1)}, 6, []any{ext("org.example.a", 1)}), nc)
|
||||
ph("trailing byte after the map", appendRaw(h, 0x00), nc)
|
||||
ph("non-canonical dk1_ JSON", with(h, 3, dkJSON(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`)), "ERR_DATEKEY_NON_CANONICAL")
|
||||
ph("DateKey that is not dk1_", with(h, 3, "hello"), "ERR_DATEKEY_INVALID")
|
||||
ph("non-canonical DateKey and undefined access_policy: the CDDL is checked first",
|
||||
with(h, 3, dkJSON(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`), 4, uint64(2)), nc)
|
||||
|
||||
// CONTROL_CBOR (spec §31): keys 0 to 3, optional 4 and 5.
|
||||
c := map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(capsule.ControlVersion), 2: fill(0x11, 32), 3: fill(0x22, 32)}
|
||||
pc := func(name string, v any, want string) { add(SchemaControl, SchemaControl, name, v, want) }
|
||||
pc("minimal control", c, ResultOK)
|
||||
pc("both extension arrays", with(c, 4, []any{ext("org.example.a", 1)}, 5, []any{ext("org.example.b", 1, []byte("x"))}), ResultOK)
|
||||
pc("unknown key 6", with(c, 6, uint64(0)), nc)
|
||||
pc("missing key 3, payload_identity", with(c, 3, nil), nc)
|
||||
pc("missing key 2, header_binding", with(c, 2, nil), nc)
|
||||
pc("header_binding as a text string", with(c, 2, strings.Repeat("a", 32)), nc)
|
||||
pc("header_binding of 31 bytes", with(c, 2, fill(0x11, 31)), nc)
|
||||
pc("payload_identity of 33 bytes", with(c, 3, fill(0x22, 33)), nc)
|
||||
pc("null payload_identity", with(c, 3, null), nc)
|
||||
pc("schema version 2", with(c, 1, uint64(2)), unsup)
|
||||
pc("type tag of PUBLIC_HEADER", with(c, 0, capsule.HeaderTypeTag), nc)
|
||||
pc("empty noncritical_extensions", with(c, 5, []any{}), nc)
|
||||
|
||||
// .dkk body (spec §41): keys 0 to 5, optional 6, 7 and 8.
|
||||
k := map[uint64]any{0: accesskey.TypeTag, 1: uint64(accesskey.SchemaVersion), 2: fill(0x33, 16), 3: fill(0x44, 16), 4: accesskey.TypeX25519, 5: fill(0x55, 32)}
|
||||
pk := func(name string, v any, want string) { add(SchemaDKKBody, SchemaDKKBody, name, v, want) }
|
||||
pk("minimal body", k, ResultOK)
|
||||
pk("verification_metadata and both extension arrays",
|
||||
with(k, 6, map[uint64]any{0: fill(0x66, 32)}, 7, []any{ext("org.example.a", 1)}, 8, []any{ext("org.example.b", 1, []byte("x"))}), ResultOK)
|
||||
pk("unknown key 9", with(k, 9, uint64(0)), nc)
|
||||
pk("missing key 5, access_material", with(k, 5, nil), nc)
|
||||
pk("missing key 3, capsule_id", with(k, 3, nil), nc)
|
||||
pk("access_type as a byte string", with(k, 4, []byte(accesskey.TypeX25519)), nc)
|
||||
pk("credential_id of 15 bytes", with(k, 2, fill(0x33, 15)), nc)
|
||||
pk("capsule_id of 17 bytes", with(k, 3, fill(0x44, 17)), nc)
|
||||
pk("null access_material", with(k, 5, null), nc)
|
||||
pk("access_type x448", with(k, 4, "x448"), "ERR_ACCESS_INVALID")
|
||||
pk("access_material of 31 bytes", with(k, 5, fill(0x55, 31)), "ERR_ACCESS_INVALID")
|
||||
pk("access_material of 33 bytes", with(k, 5, fill(0x55, 33)), "ERR_ACCESS_INVALID")
|
||||
pk("schema version 2", with(k, 1, uint64(2)), unsup)
|
||||
pk("type tag of CONTROL_CBOR", with(k, 0, capsule.ControlTypeTag), nc)
|
||||
pk("empty critical_extensions", with(k, 7, []any{}), nc)
|
||||
|
||||
// verification_metadata (spec §43), key 6 of a .dkk body.
|
||||
const vm = "verification_metadata"
|
||||
pv := func(name string, v any, want string) { add(vm, SchemaDKKBody, name, with(k, 6, v), want) }
|
||||
pv("capsule_digest", map[uint64]any{0: fill(0x66, 32)}, ResultOK)
|
||||
pv("empty map", map[uint64]any{}, nc)
|
||||
pv("unknown key 1 instead of key 0", map[uint64]any{1: fill(0x66, 32)}, nc)
|
||||
pv("unknown key 1 after capsule_digest", map[uint64]any{0: fill(0x66, 32), 1: uint64(0)}, nc)
|
||||
pv("capsule_digest of 31 bytes", map[uint64]any{0: fill(0x66, 31)}, nc)
|
||||
pv("capsule_digest of 33 bytes", map[uint64]any{0: fill(0x66, 33)}, nc)
|
||||
pv("capsule_digest as a text string", map[uint64]any{0: strings.Repeat("f", 32)}, nc)
|
||||
pv("null capsule_digest", map[uint64]any{0: null}, nc)
|
||||
pv("verification_metadata as an array", []any{fill(0x66, 32)}, nc)
|
||||
pv("null verification_metadata", null, nc)
|
||||
|
||||
// Extensions (spec §31, §54), in the noncritical_extensions of a
|
||||
// PUBLIC_HEADER.
|
||||
const ex = "extension"
|
||||
pe := func(name string, v []any, want string) { add(ex, SchemaHeader, name, with(h, 6, v), want) }
|
||||
pe("one extension without data", []any{ext("org.example.a", 1)}, ResultOK)
|
||||
pe("data of one byte", []any{ext("org.example.a", 1, []byte{0})}, ResultOK)
|
||||
pe("data that is not CBOR", []any{ext("org.example.a", 1, []byte{0xff, 0xfe})}, ResultOK)
|
||||
pe("data 40, an empty byte string", []any{ext("org.example.a", 1, []byte{})}, nc)
|
||||
pe("data 5801xx, a length not in its shortest form", []any{ext("org.example.a", 1, cbortest.Raw{0x58, 0x01, 0x2a})}, nc)
|
||||
pe("data as a text string", []any{ext("org.example.a", 1, "public label")}, nc)
|
||||
pe("null data", []any{ext("org.example.a", 1, null)}, nc)
|
||||
pe("data as an unsigned integer", []any{ext("org.example.a", 1, uint64(7))}, nc)
|
||||
pe("data as a map", []any{ext("org.example.a", 1, map[uint64]any{0: uint64(7)})}, nc)
|
||||
pe("data as an array", []any{ext("org.example.a", 1, []any{[]byte{0}})}, nc)
|
||||
pe("data as a tagged byte string", []any{ext("org.example.a", 1, cbortest.Raw{0xc1, 0x41, 0x00})}, nc)
|
||||
pe("data as an indefinite-length byte string", []any{ext("org.example.a", 1, cbortest.Raw{0x5f, 0x41, 0x00, 0xff})}, nc)
|
||||
pe("64 extensions", exts(64), ResultOK)
|
||||
pe("65 extensions", exts(65), nc)
|
||||
pe("empty array", []any{}, nc)
|
||||
pe("extension_id starting with a BOM", []any{ext("\ufefforg.example.a", 1)}, ResultOK)
|
||||
pe("U+FF61 before U+10000: UTF-8 byte order", []any{ext("\uff61", 1), ext("\U00010000", 1)}, ResultOK)
|
||||
pe("U+10000 before U+FF61: UTF-16 order, not UTF-8 byte order", []any{ext("\U00010000", 1), ext("\uff61", 1)}, nc)
|
||||
pe("extensions out of order", []any{ext("org.example.b", 1), ext("org.example.a", 1)}, nc)
|
||||
pe("extension_id repeated", []any{ext("org.example.a", 1), ext("org.example.a", 2)}, nc)
|
||||
pe("extension_version 2^32-1", []any{ext("org.example.a", 1<<32-1)}, ResultOK)
|
||||
pe("extension_version 2^32", []any{ext("org.example.a", 1<<32)}, nc)
|
||||
pe("extension_version 2^53", []any{ext("org.example.a", 1<<53)}, nc)
|
||||
pe("extension_version 0", []any{ext("org.example.a", 0)}, ResultOK)
|
||||
pe("unknown key 3", []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: []byte{0}}}, nc)
|
||||
pe("missing key 1, extension_version", []any{map[uint64]any{0: "org.example.a"}}, nc)
|
||||
pe("missing key 0, extension_id", []any{map[uint64]any{1: uint64(1)}}, nc)
|
||||
pe("keys 0 and 1 out of order", []any{cbortest.Pairs{uint64(1), uint64(1), uint64(0), "org.example.a"}}, nc)
|
||||
pe("extension_id as a byte string", []any{map[uint64]any{0: []byte("org.example.a"), 1: uint64(1)}}, nc)
|
||||
pe("empty extension_id", []any{ext("", 1)}, nc)
|
||||
pe("extension_id of 256 bytes, the implementation limit", []any{ext(strings.Repeat("a", 256), 1)}, ResultOK)
|
||||
pe("extension_id of 257 bytes, above the implementation limit", []any{ext(strings.Repeat("a", 257), 1)}, nc)
|
||||
pe("extension_id that is not valid UTF-8", []any{map[uint64]any{0: "org.example.\xff", 1: uint64(1)}}, nc)
|
||||
pe("extension that is not a map", []any{uint64(1)}, nc)
|
||||
return out, nil
|
||||
}
|
||||
@ -0,0 +1,670 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"g.activething.com/go/DateKeys/capsule"
|
||||
"g.activething.com/go/DateKeys/datekey"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
)
|
||||
|
||||
// DifferentialSeed seeds the generator of the differential corpus.
|
||||
const DifferentialSeed = 20260925
|
||||
|
||||
// DifferentialFile is testdata/vectors/inspect_differential.json.
|
||||
type DifferentialFile struct {
|
||||
Spec string `json:"spec"`
|
||||
Description string `json:"description"`
|
||||
Format string `json:"format"`
|
||||
Seed uint64 `json:"seed"`
|
||||
Bases []DifferentialBase `json:"bases"`
|
||||
Mutations []DifferentialCase `json:"mutations"`
|
||||
}
|
||||
|
||||
// DifferentialBase is an official .dkc fixture the mutations edit.
|
||||
type DifferentialBase struct {
|
||||
File string `json:"file"`
|
||||
SHA256 string `json:"sha256"`
|
||||
}
|
||||
|
||||
// DifferentialCase is one mutation and the verdict of capsule.Inspect.
|
||||
type DifferentialCase struct {
|
||||
Base int `json:"base"` // index into Bases
|
||||
Kind string `json:"kind"`
|
||||
Edits []Edit `json:"edits"`
|
||||
// Result is ResultOK or the normative code of the failure.
|
||||
Result string `json:"result"`
|
||||
// Step is the step of spec §63 that failed; absent when Result is ok.
|
||||
Step int `json:"step,omitempty"`
|
||||
}
|
||||
|
||||
const differentialFormat = "Each mutation is bases[base].file (in testdata/fixtures) with its edits applied. " +
|
||||
"An edit is [at, delete, insert]: the delete bytes at offset at of the base are replaced by the bytes of the hex string insert. " +
|
||||
"The edits of one mutation refer to offsets of the unmodified base, are sorted by offset and do not overlap. " +
|
||||
"result is the verdict of steps 1 to 8 of spec §63 (capsule.Inspect, the Quicknet profile pinned, no extension known, no network, no secret): " +
|
||||
"ok, or the normative error code, with step the step that failed. kind names the generator of the mutation and is informative."
|
||||
|
||||
// Kinds of differential mutations and how many each base gets.
|
||||
var differentialKinds = []struct {
|
||||
kind string
|
||||
count int
|
||||
}{
|
||||
{"flip", 60}, // one bit flipped
|
||||
{"byte", 30}, // one byte replaced
|
||||
{"truncate", 25}, // the file cut short
|
||||
{"insert", 30}, // one to four bytes inserted
|
||||
{"delete", 30}, // one to four bytes deleted
|
||||
{"length", 25}, // PUBLIC_HEADER_LEN or SEALED_CONTROL_LEN edited
|
||||
{"header", 80}, // PUBLIC_HEADER re-encoded with a CBOR-aware change
|
||||
{"datekey", 25}, // PUBLIC_HEADER re-encoded with another DateKey string
|
||||
{"age", 60}, // an age header of SEALED_CONTROL or PAYLOAD_AGE edited
|
||||
}
|
||||
|
||||
// InspectVerdict runs capsule.Inspect on dkc with the default registry and
|
||||
// no extension known, and returns ResultOK or the error code, and the step
|
||||
// that failed.
|
||||
func InspectVerdict(dkc []byte) (string, int) {
|
||||
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: Registry()})
|
||||
if err == nil {
|
||||
return ResultOK, 0
|
||||
}
|
||||
step := 0
|
||||
if n := len(in.Checks); n > 0 && !in.Checks[n-1].OK {
|
||||
step = in.Checks[n-1].Step
|
||||
}
|
||||
return Result(err), step
|
||||
}
|
||||
|
||||
// splitmix64 is the generator of the corpus: fixed, simple and independent
|
||||
// of the Go release.
|
||||
type splitmix64 struct{ s uint64 }
|
||||
|
||||
func (r *splitmix64) next() uint64 {
|
||||
r.s += 0x9e3779b97f4a7c15
|
||||
z := r.s
|
||||
z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9
|
||||
z = (z ^ (z >> 27)) * 0x94d049bb133111eb
|
||||
return z ^ (z >> 31)
|
||||
}
|
||||
|
||||
// intn returns a number in [0, n).
|
||||
func (r *splitmix64) intn(n int) int { return int(r.next() % uint64(n)) }
|
||||
|
||||
func (r *splitmix64) byte() byte { return byte(r.next()) }
|
||||
|
||||
func (r *splitmix64) bytes(n int) []byte {
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
b[i] = r.byte()
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func pick[T any](r *splitmix64, s []T) T { return s[r.intn(len(s))] }
|
||||
|
||||
// diffBase is a base fixture and the offsets of its sections.
|
||||
type diffBase struct {
|
||||
file string
|
||||
dkc []byte
|
||||
parts Parts
|
||||
header map[uint64]any
|
||||
sealedAt, payloadAt int // offsets of SEALED_CONTROL and PAYLOAD_AGE
|
||||
sealedHdr, payHdr int // lengths of their age headers
|
||||
interesting int // the end of the bytes Inspect reads, and a little more
|
||||
regions [][2]int
|
||||
}
|
||||
|
||||
func newDiffBase(dir, name string) (*diffBase, error) {
|
||||
f, err := LoadFixture(dir, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b := &diffBase{file: f.File, dkc: f.DKC, parts: f.Parts}
|
||||
if b.header, err = cbortest.UnmarshalMap(f.Parts.Header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.sealedAt = capsule.PreludeSize + len(f.Parts.Header)
|
||||
b.payloadAt = b.sealedAt + len(f.Parts.Sealed)
|
||||
if b.sealedHdr, err = HeaderLen(f.Parts.Sealed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if b.payHdr, err = HeaderLen(f.Parts.Payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.interesting = min(len(b.dkc), b.payloadAt+b.payHdr+64)
|
||||
b.regions = [][2]int{
|
||||
{0, capsule.PreludeSize},
|
||||
{capsule.PreludeSize, b.sealedAt},
|
||||
{b.sealedAt, b.sealedAt + b.sealedHdr},
|
||||
{b.sealedAt + b.sealedHdr, b.payloadAt},
|
||||
{b.payloadAt, b.payloadAt + b.payHdr},
|
||||
{b.payloadAt + b.payHdr, len(b.dkc)},
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// position picks an offset, weighted towards the bytes steps 1 to 8 read:
|
||||
// the prelude, the header, and the age headers of the two age files.
|
||||
func (b *diffBase) position(r *splitmix64) int {
|
||||
weights := []int{15, 30, 25, 5, 20, 5}
|
||||
n := r.intn(100)
|
||||
for i, w := range weights {
|
||||
if n < w {
|
||||
reg := b.regions[i]
|
||||
if reg[1] > reg[0] {
|
||||
return reg[0] + r.intn(reg[1]-reg[0])
|
||||
}
|
||||
break
|
||||
}
|
||||
n -= w
|
||||
}
|
||||
return r.intn(len(b.dkc))
|
||||
}
|
||||
|
||||
// lengths returns an edit of the prelude that sets the section lengths.
|
||||
func (b *diffBase) lengths(headerLen, sealedLen int) Edit {
|
||||
var v [8]byte
|
||||
binary.BigEndian.PutUint32(v[0:4], uint32(headerLen))
|
||||
binary.BigEndian.PutUint32(v[4:8], uint32(sealedLen))
|
||||
return Edit{At: 8, Delete: 8, Insert: v[:]}
|
||||
}
|
||||
|
||||
// InspectDifferential computes testdata/vectors/inspect_differential.json:
|
||||
// deterministic mutations of the official .dkc fixtures of dir, each with
|
||||
// the verdict of capsule.Inspect.
|
||||
func InspectDifferential(dir string, names []string) (DifferentialFile, error) {
|
||||
f := DifferentialFile{
|
||||
Spec: SpecVersion,
|
||||
Description: "Differential corpus of the pre-unlock checks (spec §63 steps 1 to 8): deterministic mutations of the official .dkc fixtures " +
|
||||
"with the verdict of the reference implementation. See testdata/README.md.",
|
||||
Format: differentialFormat,
|
||||
Seed: DifferentialSeed,
|
||||
}
|
||||
r := &splitmix64{s: DifferentialSeed}
|
||||
for bi, name := range names {
|
||||
b, err := newDiffBase(dir, name)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
sum := sha256.Sum256(b.dkc)
|
||||
f.Bases = append(f.Bases, DifferentialBase{File: b.file, SHA256: hex.EncodeToString(sum[:])})
|
||||
seen := map[[32]byte]bool{sum: true}
|
||||
for _, k := range differentialKinds {
|
||||
made := 0
|
||||
for attempt := 0; made < k.count; attempt++ {
|
||||
if attempt > 50*k.count {
|
||||
return f, fmt.Errorf("differential %s %s: only %d distinct mutations", name, k.kind, made)
|
||||
}
|
||||
edits, err := b.mutate(r, k.kind)
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("differential %s %s: %w", name, k.kind, err)
|
||||
}
|
||||
if edits, err = normalizeEdits(b.dkc, edits); err != nil {
|
||||
return f, err
|
||||
}
|
||||
out, err := ApplyEdits(b.dkc, edits)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
h := sha256.Sum256(out)
|
||||
if len(edits) == 0 || seen[h] {
|
||||
continue
|
||||
}
|
||||
seen[h] = true
|
||||
result, step := InspectVerdict(out)
|
||||
f.Mutations = append(f.Mutations, DifferentialCase{Base: bi, Kind: k.kind, Edits: edits, Result: result, Step: step})
|
||||
made++
|
||||
}
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (b *diffBase) mutate(r *splitmix64, kind string) ([]Edit, error) {
|
||||
switch kind {
|
||||
case "flip":
|
||||
p := b.position(r)
|
||||
return []Edit{{At: p, Delete: 1, Insert: []byte{b.dkc[p] ^ 1<<r.intn(8)}}}, nil
|
||||
case "byte":
|
||||
p := b.position(r)
|
||||
v := pick(r, []byte{0x00, 0xff, 0x7f, 0x80, 0x18, 0x1b, 0x40, 0x60, 0x80, 0xa0, 0xf6, '\n', ' ', '-', r.byte()})
|
||||
return []Edit{{At: p, Delete: 1, Insert: []byte{v}}}, nil
|
||||
case "truncate":
|
||||
p := r.intn(b.interesting)
|
||||
return []Edit{{At: p, Delete: len(b.dkc) - p}}, nil
|
||||
case "insert":
|
||||
p := b.position(r)
|
||||
ins := r.bytes(1 + r.intn(4))
|
||||
if r.intn(3) == 0 {
|
||||
ins = []byte{pick(r, []byte{0x00, 0xff, 0xf6, 0x20, '\n', ' ', '\r'})}
|
||||
}
|
||||
return []Edit{{At: p, Insert: ins}}, nil
|
||||
case "delete":
|
||||
p := b.position(r)
|
||||
return []Edit{{At: p, Delete: min(1+r.intn(4), len(b.dkc)-p)}}, nil
|
||||
case "length":
|
||||
return b.lengthEdit(r), nil
|
||||
case "header":
|
||||
return b.headerEdit(r)
|
||||
case "datekey":
|
||||
m := with(b.header)
|
||||
m[3] = r.dateKey(b.dateKey())
|
||||
h, err := cbortest.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.replaceHeader(h, false), nil
|
||||
case "age":
|
||||
return b.ageEdit(r)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown kind %q", kind)
|
||||
}
|
||||
|
||||
func (b *diffBase) lengthEdit(r *splitmix64) []Edit {
|
||||
hl, sl := len(b.parts.Header), len(b.parts.Sealed)
|
||||
vary := func(v int) int {
|
||||
switch r.intn(8) {
|
||||
case 0:
|
||||
return v + 1
|
||||
case 1:
|
||||
return v - 1
|
||||
case 2:
|
||||
return v + 1 + r.intn(64)
|
||||
case 3:
|
||||
return max(0, v-1-r.intn(64))
|
||||
case 4:
|
||||
return pick(r, []int{0, 1, 16, 255, 256, 65535, 65536})
|
||||
case 5:
|
||||
return pick(r, []int{capsule.MaxPublicHeaderLen, capsule.MaxPublicHeaderLen + 1, capsule.MaxSealedControlLen, capsule.MaxSealedControlLen + 1, 1<<32 - 1})
|
||||
case 6:
|
||||
return int(r.next() >> 32)
|
||||
}
|
||||
return v ^ 1<<(8+r.intn(24))
|
||||
}
|
||||
switch r.intn(4) {
|
||||
case 0:
|
||||
hl = vary(hl)
|
||||
case 1:
|
||||
sl = vary(sl)
|
||||
case 2:
|
||||
hl, sl = sl, hl
|
||||
default:
|
||||
hl, sl = vary(hl), vary(sl)
|
||||
}
|
||||
return []Edit{b.lengths(hl, sl)}
|
||||
}
|
||||
|
||||
// replaceHeader returns the edits that replace PUBLIC_HEADER with h and,
|
||||
// unless keepPrelude, set PUBLIC_HEADER_LEN to its length.
|
||||
func (b *diffBase) replaceHeader(h []byte, keepPrelude bool) []Edit {
|
||||
edits := []Edit{{At: capsule.PreludeSize, Delete: len(b.parts.Header), Insert: h}}
|
||||
if !keepPrelude {
|
||||
edits = append([]Edit{b.lengths(len(h), len(b.parts.Sealed))}, edits...)
|
||||
}
|
||||
return edits
|
||||
}
|
||||
|
||||
// dateKey returns the DateKey of the base header.
|
||||
func (b *diffBase) dateKey() datekey.DateKey {
|
||||
if s, ok := b.header[3].(string); ok {
|
||||
if d, err := datekey.Parse(s); err == nil {
|
||||
return d
|
||||
}
|
||||
}
|
||||
return datekey.DateKey{ProfileID: profile.QuicknetID, Round: 1000}
|
||||
}
|
||||
|
||||
func (b *diffBase) headerEdit(r *splitmix64) ([]Edit, error) {
|
||||
m := with(b.header)
|
||||
dk := b.dateKey()
|
||||
var v any = m
|
||||
switch r.intn(12) {
|
||||
case 0: // remove a key
|
||||
keys := sortedKeys(m)
|
||||
delete(m, pick(r, keys))
|
||||
case 1: // add a key
|
||||
m[pick(r, []uint64{5, 6, 7, 8, 23, 24, 255, 65535, 1 << 32})] = r.value()
|
||||
case 2: // change the type of a value
|
||||
m[pick(r, sortedKeys(m))] = r.value()
|
||||
case 3:
|
||||
m[0] = pick(r, []string{"", "datekeyca", "datekeycapx", "DATEKEYCAP", capsule.ControlTypeTag, "datekeys-access-key", strings.Repeat("a", 65)})
|
||||
case 4:
|
||||
m[1] = pick(r, []any{uint64(0), uint64(2), uint64(255), uint64(256), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53), uint64(1<<64 - 1), "1", -1})
|
||||
case 5:
|
||||
m[2] = r.bytes(pick(r, []int{0, 1, 15, 17, 32}))
|
||||
case 6:
|
||||
m[3] = r.dateKey(dk)
|
||||
case 7:
|
||||
m[4] = pick(r, []any{uint64(2), uint64(3), uint64(23), uint64(24), uint64(255), uint64(256), uint64(257), uint64(65536), uint64(1 << 32), uint64(1<<53 - 1), uint64(1 << 53)})
|
||||
case 8: // an extension array
|
||||
key := uint64(5 + r.intn(2))
|
||||
arr := r.extensions()
|
||||
if arr == nil {
|
||||
// The same extension in both arrays.
|
||||
m[5] = []any{ext("org.example.a", 1)}
|
||||
m[6] = []any{ext("org.example.a", 1)}
|
||||
} else {
|
||||
m[key] = arr
|
||||
}
|
||||
case 9: // a head not in its shortest form
|
||||
enc, err := cbortest.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch r.intn(4) {
|
||||
case 0:
|
||||
v = cbortest.Raw(append([]byte{0xb8, enc[0] & 0x1f}, enc[1:]...))
|
||||
case 1:
|
||||
m[4] = cbortest.Raw(pick(r, [][]byte{{0x18, 0x00}, {0x18, 0x01}, {0x19, 0x00, 0x00}, {0x1b, 0, 0, 0, 0, 0, 0, 0, 0}}))
|
||||
case 2:
|
||||
m[1] = cbortest.Raw{0x18, 0x01}
|
||||
default:
|
||||
if id, ok := m[2].([]byte); ok {
|
||||
m[2] = cbortest.Raw(append([]byte{0x58, byte(len(id))}, id...))
|
||||
}
|
||||
}
|
||||
case 10: // keys out of order or repeated
|
||||
keys := sortedKeys(m)
|
||||
var p cbortest.Pairs
|
||||
for _, k := range keys {
|
||||
p = append(p, k, m[k])
|
||||
}
|
||||
i := 2 * r.intn(len(keys))
|
||||
j := 2 * r.intn(len(keys))
|
||||
if r.intn(2) == 0 {
|
||||
p[i], p[j] = p[j], p[i]
|
||||
p[i+1], p[j+1] = p[j+1], p[i+1]
|
||||
} else {
|
||||
p = append(p, p[i], p[i+1])
|
||||
}
|
||||
v = p
|
||||
default: // the whole item wrapped or framed differently
|
||||
enc, err := cbortest.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch r.intn(6) {
|
||||
case 0:
|
||||
v = cbortest.Raw(append([]byte{0xc1}, enc...))
|
||||
case 1:
|
||||
v = []any{cbortest.Raw(enc)}
|
||||
case 2:
|
||||
v = cbortest.Raw(append(append([]byte{0xbf}, enc[1:]...), 0xff))
|
||||
case 3:
|
||||
v = cbortest.Raw(append(enc, 0x00))
|
||||
case 4:
|
||||
v = cbortest.Raw(append(enc, enc...))
|
||||
default:
|
||||
v = cbortest.Raw(enc[:len(enc)-1])
|
||||
}
|
||||
}
|
||||
h, err := cbortest.Marshal(v)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.replaceHeader(h, r.intn(10) == 0), nil
|
||||
}
|
||||
|
||||
func sortedKeys(m map[uint64]any) []uint64 {
|
||||
var keys []uint64
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
for i := 1; i < len(keys); i++ {
|
||||
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
|
||||
keys[j], keys[j-1] = keys[j-1], keys[j]
|
||||
}
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// value returns a value of a random type, most of them outside the profile.
|
||||
func (r *splitmix64) value() any {
|
||||
return pick(r, []any{
|
||||
uint64(0), uint64(1), uint64(1 << 53), "x", []byte{0}, []byte{}, []any{}, map[uint64]any{},
|
||||
cbortest.Raw{0xf6}, cbortest.Raw{0xf5}, cbortest.Raw{0x20}, cbortest.Raw{0xc1, 0x00},
|
||||
cbortest.Raw{0xf9, 0x3c, 0x00}, cbortest.Raw{0x5f, 0x40, 0xff},
|
||||
})
|
||||
}
|
||||
|
||||
// dateKey returns a DateKey string derived from dk, most of them invalid.
|
||||
func (r *splitmix64) dateKey(dk datekey.DateKey) any {
|
||||
s := dk.Compact()
|
||||
enc := func(j string) string { return datekey.Prefix + b64(j) }
|
||||
switch r.intn(11) {
|
||||
case 0: // one character changed
|
||||
i := len(datekey.Prefix) + r.intn(len(s)-len(datekey.Prefix))
|
||||
c := pick(r, []byte("AZaz09-_=+/ ."))
|
||||
return s[:i] + string(c) + s[i+1:]
|
||||
case 1:
|
||||
return s[:len(datekey.Prefix)+r.intn(len(s)-len(datekey.Prefix))]
|
||||
case 2:
|
||||
return s + "="
|
||||
case 3:
|
||||
return "DK1_" + s[len(datekey.Prefix):]
|
||||
case 4: // canonical, but of a profile that is not pinned
|
||||
return datekey.DateKey{ProfileID: pick(r, []string{"datekeys:evmnet:v1", "datekeys:quicknet:v2", "drand:quicknet"}), Round: dk.Round}.Compact()
|
||||
case 5: // canonical, but of a round the tlock stanza does not name or the profile never reaches
|
||||
return datekey.DateKey{ProfileID: dk.ProfileID, Round: pick(r, []uint64{1, dk.Round - 1, dk.Round + 1, 66884212,
|
||||
profile.Quicknet().MaxRound(), profile.Quicknet().MaxRound() + 1, datekey.MaxRound})}.Compact()
|
||||
case 6:
|
||||
return enc(fmt.Sprintf(`{"version":1,"network":"%s","round":%s}`, dk.ProfileID,
|
||||
pick(r, []string{"0", "-1", "1.0", "1e3", "9007199254740992", strconv.FormatUint(dk.Round+1, 10), `"1000"`})))
|
||||
case 7:
|
||||
return enc(fmt.Sprintf(`{"version":1, "network":"%s", "round":%d}`, dk.ProfileID, dk.Round))
|
||||
case 8:
|
||||
return datekey.Prefix
|
||||
case 9:
|
||||
return cbortest.Raw(append([]byte{0x62}, 0xc0, 0x80))
|
||||
}
|
||||
return s + strings.Repeat("A", 200+r.intn(100))
|
||||
}
|
||||
|
||||
func (r *splitmix64) extensions() []any {
|
||||
many := func(n int) []any {
|
||||
out := make([]any, n)
|
||||
for i := range out {
|
||||
out[i] = ext(fmt.Sprintf("org.example.%03d", i), 1)
|
||||
}
|
||||
return out
|
||||
}
|
||||
switch r.intn(16) {
|
||||
case 0:
|
||||
return []any{}
|
||||
case 1:
|
||||
return []any{ext("org.example.a", 1)}
|
||||
case 2:
|
||||
return []any{ext("org.example.a", 1, r.bytes(1+r.intn(8)))}
|
||||
case 3:
|
||||
return []any{ext("org.example.a", 1, []byte{})}
|
||||
case 4:
|
||||
return []any{ext("org.example.a", 1, pick(r, []any{"text", uint64(7), cbortest.Raw{0xf6}, map[uint64]any{}, []any{}, cbortest.Raw{0x58, 0x01, 0x2a}, cbortest.Raw{0xc1, 0x41, 0x00}}))}
|
||||
case 5:
|
||||
return []any{ext("org.example.a", 1<<32)}
|
||||
case 6:
|
||||
return many(64)
|
||||
case 7:
|
||||
return many(65)
|
||||
case 8:
|
||||
return []any{ext("org.example.b", 1), ext("org.example.a", 1)}
|
||||
case 9:
|
||||
return []any{ext("org.example.a", 1), ext("org.example.a", 1)}
|
||||
case 10:
|
||||
return []any{map[uint64]any{0: "org.example.a", 1: uint64(1), 3: uint64(0)}}
|
||||
case 11:
|
||||
return []any{map[uint64]any{0: "org.example.a"}}
|
||||
case 12:
|
||||
return []any{ext("\ufefforg.example.a", 1)}
|
||||
case 13:
|
||||
return []any{ext("\U00010000", 1), ext("\uff61", 1)}
|
||||
case 14:
|
||||
return []any{ext("\uff61", 1), ext("\U00010000", 1)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ageEdit edits the age header of SEALED_CONTROL (and then, most of the
|
||||
// time, SEALED_CONTROL_LEN) or of PAYLOAD_AGE.
|
||||
func (b *diffBase) ageEdit(r *splitmix64) ([]Edit, error) {
|
||||
sealed := r.intn(5) < 3
|
||||
at, n := b.payloadAt, b.payHdr
|
||||
if sealed {
|
||||
at, n = b.sealedAt, b.sealedHdr
|
||||
}
|
||||
hdr := string(b.dkc[at : at+n])
|
||||
lines := strings.SplitAfter(hdr, "\n")
|
||||
lines = lines[:len(lines)-1] // SplitAfter leaves an empty string after the final newline
|
||||
// lines: intro, stanza lines (argument line, body lines), MAC line.
|
||||
stanzaAt := 1
|
||||
mac := len(lines) - 1
|
||||
switch r.intn(11) {
|
||||
case 0: // intro line
|
||||
lines[0] = pick(r, []string{"age-encryption.org/v2\n", "age-encryption.org/V1\n", "age-encryption.org/v1 \n", "age-encryption.org/\n", "\n"})
|
||||
case 1: // stanza type
|
||||
f := strings.Fields(lines[stanzaAt])
|
||||
f[1] = pick(r, []string{"tlock", "TLOCK", "X25519", "x25519", "scrypt", "tlock2", "t"})
|
||||
lines[stanzaAt] = strings.Join(f, " ") + "\n"
|
||||
case 2: // one argument
|
||||
f := strings.Fields(lines[stanzaAt])
|
||||
if len(f) > 2 {
|
||||
i := 2 + r.intn(len(f)-2)
|
||||
f[i] = r.arg(f[i])
|
||||
lines[stanzaAt] = strings.Join(f, " ") + "\n"
|
||||
}
|
||||
case 3: // one more argument, or one less
|
||||
f := strings.Fields(lines[stanzaAt])
|
||||
if r.intn(2) == 0 {
|
||||
f = append(f, pick(r, []string{"extra", "1000", "AAAA"}))
|
||||
} else if len(f) > 2 {
|
||||
f = f[:len(f)-1]
|
||||
}
|
||||
lines[stanzaAt] = strings.Join(f, " ") + "\n"
|
||||
case 4: // the stanza twice
|
||||
stanza := lines[stanzaAt:mac]
|
||||
lines = append(append(append([]string{}, lines[:mac]...), stanza...), lines[mac])
|
||||
case 5: // an extra stanza
|
||||
extra := pick(r, []string{
|
||||
"-> X25519 " + strings.Repeat("A", 43) + "\n" + strings.Repeat("B", 43) + "\n",
|
||||
"-> scrypt c2FsdHNhbHRzYWx0c2FsdA 18\n" + strings.Repeat("C", 43) + "\n",
|
||||
"-> tlock 1000 " + profile.Quicknet().ChainHashHex() + "\n" + strings.Repeat("D", 64) + "\n\n",
|
||||
"-> grease-x !@#\n\n",
|
||||
})
|
||||
lines = append(append(append([]string{}, lines[:mac]...), extra), lines[mac])
|
||||
case 6: // no stanza
|
||||
lines = []string{lines[0], lines[mac]}
|
||||
case 7: // a body line
|
||||
if mac-stanzaAt > 1 {
|
||||
i := stanzaAt + 1 + r.intn(mac-stanzaAt-1)
|
||||
l := strings.TrimSuffix(lines[i], "\n")
|
||||
switch r.intn(4) {
|
||||
case 0:
|
||||
if len(l) > 0 {
|
||||
j := r.intn(len(l))
|
||||
l = l[:j] + string(pick(r, []byte("A/+=_-"))) + l[j+1:]
|
||||
}
|
||||
case 1:
|
||||
l += "A"
|
||||
case 2:
|
||||
l += " "
|
||||
default:
|
||||
if len(l) > 0 {
|
||||
l = l[:len(l)-1]
|
||||
}
|
||||
}
|
||||
lines[i] = l + "\n"
|
||||
}
|
||||
case 8: // the MAC line
|
||||
l := lines[mac]
|
||||
switch r.intn(4) {
|
||||
case 0:
|
||||
l = "--" + l[3:]
|
||||
case 1:
|
||||
j := 4 + r.intn(len(l)-5)
|
||||
l = l[:j] + string(pick(r, []byte("AB/+"))) + l[j+1:]
|
||||
case 2:
|
||||
l = "---\n"
|
||||
default:
|
||||
l = strings.TrimSuffix(l, "\n") + " \n"
|
||||
}
|
||||
lines[mac] = l
|
||||
case 9: // a line ending
|
||||
i := r.intn(len(lines))
|
||||
lines[i] = strings.TrimSuffix(lines[i], "\n") + "\r\n"
|
||||
default: // an empty line
|
||||
i := 1 + r.intn(len(lines)-1)
|
||||
lines = append(append(append([]string{}, lines[:i]...), "\n"), lines[i:]...)
|
||||
}
|
||||
edited := []byte(strings.Join(lines, ""))
|
||||
edits := []Edit{{At: at, Delete: n, Insert: edited}}
|
||||
if sealed && r.intn(10) != 0 {
|
||||
edits = append([]Edit{b.lengths(len(b.parts.Header), len(b.parts.Sealed)-n+len(edited))}, edits...)
|
||||
}
|
||||
return edits, nil
|
||||
}
|
||||
|
||||
// arg returns a variant of a stanza argument.
|
||||
func (r *splitmix64) arg(a string) string {
|
||||
if _, err := strconv.ParseUint(a, 10, 64); err == nil {
|
||||
return pick(r, []string{"999", "1001", "0" + a, a + "0", "0", "-1", "18446744073709551616", a + "a", "2000"})
|
||||
}
|
||||
if len(a) == 0 {
|
||||
return "A"
|
||||
}
|
||||
i := r.intn(len(a))
|
||||
switch r.intn(4) {
|
||||
case 0:
|
||||
return a[:i] + string(pick(r, []byte("0aAf/+"))) + a[i+1:]
|
||||
case 1:
|
||||
return strings.ToUpper(a)
|
||||
case 2:
|
||||
return a[:len(a)-1]
|
||||
}
|
||||
return a + "A"
|
||||
}
|
||||
|
||||
// MarshalDifferential writes f with one mutation per line.
|
||||
func MarshalDifferential(f DifferentialFile) ([]byte, error) {
|
||||
head := f
|
||||
head.Mutations = nil
|
||||
b, err := json.MarshalIndent(head, "", " ")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Replace the closing "\n}" and the null mutations.
|
||||
b = bytes.TrimSuffix(b, []byte("\n}"))
|
||||
b = bytes.TrimSuffix(b, []byte(",\n \"mutations\": null"))
|
||||
var out bytes.Buffer
|
||||
out.Write(b)
|
||||
out.WriteString(",\n \"mutations\": [\n")
|
||||
for i, m := range f.Mutations {
|
||||
line, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out.WriteString(" ")
|
||||
out.Write(line)
|
||||
if i < len(f.Mutations)-1 {
|
||||
out.WriteByte(',')
|
||||
}
|
||||
out.WriteByte('\n')
|
||||
}
|
||||
out.WriteString(" ]\n}\n")
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// WriteDifferential writes f to path with MarshalDifferential.
|
||||
func WriteDifferential(path string, f DifferentialFile) error {
|
||||
b, err := MarshalDifferential(f)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeFile(path, b)
|
||||
}
|
||||
@ -0,0 +1,103 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
)
|
||||
|
||||
// Edit replaces Delete bytes at offset At of a base file with Insert. In JSON
|
||||
// it is the array [at, delete, "insert in lowercase hex"].
|
||||
//
|
||||
// The edits of one file refer to offsets of the unmodified base, are sorted by
|
||||
// At and do not overlap: the result is the base with every range
|
||||
// [At, At+Delete) replaced by its Insert.
|
||||
type Edit struct {
|
||||
At, Delete int
|
||||
Insert []byte
|
||||
}
|
||||
|
||||
// MarshalJSON writes e as [at, delete, "hex"].
|
||||
func (e Edit) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]any{e.At, e.Delete, hex.EncodeToString(e.Insert)})
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads [at, delete, "hex"].
|
||||
func (e *Edit) UnmarshalJSON(b []byte) error {
|
||||
var raw []json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(raw) != 3 {
|
||||
return fmt.Errorf("testkit: edit of %d elements, want 3", len(raw))
|
||||
}
|
||||
var s string
|
||||
if err := errors.Join(json.Unmarshal(raw[0], &e.At), json.Unmarshal(raw[1], &e.Delete), json.Unmarshal(raw[2], &s)); err != nil {
|
||||
return err
|
||||
}
|
||||
var err error
|
||||
e.Insert, err = hex.DecodeString(s)
|
||||
return err
|
||||
}
|
||||
|
||||
// ApplyEdits returns a new file: base with edits applied.
|
||||
func ApplyEdits(base []byte, edits []Edit) ([]byte, error) {
|
||||
var out []byte
|
||||
next := 0
|
||||
for _, e := range edits {
|
||||
if e.At < next || e.Delete < 0 || e.At+e.Delete > len(base) {
|
||||
return nil, fmt.Errorf("testkit: edit [%d, %d] outside the base or out of order", e.At, e.Delete)
|
||||
}
|
||||
out = append(out, base[next:e.At]...)
|
||||
out = append(out, e.Insert...)
|
||||
next = e.At + e.Delete
|
||||
}
|
||||
return append(out, base[next:]...), nil
|
||||
}
|
||||
|
||||
// Splice returns the edits that turn base into out: one edit covering the
|
||||
// bytes between their common prefix and their common suffix, or none when
|
||||
// they are equal.
|
||||
func Splice(base, out []byte) []Edit {
|
||||
return trimEdits(base, []Edit{{At: 0, Delete: len(base), Insert: out}})
|
||||
}
|
||||
|
||||
// trimEdits drops from each edit the leading and trailing bytes it leaves
|
||||
// unchanged, and then the edits that change nothing.
|
||||
func trimEdits(base []byte, edits []Edit) []Edit {
|
||||
var out []Edit
|
||||
for _, e := range edits {
|
||||
old, ins := base[e.At:e.At+e.Delete], e.Insert
|
||||
p := 0
|
||||
for p < len(old) && p < len(ins) && old[p] == ins[p] {
|
||||
p++
|
||||
}
|
||||
old, ins = old[p:], ins[p:]
|
||||
s := 0
|
||||
for s < len(old) && s < len(ins) && old[len(old)-1-s] == ins[len(ins)-1-s] {
|
||||
s++
|
||||
}
|
||||
old, ins = old[:len(old)-s], ins[:len(ins)-s]
|
||||
if len(old) == 0 && len(ins) == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, Edit{At: e.At + p, Delete: len(old), Insert: bytes.Clone(ins)})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// normalizeEdits sorts edits by offset and trims them. Overlapping edits are
|
||||
// an error of the caller.
|
||||
func normalizeEdits(base []byte, edits []Edit) ([]Edit, error) {
|
||||
edits = slices.Clone(edits)
|
||||
slices.SortStableFunc(edits, func(a, b Edit) int { return a.At - b.At })
|
||||
for i := 1; i < len(edits); i++ {
|
||||
if edits[i].At < edits[i-1].At+edits[i-1].Delete {
|
||||
return nil, fmt.Errorf("testkit: overlapping edits at %d and %d", edits[i-1].At, edits[i].At)
|
||||
}
|
||||
}
|
||||
return trimEdits(base, edits), nil
|
||||
}
|
||||
@ -0,0 +1,919 @@
|
||||
package testkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"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/datekey"
|
||||
"g.activething.com/go/DateKeys/extension"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
"g.activething.com/go/DateKeys/provider"
|
||||
)
|
||||
|
||||
// Mutation is one entry of the mutation corpus (spec §64): a capsule, and
|
||||
// the options to open it, that must fail with one exact normative error at
|
||||
// one step of spec §63.
|
||||
type Mutation struct {
|
||||
Name string
|
||||
// Spec is true for the twenty-three mutations listed in spec §64.
|
||||
Spec bool
|
||||
Want *datekeys.Error
|
||||
Step int
|
||||
// Network reports whether the failure may happen after a release was
|
||||
// requested. Failures of steps 1 to 8 and of the access pre-checks must
|
||||
// not cause any request (spec §27, §63).
|
||||
Network bool
|
||||
// Random reports that Make builds the capsule with fresh age
|
||||
// randomness, so that its bytes differ on every call. The exported
|
||||
// corpus freezes the bytes of the first build (MutationCorpus).
|
||||
Random bool
|
||||
Make func(e *MutationEnv) (*MutationInput, error)
|
||||
}
|
||||
|
||||
// MutationInput is a mutated capsule and what the reader is given to open
|
||||
// it.
|
||||
type MutationInput struct {
|
||||
// Base is the file name of the official fixture the capsule derives
|
||||
// from, or "" for a capsule built from nothing.
|
||||
Base string
|
||||
DKC []byte
|
||||
// DKK is the .dkk file offered, or nil.
|
||||
DKK []byte
|
||||
// Identities are the age X25519 identities offered, AGE-SECRET-KEY-1...
|
||||
Identities []string
|
||||
// Release is the only release the source knows: it answers every request
|
||||
// with it. Nil means that no release is available.
|
||||
Release *provider.Release
|
||||
Now time.Time
|
||||
// EmptyRegistry pins no profile; otherwise profile.Default is used.
|
||||
EmptyRegistry bool
|
||||
// Extensions are the extensions the application implements; nil knows
|
||||
// none.
|
||||
Extensions KnownExtensions
|
||||
}
|
||||
|
||||
// KnownExtension is an extension an application implements, whose data is
|
||||
// valid only when it equals ValidData.
|
||||
type KnownExtension struct {
|
||||
ID string
|
||||
Version uint64
|
||||
ValidData []byte
|
||||
}
|
||||
|
||||
// KnownExtensions is an extension.Registry and extension.DataValidator.
|
||||
type KnownExtensions []KnownExtension
|
||||
|
||||
func (k KnownExtensions) find(id string, version uint64) *KnownExtension {
|
||||
for i := range k {
|
||||
if k[i].ID == id && k[i].Version == version {
|
||||
return &k[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Known implements extension.Registry.
|
||||
func (k KnownExtensions) Known(id string, version uint64) bool { return k.find(id, version) != nil }
|
||||
|
||||
// ValidateData implements extension.DataValidator.
|
||||
func (k KnownExtensions) ValidateData(e extension.Extension) error {
|
||||
x := k.find(e.ID, e.Version)
|
||||
if x == nil {
|
||||
return fmt.Errorf("extension %s version %d is not known", e.ID, e.Version)
|
||||
}
|
||||
if !bytes.Equal(x.ValidData, e.Data) {
|
||||
return fmt.Errorf("data %x is not %x", e.Data, x.ValidData)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// singleSource answers every request with one release, or with
|
||||
// ErrReleaseUnavailable, and counts the requests.
|
||||
type singleSource struct {
|
||||
release *provider.Release
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *singleSource) Fetch(context.Context, *profile.Profile, provider.Condition) (provider.Release, error) {
|
||||
s.calls++
|
||||
if s.release == nil {
|
||||
return provider.Release{}, fmt.Errorf("testkit: no release: %w", datekeys.ErrReleaseUnavailable)
|
||||
}
|
||||
return *s.release, nil
|
||||
}
|
||||
|
||||
// Verdict is how a reader rejected a capsule.
|
||||
type Verdict struct {
|
||||
Err error
|
||||
Step int // the step of spec §63 that failed, 0 on success
|
||||
// Calls is the number of release requests made.
|
||||
Calls int
|
||||
}
|
||||
|
||||
// Open opens the capsule with capsule.Open, as a reader given exactly the
|
||||
// input would, from a seekable reader, and returns the verdict.
|
||||
func (in *MutationInput) Open() (Verdict, error) {
|
||||
reg := Registry()
|
||||
if in.EmptyRegistry {
|
||||
var err error
|
||||
if reg, err = profile.NewRegistry(); err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
}
|
||||
src := &singleSource{release: in.Release}
|
||||
o := capsule.OpenOptions{Registry: reg, Source: src, Now: Fixed(in.Now)}
|
||||
if in.Extensions != nil {
|
||||
o.Extensions = in.Extensions
|
||||
}
|
||||
if in.DKK != nil {
|
||||
k, err := accesskey.Decode(bytes.NewReader(in.DKK))
|
||||
if err != nil {
|
||||
return Verdict{}, fmt.Errorf("testkit: the .dkk of a mutation must decode: %w", err)
|
||||
}
|
||||
defer k.Wipe()
|
||||
o.AccessKey = k
|
||||
}
|
||||
for _, s := range in.Identities {
|
||||
id, err := age.ParseX25519Identity(s)
|
||||
if err != nil {
|
||||
return Verdict{}, err
|
||||
}
|
||||
o.Identities = append(o.Identities, id)
|
||||
}
|
||||
opened, err := capsule.Open(context.Background(), discard{}, bytes.NewReader(in.DKC), o)
|
||||
v := Verdict{Err: err, Calls: src.calls}
|
||||
if err == nil {
|
||||
return v, nil
|
||||
}
|
||||
if opened == nil || len(opened.Inspection.Checks) == 0 {
|
||||
return v, fmt.Errorf("testkit: Open failed without recording a step: %w", err)
|
||||
}
|
||||
checks := opened.Inspection.Checks
|
||||
if last := checks[len(checks)-1]; !last.OK {
|
||||
v.Step = last.Step
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
|
||||
type discard struct{}
|
||||
|
||||
func (discard) Write(p []byte) (int, error) { return len(p), nil }
|
||||
|
||||
// LoadedFixture is an official .dkc fixture read from a fixture directory.
|
||||
type LoadedFixture struct {
|
||||
DKCFixture
|
||||
DKC []byte
|
||||
DKK []byte // the .dkk file, when the fixture has one
|
||||
Parts Parts
|
||||
Published provider.Release // the release the fixture opens with
|
||||
Unlock time.Time
|
||||
}
|
||||
|
||||
// LoadFixture reads the fixture name from dir.
|
||||
func LoadFixture(dir, name string) (*LoadedFixture, error) {
|
||||
f := &LoadedFixture{}
|
||||
if err := ReadJSON(filepath.Join(dir, name+".json"), &f.DKCFixture); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var err error
|
||||
if f.DKC, err = os.ReadFile(filepath.Join(dir, f.File)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.Parts, err = Split(f.DKC); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if f.AccessKeyFile != "" {
|
||||
if f.DKK, err = os.ReadFile(filepath.Join(dir, f.AccessKeyFile)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sig, err := hex.DecodeString(f.Release.Signature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Published = provider.Release{Round: f.Release.Round, Signature: sig}
|
||||
if f.Unlock, err = time.Parse(time.RFC3339, f.UnlockAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// input returns dkc, derived from f, with the options that open f: its
|
||||
// release, its unlock time and, for time_and_key, its .dkk.
|
||||
func (f *LoadedFixture) input(dkc []byte) *MutationInput {
|
||||
r := f.Published
|
||||
in := &MutationInput{Base: f.File, DKC: dkc, Release: &r, Now: f.Unlock}
|
||||
if f.AccessPolicy == capsule.TimeAndKey.String() {
|
||||
in.DKK = f.DKK
|
||||
}
|
||||
return in
|
||||
}
|
||||
|
||||
// MutationEnv holds what the mutations derive from.
|
||||
type MutationEnv struct {
|
||||
Dir string
|
||||
// TimeOnly and TimeAndKey are the time_only and time_and_key_portable
|
||||
// fixtures.
|
||||
TimeOnly, TimeAndKey *LoadedFixture
|
||||
sibling []byte
|
||||
}
|
||||
|
||||
// NewMutationEnv loads the fixtures the mutations derive from.
|
||||
func NewMutationEnv(dir string) (*MutationEnv, error) {
|
||||
e := &MutationEnv{Dir: dir}
|
||||
var err error
|
||||
if e.TimeOnly, err = LoadFixture(dir, "time_only"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if e.TimeAndKey, err = LoadFixture(dir, "time_and_key_portable"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Sibling returns another time_only capsule for round 1000, built once per
|
||||
// environment with fresh randomness.
|
||||
func (e *MutationEnv) Sibling() (Parts, error) {
|
||||
if e.sibling == nil {
|
||||
var b bytes.Buffer
|
||||
p := profile.Quicknet()
|
||||
unlock, err := datekey.RoundTime(p, 1000)
|
||||
if err != nil {
|
||||
return Parts{}, err
|
||||
}
|
||||
if _, err := capsule.Encrypt(&b, strings.NewReader("sibling"), capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: Fixed(Genesis())}); err != nil {
|
||||
return Parts{}, err
|
||||
}
|
||||
e.sibling = b.Bytes()
|
||||
}
|
||||
return Split(e.sibling)
|
||||
}
|
||||
|
||||
// Stranger returns a fixed X25519 identity that is not a recipient of any
|
||||
// fixture: the key of the tests' third party. Its scalar is
|
||||
// SHA-256("DateKeys test identity: stranger").
|
||||
func Stranger() *age.X25519Identity {
|
||||
raw := sha256.Sum256([]byte("DateKeys test identity: stranger"))
|
||||
id, err := agewrap.X25519IdentityFromRaw(raw[:])
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// MustUnderstand is the critical extension of the mutations that need one.
|
||||
const MustUnderstand = "org.example.must-understand"
|
||||
|
||||
// mustUnderstandKnown is an application that knows MustUnderstand at version
|
||||
// 1 and accepts only the data "ok".
|
||||
var mustUnderstandKnown = KnownExtensions{{ID: MustUnderstand, Version: 1, ValidData: []byte("ok")}}
|
||||
|
||||
func set(b []byte, i int, v byte) []byte {
|
||||
c := bytes.Clone(b)
|
||||
c[i] = v
|
||||
return c
|
||||
}
|
||||
|
||||
func xorLast(b []byte) []byte {
|
||||
c := bytes.Clone(b)
|
||||
c[len(c)-1] ^= 0x01
|
||||
return c
|
||||
}
|
||||
|
||||
// built returns a capsule made by Build and the options that open it.
|
||||
func built(b Build) (*MutationInput, error) {
|
||||
if b.Plaintext == nil {
|
||||
b.Plaintext = []byte("malicious creator")
|
||||
}
|
||||
out, err := b.Make()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r := Release(1000)
|
||||
return &MutationInput{DKC: out.DKC, Release: &r, Now: Genesis().AddDate(1, 0, 0)}, nil
|
||||
}
|
||||
|
||||
func (e *MutationEnv) headerWithDateKey(dk string) (*MutationInput, error) {
|
||||
to := e.TimeOnly
|
||||
h, err := capsule.DecodeHeader(to.Parts.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
raw, err := RawHeader(h.CapsuleID, dk, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return to.input(Reframe(to.Parts.Prelude, raw, to.Parts.Sealed, to.Parts.Payload)), nil
|
||||
}
|
||||
|
||||
// headerWithExtensions replaces the noncritical_extensions of the time_only
|
||||
// fixture header with exts, encoded as given.
|
||||
func (e *MutationEnv) headerWithExtensions(exts []any) (*MutationInput, error) {
|
||||
to := e.TimeOnly
|
||||
m, err := cbortest.UnmarshalMap(to.Parts.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m[6] = exts
|
||||
h, err := cbortest.Marshal(m)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return to.input(Reframe(to.Parts.Prelude, h, to.Parts.Sealed, to.Parts.Payload)), nil
|
||||
}
|
||||
|
||||
// policyByte returns the offset of the access_policy value in a header
|
||||
// without extensions, whose last entry is 0x04 <value>.
|
||||
func policyByte(header []byte) (int, error) {
|
||||
i := len(header) - 2
|
||||
if header[i] != 0x04 {
|
||||
return 0, fmt.Errorf("testkit: unexpected header layout %x", header[i:])
|
||||
}
|
||||
return i + 1, nil
|
||||
}
|
||||
|
||||
func (f *LoadedFixture) withPolicy(policy byte) (*MutationInput, error) {
|
||||
i, err := policyByte(f.Parts.Header)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h := set(f.Parts.Header, i, policy)
|
||||
return f.input(Join(f.Parts.Prelude, h, f.Parts.Sealed, f.Parts.Payload)), nil
|
||||
}
|
||||
|
||||
func mustUnderstand(data []byte) extension.Extension {
|
||||
e, err := extension.New(MustUnderstand, 1, data)
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return e
|
||||
}
|
||||
|
||||
func b64(s string) string { return base64.RawURLEncoding.EncodeToString([]byte(s)) }
|
||||
|
||||
// Mutations returns the mutation corpus: the twenty-three mutations of spec
|
||||
// §64 followed by further cases.
|
||||
func Mutations() []Mutation {
|
||||
ok := func(in *MutationInput) (*MutationInput, error) { return in, nil }
|
||||
return []Mutation{
|
||||
// ---- The twenty-three mutations of spec §64 -------------------------
|
||||
{Name: "PUBLIC_HEADER_A + SEALED_CONTROL_B", Spec: true, Want: datekeys.ErrHeaderBinding, Step: 15, Network: true, Random: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
b, err := e.Sibling()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := e.TimeOnly
|
||||
return to.input(Reframe(to.Parts.Prelude, to.Parts.Header, b.Sealed, b.Payload)), nil
|
||||
}},
|
||||
{Name: "SEALED_CONTROL_A + PAYLOAD_AGE_B", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true, Random: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
b, err := e.Sibling()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
to := e.TimeOnly
|
||||
return to.input(Join(to.Parts.Prelude, to.Parts.Header, to.Parts.Sealed, b.Payload)), nil
|
||||
}},
|
||||
{Name: "DateKey A + release of round B", Spec: true, Want: datekeys.ErrRoundMismatch, Step: 10, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeOnly.input(e.TimeOnly.DKC)
|
||||
r := Release(1001)
|
||||
in.Release = &r
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "chain hash changed", Spec: true, Want: datekeys.ErrProfileMismatch, Step: 8,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
other := strings.Repeat("ab", 32)
|
||||
return ok(e.TimeOnly.input(bytes.Replace(e.TimeOnly.DKC, []byte(profile.Quicknet().ChainHashHex()), []byte(other), 1)))
|
||||
}},
|
||||
{Name: "version changed", Spec: true, Want: datekeys.ErrUnsupportedVersion, Step: 2,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 4, 2))) }},
|
||||
{Name: "flags != 0", Spec: true, Want: datekeys.ErrInvalidFlags, Step: 2,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 5, 0x80)))
|
||||
}},
|
||||
{Name: "reserved != 0", Spec: true, Want: datekeys.ErrInvalidFlags, Step: 2,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 7, 1))) }},
|
||||
{Name: "payload truncated", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return ok(e.TimeOnly.input(e.TimeOnly.DKC[:len(e.TimeOnly.DKC)-1]))
|
||||
}},
|
||||
{Name: "payload age modified", Spec: true, Want: datekeys.ErrIntegrity, Step: 17, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(xorLast(e.TimeOnly.DKC))) }},
|
||||
{Name: "control modified", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
p := e.TimeOnly.Parts
|
||||
return ok(e.TimeOnly.input(Join(p.Prelude, p.Header, xorLast(p.Sealed), p.Payload)))
|
||||
}},
|
||||
{Name: "non-canonical dk1_ JSON", Spec: true, Want: datekeys.ErrDateKeyNonCanonical, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return e.headerWithDateKey(datekey.Prefix + b64(`{"version":1, "network":"datekeys:quicknet:v1", "round":1000}`))
|
||||
}},
|
||||
{Name: "unknown profile", Spec: true, Want: datekeys.ErrUnknownProfile, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return e.headerWithDateKey(datekey.DateKey{ProfileID: "datekeys:evmnet:v1", Round: 1000}.Compact())
|
||||
}},
|
||||
{Name: "release of another round", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeOnly.input(e.TimeOnly.DKC)
|
||||
in.Release = &provider.Release{Round: 1000, Signature: Release(1001).Signature}
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "access_policy=time_only with time_and_key structure", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return e.TimeAndKey.withPolicy(0) }},
|
||||
{Name: "access_policy=time_and_key with time_only structure", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in, err := e.TimeOnly.withPolicy(1)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Identities = []string{Stranger().String()}
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "extra stanza in OUTER_TIME_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 5, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{EditOuter: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
extra, _, _ := X25519Stanza(fk)
|
||||
return append(s, extra)
|
||||
}})
|
||||
}},
|
||||
{Name: "extra stanza in PAYLOAD_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 6, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{EditPayload: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
extra, _, _ := X25519Stanza(fk)
|
||||
return append(s, extra)
|
||||
}})
|
||||
}},
|
||||
{Name: "non-X25519 stanza in INNER_ACCESS_AGE", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
|
||||
AccessRecipients: []age.Recipient{Stranger().Recipient()},
|
||||
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
return append(s, &age.Stanza{Type: "scrypt", Args: []string{"c2FsdHNhbHRzYWx0c2FsdA", "10"}, Body: make([]byte, 32)})
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Identities = []string{Stranger().String()}
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "tlock stanza round differs from DateKey.round", Spec: true, Want: datekeys.ErrRoundMismatch, Step: 8, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s }})
|
||||
}},
|
||||
{Name: "tlock stanza chain hash differs from the pinned profile", Spec: true, Want: datekeys.ErrProfileMismatch, Step: 8, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{EditOuter: func(_ []byte, s []*age.Stanza) []*age.Stanza {
|
||||
s[0].Args[1] = "dbd506d6ef76e5f386f41c651dcb808c5bcbd75471cc4eafa3f4df7ad4e4c493" // drand default chain
|
||||
return s
|
||||
}})
|
||||
}},
|
||||
{Name: "extension data of a type other than bstr", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
// The v0.8.1 form of the time_only_extensions header: data as a text string.
|
||||
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: "public label"}})
|
||||
}},
|
||||
{Name: "empty extension data (h'')", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: []byte{}}})
|
||||
}},
|
||||
{Name: "65 extensions in one array", Spec: true, Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
exts := make([]any, 65)
|
||||
for i := range exts {
|
||||
exts[i] = map[uint64]any{0: fmt.Sprintf("org.example.%03d", i), 1: uint64(1)}
|
||||
}
|
||||
return e.headerWithExtensions(exts)
|
||||
}},
|
||||
|
||||
// ---- Further cases ----------------------------------------------------
|
||||
{Name: "magic", Want: datekeys.ErrInvalidMagic, Step: 1,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, 0, 'X'))) }},
|
||||
{Name: "a .dkk offered as a .dkc", Want: datekeys.ErrInvalidMagic, Step: 1,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return ok(e.TimeOnly.input(append([]byte("DKK1"), e.TimeOnly.DKC[4:]...)))
|
||||
}},
|
||||
{Name: "empty file", Want: datekeys.ErrInvalidMagic, Step: 1,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input([]byte{})) }},
|
||||
{Name: "truncated prelude", Want: datekeys.ErrIntegrity, Step: 1,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeOnly.input(e.TimeOnly.DKC[:10])) }},
|
||||
{Name: "PUBLIC_HEADER_LEN above the limit", Want: datekeys.ErrIntegrity, Step: 2,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
c := bytes.Clone(e.TimeOnly.DKC)
|
||||
binary.BigEndian.PutUint32(c[8:12], capsule.MaxPublicHeaderLen+1)
|
||||
return ok(e.TimeOnly.input(c))
|
||||
}},
|
||||
{Name: "SEALED_CONTROL_LEN above the limit", Want: datekeys.ErrIntegrity, Step: 2,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
c := bytes.Clone(e.TimeOnly.DKC)
|
||||
binary.BigEndian.PutUint32(c[12:16], capsule.MaxSealedControlLen+1)
|
||||
return ok(e.TimeOnly.input(c))
|
||||
}},
|
||||
{Name: "truncated inside SEALED_CONTROL", Want: datekeys.ErrIntegrity, Step: 5,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
p := e.TimeOnly.Parts
|
||||
return ok(e.TimeOnly.input(e.TimeOnly.DKC[:len(p.Prelude)+len(p.Header)+10]))
|
||||
}},
|
||||
{Name: "header schema version changed", Want: datekeys.ErrUnsupportedVersion, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
// a5 00 6a "datekeycap" 01 <version>
|
||||
return ok(e.TimeOnly.input(set(e.TimeOnly.DKC, capsule.PreludeSize+14, 2)))
|
||||
}},
|
||||
{Name: "unknown key in PUBLIC_HEADER", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
p := e.TimeOnly.Parts
|
||||
h := append(bytes.Clone(p.Header), 0x07, 0x00)
|
||||
h[0]++ // one more map entry
|
||||
return ok(e.TimeOnly.input(Reframe(p.Prelude, h, p.Sealed, p.Payload)))
|
||||
}},
|
||||
{Name: "undefined access_policy", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return e.TimeOnly.withPolicy(2) }},
|
||||
// 256 and 257 end in the byte of a V1 policy: a check made after a
|
||||
// narrowing to one byte would read them as time_only and time_and_key.
|
||||
{Name: "access_policy 256 with a consistent header_binding", Want: datekeys.ErrNonCanonicalCBOR, Step: 4, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) { return built(Build{RawPolicy: 256}) }},
|
||||
{Name: "access_policy 257 with a consistent header_binding", Want: datekeys.ErrNonCanonicalCBOR, Step: 4, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) { return built(Build{RawPolicy: 257}) }},
|
||||
{Name: "unknown critical PUBLIC_HEADER extension", Want: datekeys.ErrExtensionCriticalUnknown, Step: 4, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{HeaderCritical: []extension.Extension{{ID: MustUnderstand, Version: 1}}})
|
||||
}},
|
||||
{Name: "unknown critical CONTROL_CBOR extension", Want: datekeys.ErrExtensionCriticalUnknown, Step: 14, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{ControlCritical: []extension.Extension{{ID: MustUnderstand, Version: 1}}})
|
||||
}},
|
||||
{Name: "known critical PUBLIC_HEADER extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 4, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
in, err := built(Build{HeaderCritical: []extension.Extension{mustUnderstand([]byte("ko"))}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Extensions = mustUnderstandKnown
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "known critical CONTROL_CBOR extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 14, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
in, err := built(Build{ControlCritical: []extension.Extension{mustUnderstand([]byte("ko"))}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Extensions = mustUnderstandKnown
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "known critical .dkk extension with invalid data", Want: datekeys.ErrExtensionDataInvalid, Step: 9,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
tk := e.TimeAndKey
|
||||
k, err := accesskey.Decode(bytes.NewReader(tk.DKK))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer k.Wipe()
|
||||
k.Critical = []extension.Extension{mustUnderstand([]byte("ko"))}
|
||||
var b bytes.Buffer
|
||||
if err := accesskey.Encode(&b, k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := tk.input(tk.DKC)
|
||||
in.DKK, in.Extensions = b.Bytes(), mustUnderstandKnown
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "extension_version above 2^32-1", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1) << 32}})
|
||||
}},
|
||||
{Name: "null extension data", Want: datekeys.ErrNonCanonicalCBOR, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return e.headerWithExtensions([]any{map[uint64]any{0: "org.example.label", 1: uint64(1), 2: nil}})
|
||||
}},
|
||||
{Name: "time_and_key without credentials", Want: datekeys.ErrAccessRequired, Step: 9,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
|
||||
in.DKK = nil
|
||||
return in, nil
|
||||
}},
|
||||
{Name: ".dkk of another capsule", Want: datekeys.ErrAccessInvalid, Step: 9,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
other, err := LoadFixture(e.Dir, "time_and_key_recipients")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
|
||||
in.DKK = other.DKK
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "capsule_digest of the .dkk does not match", Want: datekeys.ErrAccessInvalid, Step: 9,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) { return ok(e.TimeAndKey.input(xorLast(e.TimeAndKey.DKC))) }},
|
||||
{Name: "identity that is not a recipient", Want: datekeys.ErrAccessInvalid, Step: 13, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeAndKey.input(e.TimeAndKey.DKC)
|
||||
in.DKK, in.Identities = nil, []string{Stranger().String()}
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "round not reached yet", Want: datekeys.ErrReleaseUnavailable, Step: 9,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeOnly.input(e.TimeOnly.DKC)
|
||||
in.Now = e.TimeOnly.Unlock.Add(-1)
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "release source unavailable", Want: datekeys.ErrReleaseUnavailable, Step: 9, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeOnly.input(e.TimeOnly.DKC)
|
||||
in.Release = nil
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "trailing data after PAYLOAD_AGE", Want: datekeys.ErrIntegrity, Step: 17, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return ok(e.TimeOnly.input(append(bytes.Clone(e.TimeOnly.DKC), 0)))
|
||||
}},
|
||||
{Name: "payload stanza body modified", Want: datekeys.ErrIntegrity, Step: 17, Network: true,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
p := e.TimeOnly.Parts
|
||||
n, err := HeaderLen(p.Payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Flip a byte of the wrapped file key: the last body line before "---".
|
||||
i := bytes.LastIndex(p.Payload[:n], []byte("\n---")) - 10
|
||||
c := byte('A')
|
||||
if p.Payload[i] == 'A' {
|
||||
c = 'B'
|
||||
}
|
||||
return ok(e.TimeOnly.input(Join(p.Prelude, p.Header, p.Sealed, set(p.Payload, i, c))))
|
||||
}},
|
||||
{Name: "tlock round edited by a third party", Want: datekeys.ErrRoundMismatch, Step: 8,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
return ok(e.TimeOnly.input(bytes.Replace(e.TimeOnly.DKC, []byte("-> tlock 1000 "), []byte("-> tlock 1001 "), 1)))
|
||||
}},
|
||||
{Name: "empty registry", Want: datekeys.ErrUnknownProfile, Step: 4,
|
||||
Make: func(e *MutationEnv) (*MutationInput, error) {
|
||||
in := e.TimeOnly.input(e.TimeOnly.DKC)
|
||||
in.EmptyRegistry = true
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "time_only declared, time_and_key built by the creator", Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
return built(Build{Declared: capsule.TimeOnly, Structure: capsule.TimeAndKey, AccessRecipients: []age.Recipient{Stranger().Recipient()}})
|
||||
}},
|
||||
{Name: "time_and_key declared, time_only built by the creator", Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeOnly})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Identities = []string{Stranger().String()}
|
||||
return in, nil
|
||||
}},
|
||||
{Name: "two INNER_ACCESS_AGE stanzas for one recipient", Want: datekeys.ErrPolicyStructureMismatch, Step: 13, Network: true, Random: true,
|
||||
Make: func(*MutationEnv) (*MutationInput, error) {
|
||||
stranger := Stranger()
|
||||
in, err := built(Build{Declared: capsule.TimeAndKey, Structure: capsule.TimeAndKey,
|
||||
AccessRecipients: []age.Recipient{stranger.Recipient()},
|
||||
EditInner: func(fk []byte, s []*age.Stanza) []*age.Stanza {
|
||||
again, _ := stranger.Recipient().Wrap(fk)
|
||||
return append(s, again[0])
|
||||
}})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Identities = []string{stranger.String()}
|
||||
return in, nil
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
// Check opens in and compares the verdict with the mutation's expectation.
|
||||
func (m Mutation) Check(in *MutationInput) error {
|
||||
v, err := in.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.checkVerdict(v)
|
||||
}
|
||||
|
||||
func (m Mutation) checkVerdict(v Verdict) error {
|
||||
switch {
|
||||
case v.Err == nil:
|
||||
return errors.New("mutation accepted")
|
||||
case !errors.Is(v.Err, m.Want):
|
||||
return fmt.Errorf("got %v, want %v", v.Err, m.Want)
|
||||
case v.Step != m.Step:
|
||||
return fmt.Errorf("failed at step %d, want step %d: %v", v.Step, m.Step, v.Err)
|
||||
case !m.Network && v.Calls != 0:
|
||||
return fmt.Errorf("an invalid capsule caused %d release requests", v.Calls)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Exported corpus: testdata/vectors/mutations.json
|
||||
|
||||
// MutationFile is testdata/vectors/mutations.json.
|
||||
type MutationFile struct {
|
||||
Spec string `json:"spec"`
|
||||
Description string `json:"description"`
|
||||
Cases []MutationCase `json:"cases"`
|
||||
}
|
||||
|
||||
// MutationCase is one mutation as frozen data.
|
||||
type MutationCase struct {
|
||||
Name string `json:"name"`
|
||||
// Spec is true for the twenty-three mutations of spec §64.
|
||||
Spec bool `json:"spec"`
|
||||
DKC EditedFile `json:"dkc"`
|
||||
// DKK is the hex of the .dkk offered, absent for none.
|
||||
DKK string `json:"dkk,omitempty"`
|
||||
Identities []string `json:"identities,omitempty"`
|
||||
// Release is the only release the source serves, for any requested
|
||||
// round; null when no release is available.
|
||||
Release *FixtureRelease `json:"release"`
|
||||
// Now is the reader's clock, RFC 3339.
|
||||
Now string `json:"now"`
|
||||
// Registry is "default" (the Quicknet profile pinned) or "empty".
|
||||
Registry string `json:"registry"`
|
||||
Extensions []KnownExtensionRecord `json:"extensions,omitempty"`
|
||||
// Network reports whether a release may be requested before the
|
||||
// failure; when false the reader must fail without any request.
|
||||
Network bool `json:"network"`
|
||||
// Frozen reports that the capsule was built once with age randomness;
|
||||
// its bytes are kept and never regenerated.
|
||||
Frozen bool `json:"frozen"`
|
||||
Error string `json:"error"`
|
||||
Step int `json:"step"`
|
||||
}
|
||||
|
||||
// EditedFile is a file given as edits of a base fixture.
|
||||
type EditedFile struct {
|
||||
// Base is the file name of an official fixture in testdata/fixtures, or
|
||||
// absent for the empty file.
|
||||
Base string `json:"base,omitempty"`
|
||||
Edits []Edit `json:"edits"`
|
||||
}
|
||||
|
||||
// KnownExtensionRecord is a KnownExtension in JSON.
|
||||
type KnownExtensionRecord struct {
|
||||
ID string `json:"id"`
|
||||
Version uint64 `json:"version"`
|
||||
ValidData string `json:"valid_data"`
|
||||
}
|
||||
|
||||
func (f EditedFile) bytes(dir string) ([]byte, error) {
|
||||
var base []byte
|
||||
if f.Base != "" {
|
||||
var err error
|
||||
if base, err = os.ReadFile(filepath.Join(dir, f.Base)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return ApplyEdits(base, f.Edits)
|
||||
}
|
||||
|
||||
// Input rebuilds the input of the case with the fixtures of dir.
|
||||
func (c *MutationCase) Input(dir string) (*MutationInput, error) {
|
||||
dkc, err := c.DKC.bytes(dir)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in := &MutationInput{Base: c.DKC.Base, DKC: dkc, Identities: c.Identities, EmptyRegistry: c.Registry == "empty"}
|
||||
if c.Registry != "default" && c.Registry != "empty" {
|
||||
return nil, fmt.Errorf("testkit: unknown registry %q", c.Registry)
|
||||
}
|
||||
if c.DKK != "" {
|
||||
if in.DKK, err = hex.DecodeString(c.DKK); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if c.Release != nil {
|
||||
sig, err := hex.DecodeString(c.Release.Signature)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Release = &provider.Release{Round: c.Release.Round, Signature: sig}
|
||||
}
|
||||
if in.Now, err = time.Parse(time.RFC3339Nano, c.Now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, x := range c.Extensions {
|
||||
data, err := hex.DecodeString(x.ValidData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
in.Extensions = append(in.Extensions, KnownExtension{ID: x.ID, Version: x.Version, ValidData: data})
|
||||
}
|
||||
return in, nil
|
||||
}
|
||||
|
||||
// Check opens the input of the case and compares the verdict with the
|
||||
// recorded one.
|
||||
func (c *MutationCase) Check(dir string) error {
|
||||
in, err := c.Input(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v, err := in.Open()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if got := Result(v.Err); got != c.Error || v.Step != c.Step {
|
||||
return fmt.Errorf("got %s at step %d, want %s at step %d (%v)", got, v.Step, c.Error, c.Step, v.Err)
|
||||
}
|
||||
if !c.Network && v.Calls != 0 {
|
||||
return fmt.Errorf("an invalid capsule caused %d release requests", v.Calls)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m Mutation) record(in *MutationInput, dir string, v Verdict) (MutationCase, error) {
|
||||
c := MutationCase{
|
||||
Name: m.Name, Spec: m.Spec, Identities: in.Identities, Now: in.Now.UTC().Format(time.RFC3339Nano),
|
||||
Registry: "default", Network: m.Network, Frozen: m.Random, Error: Result(v.Err), Step: v.Step,
|
||||
}
|
||||
if in.EmptyRegistry {
|
||||
c.Registry = "empty"
|
||||
}
|
||||
c.DKC.Edits = Splice(nil, in.DKC)
|
||||
if in.Base != "" && !m.Random {
|
||||
base, err := os.ReadFile(filepath.Join(dir, in.Base))
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
c.DKC = EditedFile{Base: in.Base, Edits: Splice(base, in.DKC)}
|
||||
}
|
||||
if c.DKC.Edits == nil {
|
||||
c.DKC.Edits = []Edit{}
|
||||
}
|
||||
if in.DKK != nil {
|
||||
c.DKK = hex.EncodeToString(in.DKK)
|
||||
}
|
||||
if in.Release != nil {
|
||||
c.Release = &FixtureRelease{Round: in.Release.Round, Signature: hex.EncodeToString(in.Release.Signature)}
|
||||
}
|
||||
for _, x := range in.Extensions {
|
||||
c.Extensions = append(c.Extensions, KnownExtensionRecord{ID: x.ID, Version: x.Version, ValidData: hex.EncodeToString(x.ValidData)})
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// MutationCorpus computes testdata/vectors/mutations.json from the fixtures
|
||||
// of dir. The capsules of the Random mutations are taken from frozen, the
|
||||
// file as committed, when it records them, and built afresh otherwise. Every
|
||||
// case is opened, and the file records the verdict; a verdict other than the
|
||||
// one the mutation is written for is an error.
|
||||
func MutationCorpus(dir string, frozen *MutationFile) (MutationFile, error) {
|
||||
f := MutationFile{
|
||||
Spec: SpecVersion,
|
||||
Description: "Mutation corpus of spec §64 and further cases of capsule.TestMutationCorpus, generated by the reference implementation: " +
|
||||
"each case is a .dkc and what the reader is given, with the normative error and the step of spec §63 at which capsule.Open fails. See testdata/README.md.",
|
||||
}
|
||||
env, err := NewMutationEnv(dir)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
old := map[string]*MutationCase{}
|
||||
if frozen != nil {
|
||||
for i := range frozen.Cases {
|
||||
old[frozen.Cases[i].Name] = &frozen.Cases[i]
|
||||
}
|
||||
}
|
||||
for _, m := range Mutations() {
|
||||
var in *MutationInput
|
||||
if c := old[m.Name]; m.Random && c != nil && c.Frozen {
|
||||
in, err = c.Input(dir)
|
||||
} else {
|
||||
in, err = m.Make(env)
|
||||
}
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("mutation %q: %w", m.Name, err)
|
||||
}
|
||||
v, err := in.Open()
|
||||
if err == nil {
|
||||
err = m.checkVerdict(v)
|
||||
}
|
||||
if err != nil {
|
||||
return f, fmt.Errorf("mutation %q: %w", m.Name, err)
|
||||
}
|
||||
c, err := m.record(in, dir, v)
|
||||
if err != nil {
|
||||
return f, err
|
||||
}
|
||||
f.Cases = append(f.Cases, c)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
@ -0,0 +1,108 @@
|
||||
package testkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"g.activething.com/go/DateKeys/internal/testkit"
|
||||
)
|
||||
|
||||
func TestEdits(t *testing.T) {
|
||||
base := []byte("0123456789")
|
||||
for _, tc := range []struct {
|
||||
out string
|
||||
want string // JSON of Splice(base, out)
|
||||
}{
|
||||
{"0123456789", `[]`},
|
||||
{"01X3456789", `[[2,1,"58"]]`},
|
||||
{"012", `[[3,7,""]]`},
|
||||
{"", `[[0,10,""]]`},
|
||||
{"01234567890", `[[10,0,"30"]]`},
|
||||
{"0123XY456789", `[[4,0,"5859"]]`},
|
||||
} {
|
||||
edits := testkit.Splice(base, []byte(tc.out))
|
||||
if edits == nil {
|
||||
edits = []testkit.Edit{}
|
||||
}
|
||||
b, err := json.Marshal(edits)
|
||||
if err != nil || string(b) != tc.want {
|
||||
t.Errorf("Splice(%q) = %s, %v; want %s", tc.out, b, err, tc.want)
|
||||
}
|
||||
var back []testkit.Edit
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out, err := testkit.ApplyEdits(base, back)
|
||||
if err != nil || string(out) != tc.out {
|
||||
t.Errorf("ApplyEdits(%s) = %q, %v; want %q", b, out, err, tc.out)
|
||||
}
|
||||
}
|
||||
// Several edits refer to offsets of the base.
|
||||
out, err := testkit.ApplyEdits(base, []testkit.Edit{{At: 1, Delete: 1, Insert: []byte("ab")}, {At: 5, Delete: 2}, {At: 10, Insert: []byte("!")}})
|
||||
if err != nil || string(out) != "0ab234789!" {
|
||||
t.Fatalf("got %q, %v", out, err)
|
||||
}
|
||||
for _, bad := range [][]testkit.Edit{
|
||||
{{At: 5, Delete: 1}, {At: 2, Delete: 1}}, // out of order
|
||||
{{At: 2, Delete: 3}, {At: 4, Delete: 1}}, // overlapping
|
||||
{{At: 9, Delete: 2}}, // beyond the base
|
||||
{{At: -1}},
|
||||
} {
|
||||
if _, err := testkit.ApplyEdits(base, bad); err == nil {
|
||||
t.Errorf("ApplyEdits accepted %v", bad)
|
||||
}
|
||||
}
|
||||
for _, bad := range []string{`[1,2]`, `[1,2,"zz"]`, `["1",2,""]`, `{}`} {
|
||||
var e testkit.Edit
|
||||
if err := json.Unmarshal([]byte(bad), &e); err == nil {
|
||||
t.Errorf("Edit accepted %s", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The schema vectors of testdata/vectors/cbor.json replay: the decoder of each
|
||||
// schema gives exactly the recorded result.
|
||||
func TestSchemaVectors(t *testing.T) {
|
||||
var f testkit.CBORVectorFile
|
||||
if err := testkit.ReadJSON("../../testdata/vectors/cbor.json", &f); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
blocks := map[string]int{}
|
||||
for _, v := range f.Schemas {
|
||||
b, err := hex.DecodeString(v.Hex)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := testkit.Result(testkit.DecodeSchema(v.Schema, b)); got != v.Result {
|
||||
t.Errorf("%s %q: got %s, want %s", v.Block, v.Name, got, v.Result)
|
||||
}
|
||||
blocks[v.Block]++
|
||||
}
|
||||
for _, b := range []string{testkit.SchemaProfile, testkit.SchemaHeader, testkit.SchemaControl, testkit.SchemaDKKBody, "verification_metadata", "extension"} {
|
||||
if blocks[b] < 5 {
|
||||
t.Errorf("block %s has %d vectors", b, blocks[b])
|
||||
}
|
||||
}
|
||||
if err := testkit.DecodeSchema("nope", nil); err == nil {
|
||||
t.Error("unknown schema accepted")
|
||||
}
|
||||
}
|
||||
|
||||
// The committed vector files are what the generators compute now.
|
||||
func TestVectorFilesAreCurrent(t *testing.T) {
|
||||
want, err := testkit.CBORVectors()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got testkit.CBORVectorFile
|
||||
if err := testkit.ReadJSON("../../testdata/vectors/cbor.json", &got); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a, _ := json.Marshal(want)
|
||||
b, _ := json.Marshal(got)
|
||||
if !bytes.Equal(a, b) {
|
||||
t.Error("testdata/vectors/cbor.json is stale: run go run ./internal/testkit/genfixtures -out testdata")
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
package profile_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
datekeys "g.activething.com/go/DateKeys"
|
||||
"g.activething.com/go/DateKeys/internal/cbortest"
|
||||
"g.activething.com/go/DateKeys/profile"
|
||||
)
|
||||
|
||||
// Spec §11, §58: the Provider Profile is the closed map of keys 0 to 10, all
|
||||
// required, in ascending order.
|
||||
func TestDecodeStructure(t *testing.T) {
|
||||
b, _ := profile.Quicknet().CanonicalCBOR()
|
||||
m, err := cbortest.UnmarshalMap(b)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
encode := func(v any) []byte {
|
||||
out, err := cbortest.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
with := func(edit func(m map[uint64]any)) []byte {
|
||||
c := maps.Clone(m)
|
||||
edit(c)
|
||||
return encode(c)
|
||||
}
|
||||
for name, in := range map[string][]byte{
|
||||
"missing scheme": with(func(c map[uint64]any) { delete(c, 9) }),
|
||||
"key 11 for key 10": with(func(c map[uint64]any) { c[11] = c[10]; delete(c, 10) }),
|
||||
"twelve entries": with(func(c map[uint64]any) { c[11] = uint64(0) }),
|
||||
"chain hash of 31": with(func(c map[uint64]any) { c[5] = make([]byte, 31) }),
|
||||
"genesis seed of 33": with(func(c map[uint64]any) { c[10] = make([]byte, 33) }),
|
||||
"public key as text": with(func(c map[uint64]any) { c[6] = "key" }),
|
||||
"profile_id as bytes": with(func(c map[uint64]any) { c[2] = []byte("datekeys:quicknet:v1") }),
|
||||
"text key after key 1": encode(cbortest.Pairs{uint64(0), profile.TypeTag, uint64(1), uint64(1), "2", "datekeys:quicknet:v1"}),
|
||||
"null period": with(func(c map[uint64]any) { c[7] = nil }),
|
||||
"invalid UTF-8 network": with(func(c map[uint64]any) { c[4] = "quick\xffnet" }),
|
||||
} {
|
||||
if _, err := profile.Decode(in); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Errorf("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
// The encoder refuses a name that is not valid UTF-8, with the same code:
|
||||
// it cannot be written as a CBOR text string.
|
||||
p := profile.Quicknet()
|
||||
p.Network = "quick\xffnet"
|
||||
if _, err := p.CanonicalCBOR(); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
||||
t.Fatalf("invalid UTF-8 encoded: %v", err)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
{
|
||||
"file": "empty_payload.dkc",
|
||||
"capsule_id": "ab10174561a9a19a6d9dc9ab1ef59c66",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"round": 1001,
|
||||
"unlock_at": "2023-08-23T15:59:27Z",
|
||||
"access_policy": "time_only",
|
||||
"valid": true,
|
||||
"checks": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true,
|
||||
"detail": "magic DKC1"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true,
|
||||
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=446"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true,
|
||||
"detail": "121 bytes"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true,
|
||||
"detail": "capsule_id=ab10174561a9a19a6d9dc9ab1ef59c66 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0 policy=time_only profile=datekeys:quicknet:v1"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true,
|
||||
"detail": "one tlock stanza"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true,
|
||||
"detail": "one X25519 stanza"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true,
|
||||
"detail": "round 1001, unlock at 2023-08-23T15:59:27Z"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true,
|
||||
"detail": "round 1001, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
{
|
||||
"file": "time_and_key_portable.dkc",
|
||||
"capsule_id": "448e134a13457c319cab7fceaf7ffa1f",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"round": 1000,
|
||||
"unlock_at": "2023-08-23T15:59:24Z",
|
||||
"access_policy": "time_and_key",
|
||||
"valid": true,
|
||||
"checks": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true,
|
||||
"detail": "magic DKC1"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true,
|
||||
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=646"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true,
|
||||
"detail": "121 bytes"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true,
|
||||
"detail": "capsule_id=448e134a13457c319cab7fceaf7ffa1f datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_and_key profile=datekeys:quicknet:v1"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true,
|
||||
"detail": "one tlock stanza"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true,
|
||||
"detail": "one X25519 stanza"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true,
|
||||
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true,
|
||||
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
{
|
||||
"file": "time_and_key_recipients.dkc",
|
||||
"capsule_id": "c75dfc8e9c576d1369910664df93693a",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"round": 1001,
|
||||
"unlock_at": "2023-08-23T15:59:27Z",
|
||||
"access_policy": "time_and_key",
|
||||
"valid": true,
|
||||
"checks": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true,
|
||||
"detail": "magic DKC1"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true,
|
||||
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=842"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true,
|
||||
"detail": "121 bytes"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true,
|
||||
"detail": "capsule_id=c75dfc8e9c576d1369910664df93693a datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMX0 policy=time_and_key profile=datekeys:quicknet:v1"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true,
|
||||
"detail": "one tlock stanza"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true,
|
||||
"detail": "one X25519 stanza"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true,
|
||||
"detail": "round 1001, unlock at 2023-08-23T15:59:27Z"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true,
|
||||
"detail": "round 1001, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
{
|
||||
"file": "time_only.dkc",
|
||||
"capsule_id": "ad4d676812b134ff8a3de263f77018b4",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"round": 1000,
|
||||
"unlock_at": "2023-08-23T15:59:24Z",
|
||||
"access_policy": "time_only",
|
||||
"valid": true,
|
||||
"checks": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true,
|
||||
"detail": "magic DKC1"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true,
|
||||
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=121, SEALED_CONTROL_LEN=446"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true,
|
||||
"detail": "121 bytes"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true,
|
||||
"detail": "capsule_id=ad4d676812b134ff8a3de263f77018b4 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MTAwMH0 policy=time_only profile=datekeys:quicknet:v1"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true,
|
||||
"detail": "one tlock stanza"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true,
|
||||
"detail": "one X25519 stanza"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true,
|
||||
"detail": "round 1000, unlock at 2023-08-23T15:59:24Z"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true,
|
||||
"detail": "round 1000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -0,0 +1,60 @@
|
||||
{
|
||||
"file": "time_only_extensions.dkc",
|
||||
"capsule_id": "4286085c21ca34d1a71e649326a4a0f6",
|
||||
"datekey": "dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0",
|
||||
"profile": "datekeys:quicknet:v1",
|
||||
"round": 2000,
|
||||
"unlock_at": "2023-08-23T16:49:24Z",
|
||||
"access_policy": "time_only",
|
||||
"valid": true,
|
||||
"checks": [
|
||||
{
|
||||
"step": 1,
|
||||
"name": "parse DKC1",
|
||||
"ok": true,
|
||||
"detail": "magic DKC1"
|
||||
},
|
||||
{
|
||||
"step": 2,
|
||||
"name": "prelude",
|
||||
"ok": true,
|
||||
"detail": "DKC1 v1, PUBLIC_HEADER_LEN=159, SEALED_CONTROL_LEN=482"
|
||||
},
|
||||
{
|
||||
"step": 3,
|
||||
"name": "public header",
|
||||
"ok": true,
|
||||
"detail": "159 bytes"
|
||||
},
|
||||
{
|
||||
"step": 4,
|
||||
"name": "header validation",
|
||||
"ok": true,
|
||||
"detail": "capsule_id=4286085c21ca34d1a71e649326a4a0f6 datekey=dk1_eyJ2ZXJzaW9uIjoxLCJuZXR3b3JrIjoiZGF0ZWtleXM6cXVpY2tuZXQ6djEiLCJyb3VuZCI6MjAwMH0 policy=time_only profile=datekeys:quicknet:v1"
|
||||
},
|
||||
{
|
||||
"step": 5,
|
||||
"name": "sealed control structure",
|
||||
"ok": true,
|
||||
"detail": "one tlock stanza"
|
||||
},
|
||||
{
|
||||
"step": 6,
|
||||
"name": "payload structure",
|
||||
"ok": true,
|
||||
"detail": "one X25519 stanza"
|
||||
},
|
||||
{
|
||||
"step": 7,
|
||||
"name": "condition",
|
||||
"ok": true,
|
||||
"detail": "round 2000, unlock at 2023-08-23T16:49:24Z"
|
||||
},
|
||||
{
|
||||
"step": 8,
|
||||
"name": "tlock stanza",
|
||||
"ok": true,
|
||||
"detail": "round 2000, chain 52db9ba70e0cc0f6eaf7803dd07447a1f5477735fd3f661792ba94600c84e971"
|
||||
}
|
||||
]
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in new issue