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:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user