package checker import ( "context" "crypto/sha256" "crypto/sha512" "crypto/tls" "crypto/x509" "encoding/base64" "encoding/hex" "errors" "fmt" "net" "strconv" "strings" "time" "git.happydns.org/checker-tls/contract" ) // buildChain returns CertInfo for each cert presented by the server, in the // order the server sent them (leaf first). SPKI is extracted from the parsed // certificate's RawSubjectPublicKeyInfo so we hash exactly the DER bytes // DANE selector 1 refers to (RFC 6698 ยง1.1.3). func buildChain(certs []*x509.Certificate) []CertInfo { out := make([]CertInfo, len(certs)) for i, c := range certs { certSum256 := sha256.Sum256(c.Raw) certSum512 := sha512.Sum512(c.Raw) spkiSum256 := sha256.Sum256(c.RawSubjectPublicKeyInfo) spkiSum512 := sha512.Sum512(c.RawSubjectPublicKeyInfo) out[i] = CertInfo{ DERBase64: base64.StdEncoding.EncodeToString(c.Raw), Subject: c.Subject.String(), Issuer: c.Issuer.String(), NotAfter: c.NotAfter, CertSHA256: hex.EncodeToString(certSum256[:]), CertSHA512: hex.EncodeToString(certSum512[:]), SPKISHA256: hex.EncodeToString(spkiSum256[:]), SPKISHA512: hex.EncodeToString(spkiSum512[:]), SPKIDERBase64: base64.StdEncoding.EncodeToString(c.RawSubjectPublicKeyInfo), } } return out } // probeTypeString renders the TLSProbe.Type string from a TLSEndpoint. // Observation consumers already parse this field in its "tls" / // "starttls-" shape; the contract-level split of direct vs. // STARTTLS is collapsed back here so the wire format of tls_probes // stays unchanged. func probeTypeString(ep contract.TLSEndpoint) string { if ep.STARTTLS == "" { return "tls" } return "starttls-" + ep.STARTTLS } // probe performs a TLS handshake (or STARTTLS upgrade + handshake) on the // given endpoint and returns a populated TLSProbe. It never returns an error: // transport/handshake failures are recorded on the probe so the caller can // still surface them in the report. func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) TLSProbe { start := time.Now() host := strings.TrimSuffix(ep.Host, ".") addr := net.JoinHostPort(host, strconv.Itoa(int(ep.Port))) sni := ep.SNI if sni == "" { sni = host } p := TLSProbe{ Host: host, Port: ep.Port, Endpoint: addr, Type: probeTypeString(ep), SNI: sni, } dialCtx, cancel := context.WithTimeout(ctx, timeout) defer cancel() d := &net.Dialer{} conn, err := d.DialContext(dialCtx, "tcp", addr) if err != nil { p.Error = "dial: " + err.Error() p.Issues = append(p.Issues, Issue{ Code: "tcp_unreachable", Severity: SeverityCrit, Message: fmt.Sprintf("Cannot open TCP connection to %s: %v", addr, err), Fix: "Check DNS, firewall, and that the service listens on this port.", }) p.ElapsedMS = time.Since(start).Milliseconds() return p } defer conn.Close() if deadline, ok := dialCtx.Deadline(); ok { _ = conn.SetDeadline(deadline) } tlsConn, err := handshake(conn, ep, sni) if err != nil { p.Error = err.Error() p.Issues = append(p.Issues, classifyHandshakeError(ep, err)) p.ElapsedMS = time.Since(start).Milliseconds() return p } defer tlsConn.Close() state := tlsConn.ConnectionState() p.TLSVersion = tls.VersionName(state.Version) p.CipherSuite = tls.CipherSuiteName(state.CipherSuite) if len(state.PeerCertificates) == 0 { p.Issues = append(p.Issues, Issue{ Code: "no_peer_cert", Severity: SeverityCrit, Message: "Server presented no certificate.", }) p.ElapsedMS = time.Since(start).Milliseconds() return p } leaf := state.PeerCertificates[0] p.NotAfter = leaf.NotAfter p.Issuer = leaf.Issuer.CommonName p.IssuerDN = leaf.Issuer.String() if len(leaf.AuthorityKeyId) > 0 { p.IssuerAKI = strings.ToUpper(hex.EncodeToString(leaf.AuthorityKeyId)) } p.Subject = leaf.Subject.CommonName p.DNSNames = append(p.DNSNames, leaf.DNSNames...) p.Chain = buildChain(state.PeerCertificates) hostnameMatch := leaf.VerifyHostname(sni) == nil p.HostnameMatch = &hostnameMatch // Chain verification against system roots, using intermediates presented // by the server. We run this independently from Go's tls.Config // verification so we can report a dedicated "chain invalid" issue rather // than failing the whole handshake. intermediates := x509.NewCertPool() for _, c := range state.PeerCertificates[1:] { intermediates.AddCert(c) } now := time.Now() _, verifyErr := leaf.Verify(x509.VerifyOptions{ DNSName: sni, Intermediates: intermediates, CurrentTime: now, }) chainValid := verifyErr == nil p.ChainValid = &chainValid if !chainValid { msg := "Invalid certificate chain" if verifyErr != nil { msg = "Invalid certificate chain: " + verifyErr.Error() } p.Issues = append(p.Issues, Issue{ Code: "chain_invalid", Severity: SeverityCrit, Message: msg, Fix: "Serve the full intermediate chain and ensure the root is trusted.", }) } if !hostnameMatch { p.Issues = append(p.Issues, Issue{ Code: "hostname_mismatch", Severity: SeverityCrit, Message: fmt.Sprintf("Certificate does not cover %q (SANs: %s)", sni, strings.Join(leaf.DNSNames, ", ")), Fix: "Re-issue the certificate with a matching SAN.", }) } if leaf.NotAfter.Before(now) { p.Issues = append(p.Issues, Issue{ Code: "expired", Severity: SeverityCrit, Message: "Certificate expired on " + leaf.NotAfter.Format(time.RFC3339), Fix: "Renew the certificate.", }) } else if leaf.NotAfter.Sub(now) < 14*24*time.Hour { p.Issues = append(p.Issues, Issue{ Code: "expiring_soon", Severity: SeverityWarn, Message: "Certificate expires in less than 14 days (" + leaf.NotAfter.Format(time.RFC3339) + ")", Fix: "Renew before expiry.", }) } if state.Version < tls.VersionTLS12 { p.Issues = append(p.Issues, Issue{ Code: "weak_tls_version", Severity: SeverityWarn, Message: "Negotiated TLS version " + p.TLSVersion + " is below the recommended TLS 1.2.", Fix: "Disable TLS 1.0/1.1 on the server.", }) } p.ElapsedMS = time.Since(start).Milliseconds() return p } // handshake performs STARTTLS upgrade (when ep.STARTTLS is non-empty) and // then a TLS handshake. InsecureSkipVerify is true on purpose: we verify // the chain separately in probe so an invalid chain becomes a structured // Issue rather than aborting the handshake. func handshake(conn net.Conn, ep contract.TLSEndpoint, sni string) (*tls.Conn, error) { cfg := &tls.Config{ ServerName: sni, InsecureSkipVerify: true, } if ep.STARTTLS == "" { tlsConn := tls.Client(conn, cfg) if err := tlsConn.Handshake(); err != nil { return nil, fmt.Errorf("tls-handshake: %w", err) } return tlsConn, nil } up, ok := starttlsUpgraders[ep.STARTTLS] if !ok { return nil, fmt.Errorf("unsupported starttls protocol %q", ep.STARTTLS) } if err := up(conn, sni); err != nil { return nil, fmt.Errorf("starttls-%s: %w", ep.STARTTLS, err) } tlsConn := tls.Client(conn, cfg) if err := tlsConn.Handshake(); err != nil { return nil, fmt.Errorf("tls-handshake-after-starttls: %w", err) } return tlsConn, nil } // classifyHandshakeError converts a dial/handshake error into a structured // Issue, distinguishing "server doesn't offer STARTTLS" (which is opportunistic // for some endpoints) from hard failures. func classifyHandshakeError(ep contract.TLSEndpoint, err error) Issue { msg := err.Error() if ep.STARTTLS != "" && isStartTLSUnsupported(err) { sev := SeverityWarn if ep.RequireSTARTTLS { sev = SeverityCrit } return Issue{ Code: "starttls_not_offered", Severity: sev, Message: fmt.Sprintf("Server on %s:%d does not advertise STARTTLS: %s", ep.Host, ep.Port, msg), Fix: "Enable STARTTLS on the server or publish a direct-TLS endpoint.", } } return Issue{ Code: "handshake_failed", Severity: SeverityCrit, Message: fmt.Sprintf("TLS handshake failed on %s:%d: %s", ep.Host, ep.Port, msg), Fix: "Inspect the server's TLS configuration and certificate.", } } var errStartTLSNotOffered = errors.New("starttls not advertised by server") func isStartTLSUnsupported(err error) bool { return errors.Is(err, errStartTLSNotOffered) }