Format 3, step 4: the writer

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
dev 1 week ago
parent 7249ef4cd1
commit 8955c0f650

@ -20,7 +20,7 @@ import (
"g.activething.com/go/DateKeys/profile"
)
// EncryptOptions configures Encrypt.
// EncryptOptions configures EncryptFiles and Encrypt.
type EncryptOptions struct {
// Profile is the pinned Provider Profile. Required.
Profile *profile.Profile
@ -35,15 +35,16 @@ type EncryptOptions struct {
// canonical and not of low order, and none may be listed twice.
Recipients []age.Recipient
// NewPortableKey generates a fresh I_ACCESS for this capsule only and
// returns it as a .dkk (spec §38). An I_ACCESS is never reused: Encrypt
// accepts no existing one. The recipients and the portable key are the
// returns it as a .dkk (spec §38). An I_ACCESS is never reused: no
// existing one is accepted. The recipients and the portable key are the
// credentials of the capsule: from 1 to 16 (spec §39).
NewPortableKey bool
// Length is L, the exact number of bytes src delivers, at most
// MaxPayloadLength. It is sealed in the control before the payload is
// written, so it must be known in advance: a source of unknown length can
// be copied to a temporary file first (spec §29.1, §62.1 rule 6). If src
// delivers another number of bytes, Encrypt fails.
// Length is L for Encrypt, the exact number of bytes src delivers, at
// most MaxPayloadLength. It is sealed in the control before the payload
// is written, so it must be known in advance: a source of unknown length
// can be copied to a temporary file first (spec §29.1, §62.1 rule 6). If
// src delivers another number of bytes, Encrypt fails. EncryptFiles
// computes L, the length of BODY, from the files, and requires 0.
Length int64
// Padding is the padding rule of the payload, Bloque256 or Reforzado.
// Zero means Reforzado, the default of spec §29.1.
@ -54,31 +55,51 @@ type EncryptOptions struct {
// ControlCritical and ControlNoncritical are the CONTROL_CBOR extensions,
// sealed with the control.
ControlCritical, ControlNoncritical []extension.Extension
// Comment and Author are the comment and the declared author of the
// head that EncryptFiles writes, "" when absent (spec §29.4, §29.6):
// the comment of 1 to 16384 bytes, in which EncryptFiles turns CR LF, and
// a lone CR, into LF, and the declared author of 1 to 256. The declared
// author is text of the creator and proves nothing (spec §55.1).
Comment, Author string
// HeadCritical and HeadNoncritical are the extensions of the head that
// EncryptFiles writes, sealed in PAYLOAD_AGE (spec §29.4, §54).
HeadCritical, HeadNoncritical []extension.Extension
// TestVectors lets Encrypt write format 2, which only a generator of
// test vectors may write (spec §62.1 rule 1, §70). EncryptFiles, which
// writes format 3, ignores it.
TestVectors bool
// Now is the clock. Required: no package of this module reads the wall
// clock on its own.
Now func() time.Time
}
// Result describes a capsule written by Encrypt.
// Result describes a capsule written by EncryptFiles or Encrypt.
type Result struct {
DateKey datekey.DateKey
UnlockAt time.Time // effective round time, never before the requested instant
CapsuleID [CapsuleIDSize]byte
// Format is the format written, always Format2. Length is L, Padding the
// padding rule and PaddedLength P = rule(L), the length of the plaintext
// of PAYLOAD_AGE (spec §29.1).
// Format is the format written: Format3 by EncryptFiles, Format2 by
// Encrypt. Length is L, the length of the content, BODY in format 3,
// Padding the padding rule and PaddedLength P = rule(L), the length of
// the plaintext of PAYLOAD_AGE (spec §29.1, §29.2).
Format Format
Length uint64
Padding Padding
PaddedLength uint64
// Head is the head that EncryptFiles wrote: the files in the byte order
// of their paths, with their layout and SHA-256, and the comment as
// written. Nil for Encrypt.
Head *Head
// PortableKey is the .dkk generated when NewPortableKey is set. Encode it
// with accesskey.Encode and treat it as a sensitive capability.
PortableKey *accesskey.AccessKey
}
// Encrypt writes a format 2 .dkc for the content read from src (spec §61 for
// time_only, §62 for time_and_key, §62.1). It needs no network: the round is
// resolved locally and tlock uses only the pinned public key.
// Encrypt writes a format 2 .dkc for the content read from src (spec §61 and
// §62 of v0.9). Only a generator of test vectors may write format 2 (spec
// §62.1 rule 1, §70): Encrypt fails unless opts.TestVectors is set, and
// takes no comment, author or head extensions, which format 2 has no place
// for. Capsules are written with EncryptFiles.
//
// PAYLOAD_AGE is streamed after the small, in-memory SEALED_CONTROL, so the
// content is never held in memory. Its plaintext is the content followed by
@ -97,6 +118,38 @@ type Result struct {
// registered extension only in the objects and arrays it is registered for
// (spec §72).
func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error) {
switch {
case !opts.TestVectors:
return nil, errors.New("capsule: Encrypt writes format 2, which only a generator of test vectors may write (spec §62.1 rule 1): EncryptFiles writes format 3")
case opts.Comment != "" || opts.Author != "" || opts.HeadCritical != nil || opts.HeadNoncritical != nil:
return nil, errors.New("capsule: format 2 has no head: Comment, Author and the head extensions are for EncryptFiles")
case opts.Length < 0:
return nil, fmt.Errorf("capsule: EncryptOptions.Length %d is negative", opts.Length)
}
s, err := newSealer(opts, uint64(opts.Length))
if err != nil {
return nil, err
}
return s.write(dst, Format2, uint64(opts.Length), func(w io.Writer) error {
return copyExactly(w, src, opts.Length)
})
}
// sealer writes what the writers of both formats share: the steps of spec
// §61 and §62 other than those of the content.
type sealer struct {
opts EncryptOptions
code Padding
dk datekey.DateKey
unlock time.Time
credentials []age.Recipient
portable *age.X25519Identity
}
// newSealer validates the options that do not depend on the content, with
// length, a first L, checked against its maximum, and resolves the DateKey
// locally (spec §15, §62.1 rules 2, 3 and 8).
func newSealer(opts EncryptOptions, length uint64) (*sealer, error) {
p := opts.Profile
if p == nil {
return nil, errors.New("capsule: EncryptOptions.Profile is required")
@ -110,47 +163,52 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
if !opts.UnlockAt.After(opts.Now()) {
return nil, fmt.Errorf("capsule: unlock time %s is not in the future", opts.UnlockAt.UTC().Format(time.RFC3339Nano))
}
if opts.Length < 0 {
return nil, fmt.Errorf("capsule: EncryptOptions.Length %d is negative", opts.Length)
}
code := opts.Padding
if code == 0 {
code = Reforzado
s := &sealer{opts: opts, code: opts.Padding}
if s.code == 0 {
s.code = Reforzado
}
length := uint64(opts.Length)
padded, err := PaddedLength(length, code)
if err != nil {
if _, err := PaddedLength(length, s.code); err != nil {
return nil, err
}
// Step 2: resolve the DateKey locally.
dk, err := datekey.Resolve(p, opts.UnlockAt)
if err != nil {
// Step 4 of spec §61: resolve the DateKey locally.
var err error
if s.dk, err = datekey.Resolve(p, opts.UnlockAt); err != nil {
return nil, err
}
unlock := dk.UnlockAt(p)
s.unlock = s.dk.UnlockAt(p)
// Spec §17: round_time(round) >= requested_unlock_at, never earlier.
if unlock.Before(opts.UnlockAt) {
return nil, fmt.Errorf("capsule: resolved round %d opens before the requested time: %w", dk.Round, datekeys.ErrRoundMismatch)
if s.unlock.Before(opts.UnlockAt) {
return nil, fmt.Errorf("capsule: resolved round %d opens before the requested time: %w", s.dk.Round, datekeys.ErrRoundMismatch)
}
if s.credentials, s.portable, err = accessRecipients(opts); err != nil {
return nil, err
}
return s, nil
}
credentials, portable, err := accessRecipients(opts)
// write writes a capsule of format f whose content, of length bytes, body
// writes into the plaintext of PAYLOAD_AGE; write adds the zeros of the
// padding up to P (spec §29.1).
func (s *sealer) write(dst io.Writer, f Format, length uint64, body func(w io.Writer) error) (*Result, error) {
opts := s.opts
padded, err := PaddedLength(length, s.code)
if err != nil {
return nil, err
}
var portableRaw []byte
if portable != nil {
if portableRaw, err = agewrap.RawX25519Identity(portable); err != nil {
if s.portable != nil {
if portableRaw, err = agewrap.RawX25519Identity(s.portable); err != nil {
return nil, err
}
defer clear(portableRaw)
}
// Step 3: capsule_id, 16 random bytes (spec §21).
// Step 5 of spec §61: capsule_id, 16 random bytes (spec §21).
var capsuleID [CapsuleIDSize]byte
_, _ = rand.Read(capsuleID[:]) // never fails since Go 1.24
// Step 4: I_PAYLOAD, a fresh X25519 identity (spec §29).
// Step 6: I_PAYLOAD, a fresh X25519 identity (spec §29).
payloadID, err := age.GenerateX25519Identity()
if err != nil {
return nil, err
@ -161,17 +219,17 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
}
defer clear(payloadRaw)
// Step 6 of spec §62: the 16 recipients of INNER_ACCESS_AGE, the
// Step 7 of spec §62: the 16 recipients of INNER_ACCESS_AGE, the
// credentials and a dummy in each slot left, in a random order.
var access []age.Recipient
if opts.Policy == TimeAndKey {
if access, err = fillSlots(credentials); err != nil {
if access, err = fillSlots(s.credentials); err != nil {
return nil, err
}
}
// Step 5 (7 of spec §62): PUBLIC_HEADER.
header := &Header{CapsuleID: capsuleID, DateKey: dk, Policy: opts.Policy, Critical: opts.Critical, Noncritical: opts.Noncritical}
// Step 7 of spec §61 (8 of §62): PUBLIC_HEADER.
header := &Header{CapsuleID: capsuleID, DateKey: s.dk, Policy: opts.Policy, Critical: opts.Critical, Noncritical: opts.Noncritical}
headerBytes, err := EncodeHeader(header)
if err != nil {
return nil, err
@ -180,7 +238,7 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return nil, err
}
timeRecipient, err := agewrap.NewTimeRecipient(p, dk.Round)
timeRecipient, err := agewrap.NewTimeRecipient(opts.Profile, s.dk.Round)
if err != nil {
return nil, err
}
@ -192,7 +250,7 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
if err != nil {
return nil, err
}
if err := selfCheckInner(innerAge, control, portable); err != nil {
if err := selfCheckInner(innerAge, control, s.portable); err != nil {
return nil, err
}
plaintext = innerAge
@ -201,18 +259,18 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return encryptAll(plaintext, timeRecipient)
}
// Steps 6 to 10 (8 to 13 of spec §62). PRELUDE carries
// Steps 8 to 11 of spec §61 (9 to 12 of §62). PRELUDE carries
// SEALED_CONTROL_LEN and header_binding covers PRELUDE, so the length is
// measured first by sealing a control of identical size with a zero
// binding and a zero identity: the length of a version 2 control does not
// depend on them, on L or on the padding code (spec §62.1 rule 7). age
// output lengths depend only on plaintext length and stanza shapes; the
// real seal is checked to have the same length.
// binding and a zero identity: the length of a control of version 2 or
// 3 does not depend on them, on L or on the padding code (spec §62.1
// rule 7). age output lengths depend only on plaintext length and stanza
// shapes; the real seal is checked to have the same length.
ctrl := &Control{
Critical: opts.ControlCritical, Noncritical: opts.ControlNoncritical,
PayloadLength: length, Padding: code,
PayloadLength: length, Padding: s.code,
}
draft, err := EncodeControl(ctrl, Format2)
draft, err := EncodeControl(ctrl, f)
if err != nil {
return nil, err
}
@ -223,7 +281,7 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
if len(draftSealed) > MaxSealedControlLen {
return nil, fmt.Errorf("capsule: SEALED_CONTROL of %d bytes exceeds %d: %w", len(draftSealed), MaxSealedControlLen, datekeys.ErrIntegrity)
}
prelude := Prelude{Format: Format2, PublicHeaderLen: uint32(len(headerBytes)), SealedControlLen: uint32(len(draftSealed))}
prelude := Prelude{Format: f, PublicHeaderLen: uint32(len(headerBytes)), SealedControlLen: uint32(len(draftSealed))}
preludeBytes := prelude.Bytes()
// header_binding = SHA-256(PRELUDE || PUBLIC_HEADER_BYTES).
@ -231,13 +289,13 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
copy(ctrl.PayloadIdentity[:], payloadRaw)
defer clear(ctrl.PayloadIdentity[:])
// CONTROL_CBOR, schema version 2, with L and the padding code.
controlBytes, err := EncodeControl(ctrl, Format2)
// CONTROL_CBOR, with L and the padding code.
controlBytes, err := EncodeControl(ctrl, f)
if err != nil {
return nil, err
}
defer clear(controlBytes)
if err := selfCheckControl(controlBytes); err != nil {
if err := selfCheckControl(controlBytes, f); err != nil {
return nil, err
}
@ -265,7 +323,14 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
if err != nil {
return nil, err
}
if err := writeContent(aw, src, opts.Length, padded); err != nil {
content := &countingWriter{w: aw}
if err := body(content); err != nil {
return nil, err
}
if content.n != length {
return nil, fmt.Errorf("capsule: internal error: %d bytes of content, L = %d", content.n, length)
}
if err := writeZeros(aw, padded-length); err != nil {
return nil, err
}
if err := aw.Close(); err != nil {
@ -275,9 +340,9 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return nil, err
}
res := &Result{DateKey: dk, UnlockAt: unlock, CapsuleID: capsuleID, Format: Format2, Length: length, Padding: code, PaddedLength: padded}
if portable != nil {
// The portable identity as 32 raw bytes in a .dkk (§62 step 17).
res := &Result{DateKey: s.dk, UnlockAt: s.unlock, CapsuleID: capsuleID, Format: f, Length: length, Padding: s.code, PaddedLength: padded}
if s.portable != nil {
// The portable identity as 32 raw bytes in a .dkk (§62 step 18).
k := &accesskey.AccessKey{
CapsuleID: capsuleID,
Type: accesskey.TypeX25519,
@ -290,13 +355,12 @@ func Encrypt(dst io.Writer, src io.Reader, opts EncryptOptions) (*Result, error)
return res, nil
}
// writeContent writes to the age writer aw exactly length bytes of src, then
// the zeros of the padding up to padded bytes (spec §29.1). A source that
// copyExactly writes to w exactly length bytes of src. A source that
// delivers fewer or more than length bytes is an error: the capsule would
// fail at step 17, after the date, when it can no longer be repaired (spec
// §62.1 rule 6).
func writeContent(aw io.Writer, src io.Reader, length int64, padded uint64) error {
n, err := io.CopyN(aw, src, length)
func copyExactly(w io.Writer, src io.Reader, length int64) error {
n, err := io.CopyN(w, src, length)
if err == io.EOF {
return fmt.Errorf("capsule: the source ended after %d bytes, and EncryptOptions.Length is %d", n, length)
}
@ -310,17 +374,34 @@ func writeContent(aw io.Writer, src io.Reader, length int64, padded uint64) erro
case err != io.EOF:
return err
}
zeros := make([]byte, min(padded-uint64(length), 16<<10))
for left := padded - uint64(length); left > 0; {
k := min(left, uint64(len(zeros)))
if _, err := aw.Write(zeros[:k]); err != nil {
return nil
}
// writeZeros writes n zeros to w: the padding of spec §29.1.
func writeZeros(w io.Writer, n uint64) error {
zeros := make([]byte, min(n, 16<<10))
for n > 0 {
k := min(n, uint64(len(zeros)))
if _, err := w.Write(zeros[:k]); err != nil {
return err
}
left -= k
n -= k
}
return nil
}
// countingWriter counts the bytes written to w.
type countingWriter struct {
w io.Writer
n uint64
}
func (c *countingWriter) Write(b []byte) (int, error) {
n, err := c.w.Write(b)
c.n += uint64(n)
return n, err
}
// payloadWriter counts the bytes of PAYLOAD_AGE and keeps the first ones,
// where its age header is, for the self-check.
type payloadWriter struct {
@ -352,11 +433,12 @@ func selfCheckHeader(b []byte) error {
return nil
}
// selfCheckControl decodes CONTROL_CBOR with the reader's decoder before it is
// sealed. A control that the reader rejects would only be found at step 14
// of spec §63, after the unlock, when the capsule can no longer be repaired.
func selfCheckControl(b []byte) error {
c, err := DecodeControl(b, Format2)
// selfCheckControl decodes CONTROL_CBOR of format f with the reader's
// decoder before it is sealed. A control that the reader rejects would only
// be found at step 14 of spec §63, after the unlock, when the capsule can no
// longer be repaired (spec §62.1 rules 11 and 17).
func selfCheckControl(b []byte, f Format) error {
c, err := DecodeControl(b, f)
if err != nil {
return fmt.Errorf("capsule: self-check: the reader rejects this CONTROL_CBOR: %w", err)
}

@ -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)
}

@ -29,7 +29,7 @@ func past(t *testing.T, round uint64) capsule.EncryptOptions {
if err != nil {
t.Fatal(err)
}
return capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis())}
return capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Now: testkit.Fixed(testkit.Genesis()), TestVectors: true}
}
// encrypt runs capsule.Encrypt with content as its source, of the length it
@ -234,7 +234,7 @@ func TestPortableKeysAreNeverReused(t *testing.T) {
func TestFutureCapsuleStaysLockedWithoutRequests(t *testing.T) {
p := profile.Quicknet()
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.UTC)
opts := capsule.EncryptOptions{Profile: p, UnlockAt: now.Add(time.Hour), Now: testkit.Fixed(now)}
opts := capsule.EncryptOptions{Profile: p, UnlockAt: now.Add(time.Hour), Now: testkit.Fixed(now), TestVectors: true}
var dkc bytes.Buffer
res, err := encrypt(t, &dkc, "secret", opts)
if err != nil {

@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"fmt"
"io"
"strings"
"time"
@ -20,6 +21,35 @@ import (
// comes from drand.New(), which verifies it the same way.
var round1000, _ = hex.DecodeString("b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39")
// memorySink is a capsule.Sink that keeps the files in memory. A Sink that
// writes to disk, as the datekeys CLI does, writes them to a temporary
// folder and moves it into place in Commit.
type memorySink struct{ files []*bytes.Buffer }
func (m *memorySink) Begin(h *capsule.Head) error {
m.files = make([]*bytes.Buffer, len(h.Files))
return nil
}
func (m *memorySink) Create(i int) (io.WriteCloser, error) {
m.files[i] = new(bytes.Buffer)
return bufferCloser{m.files[i]}, nil
}
func (m *memorySink) Commit() error { return nil }
func (m *memorySink) Abort() { m.files = nil }
type bufferCloser struct{ io.Writer }
func (bufferCloser) Close() error { return nil }
// source is a capsule.Source of a file held in memory.
func source(path, content string) capsule.Source {
return capsule.Source{Path: path, Size: int64(len(content)), Open: func() (io.ReadCloser, error) {
return io.NopCloser(strings.NewReader(content)), nil
}}
}
func Example() {
reg, err := profile.Default()
if err != nil {
@ -31,12 +61,12 @@ func Example() {
// (2023-08-23T15:59:24Z) "the future", so the example runs offline.
genesis := func() time.Time { return time.Unix(p.GenesisTime, 0) }
var dkc bytes.Buffer
res, err := capsule.Encrypt(&dkc, strings.NewReader("hello from the past"), capsule.EncryptOptions{
res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("carta.txt", "hello from the past")}, capsule.EncryptOptions{
Profile: p,
UnlockAt: time.Date(2023, 8, 23, 15, 59, 24, 0, time.UTC),
Policy: capsule.TimeAndKey,
NewPortableKey: true,
Length: int64(len("hello from the past")),
Comment: "Para abrir dentro de un rato.",
Now: genesis,
})
if err != nil {
@ -53,31 +83,38 @@ func Example() {
src := provider.ReleaseSourceFunc(func(_ context.Context, _ *profile.Profile, c provider.Condition) (provider.Release, error) {
return provider.Release{Round: c.Round, Signature: round1000}, nil
})
opts := capsule.OpenOptions{Registry: reg, Source: src, AccessKey: key, Now: genesis}
_, err = capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), opts)
files := &memorySink{}
opts := capsule.OpenOptions{Registry: reg, Source: src, AccessKey: key, Now: genesis, Sink: files}
_, err = capsule.Open(context.Background(), nil, bytes.NewReader(dkc.Bytes()), opts)
fmt.Println("too early:", errors.Is(err, datekeys.ErrReleaseUnavailable))
// After the round: the release is verified locally and the capsule opens.
// The verdicts of the security area come before the comment.
opts.Now = time.Now
var plain bytes.Buffer
if _, err := capsule.Open(context.Background(), &plain, bytes.NewReader(dkc.Bytes()), opts); err != nil {
opened, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc.Bytes()), opts)
if err != nil {
panic(err)
}
fmt.Println(plain.String())
for _, line := range opened.Verdicts.Lines() {
fmt.Println(line)
}
fmt.Println(opened.Head.Comment)
fmt.Println(opened.Head.Files[0].Path+":", files.files[0])
// Output:
// round 1000 unlocks at 2023-08-23T15:59:24Z
// too early: true
// hello from the past
// Sin firma de autor.
// Para abrir dentro de un rato.
// carta.txt: hello from the past
}
func ExampleInspect() {
reg, _ := profile.Default()
p := profile.Quicknet()
var dkc bytes.Buffer
_, _ = capsule.Encrypt(&dkc, strings.NewReader("x"), capsule.EncryptOptions{
_, _ = capsule.EncryptFiles(&dkc, []capsule.Source{source("x.txt", "x")}, capsule.EncryptOptions{
Profile: p,
UnlockAt: time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC),
Length: 1,
Now: func() time.Time { return time.Date(2026, 9, 25, 0, 0, 0, 0, time.UTC) },
})
in, err := capsule.Inspect(bytes.NewReader(dkc.Bytes()), capsule.InspectOptions{Registry: reg})

@ -651,6 +651,20 @@ func EncodeHead(h *Head) ([]byte, error) {
// R7 and R9 over the tree (§29.5), all ErrHeadInvalid, and the critical
// extensions with reg (layer 4).
func DecodeHead(b []byte, reg extension.Registry) (*Head, error) {
h, err := decodeHead(b)
if err != nil {
return nil, err
}
if err := extension.CheckCriticalIn(extension.Head, h.Critical, reg); err != nil {
return nil, fmt.Errorf("capsule: head: %w", err)
}
return h, nil
}
// decodeHead is DecodeHead but for the critical extensions, whose knowledge
// depends on the reader: the self-check of a writer decodes with it, as it
// decodes the control with DecodeControl (spec §62.1 rule 17).
func decodeHead(b []byte) (*Head, error) {
if len(b) > MaxHeadLen {
return nil, fmt.Errorf("capsule: head: %d bytes, more than %d: %w", len(b), MaxHeadLen, datekeys.ErrIntegrity)
}
@ -668,9 +682,6 @@ func DecodeHead(b []byte, reg extension.Registry) (*Head, error) {
if err := checkHeadFields(h); err != nil {
return nil, err
}
if err := extension.CheckCriticalIn(extension.Head, h.Critical, reg); err != nil {
return nil, fmt.Errorf("capsule: head: %w", err)
}
return h, nil
}

@ -9,7 +9,6 @@ import (
"bytes"
"context"
"errors"
"strings"
"testing"
"time"
@ -39,28 +38,26 @@ func TestLiveLifecycle(t *testing.T) {
}
const msg = "DateKeys live integration: this stays on the local machine."
var dkc bytes.Buffer
opts.Length = int64(len(msg))
res, err := capsule.Encrypt(&dkc, strings.NewReader(msg), opts)
res, err := capsule.EncryptFiles(&dkc, []capsule.Source{source("live.txt", msg)}, opts)
if err != nil {
t.Fatal(err)
}
o := capsule.OpenOptions{Registry: reg, Source: drand.New(), Now: time.Now, Identities: []age.Identity{holder}}
if _, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc.Bytes()), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
files := &memorySink{}
o := capsule.OpenOptions{Registry: reg, Source: drand.New(), Now: time.Now, Identities: []age.Identity{holder}, Sink: files}
if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(dkc.Bytes()), o); !errors.Is(err, datekeys.ErrReleaseUnavailable) {
t.Fatalf("opened before the round: %v", err)
}
t.Logf("locked for round %d until %s", res.DateKey.Round, res.UnlockAt.Format(time.RFC3339))
time.Sleep(time.Until(res.UnlockAt) + 2*time.Second)
var out bytes.Buffer
deadline := time.Now().Add(30 * time.Second)
for {
_, err = capsule.Open(context.Background(), &out, bytes.NewReader(dkc.Bytes()), o)
_, err = capsule.Open(context.Background(), nil, bytes.NewReader(dkc.Bytes()), o)
if err == nil || !errors.Is(err, datekeys.ErrReleaseUnavailable) || time.Now().After(deadline) {
break
}
out.Reset()
time.Sleep(time.Second)
}
if err != nil || out.String() != msg {
if err != nil || len(files.files) != 1 || files.files[0].String() != msg {
t.Fatalf("open after the round: %v", err)
}
})

@ -233,7 +233,7 @@ func TestOpen3Substeps(t *testing.T) {
{"paths b and a, in that order", with(two, func(c *capsule3) { c.paths = []string{"b.txt", "a.txt"} }), datekeys.ErrNonCanonicalCBOR, false, true},
{"path ..", with(two, func(c *capsule3) { c.head = dotdot }), datekeys.ErrHeadInvalid, false, true},
{"paths A.txt and a.txt", with(two, func(c *capsule3) { c.paths = []string{"A.txt", "a.txt"} }), datekeys.ErrHeadInvalid, false, true},
{"comment with U+202E", with(two, func(c *capsule3) { c.comment = "a‮b" }), datekeys.ErrHeadInvalid, false, true},
{"comment with U+202E", with(two, func(c *capsule3) { c.comment = "a\u202eb" }), datekeys.ErrHeadInvalid, false, true},
{"start of an entry not the end of the one before", with(two, func(c *capsule3) {
c.head = func(h *capsule.Head) { h.Files[1].Start, h.Files[1].End = 2, 5 }
}), datekeys.ErrHeadInvalid, false, true},

@ -173,7 +173,9 @@ func encrypt(args []string, stderr io.Writer, now func() time.Time) error {
if *in == "" || *out == "" {
return errors.New("encrypt: -in and -out are required")
}
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code, Now: now}
// TestVectors keeps format 2 until encrypt moves to EncryptFiles, in step
// 5 of the plan of format 3.
opts := capsule.EncryptOptions{Profile: profile.Quicknet(), UnlockAt: unlock, Policy: pol, NewPortableKey: *dkk != "", Padding: code, Now: now, TestVectors: true}
for _, r := range recipients {
x, err := age.ParseX25519Recipient(r)
if err != nil {

@ -26,6 +26,10 @@ const (
type Error struct {
Rule string // "R2" to "R10", "text"
Detail string
// Paths are the positions, from 1, of the two paths of an R7
// violation, the later one first, so that a writer can name them; zero
// for the other rules.
Paths [2]int
}
func (e *Error) Error() string { return e.Rule + ": " + e.Detail }
@ -307,9 +311,9 @@ func CheckTree(paths []string) error {
case !ok:
kids[k] = node{name: s, dir: isDir, path: n + 1}
case old.name != s:
return fail("R7", "path %d collides with path %d in segment %d", n+1, old.path, i+1)
return &Error{Rule: "R7", Detail: fmt.Sprintf("path %d collides with path %d in segment %d", n+1, old.path, i+1), Paths: [2]int{n + 1, old.path}}
case old.dir != isDir || !isDir:
return fail("R7", "path %d makes a file of path %d a folder, or the reverse, in segment %d", n+1, old.path, i+1)
return &Error{Rule: "R7", Detail: fmt.Sprintf("path %d makes a file of path %d a folder, or the reverse, in segment %d", n+1, old.path, i+1), Paths: [2]int{n + 1, old.path}}
}
if parent == "" {
parent = k

@ -474,6 +474,7 @@ func generate(dir string, s spec) error {
Profile: p, UnlockAt: unlock, Policy: s.policy, NewPortableKey: s.portable,
Length: int64(len(s.plaintext)), Padding: s.padding,
Noncritical: s.headerExt, ControlNoncritical: s.controlExt, Now: testkit.Fixed(testkit.Genesis()),
TestVectors: true,
}
var ids []*age.X25519Identity
for range s.recipients {

@ -303,7 +303,7 @@ func (e *MutationEnv) Sibling(f capsule.Format) (Parts, error) {
if err != nil {
return Parts{}, err
}
opts := capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Length: int64(len(content)), Now: Fixed(Genesis())}
opts := capsule.EncryptOptions{Profile: p, UnlockAt: unlock, Length: int64(len(content)), Now: Fixed(Genesis()), TestVectors: true}
if _, err := capsule.Encrypt(&b, bytes.NewReader(content), opts); err != nil {
return Parts{}, err
}

Loading…
Cancel
Save

Powered by TurnKey Linux.