Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions lib/forkidentity/identity.go
Original file line number Diff line number Diff line change
@@ -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/<FileName>. 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/<FileName>. 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
}
59 changes: 59 additions & 0 deletions lib/forkidentity/identity_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions lib/instances/fork.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
Loading