Files
certmanager/internal/pki/pki.go
T
unkin-agent b0aad96673
ci/woodpecker/pr/build Pipeline was successful
ci/woodpecker/pr/integration Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/pre-commit Pipeline failed
Source the CA from /ca_chain, not from issuance
Deriving ca_certificate from an issue response welds CA distribution to
per-node renewal, so a rotation takes ~90 days to reach the estate, and
issuing_ca is the intermediate alone, which strict path builders reject
as a CAfile.

- add a `ca` subcommand printing {mount}/ca_chain as PEM, unauthenticated
- drop ca_certificate from the issue document and its file output
- order full_chain leaf first
- require only vault.addr for the CA path via config.LoadCA
2026-09-19 13:27:47 +10:00

107 lines
3.2 KiB
Go

package pki
import (
"crypto/x509"
"encoding/pem"
"fmt"
"strings"
"git.unkin.net/unkin/certmanager/internal/vault"
)
// CertificateResponse is the JSON document printed with --json and consumed by
// parsejson() in profiles::pki::vault. Field order fixes the key order.
type CertificateResponse struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
FullChain string `json:"full_chain"`
}
type issueRequest struct {
CommonName string `json:"common_name"`
AltNames string `json:"alt_names"`
IPSans string `json:"ip_sans"`
TTL string `json:"ttl"`
}
type issueResponse struct {
Data struct {
Certificate string `json:"certificate"`
PrivateKey string `json:"private_key"`
IssuingCA string `json:"issuing_ca"`
} `json:"data"`
}
// IssueCert requests a certificate from the Vault PKI role.
func IssueCert(client *vault.Client, mountPoint, roleName, commonName string, altNames, ipSans []string, expiryDays int) (*CertificateResponse, error) {
req := issueRequest{
CommonName: commonName,
AltNames: strings.Join(altNames, ","),
IPSans: strings.Join(ipSans, ","),
TTL: fmt.Sprintf("%dd", expiryDays),
}
var resp issueResponse
if err := client.Post(fmt.Sprintf("%s/issue/%s", mountPoint, roleName), req, &resp); err != nil {
return nil, err
}
if resp.Data.Certificate == "" || resp.Data.PrivateKey == "" {
return nil, fmt.Errorf("vault returned no certificate data for %q", commonName)
}
// Leaf first: every TLS server that reads full_chain.crt expects the end
// entity certificate ahead of the issuers.
return &CertificateResponse{
Certificate: resp.Data.Certificate,
PrivateKey: resp.Data.PrivateKey,
FullChain: resp.Data.Certificate + "\n" + resp.Data.IssuingCA,
}, nil
}
// FetchCAChain returns the mount's CA chain as PEM. /ca_chain is public and
// estate-wide: it needs no token, no role and no per-node context, which is why
// it is fetched here rather than derived from an issue response. It carries the
// root as well as the intermediate, so the result stands alone as a CAfile for
// path builders that insist on a self-signed anchor.
func FetchCAChain(client *vault.Client, mountPoint string) (string, error) {
path := fmt.Sprintf("%s/ca_chain", mountPoint)
body, err := client.Get(path)
if err != nil {
return "", err
}
// The endpoint advertises application/pkix-cert while serving PEM, so the
// body is parsed rather than sniffed.
chain := strings.TrimSpace(string(body))
if chain == "" {
return "", fmt.Errorf("GET %s: vault returned an empty CA chain", path)
}
n, err := countCertificates(chain)
if err != nil {
return "", fmt.Errorf("GET %s: %w", path, err)
}
if n == 0 {
return "", fmt.Errorf("GET %s: response is not a PEM certificate chain", path)
}
return chain + "\n", nil
}
func countCertificates(chain string) (int, error) {
rest := []byte(chain)
count := 0
for {
var block *pem.Block
block, rest = pem.Decode(rest)
if block == nil {
return count, nil
}
if block.Type != "CERTIFICATE" {
return 0, fmt.Errorf("PEM block %d is a %q, not a CERTIFICATE", count+1, block.Type)
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return 0, fmt.Errorf("parse certificate %d: %w", count+1, err)
}
count++
}
}