package capsule_test import ( "bytes" "encoding/hex" "errors" "io" "reflect" "strings" "testing" "time" "filippo.io/age" datekeys "g.activething.com/go/DateKeys" "g.activething.com/go/DateKeys/accesskey" "g.activething.com/go/DateKeys/capsule" "g.activething.com/go/DateKeys/extension" "g.activething.com/go/DateKeys/internal/testkit" "g.activething.com/go/DateKeys/profile" "g.activething.com/go/DateKeys/wordkey" ) // The tests of this file cover the writer of format 3 (spec v0.10, §61, // §62, §62.1). // files3 returns the options of a capsule of round 1000, written at the // Quicknet genesis. func files3(t *testing.T) capsule.EncryptOptions { opts := past(t, 1000) opts.TestVectors = false return opts } // Spec §29.2 to §29.6, §61: what EncryptFiles writes, Open reads back, with // the files in the byte order of their paths (R8), the comment with LF, the // mtime only within its range, and a fresh salt. func TestEncryptFilesRoundTrip(t *testing.T) { photo := strings.Repeat("playa", 30000) // three STREAM chunks when := time.Date(2026, 9, 30, 18, 0, 0, 0, time.UTC) sources := []capsule.Source{ source("vacío.txt", ""), source("nota.txt", "Hola.\n"), source("fotos/playa.jpg", photo), source("\U0001F600.txt", "emoji"), source("\uFF5E.txt", "tilde"), // before U+1F600 in UTF-8, after it in UTF-16 source("fotos/a.txt", "a"), } sources[1].ModTime = when sources[2].ModTime = time.Date(10000, 1, 1, 0, 0, 0, 0, time.UTC) // after 9999: omitted sources[3].ModTime = time.Date(1969, 12, 31, 0, 0, 0, 0, time.UTC) // before 1970: omitted note, err := extension.New("org.example.note", 1, []byte("x")) if err != nil { t.Fatal(err) } opts := files3(t) opts.Comment, opts.Author = "Hola\r\nmundo\rfin", "Ana López" opts.HeadNoncritical = []extension.Extension{note} var dkc bytes.Buffer res, err := capsule.EncryptFiles(&dkc, sources, opts) if err != nil { t.Fatal(err) } var paths []string for _, f := range res.Head.Files { paths = append(paths, f.Path) } want := []string{"fotos/a.txt", "fotos/playa.jpg", "nota.txt", "vacío.txt", "\uFF5E.txt", "\U0001F600.txt"} switch { case res.Format != capsule.Format3 || res.Padding != capsule.Reforzado: t.Errorf("format %d, padding %s", res.Format, res.Padding) case !reflect.DeepEqual(paths, want): t.Errorf("paths %q, want %q", paths, want) case res.Head.Comment != "Hola\nmundo\nfin" || res.Head.Author != opts.Author: t.Errorf("comment %q, author %q", res.Head.Comment, res.Head.Author) case !res.Head.Files[2].HasMTime || res.Head.Files[2].MTime != uint64(when.Unix()): t.Errorf("mtime of nota.txt: %v %d", res.Head.Files[2].HasMTime, res.Head.Files[2].MTime) case res.Head.Files[1].HasMTime || res.Head.Files[5].HasMTime || res.Head.Files[0].HasMTime: t.Error("an mtime out of range, or unknown, was written") case res.Head.Salt == [capsule.SaltSize]byte{}: t.Error("zero salt") } r := open3(t, dkc.Bytes(), &testkit.MemorySink{}) if r.err != nil { t.Fatal(r.err) } o := r.opened if !reflect.DeepEqual(o.Head, res.Head) { t.Errorf("head read %+v, written %+v", o.Head, res.Head) } if o.PayloadLength != res.Length || o.PaddedLength != res.PaddedLength || o.AreaLen != capsule.AreaLen { t.Errorf("L = %d, P = %d, area %d; written L = %d, P = %d", o.PayloadLength, o.PaddedLength, o.AreaLen, res.Length, res.PaddedLength) } if o.Verdicts != (capsule.Verdicts{Signature: capsule.VerdictNoSignature, Seal: capsule.VerdictNoSeal}) { t.Errorf("verdicts %+v", o.Verdicts) } byPath := map[string]string{} for _, s := range sources { rc, _ := s.Open() b, _ := io.ReadAll(rc) byPath[s.Path] = string(b) } for i, f := range o.Head.Files { if string(r.sink.Files[i]) != byPath[f.Path] { t.Errorf("%s: %d bytes", f.Path, len(r.sink.Files[i])) } } // The same files in another order give the same head, but for the salt. var again bytes.Buffer res2, err := capsule.EncryptFiles(&again, []capsule.Source{sources[5], sources[4], sources[3], sources[2], sources[1], sources[0]}, opts) if err != nil { t.Fatal(err) } if res2.Head.Salt == res.Head.Salt { t.Error("the salt was reused") } res2.Head.Salt = res.Head.Salt if !reflect.DeepEqual(res2.Head, res.Head) { t.Error("the order of the sources changed the head") } } // Spec §29.2: the lengths of its examples, written by EncryptFiles, with the // area of 32 KiB of v0.11: 32256 bytes more than with that of 512 of v0.10. func TestEncryptFilesLengths(t *testing.T) { note := source("nota.txt", strings.Repeat("n", 1000)) note.ModTime = time.Date(2026, 9, 30, 0, 0, 0, 0, time.UTC) for _, tc := range []struct { name string sources []capsule.Source comment string l, p uint64 }{ {"a comment of one byte", nil, "a", 32836, 34816}, {"nota.txt of 1000 bytes, with mtime", []capsule.Source{note}, "", 33897, 34816}, } { opts := files3(t) opts.Comment = tc.comment res, err := capsule.EncryptFiles(io.Discard, tc.sources, opts) if err != nil || res.Length != tc.l || res.PaddedLength != tc.p { t.Errorf("%s: L = %d, P = %d, %v; want %d, %d", tc.name, res.Length, res.PaddedLength, err, tc.l, tc.p) } } } // Spec §62, §38: time_and_key, with a portable key and a recipient. func TestEncryptFilesTimeAndKey(t *testing.T) { holder, _ := age.GenerateX25519Identity() opts := files3(t) opts.Policy, opts.NewPortableKey, opts.Recipients = capsule.TimeAndKey, true, []age.Recipient{holder.Recipient()} var dkc bytes.Buffer res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("a.txt", "secreto")}, opts) if err != nil { t.Fatal(err) } if r := open3(t, dkc.Bytes(), &testkit.MemorySink{}, holder); r.err != nil || string(r.sink.Files[0]) != "secreto" { t.Errorf("with the identity: %v", r.err) } var dkk bytes.Buffer if err := accesskey.Encode(&dkk, res.PortableKey); err != nil { t.Fatal(err) } key, err := accesskey.Decode(&dkk) if err != nil { t.Fatal(err) } o := defaultOpen(1000) o.AccessKey, o.Sink = key, &testkit.MemorySink{} if _, err := capsule.Open(t.Context(), nil, bytes.NewReader(dkc.Bytes()), o); err != nil { t.Errorf("with the .dkk: %v", err) } } // Spec §38.1: a key of words is salted with the capsule_id that EncryptFiles // draws, and its identity opens the capsule. func TestEncryptFilesWords(t *testing.T) { opts := files3(t) words := wordkey.Normalize("Perro luna casa verde trén mar") opts.Policy, opts.Words = capsule.TimeAndKey, words var dkc bytes.Buffer res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("a.txt", "secreto")}, opts) if err != nil { t.Fatal(err) } chain, _ := hex.DecodeString(profile.QuicknetChainHash) id, err := wordkey.Identity(words, chain, 1000, res.CapsuleID[:]) if err != nil { t.Fatal(err) } if r := open3(t, dkc.Bytes(), &testkit.MemorySink{}, id); r.err != nil || string(r.sink.Files[0]) != "secreto" { t.Errorf("with the words: %v", r.err) } other := res.CapsuleID other[0] ^= 1 if wrong, err := wordkey.Identity(words, chain, 1000, other[:]); err != nil { t.Fatal(err) } else if r := open3(t, dkc.Bytes(), &testkit.MemorySink{}, wrong); r.err == nil { t.Error("the words of another capsule_id opened it") } } // Spec §62.1 rules 3, 14, 15 and 18: EncryptFiles rejects what the reader // would reject, and a file whose size is not its Size, before it writes // anything, with a message that names the rule and the character. func TestEncryptFilesRejects(t *testing.T) { failing := source("a.txt", "a") failing.Open = func() (io.ReadCloser, error) { return nil, errors.New("permission denied") } negative := source("a.txt", "") negative.Size = -1 nilOpen := source("a.txt", "a") nilOpen.Open = nil short, long := source("a.txt", "abc"), source("a.txt", "abc") short.Size, long.Size = 4, 2 for _, tc := range []struct { name string sources []capsule.Source edit func(o *capsule.EncryptOptions) want string }{ {"nothing", nil, nil, "at least one file or a comment"}, {"an author alone", nil, func(o *capsule.EncryptOptions) { o.Author = "Ana" }, "at least one file or a comment"}, {"Length", []capsule.Source{source("a", "a")}, func(o *capsule.EncryptOptions) { o.Length = 1 }, "Length is for Encrypt"}, {"TAB in a path", []capsule.Source{source("a\tb", "")}, nil, `path "a\tb": R4: segment 1: control U+0009`}, {"empty path", []capsule.Source{source("", "")}, nil, `path "": R1: 0 bytes`}, {"path of 1025 bytes", []capsule.Source{source(strings.Repeat("a/", 512)+"a", "")}, nil, "R1: 1025 bytes"}, {"path not UTF-8", []capsule.Source{source("a\xffb", "")}, nil, "R1: not valid UTF-8"}, {"path ..", []capsule.Source{source("..", "")}, nil, `path "..": R3`}, {"path with U+202E", []capsule.Source{source("a\u202eb", "")}, nil, "R4: segment 1: invisible U+202E"}, {"CON.txt", []capsule.Source{source("CON.txt", "")}, nil, "R6"}, {"a path twice", []capsule.Source{source("a.txt", ""), source("a.txt", "")}, nil, `path "a.txt" given twice`}, {"A.txt and a.txt", []capsule.Source{source("a.txt", ""), source("A.txt", "")}, nil, `paths "A.txt" and "a.txt": R7`}, {"a and a/b", []capsule.Source{source("a/b", ""), source("a", "")}, nil, `paths "a" and "a/b": R7`}, {"comment with U+202E", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { o.Comment = "a\u202eb" }, "comment: text: bidirectional control U+202E"}, {"comment of 16385 bytes", nil, func(o *capsule.EncryptOptions) { o.Comment = strings.Repeat("a", 16385) }, "comment: 16385 bytes, more than 16384"}, {"comment not UTF-8", nil, func(o *capsule.EncryptOptions) { o.Comment = "a\xff" }, "comment: not valid UTF-8"}, {"author with LF", nil, func(o *capsule.EncryptOptions) { o.Comment, o.Author = "c", "Ana\nLópez" }, "declared author: text: control U+000A"}, {"author with a leading space", nil, func(o *capsule.EncryptOptions) { o.Comment, o.Author = "c", " Ana" }, "starts or ends with U+0020"}, {"author of 257 bytes", nil, func(o *capsule.EncryptOptions) { o.Comment, o.Author = "c", strings.Repeat("a", 257) }, "more than 256"}, {"negative size", []capsule.Source{negative}, nil, "negative size"}, {"nil Open", []capsule.Source{nilOpen}, nil, "Source.Open is nil"}, {"Open fails", []capsule.Source{failing}, nil, "permission denied"}, {"shorter than its size", []capsule.Source{short}, nil, `file "a.txt": 3 bytes, not its size of 4`}, {"longer than its size", []capsule.Source{long}, nil, `file "a.txt": more than its size of 2 bytes`}, {"65536 files", make([]capsule.Source, 65536), nil, "65536 files, more than 65535"}, {"time_only with a recipient", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { id, _ := age.GenerateX25519Identity() o.Recipients = []age.Recipient{id.Recipient()} }, "time_only takes no recipients"}, {"time_only with words", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { o.Words = wordkey.Normalize("perro luna casa verde tren mar") }, "no key of words"}, {"too few words", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { o.Policy, o.Words = capsule.TimeAndKey, wordkey.Normalize("perro luna casa") }, "at least 6 different words"}, {"an instant in the past", []capsule.Source{source("a", "")}, func(o *capsule.EncryptOptions) { o.Now = time.Now }, "is not in the future"}, } { opts := files3(t) if tc.edit != nil { tc.edit(&opts) } var dkc bytes.Buffer _, err := capsule.EncryptFiles(&dkc, tc.sources, opts) switch { case err == nil || !strings.Contains(err.Error(), tc.want): t.Errorf("%s: %v, want %q", tc.name, err, tc.want) case dkc.Len() != 0: t.Errorf("%s: %d bytes written", tc.name, dkc.Len()) } } } // changing is a file whose second reading differs from the first. func changing(first, second string) capsule.Source { n := 0 return capsule.Source{Path: "a.txt", Size: int64(len(first)), Open: func() (io.ReadCloser, error) { n++ if n == 1 { return io.NopCloser(strings.NewReader(first)), nil } return io.NopCloser(strings.NewReader(second)), nil }} } // Spec §62.1 rules 9 and 18: a file that changes between the two readings // makes EncryptFiles fail; what it wrote must be discarded. func TestEncryptFilesChangedFile(t *testing.T) { for _, tc := range []struct { name string first, second string want string }{ {"another byte", "abc", "abd", "changed after its first reading: its SHA-256 is another"}, {"shorter", "abc", "ab", "changed after its first reading: 2 bytes, not its size of 3"}, {"longer", "abc", "abcd", "changed after its first reading: more than its size of 3 bytes"}, } { if _, err := capsule.EncryptFiles(io.Discard, []capsule.Source{changing(tc.first, tc.second)}, files3(t)); err == nil || !strings.Contains(err.Error(), tc.want) { t.Errorf("%s: %v", tc.name, err) } } } // Spec §62.1 rule 1: only a generator of test vectors writes format 2, and // format 2 has no head. func TestEncryptIsForTestVectors(t *testing.T) { opts := files3(t) opts.Length = 1 var dkc bytes.Buffer if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil || dkc.Len() != 0 { t.Errorf("format 2 without TestVectors: %v", err) } opts.TestVectors, opts.Comment = true, "c" if _, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err == nil || dkc.Len() != 0 { t.Errorf("format 2 with a comment: %v", err) } opts.Comment = "" if res, err := capsule.Encrypt(&dkc, strings.NewReader("x"), opts); err != nil || res.Format != capsule.Format2 || res.Head != nil { t.Errorf("format 2 for test vectors: %v", err) } } // Spec §29.4, §54: an unknown critical extension of the head is written as // given, and the reader that does not know it fails at step 17. func TestEncryptFilesHeadCritical(t *testing.T) { opts := files3(t) opts.HeadCritical = []extension.Extension{{ID: "org.example.required", Version: 1}} var dkc bytes.Buffer if _, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("a", "a")}, opts); err != nil { t.Fatal(err) } r := open3(t, dkc.Bytes(), &testkit.MemorySink{}) expectStep(t, "unknown critical extension of the head", failedStep(t, r.opened.Inspection.Checks, r.err), r.err, datekeys.ErrExtensionCriticalUnknown, 17) }