mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat: validate image checksums (#147)
This commit is contained in:
@@ -286,6 +286,7 @@ func init() {
|
||||
imagesImportCmd.Flags().String("platform", "Linux/UNIX", "Specified platform (e.g Linux/UNIX, Windows)")
|
||||
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)")
|
||||
}
|
||||
|
||||
func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
@@ -297,6 +298,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
|
||||
cfgFile, _ := cmd.Flags().GetString("config")
|
||||
forceCmd, _ := cmd.Flags().GetBool("force")
|
||||
skipVerify, _ := cmd.Flags().GetBool("skip-verify")
|
||||
ostmpDir, _ := cmd.Flags().GetString("tmp-dir")
|
||||
|
||||
// Use default config path
|
||||
@@ -399,16 +401,12 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ Created config directory: %s\n", imagePath)
|
||||
|
||||
// Next, if the file is selected to download, fetch it, extract disk image, and save to path
|
||||
if imageName != "" && localFile == "" {
|
||||
// Download the file to the image path
|
||||
filename := path.Base(image.URL)
|
||||
imageFile = fmt.Sprintf("%s/%s", imagePath, filename)
|
||||
|
||||
fmt.Printf("Downloading image %s to %s\n", image.URL, imageFile)
|
||||
|
||||
// If image path exists, skip
|
||||
if admin.FileExists(imageFile) && !forceCmd {
|
||||
fmt.Printf("Image file already exists, skipping download, use --force to overwrite: %s\n", imageFile)
|
||||
@@ -421,8 +419,23 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// Update image file path for later extraction
|
||||
//imagePath = imageFilePath
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
|
||||
@@ -434,9 +447,6 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
}
|
||||
|
||||
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
|
||||
|
||||
fmt.Println("Extracted image to:", extractedImagePath)
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err)
|
||||
os.Exit(1)
|
||||
@@ -2134,3 +2144,17 @@ func installCACertificate(caPemPath string) {
|
||||
|
||||
fmt.Printf("✅ CA certificate installed to system trust store\n")
|
||||
}
|
||||
|
||||
// printChecksumError writes the failure, the source URL (printed for every
|
||||
// error kind so 404/non-HTTPS/size-cap failures tell the operator which URL
|
||||
// to investigate), and the exact --force recovery command. The cached file
|
||||
// is left in place: an implicit auto-delete would mutate state inside
|
||||
// "verify", and a tampered artifact is forensically useful intact.
|
||||
func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) {
|
||||
fmt.Fprintf(w, "Image integrity verification failed: %v\n", err)
|
||||
fmt.Fprintf(w, " file: %s\n", imageFile)
|
||||
fmt.Fprintf(w, " source: %s\n", image.Checksum)
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:")
|
||||
fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// Regression guard: source URL must print for every error kind, not just
|
||||
// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator
|
||||
// which URL to investigate.
|
||||
func TestPrintChecksumError(t *testing.T) {
|
||||
image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
|
||||
const imageFile = "/var/lib/img.tar.xz"
|
||||
const imageName = "debian-12-x86_64"
|
||||
|
||||
errs := []error{
|
||||
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
|
||||
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
|
||||
errors.New("open /x: no such file"),
|
||||
}
|
||||
for _, e := range errs {
|
||||
var buf bytes.Buffer
|
||||
printChecksumError(&buf, imageFile, imageName, image, e)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e)
|
||||
assert.Contains(t, out, imageFile)
|
||||
assert.Contains(t, out, "spx admin images import --name "+imageName+" --force")
|
||||
}
|
||||
}
|
||||
+32
-1
@@ -89,9 +89,40 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
|
||||
| `spx admin images import` | `--name`, `--file`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, and verify the SHA-256/SHA-512 digest before extraction. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip verification — operator-supplied media is outside Spinifex's trust boundary, and the skip is logged at INFO for audit. `--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. | 1. Import valid catalog image (verifies checksum)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>5. `--skip-verify` bypasses checksum with WARN | **DONE** |
|
||||
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
|
||||
|
||||
#### Image integrity verification (CMMC SI.L1-3.14.2)
|
||||
|
||||
Catalog imports (`spx admin images import --name <name>`) verify the image
|
||||
against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums
|
||||
file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme
|
||||
redirects refused), and verification runs on both fresh downloads and cache
|
||||
hits so a poisoned cache is caught on the next import.
|
||||
|
||||
On mismatch the import exits non-zero, the cached file is left on disk for
|
||||
inspection, and the printed guidance is `spx admin images import --name <name>
|
||||
--force` to re-download.
|
||||
|
||||
`--file` imports skip verification by design: operator-supplied media is
|
||||
outside Spinifex's trust boundary and the operator is responsible for
|
||||
integrity (e.g. `sha256sum` against a trusted upstream digest before import).
|
||||
The skip is recorded as an INFO `slog` event with `reason=local-file-import`
|
||||
so a CMMC assessor can audit the decision from journald.
|
||||
|
||||
`--skip-verify` bypasses the checksum step for catalog imports. The command
|
||||
still downloads via the catalog URL but does not compare the image digest
|
||||
against the sums file. Intended for narrow cases such as debugging upstream
|
||||
mirror issues or running against a transiently-broken `latest/` path; the
|
||||
skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr
|
||||
so operators and assessors see it. Prefer `--file` with an out-of-band
|
||||
verified image over `--skip-verify` whenever possible.
|
||||
|
||||
**Limitation:** verification confirms the image matches the digest the mirror
|
||||
served. A mirror compromise that swaps both image and sums file is not
|
||||
detected; closing that gap requires GPG signature verification of the sums
|
||||
file, deferred to a later phase.
|
||||
|
||||
## AWS Commands
|
||||
|
||||
### EC2 - Instance Management
|
||||
|
||||
@@ -3,11 +3,23 @@ package utils
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"os/signal"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
@@ -18,6 +30,230 @@ import (
|
||||
|
||||
var ErrQCOWDetected = errors.New("qcow format detected")
|
||||
|
||||
const sumsFileMaxSize = 1 * 1024 * 1024 // 1 MB, matches formation.go JoinRequest cap.
|
||||
|
||||
var (
|
||||
ErrChecksumMismatch = errors.New("image checksum mismatch")
|
||||
ErrChecksumNotFound = errors.New("checksum entry for image filename not found")
|
||||
ErrUnsupportedChecksumType = errors.New("unsupported checksum type")
|
||||
ErrChecksumFetchFailed = errors.New("checksum fetch failed")
|
||||
)
|
||||
|
||||
// checksumExtraRootCAs is a test-only hook so unit tests can stand up an
|
||||
// httptest.NewTLSServer (which uses a self-signed cert) without relaxing the
|
||||
// HTTPS enforcement. Production builds leave this nil and the verification
|
||||
// client uses the system trust store.
|
||||
var checksumExtraRootCAs *x509.CertPool
|
||||
|
||||
// checksumFetchTimeout is a var (not const) so tests can shrink it to exercise
|
||||
// the context-deadline path without waiting 30s.
|
||||
var checksumFetchTimeout = 30 * time.Second
|
||||
|
||||
// VerifyImageChecksum fetches the sums file at checksumURL, locates the entry
|
||||
// for imagePath's basename, hashes imagePath with the algorithm named by
|
||||
// checksumType ("sha256" or "sha512"), and compares digests.
|
||||
//
|
||||
// Fails closed: every error path returns without accepting the image. A
|
||||
// non-HTTPS scheme, non-2xx status, transport error, response over 1 MB, or
|
||||
// cross-scheme redirect all wrap ErrChecksumFetchFailed. Unknown algorithm
|
||||
// wraps ErrUnsupportedChecksumType. Missing filename entry wraps
|
||||
// ErrChecksumNotFound. Digest mismatch wraps ErrChecksumMismatch and the
|
||||
// wrapped error's %v includes expected and actual hex.
|
||||
func VerifyImageChecksum(imagePath, checksumURL, checksumType string) error {
|
||||
hasher, err := newHasher(checksumType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
expected, err := fetchExpectedDigest(checksumURL, filepath.Base(imagePath))
|
||||
if err != nil {
|
||||
slog.Error("image checksum fetch failed", "source", checksumURL, "err", err)
|
||||
return err
|
||||
}
|
||||
|
||||
// Distinct error for a catalog/sums-file algorithm mismatch — without this
|
||||
// it'd surface as "tampering" via ConstantTimeCompare's length-0 return.
|
||||
if len(expected) != hasher.Size()*2 {
|
||||
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 {
|
||||
slog.Error("image checksum mismatch",
|
||||
"image", imagePath,
|
||||
"algorithm", checksumType,
|
||||
"expected", expected,
|
||||
"actual", actual,
|
||||
"source", checksumURL,
|
||||
)
|
||||
return fmt.Errorf("%w: expected %s got %s", ErrChecksumMismatch, expected, actual)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func newHasher(checksumType string) (hash.Hash, error) {
|
||||
switch strings.ToLower(checksumType) {
|
||||
case "sha256":
|
||||
return sha256.New(), nil
|
||||
case "sha512":
|
||||
return sha512.New(), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %q", ErrUnsupportedChecksumType, checksumType)
|
||||
}
|
||||
}
|
||||
|
||||
// fetchExpectedDigest downloads the sums file and returns the hex digest for
|
||||
// the given filename. Enforces HTTPS on initial URL and every redirect hop,
|
||||
// caps redirects at 10, and truncates response at sumsFileMaxSize+1 so the
|
||||
// size check is unambiguous.
|
||||
func fetchExpectedDigest(checksumURL, filename string) (string, error) {
|
||||
parsed, err := url.Parse(checksumURL)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: parse url: %v", ErrChecksumFetchFailed, err)
|
||||
}
|
||||
if parsed.Scheme != "https" {
|
||||
return "", fmt.Errorf("%w: non-https checksum url scheme %q", ErrChecksumFetchFailed, parsed.Scheme)
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), checksumFetchTimeout)
|
||||
defer cancel()
|
||||
intCh := make(chan os.Signal, 1)
|
||||
signal.Notify(intCh, os.Interrupt)
|
||||
defer signal.Stop(intCh)
|
||||
go func() {
|
||||
select {
|
||||
case <-intCh:
|
||||
cancel()
|
||||
case <-ctx.Done():
|
||||
}
|
||||
}()
|
||||
|
||||
transport := &http.Transport{}
|
||||
if checksumExtraRootCAs != nil {
|
||||
transport.TLSClientConfig = &tls.Config{RootCAs: checksumExtraRootCAs}
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: checksumFetchTimeout,
|
||||
Transport: transport,
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("stopped after 10 redirects")
|
||||
}
|
||||
if req.URL.Scheme != "https" {
|
||||
return fmt.Errorf("refusing non-https redirect to %s", req.URL.Redacted())
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: build request: %v", ErrChecksumFetchFailed, err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: %v", ErrChecksumFetchFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
||||
return "", fmt.Errorf("%w: unexpected status %s", ErrChecksumFetchFailed, resp.Status)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, sumsFileMaxSize+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: read body: %v", ErrChecksumFetchFailed, err)
|
||||
}
|
||||
if len(body) > sumsFileMaxSize {
|
||||
return "", fmt.Errorf("%w: sums file exceeds %d byte limit", ErrChecksumFetchFailed, sumsFileMaxSize)
|
||||
}
|
||||
|
||||
digest, err := parseSumsFile(body, filename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return digest, nil
|
||||
}
|
||||
|
||||
// parseSumsFile scans a coreutils-style sums file and returns the hex digest
|
||||
// matching filename. Filename match is case-sensitive: upstream Debian/Ubuntu/
|
||||
// Alpine sums are consistently lowercase and a divergence is a real signal.
|
||||
//
|
||||
// Accepts three on-the-wire shapes: "<hex> <name>" (text mode), "<hex> *<name>"
|
||||
// (binary mode), and a bare single-token "<hex>" line (single-file .sha512 from
|
||||
// Alpine, which does not carry a filename).
|
||||
func parseSumsFile(body []byte, filename string) (string, error) {
|
||||
var bareDigest string
|
||||
bareCount := 0
|
||||
scanner := bufio.NewScanner(bytes.NewReader(body))
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), sumsFileMaxSize+1)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
switch len(fields) {
|
||||
case 1:
|
||||
bareDigest = fields[0]
|
||||
bareCount++
|
||||
case 2:
|
||||
name := strings.TrimPrefix(fields[1], "*")
|
||||
if name == filename {
|
||||
return fields[0], nil
|
||||
}
|
||||
default:
|
||||
// More than two fields: not a format we recognise. Skip rather than
|
||||
// reject outright — signed sums files occasionally have trailing
|
||||
// commentary we want to tolerate.
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return "", fmt.Errorf("%w: scan sums file: %v", ErrChecksumFetchFailed, err)
|
||||
}
|
||||
|
||||
// Alpine single-file .sha512 fallback: exactly one bare-digest line in the
|
||||
// whole file. Any more and we refuse, because we can't tell which line is
|
||||
// the right one without a filename.
|
||||
if bareCount == 1 {
|
||||
return bareDigest, nil
|
||||
}
|
||||
|
||||
return "", fmt.Errorf("%w: %s", ErrChecksumNotFound, filename)
|
||||
}
|
||||
|
||||
// hashImageFile streams the image through hasher and returns the digest as
|
||||
// lowercase hex. Zero-byte files are rejected outright so a truncated
|
||||
// download surfaces as an explicit error rather than an opaque "checksum
|
||||
// mismatch".
|
||||
func hashImageFile(imagePath string, hasher hash.Hash) (string, error) {
|
||||
f, err := os.Open(imagePath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
stat, err := f.Stat()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if stat.Size() == 0 {
|
||||
return "", fmt.Errorf("image file %s is empty (likely a truncated or failed download)", imagePath)
|
||||
}
|
||||
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(hasher.Sum(nil)), nil
|
||||
}
|
||||
|
||||
type Images struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
|
||||
@@ -249,9 +249,7 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout
|
||||
WithTotal(int(cl)).
|
||||
Start()
|
||||
|
||||
var written int64
|
||||
reader := io.TeeReader(resp.Body, progressWriter(func(n int) {
|
||||
written += int64(n)
|
||||
// Update progress bar with the number of bytes read in this chunk
|
||||
bar.Add(n)
|
||||
}))
|
||||
@@ -261,8 +259,6 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy error: %v", err)
|
||||
}
|
||||
|
||||
pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written)))
|
||||
return err
|
||||
} else {
|
||||
// Unknown size: spinner that shows bytes downloaded
|
||||
@@ -280,8 +276,6 @@ func DownloadFileWithProgress(url string, name string, filename string, timeout
|
||||
if err != nil {
|
||||
return fmt.Errorf("copy error: %v", err)
|
||||
}
|
||||
|
||||
pterm.Printf("Saved %s (%s)\n", filename, humanBytes(SafeInt64ToUint64(written)))
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"crypto/sha512"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1005,3 +1013,264 @@ func TestHashMAC(t *testing.T) {
|
||||
// Different input → different output
|
||||
assert.NotEqual(t, mac, HashMAC("02:00:00", "other-id"))
|
||||
}
|
||||
|
||||
// writeTempImage writes content to a file named name under t.TempDir() and
|
||||
// returns the absolute path.
|
||||
func writeTempImage(t *testing.T, name string, content []byte) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), name)
|
||||
require.NoError(t, os.WriteFile(path, content, 0o600))
|
||||
return path
|
||||
}
|
||||
|
||||
func sha256Hex(b []byte) string {
|
||||
sum := sha256.Sum256(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
func sha512Hex(b []byte) string {
|
||||
sum := sha512.Sum512(b)
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// newSumsServer stands up a TLS test server returning body for any request,
|
||||
// configures the package-level trust hook, and registers cleanup. Returns the
|
||||
// server URL.
|
||||
func newSumsServer(t *testing.T, body string) string {
|
||||
t.Helper()
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(body))
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustTestServer(t, srv)
|
||||
return srv.URL + "/SUMS"
|
||||
}
|
||||
|
||||
// trustTestServer adds srv's certificate to the package-level trust pool so
|
||||
// VerifyImageChecksum's HTTPS-only client accepts it. Restores the previous
|
||||
// pool on test cleanup.
|
||||
func trustTestServer(t *testing.T, srv *httptest.Server) {
|
||||
t.Helper()
|
||||
prev := checksumExtraRootCAs
|
||||
pool := x509.NewCertPool()
|
||||
if prev != nil {
|
||||
pool = prev.Clone()
|
||||
}
|
||||
pool.AddCert(srv.Certificate())
|
||||
checksumExtraRootCAs = pool
|
||||
t.Cleanup(func() { checksumExtraRootCAs = prev })
|
||||
}
|
||||
|
||||
func TestVerifyImageChecksum(t *testing.T) {
|
||||
const debianName = "debian-12-generic-amd64.tar.xz"
|
||||
const ubuntuName = "noble-server-cloudimg-amd64.img"
|
||||
const alpineName = "alb-alpine-3.21.6-x86_64.raw"
|
||||
|
||||
debianBytes := []byte("debian-image-bytes-fixture")
|
||||
ubuntuBytes := []byte("ubuntu-image-bytes-fixture")
|
||||
alpineBytes := []byte("alpine-image-bytes-fixture")
|
||||
|
||||
t.Run("debian sha512 multi-line match", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
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)
|
||||
})
|
||||
|
||||
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")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("alpine single-line named match", func(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")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("alpine bare-digest fallback", func(t *testing.T) {
|
||||
img := writeTempImage(t, alpineName, alpineBytes)
|
||||
body := sha512Hex(alpineBytes) + "\n"
|
||||
url := newSumsServer(t, body)
|
||||
err := VerifyImageChecksum(img, url, "sha512")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("case-sensitive filename mismatch", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
// Capitalised "Debian-..." in sums file: legitimate divergence from upstream
|
||||
// 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")
|
||||
assert.ErrorIs(t, err, ErrChecksumNotFound)
|
||||
})
|
||||
|
||||
t.Run("comment lines skipped", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
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")
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("digest mismatch", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
wrong := sha512Hex([]byte("a different blob entirely"))
|
||||
body := fmt.Sprintf("%s %s\n", wrong, debianName)
|
||||
url := newSumsServer(t, body)
|
||||
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
|
||||
// operators can compare without scraping slog.
|
||||
assert.Contains(t, err.Error(), wrong)
|
||||
assert.Contains(t, err.Error(), sha512Hex(debianBytes))
|
||||
})
|
||||
|
||||
t.Run("filename absent from sums file", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
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")
|
||||
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")
|
||||
assert.ErrorIs(t, err, ErrUnsupportedChecksumType)
|
||||
})
|
||||
|
||||
t.Run("http 404 wraps fetch failure", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustTestServer(t, srv)
|
||||
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
})
|
||||
|
||||
t.Run("non-https initial url rejected before any request", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
// 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")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
assert.Contains(t, err.Error(), "non-https")
|
||||
})
|
||||
|
||||
t.Run("non-https redirect refused", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
// Plain-HTTP target the TLS server will try to redirect us to.
|
||||
plain := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
fmt.Fprintf(w, "%s %s\n", sha512Hex(debianBytes), debianName)
|
||||
}))
|
||||
t.Cleanup(plain.Close)
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, plain.URL+"/SUMS", http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustTestServer(t, srv)
|
||||
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
})
|
||||
|
||||
t.Run("redirect cap exceeded", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
var hits atomic.Int32
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
hits.Add(1)
|
||||
http.Redirect(w, r, r.URL.Path, http.StatusFound)
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustTestServer(t, srv)
|
||||
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
assert.GreaterOrEqual(t, int(hits.Load()), 10)
|
||||
})
|
||||
|
||||
t.Run("sums file size cap exceeded", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
// Body of 2 MB of unrelated entries — over the 1 MB cap.
|
||||
var b strings.Builder
|
||||
junk := strings.Repeat("a", 128)
|
||||
for b.Len() <= sumsFileMaxSize+1 {
|
||||
fmt.Fprintf(&b, "%s %s.bin\n", sha512Hex([]byte(junk)), junk)
|
||||
}
|
||||
url := newSumsServer(t, b.String())
|
||||
err := VerifyImageChecksum(img, url, "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
assert.Contains(t, err.Error(), "exceeds")
|
||||
})
|
||||
|
||||
t.Run("context deadline exceeded wraps fetch failure", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
<-r.Context().Done()
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
trustTestServer(t, srv)
|
||||
|
||||
prev := checksumFetchTimeout
|
||||
checksumFetchTimeout = 100 * time.Millisecond
|
||||
t.Cleanup(func() { checksumFetchTimeout = prev })
|
||||
|
||||
err := VerifyImageChecksum(img, srv.URL+"/SUMS", "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
})
|
||||
|
||||
t.Run("digest length mismatch rejected before hashing", func(t *testing.T) {
|
||||
// sha256 digest served, caller declares sha512: algorithm disagreement,
|
||||
// not tampering. Must be distinct from ErrChecksumMismatch.
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
body := fmt.Sprintf("%s %s\n", sha256Hex(debianBytes), debianName)
|
||||
url := newSumsServer(t, body)
|
||||
err := VerifyImageChecksum(img, url, "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
assert.NotErrorIs(t, err, ErrChecksumMismatch)
|
||||
assert.Contains(t, err.Error(), "digest length")
|
||||
})
|
||||
|
||||
t.Run("empty image file rejected with clear error", func(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")
|
||||
require.Error(t, err)
|
||||
assert.NotErrorIs(t, err, ErrChecksumMismatch)
|
||||
assert.Contains(t, err.Error(), "empty")
|
||||
})
|
||||
|
||||
t.Run("transport error wrapped", func(t *testing.T) {
|
||||
img := writeTempImage(t, debianName, debianBytes)
|
||||
// Stand up a TLS server, trust it, then close immediately so any
|
||||
// subsequent request fails at the transport layer.
|
||||
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
trustTestServer(t, srv)
|
||||
url := srv.URL + "/SUMS"
|
||||
srv.Close()
|
||||
err := VerifyImageChecksum(img, url, "sha512")
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, ErrChecksumFetchFailed)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user