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) }