Module: Ruby::Rego::Builtins::Crypto::CertificateStruct

Defined in:
lib/ruby/rego/builtins/crypto/certificate_uri.rb,
lib/ruby/rego/builtins/crypto/certificate_struct.rb,
lib/ruby/rego/builtins/crypto/certificate_extensions.rb,
lib/ruby/rego/builtins/crypto/certificate_public_key.rb,
lib/ruby/rego/builtins/crypto/certificate_request_struct.rb

Overview

CSR fields of the x509 struct builder (see certificate_struct.rb for the module role). A CertificateRequest is a subset of a Certificate: Subject/PublicKey/Signature/Raw* and the SANs derived from the requested extensions, plus the raw Attributes set. Reuses the certificate atoms (Name.build, public_key, signature_algorithm, the SAN validators via general_names, parsed_uris/url_struct, scrub, b64, oid_ints, context_tag?).

Constant Summary collapse

SIGNATURE_ALGORITHMS =

Go x509.SignatureAlgorithm enum, keyed by OpenSSL’s signature-algorithm name. RSA-PSS shares one OID across digests (the digest lives in the parameters), so 13/14/15 are resolved from those parameters in pss_signature_algorithm, not this table.

{
  "md2WithRSAEncryption" => 1, "md5WithRSAEncryption" => 2, "sha1WithRSAEncryption" => 3,
  "sha256WithRSAEncryption" => 4, "sha384WithRSAEncryption" => 5, "sha512WithRSAEncryption" => 6,
  "dsaWithSHA1" => 7, "dsa_with_SHA256" => 8, "ecdsa-with-SHA1" => 9, "ecdsa-with-SHA256" => 10,
  "ecdsa-with-SHA384" => 11, "ecdsa-with-SHA512" => 12, "ED25519" => 16
}.freeze
RSA_PSS_NAME =

OpenSSL’s name for the RSASSA-PSS signature algorithm, and the SHA-256,384,512 digest OIDs in its parameters -> Go’s SHA*WithRSAPSS enum values.

"rsassaPss"
PSS_DIGEST_ALGORITHMS =
{
  "2.16.840.1.101.3.4.2.1" => 13, "2.16.840.1.101.3.4.2.2" => 14, "2.16.840.1.101.3.4.2.3" => 15
}.freeze
PUBLIC_KEY_ALGORITHM_OIDS =

Go x509.PublicKeyAlgorithm enum, keyed by the SubjectPublicKeyInfo algorithm OID. Keying on the DER OID (not OpenSSL’s key object) lets an unsupported algorithm (e.g. X25519/Ed448, whose key OpenSSL cannot expose) map to UnknownPublicKeyAlgorithm(0) without raising.

{
  "1.2.840.113549.1.1.1" => 1, "1.2.840.10040.4.1" => 2,
  "1.2.840.10045.2.1" => 3, "1.3.101.112" => 4
}.freeze
ZERO_FIELDS =

The certificate field hash at Go’s zero values; computed fields override these.

