-
Notifications
You must be signed in to change notification settings - Fork 2
add component to fetch all URLs from CCADB #88
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
jsha
wants to merge
10
commits into
main
Choose a base branch
from
ccadb-fetch
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 6 commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
861eb64
Add checking of CCADB CRL URLs
jsha eba01e7
Add example intermediates.pem
jsha 8b67de0
Add idp package
jsha a42e01f
Embed intermediates
jsha 540f788
Check for cross-CRL dupes
jsha 0476c3a
Add docs and fix CA Owner check
jsha 0d87a14
Merge remote-tracking branch 'origin/main' into ccadb-fetch
jsha e0d013d
Use retryhttp and skip root bailout
jsha 8966e2b
Use retryhttp more
jsha e052ae0
Call idp.Get()
jsha File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,289 @@ | ||
| package ccadb | ||
|
|
||
| import ( | ||
| "context" | ||
| "crypto/x509" | ||
| _ "embed" | ||
| "encoding/base64" | ||
| "encoding/csv" | ||
| "encoding/json" | ||
| "encoding/pem" | ||
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "log" | ||
| "net/http" | ||
| "slices" | ||
| "time" | ||
|
|
||
| "github.com/letsencrypt/boulder/crl/checker" | ||
| "github.com/letsencrypt/crl-monitor/cmd" | ||
| "github.com/letsencrypt/crl-monitor/idp" | ||
| ) | ||
|
|
||
| //go:embed intermediates.pem | ||
| var allIssuers []byte | ||
|
|
||
| const ( | ||
| CCADBAllCertificatesCSVURL cmd.EnvVar = "CCADB_ALL_CERTIFICATES_CSV_URL" | ||
| CRLAgeLimit cmd.EnvVar = "CRL_AGE_LIMIT" | ||
| CAOwner cmd.EnvVar = "CA_OWNER" | ||
| ) | ||
|
|
||
| // Checker fetches the AllCertificatesRecordsReport from CCADB, filters for a | ||
| // specific CA Owner (defaults to 'Internet Security Research Group'), and | ||
| // fetches all CRLs found. | ||
| // | ||
| // It checks that the CRLs: | ||
| // - Are not too old | ||
| // - Have an issuingDistributionPoint that matches the URL from which they | ||
| // were fetched | ||
| // - Have a valid signature based on their issuer SKID from CCADB | ||
| // (full issuer certificates for ISRG are embedded in this binary) | ||
| // - Don't have duplicate serial numbers across different CRLs | ||
| type Checker struct { | ||
| allCertificatesCSVURL string | ||
| caOwner string | ||
| crlAgeLimit time.Duration | ||
|
|
||
| // Map from SKID (bytes cast to string) to issuer. | ||
| issuers map[string]*x509.Certificate | ||
| } | ||
|
|
||
| func NewFromEnv() (*Checker, error) { | ||
| ccadbAllCertificatesCSVURL := "https://ccadb.my.salesforce-sites.com/ccadb/AllCertificateRecordsCSVFormatv2" | ||
| allCertsCSV, ok := CCADBAllCertificatesCSVURL.LookupEnv() | ||
| if ok { | ||
| ccadbAllCertificatesCSVURL = allCertsCSV | ||
| } | ||
|
|
||
| caOwner := "Internet Security Research Group" | ||
| owner, ok := CAOwner.LookupEnv() | ||
| if ok { | ||
| caOwner = owner | ||
| } | ||
|
|
||
| ageLimitDuration := 24 * time.Hour | ||
| crlAgeLimit, ok := CRLAgeLimit.LookupEnv() | ||
| if ok { | ||
| var err error | ||
| ageLimitDuration, err = time.ParseDuration(crlAgeLimit) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("parsing age limit: %s", err) | ||
| } | ||
| } | ||
|
|
||
| issuers, err := parseIssuers() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| return &Checker{ | ||
| allCertificatesCSVURL: ccadbAllCertificatesCSVURL, | ||
| caOwner: caOwner, | ||
| crlAgeLimit: ageLimitDuration, | ||
| issuers: issuers, | ||
| }, nil | ||
| } | ||
|
|
||
| func (c *Checker) Check(ctx context.Context) error { | ||
| crlURLs, err := c.getCRLURLs(ctx, c.allCertificatesCSVURL, c.caOwner) | ||
| if err != nil { | ||
| return err | ||
| } | ||
|
|
||
| var crls, entries, bytes int | ||
|
|
||
| serials := make(map[string]*x509.RevocationList) | ||
|
|
||
| var errs []error | ||
| for skid, urls := range crlURLs { | ||
| for _, url := range urls { | ||
| crls++ | ||
| issuer := c.issuers[skid] | ||
| if issuer == nil { | ||
| return fmt.Errorf("no issuer found for skid %x", skid) | ||
| } | ||
| crl, err := checkCRL(ctx, url, issuer, c.crlAgeLimit) | ||
| if err != nil { | ||
| errs = append(errs, fmt.Errorf("fetching %s: %s", url, err)) | ||
| continue | ||
| } | ||
|
|
||
| // Check for duplicates across different CRLs (or within a CRL). | ||
| // Cap any given CRL at 1M entries to limit memory use. | ||
| for i, entry := range crl.RevokedCertificateEntries { | ||
| if i > 1_000_000 { | ||
| break | ||
| } | ||
| serialByteString := string(entry.SerialNumber.Bytes()) | ||
| if otherCRL, ok := serials[serialByteString]; ok { | ||
| otherCRLURL, err := idp.Get(otherCRL) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| errs = append(errs, fmt.Errorf("serial %x seen on multiple CRLs: %s and %s", entry.SerialNumber, otherCRLURL, url)) | ||
| } | ||
| serials[serialByteString] = crl | ||
| } | ||
|
|
||
| age := time.Since(crl.ThisUpdate).Round(time.Minute) | ||
| nextUpdate := time.Until(crl.NextUpdate).Round(time.Hour) | ||
| entries += len(crl.RevokedCertificateEntries) | ||
| bytes += len(crl.Raw) | ||
| log.Printf("crl %q: %d entries, %d bytes, age %gm, nextUpdate %gh", url, len(crl.RevokedCertificateEntries), len(crl.Raw), age.Minutes(), nextUpdate.Hours()) | ||
| } | ||
| } | ||
|
|
||
| log.Printf("%d CRLs had %d entries and %d bytes", crls, entries, bytes) | ||
| return errors.Join(errs...) | ||
| } | ||
|
|
||
| func checkCRL(ctx context.Context, url string, issuer *x509.Certificate, ageLimit time.Duration) (*x509.RevocationList, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := http.DefaultClient.Do(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer resp.Body.Close() | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("HTTP status code %d", resp.StatusCode) | ||
| } | ||
| body, err := io.ReadAll(resp.Body) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("reading CRL body: %s", err) | ||
| } | ||
| crl, err := x509.ParseRevocationList(body) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| idp, err := idp.Get(crl) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| if idp != url { | ||
| return nil, fmt.Errorf("CRL fetched from %s had mismatched IDP %s", url, idp) | ||
| } | ||
|
|
||
| return crl, checker.Validate(crl, issuer, ageLimit) | ||
| } | ||
|
|
||
| // returns a map from issuer SKID to list of URLs | ||
| func (c Checker) getCRLURLs(ctx context.Context, csvURL string, owner string) (map[string][]string, error) { | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, csvURL, nil) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| resp, err := http.DefaultClient.Do(req) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| defer resp.Body.Close() | ||
| if resp.StatusCode != http.StatusOK { | ||
| return nil, fmt.Errorf("HTTP status code %d", resp.StatusCode) | ||
| } | ||
| reader := csv.NewReader(resp.Body) | ||
| header, err := reader.Read() | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| var ownerIndex, crlIndex, skidIndex, certificateNameIndex int | ||
| for i, name := range header { | ||
| if name == "CA Owner" { | ||
| ownerIndex = i | ||
| } | ||
| if name == "JSON Array of Partitioned CRLs" { | ||
| crlIndex = i | ||
| } | ||
| if name == "Subject Key Identifier" { | ||
| skidIndex = i | ||
| } | ||
| if name == "Certificate Name" { | ||
| certificateNameIndex = i | ||
| } | ||
| } | ||
| allCRLs := make(map[string][]string) | ||
| for { | ||
| record, err := reader.Read() | ||
| if err == io.EOF { | ||
| break | ||
| } | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if record[ownerIndex] != owner { | ||
| continue | ||
| } | ||
| crlJSON := record[crlIndex] | ||
| if crlJSON == "" { | ||
| continue | ||
| } | ||
| var crls []string | ||
| err = json.Unmarshal([]byte(crlJSON), &crls) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| // Roots have a CRL list containing a single "" | ||
| if len(crls) == 1 && crls[0] == "" { | ||
| continue | ||
| } | ||
jsha marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| certificateName := record[certificateNameIndex] | ||
| skidBase64 := record[skidIndex] | ||
| skid, err := base64.StdEncoding.DecodeString(skidBase64) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
| if len(skid) == 0 { | ||
| return nil, fmt.Errorf("no skid for %q", certificateName) | ||
| } | ||
| stringSKID := string(skid) | ||
| if c.issuers[stringSKID] == nil { | ||
| return nil, fmt.Errorf("CCADB contained %q with SKID %x, but that SKID is not in embedded issuers file. Might need update and rebuild this binary", | ||
| certificateName, skid) | ||
| } | ||
| // An issuer can show up multiple times, under different cross-signs. However | ||
| // it must have the same list of CRLs each time. | ||
| if c := allCRLs[stringSKID]; c != nil && !slices.Equal(c, crls) { | ||
| return nil, fmt.Errorf("CCADB contained %q with SKID %x multiple times with different CRLs", certificateName, skid) | ||
| } | ||
| allCRLs[stringSKID] = crls | ||
| } | ||
|
|
||
| if len(allCRLs) == 0 { | ||
| return nil, fmt.Errorf("no records found in CCADB for CA Owner %q", owner) | ||
| } | ||
| return allCRLs, nil | ||
| } | ||
|
|
||
| // getIssuers parses the embedded PEM file containing multiple intermediates. | ||
| // | ||
| // The file should contain an entry for every issuer that is listed in the | ||
| // CCADB All Certificates list for the relevant CA Organization. | ||
| // | ||
| // Returns a map from SubjectKeyId (cast from []byte to string) to the | ||
| // matching intermediate. | ||
| func parseIssuers() (map[string]*x509.Certificate, error) { | ||
| ret := make(map[string]*x509.Certificate) | ||
|
|
||
| remaining := allIssuers | ||
| for { | ||
| var block *pem.Block | ||
| block, remaining = pem.Decode(remaining) | ||
| if block == nil { | ||
| return ret, nil | ||
| } | ||
|
|
||
| cert, err := x509.ParseCertificate(block.Bytes) | ||
| if err != nil { | ||
| return nil, err | ||
| } | ||
|
|
||
| ret[string(cert.SubjectKeyId)] = cert | ||
| } | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.