Repository: openSUSE/umoci (go)

Issue:
security: expand CAS blob verification
Currently we verify that layer hashes match (against the DiffID), but it's probably a good idea that we also verify that the CAS blobs all are correct. We can probably do this by wrapping the returned blob with a verifier that returns an error on EOF if the digest is mismatched (though we'd have to make sure our `Close` calls as well as any `io.Copy` errors are all correctly checked).

Pull request description:
oci: cas*: add blob verification
Previously we didn't really check that blobs matched their hashes
directly -- most of the checks we did were based on layer DiffIDs and
similar checks.

Here we add a new casext.GetVerifiedBlob() API which wraps the
underlying reader with a VerifiedReadCloser that will verify that the
digest matches on EOF (or on Close). Users should be very careful to
ensure that they actually check all errors (such as when using
ioutil.Discard or when they do Close).

In addition, we add it to the underlying oci/cas/dir implementation
(just for safety). This adds effectively no overhead since
VerifiedReadCloser will not double-up on verification if the digest is
the same. We also add a basic verification of this as an integration and
some unit tests -- so that we can have some confidence it actually works
to protect against bad blobs.

This is an improvement to the hardened VerifiedReadCloser, such that it
now also acts as a verified LimitedReader. First of all, it actually
means we now check the length of blobs according to their descriptors.
It also allows us to avoid reading further than strictly necessary, if
we already know that the read will fail. Unfortunately, the standard
cas/dir cannot really handle this new verification properly.

Fixes #278 
Signed-off-by: Aleksa Sarai <asarai@suse.de>

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a896c12..ac1f8d2 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,11 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
 ## [Unreleased]
+## Added
+- Full-stack verification of blob hashes and descriptor sizes is now done on
+  all operations, improving our hardening against bad blobs (we already did
+  some verification of layer DiffIDs but this is far more thorough).
+  openSUSE/umoci#278 openSUSE/umoci#280 openSUSE/umoci#282
 
 ## [v0.4.3] - 2018-11-11
 ## Added
diff --git a/Dockerfile b/Dockerfile
index 5cc8fbb..6a9e22b 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -39,8 +39,9 @@ RUN zypper -n in \
 
 ENV GOPATH /go
 ENV PATH $GOPATH/bin:$PATH
-RUN go get -u golang.org/x/lint/golint
-RUN go get -u github.com/vbatts/git-validation && type git-validation
+RUN go get -u golang.org/x/lint/golint && \
+    go get -u github.com/vbatts/git-validation && \
+    go get -u github.com/securego/gosec/cmd/gosec
 
 ENV SOURCE_IMAGE=/opensuse SOURCE_TAG=latest
 ARG DOCKER_IMAGE=opensuse/amd64:tumbleweed
diff --git a/cmd/umoci/raw-runtime-config.go b/cmd/umoci/raw-runtime-config.go
index 8d71cb1..0d4b0ed 100644
--- a/cmd/umoci/raw-runtime-config.go
+++ b/cmd/umoci/raw-runtime-config.go
@@ -112,15 +112,15 @@ func rawConfig(ctx *cli.Context) error {
 	}
 	defer manifestBlob.Close()
 
-	if manifestBlob.MediaType != ispec.MediaTypeImageManifest {
-		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.MediaType), "invalid --image tag")
+	if manifestBlob.Descriptor.MediaType != ispec.MediaTypeImageManifest {
+		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.Descriptor.MediaType), "invalid --image tag")
 	}
 
 	// Get the manifest.
 	manifest, ok := manifestBlob.Data.(ispec.Manifest)
 	if !ok {
 		// Should _never_ be reached.
-		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.MediaType)
+		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.Descriptor.MediaType)
 	}
 
 	// Generate the configuration.
diff --git a/cmd/umoci/raw-unpack.go b/cmd/umoci/raw-unpack.go
index ebad8da..9e3f9a6 100644
--- a/cmd/umoci/raw-unpack.go
+++ b/cmd/umoci/raw-unpack.go
@@ -108,8 +108,8 @@ func rawUnpack(ctx *cli.Context) error {
 	}
 	defer manifestBlob.Close()
 
-	if manifestBlob.MediaType != ispec.MediaTypeImageManifest {
-		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.MediaType), "invalid --image tag")
+	if manifestBlob.Descriptor.MediaType != ispec.MediaTypeImageManifest {
+		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.Descriptor.MediaType), "invalid --image tag")
 	}
 
 	log.WithFields(log.Fields{
@@ -122,7 +122,7 @@ func rawUnpack(ctx *cli.Context) error {
 	manifest, ok := manifestBlob.Data.(ispec.Manifest)
 	if !ok {
 		// Should _never_ be reached.
-		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.MediaType)
+		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.Descriptor.MediaType)
 	}
 
 	// FIXME: Currently we only support OCI layouts, not tar archives. This
diff --git a/cmd/umoci/unpack.go b/cmd/umoci/unpack.go
index 21d7d17..bf00bb5 100644
--- a/cmd/umoci/unpack.go
+++ b/cmd/umoci/unpack.go
@@ -114,8 +114,8 @@ func unpack(ctx *cli.Context) error {
 	}
 	defer manifestBlob.Close()
 
-	if manifestBlob.MediaType != ispec.MediaTypeImageManifest {
-		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.MediaType), "invalid --image tag")
+	if manifestBlob.Descriptor.MediaType != ispec.MediaTypeImageManifest {
+		return errors.Wrap(fmt.Errorf("descriptor does not point to ispec.MediaTypeImageManifest: not implemented: %s", manifestBlob.Descriptor.MediaType), "invalid --image tag")
 	}
 
 	mtreeName := strings.Replace(meta.From.Descriptor().Digest.String(), ":", "_", 1)
