diff --git a/README.md b/README.md index 45aeeba6..1a8b71af 100644 --- a/README.md +++ b/README.md @@ -1236,6 +1236,53 @@ Example output from my personal workstation: watchdog present: true ``` +### TPM (Linux only) + +The `ghw.TPM()` function returns a `ghw.TPMInfo` struct that contains +information about the Trusted Platform Module(s) (TPM) on the host system. + +The `ghw.TPMInfo` struct contains a `Devices` field: + +* `ghw.TPMInfo.Devices` is a slice of pointers to `ghw.TPMDevice` structs, one + for each TPM detected under `/sys/class/tpm` + +Each `ghw.TPMDevice` struct contains multiple fields: + +* `ghw.TPMDevice.ManufacturerName` is a string with the human-readable TPM + manufacturer short name (e.g. `AMD`, `IFX` for Infineon, `STM` for + STMicroelectronics) +* `ghw.TPMDevice.ManufacturerVendorID` is a string with the 16-bit TCG Vendor + ID assigned to the manufacturer, when the host exposes it (e.g. `0x1414`) +* `ghw.TPMDevice.FirmwareVersion` is a string with the TPM firmware version, if + exposed by the driver +* `ghw.TPMDevice.SpecVersion` is a string with the TCG specification version the + TPM implements (e.g. `1.2` or `2.0`) + +```go +package main + +import ( + "fmt" + + "github.com/jaypipes/ghw" +) + +func main() { + tpm, err := ghw.TPM() + if err != nil { + fmt.Printf("Error getting TPM info: %v", err) + } + + fmt.Printf("%v\n", tpm) +} +``` + +Example output from my personal workstation: + +``` +tpm manufacturer_name=STM manufacturer_vendor_id= firmware_version= spec_version=2.0 +``` + ## Advanced Usage ### Disabling warning messages diff --git a/alias.go b/alias.go index 62cfaf49..f80f76e7 100644 --- a/alias.go +++ b/alias.go @@ -22,6 +22,7 @@ import ( pciaddress "github.com/jaypipes/ghw/pkg/pci/address" "github.com/jaypipes/ghw/pkg/product" "github.com/jaypipes/ghw/pkg/topology" + "github.com/jaypipes/ghw/pkg/tpm" "github.com/jaypipes/ghw/pkg/usb" "github.com/jaypipes/ghw/pkg/watchdog" ) @@ -206,3 +207,10 @@ type WatchdogInfo = watchdog.Info var ( Watchdog = watchdog.New ) + +type TPMInfo = tpm.Info +type TPMDevice = tpm.Device + +var ( + TPM = tpm.New +) diff --git a/cmd/ghwc/commands/root.go b/cmd/ghwc/commands/root.go index f718fe23..4404306f 100644 --- a/cmd/ghwc/commands/root.go +++ b/cmd/ghwc/commands/root.go @@ -106,6 +106,7 @@ func showAll(cmd *cobra.Command, args []string) error { showAccelerator, showUSB, showWatchdog, + showTPM, } { err := f(cmd, args) if err != nil { diff --git a/cmd/ghwc/commands/tpm.go b/cmd/ghwc/commands/tpm.go new file mode 100644 index 00000000..ff940615 --- /dev/null +++ b/cmd/ghwc/commands/tpm.go @@ -0,0 +1,43 @@ +// +// Use and distribution licensed under the Apache license version 2. +// +// See the COPYING file in the root project directory for full text. +// + +package commands + +import ( + "fmt" + + "github.com/jaypipes/ghw" + "github.com/spf13/cobra" +) + +// tpmCmd represents the `tpm` command +var tpmCmd = &cobra.Command{ + Use: "tpm", + Short: "Show TPM information for the host system", + RunE: showTPM, +} + +// showTPM shows TPM information for the host system. +func showTPM(cmd *cobra.Command, args []string) error { + tpm, err := ghw.TPM(cmd.Context()) + if err != nil { + return fmt.Errorf("error getting TPM info: %w", err) + } + + switch outputFormat { + case outputFormatHuman: + fmt.Printf("%v\n", tpm) + case outputFormatJSON: + fmt.Printf("%s\n", tpm.JSONString(pretty)) + case outputFormatYAML: + fmt.Printf("%s", tpm.YAMLString()) + } + return nil +} + +func init() { + rootCmd.AddCommand(tpmCmd) +} diff --git a/host.go b/host.go index 5361e627..26bb9e69 100644 --- a/host.go +++ b/host.go @@ -23,6 +23,7 @@ import ( "github.com/jaypipes/ghw/pkg/pci" "github.com/jaypipes/ghw/pkg/product" "github.com/jaypipes/ghw/pkg/topology" + "github.com/jaypipes/ghw/pkg/tpm" "github.com/jaypipes/ghw/pkg/usb" "github.com/jaypipes/ghw/pkg/watchdog" ) @@ -44,6 +45,7 @@ type HostInfo struct { PCI *pci.Info `json:"pci"` USB *usb.Info `json:"usb"` Watchdog *watchdog.Info `json:"watchdog"` + TPM *tpm.Info `json:"tpm"` } // Host returns a pointer to a HostInfo struct that contains fields with @@ -106,6 +108,10 @@ func Host(args ...any) (*HostInfo, error) { if err != nil { return nil, err } + tpmInfo, err := tpm.New(ctx) + if err != nil { + return nil, err + } return &HostInfo{ CPU: cpuInfo, @@ -122,6 +128,7 @@ func Host(args ...any) (*HostInfo, error) { PCI: pciInfo, USB: usbInfo, Watchdog: watchdogInfo, + TPM: tpmInfo, }, nil } @@ -129,7 +136,7 @@ func Host(args ...any) (*HostInfo, error) { // structs' String-ified output func (info *HostInfo) String() string { return fmt.Sprintf( - "%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n", + "%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n%s\n", info.Block.String(), info.CPU.String(), info.GPU.String(), @@ -144,6 +151,7 @@ func (info *HostInfo) String() string { info.PCI.String(), info.USB.String(), info.Watchdog.String(), + info.TPM.String(), ) } diff --git a/pkg/linuxpath/path_linux.go b/pkg/linuxpath/path_linux.go index b0973874..bd118d63 100644 --- a/pkg/linuxpath/path_linux.go +++ b/pkg/linuxpath/path_linux.go @@ -78,6 +78,7 @@ type Paths struct { SysClassDRM string SysClassDMI string SysClassNet string + SysClassTPM string SysClassWatchdog string SysFirmwareDeviceTree string RunUdevData string @@ -105,6 +106,7 @@ func New(ctx context.Context) *Paths { SysClassDRM: filepath.Join(chroot, roots.Sys, "class", "drm"), SysClassDMI: filepath.Join(chroot, roots.Sys, "class", "dmi"), SysClassNet: filepath.Join(chroot, roots.Sys, "class", "net"), + SysClassTPM: filepath.Join(chroot, roots.Sys, "class", "tpm"), SysClassWatchdog: filepath.Join(chroot, roots.Sys, "class", "watchdog"), SysFirmwareDeviceTree: filepath.Join(chroot, roots.Sys, "firmware", "devicetree", "base"), RunUdevData: filepath.Join(chroot, roots.Run, "udev", "data"), diff --git a/pkg/snapshot/clonetree_linux.go b/pkg/snapshot/clonetree_linux.go index 5d1dba0e..b174bef3 100644 --- a/pkg/snapshot/clonetree_linux.go +++ b/pkg/snapshot/clonetree_linux.go @@ -52,6 +52,9 @@ func ExpectedCloneStaticContent() []string { "/sys/devices/system/node/node*/memory*", "/sys/devices/system/node/node*/hugepages/hugepages-*/*", "/sys/class/watchdog/*", + "/sys/class/tpm/tpm*/caps", + "/sys/class/tpm/tpm*/tpm_version_major", + "/sys/class/tpm/tpm*/device/vendor", } } diff --git a/pkg/tpm/tpm.go b/pkg/tpm/tpm.go new file mode 100644 index 00000000..a41703aa --- /dev/null +++ b/pkg/tpm/tpm.go @@ -0,0 +1,91 @@ +// +// Use and distribution licensed under the Apache license version 2. +// +// See the COPYING file in the root project directory for full text. +// + +package tpm + +import ( + "fmt" + + "github.com/jaypipes/ghw/internal/config" + "github.com/jaypipes/ghw/pkg/marshal" +) + +// Device describes a single Trusted Platform Module (TPM) present on the host +// system. +type Device struct { + // ManufacturerName is the human-readable name of the TPM manufacturer, + // decoded from the four-character short name the TPM reports (e.g. "AMD", + // "IFX" for Infineon, "STM" for STMicroelectronics). + ManufacturerName string `json:"manufacturer_name"` + // ManufacturerVendorID is the 16-bit TCG Vendor ID assigned to the TPM + // manufacturer by the Trusted Computing Group. Note that this is a + // separate namespace from the PCI vendor ID. It may be empty when the + // host only exposes the manufacturer's short name. + // See: https://trustedcomputinggroup.org/resource/vendor-id-registry/ + ManufacturerVendorID string `json:"manufacturer_vendor_id"` + // SpecVersion is the TCG TPM library (specification) version the device + // implements, i.e. the ISO/IEC 11889 version, e.g. "1.2" or "2.0". + SpecVersion string `json:"spec_version"` + // FirmwareVersion is the TPM firmware version, when the host exposes it. + FirmwareVersion string `json:"firmware_version"` +} + +// String returns a short string describing the TPM device. +func (d *Device) String() string { + return fmt.Sprintf( + "manufacturer_name=%s manufacturer_vendor_id=%s firmware_version=%s spec_version=%s", + d.ManufacturerName, d.ManufacturerVendorID, d.FirmwareVersion, d.SpecVersion, + ) +} + +// Info describes the Trusted Platform Module(s) on the host system. +type Info struct { + // Devices are the TPM devices detected under /sys/class/tpm. A host can, + // in principle, expose more than one TPM device. + Devices []*Device `json:"devices"` +} + +// String returns a short string with summary information about the TPM(s) on +// the host system. +func (i *Info) String() string { + switch len(i.Devices) { + case 0: + return "tpm (no devices)" + case 1: + return "tpm " + i.Devices[0].String() + default: + return fmt.Sprintf("tpm (%d devices)", len(i.Devices)) + } +} + +// New returns a pointer to an Info struct that contains information about +// the TPM(s) on the host system. +func New(args ...any) (*Info, error) { + ctx := config.ContextFromArgs(args...) + info := &Info{} + if err := info.load(ctx); err != nil { + return nil, err + } + return info, nil +} + +// simple private struct used to encapsulate TPM information in a top-level +// "tpm" YAML/JSON map/object key +type tpmPrinter struct { + Info *Info `json:"tpm"` +} + +// YAMLString returns a string with the TPM information formatted as YAML +// under a top-level "tpm:" key +func (i *Info) YAMLString() string { + return marshal.SafeYAML(tpmPrinter{i}) +} + +// JSONString returns a string with the TPM information formatted as JSON +// under a top-level "tpm:" key +func (i *Info) JSONString(indent bool) string { + return marshal.SafeJSON(tpmPrinter{i}, indent) +} diff --git a/pkg/tpm/tpm_linux.go b/pkg/tpm/tpm_linux.go new file mode 100644 index 00000000..7b982269 --- /dev/null +++ b/pkg/tpm/tpm_linux.go @@ -0,0 +1,146 @@ +// +// Use and distribution licensed under the Apache license version 2. +// +// See the COPYING file in the root project directory for full text. +// + +package tpm + +import ( + "bufio" + "context" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/jaypipes/ghw/pkg/linuxpath" + "github.com/jaypipes/ghw/pkg/util" +) + +func (i *Info) load(ctx context.Context) error { + paths := linuxpath.New(ctx) + + entries, err := os.ReadDir(paths.SysClassTPM) + if err != nil { + // No /sys/class/tpm directory at all means no TPM devices. + return nil + } + + for _, entry := range entries { + // TPM device directories are named tpm0, tpm1, ... A system can + // expose more than one TPM device, so we enumerate all of them + // rather than assuming a single "tpm0". + if !isTPMDeviceName(entry.Name()) { + continue + } + dev := &Device{} + dev.load(ctx, filepath.Join(paths.SysClassTPM, entry.Name())) + i.Devices = append(i.Devices, dev) + } + return nil +} + +// isTPMDeviceName reports whether name looks like a TPM device directory +// (tpm0, tpm1, ...). It deliberately excludes the resource-manager entries +// (e.g. tpmrm0) that share the tpm prefix. +func isTPMDeviceName(name string) bool { + digits, ok := strings.CutPrefix(name, "tpm") + if !ok || digits == "" { + return false + } + _, err := strconv.Atoi(digits) + return err == nil +} + +// load populates the Device from the sysfs directory of a single TPM device +// (e.g. /sys/class/tpm/tpm0). sysfs has never been standardized in this area, +// so every source consulted here is best-effort. +func (d *Device) load(ctx context.Context, tpmPath string) { + // Both TPM 1.2 and (some) TPM 2.0 drivers may expose a `caps` file with + // manufacturer, spec and firmware version information. On TPM 2.0, when a + // `caps` file is present, it does not report the spec version; that is + // filled in separately below. + if file, err := os.Open(filepath.Join(tpmPath, "caps")); err == nil { + defer util.SafeClose(file) + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + parts := strings.SplitN(line, ":", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.TrimSpace(parts[1]) + + switch key { + case "Manufacturer": + d.setManufacturer(val) + case "TCG version": + d.SpecVersion = val + case "Firmware version": + d.FirmwareVersion = val + } + } + } + + if d.ManufacturerName == "" && d.ManufacturerVendorID == "" { + if b, err := os.ReadFile(filepath.Join(tpmPath, "device", "vendor")); err == nil { + d.setManufacturer(strings.TrimSpace(string(b))) + } + } + + // TPM 2.0 devices expose the major spec version separately (kernel >= 5.5) + // and generally do not report it via the `caps` file. + if d.SpecVersion == "" { + versionMajor := util.SafeIntFromFile( + ctx, filepath.Join(tpmPath, "tpm_version_major"), + ) + if versionMajor > 0 { + d.SpecVersion = strconv.Itoa(versionMajor) + ".0" + } + } +} + +// setManufacturer parses a raw manufacturer value read from sysfs and records +// it as either a human-readable name or a TCG Vendor ID. +// +// The kernel exposes this value inconsistently. It may be: +// - a hex-encoded 32-bit value that is really a four-character ASCII short +// name, e.g. "0x414D4400" -> "AMD" or "0x49465800" -> "IFX" (Infineon); +// - a hex-encoded 16-bit value that is the TCG Vendor ID, e.g. "0x1414" +// (Microsoft) or "0x15D1" (Infineon); +// - a plain string name, e.g. "STM". +func (d *Device) setManufacturer(raw string) { + if raw == "" { + return + } + hexStr := strings.TrimPrefix(strings.TrimPrefix(raw, "0x"), "0X") + if n, err := strconv.ParseUint(hexStr, 16, 64); err == nil { + switch len(hexStr) { + case 4: // 16 bits: the TCG Vendor ID + d.ManufacturerVendorID = "0x" + strings.ToUpper(hexStr) + return + case 8: // 32 bits: a four-character ASCII short name + if name := fourCC(uint32(n)); name != "" { + d.ManufacturerName = name + return + } + } + } + // Fall back to treating the value as a plain manufacturer name. + d.ManufacturerName = raw +} + +// fourCC decodes a 32-bit value as a four-character ASCII string, trimming +// trailing NUL and space padding. It returns "" if the bytes are not printable +// ASCII, in which case the value is not a short name. +func fourCC(v uint32) string { + b := []byte{byte(v >> 24), byte(v >> 16), byte(v >> 8), byte(v)} + for _, c := range b { + if c != 0 && (c < 0x20 || c > 0x7e) { + return "" + } + } + return strings.TrimRight(string(b), " \x00") +} diff --git a/pkg/tpm/tpm_linux_test.go b/pkg/tpm/tpm_linux_test.go new file mode 100644 index 00000000..d1ca893a --- /dev/null +++ b/pkg/tpm/tpm_linux_test.go @@ -0,0 +1,161 @@ +// +// Use and distribution licensed under the Apache license version 2. +// +// See the COPYING file in the root project directory for full text. +// + +package tpm_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/jaypipes/ghw" + "github.com/jaypipes/ghw/pkg/tpm" +) + +// makeTPMDir creates /sys/class/tpm/ and returns its path. +func makeTPMDir(t *testing.T, root, name string) string { + t.Helper() + tpmDir := filepath.Join(root, "sys", "class", "tpm", name) + if err := os.MkdirAll(tpmDir, 0700); err != nil { + t.Fatalf("failed to create tpm sysfs dir: %v", err) + } + return tpmDir +} + +func TestTPM12Caps(t *testing.T) { + root := t.TempDir() + tpmDir := makeTPMDir(t, root, "tpm0") + + // 0x49465800 is the hex-encoded ASCII short name "IFX" (Infineon). + caps := `Manufacturer: 0x49465800 +TCG version: 1.2 +Firmware version: 6.40 +` + if err := os.WriteFile(filepath.Join(tpmDir, "caps"), []byte(caps), 0600); err != nil { + t.Fatalf("failed to write caps: %v", err) + } + + info, err := tpm.New(ghw.WithChroot(root)) + if err != nil { + t.Fatalf("expected nil err, but got %v", err) + } + if len(info.Devices) != 1 { + t.Fatalf("expected 1 TPM device, but got %d", len(info.Devices)) + } + dev := info.Devices[0] + if dev.ManufacturerName != "IFX" { + t.Errorf("unexpected manufacturer name: %q", dev.ManufacturerName) + } + if dev.SpecVersion != "1.2" { + t.Errorf("unexpected spec version: %q", dev.SpecVersion) + } + if dev.FirmwareVersion != "6.40" { + t.Errorf("unexpected firmware version: %q", dev.FirmwareVersion) + } +} + +func TestTPM20VersionMajor(t *testing.T) { + root := t.TempDir() + tpmDir := makeTPMDir(t, root, "tpm0") + + // TPM 2.0 exposes no caps file; the manufacturer comes from the device + // vendor attribute and the spec version from tpm_version_major. + deviceDir := filepath.Join(tpmDir, "device") + if err := os.MkdirAll(deviceDir, 0700); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + if err := os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("STM\n"), 0600); err != nil { + t.Fatalf("failed to write vendor: %v", err) + } + if err := os.WriteFile(filepath.Join(tpmDir, "tpm_version_major"), []byte("2\n"), 0600); err != nil { + t.Fatalf("failed to write tpm_version_major: %v", err) + } + + info, err := tpm.New(ghw.WithChroot(root)) + if err != nil { + t.Fatalf("expected nil err, but got %v", err) + } + if len(info.Devices) != 1 { + t.Fatalf("expected 1 TPM device, but got %d", len(info.Devices)) + } + dev := info.Devices[0] + if dev.ManufacturerName != "STM" { + t.Errorf("unexpected manufacturer name: %q", dev.ManufacturerName) + } + if dev.SpecVersion != "2.0" { + t.Errorf("unexpected spec version: %q", dev.SpecVersion) + } +} + +func TestTPM20VendorID(t *testing.T) { + root := t.TempDir() + tpmDir := makeTPMDir(t, root, "tpm0") + + // A 16-bit hex-encoded vendor attribute is the TCG Vendor ID (0x1414 is + // Microsoft), not a manufacturer short name. + deviceDir := filepath.Join(tpmDir, "device") + if err := os.MkdirAll(deviceDir, 0700); err != nil { + t.Fatalf("failed to create device dir: %v", err) + } + if err := os.WriteFile(filepath.Join(deviceDir, "vendor"), []byte("0x1414\n"), 0600); err != nil { + t.Fatalf("failed to write vendor: %v", err) + } + if err := os.WriteFile(filepath.Join(tpmDir, "tpm_version_major"), []byte("2\n"), 0600); err != nil { + t.Fatalf("failed to write tpm_version_major: %v", err) + } + + info, err := tpm.New(ghw.WithChroot(root)) + if err != nil { + t.Fatalf("expected nil err, but got %v", err) + } + if len(info.Devices) != 1 { + t.Fatalf("expected 1 TPM device, but got %d", len(info.Devices)) + } + dev := info.Devices[0] + if dev.ManufacturerVendorID != "0x1414" { + t.Errorf("unexpected manufacturer vendor id: %q", dev.ManufacturerVendorID) + } + if dev.ManufacturerName != "" { + t.Errorf("expected empty manufacturer name, but got %q", dev.ManufacturerName) + } +} + +func TestMultipleTPMDevices(t *testing.T) { + root := t.TempDir() + // A tpm0 with a version file plus a bare tpm1: both should be enumerated. + tpm0 := makeTPMDir(t, root, "tpm0") + if err := os.WriteFile(filepath.Join(tpm0, "tpm_version_major"), []byte("2\n"), 0600); err != nil { + t.Fatalf("failed to write tpm_version_major: %v", err) + } + makeTPMDir(t, root, "tpm1") + // A resource-manager entry sharing the tpm prefix must be ignored. + makeTPMDir(t, root, "tpmrm0") + + info, err := tpm.New(ghw.WithChroot(root)) + if err != nil { + t.Fatalf("expected nil err, but got %v", err) + } + if len(info.Devices) != 2 { + t.Fatalf("expected 2 TPM devices, but got %d", len(info.Devices)) + } +} + +func TestTPMAbsent(t *testing.T) { + root := t.TempDir() + + // An empty tpm class directory without any tpmN entry means no TPM. + if err := os.MkdirAll(filepath.Join(root, "sys", "class", "tpm"), 0700); err != nil { + t.Fatalf("failed to create tpm sysfs dir: %v", err) + } + + info, err := tpm.New(ghw.WithChroot(root)) + if err != nil { + t.Fatalf("expected nil err, but got %v", err) + } + if len(info.Devices) != 0 { + t.Errorf("expected no TPM devices, but got %d", len(info.Devices)) + } +} diff --git a/pkg/tpm/tpm_stub.go b/pkg/tpm/tpm_stub.go new file mode 100644 index 00000000..9ef88ad4 --- /dev/null +++ b/pkg/tpm/tpm_stub.go @@ -0,0 +1,19 @@ +//go:build !linux +// +build !linux + +// Use and distribution licensed under the Apache license version 2. +// +// See the COPYING file in the root project directory for full text. +// + +package tpm + +import ( + "context" + "errors" + "runtime" +) + +func (i *Info) load(ctx context.Context) error { + return errors.New("tpm load not implemented on " + runtime.GOOS) +}