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.
575 lines
24 KiB
575 lines
24 KiB
package capsule_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"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/internal/testkit"
|
|
"g.activething.com/go/DateKeys/profile"
|
|
)
|
|
|
|
// The tests of this file check the precedence of errors of spec §69.1 and
|
|
// the refinements of v0.8.2 recorded in spec §76: within one object the code
|
|
// of the first failing layer, across objects and steps the order of §63.
|
|
|
|
// failedStep returns the step of the last check, which failed, or 0 when
|
|
// every check passed.
|
|
func failedStep(t *testing.T, checks []capsule.CheckResult, err error) int {
|
|
t.Helper()
|
|
if err == nil {
|
|
return 0
|
|
}
|
|
if len(checks) == 0 || checks[len(checks)-1].OK {
|
|
t.Fatalf("failure without a failed step: %v", err)
|
|
}
|
|
return checks[len(checks)-1].Step
|
|
}
|
|
|
|
// inspectStep runs steps 1 to 8 with the default registry.
|
|
func inspectStep(t *testing.T, dkc []byte, exts extension.Registry) (int, error) {
|
|
t.Helper()
|
|
in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry(), Extensions: exts})
|
|
return failedStep(t, in.Checks, err), err
|
|
}
|
|
|
|
// openStep runs the whole flow, with every release testkit knows, and also
|
|
// returns the number of release requests.
|
|
func openStep(t *testing.T, dkc []byte, o capsule.OpenOptions) (int, int, error) {
|
|
t.Helper()
|
|
src := testkit.NewSource()
|
|
for _, r := range testkit.Rounds {
|
|
src.Releases[r] = testkit.Release(r)
|
|
}
|
|
o.Source = src
|
|
if o.Registry == nil {
|
|
o.Registry = testkit.Registry()
|
|
}
|
|
if o.Now == nil {
|
|
o.Now = testkit.Fixed(testkit.Genesis().AddDate(1, 0, 0))
|
|
}
|
|
out, err := capsule.Open(context.Background(), discardWriter{}, bytes.NewReader(dkc), o)
|
|
if out == nil {
|
|
t.Fatalf("Open returned no result: %v", err)
|
|
}
|
|
return failedStep(t, out.Inspection.Checks, err), src.Calls, err
|
|
}
|
|
|
|
type discardWriter struct{}
|
|
|
|
func (discardWriter) Write(p []byte) (int, error) { return len(p), nil }
|
|
|
|
func expectStep(t *testing.T, name string, step int, err error, code error, wantStep int) {
|
|
t.Helper()
|
|
if !errors.Is(err, code) || step != wantStep {
|
|
t.Errorf("%s: got %v at step %d, want %s at step %d", name, err, step, datekeys.Code(code), wantStep)
|
|
}
|
|
}
|
|
|
|
// parts splits an official fixture and decodes its PUBLIC_HEADER map.
|
|
func parts(t *testing.T, name string) (testkit.Parts, map[uint64]any) {
|
|
t.Helper()
|
|
p, err := testkit.Split(loadFixture(t, name).dkc)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h, err := cbortest.UnmarshalMap(p.Header)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p, h
|
|
}
|
|
|
|
// rewriteHeader replaces the age header at the start of file with one built
|
|
// from edit(stanzas). The MAC is computed with an unrelated key: steps 5, 6
|
|
// and 8 never verify it (spec §27).
|
|
func rewriteHeader(t *testing.T, file []byte, edit func([]*age.Stanza) []*age.Stanza) []byte {
|
|
t.Helper()
|
|
stanzas, err := agewrap.Stanzas(bytes.NewReader(file))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
n, err := testkit.HeaderLen(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hdr, err := testkit.MarshalHeader(edit(stanzas), make([]byte, 16))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return append(hdr, file[n:]...)
|
|
}
|
|
|
|
// noStanzas replaces the age header at the start of file with the intro line
|
|
// and the MAC line only: a header without recipient stanzas, which the age
|
|
// grammar does not allow (header = v1-line 1*stanza end).
|
|
func noStanzas(t *testing.T, file []byte) []byte {
|
|
t.Helper()
|
|
n, err := testkit.HeaderLen(file)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
hdr := "age-encryption.org/v1\n--- " + strings.Repeat("A", 43) + "\n"
|
|
return append([]byte(hdr), file[n:]...)
|
|
}
|
|
|
|
// Spec §69.1, layers 2 to 4 of PUBLIC_HEADER at step 4: the type tag before
|
|
// the version, the version before the CDDL, the CDDL before the fields with
|
|
// codes of their own, and those in ascending key order: the DateKey (key 3)
|
|
// and its pinned profile, then the critical extensions (key 5), where an
|
|
// unknown one comes before invalid data.
|
|
func TestPrecedenceWithinPublicHeader(t *testing.T) {
|
|
p, h := parts(t, "time_only")
|
|
unknown := []any{ext("com.example.unknown", 1)}
|
|
unpinned := datekey.DateKey{ProfileID: "datekeys:other:v1", Round: 1000}.Compact()
|
|
for _, tc := range []struct {
|
|
name string
|
|
edit func(m map[uint64]any)
|
|
want error
|
|
}{
|
|
{"type tag of another schema and version 2", func(m map[uint64]any) { m[0], m[1] = capsule.ControlTypeTag, uint64(2) }, datekeys.ErrNonCanonicalCBOR},
|
|
{"version 2, unknown key and invalid DateKey", func(m map[uint64]any) { m[1], m[3], m[9] = uint64(2), "dk1_x", uint64(0) }, datekeys.ErrUnsupportedVersion},
|
|
{"undefined access_policy and unknown critical extension", func(m map[uint64]any) { m[4], m[5] = uint64(2), unknown }, datekeys.ErrNonCanonicalCBOR},
|
|
{"DateKey as a byte string", func(m map[uint64]any) { m[3] = []byte(h[3].(string)) }, datekeys.ErrNonCanonicalCBOR},
|
|
{"invalid DateKey and unknown critical extension", func(m map[uint64]any) { m[3], m[5] = "dk1_x", unknown }, datekeys.ErrDateKeyInvalid},
|
|
{"unpinned profile and unknown critical extension", func(m map[uint64]any) { m[3], m[5] = unpinned, unknown }, datekeys.ErrUnknownProfile},
|
|
{"invalid data before an unknown extension", func(m map[uint64]any) {
|
|
m[5] = []any{extData("org.example.a", []byte("ko")), ext("zz.unknown", 1)}
|
|
}, datekeys.ErrExtensionCriticalUnknown},
|
|
{"invalid data alone", func(m map[uint64]any) { m[5] = []any{extData("org.example.a", []byte("ko"))} }, datekeys.ErrExtensionDataInvalid},
|
|
} {
|
|
dkc := testkit.Reframe(p.Prelude, marshal(t, with(h, tc.edit)), p.Sealed, p.Payload)
|
|
step, err := inspectStep(t, dkc, strictRegistry{})
|
|
expectStep(t, tc.name, step, err, tc.want, 4)
|
|
}
|
|
// The frame comes first, whatever the object holds (spec §57).
|
|
big := append(marshal(t, with(h, func(m map[uint64]any) { m[1] = uint64(2) })), make([]byte, capsule.MaxPublicHeaderLen)...)
|
|
if _, err := capsule.DecodeHeader(big); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Errorf("PUBLIC_HEADER above 1 MiB with version 2: %v", err)
|
|
}
|
|
}
|
|
|
|
// Spec §63, §69.1: across objects and steps the first step that fails
|
|
// decides, and a check that relates an object to another belongs to its
|
|
// step, not to the object's layer 4.
|
|
func TestPrecedenceAcrossSteps(t *testing.T) {
|
|
p, h := parts(t, "time_only")
|
|
q := profile.Quicknet()
|
|
beyond := datekey.DateKey{ProfileID: profile.QuicknetID, Round: q.MaxRound() + 1}.Compact()
|
|
largest := datekey.DateKey{ProfileID: profile.QuicknetID, Round: datekey.MaxRound}.Compact()
|
|
withDateKey := func(dk string) []byte { return marshal(t, with(h, func(m map[uint64]any) { m[3] = dk })) }
|
|
badPolicy := marshal(t, with(h, func(m map[uint64]any) { m[4] = uint64(2) }))
|
|
twoStanzas := rewriteHeader(t, p.Payload, func(s []*age.Stanza) []*age.Stanza { return append(s, s[0]) })
|
|
otherRound := rewriteHeader(t, p.Sealed, func(s []*age.Stanza) []*age.Stanza { s[0].Args[0] = "1001"; return s })
|
|
for _, tc := range []struct {
|
|
name string
|
|
dkc []byte
|
|
want error
|
|
step int
|
|
}{
|
|
{"header fault and truncated SEALED_CONTROL",
|
|
testkit.Reframe(p.Prelude, badPolicy, p.Sealed, nil)[:capsule.PreludeSize+len(badPolicy)+len(p.Sealed)/2],
|
|
datekeys.ErrNonCanonicalCBOR, 4},
|
|
// Spec §15: the round time of a DateKey is at most
|
|
// 9999-12-31T23:59:59Z, checked against the profile at step 7.
|
|
{"round after 9999-12-31T23:59:59Z", testkit.Reframe(p.Prelude, withDateKey(beyond), p.Sealed, p.Payload), datekeys.ErrDateKeyInvalid, 7},
|
|
{"round 2^53-1 passes step 4 and fails step 7", testkit.Reframe(p.Prelude, withDateKey(largest), p.Sealed, p.Payload), datekeys.ErrDateKeyInvalid, 7},
|
|
{"round beyond the profile and malformed PAYLOAD_AGE", testkit.Reframe(p.Prelude, withDateKey(beyond), p.Sealed, []byte("not age")), datekeys.ErrIntegrity, 6},
|
|
{"tlock round mismatch and two PAYLOAD_AGE stanzas", testkit.Reframe(p.Prelude, p.Header, otherRound, twoStanzas), datekeys.ErrPolicyStructureMismatch, 6},
|
|
{"tlock round mismatch alone", testkit.Reframe(p.Prelude, p.Header, otherRound, p.Payload), datekeys.ErrRoundMismatch, 8},
|
|
} {
|
|
step, err := inspectStep(t, tc.dkc, nil)
|
|
expectStep(t, tc.name, step, err, tc.want, tc.step)
|
|
}
|
|
// The largest round of the profile is still valid (spec §15).
|
|
last := datekey.DateKey{ProfileID: profile.QuicknetID, Round: q.MaxRound()}
|
|
if u, err := datekey.RoundTime(q, last.Round); err != nil || u.Unix() > profile.MaxUnixTime || u.Unix()+3 <= profile.MaxUnixTime {
|
|
t.Fatalf("last round %d at %v: %v", last.Round, u, err)
|
|
}
|
|
|
|
// The examples of spec §69.1 for format 2.
|
|
precedenceFormat2(t)
|
|
}
|
|
|
|
// Spec §22, §57: PUBLIC_HEADER_LEN and SEALED_CONTROL_LEN are at least 1, and
|
|
// PAYLOAD_AGE, which has no length field, is at least a well-formed age
|
|
// header.
|
|
func TestFrameLengthLowerBounds(t *testing.T) {
|
|
p, _ := parts(t, "time_only")
|
|
lengths := func(headerLen, sealedLen uint32, flags byte) []byte {
|
|
pre := bytes.Clone(p.Prelude)
|
|
pre[5] = flags
|
|
binary.BigEndian.PutUint32(pre[8:12], headerLen)
|
|
binary.BigEndian.PutUint32(pre[12:16], sealedLen)
|
|
return testkit.Join(pre, p.Header, p.Sealed, p.Payload)
|
|
}
|
|
for _, tc := range []struct {
|
|
name string
|
|
dkc []byte
|
|
want error
|
|
step int
|
|
}{
|
|
{"PUBLIC_HEADER_LEN 0", lengths(0, uint32(len(p.Sealed)), 0), datekeys.ErrIntegrity, 2},
|
|
{"SEALED_CONTROL_LEN 0", lengths(uint32(len(p.Header)), 0, 0), datekeys.ErrIntegrity, 2},
|
|
{"both 0 and FLAGS 1", lengths(0, 0, 1), datekeys.ErrInvalidFlags, 2},
|
|
{"empty PAYLOAD_AGE", testkit.Join(p.Prelude, p.Header, p.Sealed), datekeys.ErrIntegrity, 6},
|
|
} {
|
|
step, err := inspectStep(t, tc.dkc, nil)
|
|
expectStep(t, tc.name, step, err, tc.want, tc.step)
|
|
}
|
|
}
|
|
|
|
// Spec §28.1: an age file whose header does not follow the age grammar,
|
|
// including a header without stanzas, is malformed: ERR_INTEGRITY at step 5
|
|
// or 6. A well-formed header with the wrong stanzas is
|
|
// ERR_POLICY_STRUCTURE_MISMATCH. The plaintext of OUTER_TIME_AGE is judged
|
|
// against access_policy at step 12 (spec §36).
|
|
func TestMalformedAgeHeaders(t *testing.T) {
|
|
p, _ := parts(t, "time_only")
|
|
for _, tc := range []struct {
|
|
name string
|
|
sealed, payload []byte
|
|
want error
|
|
step int
|
|
}{
|
|
{"OUTER_TIME_AGE without stanzas", noStanzas(t, p.Sealed), p.Payload, datekeys.ErrIntegrity, 5},
|
|
{"PAYLOAD_AGE without stanzas", p.Sealed, noStanzas(t, p.Payload), datekeys.ErrIntegrity, 6},
|
|
{"two spaces between tlock arguments", bytes.Replace(p.Sealed, []byte("-> tlock 1000 "), []byte("-> tlock 1000 "), 1), p.Payload, datekeys.ErrIntegrity, 5},
|
|
{"CR at the end of the intro line", bytes.Replace(p.Sealed, []byte("v1\n"), []byte("v1\r\n"), 1), p.Payload, datekeys.ErrIntegrity, 5},
|
|
{"padding in the MAC line", bytes.Replace(p.Sealed, []byte("\n--- "), []byte("\n--- ="), 1), p.Payload, datekeys.ErrIntegrity, 5},
|
|
{"extra well-formed stanza in OUTER_TIME_AGE", rewriteHeader(t, p.Sealed, func(s []*age.Stanza) []*age.Stanza { return append(s, s[0]) }), p.Payload, datekeys.ErrPolicyStructureMismatch, 5},
|
|
} {
|
|
step, err := inspectStep(t, testkit.Reframe(p.Prelude, p.Header, tc.sealed, tc.payload), nil)
|
|
expectStep(t, tc.name, step, err, tc.want, tc.step)
|
|
}
|
|
|
|
// INNER_ACCESS_AGE is the authenticated plaintext of OUTER_TIME_AGE: a
|
|
// header without stanzas there does not match time_and_key (step 12),
|
|
// and an age file of any form does not match time_only.
|
|
stranger := testkit.Stranger()
|
|
empty := func(fk []byte, s []*age.Stanza) []*age.Stanza { return nil }
|
|
for _, tc := range []struct {
|
|
name string
|
|
declared capsule.Policy
|
|
}{
|
|
{"time_and_key sealing an age header without stanzas", capsule.TimeAndKey},
|
|
{"time_only sealing an age header without stanzas", capsule.TimeOnly},
|
|
} {
|
|
b, err := testkit.Build{Declared: tc.declared, Structure: capsule.TimeAndKey,
|
|
AccessRecipients: []age.Recipient{stranger.Recipient()}, EditInner: empty}.Make()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
step, calls, err := openStep(t, b.DKC, capsule.OpenOptions{Identities: []age.Identity{stranger}})
|
|
expectStep(t, tc.name, step, err, datekeys.ErrPolicyStructureMismatch, 12)
|
|
if calls != 1 {
|
|
t.Errorf("%s: %d release requests", tc.name, calls)
|
|
}
|
|
}
|
|
|
|
// time_only: a plaintext that is not an age file is read as CONTROL_CBOR
|
|
// at step 14.
|
|
rec, err := agewrap.NewTimeRecipient(profile.Quicknet(), 1000)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
sealed, _, err := testkit.Encrypt([]byte{0xff}, rec)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
step, _, err := openStep(t, testkit.Reframe(p.Prelude, p.Header, sealed, p.Payload), capsule.OpenOptions{})
|
|
expectStep(t, "time_only sealing bytes that are not CBOR", step, err, datekeys.ErrNonCanonicalCBOR, 14)
|
|
}
|
|
|
|
// Spec §63 step 8: the tlock stanza has exactly two arguments; the first is
|
|
// the canonical decimal of DateKey.round, the second the pinned chain_hash in
|
|
// lowercase hexadecimal, both compared as strings and never parsed.
|
|
func TestTlockStanzaArgumentComparison(t *testing.T) {
|
|
p, _ := parts(t, "time_only")
|
|
chain := profile.Quicknet().ChainHashHex()
|
|
for _, tc := range []struct {
|
|
name string
|
|
args []string
|
|
want error
|
|
}{
|
|
{"canonical", []string{"1000", chain}, nil},
|
|
{"plus sign", []string{"+1000", chain}, datekeys.ErrRoundMismatch},
|
|
{"leading zero", []string{"01000", chain}, datekeys.ErrRoundMismatch},
|
|
{"exponent", []string{"1e3", chain}, datekeys.ErrRoundMismatch},
|
|
{"uppercase chain hash", []string{"1000", strings.ToUpper(chain)}, datekeys.ErrProfileMismatch},
|
|
{"prefixed chain hash", []string{"1000", "0x" + chain}, datekeys.ErrProfileMismatch},
|
|
{"round and chain hash both wrong", []string{"1001", strings.Repeat("0", 64)}, datekeys.ErrRoundMismatch},
|
|
{"one argument", []string{"1000"}, datekeys.ErrPolicyStructureMismatch},
|
|
{"three arguments, wrong round", []string{"1001", chain, "x"}, datekeys.ErrPolicyStructureMismatch},
|
|
} {
|
|
sealed := rewriteHeader(t, p.Sealed, func(s []*age.Stanza) []*age.Stanza { s[0].Args = tc.args; return s })
|
|
step, err := inspectStep(t, testkit.Reframe(p.Prelude, p.Header, sealed, p.Payload), nil)
|
|
if tc.want == nil {
|
|
if err != nil {
|
|
t.Errorf("%s: %v", tc.name, err)
|
|
}
|
|
continue
|
|
}
|
|
expectStep(t, tc.name, step, err, tc.want, 8)
|
|
}
|
|
}
|
|
|
|
// Spec §63 step 9, §69.1: a .dkk offered for a time_and_key capsule is
|
|
// checked as an object first, access_type and access_material (keys 4 and
|
|
// 5) and then its critical extensions (key 7), and only then bound to the
|
|
// capsule: capsule_id (key 3), then capsule_digest (key 6). No release is
|
|
// requested.
|
|
func TestAccessKeyCheckOrder(t *testing.T) {
|
|
f := loadFixture(t, "time_and_key_portable")
|
|
other := loadFixture(t, "time_and_key_recipients")
|
|
unknown := []extension.Extension{{ID: "com.example.unknown", Version: 1}}
|
|
wrongDigest := &accesskey.Verification{CapsuleDigest: make([]byte, 32)}
|
|
for _, tc := range []struct {
|
|
name string
|
|
edit func(k *accesskey.AccessKey)
|
|
want error
|
|
}{
|
|
{"another capsule and an unknown critical extension", func(k *accesskey.AccessKey) { *k = *other.dkk; k.Critical = unknown }, datekeys.ErrExtensionCriticalUnknown},
|
|
{"unknown critical extension and capsule_digest mismatch", func(k *accesskey.AccessKey) { k.Critical, k.Verification = unknown, wrongDigest }, datekeys.ErrExtensionCriticalUnknown},
|
|
{"unsupported access_type and an unknown critical extension", func(k *accesskey.AccessKey) { k.Type, k.Critical = "mlkem768", unknown }, datekeys.ErrAccessInvalid},
|
|
{"another capsule", func(k *accesskey.AccessKey) { *k = *other.dkk }, datekeys.ErrAccessInvalid},
|
|
{"capsule_digest mismatch", func(k *accesskey.AccessKey) { k.Verification = wrongDigest }, datekeys.ErrAccessInvalid},
|
|
} {
|
|
k := *f.dkk
|
|
tc.edit(&k)
|
|
step, calls, err := openStep(t, f.dkc, capsule.OpenOptions{AccessKey: &k, Now: testkit.Fixed(f.unlock(t))})
|
|
expectStep(t, tc.name, step, err, tc.want, 9)
|
|
if calls != 0 {
|
|
t.Errorf("%s: %d release requests", tc.name, calls)
|
|
}
|
|
}
|
|
|
|
// Step 9.b before 9.c: without a credential the clock is not even
|
|
// consulted, and a nil identity is not a credential.
|
|
early := testkit.Fixed(f.unlock(t).Add(-time.Second))
|
|
for _, tc := range []struct {
|
|
name string
|
|
ids []age.Identity
|
|
}{
|
|
{"no credential and the round time not reached", nil},
|
|
{"a nil identity and the round time not reached", []age.Identity{nil}},
|
|
} {
|
|
step, calls, err := openStep(t, f.dkc, capsule.OpenOptions{Identities: tc.ids, Now: early})
|
|
expectStep(t, tc.name, step, err, datekeys.ErrAccessRequired, 9)
|
|
if calls != 0 {
|
|
t.Errorf("%s: %d release requests", tc.name, calls)
|
|
}
|
|
}
|
|
|
|
// time_only: the credentials play no part (step 9).
|
|
g := loadFixture(t, "time_only")
|
|
k := *f.dkk
|
|
k.Type, k.Critical = "mlkem768", unknown
|
|
o := g.openOptions(t)
|
|
o.AccessKey = &k
|
|
if got, err := open(t, g.dkc, o); err != nil || !bytes.Equal(got, g.plaintext) {
|
|
t.Fatalf("time_only with an invalid .dkk: %v", err)
|
|
}
|
|
}
|
|
|
|
// Spec §63 step 9.a, §69.1: a .dkk handed over still encoded is decoded at
|
|
// step 9.a, so that its errors, framing included, come after those of steps
|
|
// 1 to 8 and never for a time_only capsule, whatever the reader does first.
|
|
func TestAccessKeyFileAtStep9(t *testing.T) {
|
|
f := loadFixture(t, "time_and_key_portable")
|
|
raw, err := os.ReadFile(filepath.Join(fixtureDir, f.AccessKeyFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
notDKK := []byte("not a .dkk")
|
|
badVersion := bytes.Clone(raw)
|
|
badVersion[4] = 2
|
|
now := testkit.Fixed(f.unlock(t))
|
|
for _, tc := range []struct {
|
|
name string
|
|
dkk []byte
|
|
want error
|
|
}{
|
|
{"not a .dkk", notDKK, datekeys.ErrInvalidMagic},
|
|
{"framing version 2", badVersion, datekeys.ErrUnsupportedVersion},
|
|
{"truncated body", raw[:len(raw)-1], datekeys.ErrIntegrity},
|
|
} {
|
|
step, calls, err := openStep(t, f.dkc, capsule.OpenOptions{AccessKeyFile: bytes.NewReader(tc.dkk), Now: now})
|
|
expectStep(t, tc.name, step, err, tc.want, 9)
|
|
if calls != 0 {
|
|
t.Errorf("%s: %d release requests", tc.name, calls)
|
|
}
|
|
}
|
|
|
|
// A failure of steps 1 to 8 comes first.
|
|
broken := bytes.Clone(f.dkc)
|
|
broken[5] = 1
|
|
step, _, err := openStep(t, broken, capsule.OpenOptions{AccessKeyFile: bytes.NewReader(notDKK), Now: now})
|
|
expectStep(t, "FLAGS 1 and not a .dkk", step, err, datekeys.ErrInvalidFlags, 2)
|
|
|
|
// time_only never reads it.
|
|
g := loadFixture(t, "time_only")
|
|
o := g.openOptions(t)
|
|
o.AccessKeyFile = bytes.NewReader(notDKK)
|
|
if got, err := open(t, g.dkc, o); err != nil || !bytes.Equal(got, g.plaintext) {
|
|
t.Fatalf("time_only with bytes that are not a .dkk: %v", err)
|
|
}
|
|
|
|
// The fixture key, still encoded, opens its capsule.
|
|
o = f.openOptions(t)
|
|
o.AccessKey, o.Identities = nil, nil
|
|
o.AccessKeyFile = bytes.NewReader(raw)
|
|
if got, err := open(t, f.dkc, o); err != nil || !bytes.Equal(got, f.plaintext) {
|
|
t.Fatalf("encoded fixture .dkk: %v", err)
|
|
}
|
|
o.AccessKey = f.dkk
|
|
if _, err := open(t, f.dkc, o); err == nil || datekeys.Code(err) != "" {
|
|
t.Fatalf("both AccessKey and AccessKeyFile: %v", err)
|
|
}
|
|
}
|
|
|
|
// Spec §63 steps 14 and 15: the critical extensions of CONTROL_CBOR are part
|
|
// of the object (step 14); header_binding binds it to PUBLIC_HEADER at step
|
|
// 15.
|
|
func TestControlCriticalBeforeHeaderBinding(t *testing.T) {
|
|
for _, c := range []struct {
|
|
fixture string
|
|
format capsule.Format
|
|
}{{"time_only", capsule.Format1}, {"format2_time_only", capsule.Format2}} {
|
|
p, _ := parts(t, c.fixture)
|
|
b, err := testkit.Build{Format: c.format, ControlCritical: []extension.Extension{{ID: "com.example.unknown", Version: 1}}}.Make()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// The control of b is bound to b's header, not to the fixture's.
|
|
dkc := testkit.Reframe(p.Prelude, p.Header, b.Sealed, b.Payload)
|
|
step, _, err := openStep(t, dkc, capsule.OpenOptions{})
|
|
expectStep(t, c.fixture+": unknown critical extension and header_binding mismatch", step, err, datekeys.ErrExtensionCriticalUnknown, 14)
|
|
step, _, err = openStep(t, dkc, capsule.OpenOptions{Extensions: extension.Set{"com.example.unknown": {1}}})
|
|
expectStep(t, c.fixture+": header_binding mismatch alone", step, err, datekeys.ErrHeaderBinding, 15)
|
|
}
|
|
|
|
// Within CONTROL_CBOR, the CDDL comes before the critical extensions.
|
|
c := map[uint64]any{0: capsule.ControlTypeTag, 1: uint64(1), 2: make([]byte, 32), 3: make([]byte, 32), 4: []any{ext("com.example.unknown", 1)}, 6: uint64(0)}
|
|
if _, err := capsule.DecodeControl(marshal(t, c), capsule.Format1); !errors.Is(err, datekeys.ErrNonCanonicalCBOR) {
|
|
t.Errorf("unknown key 6 and an unknown critical extension: %v", err)
|
|
}
|
|
}
|
|
|
|
// precedenceFormat2 checks the examples of spec §69.1 for format 2, as part
|
|
// of TestPrecedenceAcrossSteps. Each capsule derives from a format 2 fixture,
|
|
// sealed again with its known file keys, so that only the faults the example
|
|
// names are present.
|
|
func precedenceFormat2(t *testing.T) {
|
|
to, err := testkit.LoadFixture(fixtureDir, "format2_time_only_extensions")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
tk, err := testkit.LoadFixture(fixtureDir, "format2_time_and_key_portable")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
unknown := []any{ext("com.example.unknown", 1)}
|
|
control := func(edit func(m map[uint64]any)) testkit.Parts {
|
|
t.Helper()
|
|
in, err := to.WithControl(edit)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
p, err := testkit.Split(in.DKC)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return p
|
|
}
|
|
// badPadding is the PAYLOAD_AGE of to with its last padding byte 0x01.
|
|
content, err := os.ReadFile(filepath.Join(fixtureDir, to.PlaintextFile))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
padded := append(bytes.Clone(content), make([]byte, to.PaddedLength-to.PayloadLength)...)
|
|
padded[len(padded)-1] = 0x01
|
|
in, err := to.WithPayloadPlaintext(padded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
withBadPadding, err := testkit.Split(in.DKC)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
badPadding := withBadPadding.Payload
|
|
// otherHeader is the PUBLIC_HEADER of to with another capsule_id: as
|
|
// valid, and bound to no control.
|
|
h, err := cbortest.UnmarshalMap(to.Parts.Header)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h[2] = bytes.Repeat([]byte{0x5a}, capsule.CapsuleIDSize)
|
|
otherHeader := marshal(t, h)
|
|
|
|
code3 := control(func(m map[uint64]any) { m[7] = uint64(3) })
|
|
for _, tc := range []struct {
|
|
name string
|
|
p testkit.Parts
|
|
want error
|
|
step int
|
|
}{
|
|
{"CONTROL_CBOR of version 1 in a format 2 capsule, with an unknown key", control(func(m map[uint64]any) { m[1], m[9] = uint64(1), uint64(0) }), datekeys.ErrUnsupportedVersion, 14},
|
|
{"padding code 3 and an unknown critical extension in CONTROL_CBOR", control(func(m map[uint64]any) { m[7], m[4] = uint64(3), unknown }), datekeys.ErrNonCanonicalCBOR, 14},
|
|
{"padding code 3 and the header_binding of another header", testkit.Parts{Prelude: code3.Prelude, Header: otherHeader, Sealed: code3.Sealed, Payload: code3.Payload}, datekeys.ErrNonCanonicalCBOR, 14},
|
|
{"unknown critical CONTROL_CBOR extension and a padding byte other than 0x00", func() testkit.Parts {
|
|
p := control(func(m map[uint64]any) { m[4] = unknown })
|
|
p.Payload = badPadding
|
|
return p
|
|
}(), datekeys.ErrExtensionCriticalUnknown, 14},
|
|
{"a padding byte other than 0x00 alone", withBadPadding, datekeys.ErrIntegrity, 17},
|
|
{"a padding byte other than 0x00 and an extra PAYLOAD_AGE stanza", testkit.Parts{Prelude: to.Parts.Prelude, Header: to.Parts.Header, Sealed: to.Parts.Sealed,
|
|
Payload: rewriteHeader(t, badPadding, func(s []*age.Stanza) []*age.Stanza { return append(s, s[0]) })}, datekeys.ErrPolicyStructureMismatch, 6},
|
|
} {
|
|
dkc := testkit.Reframe(tc.p.Prelude, tc.p.Header, tc.p.Sealed, tc.p.Payload)
|
|
step, _, err := openStep(t, dkc, capsule.OpenOptions{Now: testkit.Fixed(to.Unlock)})
|
|
expectStep(t, tc.name, step, err, tc.want, tc.step)
|
|
}
|
|
|
|
// time_and_key: 15 stanzas and an identity that opens one fail at step
|
|
// 12; a format 1 capsule relabeled 2 with its .dkk fails at step 9.a,
|
|
// where its capsule_digest no longer matches.
|
|
fifteen, err := tk.WithInnerStanzas(func(_ []byte, s []*age.Stanza) ([]*age.Stanza, error) {
|
|
i := (*tk.AccessKeyStanza + 1) % len(s)
|
|
return append(s[:i:i], s[i+1:]...), nil
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
id, err := age.ParseX25519Identity(fifteen.Identities[0])
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
step, _, err := openStep(t, fifteen.DKC, capsule.OpenOptions{Identities: []age.Identity{id}, Now: testkit.Fixed(tk.Unlock)})
|
|
expectStep(t, "a format 2 capsule with 15 INNER_ACCESS_AGE stanzas and an identity that opens one", step, err, datekeys.ErrPolicyStructureMismatch, 12)
|
|
|
|
f1 := loadFixture(t, "time_and_key_portable")
|
|
relabeled := bytes.Clone(f1.dkc)
|
|
relabeled[4] = 2
|
|
step, calls, err := openStep(t, relabeled, capsule.OpenOptions{AccessKey: f1.dkk, Now: testkit.Fixed(f1.unlock(t))})
|
|
expectStep(t, "a format 1 time_and_key capsule relabeled 2 and its .dkk, with capsule_digest", step, err, datekeys.ErrAccessInvalid, 9)
|
|
if calls != 0 {
|
|
t.Errorf("%d release requests", calls)
|
|
}
|
|
}
|