diff --git a/src/lib/containers/install.go b/src/lib/containers/install.go
index 8620a98..753711e 100644
--- a/src/lib/containers/install.go
+++ b/src/lib/containers/install.go
@@ -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
diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go
index 04e09a1..c277fc3 100644
--- a/src/lib/containers/start.go
+++ b/src/lib/containers/start.go
@@ -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.
diff --git a/src/lib/containers/uninstall.go b/src/lib/containers/uninstall.go
index 75df71d..9827198 100644
--- a/src/lib/containers/uninstall.go
+++ b/src/lib/containers/uninstall.go
@@ -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) {
diff --git a/src/lib/pki/crl.go b/src/lib/pki/crl.go
new file mode 100644
index 0000000..6da5513
--- /dev/null
+++ b/src/lib/pki/crl.go
@@ -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 .
+
+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
+}
diff --git a/src/lib/pki/helpers.go b/src/lib/pki/helpers.go
new file mode 100644
index 0000000..500501f
--- /dev/null
+++ b/src/lib/pki/helpers.go
@@ -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 .
+
+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)
+}
diff --git a/src/lib/pki/identity.go b/src/lib/pki/identity.go
new file mode 100644
index 0000000..e258132
--- /dev/null
+++ b/src/lib/pki/identity.go
@@ -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 .
+
+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)
+}
diff --git a/src/lib/pki/logs/debug.log b/src/lib/pki/logs/debug.log
new file mode 100644
index 0000000..f38b990
--- /dev/null
+++ b/src/lib/pki/logs/debug.log
@@ -0,0 +1,3 @@
+========== Start Log ==========
+[INFO] Logger initialized
+[2026-08-15T23:09:40+01:00]
diff --git a/src/lib/pki/logs/error.log b/src/lib/pki/logs/error.log
new file mode 100644
index 0000000..f38b990
--- /dev/null
+++ b/src/lib/pki/logs/error.log
@@ -0,0 +1,3 @@
+========== Start Log ==========
+[INFO] Logger initialized
+[2026-08-15T23:09:40+01:00]
diff --git a/src/lib/pki/logs/info.log b/src/lib/pki/logs/info.log
new file mode 100644
index 0000000..8eef1d3
--- /dev/null
+++ b/src/lib/pki/logs/info.log
@@ -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
diff --git a/src/lib/pki/logs/warn.log b/src/lib/pki/logs/warn.log
new file mode 100644
index 0000000..f38b990
--- /dev/null
+++ b/src/lib/pki/logs/warn.log
@@ -0,0 +1,3 @@
+========== Start Log ==========
+[INFO] Logger initialized
+[2026-08-15T23:09:40+01:00]
diff --git a/src/lib/pki/pki.go b/src/lib/pki/pki.go
new file mode 100644
index 0000000..6d5b614
--- /dev/null
+++ b/src/lib/pki/pki.go
@@ -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 .
+
+// 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 /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//tls.crt -- service leaf certificate
+// services//tls.key -- service leaf private key
+// services//meta.json -- serial/validity/fingerprint record
+// revoked/.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
+}
diff --git a/src/lib/pki/pki_test.go b/src/lib/pki/pki_test.go
new file mode 100644
index 0000000..430a47a
--- /dev/null
+++ b/src/lib/pki/pki_test.go
@@ -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 .
+
+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")
+ }
+}
diff --git a/src/main.go b/src/main.go
index 86869fd..76028e6 100644
--- a/src/main.go
+++ b/src/main.go
@@ -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)
}