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, 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 } }