mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 17:02:43 +00:00
21aeed4f4e
Phase 0 closure (Path B2, post-rewrite):
addlicense sweep — adds the canonical certctl LLC copyright + BUSL-1.1
SPDX header to every production Go file. Template:
// Copyright 2026 certctl LLC. All rights reserved.
// SPDX-License-Identifier: BUSL-1.1
Coverage: 338 / 338 production Go files (cmd/ + internal/, excluding
*_test.go and **/testdata/**). Pre-sweep coverage was 22 / 338 (6.5%);
post-sweep is 338 / 338 (100%).
Normalized 22 pre-existing legacy headers (`// Copyright (c) certctl`
+ `// SPDX-License-Identifier: BSL-1.1`) and 1 file using a
`Certctl Contributors` attribution. The legacy SPDX ID `BSL-1.1`
is non-standard; the official SPDX identifier for Business Source
License 1.1 is `BUSL-1.1` (capital U). All 338 files now share the
canonical form.
Generated via:
addlicense -c "certctl LLC" -y 2026 \
-f cowork/legal/copyright-header.tpl \
-ignore '**/testdata/**' -ignore '**/*_test.go' \
cmd/ internal/
Verification:
find cmd internal -name '*.go' -not -name '*_test.go' \
-not -path '*/testdata/*' \
-exec grep -L '^// Copyright 2026 certctl LLC' {} \; | wc -l
Returns: 0
gofmt clean. Header additions are comments only, no compile impact.
Closes: cowork/certctl-architecture-diligence-audit.html#fix-RED-4
289 lines
8.8 KiB
Go
289 lines
8.8 KiB
Go
// Copyright 2026 certctl LLC. All rights reserved.
|
|
// SPDX-License-Identifier: BUSL-1.1
|
|
|
|
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
|
|
}
|
|
}
|