EncryptFiles writes format 3 (spec 29.2 to 29.6, 61, 62, 62.1): the files of a list of Sources, each read twice, with the comment and the declared author. - Before anything is written: the paths and the texts are checked with the rules of the reader, in the words of a writer, naming the rule and the character, and the two paths of an R7 collision (rule 15); the comment has its CR LF and lone CR turned into LF (29.6); L is measured with a head whose salt and SHA-256 are zero, as long as the final one, and the first reading hashes each file, which must have exactly its Size. - The files go in the byte order of their paths (R8), whatever the order of the Sources; the mtime is kept only from 1970 to 9999, never clipped (rule 16); at least one file or a comment (rule 14). - The head, with a fresh salt, the control and the security area are decoded with the rules of the reader before sealing (rule 17), and the frame is checked against L. The area is 512 bytes with the empty security, whatever the options (rule 13). - The second reading writes each file into PAYLOAD_AGE and fails if its size or SHA-256 changed (rule 18). - Encrypt and EncryptFiles share the sealing; Encrypt writes format 2 only with the new TestVectors option (rule 1), and takes no head. The test data generators set it, and so does the CLI until step 5 moves it to EncryptFiles. - Result.Head is the head written. DecodeHead keeps the check of the critical extensions apart, so that the self-check decodes the head as the one of the control does. - The examples and the live test write with EncryptFiles. - The reader tests had a literal U+202E, now escaped. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.10
parent
7249ef4cd1
commit
8955c0f650
@ -0,0 +1,293 @@
|
|||||||
|
package capsule
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
|
"g.activething.com/go/DateKeys/internal/pathrule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source is a file that EncryptFiles writes into a format 3 capsule.
|
||||||
|
type Source struct {
|
||||||
|
// Path is the path of the file in the capsule, relative, with '/'
|
||||||
|
// between its segments (spec §29.5). It is stored as given: EncryptFiles
|
||||||
|
// rejects a path that breaks a rule, with a message that names the rule
|
||||||
|
// and the character, and never corrects it (spec §62.1 rule 15).
|
||||||
|
Path string
|
||||||
|
// Size is the number of bytes of the file. EncryptFiles checks it in
|
||||||
|
// each of its two readings.
|
||||||
|
Size int64
|
||||||
|
// ModTime is the modification time of the file at its source, taken
|
||||||
|
// when it is loaded, as os.FileInfo.ModTime gives it, or the zero Time
|
||||||
|
// when unknown. It is stored in seconds when it falls from 1970-01-01 to
|
||||||
|
// 9999-12-31T23:59:59Z, and omitted otherwise, never clipped (spec
|
||||||
|
// §62.1 rule 16). It is informative: it proves nothing.
|
||||||
|
ModTime time.Time
|
||||||
|
// Open returns a reader of the file from its start. EncryptFiles calls
|
||||||
|
// it twice, and closes each reader.
|
||||||
|
Open func() (io.ReadCloser, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EncryptFiles writes a format 3 .dkc holding the files of sources and the
|
||||||
|
// comment and declared author of opts (spec §29.2 to §29.6, §61, §62,
|
||||||
|
// §62.1). It needs no network: the round is resolved locally and tlock uses
|
||||||
|
// only the pinned public key.
|
||||||
|
//
|
||||||
|
// It reads each file twice, and writes nothing to dst before the second
|
||||||
|
// reading. First it checks the paths and the texts with the rules of the
|
||||||
|
// reader, measures L with a head whose salt and SHA-256 are zero, as long as
|
||||||
|
// the final one, and hashes each file. Then it seals the control, with L,
|
||||||
|
// and streams PAYLOAD_AGE, reading each file again: a file whose size or
|
||||||
|
// SHA-256 has changed makes it fail (spec §62.1 rule 18), and dst then holds
|
||||||
|
// a partial capsule that must be discarded and never presented as a capsule
|
||||||
|
// (rule 9). The files go in the byte order of their paths, whatever the
|
||||||
|
// order of sources (R8), without the empty folders, which a path cannot
|
||||||
|
// name.
|
||||||
|
//
|
||||||
|
// The head, the control and the security area are decoded with the rules of
|
||||||
|
// the reader before anything is written (spec §62.1 rule 17), and the
|
||||||
|
// self-checks of Encrypt apply too. The security area is the empty one of
|
||||||
|
// this version, in an area of 512 bytes, whatever the options (rule 13).
|
||||||
|
//
|
||||||
|
// opts is as for Encrypt, with the head in Comment, Author and the head
|
||||||
|
// extensions, and with Length 0: L is the length of BODY.
|
||||||
|
func EncryptFiles(dst io.Writer, sources []Source, opts EncryptOptions) (*Result, error) {
|
||||||
|
if opts.Length != 0 {
|
||||||
|
return nil, errors.New("capsule: EncryptOptions.Length is for Encrypt: EncryptFiles computes L from the files")
|
||||||
|
}
|
||||||
|
s, err := newSealer(opts, 0)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
h, order, err := newHead(sources, opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2 of spec §61: L, with a head as long as the final one.
|
||||||
|
measured, err := EncodeHead(h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(measured) > MaxHeadLen {
|
||||||
|
return nil, fmt.Errorf("capsule: the head is %d bytes, more than %d: fewer files or shorter paths", len(measured), MaxHeadLen)
|
||||||
|
}
|
||||||
|
if err := selfCheckHead(measured); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var content uint64
|
||||||
|
if n := len(h.Files); n > 0 {
|
||||||
|
content = h.Files[n-1].End
|
||||||
|
}
|
||||||
|
// newHead bounds content by MaxPayloadLength: the sum does not overflow.
|
||||||
|
length := BodyFrameSize + AreaLen + uint64(len(measured)) + content
|
||||||
|
if _, err := PaddedLength(length, s.code); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: the first reading, for the SHA-256 of each file.
|
||||||
|
for i := range h.Files {
|
||||||
|
if h.Files[i].SHA256, err = readSource(nil, sources[order[i]], h.Files[i].Size, false); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 12: the head, with a fresh salt, and SECURITY_CBOR, decoded with
|
||||||
|
// the rules of the reader; write decodes CONTROL_CBOR.
|
||||||
|
_, _ = rand.Read(h.Salt[:]) // never fails since Go 1.24
|
||||||
|
head, err := EncodeHead(h)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(head) != len(measured) {
|
||||||
|
return nil, fmt.Errorf("capsule: internal error: the head is %d bytes, measured %d", len(head), len(measured))
|
||||||
|
}
|
||||||
|
if err := selfCheckHead(head); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
security := EncodeSecurity()
|
||||||
|
if v := EvaluateSecurity(security); v != (Verdicts{VerdictNoSignature, VerdictNoSeal}) {
|
||||||
|
return nil, fmt.Errorf("capsule: self-check: the reader finds the verdicts %s and %s in this security area", v.Signature, v.Seal)
|
||||||
|
}
|
||||||
|
frame := BodyFrame{AreaLen: AreaLen, SecurityLen: uint32(len(security)), HeadLen: uint32(len(head))}
|
||||||
|
fb := frame.Bytes()
|
||||||
|
if _, err := ParseBodyFrame(fb[:], length); err != nil {
|
||||||
|
return nil, fmt.Errorf("capsule: self-check: %w", err)
|
||||||
|
}
|
||||||
|
if err := CheckHeadEnd(h, frame.ContentLength(length)); err != nil {
|
||||||
|
return nil, fmt.Errorf("capsule: self-check: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 16: BODY, and the second reading of each file.
|
||||||
|
res, err := s.write(dst, Format3, length, func(w io.Writer) error {
|
||||||
|
for _, b := range [][]byte{fb[:], security, make([]byte, AreaLen-len(security)), head} {
|
||||||
|
if _, err := w.Write(b); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i := range h.Files {
|
||||||
|
f := &h.Files[i]
|
||||||
|
sum, err := readSource(w, sources[order[i]], f.Size, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if sum != f.SHA256 {
|
||||||
|
return fmt.Errorf("capsule: file %q changed after its first reading: its SHA-256 is another", f.Path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
res.Head = h
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newHead checks the files and the texts of opts with the rules of spec
|
||||||
|
// §29.4 to §29.6, in the words of a writer (spec §62.1 rule 15), and returns
|
||||||
|
// the head with the files in the byte order of their paths, their layout and
|
||||||
|
// mtime, and a zero salt and zero SHA-256; order[i] is the source of entry
|
||||||
|
// i. The comment has its CR LF, and any lone CR, turned into LF (§29.6).
|
||||||
|
func newHead(sources []Source, opts EncryptOptions) (*Head, []int, error) {
|
||||||
|
comment := strings.ReplaceAll(strings.ReplaceAll(opts.Comment, "\r\n", "\n"), "\r", "\n")
|
||||||
|
switch {
|
||||||
|
case len(sources) == 0 && comment == "":
|
||||||
|
return nil, nil, errors.New("capsule: a format 3 capsule holds at least one file or a comment (spec §62.1 rule 14)")
|
||||||
|
case len(sources) > MaxFiles:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: %d files, more than %d", len(sources), MaxFiles)
|
||||||
|
}
|
||||||
|
if err := checkHeadText("comment", comment, MaxCommentLen, pathrule.CheckComment); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := checkHeadText("declared author", opts.Author, MaxAuthorLen, pathrule.CheckAuthor); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
order := make([]int, len(sources))
|
||||||
|
for i := range order {
|
||||||
|
order[i] = i
|
||||||
|
}
|
||||||
|
// R8: the byte order of the paths, which is the order of Go strings.
|
||||||
|
slices.SortStableFunc(order, func(a, b int) int { return strings.Compare(sources[a].Path, sources[b].Path) })
|
||||||
|
h := &Head{Comment: comment, Author: opts.Author, Critical: opts.HeadCritical, Noncritical: opts.HeadNoncritical}
|
||||||
|
paths := make([]string, len(order))
|
||||||
|
var end uint64
|
||||||
|
for i, j := range order {
|
||||||
|
src, p := sources[j], sources[j].Path
|
||||||
|
switch {
|
||||||
|
case i > 0 && p == paths[i-1]:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: path %q given twice", p)
|
||||||
|
case !utf8.ValidString(p):
|
||||||
|
return nil, nil, fmt.Errorf("capsule: path %q: R1: not valid UTF-8", p)
|
||||||
|
case len(p) == 0 || len(p) > MaxPathLen:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: path %q: R1: %d bytes, not 1 to %d", p, len(p), MaxPathLen)
|
||||||
|
case src.Size < 0:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: file %q: negative size %d", p, src.Size)
|
||||||
|
case src.Open == nil:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: file %q: Source.Open is nil", p)
|
||||||
|
case uint64(src.Size) > MaxPayloadLength-end:
|
||||||
|
return nil, nil, fmt.Errorf("capsule: the files add up to more than %d bytes, the maximum of L", uint64(MaxPayloadLength))
|
||||||
|
}
|
||||||
|
if err := pathrule.CheckPath(p); err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("capsule: path %q: %w", p, err)
|
||||||
|
}
|
||||||
|
f := File{Path: p, Size: uint64(src.Size), Start: end, End: end + uint64(src.Size)}
|
||||||
|
if t := src.ModTime; !t.IsZero() {
|
||||||
|
if u := t.Unix(); u >= 0 && u <= MaxMTime {
|
||||||
|
f.MTime, f.HasMTime = uint64(u), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.Files = append(h.Files, f)
|
||||||
|
paths[i], end = p, f.End
|
||||||
|
}
|
||||||
|
if err := pathrule.CheckTree(paths); err != nil {
|
||||||
|
var e *pathrule.Error
|
||||||
|
if errors.As(err, &e) && e.Paths[0] > 0 {
|
||||||
|
return nil, nil, fmt.Errorf("capsule: paths %q and %q: %w", paths[e.Paths[1]-1], paths[e.Paths[0]-1], err)
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("capsule: paths: %w", err)
|
||||||
|
}
|
||||||
|
return h, order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkHeadText checks the comment or the declared author, when present:
|
||||||
|
// valid UTF-8, at most max bytes, and the characters of spec §29.6.
|
||||||
|
func checkHeadText(what, s string, max int, check func(string) error) error {
|
||||||
|
switch {
|
||||||
|
case s == "":
|
||||||
|
return nil
|
||||||
|
case !utf8.ValidString(s):
|
||||||
|
return fmt.Errorf("capsule: %s: not valid UTF-8", what)
|
||||||
|
case len(s) > max:
|
||||||
|
return fmt.Errorf("capsule: %s: %d bytes, more than %d", what, len(s), max)
|
||||||
|
}
|
||||||
|
if err := check(s); err != nil {
|
||||||
|
return fmt.Errorf("capsule: %s: %w", what, err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// selfCheckHead decodes HEAD_CBOR with the rules of the reader, but for the
|
||||||
|
// knowledge of its critical extensions, which depends on the reader, as
|
||||||
|
// selfCheckControl does with the control (spec §62.1 rule 17). A head that
|
||||||
|
// the reader rejects would only be found after the date.
|
||||||
|
func selfCheckHead(b []byte) error {
|
||||||
|
if _, err := decodeHead(b); err != nil {
|
||||||
|
return fmt.Errorf("capsule: self-check: the reader rejects this head: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// readSource reads the file of src, which must be exactly size bytes, and
|
||||||
|
// returns its SHA-256; w, when not nil, receives its bytes. In the second
|
||||||
|
// reading, a file whose size differs has changed (spec §62.1 rule 18).
|
||||||
|
func readSource(w io.Writer, src Source, size uint64, second bool) ([32]byte, error) {
|
||||||
|
var sum [32]byte
|
||||||
|
mismatch := func(format string, args ...any) error {
|
||||||
|
if second {
|
||||||
|
return fmt.Errorf("capsule: file %q changed after its first reading: %s", src.Path, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
return fmt.Errorf("capsule: file %q: %s", src.Path, fmt.Sprintf(format, args...))
|
||||||
|
}
|
||||||
|
rc, err := src.Open()
|
||||||
|
if err != nil {
|
||||||
|
return sum, fmt.Errorf("capsule: file %q: %w", src.Path, err)
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
h := sha256.New()
|
||||||
|
buf := make([]byte, 32<<10)
|
||||||
|
defer clear(buf)
|
||||||
|
var n uint64
|
||||||
|
for {
|
||||||
|
k, err := rc.Read(buf)
|
||||||
|
if uint64(k) > size-n {
|
||||||
|
return sum, mismatch("more than its size of %d bytes", size)
|
||||||
|
}
|
||||||
|
h.Write(buf[:k])
|
||||||
|
if w != nil && k > 0 {
|
||||||
|
if _, err := w.Write(buf[:k]); err != nil {
|
||||||
|
return sum, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
n += uint64(k)
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return sum, fmt.Errorf("capsule: file %q: %w", src.Path, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n != size {
|
||||||
|
return sum, mismatch("%d bytes, not its size of %d", n, size)
|
||||||
|
}
|
||||||
|
h.Sum(sum[:0])
|
||||||
|
return sum, nil
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
Loading…
Reference in new issue