a73x

internal/server/boot/sealedgate_test.go

Ref:   Size: 6.8 KiB   History

package boot

import (
	"bytes"
	"os"
	"path/filepath"
	"testing"

	serverconfig "github.com/a73x/eitri/internal/server/config"
	"github.com/a73x/eitri/internal/server/seal"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
)

// gateConfig points a jump-gate config at a fresh directory, so each case gets
// its own key files.
func gateConfig(t *testing.T) serverconfig.Config {
	t.Helper()
	dir := t.TempDir()
	return serverconfig.Config{
		SSHListen:  "127.0.0.1:0",
		SSHCAKey:   filepath.Join(dir, "ssh_ca_key"),
		SSHHostKey: filepath.Join(dir, "ssh_host_key"),
	}
}

// kekFilled returns a distinct 32-byte key-encryption key per fill byte.
func kekFilled(fill byte) []byte { return bytes.Repeat([]byte{fill}, seal.KEKSize) }

// TestSetupSSHGateSealsWhatItWrites is the claim this wiring exists to make:
// the key material the gate creates is ciphertext on disk. A lifted volume or a
// nightly backup carries the file, and the file alone signs nothing.
func TestSetupSSHGateSealsWhatItWrites(t *testing.T) {
	cfg := gateConfig(t)

	g, err := setupSSHGate(cfg, kekFilled(0x2b))
	require.NoError(t, err)
	require.NotNil(t, g)

	for _, path := range []string{cfg.SSHCAKey, cfg.SSHHostKey} {
		stored, rerr := os.ReadFile(path)
		require.NoError(t, rerr)
		assert.True(t, seal.IsSealed(string(stored)),
			"%s must rest sealed, not as a readable key", filepath.Base(path))
		assert.NotContains(t, string(stored), "PRIVATE KEY",
			"%s must not carry a parseable PEM", filepath.Base(path))
	}

	// The gate is nonetheless usable: what it hands out is the opened key.
	assert.NotEmpty(t, g.ca.HostCAAuthorizedKey())
}

// TestSetupSSHGateKeepsItsIdentityAcrossBoots: the host CA is what every client
// pins with @cert-authority and what every VM's host certificate is signed by,
// so the same KEK must yield the same identity every time.
func TestSetupSSHGateKeepsItsIdentityAcrossBoots(t *testing.T) {
	cfg := gateConfig(t)
	kek := kekFilled(0x2b)

	first, err := setupSSHGate(cfg, kek)
	require.NoError(t, err)
	second, err := setupSSHGate(cfg, kek)
	require.NoError(t, err)

	assert.Equal(t, string(first.ca.HostCAAuthorizedKey()), string(second.ca.HostCAAuthorizedKey()),
		"a reboot must come back as the same CA every client already trusts")
	assert.Equal(t, first.ca.HostKey().PublicKey().Marshal(), second.ca.HostKey().PublicKey().Marshal())
}

// TestSetupSSHGateRefusesTheWrongKEK is the failure mode that matters most. A
// plane handed the wrong key_encryption_key cannot open its own host CA, and
// the only two things it could do are stop or mint a replacement. Minting one
// would present every client with an unknown CA and every VM with an
// uncertifiable host key — indistinguishable, from the outside, from an attack.
// So it stops, and it leaves the key exactly where it found it.
func TestSetupSSHGateRefusesTheWrongKEK(t *testing.T) {
	cfg := gateConfig(t)

	_, err := setupSSHGate(cfg, kekFilled(0x2b))
	require.NoError(t, err)
	before, err := os.ReadFile(cfg.SSHCAKey)
	require.NoError(t, err)

	g, refusal := setupSSHGate(cfg, kekFilled(0x7c))
	require.Error(t, refusal, "a key that will not open must stop the server")
	assert.Nil(t, g)
	assert.NotContains(t, refusal.Error(), string(kekFilled(0x7c)),
		"a failure must name none of the material")

	after, err := os.ReadFile(cfg.SSHCAKey)
	require.NoError(t, err)
	assert.Equal(t, before, after, "the unreadable key must be left intact, never regenerated over")
}

