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)
}