// 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 . 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 }