{
  "Raw" => nil, "RawTBSCertificate" => nil, "RawSubjectPublicKeyInfo" => nil,
  "RawSubject" => nil, "RawIssuer" => nil,
  "Signature" => nil, "SignatureAlgorithm" => 0,
  "PublicKeyAlgorithm" => 0, "PublicKey" => nil,
  "Version" => 0, "SerialNumber" => 0, "Issuer" => nil, "Subject" => nil,
  "NotBefore" => nil, "NotAfter" => nil, "KeyUsage" => 0,
  "Extensions" => nil, "ExtraExtensions" => nil, "UnhandledCriticalExtensions" => nil,
  "ExtKeyUsage" => nil, "UnknownExtKeyUsage" => nil,
  "BasicConstraintsValid" => false, "IsCA" => false,
  "MaxPathLen" => 0, "MaxPathLenZero" => false,
  "SubjectKeyId" => nil, "AuthorityKeyId" => nil,
  "OCSPServer" => nil, "IssuingCertificateURL" => nil,
  "DNSNames" => nil, "EmailAddresses" => nil, "IPAddresses" => nil,
  "URIs" => nil, "URIStrings" => nil,
  "PermittedDNSDomainsCritical" => false,
  "PermittedDNSDomains" => nil, "ExcludedDNSDomains" => nil,
  "PermittedIPRanges" => nil, "ExcludedIPRanges" => nil,
  "PermittedEmailAddresses" => nil, "ExcludedEmailAddresses" => nil,
  "PermittedURIDomains" => nil, "ExcludedURIDomains" => nil,
  "CRLDistributionPoints" => nil,
  "PolicyIdentifiers" => nil, "Policies" => nil, "PolicyMappings" => nil,
  "InhibitAnyPolicy" => 0, "InhibitAnyPolicyZero" => false,
  "InhibitPolicyMapping" => 0, "InhibitPolicyMappingZero" => false,
  "RequireExplicitPolicy" => 0, "RequireExplicitPolicyZero" => false
}.freeze
EXT_KEY_USAGES =

Go x509.ExtKeyUsage enum, keyed by the EKU purpose OID.

{
  "2.5.29.37.0" => 0, "1.3.6.1.5.5.7.3.1" => 1, "1.3.6.1.5.5.7.3.2" => 2,
  "1.3.6.1.5.5.7.3.3" => 3, "1.3.6.1.5.5.7.3.4" => 4, "1.3.6.1.5.5.7.3.5" => 5,
  "1.3.6.1.5.5.7.3.6" => 6, "1.3.6.1.5.5.7.3.7" => 7, "1.3.6.1.5.5.7.3.8" => 8,
  "1.3.6.1.5.5.7.3.9" => 9, "1.3.6.1.4.1.311.10.3.3" => 10, "2.16.840.1.113730.4.1" => 11,
  "1.3.6.1.4.1.311.2.1.22" => 12, "1.3.6.1.4.1.311.61.1.1" => 13
}.freeze
NAME_CONSTRAINT_FIELDS =

GeneralName context tag -> [permitted field, excluded field] for nameConstraints subtrees.

{
  2 => %w[PermittedDNSDomains ExcludedDNSDomains],
  1 => %w[PermittedEmailAddresses ExcludedEmailAddresses],
  6 => %w[PermittedURIDomains ExcludedURIDomains],
  7 => %w[PermittedIPRanges ExcludedIPRanges]
}.freeze
ACCESS_METHODS =

AccessDescription method OID -> the field its URI accessLocation populates.

{
  "1.3.6.1.5.5.7.48.1" => "OCSPServer",
  "1.3.6.1.5.5.7.48.2" => "IssuingCertificateURL"
}.freeze
RECOGNIZED_EXTENSION_OIDS =

Extension OIDs crypto/x509’s parseCertificate recognizes; a critical extension whose OID is not here is reported in UnhandledCriticalExtensions (matching Go).

%w[
  2.5.29.14 2.5.29.35 2.5.29.15 2.5.29.37 2.5.29.19 2.5.29.17 2.5.29.31
  1.3.6.1.5.5.7.1.1 2.5.29.30 2.5.29.32 2.5.29.36 2.5.29.54 2.5.29.33
].freeze
MAX_ASN1_DEPTH =

Nesting cap for a known extension’s DER. Far beyond any real certificate (extensions nest a handful deep) yet well under the depth at which OpenSSL::ASN1.decode’s C recursion overflows the stack uncatchably; OPA’s Go asn1 tolerates deeper input, so this diverges only on adversarial certificates that no legitimate issuer produces.

1000
INT64_MIN =

Go’s int is 64-bit on OPA’s platforms; asn1.Unmarshal into an int field errors (-> OPA undefined) when the value does not fit, so the int-typed extension fields are bounded here.

-(2**63)
INT64_MAX =
(2**63) - 1
SUPPORTED_EC_CURVES =

The named curves Go’s crypto/x509 supports (P-224/256/384/521); any other curve makes ParseCertificate error -> OPA undefined.

