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.
282 lines
13 KiB
282 lines
13 KiB
package capsule_test
|
|
|
|
import (
|
|
"bytes"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
datekeys "g.activething.com/go/DateKeys"
|
|
"g.activething.com/go/DateKeys/capsule"
|
|
"g.activething.com/go/DateKeys/extension"
|
|
)
|
|
|
|
// Spec §29.2: the frame of BODY and its limits. Every violation is
|
|
// ERR_INTEGRITY.
|
|
func TestBodyFrame(t *testing.T) {
|
|
frame := func(area, sec, head uint32) []byte {
|
|
b := capsule.BodyFrame{AreaLen: area, SecurityLen: sec, HeadLen: head}.Bytes()
|
|
return b[:]
|
|
}
|
|
// Valid, at the limits.
|
|
for _, c := range []struct {
|
|
area, sec, head uint32
|
|
l uint64
|
|
}{
|
|
{512, 22, 53, 577},
|
|
{512, 512, 1, 525},
|
|
{65536, 1, 1 << 24, 12 + 65536 + 1<<24},
|
|
{1024, 1024, 100, 1 << 40},
|
|
} {
|
|
f, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l)
|
|
if err != nil {
|
|
t.Errorf("%+v: %v", c, err)
|
|
continue
|
|
}
|
|
if want := c.l - 12 - uint64(c.area) - uint64(c.head); f.ContentLength(c.l) != want {
|
|
t.Errorf("%+v: C = %d, want %d", c, f.ContentLength(c.l), want)
|
|
}
|
|
}
|
|
for name, c := range map[string]struct {
|
|
area, sec, head uint32
|
|
l uint64
|
|
}{
|
|
"L shorter than the frame": {512, 22, 53, 11},
|
|
"AREA_LEN 0": {0, 22, 53, 1000},
|
|
"AREA_LEN 511": {511, 22, 53, 1000},
|
|
"AREA_LEN 513": {513, 22, 53, 1000},
|
|
"AREA_LEN 66048": {66048, 22, 53, 100000},
|
|
"SECURITY_LEN 0": {512, 0, 53, 1000},
|
|
"SECURITY_LEN above AREA_LEN": {512, 513, 53, 1000},
|
|
"HEAD_LEN 0": {512, 22, 0, 1000},
|
|
"HEAD_LEN 2^24 + 1": {512, 22, 1<<24 + 1, 1 << 30},
|
|
"frame, area and head above L": {512, 22, 53, 576},
|
|
} {
|
|
if _, err := capsule.ParseBodyFrame(frame(c.area, c.sec, c.head), c.l); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Errorf("%s: %v", name, err)
|
|
}
|
|
}
|
|
area := make([]byte, 512)
|
|
copy(area, capsule.EncodeSecurity())
|
|
if err := capsule.CheckArea(area, 22); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
area[511] = 1
|
|
if err := capsule.CheckArea(area, 22); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("a byte of the area that is not zero: %v", err)
|
|
}
|
|
}
|
|
|
|
// Spec §29.3, §29.7: the security area and its verdicts, which never fail.
|
|
func TestSecurityVerdicts(t *testing.T) {
|
|
empty := capsule.EncodeSecurity()
|
|
if len(empty) != 22 {
|
|
t.Fatalf("empty security is %d bytes, want 22", len(empty))
|
|
}
|
|
sig, err := capsule.EncodeAuthorSignature(1, make([]byte, 32), make([]byte, 64))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sig) != 105 {
|
|
t.Fatalf("an Ed25519 author-signature is %d bytes, want 105", len(sig))
|
|
}
|
|
seal, err := capsule.EncodeSeal(1, []byte{1, 2, 3})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
with := func(sig, seal []byte) []byte {
|
|
b, err := capsule.EncodeSecurityWith(sig, seal)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
if n := len(with(sig, nil)); n != 130 {
|
|
t.Fatalf("security with a signature is %d bytes, want 130", n)
|
|
}
|
|
x := capsule.Verdicts{Signature: capsule.VerdictUnreadable, Seal: capsule.VerdictUnreadable}
|
|
for name, c := range map[string]struct {
|
|
b []byte
|
|
want capsule.Verdicts
|
|
}{
|
|
"empty": {empty, capsule.Verdicts{Signature: "F0", Seal: "S0"}},
|
|
"a signature of alg 1": {with(sig, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
"a seal of seal_type 1": {with(nil, seal), capsule.Verdicts{Signature: "F0", Seal: "S1"}},
|
|
"both": {with(sig, seal), capsule.Verdicts{Signature: "F1", Seal: "S1"}},
|
|
"alg 0": {with(mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{}, 2: []byte{}}), nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
"a signature that is no map": {with([]byte{0x01}, nil), capsule.Verdicts{Signature: "F1", Seal: "S0"}},
|
|
// The first row that holds decides: a seal with an unknown key and an
|
|
// unknown seal_type breaks its schema, S2, before its type is read.
|
|
"a seal with an unknown key": {with(nil, []byte{0xa3, 0x00, 0x07, 0x01, 0x41, 0x00, 0x02, 0x00}), capsule.Verdicts{Signature: "F0", Seal: "S2"}},
|
|
"seal_type 0": {with(nil, mustMarshal(t, map[uint64]any{0: uint64(0), 1: []byte{1}})), capsule.Verdicts{Signature: "F0", Seal: "S2"}},
|
|
"a seal that is not CBOR": {with(sig, []byte{0xff}), capsule.Verdicts{Signature: "F1", Seal: "S2"}},
|
|
"version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(2)}), x},
|
|
"another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(1)}), x},
|
|
"an unknown key 4": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 4: []byte{1}}), x},
|
|
"key 2 not a byte string": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: uint64(1)}), x},
|
|
"an empty key 2": {mustMarshal(t, map[uint64]any{0: "datekeys-security", 1: uint64(1), 2: []byte{}}), x},
|
|
"a byte more": {append(bytes.Clone(empty), 0), x},
|
|
"not CBOR": {[]byte("security"), x},
|
|
} {
|
|
if got := capsule.EvaluateSecurity(c.b); got != c.want {
|
|
t.Errorf("%s: %+v, want %+v", name, got, c.want)
|
|
}
|
|
}
|
|
if got := x.Lines(); len(got) != 1 || !strings.HasPrefix(got[0], "No se han podido") {
|
|
t.Errorf("X shows %q", got)
|
|
}
|
|
if got := (capsule.Verdicts{Signature: "F0", Seal: "S0"}).Lines(); len(got) != 1 || got[0] != "Sin firma de autor." {
|
|
t.Errorf("F0 and S0 show %q", got)
|
|
}
|
|
if got := (capsule.Verdicts{Signature: "F1", Seal: "S1"}).Lines(); len(got) != 2 {
|
|
t.Errorf("F1 and S1 show %q", got)
|
|
}
|
|
}
|
|
|
|
func sampleHead() *capsule.Head {
|
|
h := &capsule.Head{
|
|
Comment: "Para ti ❤\ufe0f",
|
|
Author: "Ana López",
|
|
Files: []capsule.File{
|
|
{Path: "fotos/playa.jpg", Size: 10, Start: 0, End: 10, MTime: 1759190400, HasMTime: true},
|
|
{Path: "nota.txt", Size: 5, Start: 10, End: 15},
|
|
},
|
|
}
|
|
h.Salt[0] = 1
|
|
h.Files[0].SHA256[0] = 2
|
|
return h
|
|
}
|
|
|
|
// Spec §29.4: a head round-trips, and its sizes are those of §29.2.
|
|
func TestHeadRoundTrip(t *testing.T) {
|
|
h := sampleHead()
|
|
b, err := capsule.EncodeHead(h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
got, err := capsule.DecodeHead(b, nil)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Comment != h.Comment || got.Author != h.Author || len(got.Files) != 2 || got.Files[0] != h.Files[0] || got.Files[1] != h.Files[1] || got.Salt != h.Salt {
|
|
t.Fatalf("round trip: %+v", got)
|
|
}
|
|
if err := capsule.CheckHeadEnd(got, 15); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := capsule.CheckHeadEnd(got, 16); !errors.Is(err, datekeys.ErrIntegrity) {
|
|
t.Fatalf("files that do not fill the content: %v", err)
|
|
}
|
|
if err := capsule.CheckHeadEnd(&capsule.Head{}, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range []struct {
|
|
h capsule.Head
|
|
size int
|
|
}{
|
|
{capsule.Head{}, 53},
|
|
{capsule.Head{Comment: "x"}, 56},
|
|
{capsule.Head{Files: []capsule.File{{Path: "nota.txt", Size: 1000, End: 1000, MTime: 1759190400, HasMTime: true}}}, 117},
|
|
{capsule.Head{Files: []capsule.File{{Path: "a"}}}, 100},
|
|
} {
|
|
b, err := capsule.EncodeHead(&c.h)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(b) != c.size {
|
|
t.Errorf("%+v: %d bytes, want %d", c.h, len(b), c.size)
|
|
}
|
|
}
|
|
}
|
|
|
|
// head builds HEAD_CBOR with the test encoder, for heads that the capsule
|
|
// encoder refuses to write.
|
|
func head(t *testing.T, fields map[uint64]any) []byte {
|
|
m := map[uint64]any{0: "datekeys-head", 1: uint64(1), 2: make([]byte, 32)}
|
|
for k, v := range fields {
|
|
if v == nil {
|
|
delete(m, k)
|
|
} else {
|
|
m[k] = v
|
|
}
|
|
}
|
|
return mustMarshal(t, m)
|
|
}
|
|
|
|
func file(path string, size, start, end uint64) map[uint64]any {
|
|
return map[uint64]any{0: path, 1: size, 2: start, 3: end, 4: make([]byte, 32)}
|
|
}
|
|
|
|
// Spec §29.4, §69.1: the layers of the head and their codes, and the first
|
|
// failing layer decides.
|
|
func TestDecodeHeadLayers(t *testing.T) {
|
|
many := make([]any, 65536)
|
|
for i := range many {
|
|
many[i] = file(strings.Repeat("a", 1)+string(rune('a'+i%26))+strings.Repeat("x", i/26%3), 0, 0, 0)
|
|
}
|
|
for name, c := range map[string]struct {
|
|
b []byte
|
|
want error
|
|
}{
|
|
// Layer 2.
|
|
"another type tag": {mustMarshal(t, map[uint64]any{0: "datekeys-control", 1: uint64(1), 2: make([]byte, 32)}), datekeys.ErrNonCanonicalCBOR},
|
|
"version 2": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32)}), datekeys.ErrUnsupportedVersion},
|
|
"version 2 and ..": {mustMarshal(t, map[uint64]any{0: "datekeys-head", 1: uint64(2), 2: make([]byte, 32), 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrUnsupportedVersion},
|
|
// Layer 3.
|
|
"no salt": {head(t, map[uint64]any{2: nil}), datekeys.ErrNonCanonicalCBOR},
|
|
"a salt of 31 bytes": {head(t, map[uint64]any{2: make([]byte, 31)}), datekeys.ErrNonCanonicalCBOR},
|
|
"an empty comment": {head(t, map[uint64]any{3: ""}), datekeys.ErrNonCanonicalCBOR},
|
|
"a comment of 16385 bytes": {head(t, map[uint64]any{3: strings.Repeat("a", 16385)}), datekeys.ErrNonCanonicalCBOR},
|
|
"an author of 257 bytes": {head(t, map[uint64]any{4: strings.Repeat("a", 257)}), datekeys.ErrNonCanonicalCBOR},
|
|
"an empty array of files": {head(t, map[uint64]any{5: []any{}}), datekeys.ErrNonCanonicalCBOR},
|
|
"65536 files": {head(t, map[uint64]any{5: many}), datekeys.ErrNonCanonicalCBOR},
|
|
"R1: an empty path": {head(t, map[uint64]any{5: []any{file("", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"R1: a path of 1025 bytes": {head(t, map[uint64]any{5: []any{file(strings.Repeat("a", 1025), 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"R8: b before a": {head(t, map[uint64]any{5: []any{file("b", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"R8: a repeated path": {head(t, map[uint64]any{5: []any{file("a", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"R8 before R3: b/.. and a": {head(t, map[uint64]any{5: []any{file("b/..", 0, 0, 0), file("a", 0, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"a size above L_MAX": {head(t, map[uint64]any{5: []any{file("a", capsule.MaxPayloadLength+1, 0, 0)}}), datekeys.ErrNonCanonicalCBOR},
|
|
"an mtime after 9999": {head(t, map[uint64]any{5: []any{map[uint64]any{0: "a", 1: uint64(0), 2: uint64(0), 3: uint64(0), 4: make([]byte, 32), 5: uint64(253402300800)}}}), datekeys.ErrNonCanonicalCBOR},
|
|
"an unknown key 8": {head(t, map[uint64]any{8: uint64(1)}), datekeys.ErrNonCanonicalCBOR},
|
|
"a byte more": {append(head(t, nil), 0), datekeys.ErrNonCanonicalCBOR},
|
|
// Layer 4, in key order.
|
|
"a comment with U+202E": {head(t, map[uint64]any{3: "a\u202eb"}), datekeys.ErrHeadInvalid},
|
|
"a comment with the tag U+E0041": {head(t, map[uint64]any{3: "a\U000E0041"}), datekeys.ErrHeadInvalid},
|
|
"an author with LF": {head(t, map[uint64]any{4: "a\nb"}), datekeys.ErrHeadInvalid},
|
|
"R3: ..": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"R2: /a": {head(t, map[uint64]any{5: []any{file("/a", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"R4b: a and VS16": {head(t, map[uint64]any{5: []any{file("a\ufe0f", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"R6: CON.txt": {head(t, map[uint64]any{5: []any{file("CON.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"R10: .datekeys-x": {head(t, map[uint64]any{5: []any{file(".datekeys-x", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"layout: a first start that is not 0": {head(t, map[uint64]any{5: []any{file("a", 1, 1, 2)}}), datekeys.ErrHeadInvalid},
|
|
"layout: end minus start is not size": {head(t, map[uint64]any{5: []any{file("a", 2, 0, 1)}}), datekeys.ErrHeadInvalid},
|
|
"layout: a gap": {head(t, map[uint64]any{5: []any{file("a", 1, 0, 1), file("b", 1, 2, 3)}}), datekeys.ErrHeadInvalid},
|
|
"R7: A.txt and a.txt": {head(t, map[uint64]any{5: []any{file("A.txt", 0, 0, 0), file("a.txt", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"a comment and a path that break": {head(t, map[uint64]any{3: "a\u202e", 5: []any{file("..", 0, 0, 0)}}), datekeys.ErrHeadInvalid},
|
|
"a path that breaks, then an unknown critical extension": {head(t, map[uint64]any{5: []any{file("..", 0, 0, 0)}, 6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrHeadInvalid},
|
|
"an unknown critical extension": {head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}}), datekeys.ErrExtensionCriticalUnknown},
|
|
} {
|
|
if _, err := capsule.DecodeHead(c.b, nil); !errors.Is(err, c.want) {
|
|
t.Errorf("%s: %v, want %v", name, err, c.want)
|
|
} else if n := codes(err); n != 1 {
|
|
t.Errorf("%s: %d normative codes in %v", name, n, err)
|
|
}
|
|
}
|
|
// A known critical extension of the head passes.
|
|
b := head(t, map[uint64]any{6: []any{map[uint64]any{0: "x.example", 1: uint64(1)}}})
|
|
if _, err := capsule.DecodeHead(b, extension.Set{"x.example": {1}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
// codes counts the normative errors err wraps.
|
|
func codes(err error) int {
|
|
n := 0
|
|
for _, e := range datekeys.All() {
|
|
if errors.Is(err, e) {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|