Skip to content

x509crl: add OpenSSL::X509::CRL#by_serial#1065

Open
jarthod wants to merge 1 commit into
ruby:masterfrom
jarthod:x509crl-by-serial
Open

x509crl: add OpenSSL::X509::CRL#by_serial#1065
jarthod wants to merge 1 commit into
ruby:masterfrom
jarthod:x509crl-by-serial

Conversation

@jarthod

@jarthod jarthod commented Jun 20, 2026

Copy link
Copy Markdown

Adds OpenSSL::X509::CRL#by_serial(serial), which returns the
OpenSSL::X509::Revoked entry for a given certificate serial (Integer or
OpenSSL::BN), or nil if the serial isn't listed. It wraps the OpenSSL
function X509_CRL_get0_by_serial.

Implements the request in #1064.

Why

Today the only way to check whether a serial is revoked is to iterate
#revoked, which instantiates the entire revocation list as Ruby objects
even though we only care about one entry. For large CRLs (now commonly
hundreds of thousands to millions of entries) that is very slow and allocates
a lot. #by_serial does a sorted lookup instead, so the cost is independent
of the CRL size.

Notes

  • Returns a dup'd Revoked (via the existing ossl_x509revoked_new), so the result is safe to keep after the CRL is gone.
  • Accepts an Integer or OpenSSL::BN and converts it internally.
  • X509_CRL_get0_by_serial sorts the revoked stack on first use (cached on the X509_CRL), so repeated lookups on the same object are O(log n).
  • Available since OpenSSL 1.0.0; present in LibreSSL and AWS-LC, so no version guard is needed.
  • Tests added in test/openssl/test_x509crl.rb (test_by_serial): present and absent serials, OpenSSL::BN argument, DER round-trip, agreement with #revoked, and a TypeError on a non-integer argument.
  • I added some comments/doc at the top of the method but noticed it's not very common in this file, if you prefer I remove that and put the explanation elsewhere let me know. (It's supposed to end here I guess: https://ruby.github.io/openssl/OpenSSL/X509/CRL.html#method-i-revoked ?)
  • It's been some time since I've written C so feel free to criticize 😅

Benchmark

Ruby 3.3.5 / OpenSSL 3.5.5, via benchmark-ips / benchmark-memory. Two modes:

  • warm = lookup on an already-parsed CRL (you reuse the object, so by_serial's one-time sort is amortised as next queries are O(log n)). Very efficient as you can see below but not easy to leverage (you would need to verify a lot of certs agains a single CRL at the same time for this to matter). Always good to see of course :)
  • cold = parse a fresh CRL + one lookup (the most common one-shot pattern IMO). Here the gain is a more moderate 2-3x in CPU time, but huge in ruby object allocations (=way less work for GC and memory noise).

Big CRL — 53.5 MB, 1,092,362 revoked entries

mode serial revoked.find by_serial
warm not revoked ~0.36 s, 139.8 MB / 3.28M objs ~0.24 µs, 40 B / 1 obj (1,500,000x)
warm revoked ~0.13 s, 79.7 MB / 1.78M objs ~1.1 µs, 40 B / 1 obj (100,000x)
cold not revoked ~2.17 s,139.8 MB / 3.28M objs ~0.95 s, 80 B / 2 objs (2.3x)
cold revoked ~1.69 s, 74.3 MB / 1.63M objs ~0.84 s, 80 B / 2 objs (2.0x)

Average CRL (Let's Encrypt shard) — 0.2 MB, 5,146 revoked entries : http://r13.c.lencr.org/114.crl

mode serial revoked.find by_serial
warm not revoked ~1.5 ms, 659 KB / 15.4k objs ~0.23 µs, 40 B / 1 obj (6600x)
warm revoked ~0.2 ms, 291 KB / 6.2k objs ~0.60 µs, 40 B / 1 obj (352x)
cold not revoked ~5.3 ms, 659 KB / 15.4k objs ~1.8 ms, 80 B / 2 objs (2.8x)
cold revoked ~4.2 ms, 350 KB / 7.7k objs ~1.5 ms, 80 B / 2 objs (2.7x)

This benchmark counts ruby allocations but not C, so some of the C allocations made by openssl are not shown here. But they are similar in both columns as by_serial still needs to parse the whole CRL once, we're just avoiding the instantiation of all the ruby objects (which is the most important).

#!/usr/bin/env ruby
#
# Needs the patched openssl that defines #by_serial; point OPENSSL_LIB at the dev
# build (defaults to ../openssl/lib). gem install benchmark-ips benchmark-memory
#
#   ruby by_serial_bench.rb [CRL_URL]

dev = ENV["OPENSSL_LIB"] || File.expand_path("../openssl/lib", __dir__)
$LOAD_PATH.unshift(dev) if File.exist?(File.join(dev, "openssl.so"))

require "openssl"
require "net/http"
require "uri"
require "tmpdir"
require "benchmark/ips"
require "benchmark/memory"

abort "loaded openssl has no #by_serial (set OPENSSL_LIB to the patched build)" unless
  OpenSSL::X509::CRL.method_defined?(:by_serial)

url   = ARGV[0] || "http://c.cf-i.ssl.com/ae801ed1c55bb579d79208b0d772acfb8cc3a208.crl" # big CRL example
cache = File.join(Dir.tmpdir, "by_serial_bench_#{File.basename(URI(url).path)}")
body  = File.exist?(cache) ? File.binread(cache) :
        (warn("downloading #{url} ..."); d = Net::HTTP.get(URI(url)); File.binwrite(cache, d); d)

crl     = OpenSSL::X509::CRL.new(body)
entries = crl.revoked.size
absent  = 0xDEAD_BEEF_CAFE_F00D
present = crl.revoked[entries / 2].serial # middle of the list for median performance

puts "#{OpenSSL::OPENSSL_LIBRARY_VERSION}#{url}"
puts "#{(body.bytesize / 1e6).round(1)} MB DER, #{entries} revoked entries"

[["not revoked", absent], ["revoked", present]].each do |label, serial|
  abort "mismatch for #{label}" unless
    crl.by_serial(serial) == crl.revoked.find { |r| r.serial == serial }
  puts "\n=== #{label} serial ==="

  puts "\n-- warm: lookup on a parsed CRL --"
  @list = nil
  Benchmark.ips do |x|
    x.config(warmup: 1, time: 3)
    x.report("revoked.find") { (@list ||= crl.revoked).find { |r| r.serial == serial } }
    x.report("by_serial")    { crl.by_serial(serial) }
    x.compare!
  end
  Benchmark.memory do |x|
    x.report("revoked.find") { crl.revoked.find { |r| r.serial == serial } }
    x.report("by_serial")    { crl.by_serial(serial) }
    x.compare!
  end

  puts "\n-- cold: parse a fresh CRL + one lookup --"
  Benchmark.ips do |x|
    x.config(warmup: 0, time: 3)
    x.report("revoked.find") { OpenSSL::X509::CRL.new(body).revoked.find { |r| r.serial == serial } }
    x.report("by_serial")    { OpenSSL::X509::CRL.new(body).by_serial(serial) }
    x.compare!
  end
  Benchmark.memory do |x|
    x.report("revoked.find") { OpenSSL::X509::CRL.new(body).revoked.find { |r| r.serial == serial } }
    x.report("by_serial")    { OpenSSL::X509::CRL.new(body).by_serial(serial) }
    x.compare!
  end
end

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant