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.
335 lines
14 KiB
335 lines
14 KiB
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)
|
|
}
|