zatca 0.1.2 → 1.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (56) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +11 -2
  3. data/bin/console +0 -0
  4. data/bin/setup +0 -0
  5. data/lib/zatca/client.rb +173 -0
  6. data/lib/zatca/hacks.rb +45 -0
  7. data/lib/zatca/hashing.rb +18 -0
  8. data/lib/zatca/qr_code_extractor.rb +31 -0
  9. data/lib/zatca/qr_code_generator.rb +9 -2
  10. data/lib/zatca/signing/certificate.rb +78 -0
  11. data/lib/zatca/signing/csr.rb +220 -0
  12. data/lib/zatca/signing/ecdsa.rb +59 -0
  13. data/lib/zatca/tag.rb +18 -8
  14. data/lib/zatca/tags.rb +5 -1
  15. data/lib/zatca/tags_schema.rb +5 -5
  16. data/lib/zatca/types.rb +7 -0
  17. data/lib/zatca/ubl/base_component.rb +142 -0
  18. data/lib/zatca/ubl/builder.rb +166 -0
  19. data/lib/zatca/ubl/common_aggregate_components/allowance_charge.rb +64 -0
  20. data/lib/zatca/ubl/common_aggregate_components/classified_tax_category.rb +25 -0
  21. data/lib/zatca/ubl/common_aggregate_components/delivery.rb +27 -0
  22. data/lib/zatca/ubl/common_aggregate_components/invoice_line.rb +63 -0
  23. data/lib/zatca/ubl/common_aggregate_components/item.rb +21 -0
  24. data/lib/zatca/ubl/common_aggregate_components/legal_monetary_total.rb +59 -0
  25. data/lib/zatca/ubl/common_aggregate_components/party.rb +28 -0
  26. data/lib/zatca/ubl/common_aggregate_components/party_identification.rb +25 -0
  27. data/lib/zatca/ubl/common_aggregate_components/party_legal_entity.rb +19 -0
  28. data/lib/zatca/ubl/common_aggregate_components/party_tax_scheme.rb +30 -0
  29. data/lib/zatca/ubl/common_aggregate_components/postal_address.rb +59 -0
  30. data/lib/zatca/ubl/common_aggregate_components/price.rb +20 -0
  31. data/lib/zatca/ubl/common_aggregate_components/tax_category.rb +56 -0
  32. data/lib/zatca/ubl/common_aggregate_components/tax_total.rb +58 -0
  33. data/lib/zatca/ubl/common_aggregate_components.rb +2 -0
  34. data/lib/zatca/ubl/invoice.rb +481 -0
  35. data/lib/zatca/ubl/invoice_subtype_builder.rb +50 -0
  36. data/lib/zatca/ubl/signing/cert.rb +48 -0
  37. data/lib/zatca/ubl/signing/invoice_signed_data_reference.rb +44 -0
  38. data/lib/zatca/ubl/signing/key_info.rb +25 -0
  39. data/lib/zatca/ubl/signing/object.rb +20 -0
  40. data/lib/zatca/ubl/signing/qualifying_properties.rb +27 -0
  41. data/lib/zatca/ubl/signing/signature.rb +50 -0
  42. data/lib/zatca/ubl/signing/signature_information.rb +19 -0
  43. data/lib/zatca/ubl/signing/signature_properties_reference.rb +26 -0
  44. data/lib/zatca/ubl/signing/signed_info.rb +21 -0
  45. data/lib/zatca/ubl/signing/signed_properties.rb +81 -0
  46. data/lib/zatca/ubl/signing/signed_signature_properties.rb +23 -0
  47. data/lib/zatca/ubl/signing/ubl_document_signatures.rb +25 -0
  48. data/lib/zatca/ubl/signing/ubl_extension.rb +22 -0
  49. data/lib/zatca/ubl/signing/ubl_extensions.rb +17 -0
  50. data/lib/zatca/ubl/signing.rb +2 -0
  51. data/lib/zatca/ubl.rb +2 -0
  52. data/lib/zatca/version.rb +1 -1
  53. data/lib/zatca.rb +27 -3
  54. data/zatca.gemspec +52 -0
  55. metadata +165 -10
  56. data/Gemfile.lock +0 -100
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: c3d50ea1cc7ced6ae04db6b019bbb93b89acd164c93ce6bf0f918e93d3300ca2
4
- data.tar.gz: 29217d567f38527e15424d822c681e84c44de620f1212658f49fd7e9c9d28a46
3
+ metadata.gz: dfcfffb30bc80671008a9f4d85bc6300f7bed524f02919a35314be134f6ced61
4
+ data.tar.gz: b5fdeac07afe47aabcb05284283a4197ddaf55262961d34dd953a7e9d74f6487
5
5
  SHA512:
