a73x

internal/cli/mint_test.go

Ref:   Size: 6.1 KiB   History

package cli

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/pem"
	"os"
	"path/filepath"
	"strings"
	"testing"
	"time"

	"github.com/a73x/eitri/internal/guest"
	"golang.org/x/crypto/ssh"
)

// newCA writes an unencrypted ed25519 CA private key and returns its path
// and public key.
func newCA(t *testing.T) (string, ssh.PublicKey) {
	t.Helper()
	dir := t.TempDir()
	caPath := filepath.Join(dir, "ca")
	if err := EnsureKeypair(caPath); err != nil {
		t.Fatal(err)
	}
	pub, err := os.ReadFile(caPath + ".pub")
	if err != nil {
		t.Fatal(err)
	}
	k, _, _, _, err := ssh.ParseAuthorizedKey(pub)
	if err != nil {
		t.Fatal(err)
	}
	return caPath, k
}

func TestEnsureKeypairGeneratesOnceAndParses(t *testing.T) {
	key := filepath.Join(t.TempDir(), "id_ed25519")
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}
	priv1, err := os.ReadFile(key)
	if err != nil {
		t.Fatal(err)
	}
	if _, err := ssh.ParsePrivateKey(priv1); err != nil {
		t.Fatalf("generated key unparsable: %v", err)
	}
	if fi, _ := os.Stat(key); fi.Mode().Perm() != 0o600 {
		t.Errorf("key mode = %v", fi.Mode().Perm())
	}
	// Second call must not touch the existing key.
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}
	priv2, _ := os.ReadFile(key)
	if string(priv1) != string(priv2) {
		t.Error("EnsureKeypair overwrote an existing key")
	}
}

func TestMintCertShape(t *testing.T) {
	caPath, caPub := newCA(t)
	key := filepath.Join(t.TempDir(), "id_ed25519")
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}

	if err := MintCert(caPath, key, "tester@box"); err != nil {
		t.Fatal(err)
	}

	raw, err := os.ReadFile(key + "-cert.pub")
	if err != nil {
		t.Fatal(err)
	}
	k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
	if err != nil {
		t.Fatal(err)
	}
	cert, ok := k.(*ssh.Certificate)
	if !ok {
		t.Fatalf("not a certificate: %T", k)
	}
	if cert.CertType != ssh.UserCert || cert.KeyId != "tester@box" {
		t.Errorf("type/keyid: %v %q", cert.CertType, cert.KeyId)
	}
	if len(cert.ValidPrincipals) != 1 || cert.ValidPrincipals[0] != guest.LoginUser {
		t.Errorf("principals: %v", cert.ValidPrincipals)
	}
	now := time.Now().Unix()
	if int64(cert.ValidAfter) > now || int64(cert.ValidBefore) < now+25*60 || int64(cert.ValidBefore) > now+35*60 {
		t.Errorf("validity window: after=%d before=%d now=%d", cert.ValidAfter, cert.ValidBefore, now)
	}
	for _, ext := range []string{"permit-X11-forwarding", "permit-agent-forwarding", "permit-port-forwarding", "permit-pty", "permit-user-rc"} {
		if _, ok := cert.Permissions.Extensions[ext]; !ok {
			t.Errorf("missing extension %s (pty allocation breaks without defaults)", ext)
		}
	}
	// Signed by OUR CA.
	checker := ssh.CertChecker{IsUserAuthority: func(a ssh.PublicKey) bool {
		return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(a))) ==
			strings.TrimSpace(string(ssh.MarshalAuthorizedKey(caPub)))
	}}
	if err := checker.CheckCert(guest.LoginUser, cert); err != nil {
		t.Errorf("cert not accepted by its own CA: %v", err)
	}
}

func TestMintCertMissingCA(t *testing.T) {
	key := filepath.Join(t.TempDir(), "id_ed25519")
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}
	err := MintCert(filepath.Join(t.TempDir(), "nope"), key, "x")
	if err == nil || !strings.Contains(err.Error(), "eitri init") {
		t.Fatalf("want actionable no-CA error, got %v", err)
	}
}

func TestMintCertDerivesPubFromPrivateKey(t *testing.T) {
	caPath, _ := newCA(t)
	key := filepath.Join(t.TempDir(), "id_ed25519")
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}
	// A stale or missing .pub must not matter: the mint derives from the key.
	if err := os.Remove(key + ".pub"); err != nil {
		t.Fatal(err)
	}
	if err := MintCert(caPath, key, "x"); err != nil {
		t.Fatalf("mint with missing .pub: %v", err)
	}
	raw, err := os.ReadFile(key + "-cert.pub")
	if err != nil {
		t.Fatal(err)
	}
	k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
	if err != nil {
		t.Fatal(err)
	}
	cert := k.(*ssh.Certificate)
	priv, _ := os.ReadFile(key)
	signer, _ := ssh.ParsePrivateKey(priv)
	if string(cert.Key.Marshal()) != string(signer.PublicKey().Marshal()) {
		t.Error("cert key does not match the private key on disk")
	}
}

func TestMintCertEncryptedCANonInteractive(t *testing.T) {
	dir := t.TempDir()
	caPath := filepath.Join(dir, "ca")
	_, priv, err := ed25519GenerateForTest()
	if err != nil {
		t.Fatal(err)
	}
	block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("secret"))
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(caPath, pem.EncodeToMemory(block), 0o600); err != nil {
		t.Fatal(err)
	}
	key := filepath.Join(dir, "id_ed25519")
	if err := EnsureKeypair(key); err != nil {
		t.Fatal(err)
	}
	// Non-interactive stdin (go test): must fail fast with a helpful error,
	// never hang on a passphrase prompt.
	err = MintCert(caPath, key, "x")
	if err == nil || !strings.Contains(err.Error(), "passphrase") {
		t.Fatalf("want passphrase-protected error, got %v", err)
	}
}

func TestMintCertEncryptedUserKeyUsesEmbeddedPublicKey(t *testing.T) {
	caPath, _ := newCA(t)
	dir := t.TempDir()
	key := filepath.Join(dir, "id_ed25519")
	pub, priv, err := ed25519GenerateForTest()
	if err != nil {
		t.Fatal(err)
	}
	// Passphrase-encrypted USER key: minting must still work — the openssh
	// format embeds the public key unencrypted, and only the public half is
	// needed (ssh prompts for the passphrase itself at connect time).
	block, err := ssh.MarshalPrivateKeyWithPassphrase(priv, "", []byte("secret"))
	if err != nil {
		t.Fatal(err)
	}
	if err := os.WriteFile(key, pem.EncodeToMemory(block), 0o600); err != nil {
		t.Fatal(err)
	}
	if err := MintCert(caPath, key, "x"); err != nil {
		t.Fatalf("mint with encrypted user key: %v", err)
	}
	raw, err := os.ReadFile(key + "-cert.pub")
	if err != nil {
		t.Fatal(err)
	}
	k, _, _, _, err := ssh.ParseAuthorizedKey(raw)
	if err != nil {
		t.Fatal(err)
	}
	sshPub, err := ssh.NewPublicKey(pub)
	if err != nil {
		t.Fatal(err)
	}
	if string(k.(*ssh.Certificate).Key.Marshal()) != string(sshPub.Marshal()) {
		t.Error("cert key does not match the encrypted private key's public half")
	}
}

func ed25519GenerateForTest() (ed25519.PublicKey, ed25519.PrivateKey, error) {
	return ed25519.GenerateKey(rand.Reader)
}