%w[secp224r1 prime256v1 secp384r1 secp521r1].freeze
EXTENSION_REQUEST_OID =

PKCS#9 extensionRequest attribute OID (its value carries the requested extensions).

"1.2.840.113549.1.9.14"
REQUEST_ZERO_FIELDS =

The CertificateRequest field hash at Go’s zero values; computed fields override these.

{
  "Raw" => nil, "RawTBSCertificateRequest" => nil, "RawSubjectPublicKeyInfo" => nil,
  "RawSubject" => nil, "Version" => 0, "Signature" => nil, "SignatureAlgorithm" => 0,
  "PublicKeyAlgorithm" => 0, "PublicKey" => nil, "Subject" => nil,
  "Attributes" => nil, "Extensions" => nil, "ExtraExtensions" => nil,
  "DNSNames" => nil, "EmailAddresses" => nil, "IPAddresses" => nil, "URIs" => nil
}.freeze

Class Method Summary collapse

Class Method Details

.apply_extensions(fields, tbs) ⇒ Hash[String, untyped]

A known extension whose DER is not the shape crypto/x509 expects makes Go’s asn1.Unmarshal fail, so parseCertificate errors and OPA returns undefined; the structural exceptions that surfaces in Ruby (nil/array/string where another node was assumed) are mapped to that same undefined via MalformedCertificate. Unknown extension OIDs never reach a handler, matching Go keeping them as raw bytes. (SystemStackError from a pathologically nested extension is a non-StandardError; it is caught by the certificate builder’s outer rescue.)

Parameters:

  • fields (Hash[String, untyped])
  • cert (OpenSSL::X509::Certificate)

Returns:

  • (Hash[String, untyped])

Raises:



63
64
65
66
67
68
69
70
71
72
73
74
75
# File 'lib/ruby/rego/builtins/crypto/certificate_extensions.rb', line 63

def self.apply_extensions(fields, tbs)
  extensions = certificate_extensions(tbs)
  oids = extensions.map(&:first)
  # Go's parseCertificate rejects a certificate with a repeated extension OID -> OPA undefined.
  raise MalformedCertificate, "duplicate extension" if oids.uniq.length != oids.length

  unhandled = extensions.filter_map do |oid, critical, der|
    dispatch_extension(fields, oid, critical, der)
    oid_ints(oid) if critical && !RECOGNIZED_EXTENSION_OIDS.include?(oid)
  end
  fields["UnhandledCriticalExtensions"] = unhandled unless unhandled.empty?
  fields
end

.build(cert) ⇒ Hash[String, untyped]

rubocop:disable Metrics/AbcSize, Metrics/MethodLength

Parameters:

  • cert (OpenSSL::X509::Certificate)

Returns:

  • (Hash[String, untyped])


74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
# File 'lib/ruby/rego/builtins/crypto/certificate_struct.rb', line 74

def self.build(cert)
  decoded = OpenSSL::ASN1.decode(cert.to_der)
  tbs = decoded.value[0]
  elements = tbs.value
  # TBSCertificate omits the [0] EXPLICIT version field for v1 certs, shifting serialNumber
  # to index 0 (it sits at index 1 once a version is present) and every later field with it.
  serial_index = context_tag?(elements[0], 0) ? 1 : 0
  issuer = elements[serial_index + 2]
  subject = elements[serial_index + 4]
  spki = elements[serial_index + 5]
  # A 0 (unknown) algorithm has PublicKey null and never calls cert.public_key (it raises for
  # key types OpenSSL cannot represent), matching Go's UnknownPublicKeyAlgorithm handling.
  algorithm = PUBLIC_KEY_ALGORITHM_OIDS.fetch(spki.value[0].value[0].oid, 0)
  fields = ZERO_FIELDS.merge(
    scalar_fields(cert, decoded),
    raw_fields(cert, tbs, issuer, subject, spki),
    "PublicKey" => algorithm.zero? ? nil : CertificateStruct.public_key(cert),
    "PublicKeyAlgorithm" => algorithm,
    "Issuer" => Name.build(issuer),
    "Subject" => Name.build(subject),
    "Extensions" => extension_list(tbs)
  )
  scrub(apply_extensions(fields, tbs))
