feat(security): trusted-services surface + auth mgmt client (M2 §3)

Maestro is the UI/CA, not the enforcement: the auth service owns its
authorized-keys trust store. This is Maestro's side of the flow.

- lib/authmgmt: mTLS management client (Maestro's own identity cert from
  the instance CA) for /internal/{attestation,authorizations,revoke,
  pending}; RevokeServiceBestEffort for uninstall.
- handlers/peers: GET /api/peers, /api/peers/attestation, POST
  approve/revoke.
- uninstall: revoke the service's authorization in the auth trust store
  alongside the CRL identity revocation.
- frontend: TrustedServices panel (pending first-contact authorizations +
  approved keys with authorize/ignore/revoke actions).
- spec.yaml: document the new endpoints and schemas.
- housekeeping: untrack pki test log artifacts, gofmt fixes in pki.
This commit is contained in:
2026-08-15 23:27:21 +01:00
parent dc79629c7e
commit 950b6e5827
16 changed files with 839 additions and 28 deletions
+238
View File
@@ -0,0 +1,238 @@
// Copyright (C) 2026 Dcntrlized
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published
// by the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
// Package authmgmt is Maestro's client for the shared auth service's
// management endpoints (security theme M2 §3). Maestro is only the CA and the
// UI here: it lists pending first-contact requests, forwards the owner's
// approve/revoke decisions to the auth service, and reads its key
// attestation. Enforcement always stays inside the auth service (D5).
package authmgmt
import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"time"
"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"
// 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.
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.
type Attestation struct {
PublicKey string `json:"public_key"`
Fingerprint string `json:"fingerprint"`
Kid string `json:"kid"`
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"`
}
// 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).
func New() (*Client, 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")
}
baseURL := os.Getenv("AUTH_SERVICE_URL")
if baseURL == "" {
baseURL = AuthServiceURL
}
return &Client{
baseURL: baseURL,
http: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{
TLSClientConfig: &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
MinVersion: tls.VersionTLS12,
},
},
},
}, nil
}
// 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
}
return &state, nil
}
// Attestation returns the auth service's public key statement.
func (c *Client) Attestation(ctx context.Context) (*Attestation, error) {
var att Attestation
if err := c.do(ctx, http.MethodGet, "/internal/attestation", nil, &att); err != nil {
return nil, err
}
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)
}
func (c *Client) do(ctx context.Context, method, path string, body, out any) error {
var payload []byte
if body != nil {
payload, _ = json.Marshal(body)
}
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, bytes.NewReader(payload))
if err != nil {
return err
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.http.Do(req)
if err != nil {
return fmt.Errorf("auth service %s %s: %w", method, path, err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("auth service %s %s: status %d", method, path, resp.StatusCode)
}
if out != nil {
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode auth service response: %w", 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)
}
}