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>
220 lines
6.3 KiB
Go
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})
|
|
}
|