Maestro is the UI/CA, not the enforcement: the auth service owns its
authorized-keys trust store. This is Maestro's side of the flow.
- lib/authmgmt: mTLS management client (Maestro's own identity cert from
the instance CA) for /internal/{attestation,authorizations,revoke,
pending}; RevokeServiceBestEffort for uninstall.
- handlers/peers: GET /api/peers, /api/peers/attestation, POST
approve/revoke.
- uninstall: revoke the service's authorization in the auth trust store
alongside the CRL identity revocation.
- frontend: TrustedServices panel (pending first-contact authorizations +
approved keys with authorize/ignore/revoke actions).
- spec.yaml: document the new endpoints and schemas.
- housekeeping: untrack pki test log artifacts, gofmt fixes in pki.
168 lines
4.9 KiB
Go
168 lines
4.9 KiB
Go
// Copyright (C) 2026 Dcntrlized
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify
|
|
// it under the terms of the GNU Affero General Public License as published
|
|
// by the Free Software Foundation, either version 3 of the License, or
|
|
// (at your option) any later version.
|
|
//
|
|
// This program is distributed in the hope that it will be useful,
|
|
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
// GNU Affero General Public License for more details.
|
|
//
|
|
// You should have received a copy of the GNU Affero General Public License
|
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
|
|
package pki
|
|
|
|
import (
|
|
"crypto/rand"
|
|
"crypto/x509"
|
|
"crypto/x509/pkix"
|
|
"encoding/json"
|
|
"encoding/pem"
|
|
"fmt"
|
|
"math/big"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// RevokedCert is the persisted record of a revoked certificate (CRL source).
|
|
type RevokedCert struct {
|
|
Serial string `json:"serial"`
|
|
Service string `json:"service"`
|
|
RevokedAt time.Time `json:"revoked_at"`
|
|
Reason string `json:"reason,omitempty"`
|
|
}
|
|
|
|
// RevokedSerialsDir returns the directory holding revoked certificates.
|
|
func RevokedSerialsDir(dataRoot string) string {
|
|
return filepath.Join(Dir(dataRoot), revokedDir)
|
|
}
|
|
|
|
// Revoke revokes a service's identity certificate: the leaf certificate is
|
|
// recorded as revoked and the current CRL is regenerated, then the service's
|
|
// identity files are removed. Peers stop accepting the cert within one CRL
|
|
// freshness window (security theme M3 §2).
|
|
func Revoke(dataRoot, serviceName string) error {
|
|
ca, err := EnsureCA(dataRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("load CA for revocation: %w", err)
|
|
}
|
|
|
|
meta, metaErr := readMeta(ServiceDir(dataRoot, serviceName))
|
|
if metaErr == nil && meta.Serial != "" {
|
|
serial := new(big.Int)
|
|
if _, ok := serial.SetString(meta.Serial, 16); ok {
|
|
if err := recordRevoked(ca, serviceName, serial, "uninstall"); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove the identity files so a revoked service cannot be restarted with
|
|
// a valid certificate (it will get a fresh one on reinstall).
|
|
if err := os.RemoveAll(ServiceDir(dataRoot, serviceName)); err != nil {
|
|
return fmt.Errorf("remove identity for %s: %w", serviceName, err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// recordRevoked persists a revoked certificate and regenerates the CRL.
|
|
func recordRevoked(ca *CA, serviceName string, serial *big.Int, reason string) error {
|
|
dir := RevokedSerialsDir(ca.DataRoot)
|
|
if err := os.MkdirAll(dir, 0o700); err != nil {
|
|
return fmt.Errorf("create revoked dir: %w", err)
|
|
}
|
|
|
|
revoked := RevokedCert{
|
|
Serial: serial.Text(16),
|
|
Service: serviceName,
|
|
RevokedAt: now(),
|
|
Reason: reason,
|
|
}
|
|
if err := writeFile(filepath.Join(dir, serial.Text(16)+".json"), marshalOrDie(revoked), 0o600); err != nil {
|
|
return fmt.Errorf("record revoked cert: %w", err)
|
|
}
|
|
|
|
return regenerateCRL(ca)
|
|
}
|
|
|
|
// LoadRevokedSerials returns the serials currently revoked for this instance.
|
|
func LoadRevokedSerials(dataRoot string) ([]*big.Int, error) {
|
|
dir := RevokedSerialsDir(dataRoot)
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return nil, nil
|
|
}
|
|
return nil, err
|
|
}
|
|
var serials []*big.Int
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".json")
|
|
serial := new(big.Int)
|
|
if _, ok := serial.SetString(name, 16); ok {
|
|
serials = append(serials, serial)
|
|
}
|
|
}
|
|
return serials, nil
|
|
}
|
|
|
|
// regenerateCRL writes crl.pem listing every currently-revoked serial. Peers
|
|
// re-fetch it with a short freshness window (security theme M3).
|
|
func regenerateCRL(ca *CA) error {
|
|
serials, err := LoadRevokedSerials(ca.DataRoot)
|
|
if err != nil {
|
|
return fmt.Errorf("load revoked serials: %w", err)
|
|
}
|
|
|
|
template := &x509.RevocationList{
|
|
Number: mustSerial(),
|
|
ThisUpdate: now(),
|
|
NextUpdate: now().Add(24 * time.Hour),
|
|
RevokedCertificates: revokedEntries(serials),
|
|
}
|
|
|
|
der, err := x509.CreateRevocationList(rand.Reader, template, ca.Cert, ca.PrivateKey)
|
|
if err != nil {
|
|
return fmt.Errorf("create CRL: %w", err)
|
|
}
|
|
if err := writeFile(CRLPath(ca.DataRoot), pem.EncodeToMemory(&pem.Block{Type: "X509 CRL", Bytes: der}), 0o644); err != nil {
|
|
return fmt.Errorf("write CRL: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func revokedEntries(serials []*big.Int) []pkix.RevokedCertificate {
|
|
entries := make([]pkix.RevokedCertificate, 0, len(serials))
|
|
for _, s := range serials {
|
|
entries = append(entries, pkix.RevokedCertificate{
|
|
SerialNumber: s,
|
|
RevocationTime: now(),
|
|
})
|
|
}
|
|
return entries
|
|
}
|
|
|
|
func mustSerial() *big.Int {
|
|
serial, err := randomSerial()
|
|
if err != nil {
|
|
panic(fmt.Sprintf("pki: failed to generate CRL number: %v", err))
|
|
}
|
|
return serial
|
|
}
|
|
|
|
func marshalOrDie(v any) []byte {
|
|
data, err := json.MarshalIndent(v, "", " ")
|
|
if err != nil {
|
|
panic(fmt.Sprintf("pki: failed to marshal: %v", err))
|
|
}
|
|
return data
|
|
}
|