From f6906fcb835e777327f04b3f3711fe33024a31ab Mon Sep 17 00:00:00 2001 From: sjmiller609 <7516283+sjmiller609@users.noreply.github.com> Date: Fri, 8 May 2026 13:39:12 +0000 Subject: [PATCH] forkidentity: drop a per-fork identity record into every fork MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When N forks come up off the same restored memory snapshot they share /dev/urandom state, machine-id, hostname, and clock — which silently breaks crypto and makes fan-out forks indistinguishable. This change adds lib/forkidentity, a small package that produces a fresh per-fork record (256 bytes of crypto-rand entropy plus a small forward clock jitter) and atomically writes it as fork-identity.json into the fork's data directory. forkInstanceFromStoppedOrStandby now calls it on every fork before metadata save. The hypervisor side never touches guest state. A future guest-agent change reads this file at boot and applies it: reseeds /dev/urandom, refreshes machine-id, and steps the clock by ClockOffsetNs. Failure to build/write the record is fatal because identity reuse is a security regression, not a soft warning. Co-Authored-By: Claude Opus 4.7 --- lib/forkidentity/identity.go | 120 ++++++++++++++++++++++++++++++ lib/forkidentity/identity_test.go | 59 +++++++++++++++ lib/instances/fork.go | 18 +++++ 3 files changed, 197 insertions(+) create mode 100644 lib/forkidentity/identity.go create mode 100644 lib/forkidentity/identity_test.go diff --git a/lib/forkidentity/identity.go b/lib/forkidentity/identity.go new file mode 100644 index 00000000..e6d3458a --- /dev/null +++ b/lib/forkidentity/identity.go @@ -0,0 +1,120 @@ +// Package forkidentity produces and persists per-fork identity records +// for firecracker (and other) snapshot fan-out forks. When N forks share +// a single restored memory image, every fork comes up with identical +// entropy state, machine-id, hostname, and clock — which is unsafe for +// crypto and confusing for users. This package generates a small JSON +// record (random seed bytes, clock offset, fork id) and writes it into +// the fork's data directory at a known path. A guest agent reads the +// file at boot and applies it: reseeds /dev/urandom, sets a fresh +// machine-id, and steps the clock forward. The hypervisor side never +// touches guest state directly. +// +// This package is intentionally self-contained and side-effect-free +// outside of the explicit Write call so it can be composed by every +// hypervisor backend that supports snapshot forks. +package forkidentity + +import ( + "crypto/rand" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" +) + +// FileName is the canonical filename written into a fork's data dir. +// Guest agents that consume identity records read from this name. +const FileName = "fork-identity.json" + +// EntropySeedBytes is the size of the per-fork random seed in bytes. +// 256 bytes (2048 bits) is well past what any reasonable kernel RNG +// reseed would consume; keeping it large means we can split the buffer +// into several pools (urandom, machine-id, hostname salt) without +// running out. +const EntropySeedBytes = 256 + +// Identity is the on-disk record. Field tags are stable; bumping +// Version invalidates older records. +type Identity struct { + Version int `json:"version"` + ForkID string `json:"fork_id"` + EntropySeed []byte `json:"entropy_seed"` + ClockOffsetNs int64 `json:"clock_offset_ns"` + CreatedAt time.Time `json:"created_at"` +} + +// CurrentVersion is bumped whenever the on-disk format changes in a +// guest-agent-incompatible way. +const CurrentVersion = 1 + +// ErrEmpty is returned by Read when the file is missing. +var ErrEmpty = errors.New("forkidentity: identity file not present") + +// Build generates a fresh identity for forkID. It pulls EntropySeedBytes +// of cryptographic randomness and derives a small clock offset from the +// first 8 bytes so that the guest can step its clock forward without an +// extra syscall. +// +// Build never errors except on rand.Reader exhaustion, which on Linux +// means the kernel CSPRNG is broken — propagate it. +func Build(forkID string) (Identity, error) { + if forkID == "" { + return Identity{}, errors.New("forkidentity: fork id is required") + } + seed := make([]byte, EntropySeedBytes) + if _, err := rand.Read(seed); err != nil { + return Identity{}, fmt.Errorf("forkidentity: read random seed: %w", err) + } + // 0..~16 ms of forward jitter. Enough to break clock-based + // correlation across forks without measurably affecting wall time. + jitter := int64(binary.LittleEndian.Uint64(seed[:8]) % uint64(16*time.Millisecond)) + return Identity{ + Version: CurrentVersion, + ForkID: forkID, + EntropySeed: seed, + ClockOffsetNs: jitter, + CreatedAt: time.Now().UTC(), + }, nil +} + +// Write atomically persists id under dir/. The dir must +// already exist; callers typically pass the fork's data directory. +func Write(dir string, id Identity) error { + if id.Version == 0 { + return errors.New("forkidentity: refusing to write zero-versioned identity") + } + data, err := json.MarshalIndent(id, "", " ") + if err != nil { + return fmt.Errorf("forkidentity: marshal: %w", err) + } + full := filepath.Join(dir, FileName) + tmp := full + ".tmp" + if err := os.WriteFile(tmp, data, 0o600); err != nil { + return fmt.Errorf("forkidentity: write tmp: %w", err) + } + if err := os.Rename(tmp, full); err != nil { + return fmt.Errorf("forkidentity: rename: %w", err) + } + return nil +} + +// Read loads the identity record from dir/. ErrEmpty when the +// file does not exist. +func Read(dir string) (Identity, error) { + full := filepath.Join(dir, FileName) + data, err := os.ReadFile(full) + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return Identity{}, ErrEmpty + } + return Identity{}, fmt.Errorf("forkidentity: read: %w", err) + } + var id Identity + if err := json.Unmarshal(data, &id); err != nil { + return Identity{}, fmt.Errorf("forkidentity: unmarshal: %w", err) + } + return id, nil +} diff --git a/lib/forkidentity/identity_test.go b/lib/forkidentity/identity_test.go new file mode 100644 index 00000000..70ad0f64 --- /dev/null +++ b/lib/forkidentity/identity_test.go @@ -0,0 +1,59 @@ +package forkidentity + +import ( + "bytes" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuild_FreshSeedPerCall(t *testing.T) { + a, err := Build("fork-1") + require.NoError(t, err) + b, err := Build("fork-1") + require.NoError(t, err) + assert.False(t, bytes.Equal(a.EntropySeed, b.EntropySeed), + "two builds must not share entropy seeds") +} + +func TestBuild_RejectsEmptyForkID(t *testing.T) { + _, err := Build("") + assert.Error(t, err) +} + +func TestBuild_PopulatesAllFields(t *testing.T) { + id, err := Build("fork-7") + require.NoError(t, err) + + assert.Equal(t, CurrentVersion, id.Version) + assert.Equal(t, "fork-7", id.ForkID) + assert.Len(t, id.EntropySeed, EntropySeedBytes) + assert.False(t, id.CreatedAt.IsZero()) + assert.GreaterOrEqual(t, id.ClockOffsetNs, int64(0)) +} + +func TestWriteRead_RoundTrip(t *testing.T) { + dir := t.TempDir() + want, err := Build("fork-rt") + require.NoError(t, err) + require.NoError(t, Write(dir, want)) + + got, err := Read(dir) + require.NoError(t, err) + assert.Equal(t, want.ForkID, got.ForkID) + assert.Equal(t, want.Version, got.Version) + assert.Equal(t, want.ClockOffsetNs, got.ClockOffsetNs) + assert.True(t, bytes.Equal(want.EntropySeed, got.EntropySeed)) +} + +func TestRead_MissingIsErrEmpty(t *testing.T) { + _, err := Read(t.TempDir()) + assert.True(t, errors.Is(err, ErrEmpty)) +} + +func TestWrite_RejectsZeroVersion(t *testing.T) { + err := Write(t.TempDir(), Identity{ForkID: "x"}) + assert.Error(t, err) +} diff --git a/lib/instances/fork.go b/lib/instances/fork.go index 4ce7ee6e..773cc6e3 100644 --- a/lib/instances/fork.go +++ b/lib/instances/fork.go @@ -10,6 +10,7 @@ import ( "strings" "time" + "github.com/kernel/hypeman/lib/forkidentity" "github.com/kernel/hypeman/lib/forkvm" "github.com/kernel/hypeman/lib/guest" "github.com/kernel/hypeman/lib/hypervisor" @@ -319,6 +320,10 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin } } + if err := writeForkIdentity(forkMeta.DataDir, forkID); err != nil { + return nil, fmt.Errorf("write fork identity: %w", err) + } + newMeta := &metadata{StoredMetadata: forkMeta} if err := m.saveMetadata(newMeta); err != nil { return nil, fmt.Errorf("save fork metadata: %w", err) @@ -334,6 +339,19 @@ func (m *manager) forkInstanceFromStoppedOrStandby(ctx context.Context, id strin return &forked, nil } +// writeForkIdentity drops a per-fork identity record into the fork's +// data directory. The guest agent reads this on boot to reseed +// /dev/urandom, refresh machine-id, and apply a small clock-forward +// jitter so concurrent forks don't share crypto state. Failure is +// fatal because identity reuse is a security regression. +func writeForkIdentity(dataDir, forkID string) error { + id, err := forkidentity.Build(forkID) + if err != nil { + return err + } + return forkidentity.Write(dataDir, id) +} + func (m *manager) validateForkSupport(ctx context.Context, hvType hypervisor.Type) error { starter, err := m.getVMStarter(hvType) if err != nil {