Skip to content

ASN1#to_der in pure ruby #777

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
wants to merge 11 commits into
base: master
Choose a base branch
from

Conversation

HoneyryderChuck
Copy link
Contributor

@HoneyryderChuck HoneyryderChuck commented Jul 12, 2024

This is a draft request-for-comment proposal for implementing OpenSSL::ASN1::ASN1Data#to_der in ruby, as per suggestion on the previous MR. It already passes the test, although there is a part which hasn't been ported yet, and seems not be covered in the tests. I guess that the ruby code can benefit from being broke down in multiple files, but I'll keep it here just for easy of review, ,for now. I also didn't delete C code, not yet.

Currently, the whole of #to_der is implemented in the ASN1Data class. The reason is, one can build a primitive or constructive object "manually" (if tag and/or tag_class is known), or can just use arrays directly. It would benefit from moving specific implementations into the respective child classes, but then it'd be a breaking change because of what I said. Any suggestions?

I also didn't do any benchmarks yet. Is it relevant?

LMK what you think.

@rhenium
Copy link
Member

rhenium commented Jul 13, 2024

I think the tests outside test_basic_asn1data are still using the C implementation because #to_der is redefined on Primitive and Constructive.

Currently, the whole of #to_der is implemented in the ASN1Data class. The reason is, one can build a primitive or constructive object "manually" (if tag and/or tag_class is known), or can just use arrays directly. It would benefit from moving specific implementations into the respective child classes, but then it'd be a breaking change because of what I said. Any suggestions?

ossl_asn1_get_asn1type() in the C impl can definitely be broken down and moved to each class. It's currently done this way just because it's simpler to do so in C.

What breaking change do you expect?

@HoneyryderChuck
Copy link
Contributor Author

@rhenium made a few adjustments to the ruby code. I believe it got cleaner. The same point stands regarding ASN1Data class having to own to_der implementations for cons and prim though, not much one can do (?).

I broke down ossl_asn1_get_asn1type(), but not completely, as I didn't manage to port OID / UTCTime / GeneralizedTime impls to ruby. Not sure if worth pursuing.

Didn't start cutting C code just yet.

@rhenium
Copy link
Member

rhenium commented Jul 18, 2024

The same point stands regarding ASN1Data class having to own to_der implementations for cons and prim though, not much one can do (?).

I think so, unfortunately.

OpenSSL::ASN1.decode produces an instance of OpenSSL::ASN1::ASN1Data when the data doesn't have a known universal class tag number, so ASN1Data#to_der has to be able to handle both. It would have been simpler if .decode produced Primitive/Constructed instance, and ASN1Data#to_der didn't exist, but I feel it's too late to make such an API change.

OID

Encoding the value should be trivial. I added in an inline comment.

UTCTime / GeneralizedTime

Please check X.690 for exact details, but encoding these types should be relatively simple too, since they are just encoded into ISO 8601 (except UTCTime uses 2-digit years).

@HoneyryderChuck HoneyryderChuck force-pushed the asn1-in-ruby branch 4 times, most recently from 95e2d8d to 005d32b Compare July 23, 2024 14:12
@HoneyryderChuck HoneyryderChuck force-pushed the asn1-in-ruby branch 2 times, most recently from 2f32854 to 1e35012 Compare July 25, 2024 14:25
@HoneyryderChuck
Copy link
Contributor Author

@rhenium did UTCTime and GeneralizedTime, lmk what you think.

@HoneyryderChuck HoneyryderChuck force-pushed the asn1-in-ruby branch 3 times, most recently from 13e200e to 5785230 Compare July 25, 2024 14:42
xclass = take_asn1_tag_class(tag_class)

i = constructed ? V_ASN1_CONSTRUCTED : 0
i |= (xclass & V_ASN1_PRIVATE)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

& V_ASN1_PRIVATE doesn't change the value, as a tag class can only be one of the 4 classes.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I ported this from here

@HoneyryderChuck HoneyryderChuck force-pushed the asn1-in-ruby branch 3 times, most recently from 47e6cf5 to cfcbe9e Compare July 31, 2024 11:25
@HoneyryderChuck
Copy link
Contributor Author

@rhenium anything still pending?

@HoneyryderChuck
Copy link
Contributor Author

@rhenium it's been a while since I worked on this, but got a buffer to do some benchmark / optimizations while doing similar work in jruby-openssl. In order to measure the impact of the current state of affairs, I ran benchmarks against upstream with YJIT turned on. The script looks something like this:

# benchmarks/asn1.rb
require "benchmark/ips"
require "openssl"

