slice 10B: operator-signed destructive completion (offline key + signing CLI) (v0.16.0)
A destructive op runs ONLY on a pinned-key-verified, nonce-fresh, in-window, host-bound, durable-id-bound operator signature. New cmd/felhom-opsign signs canonical OpBlobs offline via ssh-keygen -Y sign (hardware-ready); the signing key is never in the hub or agent. New internal/signedjobs runner verifies each queued blob through the gate and only on all-pass runs the WipeExecutor, which re-resolves the DURABLE device id + re-inspects (8C) before mkfs — closing the 8C data-bearing-wipe pending_signature gap. New storage durable-device resolution; authz.CanonicalBlob promoted to production. Real-crypto tests assert valid executes and forged/replay/expired/retarget/non-pinned are rejected (executor never called). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,128 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Durable BLOCK-DEVICE identity for the slice-10B operator-signed wipe (anti-retarget). The
|
||||
// signed op binds a DURABLE id (a WWN / hardware serial, falling back to a filesystem UUID), and
|
||||
// execution re-resolves it to the CURRENT /dev path — so "wipe device X" wipes that exact physical
|
||||
// device, never whatever happens to be at /dev/sdb now. These reads are world-readable udev
|
||||
// symlinks under /dev/disk/by-id and /dev/disk/by-uuid — NO privilege and NO subprocess (the
|
||||
// root-CLI fence is untouched).
|
||||
//
|
||||
// devDiskRoot is overridable for tests (a fake /dev/disk layout).
|
||||
var devDiskRoot = "/dev/disk"
|
||||
|
||||
// durable-id scheme prefixes. byid: a /dev/disk/by-id/<name> entry (preferred — wwn/serial,
|
||||
// survives reformat + re-cabling); byuuid: a filesystem UUID (fallback — survives re-cabling but
|
||||
// not a reformat, acceptable for a one-shot wipe resolved immediately before wiping).
|
||||
const (
|
||||
durableByID = "byid:"
|
||||
durableByUUID = "byuuid:"
|
||||
)
|
||||
|
||||
// byIDPriority ranks /dev/disk/by-id link prefixes most-stable-first: wwn (hardware world-wide
|
||||
// name) > nvme-eui (NVMe EUI) > nvme-/ata-/scsi-/usb- (model+serial). dm-/lvm-/md- names are
|
||||
// excluded (they are mapper constructs, not the physical disk we want to bind a wipe to).
|
||||
var byIDPriority = []string{"wwn-", "nvme-eui.", "nvme-", "ata-", "scsi-", "usb-"}
|
||||
|
||||
// DeviceDurableID derives a stable durable identifier for a block device by scanning the
|
||||
// world-readable udev symlinks. Prefers a by-id (wwn/serial) link, then a filesystem UUID. Errors
|
||||
// when neither is resolvable (a device with no durable identity cannot be safely wipe-bound).
|
||||
func DeviceDurableID(device string) (string, error) {
|
||||
target, err := filepath.EvalSymlinks(device)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("storage: resolve %s: %w", device, err)
|
||||
}
|
||||
// 1. best by-id link pointing at this device.
|
||||
if name := bestByIDLink(target); name != "" {
|
||||
return durableByID + name, nil
|
||||
}
|
||||
// 2. fallback: a filesystem UUID symlink.
|
||||
if uuid := byUUIDFor(target); uuid != "" {
|
||||
return durableByUUID + uuid, nil
|
||||
}
|
||||
return "", fmt.Errorf("storage: no durable id (wwn/serial/uuid) for device %s", device)
|
||||
}
|
||||
|
||||
// ResolveDurableDevice resolves a durable id back to the CURRENT canonical /dev path, or errors if
|
||||
// it no longer resolves (the device was physically removed/replaced — the wipe must then refuse).
|
||||
func ResolveDurableDevice(durableID string) (string, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(durableID, durableByID):
|
||||
name := strings.TrimPrefix(durableID, durableByID)
|
||||
if !safeLinkName(name) {
|
||||
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
|
||||
}
|
||||
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-id", name))
|
||||
case strings.HasPrefix(durableID, durableByUUID):
|
||||
uuid := strings.TrimPrefix(durableID, durableByUUID)
|
||||
if !safeLinkName(uuid) {
|
||||
return "", fmt.Errorf("storage: unsafe durable id %q", durableID)
|
||||
}
|
||||
return filepath.EvalSymlinks(filepath.Join(devDiskRoot, "by-uuid", uuid))
|
||||
default:
|
||||
// A bare path or unknown scheme is REFUSED — a wipe must bind to a durable id, never a
|
||||
// mutable /dev path (the anti-retarget invariant).
|
||||
return "", fmt.Errorf("storage: durable id %q is not a durable scheme (byid:/byuuid:) — refusing path-only binding", durableID)
|
||||
}
|
||||
}
|
||||
|
||||
// bestByIDLink returns the highest-priority /dev/disk/by-id link name whose target is `device`
|
||||
// (already symlink-resolved), or "" if none.
|
||||
func bestByIDLink(device string) string {
|
||||
dir := filepath.Join(devDiskRoot, "by-id")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
// collect matching names, then pick by priority (then lexically for determinism).
|
||||
var matches []string
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if tgt, err := filepath.EvalSymlinks(filepath.Join(dir, name)); err == nil && tgt == device {
|
||||
matches = append(matches, name)
|
||||
}
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
return ""
|
||||
}
|
||||
sort.Strings(matches)
|
||||
for _, pfx := range byIDPriority {
|
||||
for _, name := range matches {
|
||||
if strings.HasPrefix(name, pfx) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
}
|
||||
return matches[0] // some by-id link exists but not a recognized prefix — still durable enough
|
||||
}
|
||||
|
||||
// byUUIDFor returns the filesystem UUID whose by-uuid link targets `device`, or "".
|
||||
func byUUIDFor(device string) string {
|
||||
dir := filepath.Join(devDiskRoot, "by-uuid")
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, e := range entries {
|
||||
if tgt, err := filepath.EvalSymlinks(filepath.Join(dir, e.Name())); err == nil && tgt == device {
|
||||
return e.Name()
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// safeLinkName rejects a durable-id component that could escape the by-id/by-uuid dir (path
|
||||
// traversal / separators) — defense even though the value is operator-signed.
|
||||
func safeLinkName(s string) bool {
|
||||
if s == "" || strings.ContainsAny(s, "/\x00") || s == "." || s == ".." {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeDevDisk builds a temp /dev/disk tree: a real "device" file + by-id/by-uuid symlinks to it.
|
||||
// Returns the disk root + the device path. (filepath.EvalSymlinks needs real targets, so the
|
||||
// "device" is a regular file standing in for a block device.) Skips on Windows where creating a
|
||||
// symlink needs a privilege — the durable-device code is Linux-only (the agent runs on the PVE
|
||||
// host), so these run on the build server / demo host.
|
||||
func fakeDevDisk(t *testing.T, links map[string]string, uuids map[string]string) (root, device string) {
|
||||
t.Helper()
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("durable-device symlink tests run on Linux (the agent's OS); symlink creation needs privilege on Windows")
|
||||
}
|
||||
base := t.TempDir()
|
||||
device = filepath.Join(base, "sdb")
|
||||
if err := os.WriteFile(device, []byte("x"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root = filepath.Join(base, "disk")
|
||||
mk := func(sub, name, target string) {
|
||||
dir := filepath.Join(root, sub)
|
||||
os.MkdirAll(dir, 0o755)
|
||||
if err := os.Symlink(target, filepath.Join(dir, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name := range links {
|
||||
mk("by-id", name, device)
|
||||
}
|
||||
for uuid := range uuids {
|
||||
mk("by-uuid", uuid, device)
|
||||
}
|
||||
return root, device
|
||||
}
|
||||
|
||||
func withDevDiskRoot(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
old := devDiskRoot
|
||||
devDiskRoot = root
|
||||
t.Cleanup(func() { devDiskRoot = old })
|
||||
}
|
||||
|
||||
// DeviceDurableID prefers a wwn- by-id link over ata-/serial links and over a uuid.
|
||||
func TestDeviceDurableID_PrefersWWN(t *testing.T) {
|
||||
root, device := fakeDevDisk(t,
|
||||
map[string]string{"wwn-0x5000c500abcd": "", "ata-Samsung_SSD_850_S1": "", "scsi-35000c500abcd": ""},
|
||||
map[string]string{"1111-2222": ""})
|
||||
withDevDiskRoot(t, root)
|
||||
|
||||
id, err := DeviceDurableID(device)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceDurableID: %v", err)
|
||||
}
|
||||
if id != "byid:wwn-0x5000c500abcd" {
|
||||
t.Errorf("durable id = %q, want the wwn link", id)
|
||||
}
|
||||
}
|
||||
|
||||
// With no by-id link, it falls back to a filesystem UUID.
|
||||
func TestDeviceDurableID_FallsBackToUUID(t *testing.T) {
|
||||
root, device := fakeDevDisk(t, map[string]string{}, map[string]string{"abcd-ef01": ""})
|
||||
withDevDiskRoot(t, root)
|
||||
id, err := DeviceDurableID(device)
|
||||
if err != nil {
|
||||
t.Fatalf("DeviceDurableID: %v", err)
|
||||
}
|
||||
if id != "byuuid:abcd-ef01" {
|
||||
t.Errorf("durable id = %q, want byuuid:abcd-ef01", id)
|
||||
}
|
||||
}
|
||||
|
||||
// A device with no durable identity at all → error (cannot be wipe-bound).
|
||||
func TestDeviceDurableID_NoneErrors(t *testing.T) {
|
||||
root, device := fakeDevDisk(t, map[string]string{}, map[string]string{})
|
||||
withDevDiskRoot(t, root)
|
||||
if _, err := DeviceDurableID(device); err == nil {
|
||||
t.Fatal("a device with no wwn/serial/uuid must error (no durable id)")
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveDurableDevice round-trips a by-id id back to the canonical device path.
|
||||
func TestResolveDurableDevice_RoundTrip(t *testing.T) {
|
||||
root, device := fakeDevDisk(t, map[string]string{"wwn-0xabc": ""}, map[string]string{})
|
||||
withDevDiskRoot(t, root)
|
||||
got, err := ResolveDurableDevice("byid:wwn-0xabc")
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveDurableDevice: %v", err)
|
||||
}
|
||||
want, _ := filepath.EvalSymlinks(device)
|
||||
if got != want {
|
||||
t.Errorf("resolved %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// A path-only / unknown-scheme id is REFUSED (the anti-retarget invariant).
|
||||
func TestResolveDurableDevice_RefusesPathOnly(t *testing.T) {
|
||||
withDevDiskRoot(t, t.TempDir())
|
||||
for _, bad := range []string{"/dev/sdb", "sdb", "uuid-no-scheme", "byid:../../etc/passwd", "byuuid:../x"} {
|
||||
if _, err := ResolveDurableDevice(bad); err == nil {
|
||||
t.Errorf("ResolveDurableDevice(%q) succeeded, want refusal", bad)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A durable id whose link is gone → error (device removed/replaced).
|
||||
func TestResolveDurableDevice_MissingErrors(t *testing.T) {
|
||||
withDevDiskRoot(t, t.TempDir()) // empty: no by-id dir
|
||||
if _, err := ResolveDurableDevice("byid:wwn-0xgone"); err == nil {
|
||||
t.Fatal("an absent durable id must error")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user