end

.build_request(request) ⇒ Hash[String, untyped]

rubocop:disable Metrics/AbcSize, Metrics/MethodLength

Parameters:

  • request (OpenSSL::X509::Request)

Returns:

  • (Hash[String, untyped])

Raises:



30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
# File 'lib/ruby/rego/builtins/crypto/certificate_request_struct.rb', line 30

def self.build_request(request)
  der = request.to_der
  # DoS guard. X509::Request.new stores attribute values as opaque ASN.1 ANY (its template
  # parse caps recursion at the d2i depth limit), but OpenSSL::ASN1.decode below recurses into
  # them eagerly, so a deeply-nested attribute value can overflow the C stack uncatchably.
  # Pre-scan depth like the extension decode path; a CSR nested beyond MAX_ASN1_DEPTH (which no
  # conforming CSR reaches) maps to undefined — a deliberate divergence from OPA, which stores
  # the value un-recursed and tolerates arbitrary nesting, traded for not crashing.
  raise MalformedCertificate, "request nested beyond depth limit" unless safe_asn1?(der)

  decoded = OpenSSL::ASN1.decode(der)
  info = decoded.value[0] # CertificationRequestInfo: [version, subject, spki, [0] attributes]
  subject = info.value[1]
  spki = info.value[2]
  attributes = info.value.find { |element| context_tag?(element, 0) }
  extension_nodes = request_extension_nodes(attributes) # walked once, shared by Extensions + SANs
  algorithm = PUBLIC_KEY_ALGORITHM_OIDS.fetch(spki.value[0].value[0].oid, 0)
  fields = REQUEST_ZERO_FIELDS.merge(
    "Version" => request.version.to_i,
    "Raw" => b64(der), "RawTBSCertificateRequest" => b64(info.to_der),
    "RawSubject" => b64(subject.to_der), "RawSubjectPublicKeyInfo" => b64(spki.to_der),
    "Subject" => Name.build(subject, lenient: true),
    "PublicKey" => algorithm.zero? ? nil : public_key(request),
    "PublicKeyAlgorithm" => algorithm,
    "SignatureAlgorithm" => signature_algorithm(request, decoded),
    "Signature" => b64(decoded.value[2].value),
    "Attributes" => request_attributes(attributes),
    "Extensions" => extension_nodes&.map { |ext| extension_entry(ext) }
  )
  request_sans(fields, extension_nodes)
  scrub(fields)
end

.extension_nodes(tbs) ⇒ Object

The raw Extension nodes (the [3] EXPLICIT wrapper’s SEQUENCE OF Extension), or nil when the certificate carries no extensions. Shared by extension_list and the extension parsers.



167
168
169
170
171
172
# File 'lib/ruby/rego/builtins/crypto/certificate_struct.rb', line 167

def self.extension_nodes(tbs)
  wrapper = tbs.value.find { |element| context_tag?(element, 3) }
  return nil unless wrapper

  wrapper.value[0].value
end

.oid_ints(oid) ⇒ Object

OID dotted string -> array of integer arcs (Go marshals asn1.ObjectIdentifier as []int).



211
212
213
# File 'lib/ruby/rego/builtins/crypto/certificate_struct.rb', line 211

def self.oid_ints(oid)
  oid.split(".").map { |arc| Integer(arc) }
end

.public_key(cert) ⇒ Object

The PublicKey field, matching Go’s json.Marshal of the concrete public-key type: an rsa.PublicKey is E; an ecdsa.PublicKey is Curve:{, X, Y}; an ed25519.PublicKey is the std-base64 of its 32 bytes. rubocop:disable Metrics/AbcSize



19
20
21
22
23
24
25
26
27
# File 'lib/ruby/rego/builtins/crypto/certificate_public_key.rb', line 19

