package archive import ( "archive/tar" "crypto/ecdh" "crypto/sha256" "errors" "fmt" "io" "github.com/carlosframework/platform/internal/appconfig" ) // errVerifyAborted is what Verify closes its pipe with when it stops // reading before the end, so Open's next write fails and its goroutine // exits instead of blocking on the pipe for the rest of the archive. var errVerifyAborted = errors.New("archive: verify stopped reading") // check validates a manifest on its own, before any byte of the archive is // read: the version and chunk size this package writes, the digests and // key present, and an entry list Build could have written (valid, unique // paths within the limits, sizes summing to Bytes, at most Cap). func (m Manifest) check() error { switch { case m.V != ManifestVersion: return fmt.Errorf("archive: manifest version %d, want %d", m.V, ManifestVersion) case m.Chunk != ChunkSize: return fmt.Errorf("archive: manifest chunk %d, want %d", m.Chunk, ChunkSize) case m.Plain == "" || m.Sealed == "" || m.SealedDataKey == "": return errors.New("archive: manifest carries no sealed digest, plaintext digest or sealed data key") case len(m.Entries) > entryLimit: return ErrTooManyEntries } seen := make(map[string]bool, len(m.Entries)) var sum int64 for _, e := range m.Entries { if _, _, err := checkName(e.Path, false); err != nil { return err } if seen[e.Path] { return fmt.Errorf("%w: %q named twice in the manifest", ErrBadPath, e.Path) } seen[e.Path] = true if e.Size < 0 || e.Size > Cap-sum { return ErrOverCap } sum += e.Size } if sum != m.Bytes { return fmt.Errorf("archive: manifest entries hold %d bytes, manifest says %d", sum, m.Bytes) } return nil } // Verify is T5 (ยง7.3): it opens the sealed object with priv, streams the // tar out of zstd and checks everything the manifest promised: the // plaintext digest, every entry's path, size and sha256, the entry types // (regular files and directories only), the limits, and the sealed digest // of the bytes it read. It writes nothing and returns the first mismatch. // Open's own verdict (every chunk authenticated, the final chunk present // and last) wins over any mismatch found in what it produced. func Verify(sealed io.Reader, m Manifest, priv *ecdh.PrivateKey, b appconfig.Binding) error { if err := m.check(); err != nil { return err } sealedHash := sha256.New() pr, pw := io.Pipe() done := make(chan error, 1) go func() { err := Open(io.TeeReader(sealed, sealedHash), m.SealedDataKey, priv, b, pw) pw.CloseWithError(err) done <- err }() plain, err := verifyStream(pr, m) // Break the pipe whatever happened, so a producer still writing fails // and exits, then wait for it. After a clean read to EOF this is a // no-op: Open has already returned. pr.CloseWithError(errVerifyAborted) if openErr := <-done; openErr != nil && !errors.Is(openErr, errVerifyAborted) { return openErr } if err != nil { return err } if plain != m.Plain { return fmt.Errorf("archive: plaintext digest %s, manifest says %s", plain, m.Plain) } if got := digestString(sealedHash); got != m.Sealed { return fmt.Errorf("archive: sealed digest %s, manifest says %s", got, m.Sealed) } return nil } // verifyStream reads the opened archive from r to its end and checks it // against m, returning the plaintext digest. h hashes the decoded tar, // end marker included, which is exactly what Build's hashWriter hashed (it // sits between tar.Writer and the compressor), so Plain is the same bytes // on both sides (round-4 finding 8). func verifyStream(r io.Reader, m Manifest) (string, error) { zr, err := newDecoder(r) if err != nil { return "", err } defer zr.Close() h := sha256.New() dec := &boundedReader{r: zr, left: decodedLimit} tr := tar.NewReader(io.TeeReader(dec, h)) want := make(map[string]Entry, len(m.Entries)) for _, e := range m.Entries { want[e.Path] = e } rules := newStreamRules() for { hdr, err := tr.Next() if err == io.EOF { break } if err != nil { return "", err } name, _, _, err := rules.admit(hdr) if err != nil { return "", err } if hdr.Typeflag == tar.TypeDir { continue } e, ok := want[name] if !ok { return "", fmt.Errorf("archive: entry %q is not in the manifest", hdr.Name) } if e.Size != hdr.Size { return "", fmt.Errorf("archive: entry %q size differs from the manifest", hdr.Name) } fh := sha256.New() if _, err := io.CopyN(fh, tr, hdr.Size); err != nil { return "", err } if digestString(fh) != e.SHA256 { return "", fmt.Errorf("archive: entry %q digest differs", hdr.Name) } delete(want, name) } // tar.Reader stops at the end-of-archive marker; whatever follows in // the decoded stream must still reach h, and the raw stream must then // end, so Open's terminal result becomes this read's error. A Verify // that returned at the tar marker would trust an archive whose tail // was never authenticated. if _, err := io.Copy(h, dec); err != nil { return "", err } if err := requireEOF(r); err != nil { return "", err } if len(want) != 0 { return "", fmt.Errorf("archive: %d manifest entries missing from the archive", len(want)) } if rules.content != m.Bytes { return "", fmt.Errorf("archive: the archive holds %d bytes, manifest says %d", rules.content, m.Bytes) } return digestString(h), nil }