diff --git a/.github/workflows/static.yml b/.github/workflows/static.yml index 76894227d74..c3751020961 100644 --- a/.github/workflows/static.yml +++ b/.github/workflows/static.yml @@ -181,3 +181,22 @@ jobs: - name: Build darwin CLI run: | nix build ".#${SET}.contrast.cli" + + sev-snp-measure-consistency: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + permissions: + contents: read + env: + SET: base + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + - uses: ./.github/actions/setup_nix + with: + githubToken: ${{ secrets.GITHUB_TOKEN }} + cachixToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + - name: Run sev-snp-measure-consistency + run: | + nix run ".#${SET}.scripts.sev-snp-measure-consistency" diff --git a/go.work b/go.work index fefe53b3018..5a6616b3ff5 100644 --- a/go.work +++ b/go.work @@ -11,6 +11,7 @@ use ( ./tools/igvm ./tools/imagepuller-benchmark ./tools/kernelconfig + ./tools/sev-snp-measure-go ./tools/snp-id-block-generator ./tools/tdx-measure // keep-sorted end diff --git a/internal/snp/gctx.go b/internal/snp/gctx.go new file mode 100644 index 00000000000..1353e6c147c --- /dev/null +++ b/internal/snp/gctx.go @@ -0,0 +1,105 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +package snp + +import ( + "crypto/sha512" + "encoding/binary" +) + +const ( + ldSize = sha512.Size384 // 48 bytes + vmsaGPA = uint64(0xFFFFFFFFF000) +) + +var zeros [ldSize]byte + +// GCTX holds the SNP guest launch digest state. +type GCTX struct { + ld [ldSize]byte +} + +// NewGCTX creates a new GCTX with an all-zero seed. +func NewGCTX() *GCTX { + return &GCTX{} +} + +// NewGCTXWithSeed creates a new GCTX with the provided seed (must be ldSize bytes). +func NewGCTXWithSeed(seed []byte) *GCTX { + g := &GCTX{} + copy(g.ld[:], seed) + return g +} + +// LD returns the current launch digest. +func (g *GCTX) LD() [ldSize]byte { + return g.ld +} + +// update calls the PAGE_INFO hash update as defined in SNP spec 8.17.2 Table 67. +func (g *GCTX) update(pageType uint8, gpa uint64, contents [ldSize]byte) { + const pageInfoLen = 0x70 + isIMI := uint8(0) + vmpl3Perms := uint8(0) + vmpl2Perms := uint8(0) + vmpl1Perms := uint8(0) + + // Build the PAGE_INFO structure (112 bytes = 0x70) + var info [pageInfoLen]byte + copy(info[0:ldSize], g.ld[:]) + copy(info[ldSize:ldSize*2], contents[:]) + binary.LittleEndian.PutUint16(info[ldSize*2:], pageInfoLen) + info[ldSize*2+2] = pageType + info[ldSize*2+3] = isIMI + info[ldSize*2+4] = vmpl3Perms + info[ldSize*2+5] = vmpl2Perms + info[ldSize*2+6] = vmpl1Perms + info[ldSize*2+7] = 0 + binary.LittleEndian.PutUint64(info[ldSize*2+8:], gpa) + + g.ld = sha384(info[:]) +} + +// UpdateNormalPages measures len(data)/4096 normal pages starting at startGPA. +// data must be page-aligned. +func (g *GCTX) UpdateNormalPages(startGPA uint64, data []byte) { + for offset := 0; offset < len(data); offset += 4096 { + page := data[offset : offset+4096] + g.update(0x01, startGPA+uint64(offset), sha384(page)) + } +} + +// UpdateVMSAPage measures a single VMSA page. +func (g *GCTX) UpdateVMSAPage(data [4096]byte) { + g.update(0x02, vmsaGPA, sha384(data[:])) +} + +// UpdateZeroPages measures length/4096 zero pages starting at gpa. +func (g *GCTX) UpdateZeroPages(gpa uint64, lengthBytes int) { + for offset := 0; offset < lengthBytes; offset += 4096 { + g.update(0x03, gpa+uint64(offset), zeros) + } +} + +// UpdateUnmeasuredPages measures length/4096 unmeasured pages starting at gpa. +func (g *GCTX) UpdateUnmeasuredPages(gpa uint64, lengthBytes int) { + for offset := 0; offset < lengthBytes; offset += 4096 { + g.update(0x04, gpa+uint64(offset), zeros) + } +} + +// UpdateSecretsPage measures a secrets page at gpa. +func (g *GCTX) UpdateSecretsPage(gpa uint64) { + g.update(0x05, gpa, zeros) +} + +// UpdateCPUIDPage measures a CPUID page at gpa. +func (g *GCTX) UpdateCPUIDPage(gpa uint64) { + g.update(0x06, gpa, zeros) +} + +func sha384(data []byte) [ldSize]byte { + return sha512.Sum384(data) +} diff --git a/internal/snp/guest.go b/internal/snp/guest.go new file mode 100644 index 00000000000..23803781ba4 --- /dev/null +++ b/internal/snp/guest.go @@ -0,0 +1,116 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +// Package snp implements AMD SEV-SNP launch measurement calculation. +// Only the SNP mode required by the Kata calculateSnpLaunchDigest nix package +// is implemented (--mode snp, VMM type QEMU). +package snp + +import "fmt" + +// CalcSNPLaunchDigest calculates the SEV-SNP launch measurement digest. +// +// - ovmfPath: path to the OVMF firmware binary +// - vcpus: number of guest vCPUs +// - vcpuSig: CPUID signature (e.g. from CPUSigs["EPYC-Milan"]) +// - kernel: path to kernel bzImage (empty string to skip) +// - initrd: path to initrd (empty string to skip) +// - append: kernel command line string (empty string for none) +// - guestFeatures: guest feature flags (default 0x1) +func CalcSNPLaunchDigest(ovmfPath string, vcpus int, vcpuSig uint32, + kernel, initrd, appendStr string, guestFeatures uint64, +) ([ldSize]byte, error) { + ovmf, err := NewOVMF(ovmfPath) + if err != nil { + return [ldSize]byte{}, fmt.Errorf("parsing OVMF: %w", err) + } + + gctx := NewGCTX() + + // Measure the OVMF firmware pages. + data := ovmf.Data() + if len(data)%4096 != 0 { + return [ldSize]byte{}, fmt.Errorf("OVMF size %d is not page-aligned", len(data)) + } + gctx.UpdateNormalPages(ovmf.GPA(), data) + + // Measure metadata sections (zero pages, secrets, CPUID, kernel hashes). + var sevHashes *SevHashes + if kernel != "" { + sevHashes, err = NewSevHashes(kernel, initrd, appendStr) + if err != nil { + return [ldSize]byte{}, fmt.Errorf("computing kernel hashes: %w", err) + } + } + if err := updateMetadataPages(gctx, ovmf, sevHashes); err != nil { + return [ldSize]byte{}, err + } + + // Measure VMSA pages (one per vCPU). + apEIP, err := ovmf.SEVESResetEIP() + if err != nil { + return [ldSize]byte{}, fmt.Errorf("reading OVMF reset EIP: %w", err) + } + for _, page := range VMSAPages(vcpus, apEIP, guestFeatures, vcpuSig) { + gctx.UpdateVMSAPage(page) + } + + return gctx.LD(), nil +} + +// updateMetadataPages processes each OVMF SEV metadata section in order, +// matching the logic in guest.py snp_update_metadata_pages for VMM type QEMU. +func updateMetadataPages(gctx *GCTX, ovmf *OVMF, sevHashes *SevHashes) error { + for _, desc := range ovmf.MetadataItems() { + if err := updateSection(gctx, ovmf, desc, sevHashes); err != nil { + return err + } + } + + if sevHashes != nil && !ovmf.HasMetadataSection(SectionTypeSNPKernelHash) { + return fmt.Errorf("kernel specified but OVMF metadata doesn't include SNP_KERNEL_HASHES section") + } + return nil +} + +// updateSection handles a single metadata section entry (QEMU VMM type). +func updateSection(gctx *GCTX, ovmf *OVMF, desc MetadataSection, sevHashes *SevHashes) error { + gpa := uint64(desc.GPA) + size := int(desc.Size) + + switch desc.Type { + case SectionTypeSNPSecMem: + // QEMU uses zero pages (GCE uses unmeasured, but we only support QEMU). + gctx.UpdateZeroPages(gpa, size) + + case SectionTypeSNPSecrets: + gctx.UpdateSecretsPage(gpa) + + case SectionTypeCPUID: + gctx.UpdateCPUIDPage(gpa) + + case SectionTypeSNPKernelHash: + if sevHashes != nil { + hashGPA, err := ovmf.SEVHashesTableGPA() + if err != nil { + return fmt.Errorf("getting SEV hashes table GPA: %w", err) + } + offsetInPage := hashGPA & 0xfff + page := sevHashes.ConstructPage(offsetInPage) + if size != 4096 { + return fmt.Errorf("SNP_KERNEL_HASHES section size %d != 4096", size) + } + gctx.UpdateNormalPages(gpa, page[:]) + } else { + gctx.UpdateZeroPages(gpa, size) + } + + case SectionTypeSVSMCAA: + gctx.UpdateZeroPages(gpa, size) + + default: + return fmt.Errorf("unknown OVMF metadata section type 0x%x", uint32(desc.Type)) + } + return nil +} diff --git a/internal/snp/ovmf.go b/internal/snp/ovmf.go new file mode 100644 index 00000000000..fc5513f923c --- /dev/null +++ b/internal/snp/ovmf.go @@ -0,0 +1,235 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +package snp + +import ( + "encoding/binary" + "fmt" + "os" +) + +const fourGB = uint64(0x100000000) + +// SectionType represents the type of an OVMF SEV metadata section. +type SectionType uint32 + +// Section type constant definitions. +const ( + SectionTypeSNPSecMem SectionType = 1 + SectionTypeSNPSecrets SectionType = 2 + SectionTypeCPUID SectionType = 3 + SectionTypeSVSMCAA SectionType = 4 + SectionTypeSNPKernelHash SectionType = 0x10 +) + +// MetadataSection describes one entry from the OVMF SEV metadata. +type MetadataSection struct { + GPA uint32 + Size uint32 + Type SectionType +} + +// OVMF holds the parsed OVMF firmware image. +type OVMF struct { + data []byte + gpa uint64 + table map[string][]byte + metadataItems []MetadataSection +} + +// Well-known GUIDs appearing in the OVMF footer table (as little-endian UUID strings). +const ( + ovmfTableFooterGUID = "96b582de-1fb2-45f7-baea-a366c55a082d" + sevHashTableRVGUID = "7255371f-3a3b-4b04-927b-1da6efa8d454" + sevESResetBlockGUID = "00f771de-1a7e-4fcb-890e-68c77e2fb44e" + ovmfSEVMetaDataGUID = "dc886566-984a-4798-a75e-5585a7bf67cc" +) + +// NewOVMF reads and parses an OVMF firmware file. +func NewOVMF(filename string) (*OVMF, error) { + return newOVMF(filename, fourGB) +} + +func newOVMF(filename string, endAt uint64) (*OVMF, error) { + data, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("reading OVMF file: %w", err) + } + o := &OVMF{ + data: data, + table: make(map[string][]byte), + } + if err := o.parseFooterTable(); err != nil { + return nil, err + } + if err := o.parseSEVMetadata(); err != nil { + return nil, err + } + o.gpa = endAt - uint64(len(data)) + return o, nil +} + +// Data returns the raw OVMF binary. +func (o *OVMF) Data() []byte { return o.data } + +// GPA returns the base guest physical address of the OVMF image. +func (o *OVMF) GPA() uint64 { return o.gpa } + +// MetadataItems returns the parsed SEV metadata section descriptors. +func (o *OVMF) MetadataItems() []MetadataSection { return o.metadataItems } + +// HasMetadataSection reports whether a section of the given type exists. +func (o *OVMF) HasMetadataSection(t SectionType) bool { + for _, s := range o.metadataItems { + if s.Type == t { + return true + } + } + return false +} + +// SEVHashesTableGPA returns the GPA of the SEV hashes table, or an error. +func (o *OVMF) SEVHashesTableGPA() (uint32, error) { + entry, ok := o.table[sevHashTableRVGUID] + if !ok { + return 0, fmt.Errorf("SEV_HASH_TABLE_RV_GUID not found in OVMF table") + } + if len(entry) < 4 { + return 0, fmt.Errorf("SEV_HASH_TABLE_RV_GUID entry too short") + } + return binary.LittleEndian.Uint32(entry[:4]), nil +} + +// IsSEVHashesTableSupported reports whether the OVMF supports kernel/initrd/cmdline measurement. +func (o *OVMF) IsSEVHashesTableSupported() bool { + gpa, err := o.SEVHashesTableGPA() + return err == nil && gpa != 0 +} + +// SEVESResetEIP returns the SEV-ES reset EIP from the OVMF footer table. +func (o *OVMF) SEVESResetEIP() (uint32, error) { + entry, ok := o.table[sevESResetBlockGUID] + if !ok { + return 0, fmt.Errorf("SEV_ES_RESET_BLOCK_GUID not found in OVMF table") + } + if len(entry) < 4 { + return 0, fmt.Errorf("SEV_ES_RESET_BLOCK_GUID entry too short") + } + return binary.LittleEndian.Uint32(entry[:4]), nil +} + +// parseFooterTable parses the OVMF footer GUID table. +// The table is located immediately before the last 32 bytes of the firmware. +// Each entry has: data bytes, then a 2-byte size, then a 16-byte GUID. +// The table is traversed from back to front. +func (o *OVMF) parseFooterTable() error { + const entryHeaderSize = 18 // uint16 size + 16 byte GUID + size := len(o.data) + + startOfFooterTable := size - 32 - entryHeaderSize + if startOfFooterTable < 0 { + return nil + } + + footerEntry := o.data[startOfFooterTable:] + footerSize := binary.LittleEndian.Uint16(footerEntry[0:2]) + footerGUID := guidString(footerEntry[2:18]) + + if footerGUID != ovmfTableFooterGUID { + return nil + } + + tableSize := int(footerSize) - entryHeaderSize + if tableSize < 0 { + return nil + } + + tableBytes := o.data[startOfFooterTable-tableSize : startOfFooterTable] + + for len(tableBytes) >= entryHeaderSize { + tail := tableBytes[len(tableBytes)-entryHeaderSize:] + entrySize := int(binary.LittleEndian.Uint16(tail[0:2])) + if entrySize < entryHeaderSize { + return fmt.Errorf("invalid OVMF footer table entry size %d", entrySize) + } + entryGUID := guidString(tail[2:18]) + dataStart := len(tableBytes) - entrySize + if dataStart < 0 { + return fmt.Errorf("OVMF footer table entry extends past table start") + } + entryData := tableBytes[dataStart : len(tableBytes)-entryHeaderSize] + o.table[entryGUID] = entryData + tableBytes = tableBytes[:len(tableBytes)-entrySize] + } + return nil +} + +// parseSEVMetadata parses the OVMF SEV metadata section descriptors. +func (o *OVMF) parseSEVMetadata() error { + entry, ok := o.table[ovmfSEVMetaDataGUID] + if !ok { + return nil + } + if len(entry) < 4 { + return fmt.Errorf("OVMF_SEV_META_DATA_GUID entry too short") + } + offsetFromEnd := binary.LittleEndian.Uint32(entry[:4]) + start := len(o.data) - int(offsetFromEnd) + if start < 0 || start+16 > len(o.data) { + return fmt.Errorf("SEV metadata header out of range") + } + + // OvmfSevMetadataHeader: signature(4) + size(4) + version(4) + num_items(4) = 16 bytes + sig := o.data[start : start+4] + if string(sig) != "ASEV" { + return fmt.Errorf("wrong SEV metadata signature: %q", sig) + } + mdSize := binary.LittleEndian.Uint32(o.data[start+4 : start+8]) + version := binary.LittleEndian.Uint32(o.data[start+8 : start+12]) + numItems := binary.LittleEndian.Uint32(o.data[start+12 : start+16]) + if version != 1 { + return fmt.Errorf("wrong SEV metadata version: %d", version) + } + + const headerSize = 16 + const itemSize = 12 // gpa(4) + size(4) + section_type(4) + itemsStart := start + headerSize + itemsEnd := start + int(mdSize) + if itemsEnd > len(o.data) || itemsEnd < itemsStart { + return fmt.Errorf("SEV metadata items out of range") + } + items := o.data[itemsStart:itemsEnd] + + for i := range int(numItems) { + off := i * itemSize + if off+itemSize > len(items) { + return fmt.Errorf("SEV metadata item %d out of range", i) + } + gpa := binary.LittleEndian.Uint32(items[off : off+4]) + sz := binary.LittleEndian.Uint32(items[off+4 : off+8]) + st := SectionType(binary.LittleEndian.Uint32(items[off+8 : off+12])) + o.metadataItems = append(o.metadataItems, MetadataSection{GPA: gpa, Size: sz, Type: st}) + } + return nil +} + +// guidString converts 18 bytes (footer table entry: 2-byte size + 16-byte GUID in bytes_le) +// at offset 2 to a lowercase UUID string matching Python's str(uuid.UUID(bytes_le=...)). +// Input is the 16-byte bytes_le GUID. +func guidString(b []byte) string { + // UUID bytes_le layout: + // time_low (4 bytes, LE) | time_mid (2 bytes, LE) | time_hi (2 bytes, LE) | rest (8 bytes, BE) + if len(b) < 16 { + return "" + } + timeLow := binary.LittleEndian.Uint32(b[0:4]) + timeMid := binary.LittleEndian.Uint16(b[4:6]) + timeHi := binary.LittleEndian.Uint16(b[6:8]) + return fmt.Sprintf("%08x-%04x-%04x-%02x%02x-%02x%02x%02x%02x%02x%02x", + timeLow, timeMid, timeHi, + b[8], b[9], + b[10], b[11], b[12], b[13], b[14], b[15], + ) +} diff --git a/internal/snp/sev_hashes.go b/internal/snp/sev_hashes.go new file mode 100644 index 00000000000..9d9b9c25221 --- /dev/null +++ b/internal/snp/sev_hashes.go @@ -0,0 +1,199 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +package snp + +import ( + "crypto/sha256" + "encoding/binary" + "os" +) + +// GUIDs for the SEV hash table structures (little-endian UUID bytes, formatted as standard UUID strings). +const ( + sevHashTableHeaderGUID = "9438d606-4f22-4cc9-b479-a793d411fd21" + sevKernelEntryGUID = "4de79437-abd2-427f-b835-d5b172d2045b" + sevInitrdEntryGUID = "44baf731-3a2f-4bd7-9af1-41e29169781d" + sevCmdlineEntryGUID = "97d02dd8-bd20-4c94-aa78-e7714d36ab2a" +) + +// sha256GUID converts a standard UUID string to its little-endian bytes_le representation. +// The four GUIDs above were produced by Python's uuid.UUID("{...}").bytes_le, then reformatted +// via guidString(); we need to go the other direction here to embed those bytes into the binary. + +// guidBytes returns the 16-byte little-endian UUID encoding for a GUID already in the +// bytes_le-derived display format produced by guidString(). +// Format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx where the first three groups are LE. +func guidBytesLE(s string) [16]byte { + // Parse the string (same format as guidString output). + var g [16]byte + var timeLow uint32 + var timeMid, timeHi uint16 + var rest [8]byte + // Use fmt.Sscanf-style parsing via binary reads from hex string. + // Simpler: just parse manually. + parts := splitGUID(s) + timeLow = parseHex32(parts[0]) + timeMid = parseHex16(parts[1]) + timeHi = parseHex16(parts[2]) + rest[0] = parseHex8(parts[3][0:2]) + rest[1] = parseHex8(parts[3][2:4]) + rest[2] = parseHex8(parts[4][0:2]) + rest[3] = parseHex8(parts[4][2:4]) + rest[4] = parseHex8(parts[4][4:6]) + rest[5] = parseHex8(parts[4][6:8]) + rest[6] = parseHex8(parts[4][8:10]) + rest[7] = parseHex8(parts[4][10:12]) + + binary.LittleEndian.PutUint32(g[0:], timeLow) + binary.LittleEndian.PutUint16(g[4:], timeMid) + binary.LittleEndian.PutUint16(g[6:], timeHi) + copy(g[8:], rest[:]) + return g +} + +func splitGUID(s string) [5]string { + var parts [5]string + idx := 0 + start := 0 + for i, c := range s { + if c == '-' { + parts[idx] = s[start:i] + idx++ + start = i + 1 + } + } + parts[idx] = s[start:] + return parts +} + +func parseHex32(s string) uint32 { + var v uint32 + for _, c := range s { + v <<= 4 + v |= hexVal(c) + } + return v +} + +func parseHex16(s string) uint16 { + var v uint16 + for _, c := range s { + v <<= 4 + v |= uint16(hexVal(c)) + } + return v +} + +func parseHex8(s string) byte { + var v byte + for _, c := range s { + v <<= 4 + v |= byte(hexVal(c)) + } + return v +} + +func hexVal(c rune) uint32 { + switch { + case c >= '0' && c <= '9': + return uint32(c - '0') + case c >= 'a' && c <= 'f': + return uint32(c-'a') + 10 + case c >= 'A' && c <= 'F': + return uint32(c-'A') + 10 + } + return 0 +} + +// SevHashes holds the SHA-256 hashes of kernel, initrd, and cmdline. +type SevHashes struct { + KernelHash [32]byte + InitrdHash [32]byte + CmdlineHash [32]byte +} + +// NewSevHashes computes hashes of the kernel, initrd (may be empty string), and append string. +func NewSevHashes(kernelPath, initrdPath, appendStr string) (*SevHashes, error) { + kernelData, err := os.ReadFile(kernelPath) + if err != nil { + return nil, err + } + var initrdData []byte + if initrdPath != "" { + initrdData, err = os.ReadFile(initrdPath) + if err != nil { + return nil, err + } + } + var cmdline []byte + if appendStr != "" { + cmdline = append([]byte(appendStr), 0x00) + } else { + cmdline = []byte{0x00} + } + return &SevHashes{ + KernelHash: sha256.Sum256(kernelData), + InitrdHash: sha256.Sum256(initrdData), + CmdlineHash: sha256.Sum256(cmdline), + }, nil +} + +// SevHashTableEntry binary layout (little-endian, packed): +// +// guid [16]byte +// length uint16 +// hash [32]byte +// +// Size = 50 bytes. +const sevHashTableEntrySize = 16 + 2 + 32 + +// SevHashTable binary layout (little-endian, packed): +// +// guid [16]byte +// length uint16 +// cmdline SevHashTableEntry (50 bytes) +// initrd SevHashTableEntry (50 bytes) +// kernel SevHashTableEntry (50 bytes) +// +// Size = 16 + 2 + 50*3 = 168 bytes. +const sevHashTableSize = 16 + 2 + 3*sevHashTableEntrySize + +// Padded to next 16-byte boundary: (168+15)&~15 = 176. +const paddedSevHashTableSize = (sevHashTableSize + 15) &^ 15 + +// ConstructTable builds the SEV hash table binary, padded to 176 bytes. +// This must be identical to how QEMU generates the hash table. +func (s *SevHashes) ConstructTable() [paddedSevHashTableSize]byte { + var buf [paddedSevHashTableSize]byte + + headerGUID := guidBytesLE(sevHashTableHeaderGUID) + kernelGUID := guidBytesLE(sevKernelEntryGUID) + initrdGUID := guidBytesLE(sevInitrdEntryGUID) + cmdlineGUID := guidBytesLE(sevCmdlineEntryGUID) + + writeEntry := func(b []byte, off int, guid [16]byte, hash [32]byte) { + copy(b[off:], guid[:]) + binary.LittleEndian.PutUint16(b[off+16:], uint16(sevHashTableEntrySize)) + copy(b[off+18:], hash[:]) + } + + // Header: guid + length (covers SevHashTable only, not padding) + copy(buf[0:], headerGUID[:]) + binary.LittleEndian.PutUint16(buf[16:], uint16(sevHashTableSize)) + + writeEntry(buf[:], 18, cmdlineGUID, s.CmdlineHash) + writeEntry(buf[:], 18+sevHashTableEntrySize, initrdGUID, s.InitrdHash) + writeEntry(buf[:], 18+2*sevHashTableEntrySize, kernelGUID, s.KernelHash) + + return buf +} + +// ConstructPage places the hash table in a 4096-byte page at the given byte offset. +func (s *SevHashes) ConstructPage(offsetInPage uint32) [4096]byte { + table := s.ConstructTable() + var page [4096]byte + copy(page[offsetInPage:], table[:]) + return page +} diff --git a/internal/snp/vcpu_types.go b/internal/snp/vcpu_types.go new file mode 100644 index 00000000000..d553afa24a0 --- /dev/null +++ b/internal/snp/vcpu_types.go @@ -0,0 +1,53 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +package snp + +import "fmt" + +// CPUSig computes the 32-bit CPUID signature from family, model, and stepping. +// See AMD CPUID Specification, publication #25481, section CPUID Fn0000_0001_EAX. +func CPUSig(family, model, stepping int) uint32 { + var familyLow, familyHigh uint32 + if family > 0xf { + familyLow = 0xf + familyHigh = uint32(family-0x0f) & 0xff + } else { + familyLow = uint32(family) + familyHigh = 0 + } + modelLow := uint32(model) & 0xf + modelHigh := (uint32(model) >> 4) & 0xf + steppingLow := uint32(stepping) & 0xf + + return (familyHigh << 20) | (modelHigh << 16) | (familyLow << 8) | (modelLow << 4) | steppingLow +} + +// CPUSigs maps CPU type names (as used in QEMU) to their CPUID signatures. +var CPUSigs = map[string]uint32{ + "EPYC": CPUSig(23, 1, 2), + "EPYC-v1": CPUSig(23, 1, 2), + "EPYC-v2": CPUSig(23, 1, 2), + "EPYC-IBPB": CPUSig(23, 1, 2), + "EPYC-v3": CPUSig(23, 1, 2), + "EPYC-v4": CPUSig(23, 1, 2), + "EPYC-Rome": CPUSig(23, 49, 0), + "EPYC-Rome-v1": CPUSig(23, 49, 0), + "EPYC-Rome-v2": CPUSig(23, 49, 0), + "EPYC-Rome-v3": CPUSig(23, 49, 0), + "EPYC-Milan": CPUSig(25, 1, 1), + "EPYC-Milan-v1": CPUSig(25, 1, 1), + "EPYC-Milan-v2": CPUSig(25, 1, 1), + "EPYC-Genoa": CPUSig(25, 17, 0), + "EPYC-Genoa-v1": CPUSig(25, 17, 0), +} + +// LookupCPUSig returns the CPUID signature for the named CPU type, or an error. +func LookupCPUSig(cpuType string) (uint32, error) { + sig, ok := CPUSigs[cpuType] + if !ok { + return 0, fmt.Errorf("unknown CPU type %q", cpuType) + } + return sig, nil +} diff --git a/internal/snp/vmsa.go b/internal/snp/vmsa.go new file mode 100644 index 00000000000..da2fc0b42f8 --- /dev/null +++ b/internal/snp/vmsa.go @@ -0,0 +1,226 @@ +// Translated from the Python sev-snp-measure tool: +// Copyright 2022- IBM Inc. All rights reserved +// SPDX-License-Identifier: Apache-2.0 + +package snp + +import "encoding/binary" + +// SevEsSaveArea is the VMSA page layout (AMD APM Vol 2, Table B-4). +// Total size must be exactly 4096 bytes. We represent it as a plain +// byte array and write fields at the correct offsets. +// +// Offset map (all LE, _pack_=1): +// +// 0x000 es (VmcbSeg: sel u16, attrib u16, limit u32, base u64) = 16 B +// 0x010 cs +// 0x020 ss +// 0x030 ds +// 0x040 fs +// 0x050 gs +// 0x060 gdtr +// 0x070 ldtr +// 0x080 idtr +// 0x090 tr +// 0x0a0 vmpl0_ssp u64 +// 0x0a8 vmpl1_ssp u64 +// 0x0b0 vmpl2_ssp u64 +// 0x0b8 vmpl3_ssp u64 +// 0x0c0 u_cet u64 +// 0x0c8 reserved (2 B) +// 0x0ca vmpl u8 +// 0x0cb cpl u8 +// 0x0cc reserved (4 B) +// 0x0d0 efer u64 +// 0x0d8 reserved (104 B) +// 0x140 xss u64 +// 0x148 cr4 u64 +// 0x150 cr3 u64 +// 0x158 cr0 u64 +// 0x160 dr7 u64 +// 0x168 dr6 u64 +// 0x170 rflags u64 +// 0x178 rip u64 +// 0x180 dr0..dr3 (4×8 B) +// 0x1a0 dr0..dr3_addr_mask (4×8 B) +// 0x1c0 reserved (24 B) +// 0x1d8 rsp u64 +// 0x1e0 s_cet u64 +// 0x1e8 ssp u64 +// 0x1f0 isst_addr u64 +// 0x1f8 rax u64 +// 0x200 star u64 +// 0x208 lstar u64 +// 0x210 cstar u64 +// 0x218 sfmask u64 +// 0x220 kernel_gs_base u64 +// 0x228 sysenter_cs u64 +// 0x230 sysenter_esp u64 +// 0x238 sysenter_eip u64 +// 0x240 cr2 u64 +// 0x248 reserved (32 B) +// 0x268 g_pat u64 +// 0x270 dbgctrl u64 +// 0x278 br_from u64 +// 0x280 br_to u64 +// 0x288 last_excp_from u64 +// 0x290 last_excp_to u64 +// 0x298 reserved (80 B) +// 0x2e8 pkru u32 +// 0x2ec tsc_aux u32 +// 0x2f0 reserved (24 B) +// 0x308 rcx u64 +// 0x310 rdx u64 +// 0x318 rbx u64 +// 0x320 reserved u64 +// 0x328 rbp u64 +// 0x330 rsi u64 +// 0x338 rdi u64 +// 0x340 r8 u64 +// 0x348 r9 u64 +// 0x350 r10 u64 +// 0x358 r11 u64 +// 0x360 r12 u64 +// 0x368 r13 u64 +// 0x370 r14 u64 +// 0x378 r15 u64 +// 0x380 reserved (16 B) +// 0x390 guest_exit_info_1 u64 +// 0x398 guest_exit_info_2 u64 +// 0x3a0 guest_exit_int_info u64 +// 0x3a8 guest_nrip u64 +// 0x3b0 sev_features u64 +// 0x3b8 vintr_ctrl u64 +// 0x3c0 guest_exit_code u64 +// 0x3c8 virtual_tom u64 +// 0x3d0 tlb_id u64 +// 0x3d8 pcpu_id u64 +// 0x3e0 event_inj u64 +// 0x3e8 xcr0 u64 +// 0x3f0 reserved (16 B) +// --- Floating Point Area --- +// 0x400 x87_dp u64 +// 0x408 mxcsr u32 +// 0x40c x87_ftw u16 +// 0x40e x87_fsw u16 +// 0x410 x87_fcw u16 +// 0x412 x87_fop u16 +// 0x414 x87_ds u16 +// 0x416 x87_cs u16 +// 0x418 x87_rip u64 +// 0x420 fpreg_x87 (80 B) +// 0x470 fpreg_xmm (256 B) +// 0x570 fpreg_ymm (256 B) +// 0x670 manual_padding (2448 B) +// 0xfff (end) +const vmcbSegOffset = 16 //nolint:unused // sizeof VmcbSeg + +// vmcbSeg writes a VmcbSeg at the given offset in buf. +func vmcbSeg(buf []byte, off int, selector, attrib uint16, limit uint32, base uint64) { //nolint:unparam + binary.LittleEndian.PutUint16(buf[off:], selector) + binary.LittleEndian.PutUint16(buf[off+2:], attrib) + binary.LittleEndian.PutUint32(buf[off+4:], limit) + binary.LittleEndian.PutUint64(buf[off+8:], base) +} + +func pu64(buf []byte, off int, v uint64) { binary.LittleEndian.PutUint64(buf[off:], v) } +func pu32(buf []byte, off int, v uint32) { binary.LittleEndian.PutUint32(buf[off:], v) } +func pu16(buf []byte, off int, v uint16) { binary.LittleEndian.PutUint16(buf[off:], v) } + +// BuildVMSASaveArea builds a 4096-byte VMSA save-area page for QEMU/SNP. +// eip is the reset EIP (BSP uses 0xfffffff0, APs use the OVMF reset EIP). +// sevFeatures is the guest_features value (default 0x1). +// vcpuSig is the CPUID signature from CPUSigs table. +func BuildVMSASaveArea(eip uint32, sevFeatures uint64, vcpuSig uint32) [4096]byte { + var page [4096]byte + b := page[:] + + // QEMU VMM type values + csFlags := uint16(0x9b) + ssFlags := uint16(0x93) + trFlags := uint16(0x8b) + rdx := uint64(vcpuSig) + mxcsr := uint32(0x1f80) + fcw := uint16(0x37f) + gPat := uint64(0x7040600070406) // PAT MSR, AMD APM Vol 2 Section A.3 + + // Segments at offsets 0x00 .. 0x9f + // es = 0x000 + vmcbSeg(b, 0x000, 0, 0x93, 0xffff, 0) + // cs = 0x010 selector=0xf000, base = eip & 0xffff0000, rip = eip & 0xffff + vmcbSeg(b, 0x010, 0xf000, csFlags, 0xffff, uint64(eip)&0xffff0000) + // ss = 0x020 + vmcbSeg(b, 0x020, 0, ssFlags, 0xffff, 0) + // ds = 0x030 + vmcbSeg(b, 0x030, 0, 0x93, 0xffff, 0) + // fs = 0x040 + vmcbSeg(b, 0x040, 0, 0x93, 0xffff, 0) + // gs = 0x050 + vmcbSeg(b, 0x050, 0, 0x93, 0xffff, 0) + // gdtr = 0x060 + vmcbSeg(b, 0x060, 0, 0, 0xffff, 0) + // ldtr = 0x070 + vmcbSeg(b, 0x070, 0, 0x82, 0xffff, 0) + // idtr = 0x080 + vmcbSeg(b, 0x080, 0, 0, 0xffff, 0) + // tr = 0x090 + vmcbSeg(b, 0x090, 0, trFlags, 0xffff, 0) + + // efer = 0x0d0 + pu64(b, 0x0d0, 0x1000) // KVM enables EFER_SVME + + // cr4 = 0x148 + pu64(b, 0x148, 0x40) // X86_CR4_MCE + // cr3 = 0x150 + pu64(b, 0x150, 0) + // cr0 = 0x158 + pu64(b, 0x158, 0x10) + // dr7 = 0x160 + pu64(b, 0x160, 0x400) + // dr6 = 0x168 + pu64(b, 0x168, 0xffff0ff0) + // rflags = 0x170 + pu64(b, 0x170, 0x2) + // rip = 0x178 (eip & 0xffff) + pu64(b, 0x178, uint64(eip)&0xffff) + + // g_pat = 0x268 + pu64(b, 0x268, gPat) + + // rdx = 0x310 + pu64(b, 0x310, rdx) + + // sev_features = 0x3b0 + pu64(b, 0x3b0, sevFeatures) + + // xcr0 = 0x3e8 + pu64(b, 0x3e8, 0x1) + + // mxcsr = 0x408 + pu32(b, 0x408, mxcsr) + + // x87_fcw = 0x410 + pu16(b, 0x410, fcw) + + return page +} + +const bspEIP = uint32(0xfffffff0) + +// VMSAPages returns vcpus VMSA pages for an SNP guest. +// vcpu 0 is the BSP (uses bspEIP = 0xfffffff0). +// vcpus 1..N-1 are APs (use apEIP, i.e. ovmf.SEVESResetEIP()). +func VMSAPages(vcpus int, apEIP uint32, sevFeatures uint64, vcpuSig uint32) [][4096]byte { + bsp := BuildVMSASaveArea(bspEIP, sevFeatures, vcpuSig) + ap := BuildVMSASaveArea(apEIP, sevFeatures, vcpuSig) + + pages := make([][4096]byte, vcpus) + for i := range vcpus { + if i == 0 { + pages[i] = bsp + } else { + pages[i] = ap + } + } + return pages +} diff --git a/packages/by-name/kata/calculateSnpLaunchDigest/package.nix b/packages/by-name/kata/calculateSnpLaunchDigest/package.nix index 6aae7b60a4a..f520e7a580f 100644 --- a/packages/by-name/kata/calculateSnpLaunchDigest/package.nix +++ b/packages/by-name/kata/calculateSnpLaunchDigest/package.nix @@ -6,13 +6,13 @@ stdenvNoCC, kata, OVMF-SNP, - python3Packages, }: { os-image, withDebug ? false, vcpus, + sev-snp-measure ? kata.sev-snp-measure, }: let @@ -38,7 +38,7 @@ stdenvNoCC.mkDerivation { buildPhase = '' mkdir $out - ${lib.getExe python3Packages.sev-snp-measure} \ + ${lib.getExe sev-snp-measure} \ --mode snp \ --ovmf ${ovmf-snp} \ --vcpus ${toString vcpus} \ @@ -47,7 +47,7 @@ stdenvNoCC.mkDerivation { --initrd ${initrd} \ --append "${cmdline}" \ --output-format hex > $out/milan.hex - ${lib.getExe python3Packages.sev-snp-measure} \ + ${lib.getExe sev-snp-measure} \ --mode snp \ --ovmf ${ovmf-snp} \ --vcpus ${toString vcpus} \ diff --git a/packages/by-name/kata/sev-snp-measure/package.nix b/packages/by-name/kata/sev-snp-measure/package.nix new file mode 100644 index 00000000000..2be1e5f974b --- /dev/null +++ b/packages/by-name/kata/sev-snp-measure/package.nix @@ -0,0 +1,37 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 + +{ lib, buildGoModule }: + +buildGoModule (finalAttrs: { + pname = "sev-snp-measure"; + version = "0.1.0"; + + src = + let + inherit (lib) fileset path; + root = ../../../../.; + in + fileset.toSource { + inherit root; + fileset = fileset.unions [ + (path.append root "tools/sev-snp-measure-go") + (path.append root "internal/snp") + (path.append root "go.mod") + (path.append root "go.sum") + ]; + }; + + proxyVendor = true; + vendorHash = "sha256-Qq31w1MyEsiTzj/2lUYCekTFqQDXtyiTttV8rvtpLGY="; + + sourceRoot = "${finalAttrs.src.name}/tools/sev-snp-measure-go"; + + env.CGO_ENABLED = 0; + + ldflags = [ "-s" ]; + + doCheck = false; + + meta.mainProgram = "sev-snp-measure-go"; +}) diff --git a/packages/by-name/scripts/sev-snp-measure-consistency/package.nix b/packages/by-name/scripts/sev-snp-measure-consistency/package.nix new file mode 100644 index 00000000000..05f02dc8e5e --- /dev/null +++ b/packages/by-name/scripts/sev-snp-measure-consistency/package.nix @@ -0,0 +1,81 @@ +# Copyright 2026 Edgeless Systems GmbH +# SPDX-License-Identifier: BUSL-1.1 + +{ + lib, + writeShellApplication, + kata, + contrast, + python3Packages, +}: + +let + vcpuCounts = lib.range 1 220; + images = [ + { + name = "os-image"; + image = contrast.node-installer-image.os-image; + } + { + name = "gpu-image"; + image = contrast.node-installer-image.gpu.os-image; + } + ]; + + combinations = lib.concatLists ( + map ( + img: + map (vcpus: { + inherit (img) name; + inherit vcpus; + go = kata.calculateSnpLaunchDigest { + os-image = img.image; + inherit vcpus; + inherit (contrast.node-installer-image) withDebug; + }; + python = kata.calculateSnpLaunchDigest { + os-image = img.image; + inherit (python3Packages) sev-snp-measure; + inherit vcpus; + inherit (contrast.node-installer-image) withDebug; + }; + }) vcpuCounts + ) images + ); + + checkCmds = map (c: '' + echo -n "Checking ${c.name} (${toString c.vcpus} vCPUs): " + failed_local=0 + if ! diff -q "${c.python}/milan.hex" "${c.go}/milan.hex" > /dev/null; then + failed_local=1 + echo -n "[Milan DIFF] " + exit_code=1 + fi + if ! diff -q "${c.python}/genoa.hex" "${c.go}/genoa.hex" > /dev/null; then + failed_local=1 + echo -n "[Genoa DIFF] " + exit_code=1 + fi + + if [ $failed_local -eq 0 ]; then + echo "OK" + else + echo "" + echo " Milan diff:" + diff -u "${c.python}/milan.hex" "${c.go}/milan.hex" || true + echo " Genoa diff:" + diff -u "${c.python}/genoa.hex" "${c.go}/genoa.hex" || true + fi + '') combinations; + +in +writeShellApplication { + name = "verify-snp-measure"; + text = '' + exit_code=0 + ${lib.concatStringsSep "\n" checkCmds} + if [ $exit_code -ne 0 ]; then + exit 1 + fi + ''; +} diff --git a/packages/scripts.nix b/packages/scripts.nix index 35f7bd0eca1..b1ccf131f7f 100644 --- a/packages/scripts.nix +++ b/packages/scripts.nix @@ -54,6 +54,8 @@ lib.makeScope pkgs.newScope (scripts: { nix-update --version=skip --flake legacyPackages.x86_64-linux.base.igvm-go echo "Updating vendorHash of snp-id-block-generator package" >&2 nix-update --version=skip --flake legacyPackages.x86_64-linux.base.snp-id-block-generator + echo "Updating vendorHash of sev-snp-measure package" >&2 + nix-update --version=skip --flake legacyPackages.x86_64-linux.base.kata.sev-snp-measure echo "Updating imagepuller package" >&2 nix-update --version=skip --flake legacyPackages.x86_64-linux.base.imagepuller echo "Updating imagestore package" >&2 diff --git a/tools/sev-snp-measure-go/go.mod b/tools/sev-snp-measure-go/go.mod new file mode 100644 index 00000000000..4e5358e3c22 --- /dev/null +++ b/tools/sev-snp-measure-go/go.mod @@ -0,0 +1,15 @@ +module github.com/edgelesssys/contrast/sev-snp-measure-go + +go 1.25.6 + +replace github.com/edgelesssys/contrast => ../.. + +require ( + github.com/edgelesssys/contrast v0.0.0-00010101000000-000000000000 + github.com/spf13/cobra v1.10.2 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.10 // indirect +) diff --git a/tools/sev-snp-measure-go/go.sum b/tools/sev-snp-measure-go/go.sum new file mode 100644 index 00000000000..ef5d78dd283 --- /dev/null +++ b/tools/sev-snp-measure-go/go.sum @@ -0,0 +1,11 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/tools/sev-snp-measure-go/main.go b/tools/sev-snp-measure-go/main.go new file mode 100644 index 00000000000..fe67d629a7f --- /dev/null +++ b/tools/sev-snp-measure-go/main.go @@ -0,0 +1,109 @@ +// Copyright 2026 Edgeless Systems GmbH +// SPDX-License-Identifier: BUSL-1.1 + +// sev-snp-measure computes the AMD SEV-SNP launch digest for a guest firmware image. +// This is a Go reimplementation of the Python sev-snp-measure tool, covering the +// subset of flags required for Kata Containers SNP measurement (--mode snp, VMM QEMU). +package main + +import ( + "encoding/hex" + "fmt" + "os" + "strings" + + "github.com/edgelesssys/contrast/internal/snp" + "github.com/spf13/cobra" +) + +func main() { + if err := newRootCmd().Execute(); err != nil { + os.Exit(1) + } +} + +func newRootCmd() *cobra.Command { + var ( + ovmfPath string + kernelPath string + initrdPath string + appendStr string + vcpus int + vcpuType string + vcpuSig uint32 + guestFeatures uint64 + outputFormat string + ) + + cmd := &cobra.Command{ + Use: "sev-snp-measure", + Short: "Calculate AMD SEV-SNP guest launch measurement", + Long: `sev-snp-measure calculates the AMD SEV-SNP launch digest for a guest. + +Only --mode snp with VMM type QEMU is supported (the minimum required for +Kata Containers SNP measurement). Output is the hex-encoded SHA-384 digest.`, + RunE: func(_ *cobra.Command, _ []string) error { + // Resolve vCPU signature. + var resolvedSig uint32 + switch { + case vcpuSig != 0: + resolvedSig = vcpuSig + case vcpuType != "": + sig, err := snp.LookupCPUSig(vcpuType) + if err != nil { + return err + } + resolvedSig = sig + default: + return fmt.Errorf("--vcpu-type or --vcpu-sig is required") + } + + if vcpus <= 0 { + return fmt.Errorf("--vcpus must be a positive integer") + } + if ovmfPath == "" { + return fmt.Errorf("--ovmf is required") + } + + digest, err := snp.CalcSNPLaunchDigest( + ovmfPath, vcpus, resolvedSig, + kernelPath, initrdPath, appendStr, + guestFeatures, + ) + if err != nil { + return err + } + + switch outputFormat { + case "hex": + fmt.Println(hex.EncodeToString(digest[:])) + default: + return fmt.Errorf("unsupported output format %q (only \"hex\" is supported)", outputFormat) + } + return nil + }, + } + + f := cmd.Flags() + f.StringVar(&ovmfPath, "ovmf", "", "Path to OVMF firmware binary (required)") + f.StringVar(&kernelPath, "kernel", "", "Path to kernel bzImage") + f.StringVar(&initrdPath, "initrd", "", "Path to initrd (use with --kernel)") + f.StringVar(&appendStr, "append", "", "Kernel command line (use with --kernel)") + f.IntVar(&vcpus, "vcpus", 0, "Number of guest vCPUs (required)") + f.StringVar(&vcpuType, "vcpu-type", "", "vCPU type (e.g. EPYC-Milan, EPYC-Genoa)") + f.Uint32Var(&vcpuSig, "vcpu-sig", 0, "vCPU CPUID signature value (alternative to --vcpu-type)") + f.Uint64Var(&guestFeatures, "guest-features", 0x1, "Guest feature flags (hex, e.g. 0x1)") + f.StringVar(&outputFormat, "output-format", "hex", "Output format: hex") + + // --mode is accepted for compatibility with the Python tool but must be "snp". + var mode string + f.StringVar(&mode, "mode", "snp", "Guest mode (only \"snp\" is supported)") + cmd.PreRunE = func(_ *cobra.Command, _ []string) error { + if strings.ToLower(mode) != "snp" { + return fmt.Errorf("only --mode snp is supported (got %q)", mode) + } + return nil + } + + return cmd +}