6
- metadata.gz: 3b3c1935011617463f0adee23e3590eddf956be5df53dd6adcd56e1f0c0ba97135faabcb5c97baa93b0565e04dcd2ffde0136573751c12d390f531a5cdccc576
7
- data.tar.gz: b1f4612a7c601565791d14d21bae937ef0c7728e3e2aeada65611b7db7a10d12261ebaf2f8b3c56ff6bb5f793173022ea3c26ae126732e3038d17fb27903ca69
6
+ metadata.gz: 9905e39f50b48661abc536d803254b99dbb7aa4c8a200e3b2c80b9292080aa5d5fa64f419ccf2d5e9c368691ce068cfab96a6373dcc42e9fa9dfc037b3639128
7
+ data.tar.gz: f3b19d710fce67255bd151bdd1913e900419fddf7343b547a88eb86ebc21b8aaecc40537c1b6a0e58ce0f79b4002667780a3768b95a7042c03b87faca2268038
data/README.md CHANGED
@@ -1,9 +1,9 @@
1
1
  # zatca
2
2
  ![](https://img.shields.io/gem/v/zatca) ![](https://img.shields.io/github/workflow/status/mrsool/zatca/Ruby)
3
3
 
4
- A Ruby library for generating QR Codes for the e-invoice standard by ZATCA in Saudi Arabia.
4
+ A Ruby library for generating QR Codes and e-invoices according to the standard created by ZATCA in Saudi Arabia.
5
5
 
6
- Validated to have the same output as [ZATCA's SDK](https://zatca.gov.sa/en/E-Invoicing/SystemsDevelopers/ComplianceEnablementToolbox/Pages/DownloadSDK.aspx) as of 12 November 2021.
6
+ This library supports both Phase 1 and Phase 2. Phase 2 support is still new so there may be bugs. Please [report any issues](https://github.com/mrsool/zatca/issues/new) you find.
7
7
 
8
8
  # Installation
9
9
 
@@ -19,6 +19,7 @@ bundle add zatca
19
19
 
20
20
  # Usage
21
21
 
22
+ ## Phase 1
22
23
  ```rb
23
24
  require "zatca"
24
25
 
@@ -51,3 +52,11 @@ tags = ZATCA::Tags.new({
51
52
  generator = ZATCA::QRCodeGenerator.new(tags)
52
53
  generator.render(size: 512)
53
54
  ```
55
+
56
+ ## Phase 2
57
+ Documentation lives in the [wiki](https://github.com/mrsool/zatca/wiki)
58
+
59
+ # Notice of Non-Affiliation and Disclaimer
60
+ This library is not affiliated, associated, authorized, endorsed by, or in any way officially connected with ZATCA (Zakat, Tax and Customs Authority), or any of its subsidiaries or its affiliates. The official ZATCA website can be found at https://zatca.gov.sa.
61
+
62
+
data/bin/console CHANGED
File without changes
data/bin/setup CHANGED
File without changes
@@ -0,0 +1,173 @@
1
+ require "httpx"
2
+ require "json"
3
+
4
+ # This wraps the API described here:
5
+ # https://sandbox.zatca.gov.sa/IntegrationSandbox
6
+ class ZATCA::Client
7
+ # API URLs are not present in developer portal, they can only be found in a PDF
8
+ # called Fatoora Portal User Manual, here:
9
+ # https://zatca.gov.sa/en/E-Invoicing/Introduction/Guidelines/Documents/Fatoora%20portal%20user%20manual.pdf
10
+ PRODUCTION_BASE_URL = "https://gw-fatoora.zatca.gov.sa/e-invoicing/core".freeze
11
+ SANDBOX_BASE_URL = "https://gw-apic-gov.gazt.gov.sa/e-invoicing/developer-portal".freeze
12
+ SIMULATION_BASE_URL = "https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation".freeze
13
+
14
+ ENVIRONMENTS_TO_URLS_MAP = {
15
+ production: PRODUCTION_BASE_URL,
16
+ sandbox: SANDBOX_BASE_URL,
17
+ simulation: SIMULATION_BASE_URL
18
+ }.freeze
19
+
20
+ DEFAULT_API_VERSION = "V2".freeze
21
+ LANGUAGES = %w[ar en].freeze
22
+
23
+ def initialize(username:, password:, language: "ar", version: DEFAULT_API_VERSION, environment: :production)
24
+ raise "Invalid language: #{language}, Please use one of: #{LANGUAGES}" unless LANGUAGES.include?(language)
25
+
26
+ @username = username
27
+ @password = password
28
+ @language = language
29
+ @version = version
30
+
31
+ @base_url = ENVIRONMENTS_TO_URLS_MAP[environment.to_sym] || PRODUCTION_BASE_URL
32
+ end
33
+
34
+ # Reporting API
35
+ def report_invoice(uuid:, invoice_hash:, invoice:, cleared:)
36
+ request(
37
+ path: "invoices/reporting/single",
38
+ method: :post,
39
+ body: {
40
+ uuid: uuid,
41
+ invoiceHash: invoice_hash,
42
+ invoice: invoice
43
+ },
44
+ headers: {
45
+ "Clearance-Status" => cleared ? "1" : "0"
46
+ }
47
+ )
48
+ end
49
+
50
+ # Clearance API
51
+ def clear_invoice(uuid:, invoice_hash:, invoice:, cleared:)
52
+ request(
53
+ path: "invoices/clearance/single",
54
+ method: :post,
55
+ body: {
56
+ uuid: uuid,
57
+ invoiceHash: invoice_hash,
58
+ invoice: invoice
59
+ },
60
+ headers: {
61
+ "Clearance-Status" => cleared ? "1" : "0"
62
+ }
63
+ )
64
+ end
65
+
66
+ # Compliance CSID API
67
+ # This should be used to obtain credentials to issue a certificate in the next
68
+ # request (issue_production_csid)
69
+ #
70
+ # csid stands for Cryptographic Stamp Identifier
71
+ #
72
+ # csr stands for Certificate Signing Request
73
+ # You should generate this via the ZATCA::Signing::CSR class
74
+ #
75
+ # otp stands for One Time Password.
76
+ # You can get this from the fatoora portal
77
+ # Returns:
78
+ # {
79
+ # "binarySecurityToken": "string" # To be used as a username in next request
80
+ # "secret": "string" # To be used as a password in next request
81
+ # }
82
+ def issue_csid(csr:, otp:)
83
+ request(
84
+ path: "compliance",
85
+ method: :post,
86
+ body: {csr: csr},
87
+ headers: {"OTP" => otp},
88
+ authenticated: false
89
+ )
90
+ end
91
+
92
+ # Compliance Invoice API
93
+ def compliance_check(uuid:, invoice_hash:, invoice:)
94
+ request(
95
+ path: "compliance/invoices",
96
+ method: :post,
97
+ body: {
98
+ uuid: uuid,
99
+ invoiceHash: invoice_hash,
100
+ invoice: invoice
101
+ }
102
+ )
103
+ end
104
+
105
+ # Production CSID (Onboarding) API
106
+ # This endpoint gives you the Base64-encoded certificate back
107
+ # compliance_request_id is retrieved from the issue_csid request, and is
108
+ # in the response as responseID
109
+ def issue_production_csid(compliance_request_id:)
110
+ request(
111
+ path: "production/csids",
112
+ method: :post,
113
+ body: {compliance_request_id: compliance_request_id}
114
+ )
115
+ end
116
+
117
+ # Production CSID (Renewal) API
118
+ # csr stands for Certificate Signing Request
119
+ # otp stands for One Time Password
120
+ def renew_production_csid(otp:, csr:)
121
+ request(
122
+ path: "production/csids",
123
+ method: :patch,
124
+ body: {csr: csr},
125
+ headers: {"OTP" => otp}
126
+ )
127
+ end
128
+
129
+ private
130
+
131
+ def request(method:, path:, body: {}, headers: {}, authenticated: true)
132
+ url = "#{@base_url}/#{path}"
133
+ headers = default_headers.merge(headers)
134
+
135
+ client = if authenticated
136
+ authenticated_request_cilent
137
+ else
138
+ unauthenticated_request_client
139
+ end
140
+
141
+ response = client.send(method, url, json: body, headers: headers)
142
+
143
+ response_body = response.body.to_s
144
+
145
+ if response.headers["Content-Type"] == "application/json"
146
+ parse_json_or_return_string(response_body)
147
+ else
148
+ response_body
149
+ end
150
+ end
151
+
152
+ def authenticated_request_cilent
153
+ HTTPX.plugin(:basic_authentication).basic_auth(@username, @password)
154
+ end
155
+
156
+ def unauthenticated_request_client
157
+ HTTPX
158
+ end
159
+
160
+ def default_headers
161
+ {
162
+ "Accept-Language" => @language,
163
+ "Content-Type" => "application/json",
164
+ "Accept-Version" => @version
165
+ }
166
+ end
167
+
168
+ def parse_json_or_return_string(json)
169
+ JSON.parse(json)
170
+ rescue JSON::ParserError
171
+ json
172
+ end
173
+ end
@@ -0,0 +1,45 @@
1
+ module ZATCA::Hacks
2
+ extend self
3
+
4
+ # rubocop:disable Layout/HeredocIndentation
5
+ # rubocop:disable Layout/ClosingHeredocIndentation
6
+ # ZATCA also hashes serverside to ensure our signed properties hash is correct.
7
+ # However ZATCA does not format the XML to use the same whitespace needed for
8
+ # hashing. They generate the hash using the whitespace as you sent it, so to
9
+ # account for that we need to ensure we use the same exact whitespace as them.
10
+ #
11
+ # Due to the way our SDK works, we will sadly not be able to use the same
12
+ # generated XML, we need to use ZATCA's specific spacing.
13
+ # So we will generate the entire XML first then replace the qualifying
14
+ # properties block to account for this.
15
+ def zatca_indented_qualifying_properties(signing_time:, cert_digest_value:, cert_issuer_name:, cert_serial_number:)
16
+ <<-XML.chomp
17
+ <xades:QualifyingProperties xmlns:xades="http://uri.etsi.org/01903/v1.3.2#" Target="signature">
18
+ <xades:SignedProperties Id="xadesSignedProperties">
19
+ <xades:SignedSignatureProperties>
20
+ <xades:SigningTime>#{signing_time}</xades:SigningTime>
21
+ <xades:SigningCertificate>
22
+ <xades:Cert>
23
+ <xades:CertDigest>
24
+ <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
25
+ <ds:DigestValue>#{cert_digest_value}</ds:DigestValue>
26
+ </xades:CertDigest>
27
+ <xades:IssuerSerial>
28
+ <ds:X509IssuerName>#{cert_issuer_name}</ds:X509IssuerName>
29
+ <ds:X509SerialNumber>#{cert_serial_number}</ds:X509SerialNumber>
30
+ </xades:IssuerSerial>
31
+ </xades:Cert>
32
+ </xades:SigningCertificate>
33
+ </xades:SignedSignatureProperties>
34
+ </xades:SignedProperties>
35
+ </xades:QualifyingProperties>
36
+ XML
37
+ end
38
+
39
+ # rubocop:enable Layout/HeredocIndentation
40
+ # rubocop:enable Layout/ClosingHeredocIndentation
41
+
42
+ def qualifying_properties_regex
43
+ /[ ]*<xades:QualifyingProperties.*?<\/xades:QualifyingProperties>/m
44
+ end
45
+ end
@@ -0,0 +1,18 @@
1
+ class ZATCA::Hashing
2
+ # Returns the content as:
3
+ # - hash - SHA256 digest (bytes)
4
+ # - hexdigest - SHA256 digest (hex)
5
+ # - base64 - SHA256 digest (bytes) then Base64 encoded
6
+ # - hexdigest_base64 - SHA256 digest (hex) then Base64 encoded
7
+ def self.generate_hashes(content)
8
+ sha256 = Digest::SHA256.digest(content)
9
+ sha256_hex = Digest::SHA256.hexdigest(content)
10
+
11
+ {
12
+ base64: Base64.strict_encode64(sha256),
13
+ hexdigest_base64: Base64.strict_encode64(sha256_hex),
14
+ hexdigest: sha256_hex,
15
+ hash: sha256
16
+ }
17
+ end
18
+ end
@@ -0,0 +1,31 @@
1
+ require "nokogiri"
2
+ require "base64"
3
+
4
+ class ZATCA::QRCodeExtractor
5
+ attr_reader :invoice_base64
6
+
7
+ def initialize(invoice_base64:)
8
+ @invoice_base64 = invoice_base64
9
+ end
10
+
11
+ def extract
12
+ xml_invoice = Base64.strict_decode64(invoice_base64)
13
+ extract_qr_code_base64_from_xml(xml_invoice)
14
+ end
15
+
16
+ private
17
+
18
+ def extract_qr_code_base64_from_xml(xml)
19
+ # Read Invoice
20
+ doc = Nokogiri::XML(xml)
21
+
22
+ # Extract QR Code by XPath
23
+ qr_code_node = doc.xpath(qr_code_xpath)&.first
24
+
25
+ qr_code_node.present? ? qr_code_node.text : nil
26
+ end
27
+
28
+ def qr_code_xpath
29
+ "//cac:AdditionalDocumentReference[cbc:ID='QR']/cac:Attachment/cbc:EmbeddedDocumentBinaryObject"
30
+ end
31
+ end
@@ -2,8 +2,9 @@ require "rqrcode"
2
2
 
3
3
  module ZATCA
4
4
  class QRCodeGenerator
5
- def initialize(tags)
5
+ def initialize(tags: nil, base64: nil)
6
6
  @tags = tags
7
+ @base64 = base64
7
8
  end
8
9
 
9
10
  def render(size: 256)
@@ -15,7 +16,13 @@ module ZATCA
15
16
  private
16
17
 
17
18
  def generate
18
- RQRCode::QRCode.new(@tags.to_base64)
19
+ if @tags.present?
20
+ RQRCode::QRCode.new(@tags.to_base64)
21
+ elsif @base64.present?
22
+ RQRCode::QRCode.new(@base64)
23
+ else
24
+ raise ArgumentError, "Either tags or base64 must be provided"
25
+ end
19
26
  end
20
27
  end
21
28
  end
@@ -0,0 +1,78 @@
1
+ class ZATCA::Signing::Certificate
2
+ attr_accessor :serial_number, :issuer_name, :cert_content_without_headers,
3
+ :hash, :public_key, :public_key_without_headers, :signature,
4
+ :public_key_bytes
5
+
6
+ # Returns the certificate hashed with SHA256 then Base64 encoded
7
+ def self.generate_base64_hash(base64_certificate)
8
+ ZATCA::Hashing.generate_hashes(base64_certificate)[:hexdigest_base64]
9
+ end
10
+
11
+ def self.read_certificate(certificate_path)
12
+ certificate = OpenSSL::X509::Certificate.new(File.read(certificate_path))
13
+
14
+ new(openssl_certificate: certificate)
15
+ end
16
+
17
+ def initialize(openssl_certificate:)
18
+ super()
19
+
20
+ @serial_number = nil
21
+ @issuer_name = nil
22
+ @cert_content_without_headers = nil
23
+ @hash = nil
24
+ @public_key = nil
25
+ @public_key_without_headers = nil
26
+ @public_key_bytes = nil
27
+ @signature = nil
28
+
29
+ @openssl_certificate = openssl_certificate
30
+
31
+ parse_certificate
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :openssl_certificate
37
+
38
+ def parse_certificate
39
+ @cert_content_without_headers = openssl_certificate
40
+ .to_pem
41
+ .gsub("-----BEGIN CERTIFICATE-----", "")
42
+ .gsub("-----END CERTIFICATE-----", "")
43
+ .delete("\n")
44
+
45
+ @hash = self.class.generate_base64_hash(cert_content_without_headers)
46
+
47
+ # ZATCA expects the issuer name to have spaces after commas, the issue name
48
+ # looks like "CN=TSZEINVOICE-SubCA-1,DC=extgazt,DC=gov,DC=local"
49
+ # but ZATCA wants it to be "CN=TSZEINVOICE-SubCA-1, DC=extgazt, DC=gov, DC=local"
50
+ @issuer_name = openssl_certificate.issuer.to_utf8.gsub(",", ", ")
51
+
52
+ @serial_number = openssl_certificate.serial.to_s
53
+ @cert_content_without_headers = cert_content_without_headers
54
+ @public_key = openssl_certificate.public_key.to_pem
55
+ @public_key_without_headers = @public_key
56
+ .gsub("-----BEGIN PUBLIC KEY-----", "")
57
+ .gsub("-----END PUBLIC KEY-----", "")
58
+ .delete("\n")
59
+
60
+ @public_key_bytes = parse_public_key_bytes
61
+
62
+ parse_signature
63
+ end
64
+
65
+ def parse_public_key_bytes
66
+ openssl_certificate.public_key.to_der
67
+ end
68
+
69
+ def parse_signature
70
+ der = openssl_certificate.to_der
71
+ asn1 = OpenSSL::ASN1.decode(der)
72
+
73
+ # The last element of the ASN1 structure is always the signature
74
+ # The signature would look like so:
75
+ # "0F\x02!\x00\xEEa\xD3\xEB(<\xE6;P\x19jw3\xBBOO\xB2d\xDB\xEC\xEC\xBDQ\xC6\xB3v\xD4\xE5\x9E\xD8\x13\xAF\x02!\x00\xFA\xD1\xE6\xD0jf#b\xF7^nqc5\xFCx_\x87h\xA7\xB2\xEC\x10\x11B5+\vcB\x05i"
76
+ @signature = asn1.value[-1].value
77
+ end
78
+ end
@@ -0,0 +1,220 @@
1
+ class ZATCA::Signing::CSR
2
+ attr_reader :key, :csr_options, :mode
3
+
4
+ # For security purposes, please provide your own private key.
5
+ # If you don't provide one, a new unsecure one will be generated for testing purposes.
6
+ def initialize(
7
+ csr_options:,
8
+ mode: :production,
9
+ private_key_path: nil,
10
+ private_key_password: nil
11
+ )
12
+ @csr_options = default_csr_options.merge(csr_options)
13
+ @mode = mode.to_sym
14
+ @private_key_path = private_key_path
15
+ @private_key_password = private_key_password
16
+ @generated_private_key_path = nil
17
+ end
18
+
19
+ # Returns a hash with two keys
20
+ # - csr
21
+ # - csr_without_headers
22
+ def generate(csr_options: {})
23
+ set_key
24
+ write_csr_config
25
+
26
+ command = generate_openssl_csr_command
27
+
28
+ # Run the command and return the output
29
+ output = `#{command}`
30
+
31
+ cleanup_leftover_files
32
+
33
+ output
34
+
35
+ # TODO: Ruby version
36
+ # request = OpenSSL::X509::Request.new
37
+ # request.version = 0
38
+ # request.subject = OpenSSL::X509::Name.new([
39
+ # ["C", csr_options[:country], OpenSSL::ASN1::PRINTABLESTRING],
40
+ # ["O", csr_options[:organization], OpenSSL::ASN1::UTF8STRING],
41
+ # ["OU", csr_options[:organization_unit], OpenSSL::ASN1::UTF8STRING],
42
+ # ["CN", csr_options[:common_name], OpenSSL::ASN1::UTF8STRING]
43
+ # ])
44
+
45
+ # extensions = [
46
+ # OpenSSL::X509::ExtensionFactory.new.create_extension("subjectAltName")
47
+ # ]
48
+
49
+ # # add SAN extension to the CSR
50
+ # attribute_values = OpenSSL::ASN1::Set [OpenSSL::ASN1::Sequence(extensions)]
51
+ # [
52
+ # OpenSSL::X509::Attribute.new("SN", attribute_values),
53
+ # OpenSSL::X509::Attribute.new("UID", attribute_values),
54
+ # OpenSSL::X509::Attribute.new("title", attribute_values),
55
+ # OpenSSL::X509::Attribute.new("registeredAddress", attribute_values),
56
+ # OpenSSL::X509::Attribute.new("businessCategory", attribute_values)
57
+ # ]
58
+
59
+ # attribute_values.each do |attribute|
60
+ # request.add_attribute attribute
61
+ # end
62
+
63
+ # request.public_key = public_key
64
+ # csr = request.sign(key, OpenSSL::Digest.new("SHA256"))
65
+ # csr_pem = csr.to_pem
66
+ # csr_without_headers = csr_pem
67
+ # .to_s
68
+ # .gsub("-----BEGIN CERTIFICATE REQUEST-----", "")
69
+ # .gsub("-----END CERTIFICATE REQUEST-----", "")
70
+ # .delete("\n")
71
+
72
+ # {
73
+ # csr: csr_pem,
74
+ # csr_without_headers: csr_without_headers
75
+ # }
76
+ end
77
+
78
+ private
79
+
80
+ def default_csr_options
81
+ {
82
+ common_name: "",
83
+ organization_identifier: "",
84
+ organization_name: "",
85
+ organization_unit: "",
86
+ country: "SA",
87
+ invoice_type: "1100",
88
+ address: "",
89
+ business_category: "",
90
+ egs_solution_name: "",
91
+ egs_model: "",
92
+ egs_serial_number: ""
93
+ }
94
+ end
95
+
96
+ def set_key
97
+ if private_key_provided? && @key.blank?
98
+ @key = OpenSSL::PKey::EC.new(
99
+ File.read(@private_key_path),
100
+ @private_key_password
101
+ )
102
+ else
103
+ generate_key
104
+ end
105
+ end
106
+
107
+ def generated_key?
108
+ @key.present?
109
+ end
110
+
111
+ def private_key_provided?
112
+ @private_key_path.present?
113
+ end
114
+
115
+ def generate_key
116
+ return if private_key_provided?
117
+
118
+ temp_key = OpenSSL::PKey::EC.new("secp256k1").generate_key
119
+ @generated_private_key_path = "./#{SecureRandom.uuid}.pem"
120
+ @key = temp_key
121
+
122
+ File.write(@generated_private_key_path, @key.to_pem)
123
+ end
124
+
125
+ def delete_generated_key
126
+ File.delete(@generated_private_key_path) if @generated_private_key_path.present?
127
+ end
128
+
129
+ def cert_environment
130
+ case mode
131
+ when :production
132
+ "ZATCA-Code-Signing"
133
+ when :simulation
134
+ "PREZATCA-Code-Signing"
135
+ when :sandbox
136
+ "TSTZATCA-Code-Signing"
137
+ end
138
+ end
139
+
140
+ def generate_openssl_csr_command
141
+ "openssl req -new -sha256 -key #{@private_key_path || @generated_private_key_path} -config #{@csr_config_path}"
142
+ end
143
+
144
+ def write_csr_config
145
+ @csr_config_path = "./#{SecureRandom.uuid}.conf"
146
+ File.write(@csr_config_path, generate_csr_config)
147
+ end
148
+
149
+ def cleanup_leftover_files
150
+ delete_generated_key
151
+ delete_csr_config_file
152
+ end
153
+
154
+ def delete_csr_config_file
155
+ File.delete(@csr_config_path) if @csr_config_path.present?
156
+ end
157
+
158
+ def egs_serial_number
159
+ "1-#{csr_options[:egs_solution_name]}|2-#{csr_options[:egs_model]}|3-#{csr_options[:egs_serial_number]}"
160
+ end
161
+
162
+ # Adapted from:
163
+ # https://github.com/wes4m/zatca-xml-js/blob/main/src/zatca/templates/csr_template.ts
164
+ def generate_csr_config
165
+ <<~TEMPLATE
166
+ # ------------------------------------------------------------------
167
+ # Default section for "req" command csr_options
168
+ # ------------------------------------------------------------------
169
+ [req]
170
+
171
+ # Password for reading in existing private key file
172
+ # input_password = todo_private_key_password
173
+
174
+ # Prompt for DN field values and CSR attributes in ASCII
175
+ prompt = no
176
+ utf8 = no
177
+
178
+ # Section pointer for DN field csr_options
179
+ distinguished_name = my_req_dn_prompt
180
+
181
+ # Extensions
182
+ req_extensions = v3_req
183
+
184
+ [ v3_req ]
185
+ #basicConstraints=CA:FALSE
186
+ #keyUsage = digitalSignature, keyEncipherment
187
+ # Production or Testing Template (TSTZATCA-Code-Signing - ZATCA-Code-Signing)
188
+ 1.3.6.1.4.1.311.20.2 = ASN1:PRINTABLESTRING:#{cert_environment}
189
+ subjectAltName=dirName:dir_sect
190
+
191
+ [ dir_sect ]
192
+ # EGS Serial number (1-SolutionName|2-ModelOrVersion|3-serialNumber)
193
+ SN = #{egs_serial_number}
194
+ # VAT Registration number of TaxPayer (Organization identifier [15 digits begins with 3 and ends with 3])
195
+ UID = #{csr_options[:organization_identifier]}
196
+ # Invoice type (TSCZ)(1 = supported, 0 not supported) (Tax, Simplified, future use, future use)
197
+ title = #{csr_options[:invoice_type]}
198
+ # Location (branch address or website)
199
+ registeredAddress = #{csr_options[:address]}
200
+ # Industry (industry sector name)
201
+ businessCategory = #{csr_options[:business_category]}
202
+
203
+ # ------------------------------------------------------------------
204
+ # Section for prompting DN field values to create "subject"
205
+ # ------------------------------------------------------------------
206
+ [my_req_dn_prompt]
207
+ # Common name (EGS TaxPayer PROVIDED ID [FREE TEXT])
208
+ commonName = #{csr_options[:common_name]}
209
+
210
+ # Organization Unit (Branch name)
211
+ organizationalUnitName = #{csr_options[:organization_unit]}
212
+
213
+ # Organization name (Tax payer name)
214
+ organizationName = #{csr_options[:organization_name]}
215
+
216
+ # ISO2 country code is required with US as default
217
+ countryName = #{csr_options[:country]}
218
+ TEMPLATE
219
+ end
220
+ end