// TestSetupSSHGateOffWritesNothing: with no listen address there is no gate,
// so there is no key to seal and the KEK is beside the point.
func TestSetupSSHGateOffWritesNothing(t *testing.T) {
	cfg := gateConfig(t)
	cfg.SSHListen = ""

	g, err := setupSSHGate(cfg, kekFilled(0x2b))
	require.NoError(t, err)
	assert.Nil(t, g, "no ssh_listen means no gate")

	_, err = os.Stat(cfg.SSHCAKey)
	assert.True(t, os.IsNotExist(err), "a gate that is off creates no key material")
}

// TestSetupSSHGateNeedsBothKeyPaths: a gate that is on but has nowhere to keep
// its keys is a misconfiguration, and it is caught at startup rather than at
// the first connection.
func TestSetupSSHGateNeedsBothKeyPaths(t *testing.T) {
	for _, tc := range []struct{ name, clear string }{
		{"no ca key", "ca"},
		{"no host key", "host"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			cfg := gateConfig(t)
			if tc.clear == "ca" {
				cfg.SSHCAKey = ""
			} else {
				cfg.SSHHostKey = ""
			}
			_, err := setupSSHGate(cfg, kekFilled(0x2b))
			require.Error(t, err)
			assert.Contains(t, err.Error(), "ssh_ca_key and ssh_host_key are required")
		})
	}
}

// TestSetupSSHGateNeedsAName is the refusal that keeps a plane from booting a
// gate nobody can reach. A bind says which interfaces to accept on, not what to
// call the machine, so a wildcard bind with no ssh_gate_domain leaves the gate
// nameless: /me would advertise nothing and the host certificate would name
// localhost, which every remote client refuses against the name it dialed. No
// EITRI_GATE fixes that from the client side, so the server stops instead —
// which is what lets everything downstream assume a running gate has an address.
func TestSetupSSHGateNeedsAName(t *testing.T) {
	for _, tc := range []struct {
		name           string
		listen, domain string
		wantRefusal    bool
	}{
		{name: "an all-interfaces bind with no domain refuses", listen: ":2222", wantRefusal: true},
		{name: "an IPv4 wildcard bind with no domain refuses", listen: "0.0.0.0:2222", wantRefusal: true},
		{name: "an IPv6 wildcard bind with no domain refuses", listen: "[::]:2222", wantRefusal: true},
		{name: "a wildcard bind boots once it is named", listen: ":2222", domain: "gate.eitri.sh"},
		{name: "a concrete bind names itself", listen: "gate.example.com:2222"},
		{name: "a loopback bind is the truth for a single-machine plane", listen: "127.0.0.1:2223"},
	} {
		t.Run(tc.name, func(t *testing.T) {
			cfg := gateConfig(t)
			cfg.SSHListen, cfg.SSHGateDomain = tc.listen, tc.domain

			g, err := setupSSHGate(cfg, kekFilled(0x2b))
			if !tc.wantRefusal {
				require.NoError(t, err)
				require.NotNil(t, g)
				assert.NotEmpty(t, g.gateAddr(), "a booted gate always has an address to advertise")
				return
			}
			require.Error(t, err)
			assert.Nil(t, g)
			// Both remedies, so the operator is not left to guess which half of
			// the pair to change.
			assert.Contains(t, err.Error(), "ssh_gate_domain")
			assert.Contains(t, err.Error(), "127.0.0.1")
			_, statErr := os.Stat(cfg.SSHCAKey)
			assert.True(t, os.IsNotExist(statErr), "a refused config creates no key material")
		})
	}
}

// TestSetupSSHGateOffIsUnaffectedByTheNameCheck: no ssh_listen means no gate at
// all, and a gate that does not exist needs no name.
func TestSetupSSHGateOffIsUnaffectedByTheNameCheck(t *testing.T) {
	cfg := gateConfig(t)
	cfg.SSHListen, cfg.SSHGateDomain = "", ""

	g, err := setupSSHGate(cfg, kekFilled(0x2b))
	require.NoError(t, err)
	assert.Nil(t, g)
}