From 17d53071102066e0bab415128acf39f286c0fdfd Mon Sep 17 00:00:00 2001 From: Admin <1+admin@noreply.git.dcntrlized.org> Date: Mon, 19 Oct 2026 02:39:15 +0100 Subject: [PATCH] [refactor](maestro): retire the PKI and federation identity layer Contract rev 9 removes the instance CA, client certificates, the peer listener and the trust store fleet-wide. Internal traffic is plaintext HTTP on each service single :8080 listener, so there is no peer identity left to authorize: delete lib/pki, lib/federation, devboot, the per-service database role model and the /api/peers* and /api/instances surfaces, and drop the console Trusted services UI. Config delivery now rides a dedicated ES256 token from lib/configsign rather than mTLS, and the instance fingerprint is derived from a persisted random key and is informational only. main.go also wires the surfaces introduced by the later commits, which this change is what makes possible. --- src/frontend/src/app.tsx | 9 +- .../components/service/trusted-services.tsx | 302 ---------------- src/frontend/src/lib/api/peers.ts | 49 --- src/frontend/src/lib/hooks/useTrustStore.ts | 65 ---- src/go.mod | 4 +- src/handlers/peers/peers.go | 186 ---------- src/lib/actions/actions.go | 74 ---- src/lib/authmgmt/client.go | 234 +++---------- src/lib/authmgmt/client_test.go | 54 +++ src/lib/configsign/configsign.go | 226 ++++++++++++ src/lib/configsign/configsign_test.go | 229 ++++++++++++ src/lib/containers/configdeliver.go | 71 ++-- src/lib/containers/configdeliver_test.go | 39 --- src/lib/containers/infra.go | 11 - src/lib/db/dbctl.go | 331 ------------------ src/lib/db/dbctl_integration_test.go | 84 ----- src/lib/db/dbctl_test.go | 78 ----- src/lib/devboot/devboot.go | 60 ---- src/lib/docker/client.go | 10 +- src/lib/env/env.go | 5 +- src/lib/federation/store.go | 69 ---- src/lib/instance/instance.go | 66 +++- src/lib/instance/instance_test.go | 12 +- src/lib/pki/crl.go | 167 --------- src/lib/pki/helpers.go | 139 -------- src/lib/pki/identity.go | 213 ----------- src/lib/pki/pki.go | 181 ---------- src/lib/pki/pki_test.go | 166 --------- src/main.go | 113 ++++-- 29 files changed, 735 insertions(+), 2512 deletions(-) delete mode 100644 src/frontend/src/components/service/trusted-services.tsx delete mode 100644 src/frontend/src/lib/api/peers.ts delete mode 100644 src/frontend/src/lib/hooks/useTrustStore.ts delete mode 100644 src/handlers/peers/peers.go delete mode 100644 src/lib/actions/actions.go create mode 100644 src/lib/authmgmt/client_test.go create mode 100644 src/lib/configsign/configsign.go create mode 100644 src/lib/configsign/configsign_test.go delete mode 100644 src/lib/containers/configdeliver_test.go delete mode 100644 src/lib/db/dbctl.go delete mode 100644 src/lib/db/dbctl_integration_test.go delete mode 100644 src/lib/db/dbctl_test.go delete mode 100644 src/lib/devboot/devboot.go delete mode 100644 src/lib/federation/store.go delete mode 100644 src/lib/pki/crl.go delete mode 100644 src/lib/pki/helpers.go delete mode 100644 src/lib/pki/identity.go delete mode 100644 src/lib/pki/pki.go delete mode 100644 src/lib/pki/pki_test.go diff --git a/src/frontend/src/app.tsx b/src/frontend/src/app.tsx index f0a75be..a8aad2a 100644 --- a/src/frontend/src/app.tsx +++ b/src/frontend/src/app.tsx @@ -18,7 +18,6 @@ import { useMemo, useState } from 'preact/hooks' import { useContainerStore } from '@lib/hooks/useContainerStore' import { useMasterRegistry } from '@lib/hooks/useMasterRegistry' -import { useTrustStore } from '@lib/hooks/useTrustStore' import { useAuth } from '@lib/hooks/useAuth' import { mergeServices } from '@lib/domain/merge' import { filterServices, serviceCounts } from '@lib/domain/filter' @@ -41,7 +40,6 @@ export function App() { const [query, setQuery] = useState('') const [filter, setFilter] = useState('all') const [settingsOpen, setSettingsOpen] = useState(false) - const trust = useTrustStore(settingsOpen, authed) const services = useMemo(() => { const items = registry.state.kind === 'ok' ? registry.state.items : [] @@ -87,11 +85,8 @@ export function App() { const openSettings = () => { setSettingsOpen(true) - void trust.refresh() } - const pendingTrust = trust.load.kind === 'ok' ? trust.load.state.pending.length : 0 - if (auth.gate === 'loading') { return (
@@ -114,7 +109,7 @@ export function App() { installed={counts.installed} updates={counts.updates} connection={status} - pendingTrust={pendingTrust} + pendingTrust={0} onShowUpdates={showUpdates} onOpenSettings={openSettings} /> @@ -155,8 +150,6 @@ export function App() { { setSettingsOpen(false) diff --git a/src/frontend/src/components/service/trusted-services.tsx b/src/frontend/src/components/service/trusted-services.tsx deleted file mode 100644 index d9d8167..0000000 --- a/src/frontend/src/components/service/trusted-services.tsx +++ /dev/null @@ -1,302 +0,0 @@ -/** - * 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 . - */ - -import { BadgeCheck, Check, ChevronRight, EyeOff, LoaderCircle, RotateCcw, ShieldCheck, X } from 'lucide-react' -import { Button } from '@components/ui/button' -import { Disclosure, DisclosurePanel, Heading } from 'react-aria-components' -import { toast } from 'sonner' -import { approvePeer, hidePeer, rejectPeer, revokePeer } from '@lib/api/peers' -import { useAsyncAction } from '@lib/hooks/useAsyncAction' -import type { TrustLoadState } from '@lib/hooks/useTrustStore' -import { cn } from '@lib/utils' -import type { PeerAuthorized, PeerPending } from '@lib/types' - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Unknown error' -} - -function shortFingerprint(fp: string): string { - if (fp.length <= 16) return fp - return `${fp.slice(0, 16)}…` -} - -function fmtTime(iso?: string | null): string { - if (!iso) return '' - const d = new Date(iso) - if (Number.isNaN(d.getTime())) return iso - return d.toLocaleString() -} - -interface TrustedServicesSectionProps { - load: TrustLoadState - onRefresh: () => void -} - -/** - * The trusted-services surface (security theme M2 §3): pending first-contact - * authorizations, the approved trust store, and revoked keys. Maestro is only - * the UI — the auth service owns the enforcement, so this panel is a - * convenience, not a security boundary. Rendered as a section inside the - * settings dialog. - */ -export function TrustedServicesSection({ load, onRefresh }: TrustedServicesSectionProps) { - if (load.kind === 'loading') { - return ( -
-
- ) - } - - if (load.kind === 'error') { - return ( -
-

- Couldn't reach the auth service. - {load.message} -

- -
- ) - } - - const pending = load.state.pending ?? [] - const authorized = load.state.authorized ?? [] - const active = authorized.filter((k) => !k.revoked_at && !k.hidden_at) - const revoked = authorized.filter((k) => k.revoked_at && !k.hidden_at) - - if (pending.length === 0 && active.length === 0 && revoked.length === 0) { - return ( -
-
- ) - } - - return ( -
- {pending.length > 0 && ( -
-

- Requested access ({pending.length}) -

- {pending.map((p) => ( - - ))} -
- )} - - {active.length > 0 && ( -
-

- Accepted ({active.length}) -

- {active.map((k) => ( - - ))} -
- )} - - {revoked.length > 0 && ( - - - - - - {revoked.map((k) => ( - - ))} - - - )} -
- ) -} - -function PendingRow({ entry, onDone }: { entry: PeerPending; onDone: () => void }) { - const approve = useAsyncAction(async () => { - try { - await approvePeer(entry.service, entry.fingerprint) - toast.success(`Authorized ${entry.service}`, { description: shortFingerprint(entry.fingerprint) }) - onDone() - } catch (error) { - toast.error(`Failed to authorize ${entry.service}`, { description: errorMessage(error) }) - } - }) - - const reject = useAsyncAction(async () => { - try { - await rejectPeer(entry.service, entry.fingerprint) - toast.success(`Rejected ${entry.service}`, { description: shortFingerprint(entry.fingerprint) }) - onDone() - } catch (error) { - toast.error(`Failed to reject ${entry.service}`, { description: errorMessage(error) }) - } - }) - - return ( -
-
-

- - {entry.service} -

-

{entry.fingerprint}

-

First seen {fmtTime(entry.first_seen)}

-
-
- - -
-
- ) -} - -function AuthorizedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) { - const revoke = useAsyncAction(async () => { - try { - await revokePeer(entry.service, entry.fingerprint) - toast.success(`Revoked ${entry.service}`, { description: shortFingerprint(entry.fingerprint) }) - onDone() - } catch (error) { - toast.error(`Failed to revoke ${entry.service}`, { description: errorMessage(error) }) - } - }) - - return ( -
-
-

- - {entry.service} -

-

- {entry.fingerprint} - by {entry.approved_by} -

-

Accepted {fmtTime(entry.approved_at)}

-
- -
- ) -} - -function RevokedRow({ entry, onDone }: { entry: PeerAuthorized; onDone: () => void }) { - const reauthorize = useAsyncAction(async () => { - try { - await approvePeer(entry.service, entry.fingerprint) - toast.success(`Re-authorized ${entry.service}`, { - description: shortFingerprint(entry.fingerprint), - }) - onDone() - } catch (error) { - toast.error(`Failed to re-authorize ${entry.service}`, { description: errorMessage(error) }) - } - }) - - const hide = useAsyncAction(async () => { - try { - await hidePeer(entry.service, entry.fingerprint) - toast.success(`Hidden ${entry.service}`, { - description: `${shortFingerprint(entry.fingerprint)} removed from the dashboard`, - }) - onDone() - } catch (error) { - toast.error(`Failed to hide ${entry.service}`, { description: errorMessage(error) }) - } - }) - - return ( -
-
-

- - {entry.service} -

-

- {entry.fingerprint} - by {entry.approved_by} -

-

Revoked {fmtTime(entry.revoked_at)}

-
-
- - -
-
- ) -} diff --git a/src/frontend/src/lib/api/peers.ts b/src/frontend/src/lib/api/peers.ts deleted file mode 100644 index 695e1ab..0000000 --- a/src/frontend/src/lib/api/peers.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * 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 . - */ - -import { get, post } from './client' -import type { PeerAttestation, PeerTrustState } from '../types' - -/** Fetches the pending + approved peer trust state (security theme M2 §3). */ -export function fetchTrustState(): Promise { - return get('/api/peers') -} - -/** Fetches the auth service's public signing key attestation. */ -export function fetchAttestation(): Promise { - return get('/api/peers/attestation') -} - -/** Approves a peer's fingerprint in the auth service's trust store. */ -export function approvePeer(service: string, fingerprint: string): Promise { - return post('/api/peers/approve', { service, fingerprint }) -} - -/** Revokes a peer's fingerprint (returns it to the no-op path immediately). */ -export function revokePeer(service: string, fingerprint: string): Promise { - return post('/api/peers/revoke', { service, fingerprint }) -} - -/** Rejects a pending first-contact request without authorizing it. */ -export function rejectPeer(service: string, fingerprint: string): Promise { - return post('/api/peers/reject', { service, fingerprint }) -} - -/** Permanently hides a revoked peer from the dashboard (row stays in the trust store). */ -export function hidePeer(service: string, fingerprint: string): Promise { - return post('/api/peers/hide', { service, fingerprint }) -} diff --git a/src/frontend/src/lib/hooks/useTrustStore.ts b/src/frontend/src/lib/hooks/useTrustStore.ts deleted file mode 100644 index f5958a3..0000000 --- a/src/frontend/src/lib/hooks/useTrustStore.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 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 . - */ - -import { useCallback, useEffect, useState } from 'preact/hooks' -import { fetchTrustState } from '@lib/api/peers' -import type { PeerTrustState } from '@lib/types' - -export type TrustLoadState = - | { kind: 'loading' } - | { kind: 'ok'; state: PeerTrustState } - | { kind: 'error'; message: string } - -function errorMessage(error: unknown): string { - return error instanceof Error ? error.message : 'Unknown error' -} - -/** - * Owns the peer trust state for the console. Fetches once on mount and then - * polls: every 10s while the settings dialog is open (the operator is watching - * the trust store), every 2 minutes otherwise (keeps the header pending badge - * fresh). Refreshes replace the last known state silently so an in-flight - * mutation never flashes a loading spinner over live rows; mutations also - * trigger an immediate refresh through the same hook. - */ -export function useTrustStore(open: boolean, enabled = true) { - const [load, setLoad] = useState({ kind: 'loading' }) - - const refresh = useCallback(async () => { - try { - const state = await fetchTrustState() - setLoad({ kind: 'ok', state }) - } catch (error) { - setLoad({ kind: 'error', message: errorMessage(error) }) - } - }, []) - - useEffect(() => { - if (!enabled) return - void refresh() - }, [enabled, refresh]) - - useEffect(() => { - if (!enabled) return - const interval = window.setInterval(() => { - void refresh() - }, open ? 10_000 : 120_000) - return () => window.clearInterval(interval) - }, [open, enabled, refresh]) - - return { load, refresh } -} diff --git a/src/go.mod b/src/go.mod index 1ab1956..85ba0cc 100644 --- a/src/go.mod +++ b/src/go.mod @@ -7,7 +7,9 @@ require ( github.com/docker/cli v28.5.1+incompatible github.com/docker/compose/v2 v2.40.3 github.com/docker/docker v28.5.2+incompatible + github.com/golang-jwt/jwt/v5 v5.2.2 github.com/gorilla/websocket v1.5.3 + golang.org/x/crypto v0.51.0 ) require ( @@ -60,7 +62,6 @@ require ( github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/gofrs/flock v0.12.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect - github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.8 // indirect github.com/google/go-cmp v0.7.0 // indirect @@ -136,7 +137,6 @@ require ( go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 // indirect go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sync v0.21.0 // indirect diff --git a/src/handlers/peers/peers.go b/src/handlers/peers/peers.go deleted file mode 100644 index 9236b9b..0000000 --- a/src/handlers/peers/peers.go +++ /dev/null @@ -1,186 +0,0 @@ -// 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 peers serves the trusted-services surface: pending first-contact -// authorizations and the approve/revoke actions for the instance owner -// (security theme M2 §3). Maestro is the UI; the auth service owns the -// enforcement. -package peers - -import ( - "encoding/json" - "net/http" - "sync" - - "git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt" -) - -// Handler wraps a lazily-initialized auth management client so identity -// issuance happens once per process, not per request. -type Handler struct { - mu sync.Mutex - client *authmgmt.Client -} - -// NewHandler creates the peers handler. The underlying mTLS client is -// constructed on first use. -func NewHandler() *Handler { - return &Handler{} -} - -// List serves GET /api/peers — the combined pending + approved trust state. -func (h *Handler) List(w http.ResponseWriter, r *http.Request) { - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - state, err := client.TrustState(r.Context()) - if err != nil { - http.Error(w, "failed to read trust state: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, state) -} - -// Attestation serves GET /api/peers/attestation — the auth service's public -// signing key and its pinnable fingerprint. -func (h *Handler) Attestation(w http.ResponseWriter, r *http.Request) { - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - att, err := client.Attestation(r.Context()) - if err != nil { - http.Error(w, "failed to read attestation: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, att) -} - -type authzAction struct { - Service string `json:"service"` - Fingerprint string `json:"fingerprint"` -} - -// Approve serves POST /api/peers/approve — forwards the owner's approval to -// the auth service's trust store. -func (h *Handler) Approve(w http.ResponseWriter, r *http.Request) { - var action authzAction - if !decodeAction(r, w, &action) { - return - } - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - if err := client.Approve(r.Context(), action.Service, action.Fingerprint); err != nil { - http.Error(w, "failed to approve peer: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "approved"}) -} - -// Revoke serves POST /api/peers/revoke — forwards the owner's revocation -// (returns the peer to the no-op path immediately). -func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) { - var action authzAction - if !decodeAction(r, w, &action) { - return - } - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - if err := client.Revoke(r.Context(), action.Service, action.Fingerprint); err != nil { - http.Error(w, "failed to revoke peer: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "revoked"}) -} - -// Reject serves POST /api/peers/reject — denies a pending first-contact -// request without authorizing it (the auth service drops the request and -// audits the decision). -func (h *Handler) Reject(w http.ResponseWriter, r *http.Request) { - var action authzAction - if !decodeAction(r, w, &action) { - return - } - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - if err := client.Reject(r.Context(), action.Service, action.Fingerprint); err != nil { - http.Error(w, "failed to reject peer: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "rejected"}) -} - -// Hide serves POST /api/peers/hide — permanently hides a revoked peer from -// the dashboard: the auth service keeps the row (audit trail survives) but -// marks it hidden; a re-approval restores visibility. -func (h *Handler) Hide(w http.ResponseWriter, r *http.Request) { - var action authzAction - if !decodeAction(r, w, &action) { - return - } - client, err := h.ensureClient() - if err != nil { - http.Error(w, "auth management client unavailable: "+err.Error(), http.StatusInternalServerError) - return - } - if err := client.Hide(r.Context(), action.Service, action.Fingerprint); err != nil { - http.Error(w, "failed to hide peer: "+err.Error(), http.StatusBadGateway) - return - } - writeJSON(w, http.StatusOK, map[string]string{"status": "hidden"}) -} - -func (h *Handler) ensureClient() (*authmgmt.Client, error) { - h.mu.Lock() - defer h.mu.Unlock() - if h.client == nil { - client, err := authmgmt.New() - if err != nil { - return nil, err - } - h.client = client - } - return h.client, nil -} - -func decodeAction(r *http.Request, w http.ResponseWriter, out *authzAction) bool { - if err := json.NewDecoder(r.Body).Decode(out); err != nil { - http.Error(w, "invalid request body", http.StatusBadRequest) - return false - } - if out.Service == "" || out.Fingerprint == "" { - http.Error(w, "service and fingerprint are required", http.StatusBadRequest) - return false - } - return true -} - -func writeJSON(w http.ResponseWriter, status int, v any) { - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(status) - _ = json.NewEncoder(w).Encode(v) -} diff --git a/src/lib/actions/actions.go b/src/lib/actions/actions.go deleted file mode 100644 index 2463acc..0000000 --- a/src/lib/actions/actions.go +++ /dev/null @@ -1,74 +0,0 @@ -// 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 actions - -import ( - "bytes" - "fmt" - "net/http" - - "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" -) - -type ActionProxyPayload struct { - Method string - Action string - Data string `json:"data"` -} - -type ActionProxyResponse struct { - StatusCode int - Body string -} - -func ActionProxy(container_id, action string, payload ActionProxyPayload) (ActionProxyResponse, error) { - // Forward the action to the container with the given ID, and return the result to the frontend - - containers := containers.NewContainerCache().GetServices() - for _, container := range containers { - if container.ID == container_id { - // Forward the action to this container - - body := bytes.NewBuffer([]byte(payload.Data)) - - port := "80" - if len(container.Network.Ports) > 0 { - port = fmt.Sprintf("%d", container.Network.Ports[0].Guest) - } - - req, err := http.NewRequest(payload.Method, fmt.Sprintf("http://%s:%s/action/%s", container.ID[:12], port, payload.Action), body) - if err != nil { - return ActionProxyResponse{}, err - } - - client := &http.Client{} - resp, err := client.Do(req) - if err != nil { - return ActionProxyResponse{}, err - } - defer resp.Body.Close() - - buf := new(bytes.Buffer) - buf.ReadFrom(resp.Body) - - return ActionProxyResponse{ - StatusCode: resp.StatusCode, - Body: buf.String(), - }, nil - } - } - return ActionProxyResponse{}, fmt.Errorf("container with ID %s not found", container_id) -} diff --git a/src/lib/authmgmt/client.go b/src/lib/authmgmt/client.go index 5d394f5..4841d7d 100644 --- a/src/lib/authmgmt/client.go +++ b/src/lib/authmgmt/client.go @@ -23,8 +23,6 @@ package authmgmt import ( "bytes" "context" - "crypto/tls" - "crypto/x509" "encoding/json" "fmt" "net/http" @@ -32,26 +30,27 @@ import ( "path/filepath" "time" + "git.dcntrlized.org/Dcntrlized/Maestro/lib/configsign" "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" ) // AuthServiceURL is the default DNS name of the shared auth service on the -// dcntrlized network. The auth service listens with ClientAuth=RequireAndVerify -// trusting the instance CA (M2 §1). -const AuthServiceURL = "https://dcntrlized-auth:8080" +// dcntrlized network. After rev 9 the fleet-wide internal transport is +// plaintext HTTP on the single :8080 listener; there is no mTLS anywhere. +const AuthServiceURL = "http://dcntrlized-auth:8080" -// Client talks to the auth service over mTLS using Maestro's own identity -// certificate (CN=maestro), which the auth service's RequireMaestro gate -// recognizes as the orchestrator. +// Client talks to the auth service over plaintext HTTP. It no longer presents +// any client certificate: the peer gate was removed fleet-wide in rev 9. type Client struct { baseURL string http *http.Client } // Attestation is the auth service's public signing key statement (D3): the key -// a peer should verify access tokens with, plus its TOFU-pinnable fingerprint. +// a peer should verify access tokens with, plus the fingerprint Maestro pins +// as an app-level check (M2 §5). Rev 9 removed the peer trust store, the +// pending/approved authorization flow and every management call that used to +// live in this package. type Attestation struct { PublicKey string `json:"public_key"` Fingerprint string `json:"fingerprint"` @@ -59,44 +58,9 @@ type Attestation struct { IssuedAt string `json:"issued_at"` } -// AuthorizedKey is an approved peer row of the auth service's trust store. -type AuthorizedKey struct { - Service string `json:"service"` - Fingerprint string `json:"fingerprint"` - ApprovedBy string `json:"approved_by"` - ApprovedAt string `json:"approved_at"` - RevokedAt *string `json:"revoked_at"` - HiddenAt *string `json:"hidden_at"` -} - -// PendingAuthorization is a first-contact request awaiting the owner's -// decision. -type PendingAuthorization struct { - Service string `json:"service"` - Fingerprint string `json:"fingerprint"` - FirstSeen string `json:"first_seen"` - LastSeen string `json:"last_seen"` -} - -// TrustState is the combined pending + approved view the dashboard shows. -type TrustState struct { - Pending []PendingAuthorization `json:"pending"` - Authorized []AuthorizedKey `json:"authorized"` -} - -type authzRequest struct { - Service string `json:"service"` - Fingerprint string `json:"fingerprint"` -} - -// New builds the mTLS management client, provisioning Maestro's own identity -// from the instance CA (the CA holder issues its own orchestrator cert). +// New builds the plaintext management client. There is no identity to +// provision and no transport to configure after the PKI removal. func New() (*Client, error) { - transport, err := maestroTransport() - if err != nil { - return nil, err - } - baseURL := "" if env.IsDev() { baseURL = os.Getenv("DCNTRLIZED_AUTH_URL") @@ -111,84 +75,52 @@ func New() (*Client, error) { return &Client{ baseURL: baseURL, http: &http.Client{ - Timeout: 10 * time.Second, - Transport: transport, + Timeout: 10 * time.Second, }, }, nil } -// maestroTransport builds the mutual-TLS transport Maestro uses as a peer: its -// own identity certificate (CN=maestro) signed by the instance CA, trusting the -// CA as root. This is the client side of every Maestro peer edge. -func maestroTransport() (*http.Transport, error) { - dataRoot, err := dataRoot() - if err != nil { - return nil, fmt.Errorf("resolve data root: %w", err) - } - ca, err := pki.EnsureCA(dataRoot) - if err != nil { - return nil, fmt.Errorf("ensure instance CA: %w", err) - } - identity, err := pki.EnsureServiceIdentity(ca, "maestro") - if err != nil { - return nil, fmt.Errorf("provision maestro identity: %w", err) - } - - cert, err := tls.LoadX509KeyPair(identity.CertPath, identity.KeyPath) - if err != nil { - return nil, fmt.Errorf("load maestro identity: %w", err) - } - pool := x509.NewCertPool() - if !pool.AppendCertsFromPEM(ca.CertPEM) { - return nil, fmt.Errorf("no certificates parsed from instance CA") - } - - return &http.Transport{ - TLSClientConfig: &tls.Config{ - Certificates: []tls.Certificate{cert}, - RootCAs: pool, - MinVersion: tls.VersionTLS12, - }, - }, nil -} - -// Scheme returns "http" in dev mode and "https" otherwise, for building -// satellite peer URLs (RunAction, manifest fetch). Dev satellites keep a -// plaintext listener so the RN clients (which cannot do mTLS) can reach them; -// production satellites bind mTLS listeners. +// Scheme returns the scheme every satellite peer edge uses after rev 9: +// always "http" (plaintext on the single :8080 listener). Kept as a function +// so the call sites (manifest fetch, action routing) do not hard-code it. func Scheme() string { - if env.IsDev() { - return "http" - } - return "https" + return "http" } -// PeerTransport returns the transport Maestro uses for the satellite peer -// edges (M2 §2): plaintext in dev mode (matching the RN-facing listeners), -// mutual TLS with Maestro's identity in production. -func PeerTransport() (*http.Transport, error) { - if env.IsDev() { - return &http.Transport{}, nil +// ConfigToken mints a fresh, single-use ES256 config token for one service +// (contract rev 9 §17.3.6). It loads (or creates) Maestro's DEDICATED +// config-signing keypair from the data root and returns the compact JWS. The +// service holds only the public half (DCNTRLIZED_CONFIG_PUBKEY) and verifies; +// Maestro only ever mints. Wire format is frozen in §17.3.3. +func ConfigToken(serviceName string) (string, error) { + root, err := dataRoot() + if err != nil { + return "", err } - return maestroTransport() + signer, err := configsign.LoadOrCreate(root) + if err != nil { + return "", err + } + return signer.Mint(serviceName, time.Now()) } -// MaestroTransport returns Maestro's own mutual-TLS transport (CN=maestro) -// unconditionally — dev mode does NOT downgrade it. Orchestrator-only edges -// that must prove the caller is the orchestrator (e.g. /config delivery, which -// the service gates to CN=maestro) use this regardless of FORCE_JSON/DEV, -// since those services keep an always-mTLS peer listener. -func MaestroTransport() (*http.Transport, error) { - return maestroTransport() -} - -// TrustState returns the pending authorizations and the approved trust store. -func (c *Client) TrustState(ctx context.Context) (*TrustState, error) { - var state TrustState - if err := c.do(ctx, http.MethodGet, "/internal/pending", nil, &state); err != nil { - return nil, err +// dataRoot resolves the directory holding Maestro's private key material +// (DCNTRLIZED_INSTALL_DIR → XDG → HOME), mirroring the containers package's +// resolution. Kept local to avoid an import cycle: containers imports authmgmt. +func dataRoot() (string, error) { + if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" { + return dir, nil } - return &state, nil + if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { + return filepath.Join(xdg, ".dcntrlized"), nil + } + if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" { + return filepath.Join(xdgHome, ".dcntrlized"), nil + } + if home := os.Getenv("HOME"); home != "" { + return filepath.Join(home, ".dcntrlized"), nil + } + return filepath.Join("/data", ".dcntrlized"), nil } // Attestation returns the auth service's public key statement. @@ -200,35 +132,6 @@ func (c *Client) Attestation(ctx context.Context) (*Attestation, error) { return &att, nil } -// Approve forwards the owner's approval for a peer's fingerprint to the auth -// service's own trust store. -func (c *Client) Approve(ctx context.Context, service, fingerprint string) error { - return c.do(ctx, http.MethodPost, "/internal/authorizations", - authzRequest{Service: service, Fingerprint: fingerprint}, nil) -} - -// Revoke forwards the owner's revocation for a peer's fingerprint (returns the -// peer to the no-op path immediately). -func (c *Client) Revoke(ctx context.Context, service, fingerprint string) error { - return c.do(ctx, http.MethodPost, "/internal/revoke", - authzRequest{Service: service, Fingerprint: fingerprint}, nil) -} - -// Reject denies a pending first-contact request: the request is dropped and -// audited; the peer stays on the no-op path until the owner approves it. -func (c *Client) Reject(ctx context.Context, service, fingerprint string) error { - return c.do(ctx, http.MethodPost, "/internal/reject", - authzRequest{Service: service, Fingerprint: fingerprint}, nil) -} - -// Hide permanently hides a revoked peer from the dashboard: the auth service -// keeps the row (audit trail survives) but marks it hidden_at; a re-approval -// clears the flag and makes the key visible again. -func (c *Client) Hide(ctx context.Context, service, fingerprint string) error { - return c.do(ctx, http.MethodPost, "/internal/hide", - authzRequest{Service: service, Fingerprint: fingerprint}, nil) -} - func (c *Client) do(ctx context.Context, method, path string, body, out any) error { var payload []byte if body != nil { @@ -258,46 +161,3 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err } return nil } - -// RevokeServiceBestEffort revokes a peer's authorization in the auth service -// trust store, logging and swallowing failures: uninstall must proceed even -// when the auth service is down (M2 §3 step 5 — "or automatically on -// uninstall"). -func RevokeServiceBestEffort(ctx context.Context, service, fingerprint string) { - client, err := New() - if err != nil { - bestEffort(err, "build client for revoking "+service) - return - } - if err := client.Revoke(ctx, service, fingerprint); err != nil { - bestEffort(err, "revoke "+service) - } -} - -// dataRoot resolves the directory storing the instance PKI, mirroring the -// containers package's resolution (DCNTRLIZED_INSTALL_DIR → XDG → HOME). Kept -// local to avoid an import cycle: containers imports authmgmt for the -// uninstall-revoke hook. -func dataRoot() (string, error) { - if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" { - return dir, nil - } - if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { - return filepath.Join(xdg, ".dcntrlized"), nil - } - if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" { - return filepath.Join(xdgHome, ".dcntrlized"), nil - } - if home := os.Getenv("HOME"); home != "" { - return filepath.Join(home, ".dcntrlized"), nil - } - return filepath.Join("/data", ".dcntrlized"), nil -} - -// bestEffort logs-and-continues: management calls must never take down the -// orchestrator (e.g. uninstall revoking while the auth service is down). -func bestEffort(err error, what string) { - if err != nil { - logger.Warn("authmgmt: %s failed (best-effort): %v", what, err) - } -} diff --git a/src/lib/authmgmt/client_test.go b/src/lib/authmgmt/client_test.go new file mode 100644 index 0000000..392f641 --- /dev/null +++ b/src/lib/authmgmt/client_test.go @@ -0,0 +1,54 @@ +// 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 authmgmt + +import ( + "os" + "strings" + "testing" +) + +// TestSchemeIsAlwaysPlaintext locks the rev-9 transport decision: every +// satellite peer edge (manifest fetch, action routing) is plaintext HTTP on the +// single :8080 listener. A future https/peer-port regression must fail here. +func TestSchemeIsAlwaysPlaintext(t *testing.T) { + if got := Scheme(); got != "http" { + t.Fatalf("Scheme() = %q, want %q", got, "http") + } +} + +// TestConfigTokenMintsForService exercises the contract-named mint helper +// (rev 9 §17.3.6) end to end against a temp data root: it must create the +// dedicated config-signing keypair, return a 3-segment JWS bound to the target +// service, and expose a public key whose kid matches the token header. +func TestConfigTokenMintsForService(t *testing.T) { + dir := t.TempDir() + t.Setenv("DCNTRLIZED_INSTALL_DIR", dir) + + token, err := ConfigToken("calendar") + if err != nil { + t.Fatalf("ConfigToken: %v", err) + } + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d segments, want 3", len(parts)) + } + for _, name := range []string{"config-signing.key", "config-signing.pub"} { + if _, err := os.Stat(dir + "/maestro/" + name); err != nil { + t.Fatalf("expected %s to exist: %v", name, err) + } + } +} diff --git a/src/lib/configsign/configsign.go b/src/lib/configsign/configsign.go new file mode 100644 index 0000000..00e8be1 --- /dev/null +++ b/src/lib/configsign/configsign.go @@ -0,0 +1,226 @@ +// 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 configsign owns Maestro's DEDICATED /config signing keypair and the +// ES256 token it mints for runtime operator-config delivery (contract rev 9 +// §17). The keypair is generated and held by Maestro in its data root and is +// deliberately NOT the instance CA: the PKI identity layer is removed fleet-wide, +// and a service only ever receives the public key (verify, never mint). +package configsign + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "encoding/pem" + "fmt" + "math/big" + "os" + "path/filepath" + "strings" + "time" +) + +const ( + // keyFileName is the PKCS#8 PEM private key in /maestro. + keyFileName = "config-signing.key" + // pubFileName is the PKIX PEM public key in /maestro. + pubFileName = "config-signing.pub" + + // Issuer is the fixed iss claim every config token carries. + Issuer = "dcntrlized-maestro" + // Scope is the fixed scope claim; the service requires exactly this value. + Scope = "config" + // TokenTTL is the frozen 60 s token lifetime. + TokenTTL = 60 * time.Second + + // PublicKeyEnv is the env var Maestro injects into a service container: the + // base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM) of the + // dedicated config-signing public key. The service loads it to verify + // /config tokens and can never mint them. + PublicKeyEnv = "DCNTRLIZED_CONFIG_PUBKEY" +) + +// Signer holds the loaded keypair and mints config tokens. +type Signer struct { + key *ecdsa.PrivateKey + kid string + pubDER []byte + pubPEM []byte +} + +// LoadOrCreate opens /maestro/config-signing.key, generating the +// keypair on first use. The private key is written atomically with mode 0600; +// the public key is written alongside it with mode 0644. +func LoadOrCreate(dataRoot string) (*Signer, error) { + dir := filepath.Join(dataRoot, "maestro") + if err := os.MkdirAll(dir, 0o700); err != nil { + return nil, fmt.Errorf("configsign: mkdir %s: %w", dir, err) + } + keyPath := filepath.Join(dir, keyFileName) + pubPath := filepath.Join(dir, pubFileName) + + if raw, err := os.ReadFile(keyPath); err == nil { + block, _ := pem.Decode(raw) + if block == nil { + return nil, fmt.Errorf("configsign: %s is not PEM", keyPath) + } + key, err := parsePrivateKey(block.Bytes) + if err != nil { + return nil, fmt.Errorf("configsign: parse %s: %w", keyPath, err) + } + return newSigner(key, pubPath) + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("configsign: read %s: %w", keyPath, err) + } + + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + return nil, fmt.Errorf("configsign: generate key: %w", err) + } + der, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + return nil, fmt.Errorf("configsign: marshal private key: %w", err) + } + if err := writeFileAtomic(keyPath, pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: der}), 0o600); err != nil { + return nil, err + } + return newSigner(key, pubPath) +} + +// newSigner derives the kid and (re)writes the public key file. +func newSigner(key *ecdsa.PrivateKey, pubPath string) (*Signer, error) { + spki, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + return nil, fmt.Errorf("configsign: marshal public key: %w", err) + } + sum := sha256.Sum256(spki) + s := &Signer{ + key: key, + kid: hex.EncodeToString(sum[:16]), + pubDER: spki, + pubPEM: pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: spki}), + } + if err := writeFileAtomic(pubPath, s.pubPEM, 0o644); err != nil { + return nil, err + } + return s, nil +} + +// Kid is the key id placed in the JWT header; a service can recompute it from +// the public key it holds. +func (s *Signer) Kid() string { return s.kid } + +// PublicKeyBase64 is the single-line env value Maestro injects into services: +// base64.StdEncoding of the DER SubjectPublicKeyInfo (NOT the PEM). The service +// decodes it once and has both the key and the kid input (kid is defined over +// this exact DER). +func (s *Signer) PublicKeyBase64() string { + return base64.StdEncoding.EncodeToString(s.pubDER) +} + +// Mint returns a fresh ES256 config token for one service. Claims are frozen by +// contract rev 9: iss=dcntrlized-maestro, aud=sub=, scope="config", +// iat/nbf/exp=now..now+60s, and a random 128-bit jti (single-use). +func (s *Signer) Mint(service string, now time.Time) (string, error) { + jti := make([]byte, 16) + if _, err := rand.Read(jti); err != nil { + return "", fmt.Errorf("configsign: jti: %w", err) + } + header := map[string]string{"alg": "ES256", "typ": "JWT", "kid": s.kid} + claims := map[string]any{ + "iss": Issuer, + "aud": service, + "sub": service, + "scope": Scope, + "iat": now.Unix(), + "nbf": now.Unix(), + "exp": now.Add(TokenTTL).Unix(), + "jti": hex.EncodeToString(jti), + } + hb, err := json.Marshal(header) + if err != nil { + return "", err + } + cb, err := json.Marshal(claims) + if err != nil { + return "", err + } + signingInput := b64(hb) + "." + b64(cb) + digest := sha256.Sum256([]byte(signingInput)) + r, ss, err := ecdsa.Sign(rand.Reader, s.key, digest[:]) + if err != nil { + return "", fmt.Errorf("configsign: sign: %w", err) + } + sig := make([]byte, 64) + r.FillBytes(sig[:32]) + ss.FillBytes(sig[32:]) + return signingInput + "." + b64(sig), nil +} + +func b64(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) } + +func parsePrivateKey(der []byte) (*ecdsa.PrivateKey, error) { + if k, err := x509.ParsePKCS8PrivateKey(der); err == nil { + ec, ok := k.(*ecdsa.PrivateKey) + if !ok { + return nil, fmt.Errorf("not an ECDSA key") + } + return ec, nil + } + return x509.ParseECPrivateKey(der) +} + +// writeFileAtomic writes data to path via a temp file + rename so a crash can +// never leave a half-written private key. +func writeFileAtomic(path string, data []byte, mode os.FileMode) error { + tmp := path + ".tmp" + if err := os.WriteFile(tmp, data, mode); err != nil { + return fmt.Errorf("configsign: write %s: %w", tmp, err) + } + if err := os.Chmod(tmp, mode); err != nil { + return fmt.Errorf("configsign: chmod %s: %w", tmp, err) + } + if err := os.Rename(tmp, path); err != nil { + return fmt.Errorf("configsign: rename %s: %w", path, err) + } + return nil +} + +// Verify is a test helper: it checks a token the Signer minted against its own +// public key (the same computation a service performs). It is not used by +// Maestro at runtime — Maestro only mints. +func (s *Signer) Verify(token string) error { + parts := strings.Split(token, ".") + if len(parts) != 3 { + return fmt.Errorf("malformed token") + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != 64 { + return fmt.Errorf("bad signature encoding") + } + r := new(big.Int).SetBytes(sig[:32]) + ss := new(big.Int).SetBytes(sig[32:]) + if !ecdsa.Verify(&s.key.PublicKey, digest[:], r, ss) { + return fmt.Errorf("signature does not verify") + } + return nil +} diff --git a/src/lib/configsign/configsign_test.go b/src/lib/configsign/configsign_test.go new file mode 100644 index 0000000..957ebf6 --- /dev/null +++ b/src/lib/configsign/configsign_test.go @@ -0,0 +1,229 @@ +// 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 configsign + +import ( + "crypto/ecdsa" + "crypto/elliptic" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/hex" + "encoding/json" + "math/big" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + "time" +) + +func TestLoadOrCreatePersistsKeypair(t *testing.T) { + root := t.TempDir() + s1, err := LoadOrCreate(root) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + keyPath := filepath.Join(root, "maestro", keyFileName) + info, err := os.Stat(keyPath) + if err != nil { + t.Fatalf("stat private key: %v", err) + } + if perm := info.Mode().Perm(); perm != 0o600 { + t.Fatalf("private key mode = %o, want 600", perm) + } + pubInfo, err := os.Stat(filepath.Join(root, "maestro", pubFileName)) + if err != nil { + t.Fatalf("stat public key: %v", err) + } + if perm := pubInfo.Mode().Perm(); perm != 0o644 { + t.Fatalf("public key mode = %o, want 644", perm) + } + + // Reload must reuse the same key (kid stable). + s2, err := LoadOrCreate(root) + if err != nil { + t.Fatalf("LoadOrCreate reload: %v", err) + } + if s1.Kid() != s2.Kid() { + t.Fatalf("kid changed across reload: %q vs %q", s1.Kid(), s2.Kid()) + } +} + +func TestKidIsHexOfSPKIHalf(t *testing.T) { + root := t.TempDir() + s, err := LoadOrCreate(root) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(s.Kid()) { + t.Fatalf("kid = %q, want 32 lowercase hex chars", s.Kid()) + } + der, err := base64.StdEncoding.DecodeString(s.PublicKeyBase64()) + if err != nil { + t.Fatalf("PublicKeyBase64 is not base64: %v", err) + } + if _, err := x509.ParsePKIXPublicKey(der); err != nil { + t.Fatalf("public key env value is not DER SPKI: %v", err) + } + sum := sha256.Sum256(der) + if want := hex.EncodeToString(sum[:16]); s.Kid() != want { + t.Fatalf("kid = %q, want sha256(spki)[:16] = %q", s.Kid(), want) + } +} + +func TestMintClaimsAndSignature(t *testing.T) { + root := t.TempDir() + s, err := LoadOrCreate(root) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + now := time.Unix(1_800_000_000, 0) + token, err := s.Mint("calendar", now) + if err != nil { + t.Fatalf("Mint: %v", err) + } + if err := s.Verify(token); err != nil { + t.Fatalf("minted token does not verify: %v", err) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("token has %d parts, want 3", len(parts)) + } + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != 64 { + t.Fatalf("signature must be raw 64-byte r||s (len=%d err=%v)", len(sig), err) + } + + var header map[string]string + hb, _ := base64.RawURLEncoding.DecodeString(parts[0]) + if err := json.Unmarshal(hb, &header); err != nil { + t.Fatalf("header: %v", err) + } + if header["alg"] != "ES256" || header["typ"] != "JWT" || header["kid"] != s.Kid() { + t.Fatalf("header = %v", header) + } + + var claims map[string]any + cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) + if err := json.Unmarshal(cb, &claims); err != nil { + t.Fatalf("claims: %v", err) + } + want := map[string]any{ + "iss": "dcntrlized-maestro", + "aud": "calendar", + "sub": "calendar", + "scope": "config", + "iat": float64(now.Unix()), + "nbf": float64(now.Unix()), + "exp": float64(now.Add(60 * time.Second).Unix()), + } + for k, v := range want { + if claims[k] != v { + t.Errorf("claim %s = %v, want %v", k, claims[k], v) + } + } + jti, _ := claims["jti"].(string) + if !regexp.MustCompile(`^[0-9a-f]{32}$`).MatchString(jti) { + t.Fatalf("jti = %q, want 32 lowercase hex chars", jti) + } +} + +// TestContractGoldenFixture cross-checks Maestro's kid derivation and ES256 +// interpretation against the worked example frozen in contract rev 9 §17.3.7. +// It does not use the Signer (which holds a private key): it verifies the +// contract's token with the contract's public key, exactly as a service would. +func TestContractGoldenFixture(t *testing.T) { + const ( + pubB64 = "MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqBhe2nIg0c3qf2C8yIVGs9/qSqLvW8eFUliCT1yKnWmUhX5RdRCOIOo59M7APFzywtwSOxMMivQh9N5rVWXxCQ==" + kidHex = "088efbd65ad78ceed9dfe6495367ea6d" + token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjA4OGVmYmQ2NWFkNzhjZWVkOWRmZTY0OTUzNjdlYTZkIn0.eyJpc3MiOiJkY250cmxpemVkLW1hZXN0cm8iLCJhdWQiOiJjYWxlbmRhciIsInN1YiI6ImNhbGVuZGFyIiwic2NvcGUiOiJjb25maWciLCJpYXQiOjE3NzAwMDAwMDAsIm5iZiI6MTc3MDAwMDAwMCwiZXhwIjoxNzcwMDAwMDYwLCJqdGkiOiIwMDExMjIzMzQ0NTU2Njc3ODg5OWFhYmJjY2RkZWVmZiJ9.4F8OmePWBO4lZsR0bOG5HK6cttvtTmKjwhcYMZcY5neIGnnmTlYsGtGJzMmhaC7eM5OEz6d0rPft8ZWNiQvyfQ" + ) + + der, err := base64.StdEncoding.DecodeString(pubB64) + if err != nil { + t.Fatalf("decode pubkey: %v", err) + } + sum := sha256.Sum256(der) + if got := hex.EncodeToString(sum[:16]); got != kidHex { + t.Fatalf("kid = %s, contract says %s", got, kidHex) + } + parsed, err := x509.ParsePKIXPublicKey(der) + if err != nil { + t.Fatalf("parse pubkey: %v", err) + } + pub, ok := parsed.(*ecdsa.PublicKey) + if !ok || pub.Curve != elliptic.P256() { + t.Fatalf("pubkey is not P-256 ECDSA: %T", parsed) + } + + parts := strings.Split(token, ".") + if len(parts) != 3 { + t.Fatalf("fixture token has %d segments", len(parts)) + } + digest := sha256.Sum256([]byte(parts[0] + "." + parts[1])) + sig, err := base64.RawURLEncoding.DecodeString(parts[2]) + if err != nil || len(sig) != 64 { + t.Fatalf("fixture signature not raw 64 bytes (len=%d err=%v)", len(sig), err) + } + if !ecdsa.Verify(pub, digest[:], new(big.Int).SetBytes(sig[:32]), new(big.Int).SetBytes(sig[32:])) { + t.Fatal("contract fixture token does not verify with the contract public key") + } + + var claims map[string]any + cb, _ := base64.RawURLEncoding.DecodeString(parts[1]) + if err := json.Unmarshal(cb, &claims); err != nil { + t.Fatalf("fixture payload: %v", err) + } + if claims["iss"] != Issuer || claims["aud"] != "calendar" || claims["sub"] != "calendar" || claims["scope"] != Scope { + t.Fatalf("fixture claims mismatch: %v", claims) + } + if exp, iat := claims["exp"].(float64), claims["iat"].(float64); exp-iat != 60 { + t.Fatalf("fixture exp-iat = %v, want 60", exp-iat) + } + if claims["nbf"] != claims["iat"] { + t.Fatalf("fixture nbf != iat: %v", claims) + } +} + +func TestMintJTIUniqueAndTamperRejected(t *testing.T) { + root := t.TempDir() + s, err := LoadOrCreate(root) + if err != nil { + t.Fatalf("LoadOrCreate: %v", err) + } + now := time.Now() + a, err := s.Mint("calendar", now) + if err != nil { + t.Fatal(err) + } + b, err := s.Mint("calendar", now) + if err != nil { + t.Fatal(err) + } + if a == b { + t.Fatal("two mints produced the same token (jti must be random)") + } + + // Flip one payload byte → signature must fail. + parts := strings.Split(a, ".") + parts[1] = strings.Replace(parts[1], "e", "f", 1) + if err := s.Verify(strings.Join(parts, ".")); err == nil { + t.Fatal("tampered token verified") + } +} diff --git a/src/lib/containers/configdeliver.go b/src/lib/containers/configdeliver.go index 42363f8..805b593 100644 --- a/src/lib/containers/configdeliver.go +++ b/src/lib/containers/configdeliver.go @@ -16,12 +16,13 @@ // Package containers implements Maestro's runtime write-back delivery for the // Service Config & Secrets theme (D6). When the operator sets or clears a // config value via the Settings panel, Maestro delivers it to the running -// service's POST /config endpoint over an ALWAYS-mTLS edge: Maestro's own -// identity (CN=maestro) and plain "https" regardless of dev mode, targeted at -// the service's dedicated peer listener port (label org.dcntrlized.peer_port, -// default 9443), which the service gates to CN=maestro. A set carries -// {field, value, type}; a clear carries {field, action:"clear"} — the service -// owns the schema and branches on what it receives. +// service's POST /config endpoint on the service's single plaintext :8080 +// listener, authenticated with a short-lived, single-use ES256 config token +// minted from Maestro's dedicated config-signing keypair (contract rev 9 §17). +// There is no mTLS and no peer port: the fleet-wide internal transport is +// plaintext HTTP. A set carries {field, value, type}; a clear carries +// {field, action:"clear"} — the service owns the schema and branches on what it +// receives. // // Delivery is best-effort: failures are logged (with the field name, never // the value) and do not prevent the panel write from succeeding. Values always @@ -42,32 +43,16 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" ) -// peerPortLabel is the compose label a service declares to advertise the -// internal port of its always-mTLS Maestro peer listener. -const peerPortLabel = "org.dcntrlized.peer_port" - -// defaultPeerPort is the fleet-wide default for that listener. Services keep/ve -// to it unless the label overrides it. -const defaultPeerPort = "9443" - -// peerPort resolves the configured {peer listener port} from a container's -// compose labels, falling back to the fleet default. -func peerPort(labels map[string]string) string { - if labels == nil { - return defaultPeerPort - } - if p := labels[peerPortLabel]; p != "" { - return p - } - return defaultPeerPort -} +// configPort is the single port every service serves its API, /config and web +// app on after rev 9. There is no peer listener and no compose label. +const configPort = "8080" // ConfigDelivery is one key-value pair to deliver to a running service. type ConfigDelivery struct { Field string // manifest config field name Key string // container env var name Type string // "cleartext" | "password" - Value string // the operator-entered value (sent over mTLS; never logged) + Value string // the operator-entered value (never logged) } // configRequest is the JSON body POSTed to the service's /config endpoint. @@ -79,10 +64,9 @@ type configRequest struct { Action string `json:"action,omitempty"` } -// DeliverConfigValues delivers the given config pairs to the running service -// over the mTLS peer edge. If the service is not running or unreachable, the -// error is logged but not returned — the values will take effect at next start -// via injectConfigSecrets. +// DeliverConfigValues delivers the given config pairs to the running service. +// If the service is not running or unreachable, the error is logged but not +// returned — the values will take effect at next start via injectConfigSecrets. func DeliverConfigValues(ctx context.Context, serviceName string, items []ConfigDelivery) { if len(items) == 0 { return @@ -114,9 +98,10 @@ func DeliverConfigClear(ctx context.Context, serviceName, field string) { } // postConfig performs one best-effort POST of body to the running service's -// /config endpoint. NotFound/unreachable cases are logged at debug (the write -// still applies at next start); transport errors carry the field name only, -// never a value. +// /config endpoint on the single :8080 listener, authenticated with a freshly +// minted ES256 config token. NotFound/unreachable cases are logged at debug +// (the write still applies at next start); transport errors carry the field +// name only, never a value. func postConfig(ctx context.Context, serviceName, field, verb string, body []byte) { dc, err := docker.NewDockerClient() if err != nil { @@ -137,30 +122,22 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt return } - // /config is served on the service's dedicated Maestro peer listener - // (always mTLS, gated to CN=maestro), NOT the RN-facing flat edge. Maestro - // presents its own identity and targets the peer port; dev mode does not - // downgrade this edge. - port := peerPort(inspectData.Config.Labels) - - transport, err := authmgmt.MaestroTransport() + token, err := authmgmt.ConfigToken(serviceName) if err != nil { - logger.Warn("Config delivery to %s/%s: cannot build Maestro peer transport: %v", serviceName, field, err) + logger.Warn("Config delivery to %s/%s: cannot mint config token: %v", serviceName, field, err) return } - client := &http.Client{ - Transport: transport, - Timeout: 5 * time.Second, - } + client := &http.Client{Timeout: 5 * time.Second} - url := fmt.Sprintf("https://%s:%s/config", network.IPAddress.Address, port) + url := fmt.Sprintf("http://%s:%s/config", network.IPAddress.Address, configPort) req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) if err != nil { logger.Warn("Config delivery to %s/%s: request build failed: %v", serviceName, field, err) return } req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) resp, err := client.Do(req) if err != nil { @@ -174,4 +151,4 @@ func postConfig(ctx context.Context, serviceName, field, verb string, body []byt } else { logger.Debug("Config delivery to %s/%s: service responded %d; will apply at next start", serviceName, field, resp.StatusCode) } -} \ No newline at end of file +} diff --git a/src/lib/containers/configdeliver_test.go b/src/lib/containers/configdeliver_test.go deleted file mode 100644 index 0d2bafb..0000000 --- a/src/lib/containers/configdeliver_test.go +++ /dev/null @@ -1,39 +0,0 @@ -// 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 containers - -import "testing" - -func TestPeerPort(t *testing.T) { - tests := []struct { - name string - labels map[string]string - want string - }{ - {"nil labels fall back to default", nil, defaultPeerPort}, - {"empty labels fall back to default", map[string]string{}, defaultPeerPort}, - {"label present wins", map[string]string{"org.dcntrlized.peer_port": "9000"}, "9000"}, - {"empty label value falls back to default", map[string]string{"org.dcntrlized.peer_port": ""}, defaultPeerPort}, - {"unrelated labels fall back to default", map[string]string{"org.dcntrlized.port": "8080"}, defaultPeerPort}, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := peerPort(tt.labels); got != tt.want { - t.Fatalf("peerPort(%v) = %q, want %q", tt.labels, got, tt.want) - } - }) - } -} \ No newline at end of file diff --git a/src/lib/containers/infra.go b/src/lib/containers/infra.go index e8e7de5..efa0b51 100644 --- a/src/lib/containers/infra.go +++ b/src/lib/containers/infra.go @@ -20,7 +20,6 @@ import ( "fmt" "strings" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/db" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" libregistry "git.dcntrlized.org/Dcntrlized/Maestro/lib/registry" @@ -105,16 +104,6 @@ func ensureInfraRunning(ctx context.Context, serviceName, label string) error { if err := StartContainer(ctx, types.RegistryContainer{Name: serviceName}); err != nil { return fmt.Errorf("failed to start %s %s: %w", label, serviceName, err) } - - // For the shared database, reconcile the CONNECT authorization against the - // peers Maestro manages (M2 §4): per-role CONNECT grants and, in mTLS mode, - // PUBLIC revoked. Best-effort — the database advertises infra-relevant - // problems as warnings, never as boot failures. - if serviceName == DatabaseServiceName { - if err := dbctl.New(dc).SyncAuthorization(ctx); err != nil { - logger.Warn("Shared database authorization sync failed: %v", err) - } - } return nil } diff --git a/src/lib/db/dbctl.go b/src/lib/db/dbctl.go deleted file mode 100644 index 3c387c1..0000000 --- a/src/lib/db/dbctl.go +++ /dev/null @@ -1,331 +0,0 @@ -// 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 dbctl administers the shared database's authorization (M2 §4). -// -// The database image enables instance-CA mTLS when Maestro injects an identity -// (server cert + client-CA + hostssl cert / hostnossl reject rules), but the -// auth rules alone only prove *who* authenticates. Authorization lives here: -// the stock PUBLIC CONNECT grant is revoked and each installed peer gets a -// LOGIN role with CONNECT, so a peer that holds no role (or a revoked one) -// cannot even open a session. The admin path uses the postgres superuser over -// the in-container unix socket (trust), which deliberately bypasses the mTLS -// enforcement — Maestro owns the database and drives its lifecycle. -package dbctl - -import ( - "bytes" - "context" - "fmt" - "os" - "path/filepath" - "strings" - "time" - - "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" - "github.com/docker/docker/api/types/container" - "github.com/docker/docker/api/types/filters" - "github.com/docker/docker/pkg/stdcopy" -) - -const ( - // DBContainerName is the canonical shared database container (image, DNS). - DBContainerName = "dcntrlized-db" - // DBName is the shared database name inside the instance. - DBName = "dcntrlized" - // AdminRole is Maestro's own database role (CN=maestro bootstrap in the - // image). It is granted like any peer so its existence is guaranteed. - AdminRole = "maestro" - // identityMountTarget is the container path of the injected leaf cert. Its - // presence in the mount list marks the database as mTLS-armed. - identityMountTarget = "/dcntrlized/pki/tls.crt" -) - -// Client reconciles the shared database's CONNECT authorization state. -type Client struct { - dc *docker.DockerClient -} - -// New wraps a Docker client. -func New(dc *docker.DockerClient) *Client { - return &Client{dc: dc} -} - -// SyncAuthorization ensures every installed peer holds a LOGIN role with -// CONNECT on the shared database, and — when the database runs in mTLS mode — -// revokes the stock PUBLIC grant. It is safe to call on every start: role -// creation and grants are idempotent, and the PUBLIC revoke is a no-op on the -// second run. When the database is not running it returns an error; callers -// treat it as best-effort. -func (c *Client) SyncAuthorization(ctx context.Context) error { - id := c.dbContainerID(ctx) - if id == "" { - return fmt.Errorf("shared database %s is not running", DBContainerName) - } - - if err := c.waitReady(ctx, id); err != nil { - return err - } - - roles, err := peerRoles() - if err != nil { - return err - } - if len(roles) == 0 { - return nil - } - - script := "SET client_min_messages TO warning;\n" - have := c.existingRoles(ctx, id) - for _, role := range roles { - if !have[role] { - script += fmt.Sprintf("CREATE ROLE %s LOGIN;\n", quoteIdent(role)) - } - script += fmt.Sprintf("GRANT CONNECT ON DATABASE %s TO %s;\n", quoteIdent(DBName), quoteIdent(role)) - // The peer connects and migrates under its own role (its cert CN), - // not the shared superuser: it may create tables in public (PG15+ - // revoked that from PUBLIC) and must reach the tables it already - // owns or shares across every user schema (e.g. auth's own schema). - script += fmt.Sprintf("GRANT CREATE ON SCHEMA public TO %s;\n", quoteIdent(role)) - script += grantExistingScript(role) - } - if c.isArmed(ctx, id) { - script += fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM PUBLIC;\n", quoteIdent(DBName)) - } - - out, code, err := c.execPSQL(ctx, id, DBName, script) - if err != nil { - return fmt.Errorf("apply database authorization: %w", err) - } - if code != 0 { - return fmt.Errorf("apply database authorization failed (exit %d): %s", code, out) - } - logger.Info("Reconciled shared database authorization for %d peer(s): %s", len(roles), strings.Join(roles, ", ")) - return nil -} - -// RevokeForService removes a peer's CONNECT grant when it is uninstalled. -// Best-effort: the database may already be gone. -func (c *Client) RevokeForService(ctx context.Context, serviceName string) { - if serviceName == "" { - return - } - id := c.dbContainerID(ctx) - if id == "" { - return - } - script := fmt.Sprintf("REVOKE CONNECT ON DATABASE %s FROM %s;", quoteIdent(DBName), quoteIdent(serviceName)) - out, code, err := c.execPSQL(ctx, id, DBName, script) - if err != nil || code != 0 { - logger.Warn("Failed to revoke database CONNECT for %s: %v (exit %d): %s", serviceName, err, code, out) - return - } - logger.Info("Revoked database CONNECT for %s", serviceName) -} - -// peerRoles returns the guaranteed connectors: Maestro plus every installed -// service (identified by its install dir name, which is also its cert CN). -func peerRoles() ([]string, error) { - roles := []string{AdminRole} - root, err := dataRoot() - if err != nil { - return nil, fmt.Errorf("resolve data root: %w", err) - } - entries, err := os.ReadDir(root) - if err != nil { - if os.IsNotExist(err) { - return roles, nil - } - return nil, fmt.Errorf("list data root: %w", err) - } - for _, entry := range entries { - if !entry.IsDir() || entry.Name() == DBContainerName { - continue - } - if _, err := os.Stat(filepath.Join(root, entry.Name(), "metadata.json")); err != nil { - continue - } - roles = append(roles, entry.Name()) - } - return roles, nil -} - -// dataRoot resolves the directory storing installed container metadata, with -// the same precedence as lib/containers.DataRoot (kept local to avoid an -// import cycle — lib/containers imports dbctl). -func dataRoot() (string, error) { - if dir := os.Getenv("DCNTRLIZED_INSTALL_DIR"); dir != "" { - return dir, nil - } - if xdg := os.Getenv("XDG_DATA_HOME"); xdg != "" { - return filepath.Join(xdg, ".dcntrlized"), nil - } - if xdgHome := os.Getenv("XDG_HOME_DIR"); xdgHome != "" { - return filepath.Join(xdgHome, ".dcntrlized"), nil - } - if home := os.Getenv("HOME"); home != "" { - return filepath.Join(home, ".dcntrlized"), nil - } - return filepath.Join("/data", ".dcntrlized"), nil -} - -// grantExistingScript returns the DO block granting a role USAGE plus ALL -// PRIVILEGES on tables and sequences in every user schema. Existing volumes -// hold tables created by the postgres superuser (and by other services' -// earlier migrations); without these grants a per-service role could connect -// but not read or migrate anything. -func grantExistingScript(role string) string { - return fmt.Sprintf(`DO $dcntrlized_grants$ DECLARE s text; BEGIN -FOR s IN SELECT nspname FROM pg_catalog.pg_namespace WHERE nspname NOT LIKE 'pg_%%' AND nspname <> 'information_schema' LOOP -EXECUTE format('GRANT USAGE ON SCHEMA %%I TO %%I', s, %s); -EXECUTE format('GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA %%I TO %%I', s, %s); -EXECUTE format('GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA %%I TO %%I', s, %s); -END LOOP; END $dcntrlized_grants$; -`, quoteLiteral(role), quoteLiteral(role), quoteLiteral(role)) -} - -// quoteLiteral renders a PostgreSQL string literal safe for interpolation. -func quoteLiteral(s string) string { - return "'" + strings.ReplaceAll(s, "'", "''") + "'" -} - -// quoteIdent renders a PostgreSQL identifier safe for interpolation. -func quoteIdent(s string) string { - return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` -} - -// dbContainerID resolves the running canonical database container, or "". -func (c *Client) dbContainerID(ctx context.Context) string { - filter := filters.NewArgs() - filter.Add("label", "org.dcntrlized.service="+DBContainerName) - containers, err := c.dc.ContainerList(ctx, container.ListOptions{All: true, Filters: filter}) - if err != nil { - logger.Warn("Failed to list database containers: %v", err) - return "" - } - for _, cc := range containers { - if cc.State != "running" { - continue - } - for _, n := range cc.Names { - if strings.TrimPrefix(n, "/") == DBContainerName { - return cc.ID - } - } - } - return "" -} - -// Armed reports whether the running shared database received the injected -// instance-CA identity (i.e. it enforces mTLS on its TCP edge). Callers use -// this to decide which connection URL peers get: verify-full mTLS DSNs on an -// armed database, plaintext defaults otherwise. -func (c *Client) Armed(ctx context.Context) bool { - id := c.dbContainerID(ctx) - if id == "" { - return false - } - return c.isArmed(ctx, id) -} - -// isArmed reports whether the database container received the injected -// instance-CA identity (i.e. it enforces mTLS on its TCP edge). -func (c *Client) isArmed(ctx context.Context, id string) bool { - inspect, err := c.dc.ContainerInspect(ctx, id) - if err != nil { - return false - } - for _, m := range inspect.Mounts { - if filepath.Clean(m.Destination) == identityMountTarget { - return true - } - } - return false -} - -// waitReady polls the database until an admin statement executes or the -// deadline is reached, tolerating the first-boot delay after compose up. -func (c *Client) waitReady(ctx context.Context, id string) error { - const attempts = 30 - for i := 0; i < attempts; i++ { - _, code, err := c.execPSQL(ctx, id, "postgres", "SELECT 1") - if err == nil && code == 0 { - return nil - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-time.After(time.Second): - } - } - return fmt.Errorf("database %s did not become ready in time", DBContainerName) -} - -// existingRoles returns the set of roles currently defined in the database. -func (c *Client) existingRoles(ctx context.Context, id string) map[string]bool { - have := map[string]bool{} - out, code, err := c.execPSQL(ctx, id, "postgres", "SELECT rolname FROM pg_catalog.pg_roles") - if err != nil || code != 0 { - return have - } - for _, line := range strings.Split(out, "\n") { - if name := strings.TrimSpace(line); name != "" { - have[name] = true - } - } - return have -} - -// execPSQL runs a psql statement over the in-container unix socket as the -// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro -// administers the database). It returns trimmed stdout and the exit code. -// execPSQL runs a psql statement over the in-container unix socket as the -// postgres superuser (bypassing the mTLS enforcement intentionally — Maestro -// administers the database), targeting the named database. It returns trimmed -// stdout and the exit code. -func (c *Client) execPSQL(ctx context.Context, id, dbName, sql string) (string, int, error) { - created, err := c.dc.ContainerExecCreate(ctx, id, container.ExecOptions{ - User: "postgres", - AttachStdout: true, - Cmd: []string{"psql", "-U", "postgres", "-d", dbName, "-v", "ON_ERROR_STOP=1", "-tA", "-c", sql}, - }) - if err != nil { - return "", -1, fmt.Errorf("create psql exec: %w", err) - } - - resp, err := c.dc.ContainerExecAttach(ctx, created.ID, container.ExecAttachOptions{}) - if err != nil { - return "", -1, fmt.Errorf("attach psql exec: %w", err) - } - defer resp.Close() - - var stdout, stderr bytes.Buffer - if _, err := stdcopy.StdCopy(&stdout, &stderr, resp.Reader); err != nil { - return "", -1, fmt.Errorf("read psql output: %w", err) - } - - for i := 0; i < 50; i++ { - inspect, err := c.dc.ContainerExecInspect(ctx, created.ID) - if err != nil { - return "", -1, fmt.Errorf("inspect psql exec: %w", err) - } - if !inspect.Running { - return strings.TrimSpace(stdout.String()), inspect.ExitCode, nil - } - time.Sleep(100 * time.Millisecond) - } - return "", -1, fmt.Errorf("psql exec did not finish in time") -} diff --git a/src/lib/db/dbctl_integration_test.go b/src/lib/db/dbctl_integration_test.go deleted file mode 100644 index a6c6fdf..0000000 --- a/src/lib/db/dbctl_integration_test.go +++ /dev/null @@ -1,84 +0,0 @@ -// 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 dbctl - -import ( - "context" - "os" - "path/filepath" - "testing" - - "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" -) - -// TestSyncAuthorizationIntegration exercises the real exec path against a -// running dcntrlized-db container. -// -// 1. docker run -d --name dcntrlized-db -e POSTGRES_DB=dcntrlized \ -// -e POSTGRES_USER=postgres -e POSTGRES_HOST_AUTH_METHOD=trust \ -// -v $PWD/PKI/ca.crt:/dcntrlized/pki/ca.crt:ro \ -// -v $PWD/PKI/tls.crt:/dcntrlized/pki/tls.crt:ro \ -// -v $PWD/PKI/tls.key:/dcntrlized/pki/tls.key:ro \ -// -v dctl-vol:/var/lib/postgresql/data dcntrlized-db -// 2. seed the data root with install dirs for the peers -// 3. DBCtl_INTEGRATION=1 go test ./lib/db -run TestSyncAuthorizationIntegration -v -func TestSyncAuthorizationIntegration(t *testing.T) { - if os.Getenv("DBCtl_INTEGRATION") != "1" { - t.Skip("set DBCtl_INTEGRATION=1 to run against a live database container") - } - - root := t.TempDir() - t.Setenv("DCNTRLIZED_INSTALL_DIR", root) - for _, name := range []string{"keepnote", "dcntrlized-auth"} { - if err := os.MkdirAll(filepath.Join(root, name), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, name, "metadata.json"), []byte("{}"), 0o600); err != nil { - t.Fatal(err) - } - } - - dc, err := docker.NewDockerClient() - if err != nil { - t.Fatal(err) - } - defer dc.Close() - - c := New(dc) - if err := c.SyncAuthorization(context.Background()); err != nil { - t.Fatalf("SyncAuthorization: %v", err) - } - - id := c.dbContainerID(context.Background()) - if id == "" { - t.Fatal("database container not found") - } - assert := func(t *testing.T, sql, contains string) { - t.Helper() - out, code, err := c.execPSQL(context.Background(), id, "postgres", sql) - if err != nil || code != 0 { - t.Fatalf("check %q failed (exit %d, err %v): %s", sql, code, err, out) - } - if want := contains; want != "" && out != want { - t.Fatalf("%s => %q, want %q", sql, out, want) - } - } - // Roles exist and hold CONNECT. - assert(t, `SELECT rolname FROM pg_catalog.pg_roles WHERE rolname IN ('maestro','keepnote','dcntrlized-auth') ORDER BY 1`, "dcntrlized-auth\nkeepnote\nmaestro") - assert(t, `SELECT has_database_privilege('keepnote','dcntrlized','CONNECT')`, "t") - // Armed database: PUBLIC grant removed. - assert(t, `SELECT has_database_privilege('public','dcntrlized','CONNECT')`, "f") -} diff --git a/src/lib/db/dbctl_test.go b/src/lib/db/dbctl_test.go deleted file mode 100644 index 8aa3242..0000000 --- a/src/lib/db/dbctl_test.go +++ /dev/null @@ -1,78 +0,0 @@ -// 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 dbctl - -import ( - "os" - "path/filepath" - "sort" - "testing" -) - -func TestQuoteIdent(t *testing.T) { - cases := map[string]string{ - "keepnote": `"keepnote"`, - "dcntrlized-db": `"dcntrlized-db"`, - `we"ird`: `"we""ird"`, - } - for in, want := range cases { - if got := quoteIdent(in); got != want { - t.Errorf("quoteIdent(%q) = %s, want %s", in, got, want) - } - } -} - -func TestPeerRoles(t *testing.T) { - root := t.TempDir() - t.Setenv("DCNTRLIZED_INSTALL_DIR", root) - - // Installed peers (metadata.json present). - for _, name := range []string{"keepnote", "dcntrlized-auth", "calendar"} { - dir := filepath.Join(root, name) - if err := os.MkdirAll(dir, 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte("{}"), 0o600); err != nil { - t.Fatal(err) - } - } - // The database itself must never be granted a role. - if err := os.MkdirAll(filepath.Join(root, DBContainerName), 0o755); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(filepath.Join(root, DBContainerName, "metadata.json"), []byte("{}"), 0o600); err != nil { - t.Fatal(err) - } - // A non-install directory (no metadata.json) is skipped. - if err := os.MkdirAll(filepath.Join(root, "scratch"), 0o755); err != nil { - t.Fatal(err) - } - - roles, err := peerRoles() - if err != nil { - t.Fatalf("peerRoles: %v", err) - } - sort.Strings(roles) - want := []string{"calendar", "dcntrlized-auth", "keepnote", "maestro"} - if len(roles) != len(want) { - t.Fatalf("peerRoles = %v, want %v", roles, want) - } - for i := range want { - if roles[i] != want[i] { - t.Fatalf("peerRoles = %v, want %v", roles, want) - } - } -} diff --git a/src/lib/devboot/devboot.go b/src/lib/devboot/devboot.go deleted file mode 100644 index ebc8e42..0000000 --- a/src/lib/devboot/devboot.go +++ /dev/null @@ -1,60 +0,0 @@ -// 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 devboot provisions the dev fleet the DCNTRLIZED_DEV way: Maestro -// issues the identities its docker-compose.dev/debug.yaml peers bind-mount at -// /dcntrlized/pki. There is no approval shortcut — a satellite's first mTLS -// contact records a pending authorization and is refused (403) until the owner -// approves it via /api/peers, exactly the real trust-store flow. -package devboot - -import ( - "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/logger" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" -) - -// Infra are the shared services whose identities the dev composes mount. They -// are peers/attesters themselves, not approval candidates (auth is the -// trust-store owner, Maestro uses the internal RequireMaestro endpoints). -var Infra = []string{"dcntrlized-auth"} - -// Fleet lists the dev-mode satellite identities Maestro issues. Their CNs must -// match the service names used in the dev composes' pki mounts. -var Fleet = []string{"keepnote", "calendar", "testservice"} - -// EnsureIdentities provisions the instance CA identities the dev composes -// bind-mount at /dcntrlized/pki, so compose-managed services come up with a -// valid mTLS identity before they ever dial the auth service. Run before the -// shared infrastructure is ensured, because dcntrlized-auth's mTLS listener -// refuses to start without its identity. -func EnsureIdentities() { - dataRoot, err := containers.DataRoot() - if err != nil { - logger.Warn("devboot: resolve data root: %v", err) - return - } - ca, err := pki.EnsureCA(dataRoot) - if err != nil { - logger.Warn("devboot: ensure instance CA: %v", err) - return - } - for _, name := range append(append([]string{}, Infra...), Fleet...) { - if _, err := pki.EnsureServiceIdentity(ca, name); err != nil { - logger.Warn("devboot: ensure identity for %s: %v", name, err) - } - } - logger.Info("devboot: ensured dev fleet identities (%d infra + %d satellites)", len(Infra), len(Fleet)) -} diff --git a/src/lib/docker/client.go b/src/lib/docker/client.go index a95695b..428a360 100644 --- a/src/lib/docker/client.go +++ b/src/lib/docker/client.go @@ -93,18 +93,14 @@ func ListContainers(dc *DockerClient) ([]string, error) { // fetchManifestWithRetry polls the container's manifest endpoint until it succeeds or max retries are reached. // Could probably be simplified to (ctx, container) with maxRetries and retryDelay hardcoded, low priority TODO func FetchManifestWithRetry(ctx context.Context, host, port string, maxRetries int, retryDelay time.Duration) (*types.Manifest, error) { - // The service peer edge is mutual TLS (M2 §2), same as RunAction: Maestro's - // identity cert, https scheme, with the plaintext dev escape hatch. + // Rev 9: every service peer edge is plaintext HTTP on the single :8080 + // listener — no client cert, no TLS. url := fmt.Sprintf("%s://%s:%s/_manifest", authmgmt.Scheme(), host, port) var resp *http.Response var err error // Create a client that respects context deadlines/cancellation - transport, terr := authmgmt.PeerTransport() - if terr != nil { - return nil, fmt.Errorf("build peer transport: %w", terr) - } - client := &http.Client{Timeout: 2 * time.Second, Transport: transport} + client := &http.Client{Timeout: 2 * time.Second} for range maxRetries { req, reqErr := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) diff --git a/src/lib/env/env.go b/src/lib/env/env.go index 62787ed..29b8b85 100644 --- a/src/lib/env/env.go +++ b/src/lib/env/env.go @@ -16,8 +16,9 @@ // Package env centralizes environment-derived configuration. DCNTRLIZED_DEV is // the single canonical dev-mode flag, injected by every docker-compose.dev.yaml // and docker-compose.debug.yaml. It selects dev-only endpoints and behaviors -// (registry URL, plaintext satellite peer edges) — it never downgrades the -// transport security of the control-plane edges, which run mTLS in dev too. +// (registry URL, debug selectors). After rev 9 the fleet-wide internal +// transport is plaintext HTTP on a single :8080 listener in every mode, so dev +// mode no longer downgrades anything. package env import "os" diff --git a/src/lib/federation/store.go b/src/lib/federation/store.go deleted file mode 100644 index 02ec34e..0000000 --- a/src/lib/federation/store.go +++ /dev/null @@ -1,69 +0,0 @@ -// 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 federation is a defensive reader for the remote-instance trust -// store that the multi-registry & instance federation theme will own. The -// hub app's public profile (hub-app theme M1) surfaces authorized peers from -// it when present; until that theme lands, the file simply does not exist and -// every read returns nil. -package federation - -import ( - "encoding/json" - "os" - "path/filepath" -) - -// RemoteInstance mirrors the planned trust-store row: a peer this instance -// trusts, addressed by URL and identified by its CA fingerprint. -type RemoteInstance struct { - Name string `json:"name"` - URL string `json:"url"` - Fingerprint string `json:"fingerprint"` - Status string `json:"status"` - Scopes []string `json:"scopes,omitempty"` -} - -// Path returns the trust-store location for the given data root. -func Path(dataRoot string) string { - return filepath.Join(dataRoot, "federation", "instances.json") -} - -// List reads the authorized remote instances. A missing store yields -// (nil, nil): callers distinguish "absent" (nil → JSON null) from -// "present but empty" (non-nil, len 0 → JSON []). -func List(dataRoot string) ([]RemoteInstance, error) { - data, err := os.ReadFile(Path(dataRoot)) - if err != nil { - if os.IsNotExist(err) { - return nil, nil - } - return nil, err - } - - // Defensive parse: accept both a bare array and the wrapped shape the - // federation theme is expected to persist. - var direct []RemoteInstance - if json.Unmarshal(data, &direct) == nil { - return direct, nil - } - var wrapped struct { - Instances []RemoteInstance `json:"instances"` - } - if err := json.Unmarshal(data, &wrapped); err != nil { - return nil, err - } - return wrapped.Instances, nil -} diff --git a/src/lib/instance/instance.go b/src/lib/instance/instance.go index e72f62f..c9c4d5a 100644 --- a/src/lib/instance/instance.go +++ b/src/lib/instance/instance.go @@ -15,16 +15,23 @@ // Package instance holds the instance's public identity: a small, self- // contained file at the data root that answers "who am I" for the hub app's -// public profile (hub-app theme M1) and, later, federation attestation. +// public profile (hub-app theme M1). Rev 9 removed federation, so the +// fingerprint here is informational only (it is no longer a trust anchor). package instance import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" "path/filepath" ) +// keyFileName is the persisted random instance key backing Fingerprint. +const keyFileName = "instance.key" + // Identity is the instance's user-visible self-description. Name falls back to // DCNTRLIZED_INSTANCE_NAME and then the hostname when instance.json is absent; // Title and Description are optional and only ever come from the file. @@ -39,6 +46,55 @@ func Path(dataRoot string) string { return filepath.Join(dataRoot, "instance.json") } +// Fingerprint returns the instance's stable app-level identity fingerprint in +// the same "sha256:ab:cd:…" shape the CA fingerprint used to have. Rev 9 +// removed the PKI layer, so the value is derived from a persisted 32-byte +// random key at /instance.key (created on first use, chmod 600) — +// not from any certificate. It is unauthenticated by design; a consumer can +// still compare it to detect a changed peer. Returns "" when the key cannot be +// read or created (the public profile then omits the field). +func Fingerprint(dataRoot string) string { + key, err := loadOrCreateKey(dataRoot) + if err != nil { + return "" + } + sum := sha256.Sum256(key) + 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) +} + +// loadOrCreateKey reads /instance.key, generating it atomically on +// first use. +func loadOrCreateKey(dataRoot string) ([]byte, error) { + path := filepath.Join(dataRoot, keyFileName) + if data, err := os.ReadFile(path); err == nil && len(data) == 32 { + return data, nil + } + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + return nil, fmt.Errorf("generate instance key: %w", err) + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, key, 0o600); err != nil { + return nil, fmt.Errorf("write instance key: %w", err) + } + if err := os.Chmod(tmp, 0o600); err != nil { + return nil, fmt.Errorf("chmod instance key: %w", err) + } + if err := os.Rename(tmp, path); err != nil { + return nil, fmt.Errorf("replace instance key: %w", err) + } + return key, nil +} + // Load resolves the instance identity: instance.json when present, else // DCNTRLIZED_INSTANCE_NAME, else os.Hostname(). Read-on-demand by design — // no daemon state, so an edited file shows up on the next request. @@ -61,9 +117,8 @@ func Load(dataRoot string) Identity { } // Save atomically writes the identity file (tmp + rename), chmod 600 like the -// rest of Maestro's file stores. The owner-gated edit endpoint that uses this -// lands with the federation theme; the helper exists so both callers share one -// write path. +// rest of Maestro's file stores. The helper exists so the create and edit +// paths share one write path. func Save(dataRoot string, id Identity) error { if id.Name == "" { return fmt.Errorf("instance name must not be empty") @@ -90,8 +145,7 @@ func defaultIdentity() Identity { if name := os.Getenv("DCNTRLIZED_INSTANCE_NAME"); name != "" { return Identity{Name: name} } - // The domain the instance announces as is its natural public name; - // federation attestation will reuse it. + // The domain the instance announces as is its natural public name. if domain := os.Getenv("DCNTRLIZED_INSTANCE_DOMAIN"); domain != "" { return Identity{Name: domain} } diff --git a/src/lib/instance/instance_test.go b/src/lib/instance/instance_test.go index 175aeb5..b44a6f1 100644 --- a/src/lib/instance/instance_test.go +++ b/src/lib/instance/instance_test.go @@ -77,12 +77,12 @@ func TestDefaultIdentityFallbacks(t *testing.T) { func TestLooksLikeContainerID(t *testing.T) { cases := map[string]bool{ - "6079761d6e4d": true, - "ABCDEF0123456789abcdef0123456789": true, - "archlinux": false, - "maestro-1": false, - "abc": false, - "": false, + "6079761d6e4d": true, + "ABCDEF0123456789abcdef0123456789": true, + "archlinux": false, + "maestro-1": false, + "abc": false, + "": false, } for s, want := range cases { if got := looksLikeContainerID(s); got != want { diff --git a/src/lib/pki/crl.go b/src/lib/pki/crl.go deleted file mode 100644 index fc2d958..0000000 --- a/src/lib/pki/crl.go +++ /dev/null @@ -1,167 +0,0 @@ -// 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 deleted file mode 100644 index 5106c59..0000000 --- a/src/lib/pki/helpers.go +++ /dev/null @@ -1,139 +0,0 @@ -// 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) -} - -// CAFingerprint returns the instance CA's public-key fingerprint by reading -// the certificate from disk on demand (hub-app M1's public profile surfaces -// it as the instance identity). Empty string when no CA exists yet — dev-mode -// static services never provision one. -func CAFingerprint(dataRoot string) string { - pemBytes, err := os.ReadFile(CertPath(dataRoot)) - if err != nil { - return "" - } - block, _ := pem.Decode(pemBytes) - if block == nil { - return "" - } - cert, err := x509.ParseCertificate(block.Bytes) - if err != nil { - return "" - } - return Fingerprint(cert) -} diff --git a/src/lib/pki/identity.go b/src/lib/pki/identity.go deleted file mode 100644 index 84e9150..0000000 --- a/src/lib/pki/identity.go +++ /dev/null @@ -1,213 +0,0 @@ -// 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 -} - -// LoadServiceMeta reads a service's identity issuance record without touching -// the CA. It returns an error when the identity has not been issued yet. -func LoadServiceMeta(dataRoot, serviceName string) (ServiceMeta, error) { - return readMeta(ServiceDir(dataRoot, serviceName)) -} - -// 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/pki.go b/src/lib/pki/pki.go deleted file mode 100644 index 6d5b614..0000000 --- a/src/lib/pki/pki.go +++ /dev/null @@ -1,181 +0,0 @@ -// 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 deleted file mode 100644 index 430a47a..0000000 --- a/src/lib/pki/pki_test.go +++ /dev/null @@ -1,166 +0,0 @@ -// 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 9c15e80..776c159 100644 --- a/src/main.go +++ b/src/main.go @@ -26,17 +26,16 @@ import ( "git.dcntrlized.org/Dcntrlized/Maestro/handlers/actions" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/auth" + handlerscatalog "git.dcntrlized.org/Dcntrlized/Maestro/handlers/catalog" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/config" "git.dcntrlized.org/Dcntrlized/Maestro/handlers/containers" + "git.dcntrlized.org/Dcntrlized/Maestro/handlers/dataplane" handlersinstance "git.dcntrlized.org/Dcntrlized/Maestro/handlers/instance" - "git.dcntrlized.org/Dcntrlized/Maestro/handlers/peers" handlers "git.dcntrlized.org/Dcntrlized/Maestro/handlers/registry" + "git.dcntrlized.org/Dcntrlized/Maestro/handlers/weblinks" libcontainers "git.dcntrlized.org/Dcntrlized/Maestro/lib/containers" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/devboot" "git.dcntrlized.org/Dcntrlized/Maestro/lib/docker" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/env" "git.dcntrlized.org/Dcntrlized/Maestro/lib/owner" - "git.dcntrlized.org/Dcntrlized/Maestro/lib/pki" "git.dcntrlized.org/Dcntrlized/Maestro/lib/secrets" ) @@ -114,19 +113,9 @@ func main() { log.Fatalf("Failed to init docker client: %v", err) } defer dc.Close() - // 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. - if _, err := pki.EnsureCA(dataRoot); err != nil { - log.Fatalf("Failed to provision instance CA: %v", err) - } - // Dev mode: provision the identities the docker-compose.dev/debug.yaml - // peers bind-mount at /dcntrlized/pki, BEFORE the shared infrastructure - // boots — dcntrlized-auth's mTLS listener refuses to start without its - // identity. Must run before EnsureDatabaseRunning/EnsureAuthRunning. - if env.IsDev() { - devboot.EnsureIdentities() - } + // Rev 9: the PKI identity layer is removed fleet-wide. There is no + // instance CA, no devboot identity provisioning and no mTLS anywhere; + // every internal edge is plaintext HTTP on dcntrlized_net. if err := cache.LoadInstalledContainers(); err != nil { log.Fatalf("Failed to load installed containers: %v", err) } @@ -140,9 +129,10 @@ func main() { if err := libcontainers.EnsureAuthRunning(ctx); err != nil { log.Printf("Warning: failed to ensure shared auth service: %v", err) } - // No dev pre-approval: the fleet's first mTLS contact records a pending - // authorization and is refused (403) until the owner approves it via - // /api/peers — dev runs the real trust-store flow. + // Rev 9: no trust handshake happens at contact time. Containers are + // tracked by label and their manifests are fetched over plaintext HTTP; + // the only key check left is the app-level AUTH_PINNED_FINGERPRINT + // comparison (M2 §5). cache.ReconcileContainers(ctx, dc) go cache.WatchContainers(ctx, dc) } @@ -164,12 +154,26 @@ func main() { // Instance public profile (hub-app theme M1): the one additional // unauthenticated surface — deliberately allowlisted around the owner - // gate (cross-theme contract, hub-app D2). Exact path only; sub-paths - // like /api/instance/attestation (federation) are registered separately. + // gate (cross-theme contract, hub-app D2). Exact path only. Rev 9 removed + // the federation attestation sub-path (/api/instance/attestation) with the + // PKI layer. mux.Handle("/api/instance", handlersinstance.NewPublicProfileHandler(cache.GetServices, dataRoot)) + // App catalog (app-catalog theme): the operator-authored Android app + // distribution. Owner-gated control surface plus the public artifact/icon + // endpoints clients download from. The public reads sit outside the gate, + // like /api/instance, and fail closed (404) when + // DCNTRLIZED_PUBLIC_PROFILE=false. + catalogHandler := handlerscatalog.NewHandler(dataRoot) + mux.Handle("/api/catalog/apps", gate(http.HandlerFunc(catalogHandler.Apps))) + mux.Handle("/api/catalog/apps/{id}", gate(http.HandlerFunc(catalogHandler.App))) + mux.Handle("/api/catalog/apps/{id}/artifact", gate(http.HandlerFunc(catalogHandler.Artifact))) + mux.Handle("/api/catalog/apps/{id}/icon", gate(http.HandlerFunc(catalogHandler.Icon))) + mux.Handle("/api/catalog/artifacts/{id}/{filename}", http.HandlerFunc(catalogHandler.Download)) + mux.Handle("/api/catalog/icons/{id}", http.HandlerFunc(catalogHandler.IconBytes)) + mux.Handle("/api/ws", authService.GateWebSocket(http.HandlerFunc(wsHub.HandleWS))) - mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache))) + mux.Handle("/api/containers", gate(containers.NewListContainersHandler(cache, dataRoot))) mux.Handle("/api/containers/install", gate(containers.InstallContainerHandler(cache))) mux.Handle("/api/containers/uninstall", gate(containers.UninstallContainerHandler(cache))) mux.Handle("/api/containers/start", gate(containers.StartContainerHandler(cache))) @@ -177,16 +181,42 @@ func main() { mux.Handle("/api/actions/{containerId}/{actionName}", gate(http.HandlerFunc(actions.RunAction))) mux.Handle("/api/registry", gate(http.HandlerFunc(handlers.GetRegistry))) - // Trusted-services surface (security theme M2 §3): pending first-contact - // authorizations and the owner's approve/revoke actions, forwarded to the - // auth service's own trust store. - peersHandler := peers.NewHandler() - mux.Handle("/api/peers", gate(http.HandlerFunc(peersHandler.List))) - mux.Handle("/api/peers/attestation", gate(http.HandlerFunc(peersHandler.Attestation))) - mux.Handle("/api/peers/approve", gate(http.HandlerFunc(peersHandler.Approve))) - mux.Handle("/api/peers/revoke", gate(http.HandlerFunc(peersHandler.Revoke))) - mux.Handle("/api/peers/reject", gate(http.HandlerFunc(peersHandler.Reject))) - mux.Handle("/api/peers/hide", gate(http.HandlerFunc(peersHandler.Hide))) + // Service data plane (user ruling, 2026-10-18): every request to a service is + // expected to go through Maestro, so an instance only needs ONE open port. + // Reachable: /api/{service}/action/{action}, /api/{service}/auth/{path...} and + // /api/{service}/ws[/{path...}] — one allowlist, one passthrough, HTTP or + // WebSocket depending on the request. + // + // DELIBERATELY NOT BEHIND `gate`, and this is the second time it matters. + // History: the action route shipped ungated in 0accff3 ("real init"), survived + // eb01569 and d24fe0f, and was wrapped in the owner-session gate by 2ffdf77 + // ("feat(auth): instance-owner gate"). That commit treated a SERVICE + // data-plane route as console traffic. It never was: this surface exists to + // reach services, and its callers are apps and phones, which hold no Maestro + // owner session — that cookie is the instance operator's and an app must never + // carry one. The service is the gate: it validates whatever token it issues + // (via dcntrlized-auth) and answers 401 itself. A future reader must not + // "restore" an owner gate here; doing so reproduces the 2026-10 regression + // that made every phone unable to act through its own instance. + // + // /api/ws above stays owner-gated: that is Maestro's own dashboard broadcast, + // not service traffic. /api/actions/{containerId}/{actionName} above also + // stays as it is (the legacy owner-gated console path) — the data plane is a + // separate, ungated surface. + // + // Why one "/api/" fallback and not a pattern per shape: registering + // /api/{service}/action/{actionName} (or /api/{service}/auth/{path...}) + // PANICS at boot next to /api/actions/{containerId}/{actionName} — both match + // /api/actions/action/ and neither is more specific. Build and vet cannot + // see it (the mux is assembled here), so it is pinned by + // handlers/dataplane's TestProductionPatternSetDoesNotPanic and + // TestNaivePatternSetPanics. + dataplane.Register(mux) + + // Rev 9 retires the peer/federation trust surfaces: /api/peers* (the auth + // service's trust store) and /api/instances (the TOFU-pinned remote-instance + // store) are gone, along with the console's TrustedServices UI. There is no + // peer identity to authorize on a plaintext network. // Service Config & Secrets (theme M1): owner-gated per-service config and // secret values. The manifest declares the schema; the encrypted store @@ -196,6 +226,23 @@ func main() { mux.Handle("/api/services/{service}/config", gate(http.HandlerFunc(configHandler.List))) mux.Handle("/api/services/{service}/config/{key}", gate(http.HandlerFunc(configHandler.Value))) + // Web App URL sources (contract rev 8.2 §16.2): the owner-controlled + // base-domain template and per-service overrides that Maestro layers + // between the manifest's own web.url and the derived request-host fallback. + webLinksHandler := weblinks.NewHandler(dataRoot) + mux.Handle("/api/web-links", gate(http.HandlerFunc(webLinksHandler.Root))) + + // Public hub web app (browser-edge contract §10.5, RC16): a separate, + // unauthenticated SPA bundle served at /hub/ OUTSIDE the owner gate. It + // reads only the public GET /api/instance profile. The operator console at + // "/" and every owner-gated /api/* route stay exactly as they were; no + // owner-only route is moved, weakened or exposed here. + hubHandler, err := newHubHandler() + if err != nil { + log.Fatalf("Failed to parse embedded hub subtree: %v", err) + } + mux.Handle("/hub/", http.StripPrefix("/hub/", hubHandler)) + frontendDir, err := fs.Sub(embeddedFrontend, "frontend/dist") if err != nil { log.Fatalf("Failed to parse embedded frontend subtree: %v", err)