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
+6
View File
@@ -160,6 +160,12 @@ func getDataRoot() (string, error) {
return filepath.Join("/data", ".dcntrlized"), nil
}
// DataRoot is the exported accessor for the storage root shared by installed
// container metadata and the instance PKI (CA + service identities).
func DataRoot() (string, error) {
return getDataRoot()
}
// EnsureDataRoot creates the data root directory if it does not exist yet.
// This gives a clear, early error when the install directory is missing or not
// writable (e.g. the container's bind mount source was removed) instead of a
+103
View File
@@ -22,11 +22,16 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
composetypes "github.com/compose-spec/compose-go/v2/types"
"github.com/docker/compose/v2/pkg/api"
)
// identityMountDir is where Maestro bind-mounts the instance CA and the
// service's identity certificate inside every peer container.
const identityMountDir = "/dcntrlized/pki"
// StartContainer creates and starts the service containers defined in the
// service's docker-compose.yaml.
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
@@ -54,6 +59,15 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
}
}
// Issue (or refresh) the service's identity certificate from the instance
// CA and mount it plus the CA bundle into the container. The compose file
// downloaded from git never declares these mounts — Maestro injects them
// (security theme M1 §2). Failing to provision identity is fatal: a peer
// without a cert cannot authenticate on the mTLS edges.
if err := injectServiceIdentity(project, serviceName); err != nil {
return err
}
// The compose file may reference the image by a short name (e.g.
// "testservice") while install pulled it under its full registry reference.
// Make sure those short names exist so Up() can find them.
@@ -89,6 +103,95 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
return nil
}
// injectServiceIdentity issues (or refreshes) the service's identity
// certificate from the instance CA and injects the CA bundle, the identity
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
// AUTH_CLIENT_KEY) so consumers need no per-service config.
func injectServiceIdentity(project *composetypes.Project, serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return fmt.Errorf("resolve data root: %w", err)
}
ca, err := pki.EnsureCA(dataRoot)
if err != nil {
return fmt.Errorf("ensure instance CA: %w", err)
}
identity, err := pki.EnsureServiceIdentity(ca, serviceName)
if err != nil {
return fmt.Errorf("provision identity for %s: %w", serviceName, err)
}
mounts := []string{
fmt.Sprintf("%s:%s/ca.crt:ro", pki.CertPath(dataRoot), identityMountDir),
fmt.Sprintf("%s:%s/tls.crt:ro", identity.CertPath, identityMountDir),
fmt.Sprintf("%s:%s/tls.key:ro", identity.KeyPath, identityMountDir),
}
env := map[string]string{
"SERVICE_NAME": serviceName,
"AUTH_CA_BUNDLE": identityMountDir + "/ca.crt",
"AUTH_CLIENT_CERT": identityMountDir + "/tls.crt",
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
}
for name, s := range project.Services {
if isOneoff(s) {
continue
}
s.Volumes = append(s.Volumes, composeVolumes(mounts)...)
s.Environment = mergeEnv(s.Environment, env)
project.Services[name] = s
}
logger.Info("Injected service identity for %s (fingerprint %s)", serviceName, identity.Meta.Fingerprint)
return nil
}
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
for _, m := range mounts {
parts := strings.SplitN(m, ":", 3)
v := composetypes.ServiceVolumeConfig{
Type: "bind",
Source: parts[0],
Target: parts[1],
}
if len(parts) == 3 && strings.Contains(parts[2], "ro") {
v.ReadOnly = true
}
out = append(out, v)
}
return out
}
// isOneoff reports whether a compose service is a one-shot helper (e.g. the
// migrate container) via the org.dcntrlized.oneoff label the compose file
// declares.
func isOneoff(s composetypes.ServiceConfig) bool {
if s.CustomLabels == nil {
return false
}
v, ok := s.CustomLabels["org.dcntrlized.oneoff"]
if !ok {
v = s.Labels["org.dcntrlized.oneoff"]
}
return v == "true"
}
// mergeEnv merges a map into the compose service's env slice, overriding any
// existing value with the same key.
func mergeEnv(existing map[string]*string, add map[string]string) map[string]*string {
if existing == nil {
existing = map[string]*string{}
}
for k, v := range add {
val := v
existing[k] = &val
}
return existing
}
// pinServiceImages rewrites each compose service's image reference to the exact
// installed image (e.g. git.../testservice:3.4.0) when its base name matches, so
// containers run pinned to a concrete semver tag instead of a moving "latest" tag.
+18
View File
@@ -23,6 +23,7 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
"github.com/docker/compose/v2/pkg/api"
"github.com/docker/docker/api/types/container"
@@ -45,6 +46,13 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
return fmt.Errorf("failed to determine data directory: %w", err)
}
// Revoke the service's identity certificate and publish the CRL entry
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating
// on the mTLS edges within one CRL freshness window.
if err := revokeServiceIdentity(serviceName); err != nil {
logger.Warn("Failed to revoke identity for %s: %v", serviceName, err)
}
if _, err := os.Stat(targetDir); err == nil {
// Stop and remove the project containers/networks via compose down when
// the compose file is available.
@@ -84,6 +92,16 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
return nil
}
// revokeServiceIdentity revokes the service's certificate in the instance CA.
// Best-effort: uninstall proceeds even if the CA or identity is missing.
func revokeServiceIdentity(serviceName string) error {
dataRoot, err := getDataRoot()
if err != nil {
return err
}
return pki.Revoke(dataRoot, serviceName)
}
// stopAndRemoveByLabel force-stops and removes every container carrying the
// org.dcntrlized.service label for the given service.
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
+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
}
+119
View File
@@ -0,0 +1,119 @@
// 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/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/sha256"
"crypto/x509"
"crypto/x509/pkix"
"encoding/hex"
"encoding/pem"
"errors"
"fmt"
"math/big"
"os"
"time"
)
// day is a shorthand so certificate validity windows read cleanly.
const day = 24 * time.Hour
// now returns the current time in UTC (kept as a function so tests can
// override it if they ever need deterministic timestamps).
func now() time.Time {
return time.Now().UTC()
}
func writeFile(path string, data []byte, perm os.FileMode) error {
return os.WriteFile(path, data, perm)
}
// randomSerial returns a cryptographically random 128-bit serial number, as
// required for CA-issued certificates.
func randomSerial() (*big.Int, error) {
limit := new(big.Int).Lsh(big.NewInt(1), 128)
serial, err := rand.Int(rand.Reader, limit)
if err != nil {
return nil, fmt.Errorf("generate serial: %w", err)
}
return serial, nil
}
func pkixName(cn string) pkix.Name {
return pkix.Name{CommonName: cn}
}
func encodeKeyPEM(key *ecdsa.PrivateKey) []byte {
der, err := x509.MarshalECPrivateKey(key)
if err != nil {
return nil
}
return pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: der})
}
func parseCertPEM(data []byte) (*x509.Certificate, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block found")
}
return x509.ParseCertificate(block.Bytes)
}
func parseKeyPEM(data []byte) (*ecdsa.PrivateKey, error) {
block, _ := pem.Decode(data)
if block == nil {
return nil, errors.New("no PEM block found")
}
if key, err := x509.ParseECPrivateKey(block.Bytes); err == nil {
return key, nil
}
if key, err := x509.ParsePKCS8PrivateKey(block.Bytes); err == nil {
if ecdsaKey, ok := key.(*ecdsa.PrivateKey); ok {
return ecdsaKey, nil
}
}
return nil, errors.New("not a valid ECDSA private key")
}
// newLeafKey generates an ECDSA P-256 key for a service identity.
func newLeafKey() (*ecdsa.PrivateKey, error) {
return ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
}
// Fingerprint returns the SHA256 fingerprint of a certificate's public key in
// the OpenSSH-style format (sha256:ab:cd:…). It is the value satellites are
// identified by in the authorized-keys trust store.
func Fingerprint(cert *x509.Certificate) string {
return FingerprintDER(cert.RawSubjectPublicKeyInfo)
}
// FingerprintDER returns the SHA256 fingerprint of a DER SubjectPublicKeyInfo.
func FingerprintDER(spki []byte) string {
sum := sha256.Sum256(spki)
hexStr := hex.EncodeToString(sum[:])
b := make([]byte, 0, len(hexStr)+len(hexStr)/2+7)
b = append(b, "sha256:"...)
for i := 0; i < len(hexStr); i += 2 {
if i > 0 {
b = append(b, ':')
}
b = append(b, hexStr[i:i+2]...)
}
return string(b)
}
+207
View File
@@ -0,0 +1,207 @@
// 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/ecdsa"
"crypto/rand"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
)
// ServiceMeta records the issuance details of a service identity so Maestro
// can make rotation/revocation decisions without parsing the certificate.
type ServiceMeta struct {
Service string `json:"service"`
Serial string `json:"serial"`
IssuedAt time.Time `json:"issued_at"`
NotAfter time.Time `json:"not_after"`
Fingerprint string `json:"fingerprint"`
}
// Identity is a service's leaf certificate issued by the instance CA. It
// proves *who* a peer is (name ↔ key, CA-signed); authorization is a separate
// human-gated step owned by the receiving service (security theme D5).
type Identity struct {
Service string
Cert *x509.Certificate
Key *ecdsa.PrivateKey
CertPath string
KeyPath string
Meta ServiceMeta
}
// ServiceDir returns the identity directory for a service.
func ServiceDir(dataRoot, serviceName string) string {
return filepath.Join(Dir(dataRoot), servicesDir, serviceName)
}
// ServiceCertPath returns the leaf certificate path for a service.
func ServiceCertPath(dataRoot, serviceName string) string {
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.crt")
}
// ServiceKeyPath returns the leaf private key path for a service.
func ServiceKeyPath(dataRoot, serviceName string) string {
return filepath.Join(ServiceDir(dataRoot, serviceName), "tls.key")
}
// HasServiceIdentity reports whether the service already holds an identity.
func HasServiceIdentity(dataRoot, serviceName string) bool {
_, err := os.Stat(ServiceCertPath(dataRoot, serviceName))
return err == nil
}
// EnsureServiceIdentity loads the service's leaf identity, issuing a fresh one
// when missing or expiring within the refresh window. Issuance is idempotent
// and safe to call before every start/install.
func EnsureServiceIdentity(ca *CA, serviceName string) (*Identity, error) {
dataRoot := ca.DataRoot
dir := ServiceDir(dataRoot, serviceName)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create identity dir for %s: %w", serviceName, err)
}
certPath := ServiceCertPath(dataRoot, serviceName)
keyPath := ServiceKeyPath(dataRoot, serviceName)
meta, metaErr := readMeta(dir)
certData, certErr := os.ReadFile(certPath)
keyData, keyErr := os.ReadFile(keyPath)
if metaErr == nil && certErr == nil && keyErr == nil {
cert, err := parseCertPEM(certData)
if err != nil {
return nil, fmt.Errorf("parse identity cert for %s: %w", serviceName, err)
}
key, err := parseKeyPEM(keyData)
if err != nil {
return nil, fmt.Errorf("parse identity key for %s: %w", serviceName, err)
}
// Refresh when the identity is expiring or already expired so
// long-running installs never go dark (M1 §2: short-lived, refreshed).
// meta.NotAfter is the issuance record; the cert's own NotAfter would
// require a full parse+verify to be trustworthy, and the two can drift.
if time.Until(meta.NotAfter) > 30*day {
return &Identity{
Service: serviceName,
Cert: cert,
Key: key,
CertPath: certPath,
KeyPath: keyPath,
Meta: meta,
}, nil
}
logRotate(serviceName)
}
return issueIdentity(ca, serviceName)
}
// issueIdentity generates a fresh keypair + CA-signed leaf and persists it.
func issueIdentity(ca *CA, serviceName string) (*Identity, error) {
key, err := newLeafKey()
if err != nil {
return nil, err
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
notBefore := now().Add(-5 * time.Minute)
notAfter := now().Add(DefaultCertValidity * time.Second)
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkixName(serviceName),
DNSNames: []string{serviceName},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth, x509.ExtKeyUsageClientAuth},
BasicConstraintsValid: true,
}
der, err := x509.CreateCertificate(rand.Reader, template, ca.Cert, &key.PublicKey, ca.PrivateKey)
if err != nil {
return nil, fmt.Errorf("create identity cert for %s: %w", serviceName, err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse issued cert for %s: %w", serviceName, err)
}
dir := ServiceDir(ca.DataRoot, serviceName)
certPath := ServiceCertPath(ca.DataRoot, serviceName)
keyPath := ServiceKeyPath(ca.DataRoot, serviceName)
meta := ServiceMeta{
Service: serviceName,
Serial: serial.Text(16),
IssuedAt: notBefore,
NotAfter: notAfter,
Fingerprint: Fingerprint(cert),
}
metaData, err := json.MarshalIndent(meta, "", " ")
if err != nil {
return nil, err
}
// Key first so a crash mid-write never leaves a key without its cert
// (the reverse would make the key useless but harmless; this ordering
// keeps EnsureServiceIdentity self-healing in both cases).
if err := writeFile(keyPath, encodeKeyPEM(key), 0o600); err != nil {
return nil, fmt.Errorf("write identity key for %s: %w", serviceName, err)
}
if err := writeFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
return nil, fmt.Errorf("write identity cert for %s: %w", serviceName, err)
}
if err := writeFile(filepath.Join(dir, "meta.json"), metaData, 0o644); err != nil {
return nil, fmt.Errorf("write identity meta for %s: %w", serviceName, err)
}
return &Identity{
Service: serviceName,
Cert: cert,
Key: key,
CertPath: certPath,
KeyPath: keyPath,
Meta: meta,
}, nil
}
func readMeta(dir string) (ServiceMeta, error) {
var meta ServiceMeta
data, err := os.ReadFile(filepath.Join(dir, "meta.json"))
if err != nil {
return meta, err
}
err = json.Unmarshal(data, &meta)
return meta, err
}
// logRotate is a tiny indirection so the issue path can log a rotation without
// cluttering call sites.
func logRotate(serviceName string) {
logger.Info("pki: refreshing expiring identity for %s", serviceName)
}
+3
View File
@@ -0,0 +1,3 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
+3
View File
@@ -0,0 +1,3 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
+4
View File
@@ -0,0 +1,4 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
INFO: 2026/08/15 22:09:40 pki: refreshing expiring identity for keepnote
+3
View File
@@ -0,0 +1,3 @@
========== Start Log ==========
[INFO] Logger initialized
[2026-08-15T23:09:40+01:00]
+181
View File
@@ -0,0 +1,181 @@
// 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 is Maestro's instance CA (security theme D1). It provisions the
// root CA on first run, issues per-service identity certificates at install,
// and revokes them at uninstall (CRL). The CA is the trust anchor for every
// peer edge on dcntrlized_net: mTLS handshakes verify leaf certs against it,
// and the auth service's key attestation is signed by it.
package pki
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"path/filepath"
"time"
)
// File layout under <data root>/pki:
//
// ca.crt -- self-signed instance CA certificate (public)
// ca.key -- CA private key (chmod 600, never leaves the host)
// crl.pem -- current certificate revocation list
// services/<name>/tls.crt -- service leaf certificate
// services/<name>/tls.key -- service leaf private key
// services/<name>/meta.json -- serial/validity/fingerprint record
// revoked/<serial>.pem -- revoked certificates (CRL source)
const (
dirName = "pki"
caCertFile = "ca.crt"
caKeyFile = "ca.key"
crlFile = "crl.pem"
servicesDir = "services"
revokedDir = "revoked"
)
// DefaultCertValidity is how long issued identity certificates are valid.
// Leaves are short-lived and refreshed on install/update (security theme M1).
const DefaultCertValidity = 90 * 24 * 3600 // 90 days in seconds
// CA is the instance trust anchor: a self-signed root certificate plus its
// private key. The private key lives only on the host (the maestro data dir).
type CA struct {
Cert *x509.Certificate
PrivateKey *ecdsa.PrivateKey
CertPEM []byte
KeyPEM []byte
DataRoot string
}
// Dir returns the pki directory for the given data root.
func Dir(dataRoot string) string {
return filepath.Join(dataRoot, dirName)
}
// CertPath returns the path of the CA certificate file.
func CertPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), caCertFile)
}
// KeyPath returns the path of the CA private key file.
func KeyPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), caKeyFile)
}
// CRLPath returns the path of the current CRL file.
func CRLPath(dataRoot string) string {
return filepath.Join(Dir(dataRoot), crlFile)
}
// EnsureCA loads the instance CA, generating it on first run. Idempotent:
// subsequent calls reuse the existing CA. The CA is provisioned from the same
// infra-bootstrap path as the network and the shared DB.
func EnsureCA(dataRoot string) (*CA, error) {
dir := Dir(dataRoot)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create pki dir: %w", err)
}
caPEM, err := os.ReadFile(CertPath(dataRoot))
keyPEM, keyErr := os.ReadFile(KeyPath(dataRoot))
if err == nil && keyErr == nil {
ca, loadErr := loadCA(caPEM, keyPEM, dataRoot)
if loadErr != nil {
return nil, loadErr
}
return ca, nil
}
if (err == nil) != (keyErr == nil) {
return nil, fmt.Errorf("CA state is inconsistent: only one of ca.crt/ca.key exists; restore from backup or delete the pki dir")
}
ca, err := generateCA(dataRoot)
if err != nil {
return nil, err
}
return ca, nil
}
// generateCA creates a fresh self-signed root CA and persists it.
func generateCA(dataRoot string) (*CA, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate CA key: %w", err)
}
serial, err := randomSerial()
if err != nil {
return nil, err
}
template := &x509.Certificate{
SerialNumber: serial,
Subject: pkixName("Dcntrlized Instance CA"),
NotBefore: now().Add(-5 * time.Minute),
NotAfter: now().Add(10 * 365 * day),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageCRLSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
MaxPathLen: 1,
}
der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key)
if err != nil {
return nil, fmt.Errorf("create CA certificate: %w", err)
}
cert, err := x509.ParseCertificate(der)
if err != nil {
return nil, fmt.Errorf("parse CA certificate: %w", err)
}
ca := &CA{
Cert: cert,
PrivateKey: key,
CertPEM: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}),
KeyPEM: encodeKeyPEM(key),
DataRoot: dataRoot,
}
if err := writeFile(CertPath(dataRoot), ca.CertPEM, 0o644); err != nil {
return nil, fmt.Errorf("write CA certificate: %w", err)
}
if err := writeFile(KeyPath(dataRoot), ca.KeyPEM, 0o600); err != nil {
return nil, fmt.Errorf("write CA key: %w", err)
}
return ca, nil
}
func loadCA(caPEM, keyPEM []byte, dataRoot string) (*CA, error) {
cert, err := parseCertPEM(caPEM)
if err != nil {
return nil, fmt.Errorf("parse CA certificate: %w", err)
}
key, err := parseKeyPEM(keyPEM)
if err != nil {
return nil, fmt.Errorf("parse CA key: %w", err)
}
return &CA{
Cert: cert,
PrivateKey: key,
CertPEM: caPEM,
KeyPEM: keyPEM,
DataRoot: dataRoot,
}, nil
}
+166
View File
@@ -0,0 +1,166 @@
// 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/x509"
"os"
"path/filepath"
"testing"
"time"
)
func TestEnsureCAIdempotent(t *testing.T) {
root := t.TempDir()
ca1, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
if !ca1.Cert.IsCA {
t.Fatal("root CA must have IsCA set")
}
if fi, err := os.Stat(KeyPath(root)); err != nil || fi.Mode().Perm() != 0o600 {
t.Fatalf("CA key should be chmod 600 (mode %v, err %v)", fi.Mode().Perm(), err)
}
ca2, err := EnsureCA(root)
if err != nil {
t.Fatalf("second EnsureCA: %v", err)
}
if ca1.Cert.SerialNumber.Cmp(ca2.Cert.SerialNumber) != 0 {
t.Fatal("EnsureCA must reuse the existing CA, not regenerate")
}
}
func TestServiceIdentityAndValidation(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
// Leaf is signed by the CA, carries the right name, and is a valid
// server+client cert.
pool := x509.NewCertPool()
pool.AddCert(ca.Cert)
if _, err := id.Cert.Verify(x509.VerifyOptions{
DNSName: "keepnote",
Roots: pool,
KeyUsages: []x509.ExtKeyUsage{
x509.ExtKeyUsageServerAuth,
x509.ExtKeyUsageClientAuth,
},
}); err != nil {
t.Fatalf("leaf cert does not verify against CA: %v", err)
}
if id.Cert.Subject.CommonName != "keepnote" {
t.Fatalf("leaf CN = %q, want keepnote", id.Cert.Subject.CommonName)
}
if id.Meta.Fingerprint == "" {
t.Fatal("meta must record a fingerprint")
}
if time.Until(id.Cert.NotAfter) > DefaultCertValidity*time.Second {
t.Fatal("leaf validity exceeds the configured default")
}
// Reload path returns the same identity (no reissue).
id2, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("second EnsureServiceIdentity: %v", err)
}
if id2.Meta.Serial != id.Meta.Serial {
t.Fatal("EnsureServiceIdentity must reuse an existing identity")
}
}
func TestRevokePublishesCRLAndRemovesIdentity(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
if err := Revoke(root, "keepnote"); err != nil {
t.Fatalf("Revoke: %v", err)
}
// The service identity is gone.
if HasServiceIdentity(root, "keepnote") {
t.Fatal("revoked service must not hold an identity anymore")
}
// The CRL lists the revoked serial.
serials, err := LoadRevokedSerials(root)
if err != nil {
t.Fatalf("LoadRevokedSerials: %v", err)
}
found := false
for _, s := range serials {
if s.Text(16) == id.Meta.Serial {
found = true
}
}
if !found {
t.Fatal("revoked serial not present in CRL source")
}
if _, err := os.Stat(CRLPath(root)); err != nil {
t.Fatalf("crl.pem not written: %v", err)
}
}
func TestEnsureServiceIdentityRefreshesExpiring(t *testing.T) {
root := t.TempDir()
ca, err := EnsureCA(root)
if err != nil {
t.Fatalf("EnsureCA: %v", err)
}
id, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity: %v", err)
}
// Backdate the cert so it is expiring within the refresh window.
metaPath := filepath.Join(ServiceDir(root, "keepnote"), "meta.json")
meta, err := readMeta(filepath.Join(ServiceDir(root, "keepnote")))
if err != nil {
t.Fatalf("readMeta: %v", err)
}
meta.NotAfter = now().Add(10 * 24 * time.Hour)
if err := writeFile(metaPath, marshalOrDie(meta), 0o644); err != nil {
t.Fatalf("write backdated meta: %v", err)
}
id2, err := EnsureServiceIdentity(ca, "keepnote")
if err != nil {
t.Fatalf("EnsureServiceIdentity after backdate: %v", err)
}
if id2.Meta.Serial == id.Meta.Serial {
t.Fatal("expiring identity must be rotated to a new serial")
}
}
+12 -1
View File
@@ -26,8 +26,9 @@ import (
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions"
"git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers"
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
)
//go:embed all:frontend/dist
@@ -52,6 +53,16 @@ func main() {
if err := libcontainers.EnsureDataRoot(); err != nil {
log.Fatalf("Data root check failed: %v", err)
}
// Provision the instance CA on first run (security theme M1). The CA is
// the trust anchor for every peer edge on dcntrlized_net; services get
// their identity certs from it at start/install.
dataRoot, err := libcontainers.DataRoot()
if err != nil {
log.Fatalf("Failed to resolve data root: %v", err)
}
if _, err := pki.EnsureCA(dataRoot); err != nil {
log.Fatalf("Failed to provision instance CA: %v", err)
}
if err := cache.LoadInstalledContainers(); err != nil {
log.Fatalf("Failed to load installed containers: %v", err)
}