internal/server/sshca/sealed_test.go
Ref: Size: 6.2 KiB History
package sshca
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/a73x/eitri/internal/server/seal"
"golang.org/x/crypto/ssh"
)
// read returns a key file's contents as a string.
func read(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
// plaintextKey writes an unsealed key file — the shape a plane that predates
// sealing has on disk — and returns its path and public key.
func plaintextKey(t *testing.T, dir string) (path string, pub string) {
t.Helper()
pemBytes, signer, err := GenerateHostKey()
if err != nil {
t.Fatalf("GenerateHostKey: %v", err)
}
path = filepath.Join(dir, "ca")
if err := os.WriteFile(path, pemBytes, 0o600); err != nil {
t.Fatalf("write plaintext key: %v", err)
}
return path, AuthorizedKeyLine(signer.PublicKey())
}
// TestFirstBootWritesASealedKey: a key generated today is never written in the
// clear, not even briefly.
func TestFirstBootWritesASealedKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "ca")
signer, err := LoadOrCreate(path, testKEK)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
stored := read(t, path)
if !seal.IsSealed(stored) {
t.Fatalf("key file is not sealed: %q", stored)
}
if strings.Contains(stored, "PRIVATE KEY") {
t.Fatal("key file contains a PEM")
}
// And it is the key that was returned: reopening yields the same public half.
reopened, err := LoadOrCreate(path, testKEK)
if err != nil {
t.Fatalf("reload: %v", err)
}
if AuthorizedKeyLine(reopened.PublicKey()) != AuthorizedKeyLine(signer.PublicKey()) {
t.Fatal("the sealed key reopened as a different key")
}
}
// TestPlaintextKeyIsSealedInPlace is the upgrade path: a plane whose host CA
// predates sealing seals it on the boot that first has a key, keeps the same
// identity, and does not do it again.
func TestPlaintextKeyIsSealedInPlace(t *testing.T) {
dir := t.TempDir()
path, pub := plaintextKey(t, dir)
signer, err := LoadOrCreate(path, testKEK)
if err != nil {
t.Fatalf("LoadOrCreate: %v", err)
}
// The identity is unchanged — this is the whole point. A different key here
// would invalidate every @cert-authority pin in the fleet.
if got := AuthorizedKeyLine(signer.PublicKey()); got != pub {
t.Fatalf("identity changed on seal:\n before %s\n after %s", pub, got)
}
sealed := read(t, path)
if !seal.IsSealed(sealed) {
t.Fatalf("key file was not sealed in place: %q", sealed)
}
if fi, err := os.Stat(path); err != nil {
t.Fatalf("stat: %v", err)
} else if perm := fi.Mode().Perm(); perm != 0o600 {
t.Fatalf("sealed key perms = %o, want 0600", perm)
}
// Idempotent: the second boot opens what the first wrote and rewrites nothing.
again, err := LoadOrCreate(path, testKEK)
if err != nil {
t.Fatalf("second boot: %v", err)
}
if AuthorizedKeyLine(again.PublicKey()) != pub {
t.Fatal("identity changed on the second boot")
}
if read(t, path) != sealed {
t.Fatal("the second boot rewrote an already-sealed key")
}
// Nothing is left behind in the data directory.
entries, err := os.ReadDir(dir)
if err != nil {
t.Fatalf("readdir: %v", err)
}
if len(entries) != 1 {
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
t.Fatalf("sealing left temp files behind: %v", names)
}
}
// TestWrongKEKRefusesAndKeepsTheKey is the one that matters most: a plane that
// comes back with the wrong key must stop, not mint a new identity. A
// regenerated host CA would present every client with an impostor.
func TestWrongKEKRefusesAndKeepsTheKey(t *testing.T) {
path := filepath.Join(t.TempDir(), "ca")
if _, err := LoadOrCreate(path, testKEK); err != nil {
t.Fatalf("seed: %v", err)
}
sealed := read(t, path)
for _, tc := range []struct {
name string
kek []byte
}{
{"wrong key", otherKEK},
{"no key at all", nil},
{"short key", testKEK[:16]},
} {
t.Run(tc.name, func(t *testing.T) {
signer, err := LoadOrCreate(path, tc.kek)
if err == nil {
t.Fatal("a sealed key opened with the wrong KEK")
}
if signer != nil {
t.Fatal("a signer was returned despite the failure")
}
if !strings.Contains(err.Error(), path) {
t.Errorf("the error must name the file, got %v", err)
}
if !strings.Contains(err.Error(), "key_encryption_key") {
t.Errorf("the error must name the config key, got %v", err)
}
if strings.Contains(err.Error(), "PRIVATE KEY") {
t.Errorf("the error echoes key material: %v", err)
}
if read(t, path) != sealed {
t.Fatal("the key file was rewritten — a host CA must never be regenerated over an unreadable one")
}
})
}
// And the right key still opens it afterwards: nothing was consumed.
if _, err := LoadOrCreate(path, testKEK); err != nil {
t.Fatalf("the key must survive a failed open: %v", err)
}
}
// TestUnparseableKeyIsReportedNotSealed: a corrupt file is a corrupt file. It is
// reported as one, and not rewritten as sealed nonsense that would hide what
// happened.
func TestUnparseableKeyIsReportedNotSealed(t *testing.T) {
path := filepath.Join(t.TempDir(), "ca")
if err := os.WriteFile(path, []byte("not a key at all\n"), 0o600); err != nil {
t.Fatalf("write: %v", err)
}
if _, err := LoadOrCreate(path, testKEK); err == nil {
t.Fatal("garbage was accepted as a key")
}
if got := read(t, path); got != "not a key at all\n" {
t.Fatalf("the file was rewritten: %q", got)
}
}
// TestNewSealsBothKeys: the host CA and the gate host key both rest sealed, and
// remain distinct keys.
func TestNewSealsBothKeys(t *testing.T) {
dir := t.TempDir()
caPath, hostPath := filepath.Join(dir, "ca"), filepath.Join(dir, "host")
ca, err := New(caPath, hostPath, testKEK)
if err != nil {
t.Fatalf("New: %v", err)
}
for _, p := range []string{caPath, hostPath} {
if stored := read(t, p); !seal.IsSealed(stored) {
t.Errorf("%s is not sealed: %q", p, stored)
}
}
if string(ssh.MarshalAuthorizedKey(ca.HostCA().PublicKey())) ==
string(ssh.MarshalAuthorizedKey(ca.HostKey().PublicKey())) {
t.Fatal("HostCA and HostKey share the same public key")
}
// A wrong KEK stops the whole gate rather than half-loading it.
if _, err := New(caPath, hostPath, otherKEK); err == nil {
t.Fatal("New accepted the wrong KEK")
}
}