feat(security): instance CA + service identity certs (M1)

- lib/pki: root CA provisioning on first run (~/.dcntrlized/pki/), per-service
  leaf identity issuance (90d, refreshed at start), revocation with CRL
- main: EnsureCA on startup
- start: inject CA + identity mounts and AUTH_* mTLS env into compose services
- uninstall: revoke the service's cert (CRL entry published)
This commit is contained in:
2026-08-15 23:10:12 +01:00
parent a172b542b7
commit dc79629c7e
13 changed files with 992 additions and 1 deletions
+167
View File
@@ -0,0 +1,167 @@
// 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
}