diff --git a/src/lib/containers/install.go b/src/lib/containers/install.go index 753711e..ccdf09f 100644 --- a/src/lib/containers/install.go +++ b/src/lib/containers/install.go @@ -30,6 +30,7 @@ import ( "syscall" "time" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" "git.dcntrlized.org/Dcntrlized/Maestro/lib/types" @@ -70,6 +71,20 @@ func InstallContainer(ctx context.Context, item types.RegistryContainer) error { RegistryContainer: item, 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") if err := writeMetadata(metadataPath, metadata); err != nil { 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) } + +// 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 +} diff --git a/src/lib/containers/start.go b/src/lib/containers/start.go index c277fc3..ff050a4 100644 --- a/src/lib/containers/start.go +++ b/src/lib/containers/start.go @@ -18,6 +18,7 @@ package containers import ( "context" "fmt" + "path/filepath" "strings" "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 // (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 { + if err := injectServiceIdentity(ctx, project, serviceName); err != nil { 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 // 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 { +// +// 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() if err != nil { 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", } + // 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 { if isOneoff(s) { continue @@ -147,6 +161,41 @@ func injectServiceIdentity(project *composetypes.Project, serviceName string) er 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. func composeVolumes(mounts []string) []composetypes.ServiceVolumeConfig { out := make([]composetypes.ServiceVolumeConfig, 0, len(mounts)) diff --git a/src/lib/types/registry.go b/src/lib/types/registry.go index c1d2739..f937e95 100644 --- a/src/lib/types/registry.go +++ b/src/lib/types/registry.go @@ -58,4 +58,14 @@ type InstalledMetadata struct { // services keep advertising auth consumption and its key. HasAuth bool `json:"has_auth,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"` }