Files
rollcall/cmd/rollcall/admincert.go
T
Ilker ManapandClaude Opus 5.5 53c05bb44d
CI / agent (push) Successful in 3s
CI / site (push) Successful in 2s
CI / go (push) Successful in 1m30s
Admin client certificates as an alternative to the admin token
rollcall admin-cert request NAME makes a key locally and sends a CSR; the
region signs admin:NAME (client auth) and records the serial; a verified,
recorded, unrevoked certificate is an admin. CLI/console read it from
client.json, a .p12 is written for the browser; the web UI no longer insists
on a token.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 17:44:06 +01:00

220 lines
6.3 KiB
Go

package main
// Admin client certificates: an alternative to the admin token. A browser or
// CLI that presents a certificate from the installation's CA with the name
// "admin:<name>", made for client authentication and with a serial the region
// recorded (and not revoked), is an admin. The key is made on the client; only
// a CSR travels. Issuing needs admin rights once (the token, or another admin
// certificate); revoking takes effect within the cache period (30 s).
import (
"context"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
const adminCertSchema = `
CREATE TABLE IF NOT EXISTS admin_certs (
serial text PRIMARY KEY,
name text NOT NULL,
expires timestamptz NOT NULL,
created timestamptz NOT NULL DEFAULT now(),
issued_by text,
revoked timestamptz
);
`
type AdminCert struct {
Serial string `json:"serial"`
Name string `json:"name"`
Expires time.Time `json:"expires"`
Created time.Time `json:"created"`
IssuedBy string `json:"issued_by"`
Revoked *time.Time `json:"revoked,omitempty"`
}
func (s *Store) AddAdminCert(ctx context.Context, c AdminCert) error {
_, err := s.pool.Exec(ctx, `INSERT INTO admin_certs(serial, name, expires, issued_by) VALUES ($1,$2,$3,$4)`, c.Serial, c.Name, c.Expires, c.IssuedBy)
return err
}
func (s *Store) AdminCerts(ctx context.Context) ([]AdminCert, error) {
rows, err := s.pool.Query(ctx, `SELECT serial, name, expires, created, coalesce(issued_by,''), revoked FROM admin_certs ORDER BY created`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AdminCert{}
for rows.Next() {
var c AdminCert
if err := rows.Scan(&c.Serial, &c.Name, &c.Expires, &c.Created, &c.IssuedBy, &c.Revoked); err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// RevokeAdminCerts revokes by serial or by name (all of that name); returns how many.
func (s *Store) RevokeAdminCerts(ctx context.Context, ref string) (int64, error) {
tag, err := s.pool.Exec(ctx, `UPDATE admin_certs SET revoked=now() WHERE revoked IS NULL AND (serial=$1 OR name=$1)`, strings.ToLower(ref))
if err != nil {
return 0, err
}
return tag.RowsAffected(), nil
}
func (s *Store) validAdminSerials(ctx context.Context) (map[string]string, error) {
rows, err := s.pool.Query(ctx, `SELECT serial, name FROM admin_certs WHERE revoked IS NULL AND expires > now()`)
if err != nil {
return nil, err
}
defer rows.Close()
out := map[string]string{}
for rows.Next() {
var serial, name string
if err := rows.Scan(&serial, &name); err != nil {
return nil, err
}
out[serial] = name
}
return out, rows.Err()
}
type adminCertCache struct {
mu sync.Mutex
at time.Time
serial map[string]string
}
func (a *App) adminCertsCached(ctx context.Context) map[string]string {
a.adminCC.mu.Lock()
defer a.adminCC.mu.Unlock()
if a.adminCC.serial != nil && time.Since(a.adminCC.at) < 30*time.Second || a.store == nil {
return a.adminCC.serial
}
m, err := a.store.validAdminSerials(ctx)
if err != nil {
log.Printf("admin certificates: %v", err)
return a.adminCC.serial // keep the last good view on a database hiccup
}
a.adminCC.serial, a.adminCC.at = m, time.Now()
return m
}
func (a *App) dropAdminCertCache() {
a.adminCC.mu.Lock()
a.adminCC.serial = nil
a.adminCC.mu.Unlock()
}
// peerAdmin is the admin name of a verified client certificate, "" otherwise.
func (a *App) peerAdmin(r *http.Request) string {
if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 || len(r.TLS.PeerCertificates) == 0 {
return ""
}
c := r.TLS.PeerCertificates[0]
name, ok := strings.CutPrefix(c.Subject.CommonName, "admin:")
if !ok || name == "" {
return ""
}
clientAuth := false
for _, u := range c.ExtKeyUsage {
clientAuth = clientAuth || u == x509.ExtKeyUsageClientAuth
}
if !clientAuth {
return ""
}
if a.adminCertsCached(r.Context())[c.SerialNumber.Text(16)] != name {
return ""
}
return name
}
// who describes the admin making a request, for the log.
func (a *App) who(r *http.Request) string {
if n := a.peerAdmin(r); n != "" {
return "admin:" + n
}
return "token from " + r.RemoteAddr
}
var adminNameRe = regexp.MustCompile(`^[a-z0-9][a-z0-9._-]{0,62}$`)
func (a *App) handleAdminCerts(w http.ResponseWriter, r *http.Request) {
switch r.Method {
case http.MethodGet:
cs, err := a.store.AdminCerts(r.Context())
if err != nil {
fail(w, 500, err)
return
}
writeJSON(w, 200, cs)
case http.MethodPost:
var req struct {
Name string `json:"name"`
CSR string `json:"csr"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
fail(w, 400, err)
return
}
req.Name = strings.ToLower(strings.TrimSpace(req.Name))
if !adminNameRe.MatchString(req.Name) {
fail(w, 400, errors.New("name: lower-case letters, digits, . _ - (e.g. the laptop's or person's name)"))
return
}
blk, _ := pem.Decode([]byte(req.CSR))
if blk == nil || blk.Type != "CERTIFICATE REQUEST" {
fail(w, 400, errors.New("csr: a PEM CERTIFICATE REQUEST is required (the key stays on the client)"))
return
}
csr, err := x509.ParseCertificateRequest(blk.Bytes)
if err == nil {
err = csr.CheckSignature()
}
if err != nil {
fail(w, 400, fmt.Errorf("csr: %w", err))
return
}
certPEM, cert, err := a.ca.sign(csr.PublicKey, "admin:"+req.Name, nil, x509.ExtKeyUsageClientAuth)
if err != nil {
fail(w, 500, err)
return
}
by := a.who(r)
if err := a.store.AddAdminCert(r.Context(), AdminCert{Serial: cert.SerialNumber.Text(16), Name: req.Name, Expires: cert.NotAfter, IssuedBy: by}); err != nil {
fail(w, 500, err)
return
}
a.dropAdminCertCache()
log.Printf("admin certificate %q (serial %s, until %s) issued by %s", req.Name, cert.SerialNumber.Text(16), cert.NotAfter.Format("2006-01-02"), by)
writeJSON(w, 200, obj{"name": req.Name, "serial": cert.SerialNumber.Text(16), "expires": cert.NotAfter, "cert": string(certPEM), "ca": string(a.ca.PEM)})
}
}
func (a *App) handleAdminCertRevoke(w http.ResponseWriter, r *http.Request) {
ref := r.PathValue("ref")
n, err := a.store.RevokeAdminCerts(r.Context(), ref)
if err != nil {
fail(w, 500, err)
return
}
if n == 0 {
fail(w, 404, fmt.Errorf("no valid admin certificate %q", ref))
return
}
a.dropAdminCertCache()
log.Printf("admin certificate %q revoked (%d) by %s", ref, n, a.who(r))
writeJSON(w, 200, obj{"revoked": n})
}