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)
}