M2 §5: pin auth attestation into InstalledMetadata and inject AUTH_PINNED_FINGERPRINT
Fetch the auth service's key attestation over mTLS at install time and persist the fingerprint (plus kid/issued-at) in the install record. At start, inject AUTH_PINNED_FINGERPRINT into every non-oneoff service so the shared authclient refuses a substituted key instead of silently trusting it (TOFU/D3). Best-effort: installs that cannot reach the auth service backfill the pin on the next start. Skip pinning for the auth service itself (it is the attester).
This commit is contained in:
@@ -30,6 +30,7 @@ import (
|
|||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
|
||||||
"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/types"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/types"
|
||||||
@@ -70,6 +71,20 @@ func InstallContainer(ctx context.Context, item types.RegistryContainer) error {
|
|||||||
RegistryContainer: item,
|
RegistryContainer: item,
|
||||||
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
InstalledAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2.6. Pin the auth service's signing key (security theme M2 §5): fetch its
|
||||||
|
// attestation over mTLS and store the fingerprint in the install record so
|
||||||
|
// the container gets AUTH_PINNED_FINGERPRINT at start. Best-effort — when
|
||||||
|
// the auth service is not reachable yet the pin stays empty and is retried
|
||||||
|
// on start.
|
||||||
|
if att, err := fetchAuthAttestation(ctx); err == nil {
|
||||||
|
metadata.AuthFingerprint = att.Fingerprint
|
||||||
|
metadata.AuthKid = att.Kid
|
||||||
|
metadata.AuthAttestedAt = att.IssuedAt
|
||||||
|
} else {
|
||||||
|
logger.Warn("Install %s: auth attestation not pinned (will retry at start): %v", item.Name, err)
|
||||||
|
}
|
||||||
|
|
||||||
metadataPath := filepath.Join(targetDir, "metadata.json")
|
metadataPath := filepath.Join(targetDir, "metadata.json")
|
||||||
if err := writeMetadata(metadataPath, metadata); err != nil {
|
if err := writeMetadata(metadataPath, metadata); err != nil {
|
||||||
return fmt.Errorf("failed to write metadata: %w", err)
|
return fmt.Errorf("failed to write metadata: %w", err)
|
||||||
@@ -453,3 +468,23 @@ func writeMetadata(path string, metadata types.InstalledMetadata) error {
|
|||||||
}
|
}
|
||||||
return os.WriteFile(path, data, 0644)
|
return os.WriteFile(path, data, 0644)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// fetchAuthAttestation fetches the auth service's public key statement over
|
||||||
|
// mTLS (Maestro's own identity certificate). It backs the M2 §5 TOFU pin: the
|
||||||
|
// returned fingerprint is what consumer containers are pinned to.
|
||||||
|
func fetchAuthAttestation(ctx context.Context) (*authmgmt.Attestation, error) {
|
||||||
|
client, err := authmgmt.New()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("auth management client: %w", err)
|
||||||
|
}
|
||||||
|
fetchCtx, cancel := context.WithTimeout(ctx, 8*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
att, err := client.Attestation(fetchCtx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if att.Fingerprint == "" {
|
||||||
|
return nil, fmt.Errorf("attestation carried an empty fingerprint")
|
||||||
|
}
|
||||||
|
return att, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package containers
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||||
@@ -64,7 +65,7 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
|||||||
// downloaded from git never declares these mounts — Maestro injects them
|
// downloaded from git never declares these mounts — Maestro injects them
|
||||||
// (security theme M1 §2). Failing to provision identity is fatal: a peer
|
// (security theme M1 §2). Failing to provision identity is fatal: a peer
|
||||||
// without a cert cannot authenticate on the mTLS edges.
|
// without a cert cannot authenticate on the mTLS edges.
|
||||||
if err := injectServiceIdentity(project, serviceName); err != nil {
|
if err := injectServiceIdentity(ctx, project, serviceName); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +109,12 @@ func StartContainer(ctx context.Context, item types.RegistryContainer) error {
|
|||||||
// cert/key, and the mTLS env vars into every non-oneoff compose service. The
|
// 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/
|
// env vars follow the shared-module contract (AUTH_CA_BUNDLE/AUTH_CLIENT_CERT/
|
||||||
// AUTH_CLIENT_KEY) so consumers need no per-service config.
|
// AUTH_CLIENT_KEY) so consumers need no per-service config.
|
||||||
func injectServiceIdentity(project *composetypes.Project, serviceName string) error {
|
//
|
||||||
|
// It also injects AUTH_PINNED_FINGERPRINT (M2 §5 TOFU): the auth signing key's
|
||||||
|
// fingerprint recorded at install. A service that already has the pin skips the
|
||||||
|
// fetch; one installed before this feature (or whose install could not reach
|
||||||
|
// the auth service) backfills it here, best-effort.
|
||||||
|
func injectServiceIdentity(ctx context.Context, project *composetypes.Project, serviceName string) error {
|
||||||
dataRoot, err := getDataRoot()
|
dataRoot, err := getDataRoot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("resolve data root: %w", err)
|
return fmt.Errorf("resolve data root: %w", err)
|
||||||
@@ -135,6 +141,14 @@ func injectServiceIdentity(project *composetypes.Project, serviceName string) er
|
|||||||
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
|
"AUTH_CLIENT_KEY": identityMountDir + "/tls.key",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The auth service is the attester itself — nothing to pin there. For every
|
||||||
|
// other peer, pin its signing key (fetch + persist once).
|
||||||
|
if serviceName != AuthServiceName {
|
||||||
|
if pinned := pinAuthFingerprint(ctx, serviceName); pinned != "" {
|
||||||
|
env["AUTH_PINNED_FINGERPRINT"] = pinned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for name, s := range project.Services {
|
for name, s := range project.Services {
|
||||||
if isOneoff(s) {
|
if isOneoff(s) {
|
||||||
continue
|
continue
|
||||||
@@ -147,6 +161,41 @@ func injectServiceIdentity(project *composetypes.Project, serviceName string) er
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pinAuthFingerprint returns the service's pinned auth fingerprint, fetching
|
||||||
|
// and persisting it when the install record does not have one yet (M2 §5).
|
||||||
|
// Best-effort: when the auth service is unreachable, it returns "" and the
|
||||||
|
// next start retries.
|
||||||
|
func pinAuthFingerprint(ctx context.Context, serviceName string) string {
|
||||||
|
dir, err := getContainerDataDir(serviceName)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
metadata, err := readInstalledMetadata(dir)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if metadata.AuthFingerprint != "" {
|
||||||
|
return metadata.AuthFingerprint
|
||||||
|
}
|
||||||
|
|
||||||
|
att, err := fetchAuthAttestation(ctx)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn("Auth pin for %s: attestation fetch failed (will retry at next start): %v", serviceName, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if att.Fingerprint == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
metadata.AuthFingerprint = att.Fingerprint
|
||||||
|
metadata.AuthKid = att.Kid
|
||||||
|
metadata.AuthAttestedAt = att.IssuedAt
|
||||||
|
if err := writeMetadata(filepath.Join(dir, "metadata.json"), *metadata); err != nil {
|
||||||
|
logger.Warn("Auth pin for %s: persist failed: %v", serviceName, err)
|
||||||
|
}
|
||||||
|
logger.Info("Pinned auth fingerprint %s for %s", att.Fingerprint, serviceName)
|
||||||
|
return att.Fingerprint
|
||||||
|
}
|
||||||
|
|
||||||
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
|
// composeVolumes converts "src:dst:ro" mount strings to compose volume configs.
|
||||||
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
|
func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig {
|
||||||
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
|
out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts))
|
||||||
|
|||||||
@@ -58,4 +58,14 @@ type InstalledMetadata struct {
|
|||||||
// services keep advertising auth consumption and its key.
|
// services keep advertising auth consumption and its key.
|
||||||
HasAuth bool `json:"has_auth,omitempty"`
|
HasAuth bool `json:"has_auth,omitempty"`
|
||||||
PublicKey string `json:"public_key,omitempty"`
|
PublicKey string `json:"public_key,omitempty"`
|
||||||
|
// AuthFingerprint pins the auth service's signing key after first contact
|
||||||
|
// (security theme M2 §5, TOFU/D3). It is fetched from the auth service's
|
||||||
|
// attestation at install time and injected into consumer containers as
|
||||||
|
// AUTH_PINNED_FINGERPRINT, so a substituted key is refused instead of
|
||||||
|
// silently trusted.
|
||||||
|
AuthFingerprint string `json:"auth_fingerprint,omitempty"`
|
||||||
|
// AuthKid and AuthAttestedAt record which key was pinned and when, so a
|
||||||
|
// rotation (M3) can be surfaced as an explicit re-acceptance.
|
||||||
|
AuthKid string `json:"auth_kid,omitempty"`
|
||||||
|
AuthAttestedAt string `json:"auth_attested_at,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user