Skip to content

Fix X509::Certificate#dup dropping all extensions#356

Open
sferik wants to merge 1 commit intojruby:masterfrom
sferik:fix-x509-certificate-dup
Open

Fix X509::Certificate#dup dropping all extensions#356
sferik wants to merge 1 commit intojruby:masterfrom
sferik:fix-x509-certificate-dup

Conversation

@sferik
Copy link
Copy Markdown

@sferik sferik commented Apr 10, 2026

OpenSSL::X509::Certificate#dup produces an invalid copy. Historically, initialize_copy checked for identity and frozenness but never copied any certificate state from the source object, so the duplicate could lose its extensions, subject, issuer, serial, and other fields.

As far as I can tell, this has been latent for a long time but was surfaced following the addition of Certificate#tbs_bytes in ee7f0c8. That addition caused sigstore-ruby to enter a code path that dups a Fulcio-issued certificate and iterates its extensions, which were silently empty, causing gem push with Sigstore attestation to fail on JRuby:

No PrecertificateSignedCertificateTimestamps found for the certificate (Sigstore::Error::InvalidCertificate)

Here is an example of a Java-platform gem push failing in CI: https://github.com/sferik/multi_json/actions/runs/24221466355/job/70713680096

I was able to successfully work around this issue by using CRuby for the signing step.

Here is a minimal reproduction of this issue:

require "openssl"

key = OpenSSL::PKey::RSA.new(2048)
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 1
cert.subject = OpenSSL::X509::Name.new([["CN", "test"]])
cert.issuer = cert.subject
cert.not_before = Time.now
cert.not_after = Time.now + 3600
cert.public_key = key.public_key

ef = OpenSSL::X509::ExtensionFactory.new
ef.subject_certificate = cert
ef.issuer_certificate = cert
cert.extensions = [ef.create_extension("subjectKeyIdentifier", "hash")]
cert.sign(key, OpenSSL::Digest::SHA256.new)

duped = cert.dup

puts "Original extensions: #{cert.extensions.map(&:oid)}"
puts "Duped extensions:    #{duped.extensions.map(&:oid)}"

Expected: both lines print ["subjectKeyIdentifier"]
Actual: duped prints []

This patch fixes Certificate#dup by copying the live certificate state instead of leaving the duplicate empty.

For signed certificates, it now clones the underlying parsed certificate and also copies the current Ruby-side fields.

For unsigned certificates still under construction, it deep-copies mutable fields.

This patch also marks extensions= as a mutating operation so the object’s state stays consistent after replacing extensions on an already-signed certificate.

@headius
Copy link
Copy Markdown
Member

headius commented Apr 10, 2026

This is great background, thank you! I'll review this along with @kares and @enebo and we'll get it into the next release.

@headius headius added this to the 0.15.1 milestone Apr 10, 2026
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.

2 participants