a73x

internal/server/delegation/delegation.go

Ref:   Size: 12.3 KiB   History

// Package delegation holds the credentials a tenant has lent eitri.
//
// eitri generates an ephemeral keypair per tenant and keeps it in memory. The
// tenant signs its public half with their own CA, on their own terms — their
// TTL, their principals — and posts the certificate back. eitri then
// authenticates to that tenant's guests as key-plus-certificate until the
// certificate expires, and holds nothing else. There is no signing key here for
// anyone: the most privileged thing eitri can possess is a certificate that runs
// out.
//
// Nothing is persisted. A restart is a revocation, which is the point, and the
// certificate's own expiry is the second bound. Because the delegated
// certificate chains to a CA the tenant has already registered, guests created
// long before the delegation accept it — a delegation is a credential, not a
// change to what a guest trusts.
//
// The package is a leaf on purpose: every validation rule lives here, where it
// can be tested exhaustively and cheaply, and the HTTP and MCP layers above stay
// dumb.
package delegation

import (
	"bytes"
	"crypto/ed25519"
	"crypto/rand"
	"errors"
	"fmt"
	"slices"
	"strings"
	"sync"
	"time"

	"golang.org/x/crypto/ssh"
)

// defaultPendingGrace is how long Sweep keeps a keypair whose certificate has
// not come back yet. An hour is far longer than a human takes to run one
// ssh-keygen and far shorter than a handful of abandoned keypairs is worth
// worrying about.
const defaultPendingGrace = time.Hour

// Keyring holds one ephemeral keypair per tenant and, once delegated, the
// certificate that makes it usable.
type Keyring struct {
	// Now is the clock every validity decision is made against. Injected so the
	// expiry rules are testable without sleeping.
	Now func() time.Time
	// Principal is the guest login user every delegated certificate must name.
	// A guest trusts its tenant's CA set through a bare TrustedUserCAKeys line
	// with no AuthorizedPrincipalsFile, so sshd matches the certificate's
	// principals against the user being logged in as — not against the tenant.
	Principal string

	mu sync.Mutex
	// pendingGrace is how long a begun-but-unsigned entry survives Sweep. It
	// is unexported and set from defaultPendingGrace in New: how long eitri
	// waits for a certificate is this package's rule, and a caller that could
	// pin its own would be setting a security bound from the outside.
	pendingGrace time.Duration
	tenants      map[string]*entry
}

type entry struct {
	key        ssh.Signer // ephemeral, generated once per tenant per process
	began      time.Time  // when the keypair was minted; what Sweep's grace runs from
	cert       *ssh.Certificate
	certSigner ssh.Signer
}

// Delegation is the public description of a live delegation. Every field is
// public material: eitri's half is a key it holds only in memory, and a
// certificate is not a secret.
type Delegation struct {
	PublicKey     string    // the ephemeral public key, authorized_keys form
	CAFingerprint string    // SHA256 fingerprint of the CA that signed
	KeyID         string    // the certificate's key id, as the signer set it
	Serial        uint64    // the certificate's serial, for revocation
	Principals    []string  // the certificate's principals
	ExpiresAt     time.Time // when eitri stops being able to reach anything
}

// New builds an empty keyring. principal is the guest login user delegated
// certificates must name.
func New(now func() time.Time, principal string) *Keyring {
	if now == nil {
		now = time.Now
	}
	return &Keyring{Now: now, Principal: principal, pendingGrace: defaultPendingGrace, tenants: map[string]*entry{}}
}

// Begin returns the public key this tenant is to sign, generating the keypair
// on first call and returning the same key on every call afterwards for as long
// as the entry lives.
//
// Stability is deliberate. Re-delegating after an expiry is then one ssh-keygen
// and one Complete, with no round trip to re-fetch a key that has not changed.
// Two things end it, and Complete's wrong-key refusal names both: a restart,
// which mints a new key because nothing is persisted, and a begin left unsigned
// past Sweep's grace, which abandons a keypair no certificate ever came back
// for. The public key is not a secret in any sense that matters: it is useless
// without a certificate, and eitri cannot make itself one.
func (k *Keyring) Begin(tenant string) (string, error) {
	k.mu.Lock()
	defer k.mu.Unlock()
	e, err := k.entryLocked(tenant)
	if err != nil {
		return "", err
	}
	return authorizedLine(e.key.PublicKey()), nil
}

