Files
certctl/cmd/agent/verify.go
T
shankar0123 7cb453a336 chore(fmt): repo-wide gofmt -w sweep — close drift surfaced by ci-pipeline-cleanup Phase 4
Mechanical reformat. The new 'gofmt drift' CI step (added in
ci-pipeline-cleanup Phase 4, commit 0f205a8) surfaced 111 files
with accumulated gofmt drift across cmd/, internal/, and deploy/test/.

Each file's diff is gofmt-standard: whitespace adjustments, intra-
group import sorting (alphabetical by import path within blank-line-
separated groups), and struct-tag column alignment. No semantic
changes — verified via 'git diff --ignore-all-space' which shows only
the line-position deltas from import reordering.

The gate stays in place after this commit. Going forward it catches
gofmt drift at PR time.
2026-04-30 22:33:57 +00:00

286 lines
8.7 KiB
Go

package main
import (
"bytes"
"context"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"time"
)
// verifyDeployment probes the live TLS endpoint for a deployment target and verifies
// that the deployed certificate matches what we expect.
//
// Parameters:
// - targetHost: the hostname or IP of the target (extracted from target config)
// - targetPort: the TLS port of the target (e.g., 443)
// - expectedCertPEM: the PEM-encoded certificate that was deployed
// - delay: wait time before probing (e.g., 2 seconds for reload to take effect)
// - timeout: overall timeout for TLS connection attempt (e.g., 10 seconds)
//
// Returns:
// - A VerificationResult if probing succeeded (even if cert doesn't match)
// - An error if the probe itself failed (network error, timeout, etc.)
//
// The function compares the SHA-256 fingerprints of the expected and actual certificates.
// If the certificate served at the endpoint differs, Verified will be false but no error
// is returned — this is an expected verification failure, not a probe failure.
func verifyDeployment(
ctx context.Context,
targetHost string,
targetPort int,
expectedCertPEM string,
delay time.Duration,
timeout time.Duration,
logger *slog.Logger,
) (*VerificationResult, error) {
// Wait for reload to take effect
if delay > 0 {
select {
case <-time.After(delay):
case <-ctx.Done():
return nil, ctx.Err()
}
}
// Parse expected certificate to compute its fingerprint
expectedFp, err := computeCertificateFingerprint(expectedCertPEM)
if err != nil {
return nil, fmt.Errorf("failed to parse expected certificate: %w", err)
}
// Connect to the target's TLS endpoint
address := fmt.Sprintf("%s:%d", targetHost, targetPort)
if logger != nil {
logger.Debug("probing TLS endpoint for verification",
"address", address,
"expected_fingerprint", expectedFp)
}
dialer := &net.Dialer{Timeout: timeout}
conn, err := tls.DialWithDialer(dialer, "tcp", address, &tls.Config{
// SECURITY NOTE: InsecureSkipVerify is intentionally set to true here.
// Post-deployment verification must probe the live endpoint to extract and
// compare the served certificate fingerprint, regardless of its validity
// state (expired, self-signed, internal CA, etc.). This setting is scoped
// to verification probing only — it is NEVER used for control-plane API
// calls, issuer connector communication, or any operation that trusts the
// certificate. The verification result compares SHA-256 fingerprints only.
// See TICKET-016 for full security audit rationale.
InsecureSkipVerify: true, //nolint:gosec // verification probe; documented above + docs/tls.md L-001 table
ServerName: targetHost, // For SNI
})
if err != nil {
return nil, fmt.Errorf("failed to connect to %s: %w", address, err)
}
defer conn.Close()
// Extract the leaf certificate from the TLS connection
state := conn.ConnectionState()
if len(state.PeerCertificates) == 0 {
return nil, fmt.Errorf("no certificates presented by %s", address)
}
leafCert := state.PeerCertificates[0]
actualFp := fmt.Sprintf("%x", sha256.Sum256(leafCert.Raw))
if logger != nil {
logger.Debug("received certificate from endpoint",
"address", address,
"cn", leafCert.Subject.CommonName,
"actual_fingerprint", actualFp)
}
// Compare fingerprints
verified := actualFp == expectedFp
if logger != nil {
if !verified {
logger.Warn("certificate fingerprint mismatch at endpoint",
"address", address,
"expected_fingerprint", expectedFp,
"actual_fingerprint", actualFp)
} else {
logger.Info("certificate verification succeeded",
"address", address,
"fingerprint", actualFp)
}
}
return &VerificationResult{
ExpectedFingerprint: expectedFp,
ActualFingerprint: actualFp,
Verified: verified,
VerifiedAt: time.Now().UTC(),
}, nil
}
// VerificationResult represents the outcome of verifying a deployed certificate.
type VerificationResult struct {
ExpectedFingerprint string `json:"expected_fingerprint"`
ActualFingerprint string `json:"actual_fingerprint"`
Verified bool `json:"verified"`
VerifiedAt time.Time `json:"verified_at"`
Error string `json:"error,omitempty"`
}
// computeCertificateFingerprint computes the SHA-256 fingerprint of a PEM-encoded certificate.
func computeCertificateFingerprint(certPEM string) (string, error) {
block, _ := pem.Decode([]byte(certPEM))
if block == nil {
return "", fmt.Errorf("failed to decode PEM certificate")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("failed to parse x509 certificate: %w", err)
}
fp := sha256.Sum256(cert.Raw)
return fmt.Sprintf("%x", fp), nil
}
// reportVerificationResult submits the verification result back to the control plane.
// This is a best-effort operation — a failure to report doesn't block agent progress.
func (a *Agent) reportVerificationResult(
ctx context.Context,
jobID string,
targetID string,
result *VerificationResult,
) error {
if jobID == "" || targetID == "" || result == nil {
return fmt.Errorf("missing required fields for verification report")
}
// Build the request payload
payload := map[string]interface{}{
"target_id": targetID,
"expected_fingerprint": result.ExpectedFingerprint,
"actual_fingerprint": result.ActualFingerprint,
"verified": result.Verified,
"error": result.Error,
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("failed to marshal verification result: %w", err)
}
// POST to /api/v1/jobs/{id}/verify
url := fmt.Sprintf("%s/api/v1/jobs/%s/verify", a.config.ServerURL, jobID)
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("failed to create verification request: %w", err)
}
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", a.config.APIKey))
req.Header.Set("Content-Type", "application/json")
resp, err := a.client.Do(req)
if err != nil {
return fmt.Errorf("failed to send verification result: %w", err)
}
defer resp.Body.Close()
// Check response status
if resp.StatusCode != http.StatusOK {
bodyBytes, _ := io.ReadAll(resp.Body)
return fmt.Errorf("verification reporting failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
if a.logger != nil {
a.logger.Debug("verification result reported to control plane",
"job_id", jobID,
"verified", result.Verified)
}
return nil
}
// extractTargetHostAndPort extracts the host and port from target configuration.
// Common target configs include "host" or "hostname" and "port" fields.
func extractTargetHostAndPort(configJSON json.RawMessage) (string, int, error) {
var config map[string]interface{}
if err := json.Unmarshal(configJSON, &config); err != nil {
return "", 0, fmt.Errorf("invalid target config JSON: %w", err)
}
// Try common field names for hostname
var host string
for _, key := range []string{"host", "hostname", "target", "address"} {
if h, ok := config[key].(string); ok && h != "" {
host = h
break
}
}
if host == "" {
return "", 0, fmt.Errorf("target config missing host/hostname field")
}
// Try common field names for port, default to 443
port := 443
if p, ok := config["port"].(float64); ok {
port = int(p)
}
if port < 1 || port > 65535 {
return "", 0, fmt.Errorf("invalid port: %d", port)
}
return host, port, nil
}
// verifyAndReportDeployment performs TLS endpoint verification and reports the result.
// This is a best-effort operation — failures are logged but don't affect deployment status.
func (a *Agent) verifyAndReportDeployment(
ctx context.Context,
job JobItem,
targetHost string,
targetPort int,
certPEM string,
) {
// Perform verification with configured timeout and delay
result, err := verifyDeployment(ctx, targetHost, targetPort, certPEM,
2*time.Second, // delay before probing
10*time.Second, // timeout for TLS connection
a.logger)
if err != nil {
if a.logger != nil {
a.logger.Warn("verification probe failed",
"job_id", job.ID,
"target_host", targetHost,
"target_port", targetPort,
"error", err)
}
// Probe failure: report error but continue
result = &VerificationResult{
Error: err.Error(),
VerifiedAt: time.Now().UTC(),
}
}
// Report result to control plane
if job.TargetID == nil {
if a.logger != nil {
a.logger.Warn("cannot report verification: target_id is nil", "job_id", job.ID)
}
return
}
if err := a.reportVerificationResult(ctx, job.ID, *job.TargetID, result); err != nil {
if a.logger != nil {
a.logger.Warn("failed to report verification result",
"job_id", job.ID,
"error", err)
}
// Non-blocking: continue even if report fails
}
}