diff --git a/capsule/format2_test.go b/capsule/format2_test.go index 37acf29..5a02dfe 100644 --- a/capsule/format2_test.go +++ b/capsule/format2_test.go @@ -23,7 +23,7 @@ import ( // The tests of this file cover format 2 (spec v0.9): the changes of spec §76 // "Cambios normativos de la v0.9" and the tests it names. -// Spec §22, §23, §70: a reader opens both formats and reports which; any +// Spec §22, §23, §70: a reader opens every format and reports which; any // other VERSION is rejected at step 2, without a request. func TestFormatDispatch(t *testing.T) { for _, name := range fixtureNames { @@ -35,7 +35,7 @@ func TestFormatDispatch(t *testing.T) { } } f := loadFixture(t, "format2_time_only") - for _, v := range []byte{0, 3, 0x80, 0xff} { + for _, v := range []byte{0, 4, 0x80, 0xff} { dkc := bytes.Clone(f.dkc) dkc[4] = v step, calls, err := openStep(t, dkc, capsule.OpenOptions{Now: testkit.Fixed(f.unlock(t))}) @@ -44,10 +44,39 @@ func TestFormatDispatch(t *testing.T) { t.Errorf("VERSION %d: %d release requests", v, calls) } } + + // VERSION 3 is format 3 since v0.10: it passes step 2. Without a Sink, + // Open stops right there with ErrSinkRequired, a caller error with no code, + // no failed step and no request; with one, the version 2 control fails at + // step 14. + dkc := bytes.Clone(f.dkc) + dkc[4] = 3 + src := testkit.NewSource(f.release) + opened, err := capsule.Open(context.Background(), &bytes.Buffer{}, bytes.NewReader(dkc), + capsule.OpenOptions{Registry: testkit.Registry(), Source: src, Now: testkit.Fixed(f.unlock(t))}) + if !errors.Is(err, capsule.ErrSinkRequired) || datekeys.Code(err) != "" || src.Calls != 0 || opened.Format != capsule.Format3 { + t.Errorf("VERSION 3 without a Sink: %v, code %q, %d requests, format %d", err, datekeys.Code(err), src.Calls, opened.Format) + } + if c := opened.Inspection.Checks; len(c) != 2 || !c[0].OK || !c[1].OK || c[1].Step != 2 { + t.Errorf("VERSION 3 without a Sink: checks %+v", c) + } + step, calls, err := openStep(t, dkc, capsule.OpenOptions{Now: testkit.Fixed(f.unlock(t)), Sink: testkit.DiscardSink{}}) + expectStep(t, "VERSION 3 with a Sink", step, err, datekeys.ErrUnsupportedVersion, 14) + if calls != 1 { + t.Errorf("VERSION 3 with a Sink: %d release requests", calls) + } + + // A capsule of format 1 or 2 needs dst, whatever the Sink. + src = testkit.NewSource(f.release) + o := f.openOptions(t) + o.Source, o.Sink = src, testkit.DiscardSink{} + if _, err := capsule.Open(context.Background(), nil, bytes.NewReader(f.dkc), o); err == nil || datekeys.Code(err) != "" || src.Calls != 0 { + t.Errorf("format 2 without dst: %v, %d requests", err, src.Calls) + } } // Spec §22, §76 change 1: VERSION is public and anyone can edit it. A capsule -// relabeled to the other format fails at step 12 or 14, after the release, +// relabeled to another format fails at step 12 or 14, after the release, // and nothing is written. func TestFormatRelabel(t *testing.T) { relabel := func(dkc []byte, v byte) []byte { @@ -86,8 +115,15 @@ func TestFormatRelabel(t *testing.T) { {"format 1 time_only relabeled 2", relabel(to1.dkc, 2), nil, to1, datekeys.ErrUnsupportedVersion, 14}, {"format 1 time_and_key with one stanza relabeled 2", relabel(tk1.dkc, 2), identity(tk1), tk1, datekeys.ErrPolicyStructureMismatch, 12}, {"format 1 time_and_key with 16 stanzas relabeled 2", relabel(b.DKC, 2), []age.Identity{stranger}, nil, datekeys.ErrUnsupportedVersion, 14}, + // Format 3 has the 16 stanzas and the padding of format 2 (spec §29.1). + {"format 2 time_only relabeled 3", relabel(to2.dkc, 3), nil, to2, datekeys.ErrUnsupportedVersion, 14}, + {"format 2 time_and_key relabeled 3", relabel(tk2.dkc, 3), identity(tk2), tk2, datekeys.ErrUnsupportedVersion, 14}, + {"format 1 time_only relabeled 3", relabel(to1.dkc, 3), nil, to1, datekeys.ErrUnsupportedVersion, 14}, + {"format 1 time_and_key with one stanza relabeled 3", relabel(tk1.dkc, 3), identity(tk1), tk1, datekeys.ErrPolicyStructureMismatch, 12}, + {"format 1 time_and_key with 16 stanzas relabeled 3", relabel(b.DKC, 3), []age.Identity{stranger}, nil, datekeys.ErrUnsupportedVersion, 14}, } { - o := capsule.OpenOptions{Identities: tc.ids} + // The Sink plays no part in formats 1 and 2. + o := capsule.OpenOptions{Identities: tc.ids, Sink: testkit.DiscardSink{}} if tc.now != nil { o.Now = testkit.Fixed(tc.now.unlock(t)) } diff --git a/capsule/framing.go b/capsule/framing.go index c54695e..a2fccb5 100644 --- a/capsule/framing.go +++ b/capsule/framing.go @@ -134,9 +134,7 @@ func ParsePrelude(b []byte) (Prelude, error) { if len(b) < PreludeSize { return Prelude{}, fmt.Errorf("capsule: truncated prelude: %w", datekeys.ErrIntegrity) } - // Format 3 is read from step 3 of the plan of format 3 on, together with - // the test data that expect VERSION 3 to be rejected here. - if f := Format(b[4]); !f.valid() || f == Format3 { + if !Format(b[4]).valid() { return Prelude{}, fmt.Errorf("capsule: framing version %d: %w", b[4], datekeys.ErrUnsupportedVersion) } if b[5] != 0 || b[6] != 0 || b[7] != 0 { diff --git a/capsule/inspect.go b/capsule/inspect.go index 3c18839..ff1ace3 100644 --- a/capsule/inspect.go +++ b/capsule/inspect.go @@ -94,11 +94,13 @@ type parsed struct { // the payload; it does not read the rest of the payload. On failure it // returns the partial Inspection together with the error. func Inspect(r io.Reader, opts InspectOptions) (*Inspection, error) { - in, _, err := inspect(r, opts) + in, _, err := inspect(r, opts, nil) return in, err } -func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) { +// inspect runs steps 1 to 8. afterPrelude, when not nil, runs right after +// step 2 passes: its error, a caller's one, ends inspect there. +func inspect(r io.Reader, opts InspectOptions, afterPrelude func(Prelude) error) (*Inspection, *parsed, error) { in := &Inspection{} if opts.Registry == nil { return in, nil, errors.New("capsule: InspectOptions.Registry is required") @@ -122,6 +124,11 @@ func inspect(r io.Reader, opts InspectOptions) (*Inspection, *parsed, error) { in.PayloadOffset = prelude.PayloadOffset() in.pass(2, "prelude", fmt.Sprintf("DKC1 v%d, PUBLIC_HEADER_LEN=%d, SEALED_CONTROL_LEN=%d", prelude.Format, prelude.PublicHeaderLen, prelude.SealedControlLen)) + if afterPrelude != nil { + if err := afterPrelude(prelude); err != nil { + return in, nil, err + } + } // Step 3: read the exact PUBLIC_HEADER bytes. hb, err := readExactly(r, int64(prelude.PublicHeaderLen)) diff --git a/capsule/open.go b/capsule/open.go index 1838f9c..992f32d 100644 --- a/capsule/open.go +++ b/capsule/open.go @@ -55,6 +55,10 @@ type OpenOptions struct { // clock on its own. Open does not ask Source for a round whose time has // not been reached. Now func() time.Time + // Sink receives the files of a format 3 capsule; dst receives the + // content of formats 1 and 2. Open fails right after step 2 with + // ErrSinkRequired when a format 3 capsule has no Sink. + Sink Sink } // Opened describes a capsule that Open decrypted completely. @@ -73,6 +77,17 @@ type Opened struct { PayloadLength uint64 Padding Padding PaddedLength uint64 + // In format 3, Head is the head, as step 17.4 validated it, Verdicts + // are the verdicts of the security area, which a caller shows before the + // declared author and the comment (spec §29.7), and AreaLen is the size + // of that area. PayloadLength is then the length of BODY. + Head *Head + Verdicts Verdicts + AreaLen uint32 + // UnusableHeadExtensions are the known noncritical extensions of the + // head that OpenOptions.Extensions rejects, as UnusableControlExtensions + // are for the control (spec §29.4, §54). + UnusableHeadExtensions []extension.Unusable // ControlCritical and ControlNoncritical are the extensions of the sealed // CONTROL_CBOR, only visible after opening. ControlCritical []extension.Extension @@ -118,8 +133,17 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O } } - // Steps 1 to 8. - in, st, err := inspect(r, InspectOptions{Registry: opts.Registry, Extensions: opts.Extensions}) + // Steps 1 to 8. Right after step 2, the format decides where the content + // goes: a format 3 capsule needs a Sink, and the others dst. + in, st, err := inspect(r, InspectOptions{Registry: opts.Registry, Extensions: opts.Extensions}, func(p Prelude) error { + switch { + case p.Format == Format3 && opts.Sink == nil: + return ErrSinkRequired + case p.Format != Format3 && dst == nil: + return errWriterRequired + } + return nil + }) out := &Opened{Inspection: in, Format: in.Prelude.Format} if err != nil { return out, err @@ -264,7 +288,7 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O return out, in.fail(16, "payload identity", err) } detail := "I_PAYLOAD recovered" - if format == Format2 { + if format.padded() { if out.PaddedLength, err = PaddedLength(control.PayloadLength, control.Padding); err != nil { return out, in.fail(16, "payload identity", err) } @@ -280,6 +304,15 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O if err != nil { return out, in.fail(17, "open payload", classify("PAYLOAD_AGE", ageHeaderFailure, err)) } + if format == Format3 { + out.PayloadLength = control.PayloadLength + if err := openBody(pr, control.PayloadLength, out.PaddedLength, opts.Sink, opts.Extensions, out); err != nil { + return out, in.fail(17, "open payload", err) + } + in.pass(17, "open payload", fmt.Sprintf("payload authenticated; BODY of %d bytes, %d files, area of %d bytes", control.PayloadLength, len(out.Head.Files), out.AreaLen)) + in.pass(18, "commit", fmt.Sprintf("%d files", len(out.Head.Files))) + return out, nil + } w := &plaintextWriter{w: dst} var n int64 if format == Format2 { @@ -317,7 +350,7 @@ func Open(ctx context.Context, dst io.Writer, r io.Reader, opts OpenOptions) (*O // accessSlots is the number of stanzas INNER_ACCESS_AGE must hold in a // capsule of format f, 0 meaning one or more (spec §33, §39). func accessSlots(f Format) int { - if f == Format2 { + if f.padded() { return agewrap.AccessSlots } return 0 diff --git a/capsule/open3.go b/capsule/open3.go new file mode 100644 index 0000000..62106e5 --- /dev/null +++ b/capsule/open3.go @@ -0,0 +1,250 @@ +package capsule + +import ( + "crypto/hmac" + "crypto/sha256" + "errors" + "fmt" + "io" + + datekeys "g.activething.com/go/DateKeys" + "g.activething.com/go/DateKeys/extension" +) + +// Sink receives the files of a format 3 capsule (spec §56, §63 steps 17 and +// 18). Open calls Begin once the head is validated, then Create for each +// file in the order of the head, and then Commit, only after every check of +// step 17 has passed. After any failure that follows a successful Begin, a +// failure of Commit included, it calls Abort, once; a Begin that fails +// cleans up after itself. Nothing a Sink receives before Commit may be +// presented as valid: write to a temporary place and publish it in Commit. +type Sink interface { + // Begin receives the validated head. Its files are h.Files. + Begin(h *Head) error + // Create returns the writer of file i of the head. Open closes it after + // writing its Size bytes, before checking its SHA-256. Size, Start and + // End are declared, not received: a Sink must not reserve memory or disk + // by them (spec §57). + Create(i int) (io.WriteCloser, error) + // Commit publishes the files: step 18. + Commit() error + // Abort discards everything the Sink received. + Abort() +} + +// ErrSinkRequired is the error of Open for a format 3 capsule without +// OpenOptions.Sink: its content is several files, which one io.Writer cannot +// receive. It is an error of the caller, with no normative code: Open returns +// it right after step 2, before any request, and never reports the capsule +// as ErrUnsupportedVersion, because it is valid (spec §70). +var ErrSinkRequired = errors.New("capsule: a format 3 capsule holds files: OpenOptions.Sink is required") + +// errWriterRequired is the error of Open for a capsule of format 1 or 2 with +// a nil dst. +var errWriterRequired = errors.New("capsule: a capsule of format 1 or 2 holds one content: dst is required") + +// plainReader reads the plaintext of PAYLOAD_AGE and counts its bytes. +type plainReader struct { + r io.Reader + n uint64 +} + +func (p *plainReader) Read(b []byte) (int, error) { + n, err := p.r.Read(b) + p.n += uint64(n) + return n, err +} + +// plaintextFailure is the error of a read of the plaintext that stopped +// before it wanted: a failure of age, or a plaintext that ends before P. +func plaintextFailure(err error, n, p uint64) error { + if err == io.EOF { + return fmt.Errorf("capsule: PAYLOAD_AGE: the plaintext is %d bytes, shorter than P = %d: %w", n, p, datekeys.ErrIntegrity) + } + return classify("PAYLOAD_AGE", ageStreamFailure, err) +} + +// readN reads exactly n bytes of the plaintext. Its memory grows with the +// bytes received, not with n, a length that BODY declares (spec §57). Not +// io.ReadFull: it would turn the io.ErrUnexpectedEOF of a truncated STREAM +// into a short read. +func readN(r *plainReader, n int, p uint64) ([]byte, error) { + var buf []byte + part := make([]byte, min(n, 32<<10)) + for len(buf) < n { + m, err := r.Read(part[:min(n-len(buf), len(part))]) + buf = append(buf, part[:m]...) + if len(buf) == n { + break + } + if err != nil { + return nil, plaintextFailure(err, r.n, p) + } + } + return buf, nil +} + +// drain reads the rest of the plaintext to EOF. A failure of age, or a +// plaintext whose length is not P, prevails over the failure of any substep +// of step 17 (spec §63). +func drain(r *plainReader, p uint64) error { + buf := make([]byte, 32<<10) + for { + _, err := r.Read(buf) + switch { + case err == io.EOF && r.n != p: + return fmt.Errorf("capsule: PAYLOAD_AGE: the plaintext is %d bytes, not P = %d: %w", r.n, p, datekeys.ErrIntegrity) + case err == io.EOF: + return nil + case err != nil: + return classify("PAYLOAD_AGE", ageStreamFailure, err) + } + } +} + +// copyFile writes the next size bytes of the plaintext to w, hashing them. +func copyFile(w io.Writer, r *plainReader, size, p uint64) ([]byte, error) { + sum := sha256.New() + buf := make([]byte, 32<<10) + for left := size; left > 0; { + m := uint64(len(buf)) + if m > left { + m = left + } + n, err := r.Read(buf[:m]) + if n > 0 { + sum.Write(buf[:n]) + if _, werr := w.Write(buf[:n]); werr != nil { + return nil, &sinkError{werr} + } + left -= uint64(n) + } + if left > 0 && err != nil { + return nil, plaintextFailure(err, r.n, p) + } + } + return sum.Sum(nil), nil +} + +// sinkError is a failure of the caller's Sink, not of the capsule. +type sinkError struct{ err error } + +func (e *sinkError) Error() string { return e.err.Error() } +func (e *sinkError) Unwrap() error { return e.err } + +// sinkFailure reports a failure of the Sink as the caller's own error, with +// ErrIntegrity as its code, as a failure of dst is in formats 1 and 2. +func sinkFailure(what string, err error) error { + return fmt.Errorf("capsule: PAYLOAD_AGE: %s: %w: %w", what, err, datekeys.ErrIntegrity) +} + +// openBody runs step 17 of a format 3 capsule and step 18 (spec §63): pr is +// the plaintext of PAYLOAD_AGE, whose BODY is l bytes long and whose +// padding goes up to p. The substeps, in order: +// +// 17.2 the frame of BODY and the area (§29.2), ErrIntegrity; +// 17.3 security, layers 2 and 3 (§29.3), without a code; +// 17.4 the head, layers 2 to 4 (§29.4 to §29.6); +// 17.5 the files fill CONTENT, ErrIntegrity; +// 17.6 the verdicts of security (§29.7), without a code; +// 17.7 the SHA-256 of each file, ErrIntegrity; +// 17.8 the padding, ErrIntegrity. +// +// A failure of age after its header, or a plaintext whose length is not P, +// is ErrIntegrity and prevails; otherwise the first substep that fails +// decides. openBody stops early only with ErrIntegrity when that is already +// the final code: on a failure of age, or of a substep of ErrIntegrity with +// no earlier failure of another code. After a failure of another code, at +// 17.4, it reads PAYLOAD_AGE to EOF before reporting it. +func openBody(pr io.Reader, l, p uint64, sink Sink, reg extension.Registry, out *Opened) (err error) { + r := &plainReader{r: pr} + begun := false + defer func() { + if err != nil && begun { + sink.Abort() + } + }() + + // 17.2: the frame of BODY and the area. + b, err := readN(r, BodyFrameSize, p) + if err != nil { + return err + } + frame, err := ParseBodyFrame(b, l) + if err != nil { + return err + } + area, err := readN(r, int(frame.AreaLen), p) + if err != nil { + return err + } + if err := CheckArea(area, frame.SecurityLen); err != nil { + return err + } + out.AreaLen = frame.AreaLen + + // 17.3 and 17.6: security never fails; its verdicts are shown after + // step 18 only. + verdicts := EvaluateSecurity(area[:frame.SecurityLen]) + + // 17.4: the head. Its codes other than ErrIntegrity are reported only + // after reading to EOF. + hb, err := readN(r, int(frame.HeadLen), p) + if err != nil { + return err + } + h, err := DecodeHead(hb, reg) + if err != nil { + if derr := drain(r, p); derr != nil { + return derr + } + return err + } + + // 17.5: the files fill CONTENT. + if err := CheckHeadEnd(h, frame.ContentLength(l)); err != nil { + return err + } + + // 17.7: each file, to the Sink, with its SHA-256. + if err := sink.Begin(h); err != nil { + return sinkFailure("beginning the files", err) + } + begun = true + for i := range h.Files { + f := &h.Files[i] + w, err := sink.Create(i) + if err != nil { + return sinkFailure(fmt.Sprintf("creating file %d", i+1), err) + } + sum, err := copyFile(w, r, f.Size, p) + if cerr := w.Close(); err == nil && cerr != nil { + err = &sinkError{cerr} + } + var se *sinkError + switch { + case errors.As(err, &se): + return sinkFailure(fmt.Sprintf("writing file %d", i+1), se.err) + case err != nil: + return err + case !hmac.Equal(sum, f.SHA256[:]): + return fmt.Errorf("capsule: PAYLOAD_AGE: file %d: its SHA-256 is not the one of the head: %w", i+1, datekeys.ErrIntegrity) + } + } + + // 17.8: the padding, up to P, and nothing after it. + if err := checkPadding(r, r.n, p); err != nil { + if datekeys.Code(err) == "" { + err = classify("PAYLOAD_AGE", ageStreamFailure, err) + } + return err + } + + // Step 18. + if err := sink.Commit(); err != nil { + return sinkFailure("committing the files", err) + } + out.Head, out.Verdicts = h, verdicts + out.UnusableHeadExtensions = extension.CheckNoncriticalIn(extension.Head, h.Noncritical, reg) + return nil +} diff --git a/capsule/open3_test.go b/capsule/open3_test.go new file mode 100644 index 0000000..a9cf50d --- /dev/null +++ b/capsule/open3_test.go @@ -0,0 +1,398 @@ +package capsule_test + +import ( + "bytes" + "context" + "encoding/binary" + "errors" + "io" + "runtime" + "testing" + + "filippo.io/age" + + datekeys "g.activething.com/go/DateKeys" + "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 reader of format 3 (spec v0.10): step 17, +// its substeps and their precedence, and step 18 (spec §29.2 to §29.7, §63). + +// capsule3 describes a format 3 capsule of round 1000 to build, with edits +// at each level: the head, HEAD_CBOR, BODY, the plaintext with its padding, +// and PAYLOAD_AGE. +type capsule3 struct { + paths []string + contents [][]byte + comment, author string + security []byte // nil: the empty security that writers write + head func(h *capsule.Head) + headCBOR func(b []byte) []byte + body func(b []byte) []byte + plain func(p []byte) []byte + payload func(t *testing.T, p []byte) []byte + structure capsule.Policy + recipients []age.Recipient +} + +// build returns the .dkc and its BODY, before any edit of the plaintext. +func (c capsule3) build(t *testing.T) ([]byte, []byte) { + t.Helper() + h := testkit.Head3(c.comment, c.author, c.paths, c.contents) + if c.head != nil { + c.head(h) + } + hb, err := capsule.EncodeHead(h) + if err != nil { + t.Fatal(err) + } + if c.headCBOR != nil { + hb = c.headCBOR(hb) + } + sec := c.security + if sec == nil { + sec = capsule.EncodeSecurity() + } + body := testkit.Body3(capsule.AreaLen, sec, hb, c.contents...) + if c.body != nil { + body = c.body(body) + } + b, err := testkit.Build{Format: capsule.Format3, Declared: c.structure, Structure: c.structure, + AccessRecipients: c.recipients, Plaintext: body, EditPlaintext: c.plain}.Make() + if err != nil { + t.Fatal(err) + } + if c.payload == nil { + return b.DKC, body + } + return testkit.Join(b.Prelude[:], b.PublicHeader, b.Sealed, c.payload(t, bytes.Clone(b.Payload))), body +} + +type result3 struct { + opened *capsule.Opened + sink *testkit.MemorySink + err error + unread int // bytes of the .dkc that Open did not read +} + +// open3 opens a capsule of round 1000 into sink. +func open3(t *testing.T, dkc []byte, sink capsule.Sink, ids ...age.Identity) result3 { + t.Helper() + o := defaultOpen(1000) + o.Identities, o.Sink = ids, sink + r := bytes.NewReader(dkc) + opened, err := capsule.Open(context.Background(), nil, r, o) + res := result3{opened: opened, err: err, unread: r.Len()} + if m, ok := sink.(*testkit.MemorySink); ok { + res.sink = m + } + return res +} + +// chunk returns the offset in the age file p of its STREAM chunk i: after +// the header come a 16-byte nonce and chunks of 64 KiB, each with a 16-byte +// tag (C2SP age.md). +func chunk(t *testing.T, p []byte, i int) int { + t.Helper() + n, err := testkit.HeaderLen(p) + if err != nil { + t.Fatal(err) + } + return n + 16 + i*(64<<10+16) +} + +// Spec §29.2 to §29.7, §63 steps 17 and 18: a format 3 capsule opens, its +// files reach the Sink in the order of the head, and Commit comes last. +func TestOpen3(t *testing.T) { + photo := bytes.Repeat([]byte("playa"), 30000) // three STREAM chunks + c := capsule3{ + paths: []string{"fotos/playa.jpg", "nota.txt", "vacío.txt"}, + contents: [][]byte{photo, []byte("Hola.\n"), {}}, + comment: "Para ti ❤️", author: "Ana López", + } + dkc, body := c.build(t) + + // Steps 1 to 8 need no Sink. + in, err := capsule.Inspect(bytes.NewReader(dkc), capsule.InspectOptions{Registry: testkit.Registry()}) + if err != nil || in.Prelude.Format != capsule.Format3 { + t.Fatalf("Inspect: format %d, %v", in.Prelude.Format, err) + } + + res := open3(t, dkc, &testkit.MemorySink{}) + if res.err != nil { + t.Fatal(res.err) + } + o, s := res.opened, res.sink + if !s.Committed || s.Aborted { + t.Errorf("committed %v, aborted %v", s.Committed, s.Aborted) + } + for i, want := range c.contents { + if !bytes.Equal(s.Files[i], want) { + t.Errorf("file %d: %d bytes, want %d", i+1, len(s.Files[i]), len(want)) + } + } + p, _ := capsule.PaddedLength(uint64(len(body)), capsule.Reforzado) + switch { + case o.Format != capsule.Format3 || o.AreaLen != capsule.AreaLen: + t.Errorf("format %d, area %d", o.Format, o.AreaLen) + case o.PayloadLength != uint64(len(body)) || o.PaddedLength != p || o.Padding != capsule.Reforzado: + t.Errorf("L = %d, P = %d, padding %s; want %d, %d", o.PayloadLength, o.PaddedLength, o.Padding, len(body), p) + case o.Head == nil || o.Head != s.Head || o.Head.Comment != c.comment || o.Head.Author != c.author || len(o.Head.Files) != 3: + t.Errorf("head %+v", o.Head) + case o.Verdicts != capsule.Verdicts{Signature: capsule.VerdictNoSignature, Seal: capsule.VerdictNoSeal}: + t.Errorf("verdicts %+v", o.Verdicts) + } + checks := o.Inspection.Checks + if n := len(checks); n < 2 || checks[n-2].Step != 17 || !checks[n-2].OK || checks[n-1].Step != 18 || !checks[n-1].OK { + t.Errorf("last checks %+v", checks[len(checks)-2:]) + } + + // The examples of spec §29.2: L and P of three small capsules. + for _, tc := range []struct { + name string + c capsule3 + l, p uint64 + }{ + {"no files and no comment", capsule3{}, 577, 768}, + {"a comment of one byte", capsule3{comment: "a"}, 580, 768}, + {"nota.txt of 1000 bytes, with mtime", capsule3{paths: []string{"nota.txt"}, contents: [][]byte{make([]byte, 1000)}, + head: func(h *capsule.Head) { h.Files[0].MTime, h.Files[0].HasMTime = 1790000000, true }}, 1641, 1792}, + } { + dkc, _ := tc.c.build(t) + res := open3(t, dkc, &testkit.MemorySink{}) + if res.err != nil || res.opened.PayloadLength != tc.l || res.opened.PaddedLength != tc.p || !res.sink.Committed { + t.Errorf("%s: L = %d, P = %d, %v; want %d, %d", tc.name, res.opened.PayloadLength, res.opened.PaddedLength, res.err, tc.l, tc.p) + } + } + + // time_and_key: format 3 has the 16 stanzas of format 2 (spec §29.1, §39). + id, _ := age.GenerateX25519Identity() + dkc, _ = capsule3{paths: []string{"a"}, contents: [][]byte{[]byte("x")}, + structure: capsule.TimeAndKey, recipients: []age.Recipient{id.Recipient()}}.build(t) + if res := open3(t, dkc, &testkit.MemorySink{}, id); res.err != nil || string(res.sink.Files[0]) != "x" { + t.Errorf("time_and_key: %v", res.err) + } +} + +// Spec §63 step 17: a failure of age, or a plaintext whose length is not P, +// prevails; otherwise the first substep that fails decides. A code other +// than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. +// Nothing reaches Commit, and a Sink that got Begin gets Abort. +func TestOpen3Substeps(t *testing.T) { + two := capsule3{paths: []string{"a.txt", "b.txt"}, contents: [][]byte{[]byte("uno"), []byte("dos")}} + // With a big file the head is in STREAM chunk 0, and two chunks follow. + big := capsule3{paths: []string{"a.bin"}, contents: [][]byte{bytes.Repeat([]byte{0x5a}, 100000)}} + with := func(c capsule3, edit func(c *capsule3)) capsule3 { edit(&c); return c } + u32 := func(at int, v uint32) func(b []byte) []byte { + return func(b []byte) []byte { binary.BigEndian.PutUint32(b[at:], v); return b } + } + dotdot := func(h *capsule.Head) { h.Files[0].Path = ".." } + lastPadding := func(p []byte) []byte { p[len(p)-1] = 1; return p } + short := func(p []byte) []byte { return p[:len(p)-1] } + long := func(p []byte) []byte { return append(p, make([]byte, 256)...) } + areaByte := func(b []byte) []byte { b[capsule.BodyFrameSize+capsule.AreaLen-1] = 1; return b } + // HEAD_CBOR starts with the map, key 0, the text header and the 13 bytes + // of "datekeys-head", then key 1 and the version. + const headTag, headVersion = 3, 17 + + for _, tc := range []struct { + name string + c capsule3 + want error // nil: opens + begun bool // the Sink got Begin, and so Abort + eof bool // Open read the .dkc to its end + }{ + // 17.2: the frame and the area. + {"AREA_LEN 511", with(two, func(c *capsule3) { c.body = u32(0, 511) }), datekeys.ErrIntegrity, false, false}, + {"AREA_LEN 513", with(two, func(c *capsule3) { c.body = u32(0, 513) }), datekeys.ErrIntegrity, false, false}, + {"AREA_LEN 66048", with(two, func(c *capsule3) { c.body = u32(0, 66048) }), datekeys.ErrIntegrity, false, false}, + {"SECURITY_LEN 0", with(two, func(c *capsule3) { c.body = u32(4, 0) }), datekeys.ErrIntegrity, false, false}, + {"SECURITY_LEN 513", with(two, func(c *capsule3) { c.body = u32(4, 513) }), datekeys.ErrIntegrity, false, false}, + {"HEAD_LEN 0", with(two, func(c *capsule3) { c.body = u32(8, 0) }), datekeys.ErrIntegrity, false, false}, + {"HEAD_LEN 2^24 + 1", with(two, func(c *capsule3) { c.body = u32(8, 1<<24+1) }), datekeys.ErrIntegrity, false, false}, + {"12 + AREA_LEN + HEAD_LEN = L + 1", with(capsule3{}, func(c *capsule3) { c.body = short }), datekeys.ErrIntegrity, false, false}, + {"L < 12", with(two, func(c *capsule3) { c.body = func(b []byte) []byte { return b[:11] } }), datekeys.ErrIntegrity, false, false}, + {"a byte of the area not zero", with(two, func(c *capsule3) { c.body = areaByte }), datekeys.ErrIntegrity, false, false}, + {"a byte of the area not zero, and path ..", with(two, func(c *capsule3) { c.head, c.body = dotdot, areaByte }), datekeys.ErrIntegrity, false, false}, + + // 17.3 and 17.6: security never fails. + {"security unreadable", with(two, func(c *capsule3) { c.security = []byte{0xa0} }), nil, false, true}, + + // 17.4: the head, whose codes wait for the end of PAYLOAD_AGE. + {"head of version 2", with(two, func(c *capsule3) { + c.headCBOR = func(b []byte) []byte { b[headVersion] = 2; return b } + }), datekeys.ErrUnsupportedVersion, false, true}, + {"head of another type tag", with(two, func(c *capsule3) { + c.headCBOR = func(b []byte) []byte { b[headTag] = 'D'; return b } + }), datekeys.ErrNonCanonicalCBOR, false, true}, + {"head with one byte more within HEAD_LEN", with(two, func(c *capsule3) { + c.headCBOR = func(b []byte) []byte { return append(b, 0) } + }), datekeys.ErrNonCanonicalCBOR, false, true}, + {"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}, + {"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}, + {"an unknown critical extension", with(two, func(c *capsule3) { + c.head = func(h *capsule.Head) { h.Critical = []extension.Extension{{ID: "x.head", Version: 1}} } + }), datekeys.ErrExtensionCriticalUnknown, false, true}, + + // 17.5: the files fill CONTENT. + {"end of the last file not C", with(two, func(c *capsule3) { + c.body = func(b []byte) []byte { return append(b, 'x') } + }), datekeys.ErrIntegrity, false, false}, + + // 17.7: the SHA-256 of each file, and 17.8: the padding, up to P. + {"a byte of a file changed", with(two, func(c *capsule3) { + c.body = func(b []byte) []byte { b[len(b)-1] ^= 1; return b } + }), datekeys.ErrIntegrity, true, false}, + {"a padding byte not zero", with(two, func(c *capsule3) { c.plain = lastPadding }), datekeys.ErrIntegrity, true, false}, + {"plaintext of P - 1 bytes", with(two, func(c *capsule3) { c.plain = short }), datekeys.ErrIntegrity, true, true}, + {"plaintext of P + 256 bytes", with(two, func(c *capsule3) { c.plain = long }), datekeys.ErrIntegrity, true, false}, + + // Precedence: the head fails first, and what follows decides only when + // it is a failure of age or of the length. + {"path .. and a padding byte not zero", with(two, func(c *capsule3) { c.head, c.plain = dotdot, lastPadding }), datekeys.ErrHeadInvalid, false, true}, + {"path .. and a plaintext of P - 1 bytes", with(two, func(c *capsule3) { c.head, c.plain = dotdot, short }), datekeys.ErrIntegrity, false, true}, + {"path .. and a plaintext of P + 256 bytes", with(two, func(c *capsule3) { c.head, c.plain = dotdot, long }), datekeys.ErrIntegrity, false, true}, + {"path .. and the next STREAM chunk corrupt", with(big, func(c *capsule3) { + c.head = dotdot + c.payload = func(t *testing.T, p []byte) []byte { p[chunk(t, p, 1)+100] ^= 1; return p } + }), datekeys.ErrIntegrity, false, false}, + {"path .. and a cut right after its chunk", with(big, func(c *capsule3) { + c.head = dotdot + c.payload = func(t *testing.T, p []byte) []byte { return p[:chunk(t, p, 1)] } + }), datekeys.ErrIntegrity, false, true}, + {"path .. and a big file", with(big, func(c *capsule3) { c.head = dotdot }), datekeys.ErrHeadInvalid, false, true}, + } { + dkc, _ := tc.c.build(t) + res := open3(t, dkc, &testkit.MemorySink{}) + o, s := res.opened, res.sink + if tc.want == nil { + if res.err != nil || !s.Committed || o.Verdicts.Signature != capsule.VerdictUnreadable || len(o.Verdicts.Lines()) != 1 { + t.Errorf("%s: %v, verdicts %+v", tc.name, res.err, o.Verdicts) + } + continue + } + step := failedStep(t, o.Inspection.Checks, res.err) + expectStep(t, tc.name, step, res.err, tc.want, 17) + switch { + case s.Committed || o.Head != nil: + t.Errorf("%s: committed %v, head %v", tc.name, s.Committed, o.Head) + case (s.Head != nil) != tc.begun || s.Aborted != tc.begun: + t.Errorf("%s: begun %v, aborted %v, want %v", tc.name, s.Head != nil, s.Aborted, tc.begun) + case tc.eof && res.unread != 0: + t.Errorf("%s: %d bytes of the .dkc not read", tc.name, res.unread) + } + } +} + +// failSink fails at one point: "begin", "create", "write", "close" or +// "commit", at file 2 for "create", "write" and "close". +type failSink struct { + testkit.MemorySink + at string + aborts int +} + +var errDiskFull = errors.New("disk full") + +func (s *failSink) Begin(h *capsule.Head) error { + if s.at == "begin" { + return errDiskFull + } + return s.MemorySink.Begin(h) +} + +func (s *failSink) Create(i int) (io.WriteCloser, error) { + if s.at == "create" && i == 1 { + return nil, errDiskFull + } + w, err := s.MemorySink.Create(i) + return &failWriter{WriteCloser: w, fail: i == 1, at: s.at}, err +} + +func (s *failSink) Commit() error { + if s.at == "commit" { + return errDiskFull + } + return s.MemorySink.Commit() +} + +func (s *failSink) Abort() { s.aborts++; s.MemorySink.Abort() } + +type failWriter struct { + io.WriteCloser + fail bool + at string +} + +func (w *failWriter) Write(b []byte) (int, error) { + if w.fail && w.at == "write" { + return 0, errDiskFull + } + return w.WriteCloser.Write(b) +} + +func (w *failWriter) Close() error { + if w.fail && w.at == "close" { + return errDiskFull + } + return w.WriteCloser.Close() +} + +// A failure of the Sink is the caller's own error with ERR_INTEGRITY, as a +// failure of dst is in formats 1 and 2, and after Begin it gets Abort, once. +func TestOpen3SinkFailures(t *testing.T) { + dkc, _ := capsule3{paths: []string{"a.txt", "b.txt"}, contents: [][]byte{[]byte("uno"), []byte("dos")}}.build(t) + for _, at := range []string{"begin", "create", "write", "close", "commit"} { + s := &failSink{at: at} + res := open3(t, dkc, s) + wantAborts := 1 + if at == "begin" { + wantAborts = 0 + } + switch { + case !errors.Is(res.err, errDiskFull) || datekeys.Code(res.err) != datekeys.Code(datekeys.ErrIntegrity): + t.Errorf("%s: %v", at, res.err) + case s.aborts != wantAborts || s.Committed || res.opened.Head != nil: + t.Errorf("%s: %d aborts, committed %v", at, s.aborts, s.Committed) + } + } + if res := open3(t, dkc, &failSink{}); res.err != nil { + t.Fatal(res.err) + } +} + +// Spec §57: a reader reserves no memory by what BODY declares before it +// receives the bytes. Each capsule declares 16 MiB, in HEAD_LEN or in the +// size of a file, and holds a plaintext of 2000 bytes; Open fails, and +// allocates about what a small capsule makes it allocate. +func TestOpen3DeclaredLengths(t *testing.T) { + const declared = 16 << 20 + cut := func(p []byte) []byte { return p[:2000] } + for _, tc := range []struct { + name string + c capsule3 + }{ + {"HEAD_LEN", capsule3{plain: cut, body: func(b []byte) []byte { + binary.BigEndian.PutUint32(b[8:], declared) + return append(b, make([]byte, declared)...) + }}}, + {"size", capsule3{plain: cut, paths: []string{"a.bin"}, contents: [][]byte{make([]byte, declared)}}}, + } { + dkc, _ := tc.c.build(t) + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + res := open3(t, dkc, &testkit.MemorySink{}) + runtime.ReadMemStats(&after) + if n := after.TotalAlloc - before.TotalAlloc; n > 4<<20 || !errors.Is(res.err, datekeys.ErrIntegrity) { + t.Errorf("%s: %d bytes allocated, %v", tc.name, n, res.err) + } + } +} diff --git a/internal/testkit/builder.go b/internal/testkit/builder.go index 2da52af..2b76b39 100644 --- a/internal/testkit/builder.go +++ b/internal/testkit/builder.go @@ -28,8 +28,9 @@ import ( type Build struct { Profile *profile.Profile // default Quicknet Round uint64 // default 1000 - // Format is the format written: capsule.Format2 by default, or - // capsule.Format1, the one of spec v0.8.2. + // Format is the format written: capsule.Format2 by default, + // capsule.Format1, the one of spec v0.8.2, or capsule.Format3, whose + // Plaintext is BODY (spec §29.2; see Body3). Format capsule.Format Declared capsule.Policy // access_policy written in PUBLIC_HEADER Structure capsule.Policy // construction actually used @@ -44,7 +45,11 @@ type Build struct { // the content and the zeros of its padding. Plaintext []byte // Padding is the padding rule of format 2; 0 means capsule.Reforzado. - Padding capsule.Padding + Padding capsule.Padding + // EditPlaintext, when not nil, edits the plaintext of PAYLOAD_AGE, with + // its padding in formats 2 and 3, before it is encrypted. The control + // still declares L = len(Plaintext). + EditPlaintext func(p []byte) []byte DateKeyString string // overrides the canonical dk1_ string in PUBLIC_HEADER // RawPolicy, when not zero, is the access_policy value written in // PUBLIC_HEADER instead of Declared, which may be outside V1. A header @@ -89,7 +94,7 @@ func (b Build) Make() (*Built, error) { ctrl := &capsule.Control{Critical: b.ControlCritical, Noncritical: b.ControlNoncritical} plaintext := b.Plaintext access := b.AccessRecipients - if format == capsule.Format2 { + if format == capsule.Format2 || format == capsule.Format3 { ctrl.PayloadLength, ctrl.Padding = uint64(len(b.Plaintext)), b.Padding if ctrl.Padding == 0 { ctrl.Padding = capsule.Reforzado @@ -105,6 +110,9 @@ func (b Build) Make() (*Built, error) { } } } + if b.EditPlaintext != nil { + plaintext = b.EditPlaintext(bytes.Clone(plaintext)) + } out := &Built{} if _, err := rand.Read(out.CapsuleID[:]); err != nil { return nil, err diff --git a/internal/testkit/format3.go b/internal/testkit/format3.go new file mode 100644 index 0000000..a03b20b --- /dev/null +++ b/internal/testkit/format3.go @@ -0,0 +1,75 @@ +package testkit + +import ( + "crypto/sha256" + "io" + + "g.activething.com/go/DateKeys/capsule" +) + +// DiscardSink is a capsule.Sink that discards the files. +type DiscardSink struct{} + +func (DiscardSink) Begin(*capsule.Head) error { return nil } +func (DiscardSink) Create(int) (io.WriteCloser, error) { return nopCloser{io.Discard}, nil } +func (DiscardSink) Commit() error { return nil } +func (DiscardSink) Abort() {} + +type nopCloser struct{ io.Writer } + +func (nopCloser) Close() error { return nil } + +// MemorySink is a capsule.Sink that keeps the files in memory. +type MemorySink struct { + Head *capsule.Head + Files [][]byte + Committed bool + Aborted bool +} + +func (s *MemorySink) Begin(h *capsule.Head) error { + s.Head, s.Files = h, make([][]byte, len(h.Files)) + return nil +} + +func (s *MemorySink) Create(i int) (io.WriteCloser, error) { return &memoryFile{s, i}, nil } +func (s *MemorySink) Commit() error { s.Committed = true; return nil } +func (s *MemorySink) Abort() { s.Aborted = true } + +type memoryFile struct { + s *MemorySink + i int +} + +func (f *memoryFile) Write(b []byte) (int, error) { + f.s.Files[f.i] = append(f.s.Files[f.i], b...) + return len(b), nil +} + +func (f *memoryFile) Close() error { return nil } + +// Head3 returns the head of the given files, laid out one after another in +// the order given, with their SHA-256, no mtime and a zero salt. +func Head3(comment, author string, paths []string, contents [][]byte) *capsule.Head { + h := &capsule.Head{Comment: comment, Author: author} + var end uint64 + for i, p := range paths { + n := uint64(len(contents[i])) + h.Files = append(h.Files, capsule.File{Path: p, Size: n, Start: end, End: end + n, SHA256: sha256.Sum256(contents[i])}) + end += n + } + return h +} + +// Body3 assembles the BODY of a format 3 capsule (spec §29.2): the frame, +// security in an area of areaLen bytes, the head and the files. +func Body3(areaLen uint32, security, head []byte, contents ...[]byte) []byte { + frame := capsule.BodyFrame{AreaLen: areaLen, SecurityLen: uint32(len(security)), HeadLen: uint32(len(head))}.Bytes() + body := append(frame[:], security...) + body = append(body, make([]byte, int(areaLen)-len(security))...) + body = append(body, head...) + for _, c := range contents { + body = append(body, c...) + } + return body +} diff --git a/internal/testkit/mutations.go b/internal/testkit/mutations.go index 5be819f..8f39e36 100644 --- a/internal/testkit/mutations.go +++ b/internal/testkit/mutations.go @@ -27,12 +27,12 @@ import ( "g.activething.com/go/DateKeys/provider" ) -// Numbers of mutations of spec §64: the first two lists apply to both -// formats, and the third one is of format 2. Its first entry, VERSION 3, is +// Numbers of mutations of spec §64: the first two lists apply to formats 1 +// and 2, and the third one is of format 2. Its first entry, VERSION 4, is // the format 2 case of "version changed", in the first list. const ( SpecMutationsPerFormat = 33 - Format2SpecMutations = 22 + Format2SpecMutations = 23 ) // Mutation is one entry of the mutation corpus (spec §64): a capsule, and @@ -166,6 +166,7 @@ func (in *MutationInput) Open() (Verdict, error) { } o.Identities = append(o.Identities, id) } + o.Sink = DiscardSink{} opened, err := capsule.Open(context.Background(), discard{}, bytes.NewReader(in.DKC), o) v := Verdict{Err: err, Calls: src.calls} if err == nil { @@ -530,11 +531,11 @@ func specMutations(f capsule.Format) []Mutation { other := strings.Repeat("ab", 32) return ok(to.input(bytes.Replace(to.DKC, []byte(profile.Quicknet().ChainHashHex()), []byte(other), 1))) }}, - // VERSION 3, which no format defines (spec §22, §23). In format 2 it is + // VERSION 4, which no format defines (spec §22, §23). In format 2 it is // also the first entry of the third list of spec §64. {Name: name("version changed"), Spec: true, Want: datekeys.ErrUnsupportedVersion, Step: 2, Make: func(e *MutationEnv) (*MutationInput, error) { - return ok(e.timeOnly(f).input(set(e.timeOnly(f).DKC, 4, 3))) + return ok(e.timeOnly(f).input(set(e.timeOnly(f).DKC, 4, 4))) }}, {Name: name("flags != 0"), Spec: true, Want: datekeys.ErrInvalidFlags, Step: 2, Make: func(e *MutationEnv) (*MutationInput, error) { @@ -937,7 +938,7 @@ func paddedPlaintext(f *LoadedFixture, dir string) ([]byte, error) { } // format2Mutations returns the mutations of the third list of spec §64, -// except VERSION 3, which is "format 2: version changed", and the companions +// except VERSION 4, which is "format 2: version changed", and the companions // of those that edit a time_and_key capsule: the same capsule with its .dkk, // whose capsule_digest no longer matches, fails at step 9 (spec §43, §76). // Every capsule derives from a fixture without randomness, sealed again with @@ -1003,6 +1004,10 @@ func format2Mutations() []Mutation { two := func(e *MutationEnv) (*MutationInput, error) { return stanzasOf(e, twice) } return []Mutation{ + // Format 3 exists since v0.10: VERSION 3 passes step 2, and the + // version 2 control fails at step 14. + {Name: "format 2 time_only relabeled format 3", Spec: true, Want: datekeys.ErrUnsupportedVersion, Step: 14, Network: true, + Make: func(e *MutationEnv) (*MutationInput, error) { return ok(relabeled(e.TimeOnly2, 3)) }}, {Name: "format 1 time_only relabeled format 2", Spec: true, Want: datekeys.ErrUnsupportedVersion, Step: 14, Network: true, Make: func(e *MutationEnv) (*MutationInput, error) { return ok(relabeled(e.TimeOnly, 2)) }}, {Name: "format 1 time_and_key with one stanza relabeled format 2, with the identity", Spec: true, Want: datekeys.ErrPolicyStructureMismatch, Step: 12, Network: true, Make: relabel1}, diff --git a/testdata/README.md b/testdata/README.md index 64eb25b..4f8ed66 100644 --- a/testdata/README.md +++ b/testdata/README.md @@ -40,7 +40,7 @@ Conventions for every file: | `vectors/cbor.json` | the CBOR profile, and one block of vectors per schema, CONTROL_CBOR in both formats | §58, CDDL | | `vectors/tlock_ibe.json` | H2 of the tlock IBE: the serialization of an element of GT | §63 step 11 | | `vectors/padding.json` | the padding of format 2: P for each content length L, and the length of PAYLOAD_AGE | §29.1 | -| `vectors/mutations.json` | the mutation corpus: the 88 mutations of §64 and further cases | §63, §64 | +| `vectors/mutations.json` | the mutation corpus: the 89 mutations of §64 and further cases | §63, §64 | | `vectors/inspect_differential.json` | 4380 mutations of the fixtures with the verdict of steps 1 to 8 | §63 | | `fixtures/.dkc`, `.json` | official capsules and every intermediate value | §67 | | `fixtures/.dkk`, `.dkk.json` | official access keys | §68 | @@ -74,7 +74,7 @@ decrypt offline. edits of a base file, not as its full bytes: ```json -{ "base": "time_only.dkc", "edits": [[4, 1, "03"]] } +{ "base": "time_only.dkc", "edits": [[4, 1, "04"]] } ``` - `base` is a file of `testdata/fixtures`. In `mutations.json` it may be absent: @@ -267,7 +267,7 @@ reading flow (`capsule.Open`, §63) must fail. { "name": "version changed", "spec": true, - "dkc": { "base": "time_only.dkc", "edits": [[4, 1, "03"]] }, + "dkc": { "base": "time_only.dkc", "edits": [[4, 1, "04"]] }, "release": { "round": 1000, "signature": "b446…" }, "now": "2023-08-23T15:59:24Z", "registry": "default", @@ -279,12 +279,12 @@ reading flow (`capsule.Open`, §63) must fail. ``` - `name`: unique, stable. -- `spec`: true for the 88 mutations listed in spec §64, false for the further +- `spec`: true for the 89 mutations listed in spec §64, false for the further cases of the reference. The cases come in this order: the 33 mutations of the first two lists of §64 on the format 1 fixtures (cases 1 to 33), 32 further cases (34 to 65), the same 33 mutations on the format 2 fixtures, - named "format 2: …" (66 to 98), the 22 of the third list (99 to 120), and 5 - further cases (121 to 125). + named "format 2: …" (66 to 98), the 23 of the third list (99 to 121), and 5 + further cases (122 to 126). - `dkc`: the capsule, as edits of a fixture (see above). The reader gets it as a seekable file, so that the `capsule_digest` of an offered `.dkk` is checked before any release request (spec §63 step 9.a). @@ -373,12 +373,13 @@ x + p case has a frozen format 2 capsule for round 1004. ### Format 2: the third list of §64 -The 22 cases of the third list of §64 test what format 2 adds: +The 23 cases of the third list of §64 test what format 2 adds: -- the format against the rest of the capsule, with the other `VERSION`, a +- the format against the rest of the capsule, with another `VERSION`, a public byte (§22, §76): a format 1 `time_and_key` capsule of one stanza - relabeled format 2 fails at step 12, and the other three relabelings at step - 14, where the schema version of the control is not the format; + relabeled format 2 fails at step 12, and the other relabelings, format 2 as + 1 or 3 and format 1 as 2, at step 14, where the schema version of the + control is not the format; - the 16 stanzas of INNER_ACCESS_AGE: 15, 17, two for one recipient, and an identity that opens none of them, at steps 12 and 13; - keys 6 and 7 of CONTROL_CBOR, at step 14; @@ -406,7 +407,7 @@ ends the flow, and within one object the first failing layer decides the code | Step | Rules | Spec | |---|---|---| -| 1, 2 | magic, truncated prelude, `VERSION` 1 or 2 (the format), FLAGS and RESERVED, `PUBLIC_HEADER_LEN` in 1 to 1048576 and `SEALED_CONTROL_LEN` in 1 to 67108864 | §22, §23 | +| 1, 2 | magic, truncated prelude, `VERSION` 1, 2 or 3 (the format), FLAGS and RESERVED, `PUBLIC_HEADER_LEN` in 1 to 1048576 and `SEALED_CONTROL_LEN` in 1 to 67108864 | §22, §23 | | 3 | the PUBLIC_HEADER bytes are present | §23 | | 4 | PUBLIC_HEADER: layers 2 to 4, the `public_header` decoder of the schema vectors, then the pinned profile of the DateKey and the critical extensions; with no extension known, every `critical_extensions` array fails | §63, §69.1 | | 5 | the SEALED_CONTROL bytes are present, then its age header, parsed within `SEALED_CONTROL_LEN` bytes: malformed is `ERR_INTEGRITY`, one tlock stanza is required | §28.1 | diff --git a/testdata/vectors/mutations.json b/testdata/vectors/mutations.json index e0c0354..a232241 100644 --- a/testdata/vectors/mutations.json +++ b/testdata/vectors/mutations.json @@ -84,7 +84,7 @@ "dkc": { "base": "time_only.dkc", "edits": [ - [4, 1, "03"] + [4, 1, "04"] ] }, "release": { @@ -1380,7 +1380,7 @@ "dkc": { "base": "format2_time_only.dkc", "edits": [ - [4, 1, "03"] + [4, 1, "04"] ] }, "release": { @@ -1947,6 +1947,26 @@ "error": "ERR_RELEASE_INVALID", "step": 10 }, + { + "name": "format 2 time_only relabeled format 3", + "spec": true, + "dkc": { + "base": "format2_time_only.dkc", + "edits": [ + [4, 1, "03"] + ] + }, + "release": { + "round": 1000, + "signature": "b44679b9a59af2ec876b1a6b1ad52ea9b1615fc3982b19576350f93447cb1125e342b73a8dd2bacbe47e4b6b63ed5e39" + }, + "now": "2023-08-23T15:59:24Z", + "registry": "default", + "network": true, + "frozen": false, + "error": "ERR_UNSUPPORTED_VERSION", + "step": 14 + }, { "name": "format 1 time_only relabeled format 2", "spec": true,