From a4f70b17f022c791b6d69d51d1c5e584bd84e1e0 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Thu, 5 Mar 2026 10:32:12 +0300 Subject: [PATCH] =?UTF-8?q?feat(audit):=201.1=20=E2=80=94=20project=20scaf?= =?UTF-8?q?fold,=20schema=20types,=20collector=20stub,=20updater=20trust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - go.mod: module bee/audit - schema/hardware.go: HardwareIngestRequest types (compatible with core) - collector/collector.go: Run() stub, logs start/finish, returns empty snapshot - updater/trust.go: Ed25519 multi-key verification via ldflags injection - updater/trust_test.go: valid sig, tampered, multi-key any-match, dev build - cmd/audit/main.go: --output stdout|file:|usb, --version flag - Version = "dev" by default, injected via ldflags at release Co-Authored-By: Claude Sonnet 4.6 --- .gitmodules | 3 + audit/cmd/audit/main.go | 78 ++++++++++++++++++ audit/go.mod | 3 + audit/internal/collector/collector.go | 33 ++++++++ audit/internal/schema/hardware.go | 113 ++++++++++++++++++++++++++ audit/internal/updater/trust.go | 59 ++++++++++++++ audit/internal/updater/trust_test.go | 72 ++++++++++++++++ bible | 1 + 8 files changed, 362 insertions(+) create mode 100644 .gitmodules create mode 100644 audit/cmd/audit/main.go create mode 100644 audit/go.mod create mode 100644 audit/internal/collector/collector.go create mode 100644 audit/internal/schema/hardware.go create mode 100644 audit/internal/updater/trust.go create mode 100644 audit/internal/updater/trust_test.go create mode 160000 bible diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fa8f9e7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "bible"] + path = bible + url = https://git.mchus.pro/mchus/bible.git diff --git a/audit/cmd/audit/main.go b/audit/cmd/audit/main.go new file mode 100644 index 0000000..019b685 --- /dev/null +++ b/audit/cmd/audit/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "os" + "strings" + + "bee/audit/internal/collector" +) + +// Version is the audit binary version. +// Injected at release build time via: +// +// -ldflags "-X main.Version=1.2" +// +// Defaults to "dev" in local builds. +var Version = "dev" + +func main() { + output := flag.String("output", "stdout", `output destination: + stdout — print JSON to stdout (default) + file: — write JSON to file + usb — auto-detect removable media, write JSON there`) + showVersion := flag.Bool("version", false, "print version and exit") + flag.Parse() + + if *showVersion { + fmt.Println(Version) + return + } + + slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + Level: slog.LevelInfo, + }))) + + result := collector.Run() + + data, err := json.MarshalIndent(result, "", " ") + if err != nil { + slog.Error("marshal result", "err", err) + os.Exit(1) + } + + if err := writeOutput(*output, data); err != nil { + slog.Error("write output", "destination", *output, "err", err) + os.Exit(1) + } +} + +func writeOutput(dest string, data []byte) error { + switch { + case dest == "stdout": + _, err := os.Stdout.Write(append(data, '\n')) + return err + + case strings.HasPrefix(dest, "file:"): + path := strings.TrimPrefix(dest, "file:") + return os.WriteFile(path, append(data, '\n'), 0644) + + case dest == "usb": + return writeToUSB(data) + + default: + return fmt.Errorf("unknown output destination %q — use stdout, file:, or usb", dest) + } +} + +// writeToUSB auto-detects the first removable block device, mounts it, +// and writes the audit JSON. Falls back to /tmp on any failure. +func writeToUSB(data []byte) error { + // implemented in step 1.11 + slog.Warn("usb output not yet implemented, falling back to stdout") + _, err := os.Stdout.Write(append(data, '\n')) + return err +} diff --git a/audit/go.mod b/audit/go.mod new file mode 100644 index 0000000..be9aff9 --- /dev/null +++ b/audit/go.mod @@ -0,0 +1,3 @@ +module bee/audit + +go 1.23 diff --git a/audit/internal/collector/collector.go b/audit/internal/collector/collector.go new file mode 100644 index 0000000..0f2587b --- /dev/null +++ b/audit/internal/collector/collector.go @@ -0,0 +1,33 @@ +// Package collector runs all hardware collectors and merges results +// into a single HardwareSnapshot. Each sub-collector is independent: +// a failure in one does not abort the others. +package collector + +import ( + "bee/audit/internal/schema" + "log/slog" + "time" +) + +// Run executes all collectors and returns the combined snapshot. +// Partial failures are logged as warnings; collection always completes. +func Run() schema.HardwareIngestRequest { + start := time.Now() + slog.Info("audit started") + + snap := schema.HardwareSnapshot{} + + // collectors are added here in subsequent steps (1.2 – 1.10) + + slog.Info("audit completed", "duration", time.Since(start).Round(time.Millisecond)) + + sourceType := "livcd" + protocol := "os-direct" + + return schema.HardwareIngestRequest{ + SourceType: &sourceType, + Protocol: &protocol, + CollectedAt: time.Now().UTC().Format(time.RFC3339), + Hardware: snap, + } +} diff --git a/audit/internal/schema/hardware.go b/audit/internal/schema/hardware.go new file mode 100644 index 0000000..e912637 --- /dev/null +++ b/audit/internal/schema/hardware.go @@ -0,0 +1,113 @@ +// Package schema defines the HardwareIngestRequest types compatible with +// core/internal/ingest/parser_hardware.go. No import dependency on core. +package schema + +// HardwareIngestRequest is the top-level output document produced by the audit binary. +// It is accepted as-is by the core /api/ingest/hardware endpoint. +type HardwareIngestRequest struct { + Filename *string `json:"filename"` + SourceType *string `json:"source_type"` + Protocol *string `json:"protocol"` + TargetHost string `json:"target_host"` + CollectedAt string `json:"collected_at"` + Hardware HardwareSnapshot `json:"hardware"` +} + +type HardwareSnapshot struct { + Board HardwareBoard `json:"board"` + Firmware []HardwareFirmwareRecord `json:"firmware,omitempty"` + CPUs []HardwareCPU `json:"cpus,omitempty"` + Memory []HardwareMemory `json:"memory,omitempty"` + Storage []HardwareStorage `json:"storage,omitempty"` + PCIeDevices []HardwarePCIeDevice `json:"pcie_devices,omitempty"` + PowerSupplies []HardwarePowerSupply `json:"power_supplies,omitempty"` +} + +type HardwareBoard struct { + Manufacturer *string `json:"manufacturer"` + ProductName *string `json:"product_name"` + SerialNumber string `json:"serial_number"` + PartNumber *string `json:"part_number"` + UUID *string `json:"uuid"` +} + +type HardwareFirmwareRecord struct { + DeviceName string `json:"device_name"` + Version string `json:"version"` +} + +type HardwareCPU struct { + Socket *int `json:"socket"` + Model *string `json:"model"` + Manufacturer *string `json:"manufacturer"` + Status *string `json:"status"` + SerialNumber *string `json:"serial_number"` + Firmware *string `json:"firmware"` + Cores *int `json:"cores"` + Threads *int `json:"threads"` + FrequencyMHz *int `json:"frequency_mhz"` + MaxFrequencyMHz *int `json:"max_frequency_mhz"` +} + +type HardwareMemory struct { + Slot *string `json:"slot"` + Location *string `json:"location"` + Present *bool `json:"present"` + SizeMB *int `json:"size_mb"` + Type *string `json:"type"` + MaxSpeedMHz *int `json:"max_speed_mhz"` + CurrentSpeedMHz *int `json:"current_speed_mhz"` + Manufacturer *string `json:"manufacturer"` + SerialNumber *string `json:"serial_number"` + PartNumber *string `json:"part_number"` + Status *string `json:"status"` +} + +type HardwareStorage struct { + Slot *string `json:"slot"` + Type *string `json:"type"` + Model *string `json:"model"` + SizeGB *int `json:"size_gb"` + SerialNumber *string `json:"serial_number"` + Manufacturer *string `json:"manufacturer"` + Firmware *string `json:"firmware"` + Interface *string `json:"interface"` + Present *bool `json:"present"` + Status *string `json:"status"` + Telemetry map[string]any `json:"telemetry,omitempty"` +} + +type HardwarePCIeDevice struct { + Slot *string `json:"slot"` + VendorID *int `json:"vendor_id"` + DeviceID *int `json:"device_id"` + BDF *string `json:"bdf"` + DeviceClass *string `json:"device_class"` + Manufacturer *string `json:"manufacturer"` + Model *string `json:"model"` + LinkWidth *int `json:"link_width"` + LinkSpeed *string `json:"link_speed"` + MaxLinkWidth *int `json:"max_link_width"` + MaxLinkSpeed *string `json:"max_link_speed"` + SerialNumber *string `json:"serial_number"` + Firmware *string `json:"firmware"` + Present *bool `json:"present"` + Status *string `json:"status"` + Telemetry map[string]any `json:"telemetry,omitempty"` +} + +type HardwarePowerSupply struct { + Slot *string `json:"slot"` + Present *bool `json:"present"` + Model *string `json:"model"` + Vendor *string `json:"vendor"` + WattageW *int `json:"wattage_w"` + SerialNumber *string `json:"serial_number"` + PartNumber *string `json:"part_number"` + Firmware *string `json:"firmware"` + Status *string `json:"status"` + InputType *string `json:"input_type"` + InputPowerW *float64 `json:"input_power_w"` + OutputPowerW *float64 `json:"output_power_w"` + InputVoltage *float64 `json:"input_voltage"` +} diff --git a/audit/internal/updater/trust.go b/audit/internal/updater/trust.go new file mode 100644 index 0000000..d0d75cd --- /dev/null +++ b/audit/internal/updater/trust.go @@ -0,0 +1,59 @@ +// Package updater handles binary self-update with Ed25519 signature verification. +// See bible/rules/patterns/release-signing/contract.md for the full contract. +package updater + +import ( + "crypto/ed25519" + "encoding/base64" + "fmt" + "strings" +) + +// trustedKeysRaw is injected at release build time via: +// +// -ldflags "-X bee/audit/internal/updater.trustedKeysRaw=base64key1:base64key2" +// +// Empty in dev builds — updates are disabled when empty. +var trustedKeysRaw string + +// TrustedKeys decodes the embedded public keys. +// Returns an error if the binary was built without key injection (dev build). +func TrustedKeys() ([]ed25519.PublicKey, error) { + if strings.TrimSpace(trustedKeysRaw) == "" { + return nil, fmt.Errorf("dev build: trusted keys not embedded, updates disabled") + } + var keys []ed25519.PublicKey + for _, enc := range strings.Split(trustedKeysRaw, ":") { + enc = strings.TrimSpace(enc) + if enc == "" { + continue + } + b, err := base64.StdEncoding.DecodeString(enc) + if err != nil { + return nil, fmt.Errorf("decode trusted key: %w", err) + } + if len(b) != ed25519.PublicKeySize { + return nil, fmt.Errorf("trusted key has wrong size: got %d, want %d", len(b), ed25519.PublicKeySize) + } + keys = append(keys, ed25519.PublicKey(b)) + } + if len(keys) == 0 { + return nil, fmt.Errorf("no valid trusted keys found in build") + } + return keys, nil +} + +// VerifySignature returns nil if sig was produced by any trusted key over data. +// Returns an error (never panics) on any failure — caller logs and continues. +func VerifySignature(data, sig []byte) error { + keys, err := TrustedKeys() + if err != nil { + return err + } + for _, key := range keys { + if ed25519.Verify(key, data, sig) { + return nil + } + } + return fmt.Errorf("signature verification failed: no trusted key matched") +} diff --git a/audit/internal/updater/trust_test.go b/audit/internal/updater/trust_test.go new file mode 100644 index 0000000..10e889b --- /dev/null +++ b/audit/internal/updater/trust_test.go @@ -0,0 +1,72 @@ +package updater + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "testing" +) + +func TestVerifySignature_valid(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + + data := []byte("release binary content") + sig := ed25519.Sign(priv, data) + + trustedKeysRaw = base64.StdEncoding.EncodeToString(pub) + t.Cleanup(func() { trustedKeysRaw = "" }) + + if err := VerifySignature(data, sig); err != nil { + t.Fatalf("expected valid signature to pass: %v", err) + } +} + +func TestVerifySignature_tampered(t *testing.T) { + pub, priv, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatal(err) + } + _ = pub + + data := []byte("original content") + sig := ed25519.Sign(priv, data) + + // different key embedded + pub2, _, _ := ed25519.GenerateKey(rand.Reader) + trustedKeysRaw = base64.StdEncoding.EncodeToString(pub2) + t.Cleanup(func() { trustedKeysRaw = "" }) + + if err := VerifySignature(data, sig); err == nil { + t.Fatal("expected tampered signature to fail") + } +} + +func TestVerifySignature_multiKey_anyMatches(t *testing.T) { + pub1, _, _ := ed25519.GenerateKey(rand.Reader) + pub2, priv2, _ := ed25519.GenerateKey(rand.Reader) + + data := []byte("signed by developer 2") + sig := ed25519.Sign(priv2, data) + + trustedKeysRaw = base64.StdEncoding.EncodeToString(pub1) + ":" + + base64.StdEncoding.EncodeToString(pub2) + t.Cleanup(func() { trustedKeysRaw = "" }) + + if err := VerifySignature(data, sig); err != nil { + t.Fatalf("expected any-key match to pass: %v", err) + } +} + +func TestVerifySignature_devBuild(t *testing.T) { + trustedKeysRaw = "" + _, priv, _ := ed25519.GenerateKey(rand.Reader) + data := []byte("content") + sig := ed25519.Sign(priv, data) + + if err := VerifySignature(data, sig); err == nil { + t.Fatal("expected dev build (no keys) to fail") + } +} diff --git a/bible b/bible new file mode 160000 index 0000000..34b457d --- /dev/null +++ b/bible @@ -0,0 +1 @@ +Subproject commit 34b457d65408e92702c837fbd44d94a08f6d4c8e