diff --git a/contrib/add-verity-annotations b/contrib/add-verity-annotations new file mode 100755 index 00000000000..bbc5ddc8a5a --- /dev/null +++ b/contrib/add-verity-annotations @@ -0,0 +1,170 @@ +#!/bin/bash +set -euo pipefail + +usage() { + echo "Usage: $0 [--root STORAGE_ROOT] [--key KEY_PREFIX] SOURCE_IMAGE DEST_IMAGE" + echo "" + echo "Reads composefs blob fs-verity digests from container storage for each" + echo "layer of SOURCE_IMAGE, creates a new OCI image with" + echo "io.containers.composefs.digest annotations on each layer descriptor," + echo "signs it with cosign, and pushes the signed image to DEST_IMAGE using" + echo "skopeo copy." + echo "" + echo "SOURCE_IMAGE is a local podman image name/id." + echo "DEST_IMAGE is a remote image reference (e.g. docker://registry/repo:tag)." + echo "" + echo "Options:" + echo " --root STORAGE_ROOT Use a custom storage root" + echo " --key KEY_PREFIX Path prefix for cosign key pair (default: cosign)" + echo " Uses KEY_PREFIX.key and KEY_PREFIX.pub." + echo " If they don't exist, generates a new key pair." + exit 1 +} + +STORAGE_ROOT="" +PODMAN_ARGS=() +KEY_PREFIX="cosign" + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) + STORAGE_ROOT="$2" + shift 2 + ;; + --key) + KEY_PREFIX="$2" + shift 2 + ;; + -h|--help) + usage + ;; + *) + break + ;; + esac +done + +if [[ $# -ne 2 ]]; then + usage +fi + +SOURCE_IMAGE="$1" +DEST_IMAGE="$2" + +if [[ -n "$STORAGE_ROOT" ]]; then + PODMAN_ARGS+=(--root "$STORAGE_ROOT") +fi + +if [[ -z "$STORAGE_ROOT" ]]; then + STORAGE_ROOT=$(podman info --format '{{.Store.GraphRoot}}') +fi + +# Generate cosign key pair if it doesn't exist +if [[ ! -f "${KEY_PREFIX}.key" ]]; then + echo "Generating cosign key pair at ${KEY_PREFIX}.key / ${KEY_PREFIX}.pub" + COSIGN_PASSWORD="" cosign generate-key-pair --output-key-prefix "$KEY_PREFIX" +fi + +LAYERS_JSON="$STORAGE_ROOT/overlay-layers/layers.json" +if [[ ! -f "$LAYERS_JSON" ]]; then + echo "ERROR: layers.json not found at $LAYERS_JSON" >&2 + exit 1 +fi + +# Get image ID +IMAGE_ID=$(podman "${PODMAN_ARGS[@]}" image inspect "$SOURCE_IMAGE" --format '{{.Id}}') +if [[ -z "$IMAGE_ID" ]]; then + echo "ERROR: could not find image $SOURCE_IMAGE" >&2 + exit 1 +fi + +# Get diff IDs for the image layers (bottom-to-top order) +mapfile -t DIFF_IDS < <(podman "${PODMAN_ARGS[@]}" image inspect "$SOURCE_IMAGE" \ + --format '{{range .RootFS.Layers}}{{.}}{{"\n"}}{{end}}' | grep -v '^$') + +echo "Image has ${#DIFF_IDS[@]} layers" + +# For each diff ID, find the storage layer ID and measure its composefs blob +VERITY_DIGESTS=() +for diff_id in "${DIFF_IDS[@]}"; do + layer_id=$(jq -r --arg did "$diff_id" '.[] | select(."diff-digest" == $did) | .id' "$LAYERS_JSON") + if [[ -z "$layer_id" ]]; then + echo "ERROR: no storage layer for diff-digest $diff_id" >&2 + exit 1 + fi + + blob_path="$STORAGE_ROOT/overlay/$layer_id/composefs-data/composefs.blob" + if [[ ! -f "$blob_path" ]]; then + echo "ERROR: no composefs blob at $blob_path" >&2 + exit 1 + fi + + verity=$(fsverity measure "$blob_path" | awk '{print $1}') + echo " layer $layer_id -> $verity" + VERITY_DIGESTS+=("$verity") +done + +# Create temporary OCI layout directories +TMPDIR=$(mktemp -d) +trap 'rm -rf "$TMPDIR"' EXIT + +OCI_DIR="$TMPDIR/oci" +ZST_DIR="$TMPDIR/zst" + +echo "Exporting image to OCI layout..." +podman "${PODMAN_ARGS[@]}" save --format oci-dir -o "$OCI_DIR" "$SOURCE_IMAGE" + +# Convert to zstd:chunked first, before adding annotations. +# Recompression changes layer digests and rewrites the manifest, +# which would discard any annotations we added. +echo "Converting to zstd:chunked..." +skopeo copy \ + --dest-compress-format zstd:chunked \ + --dest-force-compress-format \ + "oci:${OCI_DIR}" \ + "oci:${ZST_DIR}" + +# Find the manifest in the zstd:chunked OCI layout +MANIFEST_DIGEST=$(jq -r '.manifests[0].digest' "$ZST_DIR/index.json") +MANIFEST_PATH="$ZST_DIR/blobs/${MANIFEST_DIGEST/://}" + +echo "Adding verity annotations to manifest..." + +# Build jq filter to add annotation to each layer +JQ_FILTER='.' +for i in "${!VERITY_DIGESTS[@]}"; do + digest="${VERITY_DIGESTS[$i]}" + JQ_FILTER="$JQ_FILTER | .layers[$i].annotations.\"io.containers.composefs.digest\" = \"$digest\"" +done + +jq "$JQ_FILTER" "$MANIFEST_PATH" > "$TMPDIR/manifest.json" + +echo "Layer annotations:" +jq '.layers[].annotations' "$TMPDIR/manifest.json" + +# Replace the manifest blob and update the index with the new digest +NEW_MANIFEST_DIGEST="sha256:$(sha256sum "$TMPDIR/manifest.json" | awk '{print $1}')" +NEW_MANIFEST_SIZE=$(stat -c%s "$TMPDIR/manifest.json") +NEW_BLOB_PATH="$ZST_DIR/blobs/${NEW_MANIFEST_DIGEST/://}" + +cp "$TMPDIR/manifest.json" "$NEW_BLOB_PATH" + +# Remove old manifest blob if digest changed +if [[ "$MANIFEST_DIGEST" != "$NEW_MANIFEST_DIGEST" ]]; then + rm -f "$MANIFEST_PATH" +fi + +# Update index.json with new digest and size +jq --arg digest "$NEW_MANIFEST_DIGEST" --argjson size "$NEW_MANIFEST_SIZE" \ + '.manifests[0].digest = $digest | .manifests[0].size = $size' \ + "$ZST_DIR/index.json" > "$TMPDIR/index.json" +cp "$TMPDIR/index.json" "$ZST_DIR/index.json" + +echo "Copying and signing image to $DEST_IMAGE..." +skopeo copy \ + --sign-by-sigstore-private-key "${KEY_PREFIX}.key" \ + "oci:${ZST_DIR}" \ + "$DEST_IMAGE" + +echo "Done: signed image with verity annotations pushed to $DEST_IMAGE" +echo "Verify with: cosign verify --key ${KEY_PREFIX}.pub --insecure-ignore-tlog $DEST_IMAGE" diff --git a/go.mod b/go.mod index 960d2e6a7b9..0a85aef214b 100644 --- a/go.mod +++ b/go.mod @@ -188,3 +188,9 @@ require ( gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect tags.cncf.io/container-device-interface/specs-go v1.1.0 // indirect ) + +replace go.podman.io/storage => github.com/alexlarsson/container-libs/storage v0.0.0-20260506093752-d9cb3d1bd3b1 + +replace go.podman.io/common => github.com/alexlarsson/container-libs/common v0.0.0-20260506093752-d9cb3d1bd3b1 + +replace go.podman.io/image/v5 => github.com/alexlarsson/container-libs/image/v5 v5.0.0-20260506093752-d9cb3d1bd3b1 diff --git a/go.sum b/go.sum index 4b4c952fd1d..475e25ada38 100644 --- a/go.sum +++ b/go.sum @@ -20,6 +20,12 @@ github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpH github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo= github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6 h1:5L8Mj9Co9sJVgW3TpYk2gxGJnDjsYuboNTcRmbtGKGs= github.com/aead/serpent v0.0.0-20160714141033-fba169763ea6/go.mod h1:3HgLJ9d18kXMLQlJvIY3+FszZYMxCz8WfE2MQ7hDY0w= +github.com/alexlarsson/container-libs/common v0.0.0-20260506093752-d9cb3d1bd3b1 h1:GHdyo6frZNSumF0WoLcSbJrh9JeSB6nhbECFoWwXVZ0= +github.com/alexlarsson/container-libs/common v0.0.0-20260506093752-d9cb3d1bd3b1/go.mod h1:TYI+ocF4gfL8QCBo5GqOSUAOA3QnVgkjjg/nQZRG3o0= +github.com/alexlarsson/container-libs/image/v5 v5.0.0-20260506093752-d9cb3d1bd3b1 h1:Nku21NV8JcOQghhhcwvVWI9E2+Hzj0NA8gtnr5a3ZuI= +github.com/alexlarsson/container-libs/image/v5 v5.0.0-20260506093752-d9cb3d1bd3b1/go.mod h1:D+09OPzsrFuzeKqsJEaaxtItkSd12+eZyOdFyuJF8TY= +github.com/alexlarsson/container-libs/storage v0.0.0-20260506093752-d9cb3d1bd3b1 h1:306lzzVN++E3qeHfb/wzHXSZjNE1yUIxUdX+lDyYGVE= +github.com/alexlarsson/container-libs/storage v0.0.0-20260506093752-d9cb3d1bd3b1/go.mod h1:eZIqDigffFi9NlPezLvUVw/nsUIruaui436E5E4GmXs= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -431,12 +437,6 @@ go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09 go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= go.podman.io/buildah v1.42.1-0.20260501153811-377cf64e213b h1:i8ntFzITajbJA3ojnA0ZdpbC+I+ccweZvZaGIhQb4i8= go.podman.io/buildah v1.42.1-0.20260501153811-377cf64e213b/go.mod h1:hPvgsjBU09C+15fKoIZJvKvNaxR+c0QvMg/n4NgBS7A= -go.podman.io/common v0.67.2-0.20260504145149-b5d50461d3b9 h1:rhSZjo2liJOlaa3SzPLvDTD93MDi4bl6E3RKTME9Hrc= -go.podman.io/common v0.67.2-0.20260504145149-b5d50461d3b9/go.mod h1:TYI+ocF4gfL8QCBo5GqOSUAOA3QnVgkjjg/nQZRG3o0= -go.podman.io/image/v5 v5.39.3-0.20260504145149-b5d50461d3b9 h1:xAeC/aqHQ01RWHq60B1FETR65XW2kfIaOBEmYNJFunc= -go.podman.io/image/v5 v5.39.3-0.20260504145149-b5d50461d3b9/go.mod h1:D+09OPzsrFuzeKqsJEaaxtItkSd12+eZyOdFyuJF8TY= -go.podman.io/storage v1.62.1-0.20260504145149-b5d50461d3b9 h1:1rviLyzh9boijwxX4UK6U6XUmE1Qyl21XPUSHNKTh0s= -go.podman.io/storage v1.62.1-0.20260504145149-b5d50461d3b9/go.mod h1:eZIqDigffFi9NlPezLvUVw/nsUIruaui436E5E4GmXs= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/libpod/container_config.go b/libpod/container_config.go index be78734cbe4..4d410f2a382 100644 --- a/libpod/container_config.go +++ b/libpod/container_config.go @@ -177,6 +177,14 @@ type ContainerRootFSConfig struct { // Volatile specifies whether the container storage can be optimized // at the cost of not syncing all the dirty files in memory. Volatile bool `json:"volatile,omitempty"` + // VerityEnforce requires composefs blob layers to have fs-verity + // digests matching those from the OCI manifest. + VerityEnforce bool `json:"verityEnforce,omitempty"` + // VerityDigests are per-layer lists of allowed fs-verity digests from + // the OCI manifest's io.containers.composefs.digest annotations. + // Each element is the set of accepted digests for that layer, indexed + // in manifest order (bottom-to-top). + VerityDigests [][]string `json:"verityDigests,omitempty"` // Passwd allows to user to override podman's passwd/group file setup Passwd *bool `json:"passwd,omitempty"` // ChrootDirs is an additional set of directories that need to be diff --git a/libpod/container_internal.go b/libpod/container_internal.go index 4799f5ed4d8..f6b84b60f47 100644 --- a/libpod/container_internal.go +++ b/libpod/container_internal.go @@ -500,6 +500,14 @@ func (c *Container) setupStorage(ctx context.Context) error { options.Volatile = c.config.Volatile + if c.config.VerityEnforce && len(c.config.VerityDigests) > 0 { + if options.Flags == nil { + options.Flags = make(map[string]any) + } + options.Flags["VerityDigests"] = c.config.VerityDigests + options.MountOpts = append(options.MountOpts, "verity=require") + } + c.setupStorageMapping(&options.IDMappingOptions, &c.config.IDMappings) // Unless the user has specified a name, use a randomly generated one. diff --git a/libpod/options.go b/libpod/options.go index 0224b39c4db..fd8a3160670 100644 --- a/libpod/options.go +++ b/libpod/options.go @@ -2206,6 +2206,21 @@ func WithVolatile() CtrCreateOption { } } +// WithVerityEnforce enables fs-verity digest enforcement for composefs +// blob layers using the given per-layer digests from the OCI manifest. +func WithVerityEnforce(digests [][]string) CtrCreateOption { + return func(ctr *Container) error { + if ctr.valid { + return define.ErrCtrFinalized + } + + ctr.config.VerityEnforce = true + ctr.config.VerityDigests = digests + + return nil + } +} + // WithChrootDirs is an additional set of directories that need to be // treated as root directories. Standard bind mounts will be mounted // into paths relative to these directories. diff --git a/pkg/specgen/generate/container_create.go b/pkg/specgen/generate/container_create.go index 3637b18ae08..3e1318ce29a 100644 --- a/pkg/specgen/generate/container_create.go +++ b/pkg/specgen/generate/container_create.go @@ -17,6 +17,10 @@ import ( "github.com/sirupsen/logrus" "go.podman.io/common/libimage" "go.podman.io/common/libnetwork/pasta" + dockerTransport "go.podman.io/image/v5/docker" + "go.podman.io/image/v5/docker/reference" + "go.podman.io/image/v5/image" + "go.podman.io/image/v5/signature" "go.podman.io/podman/v6/libpod" "go.podman.io/podman/v6/libpod/define" "go.podman.io/podman/v6/pkg/namespaces" @@ -188,6 +192,21 @@ func MakeContainer(ctx context.Context, rt *libpod.Runtime, s *specgen.SpecGener } options = append(options, libpod.WithRootFSFromImage(newImage.ID(), resolvedImageName, s.RawImageName)) + + if s.SignaturePolicy != "" { + requireSigned := s.SignaturePolicy == "require" + if err := validateManifestSignature(ctx, rt, newImage, requireSigned); err != nil { + return nil, nil, nil, err + } + } + + if s.VerityEnforce != nil && *s.VerityEnforce { + digests, err := extractVerityDigests(imageData) + if err != nil { + return nil, nil, nil, err + } + options = append(options, libpod.WithVerityEnforce(digests)) + } } _, err = rt.LookupPod(s.Hostname) @@ -760,3 +779,82 @@ func Inherit(infra *libpod.Container, s *specgen.SpecGenerator, rt *libpod.Runti func applyInfraInherit(compatibleOptions *libpod.InfraInherit, s *specgen.SpecGenerator) error { return copier.CopyWithOption(s, compatibleOptions, copier.Option{IgnoreEmpty: true}) } + +const verityDigestAnnotation = "io.containers.composefs.digest" + +func extractVerityDigests(imageData *libimage.ImageData) ([][]string, error) { + if len(imageData.LayersData) == 0 { + return nil, fmt.Errorf("verity enforcement: image has no layer data") + } + digests := make([][]string, len(imageData.LayersData)) + for i, layer := range imageData.LayersData { + val, ok := layer.Annotations[verityDigestAnnotation] + if !ok || val == "" { + return nil, fmt.Errorf("verity enforcement: layer %d missing %s annotation", i, verityDigestAnnotation) + } + parts := strings.Split(val, ",") + allowed := make([]string, 0, len(parts)) + for _, p := range parts { + d := strings.TrimSpace(p) + if d != "" { + allowed = append(allowed, d) + } + } + if len(allowed) == 0 { + return nil, fmt.Errorf("verity enforcement: layer %d has empty %s annotation", i, verityDigestAnnotation) + } + digests[i] = allowed + } + return digests, nil +} + +func validateManifestSignature(ctx context.Context, rt *libpod.Runtime, img *libimage.Image, requireSigned bool) error { + names := img.Names() + if len(names) == 0 { + return fmt.Errorf("manifest signature verification failed: image has no names") + } + + named, err := reference.ParseNormalizedNamed(names[0]) + if err != nil { + return fmt.Errorf("parsing image name %q: %w", names[0], err) + } + dockerRef, err := dockerTransport.NewReference(named) + if err != nil { + return fmt.Errorf("creating docker reference for %q: %w", names[0], err) + } + + policy, err := signature.DefaultPolicy(rt.SystemContext()) + if err != nil { + return fmt.Errorf("loading signature policy: %w", err) + } + pc, err := signature.NewPolicyContext(policy) + if err != nil { + return fmt.Errorf("creating policy context: %w", err) + } + defer pc.Destroy() + + if requireSigned { + pc.RequireSignatureVerification(true) + } + + src, err := img.ImageSource(ctx) + if err != nil { + return fmt.Errorf("getting image source: %w", err) + } + + // This will access the cached manifest from ImageSource that was also + // used in image.Inspect(), which means we can trust the parsed ImageData + // from it with no risk for TOCTOU races. + // We use UnparsedInstanceWithReference to override Reference() with the + // docker transport reference so that policy.json lookup matches "docker" + // transport entries rather than "containers-storage". + unparsed := image.UnparsedInstanceWithReference( + image.UnparsedInstance(src, nil), + dockerRef, + ) + allowed, err := pc.IsRunningImageAllowed(ctx, unparsed) + if !allowed { + return fmt.Errorf("manifest signature verification failed: %w", err) + } + return nil +} diff --git a/pkg/specgen/specgen.go b/pkg/specgen/specgen.go index 539445739b9..6224140d074 100644 --- a/pkg/specgen/specgen.go +++ b/pkg/specgen/specgen.go @@ -445,6 +445,12 @@ type ContainerSecurityConfig struct { // Optional. LabelNested *bool `json:"label_nested,omitempty"` + // VerityEnforce requires composefs blob layers to have fs-verity + // digests matching those declared in the OCI image manifest. + VerityEnforce *bool `json:"verity_enforce,omitempty"` + // SignaturePolicy controls manifest signature verification. + // "check" validates if signatures exist, "require" fails without a valid signature. + SignaturePolicy string `json:"signature_policy,omitempty"` // Umask is the umask the init process of the container will be run with. Umask string `json:"umask,omitempty"` // ProcOpts are the options used for the proc mount. diff --git a/pkg/specgenutil/specgen.go b/pkg/specgenutil/specgen.go index 7cc2fe41f4b..fbd7f35d404 100644 --- a/pkg/specgenutil/specgen.go +++ b/pkg/specgenutil/specgen.go @@ -766,6 +766,19 @@ func FillOutSpecGen(s *specgen.SpecGenerator, c *entities.ContainerCreateOptions } } s.ContainerSecurityConfig.NoNewPrivileges = &noNewPrivileges + case "signature": + switch val { + case "check", "require": + s.ContainerSecurityConfig.SignaturePolicy = val + default: + return fmt.Errorf("invalid --security-opt signature value %q, must be \"check\" or \"require\"", val) + } + case "verity": + if val != "enforce" { + return fmt.Errorf("invalid --security-opt verity value %q, must be \"enforce\"", val) + } + localTrue := true + s.ContainerSecurityConfig.VerityEnforce = &localTrue default: return fmt.Errorf("invalid --security-opt 2: %q", opt) } diff --git a/vendor/go.podman.io/common/libimage/image.go b/vendor/go.podman.io/common/libimage/image.go index ea102f81ca7..fd11dbef259 100644 --- a/vendor/go.podman.io/common/libimage/image.go +++ b/vendor/go.podman.io/common/libimage/image.go @@ -966,8 +966,8 @@ func (i *Image) StorageReference() (types.ImageReference, error) { return ref, nil } -// source returns the possibly cached image reference. -func (i *Image) source(ctx context.Context) (types.ImageSource, error) { +// ImageSource returns the possibly cached image source. +func (i *Image) ImageSource(ctx context.Context) (types.ImageSource, error) { if i.cached.imageSource != nil { return i.cached.imageSource, nil } @@ -1002,7 +1002,7 @@ func (i *Image) rawConfigBlob(ctx context.Context) ([]byte, error) { // Manifest returns the raw data and the MIME type of the image's manifest. func (i *Image) Manifest(ctx context.Context) (rawManifest []byte, mimeType string, err error) { - src, err := i.source(ctx) + src, err := i.ImageSource(ctx) if err != nil { return nil, "", err } diff --git a/vendor/go.podman.io/common/libimage/inspect.go b/vendor/go.podman.io/common/libimage/inspect.go index d1e4509472b..764779764ab 100644 --- a/vendor/go.podman.io/common/libimage/inspect.go +++ b/vendor/go.podman.io/common/libimage/inspect.go @@ -39,6 +39,7 @@ type ImageData struct { History []ociv1.History `json:"History"` NamesHistory []string `json:"NamesHistory"` HealthCheck *manifest.Schema2HealthConfig `json:"Healthcheck,omitempty"` + LayersData []types.ImageInspectLayer `json:"LayersData,omitempty"` } // DriverData includes data on the storage driver of the image. @@ -142,6 +143,7 @@ func (i *Image) Inspect(ctx context.Context, options *InspectOptions) (*ImageDat User: ociImage.Config.User, History: ociImage.History, NamesHistory: i.NamesHistory(), + LayersData: info.LayersData, } if options.WithParent { @@ -156,7 +158,7 @@ func (i *Image) Inspect(ctx context.Context, options *InspectOptions) (*ImageDat // Determine the format of the image. How we determine certain data // depends on the format (e.g., Docker v2s2, OCI v1). - src, err := i.source(ctx) + src, err := i.ImageSource(ctx) if err != nil { return nil, err } diff --git a/vendor/go.podman.io/storage/drivers/driver.go b/vendor/go.podman.io/storage/drivers/driver.go index 1ca50b6462d..dfaf59326e4 100644 --- a/vendor/go.podman.io/storage/drivers/driver.go +++ b/vendor/go.podman.io/storage/drivers/driver.go @@ -83,6 +83,11 @@ type CreateOpts struct { *idtools.IDMappings } +// LayerMountOpts contains optional arguments for Driver.Get() methods specific to a layer. +type LayerMountOpts struct { + AllowedFsVerity []string // Require one of these fs-verity for this layer (used for composefs) +} + // MountOpts contains optional arguments for Driver.Get() methods. type MountOpts struct { // Mount label is the MAC Labels to assign to mount point (SELINUX) @@ -98,6 +103,9 @@ type MountOpts struct { // DisableShifting forces the driver to not do any ID shifting at runtime. DisableShifting bool + + // Options specific to each layer being mounted, first is topmost layer + LayerOpts []LayerMountOpts } // ApplyDiffOpts contains optional arguments for ApplyDiff methods. diff --git a/vendor/go.podman.io/storage/drivers/overlay/composefs.go b/vendor/go.podman.io/storage/drivers/overlay/composefs.go index 713aeed3cb7..993f998b6c1 100644 --- a/vendor/go.podman.io/storage/drivers/overlay/composefs.go +++ b/vendor/go.podman.io/storage/drivers/overlay/composefs.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "sync" "sync/atomic" @@ -113,43 +114,36 @@ struct lcfs_erofs_header_s { */ // hasACL returns true if the erofs blob has ACLs enabled -func hasACL(path string) (bool, error) { +func hasACL(f *os.File) (bool, error) { const ( LCFS_EROFS_FLAGS_HAS_ACL = (1 << 0) versionNumberSize = 4 magicNumberSize = 4 flagsSize = 4 ) - - file, err := os.Open(path) - if err != nil { - return false, err - } - defer file.Close() - // do not worry about checking the magic number, if the file is invalid // we will fail to mount it anyway buffer := make([]byte, versionNumberSize+magicNumberSize+flagsSize) - nread, err := file.Read(buffer) + nread, err := f.ReadAt(buffer, 0) if err != nil { return false, err } if nread != len(buffer) { - return false, fmt.Errorf("failed to read flags from %q", path) + return false, fmt.Errorf("failed to read flags from %q", f.Name()) } flags := buffer[versionNumberSize+magicNumberSize:] return binary.LittleEndian.Uint32(flags)&LCFS_EROFS_FLAGS_HAS_ACL != 0, nil } -func openBlobFile(blobFile string, hasACL, useLoopDevice bool) (int, error) { +func mountBlobFile(blobFile *os.File, hasACL, useLoopDevice bool) (int, error) { if useLoopDevice { - loop, err := loopback.AttachLoopDeviceRO(blobFile) + loop, err := loopback.AttachLoopDeviceFile(blobFile) if err != nil { return -1, err } defer loop.Close() - blobFile = loop.Name() + blobFile = loop } fsfd, err := unix.Fsopen("erofs", 0) @@ -158,7 +152,8 @@ func openBlobFile(blobFile string, hasACL, useLoopDevice bool) (int, error) { } defer unix.Close(fsfd) - if err := unix.FsconfigSetString(fsfd, "source", blobFile); err != nil { + source := fmt.Sprintf("/proc/self/fd/%d", blobFile.Fd()) + if err := unix.FsconfigSetString(fsfd, "source", source); err != nil { return -1, fmt.Errorf("failed to set source for erofs filesystem: %w", err) } @@ -191,8 +186,22 @@ func openBlobFile(blobFile string, hasACL, useLoopDevice bool) (int, error) { return mfd, nil } -func openComposefsMount(dataDir string) (int, error) { - blobFile := getComposefsBlob(dataDir) +func openComposefsMount(blobPath string, allowedFsVerity []string) (int, error) { + blobFile, err := os.Open(blobPath) + if err != nil { + return -1, err + } + defer blobFile.Close() + + if len(allowedFsVerity) > 0 { + digest, err := fsverity.MeasureVerityPrefixed("composefs blob", int(blobFile.Fd())) + if err != nil { + return -1, fmt.Errorf("measure fs-verity on composefs blob: %w", err) + } + if !slices.Contains(allowedFsVerity, digest) { + return -1, fmt.Errorf("composefs blob %s has fs-verity digest %q, not in allowed list", blobPath, digest) + } + } hasACL, err := hasACL(blobFile) if err != nil { @@ -200,7 +209,7 @@ func openComposefsMount(dataDir string) (int, error) { } if !skipMountViaFile.Load() { - fd, err := openBlobFile(blobFile, hasACL, false) + fd, err := mountBlobFile(blobFile, hasACL, false) if err == nil || !errors.Is(err, unix.ENOTBLK) { return fd, err } @@ -208,11 +217,11 @@ func openComposefsMount(dataDir string) (int, error) { skipMountViaFile.Store(true) } - return openBlobFile(blobFile, hasACL, true) + return mountBlobFile(blobFile, hasACL, true) } -func mountComposefsBlob(dataDir, mountPoint string) error { - mfd, err := openComposefsMount(dataDir) +func mountComposefsBlob(dataDir, mountPoint string, allowedFsVerity []string) error { + mfd, err := openComposefsMount(getComposefsBlob(dataDir), allowedFsVerity) if err != nil { return err } diff --git a/vendor/go.podman.io/storage/drivers/overlay/overlay.go b/vendor/go.podman.io/storage/drivers/overlay/overlay.go index b12366852ae..244c7228f55 100644 --- a/vendor/go.podman.io/storage/drivers/overlay/overlay.go +++ b/vendor/go.podman.io/storage/drivers/overlay/overlay.go @@ -1548,6 +1548,14 @@ func (d *Driver) get(id string, disableShifting bool, options graphdriver.MountO }) } + if slices.Contains(optsList, "verity=require") { + if len(options.LayerOpts) == 0 { + // verity=require doesn't require specifying the erofs verity digests, but if it isn't + // this is likely some kind of configuration issue. + logrus.Debugf("Warning: verity=require specified but no erofs verity digests specified") + } + } + if slices.Contains(optsList, "ro") { readWrite = false } @@ -1604,10 +1612,13 @@ func (d *Driver) get(id string, disableShifting bool, options graphdriver.MountO }() composeFsLayers := []string{} - maybeAddComposefsMount := func(lowerID string, i int, readWrite bool) (string, error) { + maybeAddComposefsMount := func(lowerID string, i int, readWrite bool, allowedFsVerity []string) (string, error) { composefsBlob := d.getComposefsData(lowerID) if err := fileutils.Exists(composefsBlob); err != nil { if errors.Is(err, fs.ErrNotExist) { + if len(allowedFsVerity) > 0 { + return "", fmt.Errorf("composefs blob required for layer %s but not found", lowerID) + } return "", nil } return "", err @@ -1623,7 +1634,7 @@ func (d *Driver) get(id string, disableShifting bool, options graphdriver.MountO return "", err } - if err := mountComposefsBlob(composefsBlob, dest); err != nil { + if err := mountComposefsBlob(composefsBlob, dest, allowedFsVerity); err != nil { return "", err } composefsMounts = append(composefsMounts, dest) @@ -1636,9 +1647,16 @@ func (d *Driver) get(id string, disableShifting bool, options graphdriver.MountO return dest, nil } + getLayerFsVerity := func(i int) []string { + if i < len(options.LayerOpts) { + return options.LayerOpts[i].AllowedFsVerity + } + return nil + } + diffDir := path.Join(dir, "diff") - if dest, err := maybeAddComposefsMount(id, 0, readWrite); err != nil { + if dest, err := maybeAddComposefsMount(id, 0, readWrite, getLayerFsVerity(0)); err != nil { return "", err } else if dest != "" { diffDir = dest @@ -1657,7 +1675,7 @@ func (d *Driver) get(id string, disableShifting bool, options graphdriver.MountO permsKnown = true } - composefsMount, err := maybeAddComposefsMount(lowerID, i+1, readWrite) + composefsMount, err := maybeAddComposefsMount(lowerID, i+1, readWrite, getLayerFsVerity(i+1)) if err != nil { return "", err } @@ -2137,7 +2155,7 @@ func (d *Driver) DiffGetter(id string) (_ graphdriver.FileGetCloser, Err error) // not a composefs layer, ignore it continue } - fd, err := openComposefsMount(composefsData) + fd, err := openComposefsMount(getComposefsBlob(composefsData), nil) if err != nil { return nil, err } diff --git a/vendor/go.podman.io/storage/pkg/chunked/dump/dump.go b/vendor/go.podman.io/storage/pkg/chunked/dump/dump.go index 1d004023b86..11b5a120efe 100644 --- a/vendor/go.podman.io/storage/pkg/chunked/dump/dump.go +++ b/vendor/go.podman.io/storage/pkg/chunked/dump/dump.go @@ -191,7 +191,10 @@ func dumpNode(out io.Writer, added map[string]*minimal.FileMetadata, links map[s if _, err := fmt.Fprint(out, " "); err != nil { return err } - digest := verityDigests[payload] + digest := "" + if entry.Type == minimal.TypeReg { + digest = verityDigests["/"+payload] + } if _, err := fmt.Fprint(out, escapedOptional([]byte(digest), ESCAPE_LONE_DASH)); err != nil { return err } diff --git a/vendor/go.podman.io/storage/pkg/fsverity/fsverity_linux.go b/vendor/go.podman.io/storage/pkg/fsverity/fsverity_linux.go index 5b21c4b7646..0f2f8432444 100644 --- a/vendor/go.podman.io/storage/pkg/fsverity/fsverity_linux.go +++ b/vendor/go.podman.io/storage/pkg/fsverity/fsverity_linux.go @@ -43,3 +43,25 @@ func MeasureVerity(description string, fd int) (string, error) { } return fmt.Sprintf("%x", digest.Buf[:digest.Fsv.Size]), nil } + +// MeasureVerityPrefixed measures and returns the verity digest for the file represented by 'fd', +// prefixed with the hash algorithm name, e.g. "sha256:...". +// The 'description' parameter is a human-readable description of the file. +func MeasureVerityPrefixed(description string, fd int) (string, error) { + var digest verityDigest + digest.Fsv.Size = 64 + _, _, e1 := syscall.Syscall(unix.SYS_IOCTL, uintptr(fd), uintptr(unix.FS_IOC_MEASURE_VERITY), uintptr(unsafe.Pointer(&digest))) + if e1 != 0 { + return "", fmt.Errorf("failed to measure verity for %q: %w", description, e1) + } + var algName string + switch digest.Fsv.Algorithm { + case unix.FS_VERITY_HASH_ALG_SHA256: + algName = "sha256" + case unix.FS_VERITY_HASH_ALG_SHA512: + algName = "sha512" + default: + return "", fmt.Errorf("unknown fs-verity hash algorithm %d for %q", digest.Fsv.Algorithm, description) + } + return fmt.Sprintf("%s:%x", algName, digest.Buf[:digest.Fsv.Size]), nil +} diff --git a/vendor/go.podman.io/storage/pkg/fsverity/fsverity_unsupported.go b/vendor/go.podman.io/storage/pkg/fsverity/fsverity_unsupported.go index 80b9171dba0..394ae62226f 100644 --- a/vendor/go.podman.io/storage/pkg/fsverity/fsverity_unsupported.go +++ b/vendor/go.podman.io/storage/pkg/fsverity/fsverity_unsupported.go @@ -18,3 +18,10 @@ func EnableVerity(description string, fd int) error { func MeasureVerity(description string, fd int) (string, error) { return "", fmt.Errorf("fs-verity is not supported on this platform") } + +// MeasureVerityPrefixed measures and returns the verity digest for the file represented by 'fd', +// prefixed with the hash algorithm name, e.g. "sha256:...". +// The 'description' parameter is a human-readable description of the file. +func MeasureVerityPrefixed(description string, fd int) (string, error) { + return "", fmt.Errorf("fs-verity is not supported on this platform") +} diff --git a/vendor/go.podman.io/storage/pkg/loopback/attach_loopback.go b/vendor/go.podman.io/storage/pkg/loopback/attach_loopback.go index d5f5d6381cc..0fa6cf6bc55 100644 --- a/vendor/go.podman.io/storage/pkg/loopback/attach_loopback.go +++ b/vendor/go.podman.io/storage/pkg/loopback/attach_loopback.go @@ -123,31 +123,30 @@ func openNextAvailableLoopback(sparseName string, sparseFile *os.File) (*os.File // AttachLoopDevice attaches the given sparse file to the next // available loopback device. It returns an opened *os.File. func AttachLoopDevice(sparseName string) (loop *os.File, err error) { - return attachLoopDevice(sparseName, false) + return attachLoopDevice(sparseName, os.O_RDWR) } // AttachLoopDeviceRO attaches the given sparse file opened read-only to // the next available loopback device. It returns an opened *os.File. func AttachLoopDeviceRO(sparseName string) (loop *os.File, err error) { - return attachLoopDevice(sparseName, true) + return attachLoopDevice(sparseName, os.O_RDONLY) } -func attachLoopDevice(sparseName string, readonly bool) (loop *os.File, err error) { - var sparseFile *os.File - +func attachLoopDevice(sparseName string, flag int) (loop *os.File, err error) { // OpenFile adds O_CLOEXEC - if readonly { - sparseFile, err = os.OpenFile(sparseName, os.O_RDONLY, 0o644) - } else { - sparseFile, err = os.OpenFile(sparseName, os.O_RDWR, 0o644) - } + sparseFile, err := os.OpenFile(sparseName, flag, 0o644) if err != nil { logrus.Errorf("Opening sparse file: %v", err) return nil, ErrAttachLoopbackDevice } defer sparseFile.Close() + return AttachLoopDeviceFile(sparseFile) +} - loopFile, err := openNextAvailableLoopback(sparseName, sparseFile) +// AttachLoopDeviceFile attaches an already-opened file to the next +// available loopback device. It returns an opened *os.File. +func AttachLoopDeviceFile(sparseFile *os.File) (loop *os.File, err error) { + loopFile, err := openNextAvailableLoopback(sparseFile.Name(), sparseFile) if err != nil { return nil, err } diff --git a/vendor/go.podman.io/storage/store.go b/vendor/go.podman.io/storage/store.go index 36ffbafe49f..7a1f88f3c0b 100644 --- a/vendor/go.podman.io/storage/store.go +++ b/vendor/go.podman.io/storage/store.go @@ -46,10 +46,11 @@ const ( ) const ( - volatileFlag = "Volatile" - mountLabelFlag = "MountLabel" - processLabelFlag = "ProcessLabel" - mountOptsFlag = "MountOpts" + volatileFlag = "Volatile" + mountLabelFlag = "MountLabel" + processLabelFlag = "ProcessLabel" + mountOptsFlag = "MountOpts" + verityDigestsFlag = "VerityDigests" ) var ( @@ -3061,6 +3062,9 @@ func (s *store) Mount(id, mountLabel string) (string, error) { } } } + if v, found := container.Flags[verityDigestsFlag]; found { + setLayerOptsVerity(&options.LayerOpts, v) + } } // We need to make sure the home mount is present when the Mount is done, which happens by possibly reinitializing the graph driver @@ -3101,6 +3105,58 @@ func (s *store) Mount(id, mountLabel string) (string, error) { return "", ErrLayerUnknown } +func ensureLayerOpts(layerOpts *[]drivers.LayerMountOpts, n int) { + if len(*layerOpts) < n { + grown := make([]drivers.LayerMountOpts, n) + copy(grown, *layerOpts) + *layerOpts = grown + } +} + +func setLayerOptsVerity(layerOpts *[]drivers.LayerMountOpts, v any) { + var perLayer [][]string + switch val := v.(type) { + case [][]string: + perLayer = val + case []any: + for _, item := range val { + allowed := anyToStringSlice(item) + if allowed == nil { + return + } + perLayer = append(perLayer, allowed) + } + default: + return + } + if len(perLayer) == 0 { + return + } + n := len(perLayer) + // +1 for the container's own rw layer at index 0 + ensureLayerOpts(layerOpts, n+1) + for i, allowed := range perLayer { + (*layerOpts)[n-i].AllowedFsVerity = allowed + } +} + +func anyToStringSlice(v any) []string { + switch val := v.(type) { + case []string: + return val + case []any: + result := make([]string, 0, len(val)) + for _, item := range val { + if s, ok := item.(string); ok { + result = append(result, s) + } + } + return result + default: + return nil + } +} + func (s *store) Mounted(id string) (int, error) { if layerID, err := s.ContainerLayerID(id); err == nil { id = layerID diff --git a/vendor/modules.txt b/vendor/modules.txt index 5db15a2e23c..2bef94c84bd 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -735,7 +735,7 @@ go.podman.io/buildah/pkg/sshagent go.podman.io/buildah/pkg/util go.podman.io/buildah/pkg/volumes go.podman.io/buildah/util -# go.podman.io/common v0.67.2-0.20260504145149-b5d50461d3b9 +# go.podman.io/common v0.67.2-0.20260504145149-b5d50461d3b9 => github.com/alexlarsson/container-libs/common v0.0.0-20260506093752-d9cb3d1bd3b1 ## explicit; go 1.25.6 go.podman.io/common/internal go.podman.io/common/libimage @@ -801,7 +801,7 @@ go.podman.io/common/pkg/umask go.podman.io/common/pkg/util go.podman.io/common/pkg/version go.podman.io/common/version -# go.podman.io/image/v5 v5.39.3-0.20260504145149-b5d50461d3b9 +# go.podman.io/image/v5 v5.39.3-0.20260504145149-b5d50461d3b9 => github.com/alexlarsson/container-libs/image/v5 v5.0.0-20260506093752-d9cb3d1bd3b1 ## explicit; go 1.25.6 go.podman.io/image/v5/copy go.podman.io/image/v5/directory @@ -878,7 +878,7 @@ go.podman.io/image/v5/transports go.podman.io/image/v5/transports/alltransports go.podman.io/image/v5/types go.podman.io/image/v5/version -# go.podman.io/storage v1.62.1-0.20260504145149-b5d50461d3b9 +# go.podman.io/storage v1.62.1-0.20260504145149-b5d50461d3b9 => github.com/alexlarsson/container-libs/storage v0.0.0-20260506093752-d9cb3d1bd3b1 ## explicit; go 1.25.0 go.podman.io/storage go.podman.io/storage/drivers @@ -1182,3 +1182,6 @@ tags.cncf.io/container-device-interface/pkg/parser # tags.cncf.io/container-device-interface/specs-go v1.1.0 ## explicit; go 1.19 tags.cncf.io/container-device-interface/specs-go +# go.podman.io/storage => github.com/alexlarsson/container-libs/storage v0.0.0-20260506093752-d9cb3d1bd3b1 +# go.podman.io/common => github.com/alexlarsson/container-libs/common v0.0.0-20260506093752-d9cb3d1bd3b1 +# go.podman.io/image/v5 => github.com/alexlarsson/container-libs/image/v5 v5.0.0-20260506093752-d9cb3d1bd3b1