diff --git a/content/local/store.go b/content/local/store.go index 7af3d2ae9230..58cc295aa034 100644 --- a/content/local/store.go +++ b/content/local/store.go @@ -30,6 +30,7 @@ import ( "github.com/containerd/containerd/v2/content" "github.com/containerd/containerd/v2/errdefs" "github.com/containerd/containerd/v2/filters" + "github.com/containerd/containerd/v2/pkg/fsverity" "github.com/containerd/log" "github.com/opencontainers/go-digest" @@ -128,6 +129,11 @@ func (s *store) ReaderAt(ctx context.Context, desc ocispec.Descriptor) (content. return nil, fmt.Errorf("calculating blob path for ReaderAt: %w", err) } + log.G(ctx).Debugf("Getting reader for blob %v", p) + if err = validateIntegrity(s.root, p, desc); err != nil { + log.G(ctx).Errorf("error validating integrity value of blob %v: %s", p, err.Error()) + } + reader, err := OpenReader(p) if err != nil { return nil, fmt.Errorf("blob %s expected at %s: %w", desc.Digest, p, err) @@ -154,6 +160,15 @@ func (s *store) Delete(ctx context.Context, dgst digest.Digest) error { return fmt.Errorf("content %v: %w", dgst, errdefs.ErrNotFound) } + integrityFile := filepath.Join(s.root, "integrity", dgst.Encoded()) + if err := os.RemoveAll(integrityFile); err != nil { + if !os.IsNotExist(err) { + return err + } + + return fmt.Errorf("integrity file %v: %w", dgst, errdefs.ErrNotFound) + } + return nil } @@ -684,3 +699,36 @@ func writeToCompletion(path string, data []byte, mode os.FileMode) error { } return nil } + +func validateIntegrity(rootPath string, p string, desc ocispec.Descriptor) error { + // validate the integrity of the blob if integrity validation is supported + if supported := fsverity.IsSupported(rootPath); !supported { + return fmt.Errorf("integrity validation is not supported") + } + + var verityDigest string + verityDigest, merr := fsverity.Measure(p) + if merr != nil { + return fmt.Errorf("failed to take fsverity measurement of blob: %s", merr.Error()) + } + + var expectedDigest string + integrityFile := filepath.Join(rootPath, "integrity", desc.Digest.Encoded()) + ifd, err := os.Open(integrityFile) + if err != nil { + return fmt.Errorf("could not read expected integrity value of %s", p) + } + b, err := io.ReadAll(ifd) + if err != nil { + return fmt.Errorf("could not read fsverity digest from integrity file: %s", err.Error()) + } else { + expectedDigest = string(b) + } + + // compare the digest to the "good" value stored in the blob label + if verityDigest != expectedDigest { + return fmt.Errorf("blob not trusted: fsverity digest does not match the expected digest value") + } + + return nil +} diff --git a/content/local/writer.go b/content/local/writer.go index d22c3365cd67..e95989ef3eb4 100644 --- a/content/local/writer.go +++ b/content/local/writer.go @@ -28,6 +28,7 @@ import ( "github.com/containerd/containerd/v2/content" "github.com/containerd/containerd/v2/errdefs" + "github.com/containerd/containerd/v2/pkg/fsverity" "github.com/containerd/log" "github.com/opencontainers/go-digest" ) @@ -137,6 +138,10 @@ func (w *writer) Commit(ctx context.Context, size int64, expected digest.Digest, return err } + if err = storeIntegrity(w.s.root, target, dgst); err != nil { + log.G(ctx).Errorf("failed to store integrity of blob %v: %s", target, err.Error()) + } + // Ingest has now been made available in the content store, attempt to complete // setting metadata but errors should only be logged and not returned since // the content store cannot be cleanly rolled back. @@ -151,6 +156,7 @@ func (w *writer) Commit(ctx context.Context, size int64, expected digest.Digest, log.G(ctx).WithField("ref", w.ref).WithField("path", w.path).Error("failed to remove ingest directory") } + log.G(ctx).Debugf("content labels: %v", base.Labels) if w.s.ls != nil && base.Labels != nil { if err := w.s.ls.Set(dgst, base.Labels); err != nil { log.G(ctx).WithField("digest", dgst).Error("failed to set labels") @@ -206,3 +212,48 @@ func (w *writer) Truncate(size int64) error { } return w.fp.Truncate(0) } + +func storeIntegrity(rootPath string, target string, dgst digest.Digest) error { + if supported := fsverity.IsSupported(rootPath); !supported { + return fmt.Errorf("integrity validation is not supported") + } + + var verityDigest string + if err := fsverity.Enable(target); err != nil { + return fmt.Errorf("failed to enable fsverity verification: %s", err.Error()) + } + + verityDigest, merr := fsverity.Measure(target) + if merr != nil { + return fmt.Errorf("failed to take fsverity measurement of blob: %s", merr.Error()) + } + + integrityStore := filepath.Join(rootPath, "integrity") + if err := os.MkdirAll(integrityStore, 0755); err != nil { + return fmt.Errorf("error creating integrity digest directory: %s", err) + } + + digestPath := filepath.Join(integrityStore, dgst.Encoded()) + _, err := os.Stat(digestPath) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("integrity digest for blob already exists: %s", err) + } + } + + digestFile, err := os.Create(digestPath) + if err != nil { + if os.IsExist(err) { + return fmt.Errorf("error creating integrity digest file: %s", err) + } + + return fmt.Errorf("Error creating integrity digest file for blob: %s", dgst.Encoded()) + } + + _, err = digestFile.WriteString(verityDigest) + if err != nil { + return fmt.Errorf("error writing vsverity digest to file: %s", err) + } + + return nil +} diff --git a/pkg/fsverity/fsverity_linux.go b/pkg/fsverity/fsverity_linux.go new file mode 100644 index 000000000000..9c01c5cc4ed0 --- /dev/null +++ b/pkg/fsverity/fsverity_linux.go @@ -0,0 +1,170 @@ +//go:build linux + +/* + Copyright The containerd Authors. + + 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 fsverity + +import ( + "fmt" + "os" + "path/filepath" + "sync" + "syscall" + "unsafe" + + "github.com/containerd/containerd/v2/contrib/seccomp/kernelversion" + "golang.org/x/sys/unix" +) + +type fsverityEnableArg struct { + version uint32 + hashAlgorithm uint32 + blockSize uint32 + saltSize uint32 + saltPtr uint64 + sigSize uint32 + reserved1 uint32 + sigPtr uint64 + reserved2 [11]uint64 +} + +type fsverityDigest struct { + digestAlgorithm uint16 + digestSize uint16 + digest [64]uint8 +} + +const ( + defaultBlockSize int = 4096 + maxDigestSize uint16 = 64 +) + +var ( + once sync.Once + supported bool +) + +func IsSupported(rootPath string) bool { + once.Do(func () { + minKernelVersion := kernelversion.KernelVersion{Kernel: 5, Major: 4} + s, err := kernelversion.GreaterEqualThan(minKernelVersion) + if err != nil { + supported = s + return + } + + integrityStore := filepath.Join(rootPath, "integrity") + if err = os.MkdirAll(integrityStore, 0755); err != nil { + supported = false + return + } + + digestPath := filepath.Join(integrityStore, "supported") + digestFile, err := os.Create(digestPath) + if err != nil { + supported = false + return + } + digestFile.Close() + defer os.Remove(digestPath) + + eerr := Enable(digestPath) + if eerr != nil { + supported = false + return + } + + supported = true + }) + return supported +} + +func IsEnabled(path string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, fmt.Errorf("Error opening file: %s", err) + } + + var attr int + + _, _, flagErr := unix.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(unix.FS_IOC_GETFLAGS), uintptr(unsafe.Pointer(&attr))) + if flagErr != 0 { + return false, fmt.Errorf("Error getting inode flags: %s", flagErr) + } + + if attr&unix.FS_VERITY_FL == unix.FS_VERITY_FL { + return true, nil + } + + return false, nil +} + +func Enable(path string) error { + f, err := os.Open(path) + if err != nil { + return fmt.Errorf("Error opening file: %s\n", err.Error()) + } + + var args *fsverityEnableArg = &fsverityEnableArg{} + args.version = 1 + args.hashAlgorithm = 1 + + // fsverity block size should be the minimum between the page size + // and the file system block size + // If neither value is retrieved successfully, set fsverity block size to the default value + blockSize := unix.Getpagesize() + + s := unix.Stat_t{} + serr := unix.Stat(path, &s) + if serr == nil && int(s.Blksize) < blockSize { + blockSize = int(s.Blksize) + } + + if blockSize <= 0 { + blockSize = defaultBlockSize + } + + args.blockSize = uint32(blockSize) + + _, _, errno := unix.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(unix.FS_IOC_ENABLE_VERITY), uintptr(unsafe.Pointer(args))) + if errno != 0 { + return fmt.Errorf("Enable fsverity failed: %d\n", errno) + } + + return nil +} + +func Measure(path string) (string, error) { + var verityDigest string + f, err := os.Open(path) + if err != nil { + return verityDigest, fmt.Errorf("Error opening file: %s\n", err.Error()) + } + + var d *fsverityDigest = &fsverityDigest{digestSize: maxDigestSize} + _, _, errno := unix.Syscall(syscall.SYS_IOCTL, f.Fd(), uintptr(unix.FS_IOC_MEASURE_VERITY), uintptr(unsafe.Pointer(d))) + if errno != 0 { + return verityDigest, fmt.Errorf("Measure fsverity failed: %d\n", errno) + } + + var i uint16 + for i = 0; i < (*d).digestSize; i++ { + verityDigest = fmt.Sprintf("%s%x", verityDigest, (*d).digest[i]) + } + + return verityDigest, nil +} diff --git a/pkg/fsverity/fsverity_other.go b/pkg/fsverity/fsverity_other.go new file mode 100644 index 000000000000..eafd144232f8 --- /dev/null +++ b/pkg/fsverity/fsverity_other.go @@ -0,0 +1,7 @@ +//go:build !linux + +package fsverity + +func IsSupported(rootPath string) bool { + return false +}