a73x

internal/gateclient/auth.go

Ref:   Size: 8.0 KiB   History

// Package gateclient is a client of the eitri SSH-CA jump gate: it holds the
// credential cache (GateAuth) that self-signs short-lived user certs and
// verifies host certs against the eitri CA, and the two-hop dial (Dial) that
// reaches a VM by name through the gate. It's shared by internal/mcpserver
// and cmd/eitri-smoke.
package gateclient

import (
	"bytes"
	"context"
	"crypto/ed25519"
	"crypto/rand"
	"encoding/binary"
	"fmt"
	"strings"
	"sync"
	"time"

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

// CertAuthority is the subset of the eitri API client GateAuth needs: enough
// to fetch the (host) SSH CA's public key and register this client's own user
// CA with its tenant. It's declared here (rather than depending on the
// shared API client directly) so tests can fake it in-memory without
// spinning up an httptest server.
type CertAuthority interface {
	FetchSSHCA(ctx context.Context) (ssh.PublicKey, error)
	UploadUserCA(ctx context.Context, tenant, caLine string) error
}

// GateAuth is a concurrency-safe credential cache for authenticating to the
// eitri SSH-CA jump gate and the VMs behind it. It holds an ephemeral
// (never-persisted) ed25519 keypair generated once at first use and LOCALLY
// self-signs a short-lived user certificate for it on demand (refreshing
// shortly before expiry) using this client's own persistent user CA. It
// verifies host certificates against the eitri host CA. The user CA's public
// key must be registered with the tenant (see Register) so VMs trust the
// certs this client signs.
type GateAuth struct {
	api       CertAuthority
	userCA    ssh.Signer // this client's persistent user CA; signs user certs locally
	tenant    string     // this client's tenant; connect names are <tenant>.<vm>
	loginUser string     // the guest account this authenticates as; also the cert principal
	now       func() time.Time

	mu         sync.Mutex
	ephemeral  ssh.Signer    // ephemeral SSH keypair; generated lazily, once
	ca         ssh.PublicKey // eitri host CA; fetched lazily, once
	cert       *ssh.Certificate
	certSigner ssh.Signer // wraps cert + ephemeral; cached alongside cert
	registered bool       // true once the user CA has been uploaded (once-guard)
}

// NewGateAuth constructs a GateAuth backed by api, self-signing user certs with
// userCA and dialing VMs under tenant as loginUser. loginUser is both the SSH
// account the dialer logs in as (see Dial) and the sole principal the minted
// cert carries, so the login user and the authorizing principal are one value
// and cannot diverge. If now is nil, time.Now is used. The ephemeral keypair
// and host CA key are NOT fetched here; both are established lazily on first use
// so construction cannot fail.
func NewGateAuth(api CertAuthority, userCA ssh.Signer, tenant, loginUser string, now func() time.Time) *GateAuth {
	if now == nil {
		now = time.Now
	}
	return &GateAuth{api: api, userCA: userCA, tenant: tenant, loginUser: loginUser, now: now}
}

// LoginUser returns the guest account this client authenticates as, which is
// also the principal its self-signed certs carry. Dial reads the SSH login user
// from here so it and the cert principal are guaranteed to match.
func (g *GateAuth) LoginUser() string { return g.loginUser }

// Register uploads this client's user-CA public key to its tenant so VMs trust
// certs it signs. Idempotent; safe to call at startup before creating VMs.
func (g *GateAuth) Register(ctx context.Context) error {
	g.mu.Lock()
	defer g.mu.Unlock()
	if g.registered {
		return nil
	}
	line := strings.TrimSpace(string(ssh.MarshalAuthorizedKey(g.userCA.PublicKey())))
	if err := g.api.UploadUserCA(ctx, g.tenant, line); err != nil {
		return fmt.Errorf("registering user CA: %w", err)
	}
	g.registered = true
	return nil
}

// Signer returns an ssh.Signer backed by a cached, cert-signed identity,
// self-signing (or re-signing, if the cached cert is missing or expires within
// a minute) as needed.
func (g *GateAuth) Signer(ctx context.Context) (ssh.Signer, error) {
	g.mu.Lock()
	defer g.mu.Unlock()

	if g.ephemeral == nil {
		signer, err := newEphemeralSigner()
		if err != nil {
			return nil, fmt.Errorf("generating ephemeral SSH key: %w", err)
		}
		g.ephemeral = signer
	}

	if g.needsMintLocked() {
		cert, err := g.signCertLocked()
		if err != nil {
			return nil, fmt.Errorf("signing user certificate: %w", err)
		}
		certSigner, err := ssh.NewCertSigner(cert, g.ephemeral)
		if err != nil {
			return nil, fmt.Errorf("wrapping signed certificate: %w", err)
		}
		g.cert = cert
		g.certSigner = certSigner
	}

	return g.certSigner, nil
}

// signCertLocked self-signs a short-lived user certificate for g.ephemeral's
// public key using g.userCA. Callers must hold g.mu. Mirrors the (removed)
// server-side minter's cert shape.
func (g *GateAuth) signCertLocked() (*ssh.Certificate, error) {
	var serial uint64
	if err := binary.Read(rand.Reader, binary.BigEndian, &serial); err != nil {
		return nil, err
	}
	now := g.now()
	cert := &ssh.Certificate{
		Key:             g.ephemeral.PublicKey(),
		Serial:          serial,
		CertType:        ssh.UserCert,
		KeyId:           g.loginUser,
		ValidPrincipals: []string{g.loginUser},
		ValidAfter:      uint64(now.Add(-time.Minute).Unix()), // small skew backdate
		ValidBefore:     uint64(now.Add(30 * time.Minute).Unix()),
		Permissions: ssh.Permissions{Extensions: map[string]string{
			"permit-pty": "", "permit-port-forwarding": "", "permit-user-rc": "", "permit-agent-forwarding": "",
		}},
	}
	if err := cert.SignCert(rand.Reader, g.userCA); err != nil {
		return nil, err
	}
	return cert, nil
}

// ConnectName returns the gate connect name for vmName — "<tenant>.<vmName>",
// the form the gate resolves and the VM's host-cert principal matches. g.tenant
// is non-empty by construction (the caller validates it).
func (g *GateAuth) ConnectName(ctx context.Context, vmName string) (string, error) {
	if _, err := g.Signer(ctx); err != nil {
		return "", err
	}
	g.mu.Lock()
	defer g.mu.Unlock()
	return names.ConnectName(g.tenant, vmName), nil
}

// needsMintLocked reports whether the cached cert is absent or expires
// within a minute of now(). Callers must hold g.mu.
func (g *GateAuth) needsMintLocked() bool {
	if g.cert == nil {
		return true
	}
	if g.cert.ValidBefore == ssh.CertTimeInfinity {
		return false
	}
	return g.now().Add(time.Minute).Unix() >= int64(g.cert.ValidBefore)
}

// HostKeyCallback returns an ssh.HostKeyCallback that accepts only host
// certificates signed by the eitri CA, lazily fetching the CA (once) on
// first invocation.
func (g *GateAuth) HostKeyCallback() ssh.HostKeyCallback {
	checker := &ssh.CertChecker{
		IsHostAuthority: func(auth ssh.PublicKey, address string) bool {
			// ssh.HostKeyCallback has no ctx param, so caller cancellation
			// cannot reach here; the fetch deadline is the API client's HTTP
			// timeout (30s), not the SSH handshake context.
			ca, err := g.caKey(context.Background())
			if err != nil {
				return false
			}
			return caEquals(auth, ca)
		},
	}
	return checker.CheckHostKey
}

// caKey returns the cached eitri CA public key, fetching it (once) if not
// already cached.
func (g *GateAuth) caKey(ctx context.Context) (ssh.PublicKey, error) {
	g.mu.Lock()
	defer g.mu.Unlock()

	if g.ca != nil {
		return g.ca, nil
	}
	// g.mu is deliberately held across this network call: single-flight fetch
	// so concurrent callbacks share one FetchSSHCA rather than stampeding.
	ca, err := g.api.FetchSSHCA(ctx)
	if err != nil {
		return nil, fmt.Errorf("fetching SSH CA: %w", err)
	}
	g.ca = ca
	return g.ca, nil
}

// caEquals reports whether two SSH public keys are the same key, by
// comparing their wire encodings.
func caEquals(a, b ssh.PublicKey) bool {
	if a == nil || b == nil {
		return false
	}
	return bytes.Equal(a.Marshal(), b.Marshal())
}

// newEphemeralSigner generates a fresh, never-persisted ed25519 SSH signer.
func newEphemeralSigner() (ssh.Signer, error) {
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, err
	}
	return ssh.NewSignerFromSigner(priv)
}