From 8955c0f650f6ca8b84de1bd647d7188388596f1a Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 30 Sep 2026 19:32:44 +0200 Subject: [PATCH] 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 --- capsule/encrypt.go | 222 +++++++++++++------- capsule/encrypt3.go | 293 ++++++++++++++++++++++++++ capsule/encrypt3_test.go | 296 +++++++++++++++++++++++++++ capsule/encrypt_test.go | 4 +- capsule/example_test.go | 57 +++++- capsule/format3.go | 17 +- capsule/live_test.go | 15 +- capsule/open3_test.go | 2 +- cmd/datekeys/main.go | 4 +- internal/pathrule/rules.go | 8 +- internal/testkit/genfixtures/main.go | 1 + internal/testkit/mutations.go | 2 +- 12 files changed, 822 insertions(+), 99 deletions(-) create mode 100644 capsule/encrypt3.go create mode 100644 capsule/encrypt3_test.go diff --git a/capsule/encrypt.go b/capsule/encrypt.go index 25193a7..f26dc15 100644 --- a/capsule/encrypt.go +++ b/capsule/encrypt.go @@ -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) } diff --git a/capsule/encrypt3.go b/capsule/encrypt3.go new file mode 100644 index 0000000..43ec558 --- /dev/null +++ b/capsule/encrypt3.go @@ -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 +} diff --git a/capsule/encrypt3_test.go b/capsule/encrypt3_test.go new file mode 100644 index 0000000..c9414a8 --- /dev/null +++ b/capsule/encrypt3_test.go @@ -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) +} diff --git a/capsule/encrypt_test.go b/capsule/encrypt_test.go index a69205d..0e77a4b 100644 --- a/capsule/encrypt_test.go +++ b/capsule/encrypt_test.go @@ -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 { diff --git a/capsule/example_test.go b/capsule/example_test.go index 2484d60..9c941df 100644 --- a/capsule/example_test.go +++ b/capsule/example_test.go @@ -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}) diff --git a/capsule/format3.go b/capsule/format3.go index f1bc5a6..0e7a031 100644 --- a/capsule/format3.go +++ b/capsule/format3.go @@ -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 } diff --git a/capsule/live_test.go b/capsule/live_test.go index 3bb6f1e..bf200dd 100644 --- a/capsule/live_test.go +++ b/capsule/live_test.go @@ -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) } }) diff --git a/capsule/open3_test.go b/capsule/open3_test.go index a9cf50d..d87e05a 100644 --- a/capsule/open3_test.go +++ b/capsule/open3_test.go @@ -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}, diff --git a/cmd/datekeys/main.go b/cmd/datekeys/main.go index d71e7ea..bb216ce 100644 --- a/cmd/datekeys/main.go +++ b/cmd/datekeys/main.go @@ -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 { diff --git a/internal/pathrule/rules.go b/internal/pathrule/rules.go index c0e070a..bcb7d96 100644 --- a/internal/pathrule/rules.go +++ b/internal/pathrule/rules.go @@ -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 diff --git a/internal/testkit/genfixtures/main.go b/internal/testkit/genfixtures/main.go index d60370b..c2da540 100644 --- a/internal/testkit/genfixtures/main.go +++ b/internal/testkit/genfixtures/main.go @@ -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 { diff --git a/internal/testkit/mutations.go b/internal/testkit/mutations.go index 8f39e36..8ac123b 100644 --- a/internal/testkit/mutations.go +++ b/internal/testkit/mutations.go @@ -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 }