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.
DateKeys/capsule/encrypt3_test.go

297 lines
12 KiB

package capsule_test
import (
"bytes"
"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"
)
// 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.
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", 580, 768},
{"nota.txt of 1000 bytes, with mtime", []capsule.Source{note}, "", 1641, 1792},
} {
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 §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"},
{"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)
}

Powered by TurnKey Linux.