Module: CsrPeek::Extensions

Defined in:
lib/csr_peek/extensions.rb

Overview

Decoders for the X.509 extensions we surface, working from the extension's DER rather than OpenSSL's human-readable #value string. The display string splits on ", " (which corrupts a directoryName between RDNs) and its labels are locale- and version-dependent; decoding the ASN.1 is exact and stable. Every method tolerates a nil extension and never raises.

Constant Summary collapse

GN_RFC822 =

GeneralName CHOICE context tags (RFC 5280 4.2.1.6).

1
GN_DNS =

rfc822Name (email)

2
GN_URI =

dNSName

6
GN_IP =

uniformResourceIdentifier

7
GN_DIRNAME =

iPAddress

4
KEY_USAGE_NAMES =

keyUsage BIT STRING positions (RFC 5280 4.2.1.3), bit 0 first.

%w[
  digitalSignature nonRepudiation keyEncipherment dataEncipherment
  keyAgreement keyCertSign cRLSign encipherOnly decipherOnly
].freeze

Class Method Summary collapse

Class Method Details

.basic_constraints(extension) ⇒ Object

basicConstraints as { ca: true/false, path_length: Integer or nil }.



88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
# File 'lib/csr_peek/extensions.rb', line 88

def basic_constraints(extension)
  absent = {ca: false, path_length: nil}
  return absent if extension.nil?

  der = extn_value_der(extension)
  return absent if der.nil?

  sequence = OpenSSL::ASN1.decode(der)
  elements = Array(sequence.value)
  ca = elements.find { |e| e.is_a?(OpenSSL::ASN1::Boolean) }&.value || false
  path = elements.find { |e| e.is_a?(OpenSSL::ASN1::Integer) }
  {ca: ca, path_length: path&.value&.to_i}
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
  absent
end

.bit_set?(bytes, index) ⇒ Boolean

Returns:

  • (Boolean)


150
151
152
153
154
155
# File 'lib/csr_peek/extensions.rb', line 150

def bit_set?(bytes, index)
  byte = bytes[index / 8]
  return false if byte.nil?

  byte.anybits?(0x80 >> (index % 8))
end

.deep_freeze(sans) ⇒ Object

Deep-freeze so the value object that holds this is genuinely immutable: the category arrays AND the (OpenSSL-derived, non-literal) strings inside.



52
53
54
55
# File 'lib/csr_peek/extensions.rb', line 52

def deep_freeze(sans)
  sans.each_value { |values| values.each(&:freeze).freeze }
  sans.freeze
end

.empty_sansObject



28
29
30
# File 'lib/csr_peek/extensions.rb', line 28

def empty_sans
  {dns: [], ip: [], email: [], uri: [], other: []}
end

.extended_key_usages(extension) ⇒ Object

extendedKeyUsage as short OID names, e.g. ["serverAuth", "clientAuth"]. Falls back to the dotted OID for usages OpenSSL cannot name.



73
74
75
76
77
78
79
80
81
82
83
84
85
# File 'lib/csr_peek/extensions.rb', line 73

def extended_key_usages(extension)
  return [] if extension.nil?

  der = extn_value_der(extension)
  return [] if der.nil?

  sequence = OpenSSL::ASN1.decode(der)
  Array(sequence.value).map do |oid|
    (oid.respond_to?(:sn) ? (oid.sn || oid.oid) : oid.to_s).freeze
  end
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
  []
end

.extn_value_der(extension) ⇒ Object

The raw DER carried inside an extension's OCTET STRING.



105
106
107
108
109
# File 'lib/csr_peek/extensions.rb', line 105

def extn_value_der(extension)
  sequence = OpenSSL::ASN1.decode(extension.to_der)
  octet = Array(sequence.value).find { |el| el.is_a?(OpenSSL::ASN1::OctetString) }
  octet&.value
end

.format_ip(bytes) ⇒ Object

dotted-quad for IPv4, canonical (compressed) form for IPv6, nil otherwise.



128
129
130
131
132
133
134
135
136
# File 'lib/csr_peek/extensions.rb', line 128

def format_ip(bytes)
  raw = bytes.to_s
  case raw.bytesize
  when 4 then raw.unpack("C4").join(".")
  when 16 then IPAddr.new(IPAddr.ntop(raw)).to_s
  end
rescue IPAddr::Error
  nil
end

.general_name_to_s(gn) ⇒ Object

directoryName is decoded to its slash form so no information is lost; any other kind is labelled by its tag number.



140
141
142
143
144
145
146
147
148
# File 'lib/csr_peek/extensions.rb', line 140

def general_name_to_s(gn)
  if gn.tag == GN_DIRNAME
    inner = Array(gn.value).first
    return "dirName:#{OpenSSL::X509::Name.new(inner.to_der)}" if inner
  end
  "tag#{gn.tag}"
rescue
  "tag#{gn.tag}"
end

.general_names(sequence) ⇒ Object



111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
# File 'lib/csr_peek/extensions.rb', line 111

def general_names(sequence)
  result = empty_sans
  Array(sequence.value).each do |gn|
    next unless gn.respond_to?(:tag) && gn.tag_class == :CONTEXT_SPECIFIC

    case gn.tag
    when GN_DNS then result[:dns] << gn.value.to_s
    when GN_RFC822 then result[:email] << gn.value.to_s
    when GN_URI then result[:uri] << gn.value.to_s
    when GN_IP then (ip = format_ip(gn.value)) && result[:ip] << ip
    else result[:other] << general_name_to_s(gn)
    end
  end
  result
end

.key_usages(extension) ⇒ Object

keyUsage as canonical names, e.g. ["digitalSignature", "keyEncipherment"].



58
59
60
61
62
63
64
65
66
67
68
69
# File 'lib/csr_peek/extensions.rb', line 58

def key_usages(extension)
  return [] if extension.nil?

  der = extn_value_der(extension)
  return [] if der.nil?

  bits = OpenSSL::ASN1.decode(der)
  bytes = bits.value.bytes
  KEY_USAGE_NAMES.each_index.select { |i| bit_set?(bytes, i) }.map { |i| KEY_USAGE_NAMES[i] }
rescue OpenSSL::ASN1::ASN1Error, NoMethodError
  []
end

.parse_san(extension) ⇒ Object



39
40
41
42
43
44
45
46
47
48
# File 'lib/csr_peek/extensions.rb', line 39

def parse_san(extension)
  return empty_sans if extension.nil?

  der = extn_value_der(extension)
  return empty_sans if der.nil?

  general_names(OpenSSL::ASN1.decode(der))
rescue OpenSSL::ASN1::ASN1Error, OpenSSL::X509::ExtensionError
  empty_sans
end

.subject_alt_names(extension) ⇒ Object

Parse a subjectAltName extension into { dns:, ip:, email:, uri:, other: }. The result is deep-frozen: the value objects hold it directly, and a caller must not be able to mutate a memoized view.



35
36
37
# File 'lib/csr_peek/extensions.rb', line 35

def subject_alt_names(extension)
  deep_freeze(parse_san(extension))
end