// Complete accepts the certificate a tenant signed over this tenant's ephemeral
// key, and puts it to work.
//
// trusted reports whether a public key is a CA THIS TENANT has registered. It
// is a callback so the package stays free of the store; boot supplies the real
// lookup. Every refusal below says what to do next, because every one of them
// is a mistake a caller can fix — and an unexplained refusal here resurfaces
// three tool calls later as an unexplained SSH failure.
func (k *Keyring) Complete(tenant, certLine string, trusted func(ssh.PublicKey) (bool, error)) (Delegation, error) {
	k.mu.Lock()
	defer k.mu.Unlock()
	e, err := k.entryLocked(tenant)
	if err != nil {
		return Delegation{}, err
	}

	pub, _, _, _, err := ssh.ParseAuthorizedKey([]byte(certLine))
	if err != nil {
		return Delegation{}, errors.New("that is not an SSH certificate — paste the whole contents of the " +
			"*-cert.pub file ssh-keygen wrote, on one line")
	}
	cert, ok := pub.(*ssh.Certificate)
	if !ok {
		return Delegation{}, errors.New("that is a public key, not a certificate — sign it with your own CA " +
			"(`ssh-keygen -s <your-ca> -I eitri-delegation -n " + k.Principal + " -V +8h <file>.pub`) and send the " +
			"*-cert.pub it writes")
	}
	if cert.CertType != ssh.UserCert {
		return Delegation{}, errors.New("that is a HOST certificate; eitri authenticates as a user, so it needs a " +
			"user certificate — sign without `-h`")
	}
	if !bytes.Equal(cert.Key.Marshal(), e.key.PublicKey().Marshal()) {
		// Name the key that IS current. The cause is always a certificate
		// signed over a key this keyring no longer holds, and the two ways
		// that happens need different fixes, so the refusal names both. Without
		// the fingerprint to compare against there is nothing to act on either.
		return Delegation{}, fmt.Errorf("that certificate is over %s, but this tenant's current delegation key is "+
			"%s — the key changes when the control plane restarts, and when a delegation is begun and left "+
			"unsigned for longer than %s. Call delegate_begin again and sign the key it returns",
			ssh.FingerprintSHA256(cert.Key), ssh.FingerprintSHA256(e.key.PublicKey()), k.pendingGrace)
	}

	ok, err = trusted(cert.SignatureKey)
	if err != nil {
		return Delegation{}, errors.New("checking which CA signed that certificate failed")
	}
	if !ok {
		return Delegation{}, fmt.Errorf("that certificate was signed by %s, which is not a CA registered to this "+
			"tenant — your guests would refuse it too. Register that CA (`eitri ca upload`) or sign with one you "+
			"have already registered", ssh.FingerprintSHA256(cert.SignatureKey))
	}

	// Principals are checked ahead of the full verification so the error can
	// name the fix. It is the single most likely mistake, and as a bare
	// "certificate rejected" it is close to undiagnosable.
	if !slices.Contains(cert.ValidPrincipals, k.Principal) {
		return Delegation{}, fmt.Errorf("that certificate's principals are [%s]; a guest matches the principal "+
			"against the login user, so it must include %q — re-sign with `-n %s`",
			strings.Join(cert.ValidPrincipals, " "), k.Principal, k.Principal)
	}

	// A source-address restriction is a promise no one on this path can keep.
	// eitri connects to the guest from its host, so the address the guest
	// measures is the host's, not the address of whoever asked eitri to
	// connect. Refusing here says so; accepting would hand back a delegation
	// that works until the first guest silently declines it.
	if addrs, ok := cert.CriticalOptions["source-address"]; ok {
		return Delegation{}, fmt.Errorf("that certificate is restricted to source-address %s, and a guest would "+
			"measure that against the address of the host eitri connects from, not yours — re-sign it without "+
			"`-O source-address=`", addrs)
	}

	// One call does the signature, the principal and the validity window, all
	// against this keyring's clock.
	checker := &ssh.CertChecker{
		IsUserAuthority: func(auth ssh.PublicKey) bool {
			return bytes.Equal(auth.Marshal(), cert.SignatureKey.Marshal())
		},
		Clock: k.Now,
	}
	if err := checker.CheckCert(k.Principal, cert); err != nil {
		return Delegation{}, fmt.Errorf("that certificate is not usable: %w", err)
	}

	signer, err := ssh.NewCertSigner(cert, e.key)
	if err != nil {
		return Delegation{}, fmt.Errorf("that certificate does not pair with the key: %w", err)
	}
	e.cert = cert
	e.certSigner = signer
	return describe(e), nil
}

