a73x

internal/server/sshca/sshca.go

Ref:   Size: 10.2 KiB   History

// Package sshca manages eitri's SSH key material: a persistent user CA (whose
// short-lived certs authenticate admins to the jump gate and VMs) and a
// persistent gate host key. Both are load-or-create — generated once on first
// boot into a configured path and reused thereafter, so users never see
// host-key-changed warnings.
//
// Both keys rest SEALED: the file on disk is ciphertext under the server's
// key_encryption_key (internal/server/seal), which lives in the config and not
// beside the data, so a copied volume or a snapshot of one carries no signing
// power. The material is otherwise handled like the server TLS key — written
// 0600, server-user-owned, and NEVER logged or exposed in API responses. Only
// the CA *public* key is exported (for VM trust injection and known_hosts
// pinning).
package sshca

import (
	"crypto/ed25519"
	"crypto/rand"
	"encoding/binary"
	"encoding/pem"
	"fmt"
	"log/slog"
	"os"
	"path/filepath"
	"strings"
	"time"

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

// HostCertTTL is the validity window of a signed HOST certificate. Host certs
// are long-lived on purpose: they are pinned by CA (a client trusts anything
// the CA signs via `@cert-authority`), not rotated per-session like the
// short-lived user certs. Ten years keeps them out of the operator's way.
const HostCertTTL = 10 * 365 * 24 * time.Hour

// CA holds eitri's persistent SSH key material. In v1 the same key acts as both
// user CA and host CA (§B4 permits reusing ssh_ca_key); the gate host key is a
// separate persistent key.
type CA struct {
	userCA  ssh.Signer
	hostKey ssh.Signer
}

// New loads-or-creates the user CA (caPath) and the gate host key (hostKeyPath).
// Both files are created 0600 if absent and reused if present, and both rest
// sealed under kek — the server's key_encryption_key.
func New(caPath, hostKeyPath string, kek []byte) (*CA, error) {
	userCA, err := LoadOrCreate(caPath, kek)
	if err != nil {
		return nil, fmt.Errorf("ssh user CA: %w", err)
	}
	hostKey, err := LoadOrCreate(hostKeyPath, kek)
	if err != nil {
		return nil, fmt.Errorf("ssh host key: %w", err)
	}
	return &CA{userCA: userCA, hostKey: hostKey}, nil
}

// HostKey returns the gate's persistent host key.
func (c *CA) HostKey() ssh.Signer { return c.hostKey }

// HostCA returns the signer used to sign VM + gate HOST certificates. In the
// BYO model eitri no longer signs USER certs — those are per-tenant, uploaded by
// members and never held here; the gate trusts the DB-registered set. This key
// is the persistent CA loaded from ssh_ca_key (the `userCA` field, retained as
// the field name for the loaded key material).
func (c *CA) HostCA() ssh.Signer { return c.userCA }

// HostCAAuthorizedKey returns the host CA public key in authorized_keys form,
// for @cert-authority host pinning (served at GET /api/v1/ssh-ca). Public.
func (c *CA) HostCAAuthorizedKey() []byte {
	return ssh.MarshalAuthorizedKey(c.userCA.PublicKey())
}

// AuthorizedKeyLine returns pub as a canonical single-line authorized_keys
// entry ("type base64"), with no comment or trailing newline. This is the
// stable key used to register/look up a CA (the store's tenant_user_cas.ca_pubkey
// and the gate's tenant lookup MUST agree byte-for-byte).
func AuthorizedKeyLine(pub ssh.PublicKey) string {
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
}

// LoadOrCreate returns a stable ssh.Signer for the key at path, sealed at rest
// under kek (the server's key_encryption_key). An absent file is generated and
// written sealed, 0600; a present one is opened and parsed. The public key is
// stable across reloads.
//
// A file holding a bare PEM is one written before it was sealed. It is read,
// then sealed in place — see reseal — so a plane seals itself on the boot that
// first has a KEK, and does so once.
//
// Never logs or returns key material in errors.
func LoadOrCreate(path string, kek []byte) (ssh.Signer, error) {
	stored, err := os.ReadFile(path)
	if err == nil {
		return load(path, string(stored), kek)
	}
	if !os.IsNotExist(err) {
		return nil, fmt.Errorf("read ssh key %q: %w", path, err)
	}

	// Absent — generate a fresh ed25519 key and persist it sealed, 0600.
	pemBytes, signer, err := GenerateHostKey()
	if err != nil {
		return nil, err
	}
	blob, err := seal.Seal(kek, string(pemBytes))
	if err != nil {
		return nil, fmt.Errorf("seal ssh key %q: %w", path, err)
	}
	// Write 0600 exclusively so a concurrent creator can't race us into a
	// clobbered key; O_EXCL also guards against following a symlink.
	f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
	if err != nil {
		return nil, fmt.Errorf("create ssh key %q: %w", path, err)
	}
	if _, werr := f.WriteString(blob); werr != nil {
		f.Close()
		return nil, fmt.Errorf("write ssh key %q: %w", path, werr)
	}
	if cerr := f.Close(); cerr != nil {
		return nil, fmt.Errorf("close ssh key %q: %w", path, cerr)
	}
	return signer, nil
}

// load turns the contents of an existing key file into a signer, sealing it
// first if it is not sealed yet.
//
// A sealed file that will not open is fatal, and deliberately so: the caller
// must never fall through to generating a replacement. This key is eitri's
// identity — every user's `@cert-authority` pin and every VM's host certificate
// name it — so quietly minting a new one would present the whole fleet with an
// impostor and look, to every client, exactly like an attack.
func load(path, stored string, kek []byte) (ssh.Signer, error) {
	if seal.IsSealed(stored) {
		pemBytes, err := seal.Open(kek, stored)
		if err != nil {
			return nil, fmt.Errorf("ssh key %q is sealed and this server cannot open it "+
				"(key_encryption_key must be the one it was sealed with; the key is NOT regenerated): %w", path, err)
		}
		return parse(path, pemBytes)
	}
	// A plaintext key predates sealing. Parse it before rewriting anything, so
	// an unreadable file is reported as itself rather than sealed as garbage.
	signer, err := parse(path, stored)
	if err != nil {
		return nil, err
	}
	if err := reseal(path, stored, kek); err != nil {
		return nil, err
	}
	return signer, nil
}

// reseal replaces a plaintext key file with its sealed form, atomically: write
// a temp file beside it, fsync so the bytes are on the medium before anything
// points at them, then rename over the original. The order matters more than it
// looks — a crash anywhere before the rename leaves the plaintext key intact
// and the next boot simply tries again, whereas removing the original first
// would turn a badly timed crash into a lost fleet identity.
//
// A failure here stops the server rather than carrying on with an unsealed key:
// the operator asked for key material to be encrypted at rest, and continuing
// while it is not would be the one outcome nobody would notice.
func reseal(path, plaintext string, kek []byte) error {
	blob, err := seal.Seal(kek, plaintext)
	if err != nil {
		return fmt.Errorf("seal ssh key %q: %w", path, err)
	}
	dir := filepath.Dir(path)
	tmp, err := os.CreateTemp(dir, filepath.Base(path)+".sealing-*")
	if err != nil {
		return fmt.Errorf("seal ssh key %q: create temp: %w", path, err)
	}
	tmpName := tmp.Name()
	defer os.Remove(tmpName) // no-op once the rename has consumed it
	if err := tmp.Chmod(0o600); err != nil {
		tmp.Close()
		return fmt.Errorf("seal ssh key %q: chmod temp: %w", path, err)
	}
	if _, err := tmp.WriteString(blob); err != nil {
		tmp.Close()
		return fmt.Errorf("seal ssh key %q: write temp: %w", path, err)
	}
	if err := tmp.Sync(); err != nil {
		tmp.Close()
		return fmt.Errorf("seal ssh key %q: sync temp: %w", path, err)
	}
	if err := tmp.Close(); err != nil {
		return fmt.Errorf("seal ssh key %q: close temp: %w", path, err)
	}
	if err := os.Rename(tmpName, path); err != nil {
		return fmt.Errorf("seal ssh key %q: rename: %w", path, err)
	}
	slog.Info("ssh key sealed at rest", "path", path)
	return nil
}

// parse turns private-key PEM into a signer. The underlying error can quote the
// bytes it failed on, so only the path is reported.
func parse(path, pemBytes string) (ssh.Signer, error) {
	signer, err := ssh.ParsePrivateKey([]byte(pemBytes))
	if err != nil {
		return nil, fmt.Errorf("parse ssh key %q: unusable key material", path)
	}
	return signer, nil
}

// GenerateHostKey generates a fresh ed25519 key and returns it both as an
// OpenSSH-format private-key PEM (for persisting / shipping to a guest as
// /etc/ssh/ssh_host_ed25519_key) and as a ready-to-use signer. The PEM is
// unencrypted: it is handed to a guest as its own host key, which is where it
// comes to rest. The copy eitri keeps of its OWN keys is sealed (LoadOrCreate).
func GenerateHostKey() (pemBytes []byte, signer ssh.Signer, err error) {
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, nil, fmt.Errorf("generate ssh key: %w", err)
	}
	block, err := ssh.MarshalPrivateKey(priv, "")
	if err != nil {
		return nil, nil, fmt.Errorf("marshal ssh key: %w", err)
	}
	signer, err = ssh.NewSignerFromSigner(priv)
	if err != nil {
		return nil, nil, fmt.Errorf("new signer: %w", err)
	}
	return pem.EncodeToMemory(block), signer, nil
}

// SignHostCert signs hostPub as an OpenSSH HOST certificate valid for
// principals (the hostnames a client may connect to), signed by ca. In v1 the
// user CA doubles as the host CA (§B4), so the same key that a guest trusts via
// TrustedUserCAKeys also certifies host keys that a client trusts via
// `@cert-authority`. keyID is a free-form label recorded in the cert (e.g.
// "eitri-gate" or the VM name) for audit. Validity runs now .. now+ttl; host
// certs use the long HostCertTTL. Kept free of I/O so it is unit-testable.
func SignHostCert(ca ssh.Signer, hostPub ssh.PublicKey, principals []string, keyID string, now time.Time, ttl time.Duration) (*ssh.Certificate, error) {
	var serial uint64
	if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
		return nil, err
	}
	cert := &ssh.Certificate{
		Key:             hostPub,
		Serial:          serial,
		CertType:        ssh.HostCert,
		KeyId:           keyID,
		ValidPrincipals: principals,
		ValidAfter:      uint64(now.Unix()),
		ValidBefore:     uint64(now.Add(ttl).Unix()),
	}
	if err := cert.SignCert(rand.Reader, ca); err != nil {
		return nil, err
	}
	return cert, nil
}