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:
@@ -160,6 +160,12 @@ func getDataRoot() (string, error) {
|
|||||||
return filepath.Join("/data", ".dcntrlized"), nil
|
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.
|
// 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
|
// 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
|
// writable (e.g. the container's bind mount source was removed) instead of a
|
||||||
|
|||||||
@@ -22,11 +22,16 @@ import (
|
|||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
composetypes "github.com/compose-spec/compose-go/v2/types"
|
composetypes "github.com/compose-spec/compose-go/v2/types"
|
||||||
"github.com/docker/compose/v2/pkg/api"
|
"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
|
// StartContainer creates and starts the service containers defined in the
|
||||||
// service's docker-compose.yaml.
|
// service's docker-compose.yaml.
|
||||||
func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
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.
|
// The compose file may reference the image by a short name (e.g.
|
||||||
// "testservice") while install pulled it under its full registry reference.
|
// "testservice") while install pulled it under its full registry reference.
|
||||||
// Make sure those short names exist so Up() can find them.
|
// 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
|
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
|
// 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
|
// 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.
|
// containers run pinned to a concrete semver tag instead of a moving "latest" tag.
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
"github.com/docker/compose/v2/pkg/api"
|
"github.com/docker/compose/v2/pkg/api"
|
||||||
"github.com/docker/docker/api/types/container"
|
"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)
|
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 {
|
if _, err := os.Stat(targetDir); err == nil {
|
||||||
// Stop and remove the project containers/networks via compose down when
|
// Stop and remove the project containers/networks via compose down when
|
||||||
// the compose file is available.
|
// the compose file is available.
|
||||||
@@ -84,6 +92,16 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
|||||||
return nil
|
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
|
// stopAndRemoveByLabel force-stops and removes every container carrying the
|
||||||
// org.dcntrlized.service label for the given service.
|
// org.dcntrlized.service label for the given service.
|
||||||
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
|
func stopAndRemoveByLabel(ctx context.Context, dc *docker.DockerClient, serviceName string) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
========== Start Log ==========
|
||||||
|
[INFO] Logger initialized
|
||||||
|
[2026-08-15T23:09:40+01:00]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
========== Start Log ==========
|
||||||
|
[INFO] Logger initialized
|
||||||
|
[2026-08-15T23:09:40+01:00]
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
========== Start Log ==========
|
||||||
|
[INFO] Logger initialized
|
||||||
|
[2026-08-15T23:09:40+01:00]
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
+11
@@ -28,6 +28,7 @@ import (
|
|||||||
handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry"
|
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/docker"
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed all:frontend/dist
|
//go:embed all:frontend/dist
|
||||||
@@ -52,6 +53,16 @@ func main() {
|
|||||||
if err := libcontainers.EnsureDataRoot(); err != nil {
|
if err := libcontainers.EnsureDataRoot(); err != nil {
|
||||||
log.Fatalf("Data root check failed: %v", err)
|
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 {
|
if err := cache.LoadInstalledContainers(); err != nil {
|
||||||
log.Fatalf("Failed to load installed containers: %v", err)
|
log.Fatalf("Failed to load installed containers: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user