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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/authmgmt"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/docker"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/logger"
|
||||
"git.dcntrlized.org/Dcntrlized/Maestro/lib/pki"
|
||||
@@ -48,10 +49,16 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
||||
|
||||
// Revoke the service's identity certificate and publish the CRL entry
|
||||
// (security theme M1 §2 / M3 §2). A revoked service stops authenticating
|
||||
// on the mTLS edges within one CRL freshness window.
|
||||
// on the mTLS edges within one CRL freshness window. The authorization in
|
||||
// the auth service's trust store is revoked too (M2 §3 step 5) — the
|
||||
// fingerprint is read before the identity files are removed.
|
||||
fingerprint := serviceFingerprint(serviceName)
|
||||
if err := revokeServiceIdentity(serviceName); err != nil {
|
||||
logger.Warn("Failed to revoke identity for %s: %v", serviceName, err)
|
||||
}
|
||||
if fingerprint != "" {
|
||||
authmgmt.RevokeServiceBestEffort(ctx, serviceName, fingerprint)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(targetDir); err == nil {
|
||||
// Stop and remove the project containers/networks via compose down when
|
||||
@@ -92,6 +99,21 @@ func UninstallContainer(ctx context.Context, item types.RegistryContainer) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// serviceFingerprint reads the service's identity fingerprint (the public key
|
||||
// fingerprint the auth service's trust store is keyed on), or "" when the
|
||||
// identity was never issued.
|
||||
func serviceFingerprint(serviceName string) string {
|
||||
dataRoot, err := getDataRoot()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
meta, err := pki.LoadServiceMeta(dataRoot, serviceName)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return meta.Fingerprint
|
||||
}
|
||||
|
||||
// revokeServiceIdentity revokes the service's certificate in the instance CA.
|
||||
// Best-effort: uninstall proceeds even if the CA or identity is missing.
|
||||
func revokeServiceIdentity(serviceName string) error {
|
||||
|
||||
+7
-7
@@ -31,10 +31,10 @@ import (
|
||||
|
||||
// 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"`
|
||||
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.
|
||||
@@ -123,9 +123,9 @@ func regenerateCRL(ca *CA) error {
|
||||
}
|
||||
|
||||
template := &x509.RevocationList{
|
||||
Number: mustSerial(),
|
||||
ThisUpdate: now(),
|
||||
NextUpdate: now().Add(24 * time.Hour),
|
||||
Number: mustSerial(),
|
||||
ThisUpdate: now(),
|
||||
NextUpdate: now().Add(24 * time.Hour),
|
||||
RevokedCertificates: revokedEntries(serials),
|
||||
}
|
||||
|
||||
|
||||
+12
-6
@@ -43,12 +43,12 @@ type ServiceMeta struct {
|
||||
// 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
|
||||
Service string
|
||||
Cert *x509.Certificate
|
||||
Key *ecdsa.PrivateKey
|
||||
CertPath string
|
||||
KeyPath string
|
||||
Meta ServiceMeta
|
||||
}
|
||||
|
||||
// ServiceDir returns the identity directory for a service.
|
||||
@@ -72,6 +72,12 @@ func HasServiceIdentity(dataRoot, serviceName string) bool {
|
||||
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.
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
========== Start Log ==========
|
||||
[INFO] Logger initialized
|
||||
[2026-08-15T23:09:40+01:00]
|
||||
@@ -1,3 +0,0 @@
|
||||
========== Start Log ==========
|
||||
[INFO] Logger initialized
|
||||
[2026-08-15T23:09:40+01:00]
|
||||
@@ -1,4 +0,0 @@
|
||||
========== Start Log ==========
|
||||
[INFO] Logger initialized
|
||||
[2026-08-15T23:09:40+01:00]
|
||||
INFO: 2026/08/15 22:09:40 pki: refreshing expiring identity for keepnote
|
||||
@@ -1,3 +0,0 @@
|
||||
========== Start Log ==========
|
||||
[INFO] Logger initialized
|
||||
[2026-08-15T23:09:40+01:00]
|
||||
Reference in New Issue
Block a user