a73x

internal/server/api/sshcert.go

Ref:   Size: 5.2 KiB   History

package api

import (
	"net/http"
	"strconv"

	"github.com/a73x/eitri/internal/server/api/types"
	"github.com/a73x/eitri/internal/server/sshca"
	"golang.org/x/crypto/ssh"
)

// SetSSHCAAuthorizedKey publishes the eitri CA public key (authorized_keys /
// known_hosts form) served by GET /api/v1/ssh-ca. Called once by main when the
// jump gate is enabled; empty ⇒ the endpoint 404s. Public material — safe to
// serve unauthenticated so clients can pin `@cert-authority` before they hold
// any credential.
func (a *API) SetSSHCAAuthorizedKey(line string) { a.sshCAKey = line }

// SetSSHGate publishes the address clients dial for the jump-gate hop
// (host:port), served on GET /api/v1/me beside the caller's tenant. Called
// once by main alongside SetSSHCAAuthorizedKey when the gate is enabled; empty
// ⇒ the plane names no gate, and a client has nothing to connect through. The
// host part MUST be the gate's host-certificate principal: a client verifies
// the name it dialed against that certificate, so any other spelling of the
// same machine fails host verification by design.
func (a *API) SetSSHGate(addr string) { a.sshGate = addr }

// handleSSHCA returns the eitri HOST CA public key so a client can write a
// `@cert-authority * <ca>` known_hosts entry and verify the gate and every VM
// host key by certificate instead of TOFU. Unauthenticated (it is public
// material); 404s when the jump gate is off.
func (a *API) handleSSHCA(w http.ResponseWriter, r *http.Request) {
	if a.sshCAKey == "" {
		http.Error(w, "ssh jump gate not enabled", http.StatusNotFound)
		return
	}
	writeJSON(w, http.StatusOK, types.SSHCAResponse{CA: a.sshCAKey})
}

// handleRevokeSSHCert revokes a specific user cert by serial so the jump gate
// rejects it at auth before its short TTL expires. Tenant-scoped, idempotent
// (re-revoking a serial is a 204 no-op). Revocation is a pure store operation —
// it does NOT depend on the minter being wired, so unlike mint it never 404s on
// a gate-off server.
//
// SCOPING (see the ssh-cert model report): eitri never mints user certs (they
// are BYO, self-signed by the tenant's own registered user CA), so there is no
// server-side serial→tenant registry to key ownership off. The cert-LINE form
// carries a signing CA we CAN resolve: if it is provably another tenant's
// registered CA, revoking is a cross-tenant act and answers 404 (no existence
// leak). Otherwise (the caller's own CA, or an unregistered CA that is nobody's)
// the revocation is filed under the CALLER's tenant. The bare-SERIAL form has no
// CA to resolve, so it is always filed under the caller's tenant.
//
// That filing is now the whole story, because ENFORCEMENT is scoped to it: the
// gate resolves a presented certificate to the tenant that registered its
// signing CA and consults only that tenant's rows. So a revocation can never
// reach past the caller, and a bare serial the caller does not own denies
// nothing. It used to deny that serial for the entire fleet — deny-only and so
// fail-safe for the guest, but an availability hole for every other tenant.
// The revocation LIST is tenant-scoped.
func (a *API) handleRevokeSSHCert(w http.ResponseWriter, r *http.Request) {
	var req types.RevokeSSHCertRequest
	if !decodeJSON(w, r, &req) {
		return
	}
	caller := principalFromContext(r).Tenant

	var serial uint64
	switch {
	case req.Certificate != "":
		// Parse the authorized-key line into a cert and take its serial. The CA
		// signature itself is not cryptographically verified here (the gate does
		// that); we only resolve the signing CA to a tenant for the ownership gate.
		// A non-cert key line is a clear 400.
		pk, _, _, _, err := ssh.ParseAuthorizedKey([]byte(req.Certificate))
		if err != nil {
			http.Error(w, "invalid certificate", http.StatusBadRequest)
			return
		}
		cert, ok := pk.(*ssh.Certificate)
		if !ok {
			http.Error(w, "not a certificate", http.StatusBadRequest)
			return
		}
		if owner, ok, err := a.st.TenantForUserCA(sshca.AuthorizedKeyLine(cert.SignatureKey)); err != nil {
			http.Error(w, "internal error", http.StatusInternalServerError)
			return
		} else if ok && owner != caller {
			http.Error(w, "not found", http.StatusNotFound)
			return
		}
		serial = cert.Serial
	case req.Serial != nil:
		serial = *req.Serial
	default:
		http.Error(w, "serial or certificate required", http.StatusBadRequest)
		return
	}

	if err := a.st.RevokeSSHCert(caller, serial, req.Reason); err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	a.audit(caller, "ssh-cert.revoke", map[string]string{
		"remote": clientIP(r),
		"serial": strconv.FormatUint(serial, 10),
		"reason": req.Reason,
	})
	w.WriteHeader(http.StatusNoContent)
}

// handleListRevokedSSHCerts lists the caller tenant's revoked cert serials (+
// reason/time), newest first.
func (a *API) handleListRevokedSSHCerts(w http.ResponseWriter, r *http.Request) {
	revoked, err := a.st.ListRevokedSSHCerts(principalFromContext(r).Tenant)
	if err != nil {
		http.Error(w, "internal error", http.StatusInternalServerError)
		return
	}
	out := make([]types.RevokedCert, len(revoked))
	for i, rc := range revoked {
		out[i] = types.RevokedCert{
			Serial:    strconv.FormatUint(rc.Serial, 10),
			RevokedAt: rc.RevokedAt,
			Reason:    rc.Reason,
		}
	}
	writeJSON(w, http.StatusOK, out)
}