Open reads format 3 (spec 29.2 to 29.7, 63 steps 17 and 18): the PRELUDE accepts VERSION 3, and the files go to a Sink. - Sink: Begin with the validated head, Create for each file in the order of the head, and Commit only after every check of step 17; after any failure that follows a successful Begin, Abort, once. A format 3 capsule without a Sink fails right after step 2 with ErrSinkRequired, a caller error with no code, no failed step and no request; a capsule of format 1 or 2 without dst fails there too. - Step 17 in its substeps: the frame and the area, security and its verdicts, which never fail, the head, the files filling CONTENT, the SHA-256 of each file and the padding. A failure of age or a plaintext whose length is not P prevails; otherwise the first substep that fails decides, and a code other than ERR_INTEGRITY is reported only after reading PAYLOAD_AGE to its end. - The reads of BODY grow with the bytes received, never with AREA_LEN, HEAD_LEN or a declared size (spec 57); a test measures it. - A failure of the Sink is the caller's own error with ERR_INTEGRITY, as one of dst is in formats 1 and 2. - Opened gains Head, Verdicts, AreaLen and UnusableHeadExtensions. - Test data: "version changed" sets VERSION 4, and the format 2 list gains "format 2 time_only relabeled format 3", which fails at step 14, as section 64 of spec v0.10 lists: 126 cases, 89 of the spec. The randomly built capsules keep their recorded bytes. - testkit: Build writes format 3 and can edit the padded plaintext; Head3, Body3, DiscardSink and MemorySink build and open BODY. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>v0.10
parent
72e86b8d79
commit
7249ef4cd1
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
Loading…
Reference in new issue