From 28963e28affb3a6b4c5e34ad9030bfa422f2ba54 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Wed, 23 Sep 2026 15:15:23 +1000 Subject: [PATCH] feat: verify local image imports with --checksum and record AMI source digests (#1102) --- cmd/spinifex/cmd/admin.go | 207 ++++++++++++++++-- cmd/spinifex/cmd/admin_test.go | 183 ++++++++++++++++ docs/COMMANDS.md | 3 +- docs/admin/setting-up-your-cluster/README.md | 2 +- docs/admin/spinifex-admin-cli/README.md | 2 +- docs/install/install-airgapped/README.md | 22 +- docs/security/network-connections/README.md | 4 +- spinifex/admin/images_promote_test.go | 14 +- spinifex/ebsmetadata/metadata.go | 18 ++ spinifex/ebsmetadata/metadata_test.go | 25 +++ .../ec2/image/provider_snapshot_test.go | 32 +++ spinifex/handlers/ec2/image/service_impl.go | 1 + .../handlers/ec2/image/service_impl_test.go | 6 + spinifex/utils/images.go | 105 +++++++-- spinifex/utils/utils_test.go | 164 ++++++++++++-- 15 files changed, 720 insertions(+), 68 deletions(-) diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index 2bcea8b4f..600b46d80 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -200,6 +200,15 @@ takes effect immediately. Prompts for confirmation unless --yes is passed.`, Run: runimagesPromoteCmd, } +var imagesDescribeCmd = &cobra.Command{ + Use: "describe", + Short: "Show an AMI's metadata and source digest", + Long: `Print an AMI's control-plane metadata, including the digest of the +artifact it was imported from and who verified it. The digest describes the +imported source file, not the volume's current contents.`, + Run: runimagesDescribeCmd, +} + var volumesCmd = &cobra.Command{ Use: "volumes", Short: "Inspect block storage volumes", @@ -294,7 +303,7 @@ spx admin images import --name debian-13-x86_64 spx admin images list // Manually import a path -spx admin images import --file /path/to/image --distro debian --version 13 --arch x86_64 +spx admin images import --file /path/to/image --checksum /path/to/SHA512SUMS --distro debian --version 13 --arch x86_64 -> x <- */ @@ -328,6 +337,7 @@ func init() { imagesCmd.AddCommand(imagesListCmd) imagesCmd.AddCommand(imagesRemoveCmd) imagesCmd.AddCommand(imagesPromoteCmd) + imagesCmd.AddCommand(imagesDescribeCmd) adminCmd.AddCommand(volumesCmd) volumesCmd.AddCommand(volumesOrphansCmd) @@ -427,6 +437,7 @@ func init() { imagesImportCmd.Flags().StringSlice("tag", nil, "Tag to apply to the imported AMI as key=value (repeatable; e.g. --tag spinifex:managed-by=elbv2)") imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)") imagesImportCmd.Flags().Bool("skip-verify", false, "Skip catalog-image checksum verification (INSECURE; operator assumes integrity responsibility)") + imagesImportCmd.Flags().String("checksum", "", "Sums file to verify a --file image against (SHA256SUMS, SHA512SUMS, .sha256/.sha512)") imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)") imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)") @@ -436,6 +447,9 @@ func init() { imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)") imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt") _ = imagesPromoteCmd.MarkFlagRequired("image-id") + + imagesDescribeCmd.Flags().String("image-id", "", "AMI ID to describe (required)") + _ = imagesDescribeCmd.MarkFlagRequired("image-id") } const bytesPerGiB = 1024 * 1024 * 1024 @@ -464,6 +478,91 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 { return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB) } +// validateChecksumFlags rejects --checksum combinations that would otherwise be silently ignored. +// An explicitly empty --checksum (e.g. an unset shell variable) must not fall through to an unverified import. +func validateChecksumFlags(checksumPath string, checksumSet bool, localFile string, skipVerify bool) error { + if !checksumSet { + return nil + } + if strings.TrimSpace(checksumPath) == "" { + return errors.New("--checksum requires a sums file path") + } + if localFile == "" { + return errors.New("--checksum requires --file") + } + if skipVerify { + return errors.New("--checksum and --skip-verify are mutually exclusive") + } + return nil +} + +// importSource is the file an import is about to extract, and what it is to be verified against. +type importSource struct { + imageFile, imageName, localFile string + image utils.Images + checksumPath, checksumAlgo, expectedDigest string + skipVerify bool +} + +// resolveDigest verifies or hashes the source before extract and returns the digest to record. +// Upstream digests are of the artifact as supplied. Failures are reported to errOut before +// returning; the file is left in place, and a catalog download recovers with --force. +func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageDigest, error) { + digest := ebsmetadata.ImageDigest{Filename: filepath.Base(src.imageFile)} + switch sourceDigestMode(src.localFile, src.checksumPath, src.skipVerify) { + case ebsmetadata.DigestCatalog: + if src.image.Checksum == "" || src.image.ChecksumType == "" { + fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", src.imageName) + return digest, errors.New("catalog entry has no checksum") + } + actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType) + if err != nil { + printChecksumError(errOut, src.imageFile, src.imageName, src.image, err) + return digest, err + } + fmt.Fprintf(out, "✅ Verified image checksum (%s)\n", src.image.ChecksumType) + digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, src.image.Checksum + digest.Verification = ebsmetadata.DigestCatalog + case ebsmetadata.DigestOperator: + actual, err := utils.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest) + if err != nil { + fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err) + fmt.Fprintf(errOut, " file: %s\n", src.localFile) + fmt.Fprintf(errOut, " checksum: %s\n", src.checksumPath) + return digest, err + } + fmt.Fprintf(out, "✅ Verified image checksum (%s) against %s\n", src.checksumAlgo, src.checksumPath) + digest.Algorithm, digest.Value, digest.Source = src.checksumAlgo, actual, filepath.Base(src.checksumPath) + digest.Verification = ebsmetadata.DigestOperator + default: + if src.localFile != "" { + fmt.Fprintf(errOut, "Importing %s without checksum validation (pass --checksum to verify)\n", src.localFile) + } else { + fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName) + } + actual, err := utils.HashImageFile(src.imageFile, "sha256") + if err != nil { + fmt.Fprintf(errOut, "Could not hash image: %v\n", err) + return digest, err + } + digest.Algorithm, digest.Value = "sha256", actual + digest.Verification = ebsmetadata.DigestUnverified + } + return digest, nil +} + +// sourceDigestMode picks how an import's source digest is obtained. +func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string { + switch { + case localFile != "" && checksumPath != "": + return ebsmetadata.DigestOperator + case localFile != "" || skipVerify: + return ebsmetadata.DigestUnverified + default: + return ebsmetadata.DigestCatalog + } +} + func runimagesImportCmd(cmd *cobra.Command, args []string) { var image utils.Images @@ -503,6 +602,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } + checksumPath, _ := cmd.Flags().GetString("checksum") + if err := validateChecksumFlags(checksumPath, cmd.Flags().Changed("checksum"), localFile, skipVerify); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + var checksumAlgo, expectedDigest string + if checksumPath != "" { + checksumAlgo, expectedDigest, err = utils.ReadExpectedDigest(checksumPath, filepath.Base(localFile)) + if err != nil { + fmt.Fprintf(os.Stderr, "Could not read checksum from %s: %v\n", checksumPath, err) + os.Exit(1) + } + } + if imageName != "" { var exists bool image, exists = utils.AvailableImages[imageName] @@ -589,8 +702,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - // Next, if the file is selected to download, fetch it, extract disk image, and save to path - if imageName != "" && localFile == "" { + // Next, if the file is selected to download, fetch it and save to path + if localFile == "" { // Download the file to the image path filename := path.Base(image.URL) imageFile = fmt.Sprintf("%s/%s", imagePath, filename) @@ -606,24 +719,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } } + } - // Verify before extract: the catalog digest is of the artifact as it - // sits on the mirror (.tar.xz/.img/.raw). Also catches a poisoned - // cache on the re-run path — failure leaves the file on disk so the - // operator can inspect; recover with --force. - if skipVerify { - fmt.Fprintf(os.Stderr, "⚠️ --skip-verify set: checksum verification skipped for %s\n", imageName) - } else { - if image.Checksum == "" || image.ChecksumType == "" { - fmt.Fprintf(os.Stderr, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", imageName) - os.Exit(1) - } - if err := utils.VerifyImageChecksum(imageFile, image.Checksum, image.ChecksumType); err != nil { - printChecksumError(os.Stderr, imageFile, imageName, image, err) - os.Exit(1) - } - fmt.Printf("✅ Verified image checksum (%s)\n", image.ChecksumType) - } + sourceDigest, err := importSource{ + imageFile: imageFile, + imageName: imageName, + localFile: localFile, + image: image, + checksumPath: checksumPath, + checksumAlgo: checksumAlgo, + expectedDigest: expectedDigest, + skipVerify: skipVerify, + }.resolveDigest(os.Stdout, os.Stderr) + if err != nil { + os.Exit(1) } // Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image @@ -669,6 +778,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { BootMode: image.BootMode, Distro: image.Distro, DistroFamily: utils.DistroFamily(image.Distro), + SourceDigest: &sourceDigest, } // Copy catalog-provided tags (e.g. spinifex:managed-by for system AMIs) @@ -739,6 +849,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { } fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId) + fmt.Printf("✅ Source digest: %s:%s (%s)\n", sourceDigest.Algorithm, sourceDigest.Value, sourceDigest.Verification) } // registerImportedAMISnapshot writes the EC2 control plane's snapshot document @@ -1001,6 +1112,62 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) { fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias) } +func runimagesDescribeCmd(cmd *cobra.Command, args []string) { + imageID, _ := cmd.Flags().GetString("image-id") + + cfgFile, _ := cmd.Flags().GetString("config") + if cfgFile == "" { + cfgFile = DefaultConfigFile() + } + + appConfig, err := config.LoadConfig(cfgFile) + if err != nil { + fmt.Fprintln(os.Stderr, "Error loading config file:", err) + os.Exit(1) + } + + node := appConfig.Nodes[appConfig.Node] + store := objectstore.NewS3ObjectStoreFromConfig( + node.Predastore.Host, + node.Predastore.Region, + node.Predastore.AccessKey, + node.Predastore.SecretKey, + ) + + meta, err := admin.GetAMIMetadata(store, node.Predastore.Bucket, imageID) + if err != nil { + fmt.Fprintln(os.Stderr, "Failed to describe AMI:", err) + os.Exit(1) + } + + printAMIDescription(os.Stdout, meta) +} + +// printAMIDescription writes an AMI's metadata and source digest for an operator. +func printAMIDescription(w io.Writer, meta ebsmetadata.AMI) { + fmt.Fprintf(w, "Image ID: %s\n", meta.ImageID) + fmt.Fprintf(w, "Name: %s\n", meta.Name) + fmt.Fprintf(w, "Owner: %s\n", meta.ImageOwnerAlias) + if !meta.CreationDate.IsZero() { + fmt.Fprintf(w, "Created: %s\n", meta.CreationDate.UTC().Format("2006-01-02T15:04:05Z")) + } + fmt.Fprintf(w, "Boot mode: %s\n", meta.BootMode) + fmt.Fprintf(w, "Architecture: %s\n", meta.Architecture) + + d := meta.SourceDigest + if d == nil { + fmt.Fprintln(w, "Source digest: not recorded (imported before digest recording)") + return + } + fmt.Fprintf(w, "Source digest: %s:%s\n", d.Algorithm, d.Value) + if d.Source != "" { + fmt.Fprintf(w, "Verification: %s (%s)\n", d.Verification, d.Source) + } else { + fmt.Fprintf(w, "Verification: %s\n", d.Verification) + } + fmt.Fprintf(w, "Source file: %s\n", d.Filename) +} + // List remote images available. func runimagesListCmd(cmd *cobra.Command, args []string) { //fmt.Println(availableImages) diff --git a/cmd/spinifex/cmd/admin_test.go b/cmd/spinifex/cmd/admin_test.go index 0b3f73e22..97f3b9d40 100644 --- a/cmd/spinifex/cmd/admin_test.go +++ b/cmd/spinifex/cmd/admin_test.go @@ -2,6 +2,8 @@ package cmd import ( "bytes" + "crypto/sha256" + "encoding/hex" "errors" "fmt" "net/netip" @@ -9,11 +11,13 @@ import ( "path/filepath" "strings" "testing" + "time" "github.com/mulgadc/northstar/pkg/backend" nsconfig "github.com/mulgadc/northstar/pkg/config" "github.com/mulgadc/spinifex/spinifex/admin" "github.com/mulgadc/spinifex/spinifex/config" + "github.com/mulgadc/spinifex/spinifex/ebsmetadata" "github.com/mulgadc/spinifex/spinifex/formation" handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns" handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam" @@ -1288,6 +1292,185 @@ func TestAMIVolumeSizeGiB(t *testing.T) { } } +func TestValidateChecksumFlags(t *testing.T) { + tests := []struct { + name string + checksum string + unset bool + file string + skipVerify bool + wantErr string + }{ + {name: "no checksum", unset: true, file: "img.raw"}, + {name: "checksum with file", checksum: "SHA256SUMS", file: "img.raw"}, + {name: "empty checksum value", checksum: "", file: "img.raw", wantErr: "--checksum requires a sums file path"}, + {name: "blank checksum value", checksum: " ", file: "img.raw", wantErr: "--checksum requires a sums file path"}, + {name: "checksum without file", checksum: "SHA256SUMS", wantErr: "--checksum requires --file"}, + {name: "checksum with skip-verify", checksum: "SHA256SUMS", file: "img.raw", skipVerify: true, + wantErr: "mutually exclusive"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateChecksumFlags(tt.checksum, !tt.unset, tt.file, tt.skipVerify) + if tt.wantErr == "" { + assert.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + }) + } +} + +func TestSourceDigestMode(t *testing.T) { + tests := []struct { + name string + localFile string + checksum string + skipVerify bool + want string + }{ + {name: "file with checksum", localFile: "img.raw", checksum: "SHA256SUMS", want: ebsmetadata.DigestOperator}, + {name: "catalog download verified", want: ebsmetadata.DigestCatalog}, + {name: "catalog download skip-verify", skipVerify: true, want: ebsmetadata.DigestUnverified}, + {name: "file without checksum", localFile: "img.raw", want: ebsmetadata.DigestUnverified}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, sourceDigestMode(tt.localFile, tt.checksum, tt.skipVerify)) + }) + } +} + +func TestImportSourceResolveDigest(t *testing.T) { + imgBytes := []byte("import-source-fixture") + sum := sha256.Sum256(imgBytes) + imgHex := hex.EncodeToString(sum[:]) + dir := t.TempDir() + img := filepath.Join(dir, "image.raw") + require.NoError(t, os.WriteFile(img, imgBytes, 0o600)) + sums := filepath.Join(dir, "SHA256SUMS") + + t.Run("operator verified", func(t *testing.T) { + var out, errOut bytes.Buffer + got, err := importSource{ + imageFile: img, localFile: img, + checksumPath: sums, checksumAlgo: "sha256", expectedDigest: imgHex, + }.resolveDigest(&out, &errOut) + require.NoError(t, err) + assert.Equal(t, ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestOperator, + Source: "SHA256SUMS", Filename: "image.raw", + }, got) + assert.Contains(t, out.String(), "Verified image checksum (sha256)") + assert.Empty(t, errOut.String()) + }) + + t.Run("operator mismatch names the file and sums file", func(t *testing.T) { + var out, errOut bytes.Buffer + _, err := importSource{ + imageFile: img, localFile: img, + checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64), + }.resolveDigest(&out, &errOut) + require.ErrorIs(t, err, utils.ErrChecksumMismatch) + assert.Contains(t, errOut.String(), "file: "+img) + assert.Contains(t, errOut.String(), "checksum: "+sums) + assert.Contains(t, errOut.String(), imgHex) + assert.FileExists(t, img, "the operator's file must be left in place") + }) + + t.Run("local file without checksum is recorded unverified", func(t *testing.T) { + var out, errOut bytes.Buffer + got, err := importSource{imageFile: img, localFile: img}.resolveDigest(&out, &errOut) + require.NoError(t, err) + assert.Equal(t, ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestUnverified, Filename: "image.raw", + }, got) + assert.Equal(t, "Importing "+img+" without checksum validation (pass --checksum to verify)\n", + errOut.String()) + }) + + t.Run("catalog skip-verify is recorded unverified", func(t *testing.T) { + var out, errOut bytes.Buffer + got, err := importSource{imageFile: img, imageName: "debian-13-x86_64", skipVerify: true}.resolveDigest(&out, &errOut) + require.NoError(t, err) + assert.Equal(t, ebsmetadata.DigestUnverified, got.Verification) + assert.Equal(t, imgHex, got.Value) + assert.Contains(t, errOut.String(), "--skip-verify set") + }) + + t.Run("catalog entry without checksum is refused", func(t *testing.T) { + var out, errOut bytes.Buffer + _, err := importSource{imageFile: img, imageName: "debian-13-x86_64"}.resolveDigest(&out, &errOut) + require.Error(t, err) + assert.Contains(t, errOut.String(), "missing Checksum/ChecksumType") + }) + + t.Run("unreadable image fails the unverified hash", func(t *testing.T) { + var out, errOut bytes.Buffer + missing := filepath.Join(dir, "missing.raw") + _, err := importSource{imageFile: missing, localFile: missing}.resolveDigest(&out, &errOut) + require.Error(t, err) + assert.Contains(t, errOut.String(), "Could not hash image") + }) +} + +func TestImagesDescribeCmd_FlagSchema(t *testing.T) { + imageIDFlag := imagesDescribeCmd.Flags().Lookup("image-id") + require.NotNil(t, imageIDFlag, "--image-id must be defined") + assert.Equal(t, []string{"true"}, imageIDFlag.Annotations[cobraRequiredAnnotation], + "--image-id must be marked required") +} + +func TestPrintAMIDescription(t *testing.T) { + base := ebsmetadata.AMI{ + ImageID: "ami-1", + Name: "ami-debian-13-x86_64", + ImageOwnerAlias: "system", + CreationDate: time.Date(2026, 9, 23, 1, 2, 3, 0, time.UTC), + BootMode: "uefi", + Architecture: "x86_64", + } + + t.Run("digest recorded", func(t *testing.T) { + meta := base + meta.SourceDigest = &ebsmetadata.ImageDigest{ + Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestOperator, + Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz", + } + var buf bytes.Buffer + printAMIDescription(&buf, meta) + out := buf.String() + assert.Contains(t, out, "Image ID: ami-1") + assert.Contains(t, out, "Created: 2026-09-23T01:02:03Z") + assert.Contains(t, out, "Boot mode: uefi") + assert.Contains(t, out, "Source digest: sha512:abc123") + assert.Contains(t, out, "Verification: operator (SHA512SUMS)") + assert.Contains(t, out, "Source file: debian-13-generic-amd64.tar.xz") + }) + + t.Run("unverified digest has no source", func(t *testing.T) { + meta := base + meta.SourceDigest = &ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: "def456", Verification: ebsmetadata.DigestUnverified, + Filename: "image.raw", + } + var buf bytes.Buffer + printAMIDescription(&buf, meta) + assert.Contains(t, buf.String(), "Verification: unverified\n") + }) + + t.Run("digest not recorded", func(t *testing.T) { + var buf bytes.Buffer + printAMIDescription(&buf, base) + out := buf.String() + assert.Contains(t, out, "Source digest: not recorded (imported before digest recording)") + assert.NotContains(t, out, "Verification:") + }) +} + func TestCheckJoinPreconditions(t *testing.T) { withFakeProc(t, nil) noStore := filepath.Join(t.TempDir(), "nats", "jetstream") diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index b7f58d9f9..f16e61d42 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -166,8 +166,9 @@ Operational commands for inspecting cluster state. These fan out NATS requests t | Command | Flags | Description | |---------|-------|-------------| -| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. | +| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum ` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. | | `spx admin images list` | — | Lists available OS images that can be imported or downloaded | +| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. | | `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. | | `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-/config.json` (the DescribeImages barrier) followed by the rest of `ami-/` and `snap-ami-/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. | diff --git a/docs/admin/setting-up-your-cluster/README.md b/docs/admin/setting-up-your-cluster/README.md index 580312b5c..e2fd9209b 100644 --- a/docs/admin/setting-up-your-cluster/README.md +++ b/docs/admin/setting-up-your-cluster/README.md @@ -73,7 +73,7 @@ spx admin images import --name ubuntu-26.04-x86_64 ### Option B: Import a local image file ```bash -spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi +spx admin images import --file ~/images/ubuntu-26.04-server-cloudimg-amd64.img --checksum ~/images/SHA256SUMS --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi ``` Verify the import and note the AMI ID: diff --git a/docs/admin/spinifex-admin-cli/README.md b/docs/admin/spinifex-admin-cli/README.md index 663ac6c98..862b7bca6 100644 --- a/docs/admin/spinifex-admin-cli/README.md +++ b/docs/admin/spinifex-admin-cli/README.md @@ -109,7 +109,7 @@ spx admin images list spx admin images import --name debian-13-arm64 ``` -Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch. +Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` with `--checksum ` to import operator-supplied media verified against the sums file it was published with, or `--force` to re-download after a checksum mismatch. A `--file` import without `--checksum` proceeds unverified and prints a notice. Every import records the source file's digest on the AMI; `spx admin images describe --image-id ` shows it. ### EKS node image diff --git a/docs/install/install-airgapped/README.md b/docs/install/install-airgapped/README.md index ae15808d4..46b1c17f5 100644 --- a/docs/install/install-airgapped/README.md +++ b/docs/install/install-airgapped/README.md @@ -72,14 +72,19 @@ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \ -o awscliv2.zip ``` -Mirror the cloud image you intend to run as guest VMs: +Mirror the cloud image you intend to run as guest VMs, together with the sums file it was published with. Keep the upstream filename, because the import looks the image up in the sums file by name: ```bash mkdir -p images -curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \ - -o images/debian-13-amd64.qcow2 +cd images +curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" +curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS" +sha512sum -c --ignore-missing SHA512SUMS +cd .. ``` +The sums file only proves the image matches it; a sums file fetched over the same channel as the image can be tampered with alongside it. Where the vendor publishes a signature for the sums file (for example Ubuntu's `SHA256SUMS.gpg`), download it too and check it with `gpg --verify` against the vendor's signing key on this connected machine. + ## Step 3. Assemble Transfer Media ```bash @@ -88,7 +93,7 @@ cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/ cp setup.sh /media/spinifex-deploy/ cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/ cp awscliv2.zip /media/spinifex-deploy/aws/ -cp images/*.qcow2 /media/spinifex-deploy/images/ +cp images/*.qcow2 images/SHA512SUMS /media/spinifex-deploy/images/ ``` ## Step 4. Install on the Air-Gapped Target @@ -139,13 +144,16 @@ sudo systemctl start spinifex.target ## Step 8. Import Cloud Images -Register the pre-staged image with Spinifex: +Register the pre-staged image with Spinifex, verifying it against the sums file carried across with it: ```bash -sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \ +sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64.qcow2 \ + --checksum /mnt/usb/images/SHA512SUMS \ --distro debian --version 13 --arch x86_64 --boot-mode uefi ``` +A digest mismatch exits before anything is written to storage. On success the import prints the verified digest, which `spx admin images describe --image-id ` shows again later. + ## Step 9. Verify ```bash @@ -185,4 +193,4 @@ sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \ ### Image Import Fails -`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors. +`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. A `checksum entry for image filename not found` error means the image was renamed after download; restore the upstream filename so it matches its line in the sums file. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors. diff --git a/docs/security/network-connections/README.md b/docs/security/network-connections/README.md index 8c4b909d2..96c572be7 100644 --- a/docs/security/network-connections/README.md +++ b/docs/security/network-connections/README.md @@ -149,7 +149,7 @@ Spinifex nodes initiate a small, fixed set of outbound connections. **Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan. -**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped). +**Air-gapped deployments.** The image URLs above are the only destinations needed for the standard image catalogue. Mirror them locally with their sums files and use `spx admin images import --file --checksum ` with the pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped). ## 3. Cross-Node (Internal) Connections @@ -226,7 +226,7 @@ Notes that make the difference between this working and locking you out: - **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute. - **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there. -Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file`. +Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file --checksum `. ## 5. Configuration Surface diff --git a/spinifex/admin/images_promote_test.go b/spinifex/admin/images_promote_test.go index 72f9347af..1f81d4fbd 100644 --- a/spinifex/admin/images_promote_test.go +++ b/spinifex/admin/images_promote_test.go @@ -17,7 +17,18 @@ import ( func TestPromoteSystemImage_HappyPath(t *testing.T) { store := objectstore.NewMemoryObjectStore() const id = "ami-user-001" - putAMI(t, store, id, "my-app", testRemoveAccountID, "snap-user-001") + digest := &ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: "abc123", Verification: ebsmetadata.DigestOperator, + Source: "SHA256SUMS", Filename: "my-app.raw", + } + require.NoError(t, ebsmetadata.NewStore(store, testRemoveBucket).PutAMI(t.Context(), ebsmetadata.AMI{ + ImageID: id, + Name: "my-app", + ImageOwnerAlias: testRemoveAccountID, + SnapshotID: "snap-user-001", + VolumeSizeGiB: 8, + SourceDigest: digest, + })) result, err := PromoteSystemImage(store, testRemoveBucket, PromoteImageOpts{ImageID: id}) require.NoError(t, err) @@ -30,6 +41,7 @@ func TestPromoteSystemImage_HappyPath(t *testing.T) { // Other fields must be preserved. assert.Equal(t, "my-app", meta.Name) assert.Equal(t, "snap-user-001", meta.SnapshotID) + assert.Equal(t, digest, meta.SourceDigest) } // A promoted AMI's readers derive the snapshot account from the alias, so the diff --git a/spinifex/ebsmetadata/metadata.go b/spinifex/ebsmetadata/metadata.go index db028ed78..db67efcc5 100644 --- a/spinifex/ebsmetadata/metadata.go +++ b/spinifex/ebsmetadata/metadata.go @@ -108,9 +108,27 @@ type AMI struct { Distro string `json:"distro,omitempty"` DistroFamily string `json:"distro_family,omitempty"` Tags map[string]string `json:"tags,omitempty"` + SourceDigest *ImageDigest `json:"source_digest,omitempty"` State string `json:"state,omitempty"` } +// ImageDigest identifies the artifact an AMI was imported from, not the +// volume's current bytes; Verification says who vouched for it. +type ImageDigest struct { + Algorithm string `json:"algorithm"` + Value string `json:"value"` + Verification string `json:"verification"` + Source string `json:"source,omitempty"` + Filename string `json:"filename"` +} + +// ImageDigest.Verification values. +const ( + DigestOperator = "operator" + DigestCatalog = "catalog" + DigestUnverified = "unverified" +) + // VolumeKey keys a volume document under its owning account, so a listing of // one account's prefix cannot reach another account's document. accountID comes // from the document's own TenantID, never from the caller. diff --git a/spinifex/ebsmetadata/metadata_test.go b/spinifex/ebsmetadata/metadata_test.go index 584639051..d8ac3a8b1 100644 --- a/spinifex/ebsmetadata/metadata_test.go +++ b/spinifex/ebsmetadata/metadata_test.go @@ -50,6 +50,31 @@ func TestVolumeRoundTripsEncryptedAndModification(t *testing.T) { assert.Equal(t, *want.Modification, *got.Modification) } +func TestAMIRoundTripsSourceDigest(t *testing.T) { + want := AMI{ + ImageID: "ami-1", Name: "debian-13", + SourceDigest: &ImageDigest{ + Algorithm: "sha512", Value: "abc123", Verification: DigestOperator, + Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz", + }, + } + data, err := MarshalAMI(want) + require.NoError(t, err) + got, err := UnmarshalAMI(data) + require.NoError(t, err) + require.NotNil(t, got.SourceDigest) + assert.Equal(t, *want.SourceDigest, *got.SourceDigest) +} + +// TestAMIWithoutSourceDigestStillDecodes guards AMIs written before the field +// existed. The version is hard-coded so a SchemaVersion bump fails here. +func TestAMIWithoutSourceDigestStillDecodes(t *testing.T) { + got, err := UnmarshalAMI([]byte(`{"schema_version":2,"image_id":"ami-1"}`)) + require.NoError(t, err) + assert.Equal(t, "ami-1", got.ImageID) + assert.Nil(t, got.SourceDigest) +} + func TestMetadataRejectsUnknownSchemaVersion(t *testing.T) { _, err := UnmarshalAMI([]byte(`{"schema_version":99,"image_id":"ami-1"}`)) require.Error(t, err) diff --git a/spinifex/handlers/ec2/image/provider_snapshot_test.go b/spinifex/handlers/ec2/image/provider_snapshot_test.go index ea382c1f9..0dbe2c174 100644 --- a/spinifex/handlers/ec2/image/provider_snapshot_test.go +++ b/spinifex/handlers/ec2/image/provider_snapshot_test.go @@ -161,3 +161,35 @@ func TestCreateImageFromInstance_Provider_UsesEbsMetadataVolumeSize(t *testing.T require.NoError(t, err) assert.Equal(t, uint64(20), ami.VolumeSizeGiB, "the AMI's volume size must come from the ebsmetadata document") } + +// An image of a running disk is no longer the imported artifact, so it must +// not inherit the source AMI's digest even though it inherits other fields. +func TestCreateImageFromInstance_DoesNotInheritSourceDigest(t *testing.T) { + svc, _, provider := setupProviderSnapshotImageService(t) + doc := seedProviderVolume(t, svc, provider, "vol-digest001", 8) + require.NoError(t, svc.MetadataStore().PutAMI(context.Background(), ebsmetadata.AMI{ + ImageID: "ami-src-digest", + Name: "imported", + ImageOwnerAlias: "system", + BootMode: "uefi", + SourceDigest: &ebsmetadata.ImageDigest{ + Algorithm: "sha256", Value: "abc123", Verification: ebsmetadata.DigestOperator, Filename: "img.raw", + }, + })) + + out, err := svc.CreateImageFromInstance(CreateImageParams{ + Input: &ec2.CreateImageInput{ + InstanceId: aws.String("i-digest001"), + Name: aws.String("from-instance"), + }, + RootVolumeID: doc.VolumeID, + SourceImageID: "ami-src-digest", + IsRunning: false, + }, testAccountID) + require.NoError(t, err) + + ami, err := svc.MetadataStore().GetAMI(context.Background(), aws.StringValue(out.ImageId)) + require.NoError(t, err) + assert.Equal(t, "uefi", ami.BootMode, "the source AMI must have been read") + assert.Nil(t, ami.SourceDigest) +} diff --git a/spinifex/handlers/ec2/image/service_impl.go b/spinifex/handlers/ec2/image/service_impl.go index 17e3e7342..574763b18 100644 --- a/spinifex/handlers/ec2/image/service_impl.go +++ b/spinifex/handlers/ec2/image/service_impl.go @@ -890,6 +890,7 @@ func (s *ImageServiceImpl) CopyImage(ctx context.Context, input *ec2.CopyImageIn CreationDate: time.Now().UTC(), BootMode: srcMeta.BootMode, Tags: tags, + SourceDigest: srcMeta.SourceDigest, // Zero-copy: the new config shares the source's already-durable // snapshot, so the image is complete as soon as this is written. State: "available", diff --git a/spinifex/handlers/ec2/image/service_impl_test.go b/spinifex/handlers/ec2/image/service_impl_test.go index ac2deb38d..29b285f34 100644 --- a/spinifex/handlers/ec2/image/service_impl_test.go +++ b/spinifex/handlers/ec2/image/service_impl_test.go @@ -1769,6 +1769,10 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) { }) // Source AMI with non-default fields that must propagate. + digest := &ebsmetadata.ImageDigest{ + Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestCatalog, + Source: "https://example.test/SHA512SUMS", Filename: "arm.tar.xz", + } putTestAMIConfigWithSnapshot(t, store, "ami-arm001", "arm-source", testAccountID, "snap-arm001", ebsmetadata.AMI{ Architecture: "arm64", PlatformDetails: "Linux/UNIX (arm64)", @@ -1777,6 +1781,7 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) { RootDeviceType: "ebs", Description: "arm source", BootMode: "uefi", + SourceDigest: digest, }) before := time.Now() @@ -1791,6 +1796,7 @@ func TestCopyImage_InheritsSourceFields(t *testing.T) { assert.Equal(t, uint64(32), newMeta.VolumeSizeGiB) assert.Equal(t, "ebs", newMeta.RootDeviceType) assert.Equal(t, "uefi", newMeta.BootMode, "CopyImage must propagate BootMode from source") + assert.Equal(t, digest, newMeta.SourceDigest, "a zero-copy share still holds the source artifact") assert.False(t, newMeta.CreationDate.Before(before), "CreationDate must be refreshed on copy, not inherited") } diff --git a/spinifex/utils/images.go b/spinifex/utils/images.go index 73c761130..994f888f3 100644 --- a/spinifex/utils/images.go +++ b/spinifex/utils/images.go @@ -41,6 +41,7 @@ var ( ErrChecksumNotFound = errors.New("checksum entry for image filename not found") ErrUnsupportedChecksumType = errors.New("unsupported checksum type") ErrChecksumFetchFailed = errors.New("checksum fetch failed") + ErrMalformedChecksum = errors.New("malformed checksum") ) // checksumExtraRootCAs is a test-only hook for httptest TLS servers; nil in production. @@ -50,32 +51,28 @@ var checksumExtraRootCAs *x509.CertPool var checksumFetchTimeout = 30 * time.Second // VerifyImageChecksum fetches the sums file at checksumURL, finds the entry for imagePath's basename, -// hashes the file with checksumType ("sha256" or "sha512"), and compares digests. +// hashes the file with checksumType ("sha256" or "sha512"), compares digests and returns the actual one. // Fails closed: non-HTTPS, non-2xx, oversized response, or digest mismatch all return a wrapped error. -func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error { +func VerifyImageChecksum(imagePath, checksumURL, checksumType string) (string, error) { hasher, err := newHasher(checksumType) if err != nil { - return err + return "", err } expected, err := fetchExpectedDigest(checksumURL, filepath.Base(imagePath)) if err != nil { slog.Error("image checksum fetch failed", "source", checksumURL, "err", err) - return err + return "", err } // Catch algorithm mismatch before ConstantTimeCompare; a length mismatch would look like tampering. if len(expected) != hasher.Size()*2 { - return fmt.Errorf("%w: digest length %d from sums file does not match %s output length %d", + return "", fmt.Errorf("%w: digest length %d from sums file does not match %s output length %d", ErrChecksumFetchFailed, len(expected), checksumType, hasher.Size()*2) } - actual, err := hashImageFile(imagePath, hasher) - if err != nil { - return fmt.Errorf("hash image file: %w", err) - } - - if subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 { + actual, err := VerifyImageDigest(imagePath, checksumType, expected) + if errors.Is(err, ErrChecksumMismatch) { slog.Error("image checksum mismatch", "image", imagePath, "algorithm", checksumType, @@ -83,10 +80,92 @@ func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error { "actual", actual, "source", checksumURL, ) - return fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual) + } + return actual, err +} + +// VerifyImageDigest hashes imagePath with algo and compares it with the expected hex digest. +// The actual digest is returned on mismatch too, so callers can report it. +func VerifyImageDigest(imagePath, algo, expected string) (string, error) { + actual, err := HashImageFile(imagePath, algo) + if err != nil { + return "", err + } + if subtle.ConstantTimeCompare([]byte(expected), []byte(actual)) != 1 { + return actual, fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual) + } + return actual, nil +} + +// HashImageFile returns the lowercase hex digest of imagePath under algo ("sha256" or "sha512"). +func HashImageFile(imagePath, algo string) (string, error) { + hasher, err := newHasher(algo) + if err != nil { + return "", err + } + actual, err := hashImageFile(imagePath, hasher) + if err != nil { + return "", fmt.Errorf("hash image file: %w", err) + } + return actual, nil +} + +// ReadExpectedDigest reads a local sums file and returns the algorithm and lowercase hex digest for imageName. +// The algorithm is inferred from the digest length, never from the file's name. +func ReadExpectedDigest(sumsPath, imageName string) (algo, digest string, err error) { + // Stat before open: opening a FIFO would block until a writer appears. + info, err := os.Stat(sumsPath) + if err != nil { + return "", "", err + } + if !info.Mode().IsRegular() { + return "", "", fmt.Errorf("%w: %s is not a regular file", ErrMalformedChecksum, sumsPath) } - return nil + f, err := os.Open(sumsPath) + if err != nil { + return "", "", err + } + defer f.Close() + + body, err := io.ReadAll(io.LimitReader(f, sumsFileMaxSize+1)) + if err != nil { + return "", "", fmt.Errorf("read sums file: %w", err) + } + if len(body) > sumsFileMaxSize { + return "", "", fmt.Errorf("%w: sums file exceeds %d byte limit", ErrMalformedChecksum, sumsFileMaxSize) + } + + digest, err = parseSumsFile(body, imageName) + if errors.Is(err, ErrChecksumNotFound) && bytes.Contains(body, []byte("/"+imageName)) { + return "", "", fmt.Errorf("%w (the sums file names it with a directory prefix; entries must name the bare filename)", err) + } + if err != nil { + return "", "", err + } + digest = strings.ToLower(digest) + algo, err = algoForDigest(digest) + if err != nil { + return "", "", err + } + return algo, digest, nil +} + +// algoForDigest infers the hash algorithm from a lowercase hex digest's length. +func algoForDigest(digest string) (string, error) { + for _, c := range digest { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return "", fmt.Errorf("%w: digest contains non-hex character %q", ErrMalformedChecksum, c) + } + } + switch len(digest) { + case sha256.Size * 2: + return "sha256", nil + case sha512.Size * 2: + return "sha512", nil + default: + return "", fmt.Errorf("%w: digest length %d", ErrUnsupportedChecksumType, len(digest)) + } } func newHasher(checksumType string) (hash.Hash, error) { diff --git a/spinifex/utils/utils_test.go b/spinifex/utils/utils_test.go index 330fb9086..cd23684ef 100644 --- a/spinifex/utils/utils_test.go +++ b/spinifex/utils/utils_test.go @@ -1342,15 +1342,16 @@ func TestVerifyImageChecksum(t *testing.T) { body := fmt.Sprintf("%s %s\n%s other-file\n", sha512Hex(debianBytes), debianName, sha512Hex([]byte("unrelated"))) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") - assert.NoError(t, err) + got, err := VerifyImageChecksum(img, url, "sha512") + require.NoError(t, err) + assert.Equal(t, sha512Hex(debianBytes), got) }) t.Run("ubuntu sha256 binary-mode asterisk match", func(t *testing.T) { img := writeTempImage(t, ubuntuName, ubuntuBytes) body := fmt.Sprintf("%s *%s\n", sha256Hex(ubuntuBytes), ubuntuName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha256") + _, err := VerifyImageChecksum(img, url, "sha256") assert.NoError(t, err) }) @@ -1358,7 +1359,7 @@ func TestVerifyImageChecksum(t *testing.T) { img := writeTempImage(t, alpineName, alpineBytes) body := fmt.Sprintf("%s %s\n", sha512Hex(alpineBytes), alpineName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") assert.NoError(t, err) }) @@ -1366,7 +1367,7 @@ func TestVerifyImageChecksum(t *testing.T) { img := writeTempImage(t, alpineName, alpineBytes) body := sha512Hex(alpineBytes) + "\n" url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") assert.NoError(t, err) }) @@ -1376,7 +1377,7 @@ func TestVerifyImageChecksum(t *testing.T) { // convention, treated as not-found. body := fmt.Sprintf("%s Debian-12-generic-amd64.tar.xz\n", sha512Hex(debianBytes)) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") assert.ErrorIs(t, err, ErrChecksumNotFound) }) @@ -1385,7 +1386,7 @@ func TestVerifyImageChecksum(t *testing.T) { body := fmt.Sprintf("# Hash file generated by upstream\n# PGP-signed below\n%s %s\n", sha512Hex(debianBytes), debianName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") assert.NoError(t, err) }) @@ -1394,7 +1395,7 @@ func TestVerifyImageChecksum(t *testing.T) { wrong := sha512Hex([]byte("a different blob entirely")) body := fmt.Sprintf("%s %s\n", wrong, debianName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumMismatch) // Both expected and actual digests should be in the rendered error so @@ -1408,14 +1409,14 @@ func TestVerifyImageChecksum(t *testing.T) { body := fmt.Sprintf("%s some-other-file.tar.xz\n%s yet-another.iso\n", sha512Hex([]byte("a")), sha512Hex([]byte("b"))) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") assert.ErrorIs(t, err, ErrChecksumNotFound) }) t.Run("unsupported checksum type rejected before any fetch", func(t *testing.T) { img := writeTempImage(t, debianName, debianBytes) // Server URL is gibberish — function must fail before reaching it. - err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5") + _, err := VerifyImageChecksum(img, "https://example.invalid/SUMS", "md5") assert.ErrorIs(t, err, ErrUnsupportedChecksumType) }) @@ -1426,7 +1427,7 @@ func TestVerifyImageChecksum(t *testing.T) { })) t.Cleanup(srv.Close) trustTestServer(t, srv) - err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") assert.ErrorIs(t, err, ErrChecksumFetchFailed) }) @@ -1435,7 +1436,7 @@ func TestVerifyImageChecksum(t *testing.T) { // Hit a port nothing listens on — if HTTPS check ever regresses we'd // still get an error, but we want ErrChecksumFetchFailed wrapping the // scheme refusal specifically. - err := VerifyImageChecksum(img, "http://127.0.0.1:1/SUMS", "sha512") + _, err := VerifyImageChecksum(img, "http://127.0.0.1:1/SUMS", "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) assert.Contains(t, err.Error(), "non-https") @@ -1453,7 +1454,7 @@ func TestVerifyImageChecksum(t *testing.T) { })) t.Cleanup(srv.Close) trustTestServer(t, srv) - err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) }) @@ -1467,7 +1468,7 @@ func TestVerifyImageChecksum(t *testing.T) { })) t.Cleanup(srv.Close) trustTestServer(t, srv) - err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) assert.GreaterOrEqual(t, int(hits.Load()), 10) @@ -1482,7 +1483,7 @@ func TestVerifyImageChecksum(t *testing.T) { fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk) } url := newSumsServer(t, b.String()) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) assert.Contains(t, err.Error(), "exceeds") @@ -1500,7 +1501,7 @@ func TestVerifyImageChecksum(t *testing.T) { checksumFetchTimeout = 100 * time.Millisecond t.Cleanup(func() { checksumFetchTimeout = prev }) - err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") + _, err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) }) @@ -1511,7 +1512,7 @@ func TestVerifyImageChecksum(t *testing.T) { img := writeTempImage(t, debianName, debianBytes) body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) assert.NotErrorIs(t, err, ErrChecksumMismatch) @@ -1522,7 +1523,7 @@ func TestVerifyImageChecksum(t *testing.T) { img := writeTempImage(t, debianName, nil) body := fmt.Sprintf("%s %s\n", sha512Hex(nil), debianName) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") require.Error(t, err) assert.NotErrorIs(t, err, ErrChecksumMismatch) assert.Contains(t, err.Error(), "empty") @@ -1536,7 +1537,7 @@ func TestVerifyImageChecksum(t *testing.T) { trustTestServer(t, srv) url := srv.URL + "/SUMS" srv.Close() - err := VerifyImageChecksum(img, url, "sha512") + _, err := VerifyImageChecksum(img, url, "sha512") require.Error(t, err) assert.ErrorIs(t, err, ErrChecksumFetchFailed) }) @@ -1562,7 +1563,7 @@ nopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ+/abcdefghijklmn -----END PGP SIGNATURE----- `, sha256Hex([]byte("boot-iso")), rockyName, sha256Hex(rockyBytes), sha256Hex([]byte("dvd-iso"))) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha256") + _, err := VerifyImageChecksum(img, url, "sha256") assert.NoError(t, err) }) @@ -1581,7 +1582,7 @@ iHUEABYKAB0WIQTm5n8/yqv7tQAaIQAaIQAFAmJabcdEFGhijklmnopqrstuvwxyz -----END PGP SIGNATURE----- `, almaName, sha256Hex(almaBytes)) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha256") + _, err := VerifyImageChecksum(img, url, "sha256") assert.NoError(t, err) }) @@ -1600,7 +1601,7 @@ iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm -----END PGP SIGNATURE----- `, sha256Hex([]byte("boot-iso"))) url := newSumsServer(t, body) - err := VerifyImageChecksum(img, url, "sha256") + _, err := VerifyImageChecksum(img, url, "sha256") assert.ErrorIs(t, err, ErrChecksumNotFound) }) } @@ -1657,6 +1658,125 @@ iQIzBAEBCAAdFiEEnK6Ehq8eQ0z6yqv7tQAaIQAaIQAAaIQFAmJabcdEFGhijklm }) } +func TestReadExpectedDigest(t *testing.T) { + const image = "debian-13-generic-amd64.tar.xz" + imgBytes := []byte("local-image-bytes-fixture") + h256, h512 := sha256Hex(imgBytes), sha512Hex(imgBytes) + other := sha512Hex([]byte("other")) + + cases := []struct { + name string + sumsName string + body string + imageName string + dir bool + missing bool + wantAlgo string + wantDigest string + wantErr error + wantErrMsg string + }{ + {name: "coreutils text sha512", sumsName: "SHA512SUMS", + body: h512 + " " + image + "\n" + other + " other.iso\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "coreutils binary sha256", sumsName: "SHA256SUMS", + body: h256 + " *" + image + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "bsd sha256", sumsName: "CHECKSUM", + body: "SHA256 (" + image + ") = " + h256 + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "bare sha512", sumsName: image + ".sha512", + body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "neutral sums name uses length", sumsName: "checksums.txt", + body: h256 + " " + image + "\n", wantAlgo: "sha256", wantDigest: h256}, + {name: "uppercase hex normalised", sumsName: "SHA512SUMS", + body: strings.ToUpper(h512) + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "sums file name does not decide the algorithm", sumsName: "SHA256SUMS", + body: h512 + " " + image + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "renamed image in multi-entry file", sumsName: "SHA512SUMS", imageName: "renamed.tar.xz", + body: h512 + " " + image + "\n" + other + " other.iso\n", wantErr: ErrChecksumNotFound, wantErrMsg: "renamed.tar.xz"}, + {name: "path-prefixed entry names the prefix", sumsName: "image.sha512", + body: h512 + " ./" + image + "\n", wantErr: ErrChecksumNotFound, wantErrMsg: "directory prefix"}, + {name: "renamed image with bare digest", sumsName: "image.sha512", imageName: "renamed.tar.xz", + body: h512 + "\n", wantAlgo: "sha512", wantDigest: h512}, + {name: "sha1 length unsupported", sumsName: "checksums.txt", + body: strings.Repeat("a", 40) + " " + image + "\n", wantErr: ErrUnsupportedChecksumType}, + {name: "non-hex digest", sumsName: "checksums.txt", + body: strings.Repeat("g", 64) + " " + image + "\n", wantErr: ErrMalformedChecksum}, + {name: "oversized file", sumsName: "SHA512SUMS", + body: h512 + " " + image + "\n" + strings.Repeat("#", sumsFileMaxSize), wantErr: ErrMalformedChecksum}, + {name: "directory refused", sumsName: "SHA512SUMS", dir: true, wantErr: ErrMalformedChecksum}, + {name: "missing file refused", sumsName: "SHA512SUMS", missing: true, wantErr: os.ErrNotExist}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), tc.sumsName) + switch { + case tc.dir: + require.NoError(t, os.Mkdir(path, 0o755)) + case !tc.missing: + require.NoError(t, os.WriteFile(path, []byte(tc.body), 0o600)) + } + imageName := tc.imageName + if imageName == "" { + imageName = image + } + + algo, digest, err := ReadExpectedDigest(path, imageName) + if tc.wantErr != nil { + require.ErrorIs(t, err, tc.wantErr) + assert.Contains(t, err.Error(), tc.wantErrMsg) + return + } + require.NoError(t, err) + assert.Equal(t, tc.wantAlgo, algo) + assert.Equal(t, tc.wantDigest, digest) + }) + } +} + +func TestVerifyImageDigest(t *testing.T) { + imgBytes := []byte("verify-digest-fixture") + img := writeTempImage(t, "image.raw", imgBytes) + + t.Run("sha256 match returns digest", func(t *testing.T) { + got, err := VerifyImageDigest(img, "sha256", sha256Hex(imgBytes)) + require.NoError(t, err) + assert.Equal(t, sha256Hex(imgBytes), got) + }) + + t.Run("sha512 match returns digest", func(t *testing.T) { + got, err := VerifyImageDigest(img, "sha512", sha512Hex(imgBytes)) + require.NoError(t, err) + assert.Equal(t, sha512Hex(imgBytes), got) + }) + + t.Run("mismatch names both digests", func(t *testing.T) { + wrong := sha512Hex([]byte("tampered")) + got, err := VerifyImageDigest(img, "sha512", wrong) + require.ErrorIs(t, err, ErrChecksumMismatch) + assert.Equal(t, sha512Hex(imgBytes), got) + assert.Contains(t, err.Error(), wrong) + assert.Contains(t, err.Error(), sha512Hex(imgBytes)) + }) + + t.Run("unsupported algorithm", func(t *testing.T) { + _, err := VerifyImageDigest(img, "md5", "abc") + assert.ErrorIs(t, err, ErrUnsupportedChecksumType) + }) +} + +func TestHashImageFile(t *testing.T) { + imgBytes := []byte("hash-image-fixture") + img := writeTempImage(t, "image.raw", imgBytes) + + got, err := HashImageFile(img, "sha256") + require.NoError(t, err) + assert.Equal(t, sha256Hex(imgBytes), got) + + _, err = HashImageFile(writeTempImage(t, "empty.raw", nil), "sha256") + require.Error(t, err) + assert.Contains(t, err.Error(), "empty") +} + func TestAvailableImages_Rocky(t *testing.T) { cases := []struct { name string