You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1040 lines
40 KiB
1040 lines
40 KiB
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 thirty-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
|
|
}
|
|
|
|
// TimeFileKey returns FK_TIME, the file key of the OUTER_TIME_AGE of f,
|
|
// which the tlock stanza wraps and the release of f unwraps.
|
|
func (f *LoadedFixture) TimeFileKey() ([]byte, error) {
|
|
h, err := capsule.DecodeHeader(f.Parts.Header)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stanzas, err := agewrap.Stanzas(bytes.NewReader(f.Parts.Sealed))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
id, err := agewrap.NewTimeIdentity(profile.Quicknet(), h.DateKey.Round, f.Published)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return id.Unwrap(stanzas)
|
|
}
|
|
|
|
// WithTlockBody returns f with the body of its tlock stanza replaced by
|
|
// edit(body) and the header MAC of OUTER_TIME_AGE recomputed with FK_TIME.
|
|
// The age header stays authentic, as the creator, or anyone once the round is
|
|
// published, can make it: only the rules of the stanza body can reject the
|
|
// capsule (spec §63 step 11).
|
|
func (f *LoadedFixture) WithTlockBody(edit func(body []byte) ([]byte, error)) (*MutationInput, error) {
|
|
fk, err := f.TimeFileKey()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var editErr error
|
|
sealed, err := RewriteAge(f.Parts.Sealed, fk, func(s []*age.Stanza) []*age.Stanza {
|
|
s[0].Body, editErr = edit(s[0].Body)
|
|
return s
|
|
})
|
|
if err := errors.Join(err, editErr); err != nil {
|
|
return nil, err
|
|
}
|
|
return f.input(Reframe(f.Parts.Prelude, f.Parts.Header, sealed, f.Parts.Payload)), nil
|
|
}
|
|
|
|
// EditU returns a tlock stanza body edit that replaces U, the G2 point that
|
|
// starts a Quicknet body U || V || W (spec §63 step 11), with edit(U).
|
|
func EditU(edit func(u []byte) ([]byte, error)) func(body []byte) ([]byte, error) {
|
|
const uLen = 2 * CoordinateLen
|
|
return func(body []byte) ([]byte, error) {
|
|
u, err := edit(bytes.Clone(body[:uLen]))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return append(u, body[uLen:]...), nil
|
|
}
|
|
}
|
|
|
|
// withSignature sets the release of in to the release of f with its
|
|
// signature replaced by edit(signature).
|
|
func (f *LoadedFixture) withSignature(in *MutationInput, edit func(sig []byte) []byte) *MutationInput {
|
|
in.Release = &provider.Release{Round: f.Published.Round, Signature: edit(bytes.Clone(f.Published.Signature))}
|
|
return in
|
|
}
|
|
|
|
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 thirty-three mutations of spec
|
|
// §64 followed by further cases.
|
|
func Mutations() []Mutation {
|
|
ok := func(in *MutationInput) (*MutationInput, error) { return in, nil }
|
|
return []Mutation{
|
|
// ---- The thirty-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)
|
|
}},
|
|
// Canonical point encodings (spec §12.2). The U mutations keep the
|
|
// header MAC of OUTER_TIME_AGE valid, so that only the rules of the
|
|
// stanza body can reject them.
|
|
{Name: "tlock stanza U re-encoded with c0 + p", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return AddModulus(u, CoordinateLen) }))
|
|
}},
|
|
{Name: "tlock stanza U is the point at infinity", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return Infinity(len(u)), nil }))
|
|
}},
|
|
{Name: "tlock stanza U with the infinity flag and a payload", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return InfinityWithPayload(u), nil }))
|
|
}},
|
|
{Name: "tlock stanza body of 127 bytes", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.WithTlockBody(func(b []byte) ([]byte, error) { return b[:len(b)-1], nil })
|
|
}},
|
|
{Name: "tlock stanza body of 129 bytes", Spec: true, Want: datekeys.ErrIntegrity, Step: 11, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.WithTlockBody(func(b []byte) ([]byte, error) { return append(bytes.Clone(b), 0), nil })
|
|
}},
|
|
// No fixture round has a signature whose x + p fits in 381 bits: the
|
|
// capsule is built for XPlusPRound, and opens with its canonical
|
|
// signature.
|
|
{Name: "release signature re-encoded with x + p", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true, Random: true,
|
|
Make: func(*MutationEnv) (*MutationInput, error) {
|
|
in, err := built(Build{Round: XPlusPRound})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := Release(XPlusPRound)
|
|
if r.Signature, err = AddModulus(r.Signature, 0); err != nil {
|
|
return nil, err
|
|
}
|
|
in.Release = &r
|
|
return in, nil
|
|
}},
|
|
{Name: "release signature is the point at infinity", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), func(sig []byte) []byte { return Infinity(len(sig)) }), nil
|
|
}},
|
|
{Name: "release signature with the infinity flag and a payload", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), InfinityWithPayload), nil
|
|
}},
|
|
{Name: "release signature negated", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
return e.TimeOnly.withSignature(e.TimeOnly.input(e.TimeOnly.DKC), Negated), nil
|
|
}},
|
|
// Step 10 comes first: the negated signature is a canonical point
|
|
// that does not verify, so a reader must verify it before it reads U.
|
|
{Name: "negated release signature and U re-encoded with c0 + p", Spec: true, Want: datekeys.ErrReleaseInvalid, Step: 10, Network: true,
|
|
Make: func(e *MutationEnv) (*MutationInput, error) {
|
|
in, err := e.TimeOnly.WithTlockBody(EditU(func(u []byte) ([]byte, error) { return AddModulus(u, CoordinateLen) }))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return e.TimeOnly.withSignature(in, Negated), nil
|
|
}},
|
|
|
|
// ---- 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 thirty-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
|
|
}
|