Shared Apache-2.0 packages for the signed bundle format and the agent certificate conventions, the agent role interface, REST API spec for agents, enrollment tokens, revisions, deployments and leases, and ADR 0021. Generator settings now hold the target paths on the managed hosts. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
134 lines
4.5 KiB
Go
134 lines
4.5 KiB
Go
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
// Package agentpki holds the conventions shared by server and agents for the
|
|
// agent certificates (docs/adr/0008-interne-ca-enrollment.md): the identity
|
|
// URI in the client certificate, the CA fingerprint used to bootstrap trust
|
|
// during enrollment and the TLS settings of the agent connection.
|
|
package agentpki
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"git.beugsiud.de/beug.k/DDI/internal/brand"
|
|
)
|
|
|
|
// uuidPattern matches the canonical lowercase UUID form used for agent IDs.
|
|
var uuidPattern = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`)
|
|
|
|
// ValidAgentID reports whether id is a canonical lowercase UUID.
|
|
func ValidAgentID(id string) bool { return uuidPattern.MatchString(id) }
|
|
|
|
// uriPrefix is "urn:<product>:agent:".
|
|
func uriPrefix() string { return "urn:" + brand.Product + ":agent:" }
|
|
|
|
// AgentURI returns the URI SAN that identifies an agent in its client
|
|
// certificate, e.g. "urn:ddi-suite:agent:<uuid>".
|
|
func AgentURI(agentID string) *url.URL {
|
|
return &url.URL{Scheme: "urn", Opaque: strings.TrimPrefix(uriPrefix(), "urn:") + agentID}
|
|
}
|
|
|
|
// AgentID extracts the agent ID from a client certificate. The certificate
|
|
// must carry exactly one agent URI SAN and its common name must match.
|
|
func AgentID(cert *x509.Certificate) (string, error) {
|
|
var id string
|
|
for _, u := range cert.URIs {
|
|
s := u.String()
|
|
if rest, ok := strings.CutPrefix(s, uriPrefix()); ok {
|
|
if id != "" {
|
|
return "", errors.New("certificate carries more than one agent ID")
|
|
}
|
|
id = rest
|
|
}
|
|
}
|
|
switch {
|
|
case id == "":
|
|
return "", errors.New("certificate carries no agent ID")
|
|
case !ValidAgentID(id):
|
|
return "", fmt.Errorf("certificate carries an invalid agent ID %q", id)
|
|
case cert.Subject.CommonName != id:
|
|
return "", errors.New("certificate common name does not match the agent ID")
|
|
}
|
|
return id, nil
|
|
}
|
|
|
|
// FingerprintPrefix precedes the hex digest in a CA fingerprint.
|
|
const FingerprintPrefix = "sha256:"
|
|
|
|
// Fingerprint returns "sha256:<hex>" of the certificate's DER encoding. The
|
|
// enrollment command carries the fingerprint of the agent CA.
|
|
func Fingerprint(cert *x509.Certificate) string {
|
|
sum := sha256.Sum256(cert.Raw)
|
|
return FingerprintPrefix + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// ParseFingerprint validates a fingerprint and returns it in canonical
|
|
// (lowercase) form.
|
|
func ParseFingerprint(s string) (string, error) {
|
|
hexPart, ok := strings.CutPrefix(strings.TrimSpace(s), FingerprintPrefix)
|
|
if !ok {
|
|
return "", fmt.Errorf("CA fingerprint must start with %q", FingerprintPrefix)
|
|
}
|
|
hexPart = strings.ToLower(hexPart)
|
|
if b, err := hex.DecodeString(hexPart); err != nil || len(b) != sha256.Size {
|
|
return "", errors.New("CA fingerprint must be 64 hex digits")
|
|
}
|
|
return FingerprintPrefix + hexPart, nil
|
|
}
|
|
|
|
// VerifyChainWithFingerprint is used by the agent during enrollment, before
|
|
// it has the CA certificate: rawCerts (as sent by the server, leaf first)
|
|
// must contain a self-signed CA whose fingerprint equals want, and the leaf
|
|
// must chain to it and be valid for serverName. It is meant for
|
|
// tls.Config.VerifyPeerCertificate together with InsecureSkipVerify.
|
|
func VerifyChainWithFingerprint(rawCerts [][]byte, want, serverName string) (*x509.Certificate, error) {
|
|
want, err := ParseFingerprint(want)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(rawCerts) < 2 {
|
|
return nil, errors.New("server did not send its CA certificate")
|
|
}
|
|
certs := make([]*x509.Certificate, len(rawCerts))
|
|
for i, raw := range rawCerts {
|
|
if certs[i], err = x509.ParseCertificate(raw); err != nil {
|
|
return nil, fmt.Errorf("parse server certificate: %w", err)
|
|
}
|
|
}
|
|
var ca *x509.Certificate
|
|
for _, c := range certs[1:] {
|
|
if Fingerprint(c) == want {
|
|
ca = c
|
|
break
|
|
}
|
|
}
|
|
if ca == nil {
|
|
return nil, errors.New("server CA does not match the expected fingerprint")
|
|
}
|
|
roots := x509.NewCertPool()
|
|
roots.AddCert(ca)
|
|
if _, err := certs[0].Verify(x509.VerifyOptions{Roots: roots, DNSName: serverName}); err != nil {
|
|
return nil, fmt.Errorf("verify server certificate: %w", err)
|
|
}
|
|
return ca, nil
|
|
}
|
|
|
|
// ClientTLSConfig returns the TLS settings agents use after enrollment.
|
|
func ClientTLSConfig(ca *x509.Certificate, cert tls.Certificate, serverName string) *tls.Config {
|
|
roots := x509.NewCertPool()
|
|
roots.AddCert(ca)
|
|
return &tls.Config{
|
|
MinVersion: tls.VersionTLS13,
|
|
RootCAs: roots,
|
|
Certificates: []tls.Certificate{cert},
|
|
ServerName: serverName,
|
|
}
|
|
}
|