OBJS = {
  "integer" => OpenSSL::ASN1::Integer.new(128),
  "negative integer" => OpenSSL::ASN1::Integer.new(-128),
  "oid" => OpenSSL::ASN1::ObjectId.new("1.2.34.56789".b),
  "nid" => OpenSSL::ASN1::ObjectId.new("sha256"),
  "emptyseq" => OpenSSL::ASN1::Sequence.new([]),
  "seq" => OpenSSL::ASN1::Sequence.new([OpenSSL::ASN1::EndOfContent.new]),
  "emptyset" => OpenSSL::ASN1::Set.new([]),
  "set" => OpenSSL::ASN1::Set.new([OpenSSL::ASN1::EndOfContent.new]),
  "utctime" => OpenSSL::ASN1::UTCTime.new(Time.utc(2016, 9, 8, 23, 43, 39)),
  "gentime" => OpenSSL::ASN1::GeneralizedTime.new(Time.utc(2016, 12, 8, 19, 34, 29))
}

RubyVM::YJIT.enable


def test_name(name)
  "#{name} (#{OpenSSL::VERSION})"
end

Benchmark.ips do |x|
  # x.config(warmup: 2, time: 5)

  name = ENV.fetch("TEST", "integer")
  obj = OBJS.fetch(name)

  x.report(test_name(name)) { obj.to_der }

  x.save! "asn1-results.json"
  x.compare!
end

The measurements I got locally are below. The tl;dr:

  • thanks to YJIT, integer/utctime/gentime/empty sets and sequences are all either more performant, or within the same order of magnitude
  • non-empty sets suffer a penalty of -40%. I mostly narrowed this down to the cost of intermediate strings and YJIT not being able to optimize each_with_index, as it's implemented in C (unlike each).
  • objectids are the outlier, as they're roughly 2-3x slower than the C extension version. The main bottlenecks seem to be the "atoi" type of operations on each int element of the oid, as well as dealing with the intermediate strings resulting from splitting the oid 7 building the final string.
# integer
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     integer (3.3.0)   104.700k i/100ms
Calculating -------------------------------------
     integer (3.3.0)      1.180M (± 8.5%) i/s  (847.24 ns/i) -      5.863M in   5.012422s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     integer (3.3.1)   124.846k i/100ms
Calculating -------------------------------------
     integer (3.3.1)      1.384M (± 5.7%) i/s  (722.69 ns/i) -      6.991M in   5.072442s

Comparison:
     integer (3.3.1):  1383714.1 i/s
     integer (3.3.0):  1180297.1 i/s - 1.17x  slower

# negative integer
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
negative integer (3.3.0)
                       115.589k i/100ms
Calculating -------------------------------------
negative integer (3.3.0)
                          1.208M (± 5.6%) i/s  (827.74 ns/i) -      6.126M in   5.089608s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
negative integer (3.3.1)
                        90.272k i/100ms
Calculating -------------------------------------
negative integer (3.3.1)
                        939.232k (± 5.6%) i/s    (1.06 μs/i) -      4.694M in   5.016704s

Comparison:
negative integer (3.3.0):  1208113.2 i/s
negative integer (3.3.1):   939232.1 i/s - 1.29x  slower

# oid
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         oid (3.3.0)    95.141k i/100ms
Calculating -------------------------------------
         oid (3.3.0)    930.101k (± 4.8%) i/s    (1.08 μs/i) -      4.662M in   5.025354s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         oid (3.3.1)    33.935k i/100ms
Calculating -------------------------------------
         oid (3.3.1)    328.683k (± 5.1%) i/s    (3.04 μs/i) -      1.663M in   5.074128s

Comparison:
         oid (3.3.0):   930101.2 i/s
         oid (3.3.1):   328683.3 i/s - 2.83x  slower

# nid
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         nid (3.3.0)   139.380k i/100ms
Calculating -------------------------------------
         nid (3.3.0)      1.475M (± 7.4%) i/s  (678.00 ns/i) -      7.387M in   5.050591s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         nid (3.3.1)    20.461k i/100ms
Calculating -------------------------------------
         nid (3.3.1)    199.659k (± 5.2%) i/s    (5.01 μs/i) -      1.003M in   5.036607s

Comparison:
         nid (3.3.0):  1474931.9 i/s
         nid (3.3.1):   199658.8 i/s - 7.39x  slower

# emptyseq
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
    emptyseq (3.3.0)   358.605k i/100ms