// Signer returns the credential eitri may authenticate to this tenant's guests
// with, or false when there is none. An expired delegation is dropped here
// rather than reported, so it is indistinguishable from never having existed —
// which is what the caller needs to be told to do about it either way.
func (k *Keyring) Signer(tenant string) (ssh.Signer, bool) {
	k.mu.Lock()
	defer k.mu.Unlock()
	e, ok := k.tenants[tenant]
	if !ok || e.cert == nil {
		return nil, false
	}
	if k.expiredLocked(e) {
		e.cert, e.certSigner = nil, nil
		return nil, false
	}
	return e.certSigner, true
}

// Status describes this tenant's live delegation, if any.
func (k *Keyring) Status(tenant string) (Delegation, bool) {
	k.mu.Lock()
	defer k.mu.Unlock()
	e, ok := k.tenants[tenant]
	if !ok || e.cert == nil || k.expiredLocked(e) {
		return Delegation{}, false
	}
	return describe(e), true
}

// Revoke drops this tenant's delegation immediately. The ephemeral key stays,
// so a later Begin returns the same public key and re-delegating is one signing
// step.
func (k *Keyring) Revoke(tenant string) {
	k.mu.Lock()
	defer k.mu.Unlock()
	if e, ok := k.tenants[tenant]; ok {
		e.cert, e.certSigner = nil, nil
	}
}

// Sweep drops every entry that is not doing anything: an expired certificate,
// or a begin that was never signed and has outlived pendingGrace. It bounds the
// keyring by tenants that are delegating or delegated rather than by every
// tenant that ever called Begin.
//
// The grace is there because Complete waits on a human. The tenant reads the
// key out of Begin, signs it with a CA that may sit on a hardware token or
// behind someone else's approval, and posts the certificate back minutes later.
// Sweep runs on a timer that knows nothing about that, so without the grace a
// tick landing mid-signature rotates the key underneath the caller and Complete
// refuses a certificate that was correct when it was signed. Waiting the grace
// out costs one keypair, around a hundred bytes.
func (k *Keyring) Sweep() {
	k.mu.Lock()
	defer k.mu.Unlock()
	for tenant, e := range k.tenants {
		if e.cert == nil {
			if !k.Now().Before(e.began.Add(k.pendingGrace)) {
				delete(k.tenants, tenant)
			}
			continue
		}
		if k.expiredLocked(e) {
			delete(k.tenants, tenant)
		}
	}
}

// entryLocked returns this tenant's entry, generating its keypair on first use.
func (k *Keyring) entryLocked(tenant string) (*entry, error) {
	if e, ok := k.tenants[tenant]; ok {
		return e, nil
	}
	_, priv, err := ed25519.GenerateKey(rand.Reader)
	if err != nil {
		return nil, fmt.Errorf("generate delegation key: %w", err)
	}
	signer, err := ssh.NewSignerFromSigner(priv)
	if err != nil {
		return nil, fmt.Errorf("delegation signer: %w", err)
	}
	e := &entry{key: signer, began: k.Now()}
	k.tenants[tenant] = e
	return e, nil
}

func (k *Keyring) expiredLocked(e *entry) bool {
	return !k.Now().Before(time.Unix(int64(e.cert.ValidBefore), 0)) //nolint:gosec // ValidBefore is a unix time
}

func describe(e *entry) Delegation {
	return Delegation{
		PublicKey:     authorizedLine(e.key.PublicKey()),
		CAFingerprint: ssh.FingerprintSHA256(e.cert.SignatureKey),
		KeyID:         e.cert.KeyId,
		Serial:        e.cert.Serial,
		Principals:    slices.Clone(e.cert.ValidPrincipals),
		ExpiresAt:     time.Unix(int64(e.cert.ValidBefore), 0).UTC(), //nolint:gosec // ValidBefore is a unix time
	}
}

func authorizedLine(pub ssh.PublicKey) string {
	return strings.TrimSpace(string(ssh.MarshalAuthorizedKey(pub)))
}