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 }