222 lines
5.9 KiB
Go
222 lines
5.9 KiB
Go
package checker
|
|
|
|
import (
|
|
"bytes"
|
|
_ "embed"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
//go:generate wget -O AllCAAIdentifiersReport.csv https://ccadb.my.salesforce-sites.com/ccadb/AllCAAIdentifiersReportCSVV2
|
|
//go:embed AllCAAIdentifiersReport.csv
|
|
var ccadbCSV []byte
|
|
|
|
// ccadbIndex is the in-memory representation of AllCAAIdentifiersReport.csv.
|
|
// Two indexes are maintained because CCADB rows sometimes have an empty
|
|
// Subject Key Identifier column (very rare; a handful of legacy entries)
|
|
// and we want to still resolve those via Subject DN.
|
|
type ccadbIndex struct {
|
|
bySKI map[string][]string
|
|
byDN map[string][]string
|
|
}
|
|
|
|
var (
|
|
ccadbOnce sync.Once
|
|
ccadb *ccadbIndex
|
|
ccadbErr error
|
|
)
|
|
|
|
// loadCCADB parses the embedded CSV into the two lookup indexes on first
|
|
// call. Subsequent calls are no-ops. The CSV is shipped with the binary
|
|
// so parse failures indicate a bug or a corrupted build, not a runtime
|
|
// condition; tests assert the parse succeeds for the checked-in file.
|
|
func loadCCADB() (*ccadbIndex, error) {
|
|
ccadbOnce.Do(func() {
|
|
ccadb, ccadbErr = parseCCADB(bytes.NewReader(ccadbCSV))
|
|
})
|
|
return ccadb, ccadbErr
|
|
}
|
|
|
|
// parseCCADB is exposed for testing with alternate CSV inputs.
|
|
func parseCCADB(r io.Reader) (*ccadbIndex, error) {
|
|
reader := csv.NewReader(r)
|
|
reader.FieldsPerRecord = -1 // some rows carry a trailing empty field
|
|
|
|
header, err := reader.Read()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read header: %w", err)
|
|
}
|
|
|
|
idxSubject := -1
|
|
idxSKI := -1
|
|
idxDomains := -1
|
|
for i, h := range header {
|
|
switch strings.TrimSpace(h) {
|
|
case "Subject":
|
|
idxSubject = i
|
|
case "Subject Key Identifier (Hex)":
|
|
idxSKI = i
|
|
case "Recognized CAA Domains":
|
|
idxDomains = i
|
|
}
|
|
}
|
|
if idxSubject < 0 || idxSKI < 0 || idxDomains < 0 {
|
|
return nil, fmt.Errorf("unexpected CCADB header: %v", header)
|
|
}
|
|
|
|
idx := &ccadbIndex{
|
|
bySKI: map[string][]string{},
|
|
byDN: map[string][]string{},
|
|
}
|
|
for {
|
|
row, err := reader.Read()
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read row: %w", err)
|
|
}
|
|
|
|
domains := splitCAADomains(row[idxDomains])
|
|
if len(domains) == 0 {
|
|
continue
|
|
}
|
|
|
|
if ski := strings.ToUpper(strings.TrimSpace(row[idxSKI])); ski != "" {
|
|
idx.bySKI[ski] = mergeDomains(idx.bySKI[ski], domains)
|
|
}
|
|
if dn := normalizeDN(row[idxSubject]); dn != "" {
|
|
idx.byDN[dn] = mergeDomains(idx.byDN[dn], domains)
|
|
}
|
|
}
|
|
return idx, nil
|
|
}
|
|
|
|
// Lookup resolves an observed certificate issuer to the CAA identifier
|
|
// domains the issuing CA publishes in its CPS. aki is the uppercase hex
|
|
// Authority Key Identifier of the leaf (i.e. the issuer's SKI); dn is
|
|
// the RFC 2253 subject DN of the issuer (leaf.Issuer.String() in Go).
|
|
//
|
|
// AKI takes precedence because CCADB keys by it. DN is a fallback for
|
|
// the rare rows where the SKI column is empty.
|
|
//
|
|
// Returns ok=false when neither key resolves. The returned slice is a
|
|
// fresh copy; callers may retain or mutate it.
|
|
func Lookup(aki, dn string) ([]string, bool) {
|
|
idx, err := loadCCADB()
|
|
if err != nil || idx == nil {
|
|
return nil, false
|
|
}
|
|
if aki != "" {
|
|
if d, ok := idx.bySKI[strings.ToUpper(strings.TrimSpace(aki))]; ok && len(d) > 0 {
|
|
return append([]string(nil), d...), true
|
|
}
|
|
}
|
|
if dn != "" {
|
|
if d, ok := idx.byDN[normalizeDN(dn)]; ok && len(d) > 0 {
|
|
return append([]string(nil), d...), true
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
// splitCAADomains splits CCADB's "Recognized CAA Domains" cell, which
|
|
// can hold a comma-separated list (e.g. DigiCert rows list ~20
|
|
// domains). Whitespace is trimmed, empties are dropped, and the result
|
|
// is lowercased because CAA identifiers are case-insensitive.
|
|
func splitCAADomains(raw string) []string {
|
|
var out []string
|
|
for d := range strings.SplitSeq(raw, ",") {
|
|
d = strings.TrimSpace(strings.ToLower(d))
|
|
if d != "" {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// mergeDomains appends new entries to an existing slice, de-duplicating.
|
|
// CCADB occasionally lists the same CA twice (cross-signs, re-issues);
|
|
// we don't want that to bloat the lookup result.
|
|
func mergeDomains(existing, add []string) []string {
|
|
if len(existing) == 0 {
|
|
return append([]string(nil), add...)
|
|
}
|
|
seen := map[string]bool{}
|
|
for _, d := range existing {
|
|
seen[d] = true
|
|
}
|
|
for _, d := range add {
|
|
if !seen[d] {
|
|
existing = append(existing, d)
|
|
seen[d] = true
|
|
}
|
|
}
|
|
return existing
|
|
}
|
|
|
|
// normalizeDN produces a canonical key from a subject DN so that DNs
|
|
// produced by Go's pkix.Name.String (comma-joined) compare equal to
|
|
// DNs produced by CCADB (semicolon-joined) when their RDN sets match.
|
|
//
|
|
// Rules:
|
|
// - split on ',' or ';';
|
|
// - trim each RDN;
|
|
// - uppercase the RDN type (left of '=') because RFC 4514 types are
|
|
// case-insensitive; values are left as-is;
|
|
// - sort the RDNs alphabetically so reordering does not break
|
|
// comparison.
|
|
//
|
|
// This is intentionally permissive; escaping differences between
|
|
// implementations are ignored. Good enough for CCADB fallbacks, and
|
|
// the common path is the AKI lookup anyway.
|
|
func normalizeDN(dn string) string {
|
|
if dn == "" {
|
|
return ""
|
|
}
|
|
fields := splitRDNs(dn)
|
|
for i, f := range fields {
|
|
f = strings.TrimSpace(f)
|
|
if eq := strings.IndexByte(f, '='); eq > 0 {
|
|
f = strings.ToUpper(f[:eq]) + "=" + strings.TrimSpace(f[eq+1:])
|
|
}
|
|
fields[i] = f
|
|
}
|
|
sort.Strings(fields)
|
|
return strings.Join(fields, ",")
|
|
}
|
|
|
|
// splitRDNs splits a DN string on either ',' or ';', respecting
|
|
// backslash escapes. Most RDN values in CCADB do not contain escaped
|
|
// separators, but a handful (paths in OU values) do.
|
|
func splitRDNs(dn string) []string {
|
|
var out []string
|
|
var cur strings.Builder
|
|
escape := false
|
|
for i := 0; i < len(dn); i++ {
|
|
c := dn[i]
|
|
if escape {
|
|
cur.WriteByte(c)
|
|
escape = false
|
|
continue
|
|
}
|
|
switch c {
|
|
case '\\':
|
|
cur.WriteByte(c)
|
|
escape = true
|
|
case ',', ';':
|
|
out = append(out, cur.String())
|
|
cur.Reset()
|
|
default:
|
|
cur.WriteByte(c)
|
|
}
|
|
}
|
|
if cur.Len() > 0 {
|
|
out = append(out, cur.String())
|
|
}
|
|
return out
|
|
}
|