Calculating -------------------------------------
    emptyseq (3.3.0)      3.926M (± 5.3%) i/s  (254.69 ns/i) -     19.723M in   5.040535s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
    emptyseq (3.3.1)   378.188k i/100ms
Calculating -------------------------------------
    emptyseq (3.3.1)      3.845M (± 4.6%) i/s  (260.08 ns/i) -     19.288M in   5.029011s

Comparison:
    emptyseq (3.3.0):  3926367.5 i/s
    emptyseq (3.3.1):  3844918.5 i/s - same-ish: difference falls within error

# seq
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         seq (3.3.0)   249.907k i/100ms
Calculating -------------------------------------
         seq (3.3.0)      2.542M (± 4.4%) i/s  (393.35 ns/i) -     12.745M in   5.024029s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         seq (3.3.1)   172.303k i/100ms
Calculating -------------------------------------
         seq (3.3.1)      1.777M (± 3.1%) i/s  (562.69 ns/i) -      8.960M in   5.046883s

Comparison:
         seq (3.3.0):  2542276.1 i/s
         seq (3.3.1):  1777187.0 i/s - 1.43x  slower

# emptyset
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
    emptyset (3.3.0)   367.618k i/100ms
Calculating -------------------------------------
    emptyset (3.3.0)      3.636M (± 2.8%) i/s  (275.05 ns/i) -     18.381M in   5.059872s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
    emptyset (3.3.1)   336.305k i/100ms
Calculating -------------------------------------
    emptyset (3.3.1)      3.516M (± 3.4%) i/s  (284.42 ns/i) -     17.824M in   5.075931s

Comparison:
    emptyset (3.3.0):  3635660.4 i/s
    emptyset (3.3.1):  3515889.2 i/s - same-ish: difference falls within error

# set
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         set (3.3.0)   216.221k i/100ms
Calculating -------------------------------------
         set (3.3.0)      2.221M (± 3.1%) i/s  (450.20 ns/i) -     11.243M in   5.066991s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
         set (3.3.1)   148.811k i/100ms
Calculating -------------------------------------
         set (3.3.1)      1.533M (± 5.3%) i/s  (652.12 ns/i) -      7.738M in   5.063864s

Comparison:
         set (3.3.0):  2221247.5 i/s
         set (3.3.1):  1533462.8 i/s - 1.45x  slower

# utctime
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     utctime (3.3.0)    62.780k i/100ms
Calculating -------------------------------------
     utctime (3.3.0)    638.801k (± 1.8%) i/s    (1.57 μs/i) -      3.202M in   5.013930s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     utctime (3.3.1)    76.868k i/100ms
Calculating -------------------------------------
     utctime (3.3.1)    803.264k (± 2.7%) i/s    (1.24 μs/i) -      4.074M in   5.075699s

Comparison:
     utctime (3.3.1):   803264.2 i/s
     utctime (3.3.0):   638801.3 i/s - 1.26x  slower


# gentime
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     gentime (3.3.0)    62.095k i/100ms
Calculating -------------------------------------
     gentime (3.3.0)    620.070k (± 2.7%) i/s    (1.61 μs/i) -      3.105M in   5.011014s
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +YJIT +PRISM [x86_64-darwin23]
Warming up --------------------------------------
     gentime (3.3.1)    63.900k i/100ms
Calculating -------------------------------------
     gentime (3.3.1)    628.781k (± 2.8%) i/s    (1.59 μs/i) -      3.195M in   5.085630s

Comparison:
     gentime (3.3.1):   628780.7 i/s
     gentime (3.3.0):   620069.9 i/s - same-ish: difference falls within error

def to_der
if @value.is_a?(Array)
cons_to_der
elsif @indefinite_length
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't this check go to prim_to_der?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this follows what the C version is doing: indefinite length can be used only with "collections", i.e. arrays (constructive will always be arrays, and raw asn1data can be initialized with arrays).


return "\x00".b if @value.empty?

@unused_bits.chr << super
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Integer#chr produces a US-ASCII String if the value is lower than 128.

I think we should avoid using Integer#chr in OpenSSL::ASN1 at all since automatic encoding promotion with ascii-only strings can be very tricky, for example:

(1.chr << "a".b).encoding #=> #<Encoding:US-ASCII>

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've kept .chr followed by .force_encoding. I don't know of a more efficient way to perform the op in ruby (using pack would involve allocating an array). If you have a better alternative, let me know.


class Null < Primitive
def der_value
"".b
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please keep the @value == nil check because our C implementation had it.

I think we should test it too.

