diff --git a/checker/prober.go b/checker/prober.go index 3039ea1..b528814 100644 --- a/checker/prober.go +++ b/checker/prober.go @@ -2,8 +2,11 @@ package checker import ( "context" + "crypto/sha256" + "crypto/sha512" "crypto/tls" "crypto/x509" + "encoding/base64" "encoding/hex" "errors" "fmt" @@ -15,6 +18,32 @@ import ( "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. @@ -102,6 +131,7 @@ func probe(ctx context.Context, ep contract.TLSEndpoint, timeout time.Duration) } 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 diff --git a/checker/types.go b/checker/types.go index 470e825..58bd547 100644 --- a/checker/types.go +++ b/checker/types.go @@ -59,11 +59,48 @@ type TLSProbe struct { IssuerAKI string `json:"issuer_aki,omitempty"` Subject string `json:"subject,omitempty"` DNSNames []string `json:"dns_names,omitempty"` + // Chain carries one entry per certificate presented by the server + // (leaf first, then intermediates in order). Each entry precomputes + // the four TLSA selector×matching_type hashes plus the raw DER so + // DANE consumers can match without re-handshaking or re-parsing. + Chain []CertInfo `json:"chain,omitempty"` ElapsedMS int64 `json:"elapsed_ms,omitempty"` Error string `json:"error,omitempty"` Issues []Issue `json:"issues,omitempty"` } +// CertInfo describes one certificate in the presented chain together with +// pre-hashed forms suitable for DANE/TLSA matching (RFC 6698 §2.1). +// +// Hex fields are lowercase, matching the representation emitted by +// miekg/dns for TLSA RR Certificate fields. +type CertInfo struct { + // DERBase64 is the standard base64 encoding of the certificate's DER + // form. Carried so consumers can do matching-type 0 (Full) without + // requiring a precomputed "full" hash and for fallback inspection. + DERBase64 string `json:"der_base64,omitempty"` + + // Subject / Issuer are short human-readable DNs for the HTML report. + Subject string `json:"subject,omitempty"` + Issuer string `json:"issuer,omitempty"` + + // NotAfter is the certificate's expiry. Carried so editors can show + // "expires on …" without re-parsing the DER. + NotAfter time.Time `json:"not_after,omitempty"` + + // Selector 0 = full certificate. + CertSHA256 string `json:"cert_sha256,omitempty"` + CertSHA512 string `json:"cert_sha512,omitempty"` + + // Selector 1 = SubjectPublicKeyInfo. + SPKISHA256 string `json:"spki_sha256,omitempty"` + SPKISHA512 string `json:"spki_sha512,omitempty"` + + // SPKIDERBase64 lets consumers handle (selector=1, matching=0) without + // re-parsing the certificate. + SPKIDERBase64 string `json:"spki_der_base64,omitempty"` +} + // Issue is a single TLS finding surfaced to the consumer. type Issue struct { Code string `json:"code"`