diff --git a/README.md b/README.md index c5b81cb..7a2ded3 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ Creates VHD files (Linux) or CIM files (Windows) for each layer of a container i #### Windows Examples ```bash -# From container registry (requires Windows host or WSL with interop) +# From container registry ./dmverity-vhd.exe create \ -i mcr.microsoft.com/windows/nanoserver:ltsc2025 \ -o ./output \ @@ -125,7 +125,7 @@ Creates VHD files (Linux) or CIM files (Windows) for each layer of a container i ### `roothash` - Compute Root Hashes -Computes the root hash for each layer without creating VHD/CIM files. Useful for verification or comparison. +Computes the root hash for each layer without creating VHD/CIM files. Useful for verification or comparison. Outputs JSON format for easy integration with other tools. #### Linux Examples @@ -147,6 +147,8 @@ Computes the root hash for each layer without creating VHD/CIM files. Useful for #### Windows Examples +These work on both PowerShell and WSL2 provided you have `CimWriter.dll` in `C:\Windows\System32`. For deterministic hashes, use Windows Server 2025 or later. + ```bash # From container registry ./dmverity-vhd.exe roothash \ @@ -158,19 +160,50 @@ Computes the root hash for each layer without creating VHD/CIM files. Useful for --platform windows/amd64 ``` -#### Output Example +#### Output Format +**Linux Output (JSON):** +```json +{ + "layers": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f", + "d7adf568bac4ee8b05efd56775ce1504a66834781b189a8067ab5b71f513d440", + "e3701df664d4fc1d5bd68ef5e5f82d66b67bd13038822b1adb8de22cc24915ac" + ] +} ``` -Layer 0 root hash: 75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f -Layer 1 root hash: d7adf568bac4ee8b05efd56775ce1504a66834781b189a8067ab5b71f513d440 -Layer 2 root hash: e3701df664d4fc1d5bd68ef5e5f82d66b67bd13038822b1adb8de22cc24915ac -``` -For Windows multi-layer images, also shows: +**Windows Output (JSON):** + +For single-layer images: +```json +{ + "layers": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f" + ], + "mounted_cim": [ + "75f76b2620207ef52a83803bb27b3243a51b13304950ee97fd4a2540cd2f465f" + ] +} ``` -Merged layer hash: 849f1104f01c006729222c65e014eb4070608e75b9860f3411ca4b5a77b658c5 + +For multi-layer images: +```json +{ + "layers": [ + "24af9c8b208189c4846926c47963835daecfbd71168d15da1af247c749b58873", + "24980812fb450c07daea8b3c72e78ade540b48cd59b649a181cac189fe93e22d" + ], + "mounted_cim": [ + "849f1104f01c006729222c65e014eb4070608e75b9860f3411ca4b5a77b658c5" + ] +} ``` +**Field Descriptions:** +- `layers`: Array of root hashes for each layer in the image (in order) +- `mounted_cim`: (Windows only) The hash of the mounted/merged CIM filesystem. For single-layer images, this is the same as the layer hash. For multi-layer images, this is the merged CIM hash. + #### Roothash Command Flags ```bash @@ -250,13 +283,15 @@ Converts a single tar file to either ext4 (Linux) or CIM (Windows) with integrit - Creates `.vhd` files with dm-verity integrity protection - Merkle tree and superblock embedded in VHD - Can use `--hash-dev-vhd` to separate hash device +- `roothash` command outputs JSON with `layers` array ### Windows (`--platform windows/amd64`) - Creates `.bcim` files (Block CIM format) - Integrity checksums embedded in CIM structure -- Multi-layer images also generate merged hash +- Multi-layer images generate merged CIM hash for the actual mounted filesystem +- `roothash` command outputs JSON with `layers` array and `mounted_cim` field -**Note:** Windows CIM creation requires the Windows cimwriter.dll. On Linux, you can use WSL2 with Windows interop enabled to run the `.exe` binary. But to get deterministic hashes on image repull, you need to use this tool on a WS2025 OS. +**Note:** Windows CIM creation requires the Windows CimWriter.dll. On Linux, you can use WSL2 to run the `.exe` binary. For deterministic hashes on image repull, you need to use this tool on Windows Server 2025 or later. ## Output Files diff --git a/cmd/dmverity-vhd/cim.go b/cmd/dmverity-vhd/cim.go index fbb511e..4378393 100644 --- a/cmd/dmverity-vhd/cim.go +++ b/cmd/dmverity-vhd/cim.go @@ -10,17 +10,71 @@ import ( "os" "path/filepath" "strings" + "sync" "github.com/Microsoft/hcsshim/pkg/cimfs" cimimport "github.com/Microsoft/hcsshim/pkg/ociwclayer/cim" log "github.com/sirupsen/logrus" + "golang.org/x/sys/windows" ) type ParentLayers []*cimfs.BlockCIM +var ( + checkWindowsVersionOnce sync.Once + windowsVersionError error +) + +// checkWindowsVersion verifies that we're running on Windows Server 2025 (build 26100) or newer. +// CIM file generation on older versions will not produce deterministic hashes. +// Returns an error if the version requirement is not met, unless debug mode is enabled. +func checkWindowsVersion() error { + checkWindowsVersionOnce.Do(func() { + ver := windows.RtlGetVersion() + if ver == nil { + windowsVersionError = fmt.Errorf("failed to get Windows version information") + return + } + + build := ver.BuildNumber + // Windows Server 2025 is build 26100 + // Deterministic CIM file generation requires WS2025+ + if build < 26100 { + if debugSkipVersionCheck { + // Debug mode: warn but allow execution + log.WithFields(log.Fields{ + "current_build": build, + "required_build": 26100, + "debug_mode": true, + }).Warn("DEBUG MODE: Running on pre-WS2025. Hashes may not be deterministic. This is for development/testing only.") + windowsVersionError = nil + } else { + // Production mode: fail with error + windowsVersionError = fmt.Errorf( + "Windows Server 2025 (build 26100) or newer is required for deterministic Windows container hashes. "+ + "Current build: %d. Windows platform processing cannot continue on this version.", + build, + ) + log.WithFields(log.Fields{ + "current_build": build, + "required_build": 26100, + }).Error("Insufficient Windows version for deterministic CIM generation") + } + } else { + log.WithField("build", build).Debug("Windows version check passed") + } + }) + return windowsVersionError +} + func tarToCim(tarReader io.Reader, parentLayers ParentLayers, out string, layerName string) (string, ParentLayers, error) { log.Trace("tarToCim called") + // Check Windows version for deterministic hash support + if err := checkWindowsVersion(); err != nil { + return "", parentLayers, err + } + // If no out path is given, use a temp directory var err error if out == "" { @@ -70,6 +124,11 @@ func tarToCim(tarReader io.Reader, parentLayers ParentLayers, out string, layerN func generateMergedCim(parentLayers ParentLayers, out string, mergedName string) (string, error) { log.Trace("generateMergedCim called") + // Check Windows version for deterministic hash support + if err := checkWindowsVersion(); err != nil { + return "", err + } + if mergedName == "" { mergedName = "merged" } diff --git a/cmd/dmverity-vhd/instrumentation.go b/cmd/dmverity-vhd/instrumentation.go index ec6c19f..6c63bcd 100644 --- a/cmd/dmverity-vhd/instrumentation.go +++ b/cmd/dmverity-vhd/instrumentation.go @@ -20,6 +20,13 @@ func setLoggingLevel(ctx *cli.Context) { } } +func setDebugSkipVersionCheck(ctx *cli.Context) { + debugSkipVersionCheck = ctx.GlobalBool(debugSkipVersionFlag) + if debugSkipVersionCheck { + log.Warn("DEBUG MODE: Windows version check will be skipped. This is for development only and may produce non-deterministic hashes.") + } +} + var profilerEnabled bool = false func setupProfiler(ctx *cli.Context) { diff --git a/cmd/dmverity-vhd/main.go b/cmd/dmverity-vhd/main.go index 38bd556..8ecc5eb 100644 --- a/cmd/dmverity-vhd/main.go +++ b/cmd/dmverity-vhd/main.go @@ -12,24 +12,28 @@ import ( ) const ( - usernameFlag = "username" - passwordFlag = "password" - platformFlag = "platform" - inputFlag = "input" - outputFlag = "output" - typeFlag = "type" - verboseFlag = "verbose" - traceFlag = "trace" - profilerFlag = "profiler" // enable profiling - outputDirFlag = "out-dir" - dockerFlag = "docker" - bufferedReaderFlag = "buffered-reader" - tarballFlag = "tarball" - hashDeviceVhdFlag = "hash-dev-vhd" - dataVhdFlag = "data-vhd" - maxVHDSize = dmverity.RecommendedVHDSizeGB + usernameFlag = "username" + passwordFlag = "password" + platformFlag = "platform" + inputFlag = "input" + outputFlag = "output" + typeFlag = "type" + verboseFlag = "verbose" + traceFlag = "trace" + profilerFlag = "profiler" // enable profiling + outputDirFlag = "out-dir" + dockerFlag = "docker" + bufferedReaderFlag = "buffered-reader" + tarballFlag = "tarball" + hashDeviceVhdFlag = "hash-dev-vhd" + dataVhdFlag = "data-vhd" + debugSkipVersionFlag = "debug-skip-version-check" + maxVHDSize = dmverity.RecommendedVHDSizeGB ) +// Global variable to control Windows version check strictness +var debugSkipVersionCheck bool = false + func init() { log.SetFormatter(&log.TextFormatter{ DisableTimestamp: false, @@ -82,6 +86,10 @@ func main() { Name: profilerFlag, Usage: "Optional: profile and put the results in this file", }, + cli.BoolFlag{ + Name: debugSkipVersionFlag, + Usage: "Optional: DEBUG ONLY - skip Windows version check (allows running on pre-WS2025 with warnings)", + }, } if err := app.Run(os.Args); err != nil { @@ -128,6 +136,7 @@ var createVHDCommand = cli.Command{ Action: func(ctx *cli.Context) error { setupProfiler(ctx) setLoggingLevel(ctx) + setDebugSkipVersionCheck(ctx) log.Trace("createVHDCommand called") imageName, outDir, platform, verityHashDev, verityData, imageFetcher, imageParser, manifestParser, err := parseCreateVhdArgs(ctx) @@ -165,13 +174,15 @@ var rootHashVHDCommand = cli.Command{ Action: func(ctx *cli.Context) error { setupProfiler(ctx) setLoggingLevel(ctx) + setDebugSkipVersionCheck(ctx) log.Trace("rootHashVHDCommand called") imageFetcher, imageParser, manifestParser, layerParser, mergedHashGenerator, err := parseRoothashArgs(ctx) if err != nil { return err } - err = roothash(imageFetcher, imageParser, manifestParser, layerParser, mergedHashGenerator) + platform := ctx.String(platformFlag) + err = roothash(imageFetcher, imageParser, manifestParser, layerParser, mergedHashGenerator, platform) stopProfiler(ctx) return err }, @@ -195,6 +206,7 @@ var hashLayerCommand = cli.Command{ Action: func(ctx *cli.Context) error { setupProfiler(ctx) setLoggingLevel(ctx) + setDebugSkipVersionCheck(ctx) log.Trace("hashLayerCommand called") tarPath, platform, err := parseHashLayerArgs(ctx) @@ -233,6 +245,7 @@ var tar2hashedCommand = cli.Command{ Action: func(ctx *cli.Context) error { setupProfiler(ctx) setLoggingLevel(ctx) + setDebugSkipVersionCheck(ctx) log.Trace("tar2hashedCommand called") srcTarPath := ctx.String(inputFlag) diff --git a/cmd/dmverity-vhd/roothash.go b/cmd/dmverity-vhd/roothash.go index f10d212..2f24497 100644 --- a/cmd/dmverity-vhd/roothash.go +++ b/cmd/dmverity-vhd/roothash.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "io" "os" @@ -12,6 +13,11 @@ import ( "github.com/urfave/cli" ) +type RootHashOutput struct { + Layers []string `json:"layers"` + MountedCim []string `json:"mounted_cim,omitempty"` +} + type MergedHashGenerator func(layerCount int) (string, error) func parseRoothashArgs(ctx *cli.Context) ( @@ -78,6 +84,7 @@ func roothash( manifestParser ManifestParser, layerParser LayerParser, mergedHashGenerator MergedHashGenerator, + platform string, ) error { log.Trace("roothash called") @@ -96,7 +103,8 @@ func roothash( return err } - // Print the layer number to layer hash + // Collect layer hashes in order + var layerHashes []string var missingLayers []int for layerNumber := 0; layerNumber < len(layerDigests); layerNumber++ { hash, ok := layerDigestToHash[layerDigests[layerNumber]] @@ -104,22 +112,40 @@ func roothash( missingLayers = append(missingLayers, layerNumber) continue } - fmt.Fprintf(os.Stdout, "Layer %d root hash: %s\n", layerNumber, hash) + layerHashes = append(layerHashes, hash) } if len(missingLayers) > 0 { return fmt.Errorf("missing root hashes for layers: %v", missingLayers) } - // Generate and print merged hash if applicable + // Generate merged hash if applicable + var mergedHash string if mergedHashGenerator != nil { - mergedHash, err := mergedHashGenerator(len(layerDigests)) + mergedHash, err = mergedHashGenerator(len(layerDigests)) if err != nil { return fmt.Errorf("failed to generate merged hash: %w", err) } + } + + // Output format depends on platform + output := RootHashOutput{ + Layers: layerHashes, + } + + if strings.HasPrefix(platform, "windows") { + // Add mounted_cim for Windows: either merged hash or single layer hash if mergedHash != "" { - fmt.Fprintf(os.Stdout, "Merged layer hash: %s\n", mergedHash) + output.MountedCim = []string{mergedHash} + } else if len(layerHashes) == 1 { + output.MountedCim = []string{layerHashes[0]} } } + // Both Linux and Windows output JSON + jsonData, err := json.MarshalIndent(output, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal JSON output: %w", err) + } + fmt.Fprintf(os.Stdout, "%s\n", jsonData) return nil }