Comment on lines 359 to 411
class UTCTime < Primitive
FORMAT = "%y%m%d%H%M%SZ".freeze

def der_value
value = if @value.is_a?(Time)
@value
else
Time.at(Integer(@value))
end

value.utc.strftime(FORMAT)
end
end

class GeneralizedTime < Primitive
FORMAT = "%Y%m%d%H%M%SZ".freeze
def der_value
value = if @value.is_a?(Time)
@value
else
Time.at(Integer(@value))
end

value.utc.strftime(FORMAT)
end
end
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Time can express more than UTCTime or GeneralizedTime can do, so a range check is needed.

Also, new constants should be marked as private unless we intend to expose it to users. But I feel it could simply be inlined in this case.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the range check to UTCTime, but at least specwise, GeneralizedTime does not seem to have known limitations. openssl does seem to limit to numbers convertible to long, not sure whether this should be ported to ruby though. WDYT?

if constructed && indefinite_length
str << "\x80"
else
str << put_length(length)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this can be refactored since this is the only location using put_length and integer_to_octets.

integer_to_octets currently considers the case the number is negative, but it never happens because the length here is of a String object.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by refactored, you mean inlined?

integer_to_octets currently considers the case the number is negative

it's true, length is always positive 👍

@rhenium
Copy link
Member

rhenium commented Jul 4, 2025

Sorry for the slow response. I've added some additional inline comments.

My motivation for this series is that we can stop relying on those OpenSSL C API for BER encoding/decoding, which are screaming that it's not intended to be used outside OpenSSL, are completely undocumented, often have inconsistent semantics, and have undergone a lot of unexpected breaking changes. We also want to avoid performing string manipulation in C in general, especially when handling untrusted input.

I think our top priority should be to make the code more understandable and maintainable, while keeping backwards compatibility.

Good performance is nice to have, but I feel it's too early to focus on that. Some of the current optimizations feel a bit too aggressive. Using nil in place of an empty String or avoiding Array#each_with_index just to save a C method call are harming readability IMHO. There are edge cases in behaviors which need to be fixed first, and especially the parsing part of OpenSSL::ASN1 is still in C.

As for the speed of the OID encoding (or of anything else), I wouldn't rule out adding a specialized helper method implemented in C if it's necessary. However, I'm not yet sure if the current performance is an actual issue. I think it's too early to tell it.

For measuring performance, I think using a complex and realistic structure like an X.509 certificate might provide a more useful result.

this allows (when value is an integer) the runtime and JIT to optimize the call, instead of relying on C-extension openssl
…ppends)

the former is a data structure that the runtime can improve processing of, and avoids realloc'ing the string on each call (as well as packing one byte at a time)
which means that it'll be nil; this improves encoding of asn1 nulls and cons with no elements
it's US-ASCII for integers < 128
utctime should not take dates into account with a year below 1950 and above 2049
@HoneyryderChuck
Copy link
Contributor Author

My motivation for this series is that we can stop relying on those OpenSSL C API for BER encoding/decoding, which are screaming that it's not intended to be used outside OpenSSL, are completely undocumented, often have inconsistent semantics, and have undergone a lot of unexpected breaking changes. We also want to avoid performing string manipulation in C in general, especially when handling untrusted input.

I think our top priority should be to make the code more understandable and maintainable, while keeping backwards compatibility.

Wholeheartedly agree. FWIW the main motivation for this work is the belif that ruby code is more maintainable/readable than C code, and the ASN1 benefits little of being in C.

Good performance is nice to have, but I feel it's too early to focus on that. Some of the current optimizations feel a bit too aggressive.

I agree, I just want to make sure that any regression is within a certain margin. There are people using this in production, after all. I don't think that some of the optimizations done here make the code less readable though: each_with_index is being used, and having the value of ASN1::Null being nil is semantically correct (at the cost of 2 inline if statements).

There are edge cases in behaviors which need to be fixed first, and especially the parsing part of OpenSSL::ASN1 is still in C.

Agree with the edge cases, but just to clarify, the goal of this MR is not to touch parsing (that can come later).

As for the speed of the OID encoding (or of anything else), I wouldn't rule out adding a specialized helper method implemented in C if it's necessary. However, I'm not yet sure if the current performance is an actual issue. I think it's too early to tell it.

Agreed. It'd be great to have some input from someone that knows about YJIT or ZJIT whether these operations are something that future versions will be able to optimize though. The less C, the more maintainable it is.

For measuring performance, I think using a complex and realistic structure like an X.509 certificate might provide a more useful result.

Anything in the repo that I could use?

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