@@ -130,7 +130,7 @@ func unpack(ctx *cli.Context) error {
 	manifest, ok := manifestBlob.Data.(ispec.Manifest)
 	if !ok {
 		// Should _never_ be reached.
-		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.MediaType)
+		return errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.Descriptor.MediaType)
 	}
 
 	// Unpack the runtime bundle.
diff --git a/mutate/mutate.go b/mutate/mutate.go
index 6327213..4fd9310 100644
--- a/mutate/mutate.go
+++ b/mutate/mutate.go
@@ -103,7 +103,7 @@ func (m *Mutator) cache(ctx context.Context) error {
 		manifest, ok := blob.Data.(ispec.Manifest)
 		if !ok {
 			// Should _never_ be reached.
-			return errors.Errorf("[internal error] unknown manifest blob type: %s", blob.MediaType)
+			return errors.Errorf("[internal error] unknown manifest blob type: %s", blob.Descriptor.MediaType)
 		}
 
 		// Make a copy of the manifest.
@@ -120,7 +120,7 @@ func (m *Mutator) cache(ctx context.Context) error {
 		config, ok := blob.Data.(ispec.Image)
 		if !ok {
 			// Should _never_ be reached.
-			return errors.Errorf("[internal error] unknown config blob type: %s", blob.MediaType)
+			return errors.Errorf("[internal error] unknown config blob type: %s", blob.Descriptor.MediaType)
 		}
 
 		// Make a copy of the config and configDescriptor.
diff --git a/oci/cas/dir/dir.go b/oci/cas/dir/dir.go
index bcb9d80..7bcc9a5 100644
--- a/oci/cas/dir/dir.go
+++ b/oci/cas/dir/dir.go
@@ -26,6 +26,7 @@ import (
 
 	"github.com/apex/log"
 	"github.com/openSUSE/umoci/oci/cas"
+	"github.com/openSUSE/umoci/pkg/hardening"
 	"github.com/opencontainers/go-digest"
 	imeta "github.com/opencontainers/image-spec/specs-go"
 	ispec "github.com/opencontainers/image-spec/specs-go/v1"
@@ -197,7 +198,11 @@ func (e *dirEngine) GetBlob(ctx context.Context, digest digest.Digest) (io.ReadC
 		return nil, errors.Wrap(err, "compute blob path")
 	}
 	fh, err := os.Open(filepath.Join(e.path, path))
-	return fh, errors.Wrap(err, "open blob")
+	return &hardening.VerifiedReadCloser{
+		Reader:         fh,
+		ExpectedDigest: digest,
+		ExpectedSize:   int64(-1), // We don't know the expected size.
+	}, errors.Wrap(err, "open blob")
 }
 
 // PutIndex sets the index of the OCI image to the given index, replacing the
diff --git a/oci/casext/blob.go b/oci/casext/blob.go
index 9fdf777..4c91a61 100644
--- a/oci/casext/blob.go
+++ b/oci/casext/blob.go
@@ -19,11 +19,9 @@ package casext
 
 import (
 	"encoding/json"
-	"fmt"
 	"io"
+	"io/ioutil"
 
-	"github.com/openSUSE/umoci/oci/cas"
-	"github.com/opencontainers/go-digest"
 	ispec "github.com/opencontainers/image-spec/specs-go/v1"
 	"github.com/pkg/errors"
 	"golang.org/x/net/context"
@@ -32,12 +30,9 @@ import (
 // Blob represents a "parsed" blob in an OCI image's blob store. MediaType
 // offers a type-safe way of checking what the type of Data is.
 type Blob struct {
-	// MediaType is the OCI media type of Data.
-	MediaType string
-
-	// Digest is the digest of the parsed image. Note that this does not update
-	// if Data is changed (it is the digest that this blob was parsed *from*).
-	Digest digest.Digest
+	// Descriptor is the {mediatype,digest,length} 3-tuple. Note that this
+	// isn't updated if the Data is modified.
+	Descriptor ispec.Descriptor
 
 	// Data is the "parsed" blob taken from the OCI image's blob store, and is
 	// typed according to the media type. The mapping from MIME => type is as
@@ -45,7 +40,7 @@ type Blob struct {
 	//
 	// ispec.MediaTypeDescriptor => ispec.Descriptor
 	// ispec.MediaTypeImageManifest => ispec.Manifest
-	// ispec.MediaTypeImageManifestList => ispec.ManifestList
+	// ispec.MediaTypeImageIndex => ispec.Index
 	// ispec.MediaTypeImageLayer => io.ReadCloser
 	// ispec.MediaTypeImageLayerGzip => io.ReadCloser
 	// ispec.MediaTypeImageLayerNonDistributable => io.ReadCloser
@@ -55,16 +50,33 @@ type Blob struct {
 	Data interface{}
 }
 
-func (b *Blob) load(ctx context.Context, engine cas.Engine) error {
-	reader, err := engine.GetBlob(ctx, b.Digest)
+func (b *Blob) isParseable() bool {
+	return b.Descriptor.MediaType == ispec.MediaTypeDescriptor ||
+		b.Descriptor.MediaType == ispec.MediaTypeImageManifest ||
+		b.Descriptor.MediaType == ispec.MediaTypeImageIndex ||
+		b.Descriptor.MediaType == ispec.MediaTypeImageConfig
+}
+
+func (b *Blob) load(ctx context.Context, engine Engine) (Err error) {
+	reader, err := engine.GetVerifiedBlob(ctx, b.Descriptor)
 	if err != nil {
 		return errors.Wrap(err, "get blob")
 	}
 
-	switch b.MediaType {
+	if b.isParseable() {
+		defer func() {
+			if _, err := io.Copy(ioutil.Discard, reader); Err == nil {
+				Err = errors.Wrapf(err, "discard trailing %q blob", b.Descriptor.MediaType)
+			}
+			if err := reader.Close(); Err == nil {
+				Err = errors.Wrapf(err, "close %q blob", b.Descriptor.MediaType)
+			}
+		}()
+	}
+
+	switch b.Descriptor.MediaType {
 	// ispec.MediaTypeDescriptor => ispec.Descriptor
 	case ispec.MediaTypeDescriptor:
-		defer reader.Close()
 		parsed := ispec.Descriptor{}
 		if err := json.NewDecoder(reader).Decode(&parsed); err != nil {
 			return errors.Wrap(err, "parse MediaTypeDescriptor")
@@ -73,7 +85,6 @@ func (b *Blob) load(ctx context.Context, engine cas.Engine) error {
 
 	// ispec.MediaTypeImageManifest => ispec.Manifest
 	case ispec.MediaTypeImageManifest:
-		defer reader.Close()
 		parsed := ispec.Manifest{}
 		if err := json.NewDecoder(reader).Decode(&parsed); err != nil {
 			return errors.Wrap(err, "parse MediaTypeImageManifest")
@@ -82,7 +93,6 @@ func (b *Blob) load(ctx context.Context, engine cas.Engine) error {
 
 	// ispec.MediaTypeImageIndex => ispec.Index
 	case ispec.MediaTypeImageIndex:
-		defer reader.Close()
 		parsed := ispec.Index{}
 		if err := json.NewDecoder(reader).Decode(&parsed); err != nil {
 			return errors.Wrap(err, "parse MediaTypeImageIndex")
@@ -91,13 +101,15 @@ func (b *Blob) load(ctx context.Context, engine cas.Engine) error {
 
 	// ispec.MediaTypeImageConfig => ispec.Image
 	case ispec.MediaTypeImageConfig:
-		defer reader.Close()
 		parsed := ispec.Image{}
 		if err := json.NewDecoder(reader).Decode(&parsed); err != nil {
 			return errors.Wrap(err, "parse MediaTypeImageConfig")
 		}
 		b.Data = parsed
 
+	// unknown => io.ReadCloser()
+	default:
+		fallthrough
 	// ispec.MediaTypeImageLayer => io.ReadCloser
 	// ispec.MediaTypeImageLayerGzip => io.ReadCloser
 	// ispec.MediaTypeImageLayerNonDistributable => io.ReadCloser
@@ -106,24 +118,17 @@ func (b *Blob) load(ctx context.Context, engine cas.Engine) error {
 		ispec.MediaTypeImageLayerGzip, ispec.MediaTypeImageLayerNonDistributableGzip:
 		// There isn't anything else we can practically do here.
 		b.Data = reader
-		return nil
-
-	// unknown => io.ReadCloser()
-	default:
-		b.Data = reader
-		return nil
 	}
 
 	if b.Data == nil {
-		return fmt.Errorf("[internal error] b.Data was nil after parsing")
+		return errors.Errorf("[internal error] b.Data was nil after parsing")
 	}
-
 	return nil
 }
 
 // Close cleans up all of the resources for the opened blob.
 func (b *Blob) Close() error {
-	switch b.MediaType {
+	switch b.Descriptor.MediaType {
 	case ispec.MediaTypeImageLayer, ispec.MediaTypeImageLayerNonDistributable,
 		ispec.MediaTypeImageLayerGzip, ispec.MediaTypeImageLayerNonDistributableGzip:
 		if b.Data != nil {
@@ -136,14 +141,12 @@ func (b *Blob) Close() error {
 // FromDescriptor parses the blob referenced by the given descriptor.
 func (e Engine) FromDescriptor(ctx context.Context, descriptor ispec.Descriptor) (*Blob, error) {
 	blob := &Blob{
-		MediaType: descriptor.MediaType,
-		Digest:    descriptor.Digest,
-		Data:      nil,
+		Descriptor: descriptor,
+		Data:       nil,
 	}
 
 	if err := blob.load(ctx, e); err != nil {
 		return nil, errors.Wrap(err, "load")
 	}
-
 	return blob, nil
 }
diff --git a/oci/casext/verified_blob.go b/oci/casext/verified_blob.go
new file mode 100644
index 0000000..622e03f
--- /dev/null
+++ b/oci/casext/verified_blob.go
@@ -0,0 +1,40 @@
+/*
+ * umoci: Umoci Modifies Open Containers' Images
+ * Copyright (C) 2018 SUSE LLC.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package casext
+
+import (
+	"context"
+	"io"
+
+	"github.com/openSUSE/umoci/pkg/hardening"
+	ispec "github.com/opencontainers/image-spec/specs-go/v1"
+)
+
+// GetVerifiedBlob returns a VerifiedReadCloser for retrieving a blob from the
+// image, which the caller must Close() *and* read-to-EOF (checking the error
+// code of both). Returns ErrNotExist if the digest is not found, and
+// ErrBlobDigestMismatch on a mismatched blob digest. In addition, the reader
+// is limited to the descriptor.Size.
+func (e Engine) GetVerifiedBlob(ctx context.Context, descriptor ispec.Descriptor) (io.ReadCloser, error) {
+	reader, err := e.GetBlob(ctx, descriptor.Digest)
+	return &hardening.VerifiedReadCloser{
+		Reader:         reader,
+		ExpectedDigest: descriptor.Digest,
+		ExpectedSize:   descriptor.Size,
+	}, err
+}
diff --git a/oci/layer/unpack.go b/oci/layer/unpack.go
index 7b0f3f4..af1c21e 100644
--- a/oci/layer/unpack.go
+++ b/oci/layer/unpack.go
@@ -210,13 +210,13 @@ func UnpackRootfs(ctx context.Context, engine cas.Engine, rootfsPath string, man
 		return errors.Wrap(err, "get config blob")
 	}
 	defer configBlob.Close()
-	if configBlob.MediaType != ispec.MediaTypeImageConfig {
-		return errors.Errorf("unpack rootfs: config blob is not correct mediatype %s: %s", ispec.MediaTypeImageConfig, configBlob.MediaType)
+	if configBlob.Descriptor.MediaType != ispec.MediaTypeImageConfig {
+		return errors.Errorf("unpack rootfs: config blob is not correct mediatype %s: %s", ispec.MediaTypeImageConfig, configBlob.Descriptor.MediaType)
 	}
 	config, ok := configBlob.Data.(ispec.Image)
 	if !ok {
 		// Should _never_ be reached.
-		return errors.Errorf("[internal error] unknown config blob type: %s", configBlob.MediaType)
+		return errors.Errorf("[internal error] unknown config blob type: %s", configBlob.Descriptor.MediaType)
 	}
 
 	// We can't understand non-layer images.
@@ -234,8 +234,8 @@ func UnpackRootfs(ctx context.Context, engine cas.Engine, rootfsPath string, man
 			return errors.Wrap(err, "get layer blob")
 		}
 		defer layerBlob.Close()
-		if !isLayerType(layerBlob.MediaType) {
-			return errors.Errorf("unpack rootfs: layer %s: blob is not correct mediatype: %s", layerBlob.Digest, layerBlob.MediaType)
+		if !isLayerType(layerBlob.Descriptor.MediaType) {
+			return errors.Errorf("unpack rootfs: layer %s: blob is not correct mediatype: %s", layerBlob.Descriptor.Digest, layerBlob.Descriptor.MediaType)
 		}
 		layerData, ok := layerBlob.Data.(io.ReadCloser)
 		if !ok {
@@ -244,7 +244,7 @@ func UnpackRootfs(ctx context.Context, engine cas.Engine, rootfsPath string, man
 		}
 
 		layerRaw := layerData
-		if needsGunzip(layerBlob.MediaType) {
+		if needsGunzip(layerBlob.Descriptor.MediaType) {
 			// We have to extract a gzip'd version of the above layer. Also note
 			// that we have to check the DiffID we're extracting (which is the
 			// sha256 sum of the *uncompressed* layer).
@@ -308,13 +308,13 @@ func UnpackRuntimeJSON(ctx context.Context, engine cas.Engine, configFile io.Wri
 		return errors.Wrap(err, "get config blob")
 	}
 	defer configBlob.Close()
-	if configBlob.MediaType != ispec.MediaTypeImageConfig {
-		return errors.Errorf("unpack manifest: config blob is not correct mediatype %s: %s", ispec.MediaTypeImageConfig, configBlob.MediaType)
+	if configBlob.Descriptor.MediaType != ispec.MediaTypeImageConfig {
+		return errors.Errorf("unpack manifest: config blob is not correct mediatype %s: %s", ispec.MediaTypeImageConfig, configBlob.Descriptor.MediaType)
 	}
 	config, ok := configBlob.Data.(ispec.Image)
 	if !ok {
 		// Should _never_ be reached.
-		return errors.Errorf("[internal error] unknown config blob type: %s", configBlob.MediaType)
+		return errors.Errorf("[internal error] unknown config blob type: %s", configBlob.Descriptor.MediaType)
 	}
 
 	g, err := rgen.New(runtime.GOOS)
diff --git a/pkg/hardening/verified_reader.go b/pkg/hardening/verified_reader.go
new file mode 100644
index 0000000..7474358
--- /dev/null
+++ b/pkg/hardening/verified_reader.go
@@ -0,0 +1,176 @@
+/*
+ * umoci: Umoci Modifies Open Containers' Images
+ * Copyright (C) 2018 SUSE LLC.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package hardening
+
+import (
+	"io"
+
+	"github.com/apex/log"
+	"github.com/opencontainers/go-digest"
+	"github.com/pkg/errors"
+)
+
+// Exported errors for verification issues that occur during processing within
+// VerifiedReadCloser. Note that you will need to use
+// "github.com/pkg/errors".Cause to get these exported errors in most cases.
+var (
+	ErrDigestMismatch = errors.Errorf("verified reader digest mismatch")
+	ErrSizeMismatch   = errors.Errorf("verified reader size mismatch")
+)
+
+// VerifiedReadCloser is a basic io.ReadCloser which allows for simple
+// verification that a stream matches an expected hash. The entire stream is
+// hashed while being passed through this reader, and on EOF it will verify
+// that the hash matches the expected hash. If not, an error is returned. Note
+// that this means you need to read all input to EOF in order to find
+// verification errors.
+//
+// If Reader is a VerifiedReadCloser (with the same ExpectedDigest), all of the
+// methods are just piped to the underlying methods (with no verification in
+// the upper layer).
+type VerifiedReadCloser struct {
+	// Reader is the underlying reader.
+	Reader io.ReadCloser
+
+	// ExpectedDigest is the expected digest. When the underlying reader
+	// returns an EOF, the entire stream's sum will be compared to this hash
+	// and an error will be returned if they don't match.
+	ExpectedDigest digest.Digest
+
+	// ExpectedSize is the expected amount of data to be read overall. If the
+	// underlying reader hasn't returned an EOF by the time this value is
+	// exceeded, an error is returned and no further reads will occur.
+	ExpectedSize int64
+
+	// digester stores the current state of the stream's hash.
+	digester digest.Digester
+
+	// currentSize is the number of bytes that have been read so far.
+	currentSize int64
+}
+
+func (v *VerifiedReadCloser) init() {
+	// Define digester if not already set.
+	if v.digester == nil {
+		alg := v.ExpectedDigest.Algorithm()
+		if !alg.Available() {
+			log.Fatalf("verified reader: unsupported hash algorithm %s", alg)
+			panic("verified reader: unreachable section") // should never be hit
+		}
+		v.digester = alg.Digester()
+	}
+}
+
+func (v *VerifiedReadCloser) isNoop() bool {
+	innerV, ok := v.Reader.(*VerifiedReadCloser)
+	return ok &&
+		innerV.ExpectedDigest == v.ExpectedDigest &&
+		innerV.ExpectedSize == v.ExpectedSize
+}
+
+func (v *VerifiedReadCloser) verify(nilErr error) error {
+	// Digest mismatch (always takes precedence)?
+	if actualDigest := v.digester.Digest(); actualDigest != v.ExpectedDigest {
+		return errors.Wrapf(ErrDigestMismatch, "expected %s not %s", v.ExpectedDigest, actualDigest)
+	}
+	// Do we need to check the size for mismatches?
+	if v.ExpectedSize >= 0 {
+		switch {
+		// Not enough bytes in the stream.
+		case v.currentSize < v.ExpectedSize:
+			return errors.Wrapf(ErrSizeMismatch, "expected %d bytes (only %d bytes in stream)", v.ExpectedSize, v.currentSize)
+
+		// We don't read the entire blob, so the message needs to be slightly adjusted.
+		case v.currentSize > v.ExpectedSize:
+			return errors.Wrapf(ErrSizeMismatch, "expected %d bytes (extra bytes in stream)", v.ExpectedSize)
+
+		}
+	}
+	// Forward the provided error.
+	return nilErr
+}
+
+// Read is a wrapper around VerifiedReadCloser.Reader, with a digest check on
+// EOF.  Make sure that you always check for EOF and read-to-the-end for all
+// files.
+func (v *VerifiedReadCloser) Read(p []byte) (n int, err error) {
+	// Make sure we don't read after v.ExpectedSize has been passed.
+	err = io.EOF
+	left := v.ExpectedSize - v.currentSize
+	switch {
+	// ExpectedSize has been disabled.
+	case v.ExpectedSize < 0:
+		n, err = v.Reader.Read(p)
+
+	// We still have something left to read.
+	case left > 0:
+		if int64(len(p)) > left {
+			p = p[:left]
+		}
+		// Piped to the underling read.
+		n, err = v.Reader.Read(p)
+		v.currentSize += int64(n)
+
+	// We have either read everything, or just happened to land on a boundary
+	// (with potentially more things afterwards). So we must check if there is
+	// anything left by doing a 1-byte read (Go doesn't allow for zero-length
+	// Read()s to give EOFs).
+	case left == 0:
+		// We just want to know whether we read something (n>0). #nosec G104
+		nTmp, _ := v.Reader.Read(make([]byte, 1))
+		v.currentSize += int64(nTmp)
+	}
+	// Are we going to be a noop?
+	if v.isNoop() {
+		return n, err
+	}
+	// Make sure we're ready.
+	v.init()
+	// Forward it to the digester.
+	if n > 0 {
+		// hash.Hash guarantees Write() never fails and is never short.
+		nWrite, err := v.digester.Hash().Write(p[:n])
+		if nWrite != n || err != nil {
+			log.Fatalf("verified reader: short write to %s Digester (err=%v)", v.ExpectedDigest.Algorithm(), err)
+			panic("verified reader: unreachable section") // should never be hit
+		}
+	}
+	// We have finished reading -- let's verify the state!
+	if errors.Cause(err) == io.EOF {
+		err = v.verify(err)
+	}
+	return n, err
+}
+
+// Close is a wrapper around VerifiedReadCloser.Reader, but with a digest check
+// which will return an error if the underlying Close() didn't.
+func (v *VerifiedReadCloser) Close() error {
+	// Piped to underlying close.
+	err := v.Reader.Close()
+	if err != nil {
+		return err
+	}
+	// Are we going to be a noop?
+	if v.isNoop() {
+		return err
+	}
+	// Make sure we're ready.
+	v.init()
+	// Verify the state.
+	return v.verify(nil)
+}
diff --git a/utils.go b/utils.go
index 5dc3122..cb5556c 100644
--- a/utils.go
+++ b/utils.go
@@ -205,7 +205,7 @@ func Stat(ctx context.Context, engine casext.Engine, manifestDescriptor ispec.De
 	manifest, ok := manifestBlob.Data.(ispec.Manifest)
 	if !ok {
 		// Should _never_ be reached.
-		return stat, errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.MediaType)
+		return stat, errors.Errorf("[internal error] unknown manifest blob type: %s", manifestBlob.Descriptor.MediaType)
 	}
 
 	// Now get the config.
@@ -216,7 +216,7 @@ func Stat(ctx context.Context, engine casext.Engine, manifestDescriptor ispec.De
 	config, ok := configBlob.Data.(ispec.Image)
 	if !ok {
 		// Should _never_ be reached.
-		return stat, errors.Errorf("[internal error] unknown config blob type: %s", configBlob.MediaType)
+		return stat, errors.Errorf("[internal error] unknown config blob type: %s", configBlob.Descriptor.MediaType)
 	}
 
 	// TODO: This should probably be moved into separate functions.


Test patch:
diff --git a/pkg/hardening/verified_reader_test.go b/pkg/hardening/verified_reader_test.go
new file mode 100644
index 0000000..8e16b9a
--- /dev/null
+++ b/pkg/hardening/verified_reader_test.go
@@ -0,0 +1,284 @@
+/*
+ * umoci: Umoci Modifies Open Containers' Images
+ * Copyright (C) 2018 SUSE LLC.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *    http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package hardening
+
+import (
+	"bytes"
+	"crypto/rand"
+	// Needed for digest.
+	_ "crypto/sha256"
+	"fmt"
+	"io"
+	"io/ioutil"
+	"testing"
+
+	"github.com/opencontainers/go-digest"
+	"github.com/pkg/errors"
+)
+
+func TestValid(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		t.Run(fmt.Sprintf("size:%d", size), func(t *testing.T) {
+			// Fill buffer with random data.
+			buffer := new(bytes.Buffer)
+			if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+				t.Fatalf("getting random data for buffer failed: %v", err)
+			}
+
+			// Get expected hash.
+			expectedDigest := digest.SHA256.FromBytes(buffer.Bytes())
+			verifiedReader := &VerifiedReadCloser{
+				Reader:         ioutil.NopCloser(buffer),
+				ExpectedDigest: expectedDigest,
+				ExpectedSize:   int64(size),
+			}
+
+			// Make sure everything if we copy-to-EOF we get no errors.
+			if _, err := io.Copy(ioutil.Discard, verifiedReader); err != nil {
+				t.Errorf("expected digest+size to be correct on EOF: got an error: %v", err)
+			}
+
+			// And on close we shouldn't get an error either.
+			if err := verifiedReader.Close(); err != nil {
+				t.Errorf("expected digest+size to be correct on Close: got an error: %v", err)
+			}
+		})
+	}
+}
+
+func TestValidIgnoreLength(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		t.Run(fmt.Sprintf("size:%d", size), func(t *testing.T) {
+			// Fill buffer with random data.
+			buffer := new(bytes.Buffer)
+			if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+				t.Fatalf("getting random data for buffer failed: %v", err)
+			}
+
+			// Get expected hash.
+			expectedDigest := digest.SHA256.FromBytes(buffer.Bytes())
+			verifiedReader := &VerifiedReadCloser{
+				Reader:         ioutil.NopCloser(buffer),
+				ExpectedDigest: expectedDigest,
+				ExpectedSize:   int64(-1),
+			}
+
+			// Make sure everything if we copy-to-EOF we get no errors.
+			if _, err := io.Copy(ioutil.Discard, verifiedReader); err != nil {
+				t.Errorf("expected digest+size to be correct on EOF: got an error: %v", err)
+			}
+
+			// And on close we shouldn't get an error either.
+			if err := verifiedReader.Close(); err != nil {
+				t.Errorf("expected digest+size to be correct on Close: got an error: %v", err)
+			}
+		})
+	}
+}
+
+func TestInvalidDigest(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		t.Run(fmt.Sprintf("size:%d", size), func(t *testing.T) {
+			// Fill buffer with random data.
+			buffer := new(bytes.Buffer)
+			if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+				t.Fatalf("getting random data for buffer failed: %v", err)
+			}
+
+			// Generate an *incorrect* hash.
+			fakeBytes := append(buffer.Bytes()[1:], 0x80)
+			expectedDigest := digest.SHA256.FromBytes(fakeBytes)
+			verifiedReader := &VerifiedReadCloser{
+				Reader:         ioutil.NopCloser(buffer),
+				ExpectedDigest: expectedDigest,
+				ExpectedSize:   int64(size),
+			}
+
+			// Make sure everything if we copy-to-EOF we get the right error.
+			if _, err := io.Copy(ioutil.Discard, verifiedReader); errors.Cause(err) != ErrDigestMismatch {
+				t.Errorf("expected digest to be invalid on EOF: got wrong error: %v", err)
+			}
+
+			// And on close we should get the error.
+			if err := verifiedReader.Close(); errors.Cause(err) != ErrDigestMismatch {
+				t.Errorf("expected digest to be invalid on Close: got wrong error: %v", err)
+			}
+		})
+	}
+}
+
+func TestInvalidSize_Short(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		for delta := 1; delta-1 <= size/2; delta *= 2 {
+			t.Run(fmt.Sprintf("size:%d_delta:%d", size, delta), func(t *testing.T) {
+				// Fill buffer with random data.
+				buffer := new(bytes.Buffer)
+				if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+					t.Fatalf("getting random data for buffer failed: %v", err)
+				}
+
+				// Generate a correct hash (for a shorter buffer), but limit the
+				// size to be smaller.
+				shortBuffer := buffer.Bytes()[:buffer.Len()-delta]
+				expectedDigest := digest.SHA256.FromBytes(shortBuffer)
+				verifiedReader := &VerifiedReadCloser{
+					Reader:         ioutil.NopCloser(buffer),
+					ExpectedDigest: expectedDigest,
+					ExpectedSize:   int64(size - delta),
+				}
+
+				// Make sure everything if we copy-to-EOF we get the right error.
+				if _, err := io.Copy(ioutil.Discard, verifiedReader); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on EOF: got wrong error: %v", err)
+				}
+
+				// And on close we should get the error.
+				if err := verifiedReader.Close(); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on Close: got wrong error: %v", err)
+				}
+			})
+
+		}
+	}
+}
+
+func TestInvalidSize_LongBuffer(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		for delta := 1; delta-1 <= size/2; delta *= 2 {
+			t.Run(fmt.Sprintf("size:%d_delta:%d", size, delta), func(t *testing.T) {
+				// Fill buffer with random data.
+				buffer := new(bytes.Buffer)
+				if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+					t.Fatalf("getting random data for buffer failed: %v", err)
+				}
+
+				// Generate a correct hash (for the full buffer), but limit the
+				// size to be smaller (so that we ensure we don't allow such
+				// actions).
+				shortBuffer := buffer.Bytes()[:size-delta]
+				expectedDigest := digest.SHA256.FromBytes(shortBuffer)
+				verifiedReader := &VerifiedReadCloser{
+					Reader:         ioutil.NopCloser(buffer),
+					ExpectedDigest: expectedDigest,
+					ExpectedSize:   int64(size - delta),
+				}
+
+				// Make sure everything if we copy-to-EOF we get the right error.
+				if _, err := io.Copy(ioutil.Discard, verifiedReader); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on EOF: got wrong error: %v", err)
+				}
+
+				// And on close we should get the error.
+				if err := verifiedReader.Close(); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on Close: got wrong error: %v", err)
+				}
+			})
+		}
+	}
+}
+
+func TestInvalidSize_Long(t *testing.T) {
+	for size := 1; size <= 16384; size *= 2 {
+		for delta := 1; delta-1 <= size/2; delta *= 2 {
+			t.Run(fmt.Sprintf("size:%d_delta:%d", size, delta), func(t *testing.T) {
+				// Fill buffer with random data.
+				buffer := new(bytes.Buffer)
+				if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+					t.Fatalf("getting random data for buffer failed: %v", err)
+				}
+
+				// Generate a correct hash, but set the size to be larger.
+				expectedDigest := digest.SHA256.FromBytes(buffer.Bytes())
+				verifiedReader := &VerifiedReadCloser{
+					Reader:         ioutil.NopCloser(buffer),
+					ExpectedDigest: expectedDigest,
+					ExpectedSize:   int64(size + delta),
+				}
+
+				// Make sure everything if we copy-to-EOF we get the right error.
+				if _, err := io.Copy(ioutil.Discard, verifiedReader); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on EOF: got wrong error: %v", err)
+				}
+
+				// And on close we should get the error.
+				if err := verifiedReader.Close(); errors.Cause(err) != ErrSizeMismatch {
+					t.Errorf("expected size to be invalid on Close: got wrong error: %v", err)
+				}
+			})
+		}
+	}
+}
+
+func TestNoop(t *testing.T) {
+	// Fill buffer with random data.
+	buffer := new(bytes.Buffer)
+	size := 256
+	if _, err := io.CopyN(buffer, rand.Reader, int64(size)); err != nil {
+		t.Fatalf("getting random data for buffer failed: %v", err)
+	}
+
+	// Get expected hash.
+	expectedDigest := digest.SHA256.FromBytes(buffer.Bytes())
+	verifiedReader := &VerifiedReadCloser{
+		Reader:         ioutil.NopCloser(buffer),
+		ExpectedDigest: expectedDigest,
+		ExpectedSize:   int64(size),
+	}
+
+	// And make an additional wrapper with the same digest+size ...
+	wrappedReader := &VerifiedReadCloser{
+		Reader:         verifiedReader,
+		ExpectedDigest: verifiedReader.ExpectedDigest,
+		ExpectedSize:   verifiedReader.ExpectedSize,
+	}
+
+	// ... and a different digest.
+	doubleWrappedReader := &VerifiedReadCloser{
+		Reader:         wrappedReader,
+		ExpectedDigest: digest.SHA256.FromString("foo"),
+		ExpectedSize:   wrappedReader.ExpectedSize,
+	}
+
+	// ... and a different size.
+	tripleWrappedReader := &VerifiedReadCloser{
+		Reader:         doubleWrappedReader,
+		ExpectedDigest: doubleWrappedReader.ExpectedDigest,
+		ExpectedSize:   doubleWrappedReader.ExpectedSize - 1,
+	}
+
+	// Read from the uppermost wrapper, ignoring all errors.
+	_, _ = io.Copy(ioutil.Discard, tripleWrappedReader)
+	_ = tripleWrappedReader.Close()
+
+	// Bottom-most wrapper should've been hit.
+	if verifiedReader.digester == nil {
+		t.Errorf("verifiedReader didn't digest input")
+	}
+	// Middle wrapper (identical to lowest) is a noop.
+	if wrappedReader.digester != nil {
+		t.Errorf("wrappedReader wasn't noop'd out")
+	}
+	// Different-digest wrapper is *not* a noop.
+	if doubleWrappedReader.digester == nil {
+		t.Errorf("doubleWrappedReader was incorrectly noop'd out")
+	}
+	// Different-size wrapper is *not* a noop.
+	if tripleWrappedReader.digester == nil {
+		t.Errorf("tripleWrappedReader was incorrectly noop'd out")
+	}
+}
diff --git a/test/hardening.bats b/test/hardening.bats
new file mode 100644
index 0000000..5215a05
--- /dev/null
+++ b/test/hardening.bats
@@ -0,0 +1,108 @@
+#!/usr/bin/env bats -t
+# umoci: Umoci Modifies Open Containers' Images
+# Copyright (C) 2018 SUSE LLC.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+load helpers
+
+function setup() {
+	setup_image
+}
+
+function teardown() {
+	teardown_tmpdirs
+	teardown_image
+}
+
+@test "umoci unpack [blob hash hardening]" {
+	readarray -t allBlobs < <( cd "$IMAGE" && find "./blobs" -type f )
+	for blob in "${allBlobs[@]}"
+	do
+		# Get a clean image.
+		NEW_IMAGE="$(setup_tmpdir)"
+		cp -rT "$IMAGE" "$NEW_IMAGE"
+
+		blobHash="$(basename "$blob")" # sha256
+
+		# Corrupt our blob such that the digest is changed within the expected
+		# lengths (this takes priority over length issues) while also not
+		# causing other verification to fail.
+		case "$(file -bi "$NEW_IMAGE/$blob")" in
+		*gzip*)
+			# Re-compress it with a worse compression ratio. This, combined
+			# with the fact that different gzip implementations produce
+			# different output, means we will almost certainly get a new hash
+			# *without* invalidating the DiffID.
+			gzip -d <"$NEW_IMAGE/$blob" | gzip -3 | sponge "$NEW_IMAGE/$blob"
+
+			# Make sure it's actually a different hash.
+			[ "$(sha256sum "$NEW_IMAGE/$blob" | grep -o "$blobHash" | wc -l)" -eq 1 ]
+			;;
+		*)
+			# Add a single whitespace byte at the *start* of the file which
+			# will invalidate the prefix hash.
+			( printf ' ' ; cat "$NEW_IMAGE/$blob" ) | sponge "$NEW_IMAGE/$blob"
+			;;
+		esac
+
+		# Now let's try to extract it.
+		new_bundle_rootfs
+		umoci unpack --image "${NEW_IMAGE}:${TAG}" "$BUNDLE"
+		[ "$status" -ne 0 ]
+		echo "$output" | grep "verified reader digest mismatch"
+
+		# TODO: When "umoci stat" grows recursive information output, use that.
+		# TODO: Add more operations to check (repack might be complicated).
+	done
+}
+
+@test "umoci unpack [descriptor size hardening]" {
+	readarray -t allBlobs < <( cd "$IMAGE" && find "./blobs" -type f )
+	for blob in "${allBlobs[@]}"
+	do
+		# Get a clean image.
+		NEW_IMAGE="$(setup_tmpdir)"
+		cp -rT "$IMAGE" "$NEW_IMAGE"
+
+		blobHash="$(basename "$blob")" # sha256
+
+		# Corrupt our blob such that the length is changed, and the length
+		# issue will be triggered (this means the prefix-digest or whole-file
+		# digest should be correct).
+		case "$(file -bi "$NEW_IMAGE/$blob")" in
+		*gzip*)
+			# TODO: Figure out how to do this with gzip.
+			continue
+			;;
+		*)
+			# Add a single NUL byte at the *end* of the file which won't
+			# invalidate the prefix hash.
+			( cat "$NEW_IMAGE/$blob"; printf '\0x00' ) | sponge "$NEW_IMAGE/$blob"
+			;;
+		esac
+
+		# TODO: Add more variants of this attack, such as modifying the length
+		#       of descriptors. Though this might be quite hard to do in a
+		#       comprehensive way with jq.
+
+		# Now let's try to extract it.
+		new_bundle_rootfs
+		umoci unpack --image "${NEW_IMAGE}:${TAG}" "$BUNDLE"
+		[ "$status" -ne 0 ]
+		echo "$output" | grep "verified reader size mismatch"
+
+		# TODO: When "umoci stat" grows recursive information output, use that.
+		# TODO: Add more operations to check (repack might be complicated).
+	done
+}