module SSLTest::CRL

Constants

CRL_CACHE_DURATION

Private Instance Methods

follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5) click to toggle source

Returns an array with [response, error_message]

# File lib/ssl-test/crl.rb, line 54
def follow_crl_redirects(uri, open_timeout: 5, read_timeout: 5, redirection_limit: 5)
  return [nil, "Too many redirections (> #{redirection_limit})"] if redirection_limit == 0

  # Return file from cache if not expired
  @crl_response_cache ||= {}
  cache_entry = @crl_response_cache[uri]
  return [cache_entry[:body], nil] if cache_entry && cache_entry.fetch(:expires) > Time.now

  @logger&.debug { "SSLTest   + CRL: fetch URI #{uri}" }
  path = uri.path == "" ? "/" : uri.path
  http = Net::HTTP.new(uri.hostname, uri.port)
  http.open_timeout = open_timeout
  http.read_timeout = read_timeout

  req = Net::HTTP::Get.new(path)
  # Include conditional caching headers from cache to save bandwidth if list didn't change (304)
  if etag = cache_entry&.fetch(:etag)
    req["If-None-Match"] = etag
  elsif last_mod = cache_entry&.fetch(:last_mod)
    req["If-Modified-Since"] = last_mod
  end
  http_response = http.request(req)
  case http_response
  when Net::HTTPNotModified
    # No changes, bump cache expiration time and return cached body
    @logger&.debug { "SSLTest   + CRL: 304 Not Modified" }
    @crl_response_cache[uri][:expires] = Time.now + CRL_CACHE_DURATION
    [cache_entry[:body], nil]
  when Net::HTTPSuccess
    # Success, update (or add to) cache and return frech body
    @logger&.debug { "SSLTest   + CRL: 200 OK (#{http_response.body.bytesize} bytes)" }
    @logger&.warn { "SSLTest   + CRL: Warning: massive file size" } if http_response.body.bytesize > 1024**2 # 1MB
    @logger&.warn { "SSLTest   + CRL: Warning: no caching headers on #{uri}" } unless http_response["Etag"] or http_response["Last-Modified"]
    @crl_response_cache[uri] = {
      body: http_response.body,
      expires: Time.now + CRL_CACHE_DURATION,
      etag: http_response["Etag"],
      last_mod: http_response["Last-Modified"]
    }
    [http_response.body, nil]
  when Net::HTTPRedirection
    follow_crl_redirects(URI(http_response["location"]), open_timeout: open_timeout, read_timeout: read_timeout, redirection_limit: redirection_limit - 1)
  else
    @logger&.debug { "SSLTest   + CRL: Error: #{http_response.class}" }
    [nil, "Wrong response type (#{http_response.class})"]
  end
end
test_crl_revocation(cert, issuer:, chain:, **options) click to toggle source

A note about caching: I choose to only cache the raw HTTP body here (and not the parsed list or better a hash indexed by certificat serial). This is not CPU efficient because it means every time we need to check a cert from a cached CRL we need to parse it again, instantiate the list of Revoked certs and then iterate to find it (there's no API to find one cert without generting the list unfortuantely). I did this because of memory efficiency, because for big 20MB CRL list (so taking 20MB in memory cache), the parsed version takes more than 100M, the list of Revoked certs 120MB, and building a hash with serial, time and reason takes even more. So doing this would be MUCH faster in terms of CPU for subsequent tests on the same CRL but would take a LOT of memory. Also I expect most providers to support OCSP for first level cert (a lot of revokation), which means we should have to use CRL mostly for intermediaries with much smaller CRL. That's what Let's Encrypt is doing with their R3 intermediate for example.

# File lib/ssl-test/crl.rb, line 22
def test_crl_revocation cert, issuer:, chain:, **options
  crl_distribution_points = cert.extensions.find do |extension|
    extension.oid == "crlDistributionPoints"
  end

  return [false, "Missing crlDistributionPoints extension", nil] unless crl_distribution_points

  # OpenSSL 2.2+ may simplify this: https://github.com/ruby/openssl/commit/ea702a106d3d8136c48f244593de95666be0edf9
  crl = crl_distribution_points.value.split("\n").find do |description|
    description.match?(/URI:/)
  end

  return [false, "Missing CRL URI in crlDistributionPoints extension", nil] unless crl

  crl_uri = URI(crl[/URI:(.*)/, 1])
  http_response, crl_request_error = follow_crl_redirects(crl_uri, **options)
  return [false, "Request failed (URI: #{crl_uri}): #{crl_request_error}", nil] unless http_response

  response = OpenSSL::X509::CRL.new http_response
  return [false, "Signature verification failed (URI: #{crl_uri})", nil] unless response.verify(issuer.public_key)

  revoked = response.revoked.find { |r| r.serial == cert.serial }
  if revoked
    reason = revoked.extensions.find {|e| e.oid == "CRLReason"}&.value
    return [true, reason || "Unknown reason", revoked.time]
  else
  end

  :crl_ok
end