def self.public_key(cert)
  key = cert.public_key
  case key.oid
  when "id-ecPublicKey" then ec_public_key(key)
  when "ED25519" then Base64.strict_encode64(key.raw_public_key)
  when "DSA" then { "G" => key.g.to_i, "P" => key.p.to_i, "Q" => key.q.to_i, "Y" => key.pub_key.to_i }
  else { "N" => key.n.to_i, "E" => key.e.to_i }
  end
end

.safe_asn1?(der) ⇒ Boolean

Iterative scan of definite-length DER TLV headers (no decode, no recursion, never raises): false once nesting would exceed MAX_ASN1_DEPTH, and false on a BER indefinite length (which would otherwise bypass the depth count, and which DER — so Go — rejects anyway). Huge or truncated definite lengths are left for OpenSSL to reject shallowly (ASN1Error -> undefined). Public: the generic ASN.1-depth DoS guard, also called cross-module by the key parser’s format_matches? before it hands attacker DER to OpenSSL::ASN1.decode. rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/MethodLength, Metrics/PerceivedComplexity :reek:TooManyStatements – a single byte-scanning loop is the clearest form.

Returns:

  • (Boolean)


373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
# File 'lib/ruby/rego/builtins/crypto/certificate_extensions.rb', line 373

def self.safe_asn1?(der)
  bytes = der.b
  size = bytes.bytesize
  open_ends = [] # : Array[Integer]
  pos = 0
  while pos < size
    open_ends.pop while !open_ends.empty? && pos >= open_ends.fetch(-1)
    return false if open_ends.size >= MAX_ASN1_DEPTH

    tag = bytes.getbyte(pos).to_i # to_i: pos < size guarantees a byte
    pos += 1
    if tag.allbits?(0x1f) # high-tag-number form: skip the multi-byte tag
      pos += 1 while pos < size && bytes.getbyte(pos).to_i >= 0x80
      pos += 1
    end
    return true if pos >= size

    length_byte = bytes.getbyte(pos).to_i
    pos += 1
    content_len = length_byte
    if length_byte >= 0x80
      count = length_byte & 0x7f
      return false if count.zero? # BER indefinite length: bypasses the depth count; reject
      return true if count > 4 || (pos + count) > size

      content_len = 0
      count.times do
        content_len = (content_len << 8) | bytes.getbyte(pos).to_i
        pos += 1
      end
    end
    if tag.nobits?(0x20) # primitive: skip its content
      pos += content_len
    else
      open_ends.push(pos + content_len)
    end
  end
  true
end

.transcode(bytes, encoding) ⇒ Object

Transcode raw bytes from an ASN.1 string encoding to UTF-8, replacing invalid bytes with U+FFFD as Go’s json.Marshal does. Shared by the pkix.Name builder and the CSR attribute value marshaler (both reproduce Go’s asn1 string decoding). Called cross-module, so public. :reek:UtilityFunction – a pure byte-encoding transform.



229
230
231
# File 'lib/ruby/rego/builtins/crypto/certificate_struct.rb', line 229

def self.transcode(bytes, encoding)
  bytes.dup.force_encoding(encoding).encode(Encoding::UTF_8, invalid: :replace, undef: :replace)
end

.uri_fields(uri_texts) ⇒ Hash[String, untyped]

Builds the URIs / URIStrings fields from the URI SANs. crypto/x509 runs each through net/url.Parse (a reject -> the whole certificate fails to parse -> OPA undefined); URIs is json.Marshal of each *url.URL, URIStrings the injected url.String() of each.

Parameters:

  • uri_texts (Array[String])

Returns:

  • (Hash[String, untyped])


16
17
18
19
20
# File 'lib/ruby/rego/builtins/crypto/certificate_uri.rb', line 16

def self.uri_fields(uri_texts)
  parsed = parsed_uris(uri_texts)
  { "URIs" => parsed.map { |components| url_struct(components) },
    "URIStrings" => parsed.map { |components| Uri::Parser.string(components) } }
end