zatca 0.1.2 → 1.0.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/README.md +11 -2
- data/bin/console +0 -0
- data/bin/setup +0 -0
- data/lib/zatca/client.rb +173 -0
- data/lib/zatca/hacks.rb +45 -0
- data/lib/zatca/hashing.rb +18 -0
- data/lib/zatca/qr_code_extractor.rb +31 -0
- data/lib/zatca/qr_code_generator.rb +9 -2
- data/lib/zatca/signing/certificate.rb +78 -0
- data/lib/zatca/signing/csr.rb +220 -0
- data/lib/zatca/signing/ecdsa.rb +59 -0
- data/lib/zatca/tag.rb +18 -8
- data/lib/zatca/tags.rb +5 -1
- data/lib/zatca/tags_schema.rb +5 -5
- data/lib/zatca/types.rb +7 -0
- data/lib/zatca/ubl/base_component.rb +142 -0
- data/lib/zatca/ubl/builder.rb +166 -0
- data/lib/zatca/ubl/common_aggregate_components/allowance_charge.rb +64 -0
- data/lib/zatca/ubl/common_aggregate_components/classified_tax_category.rb +25 -0
- data/lib/zatca/ubl/common_aggregate_components/delivery.rb +27 -0
- data/lib/zatca/ubl/common_aggregate_components/invoice_line.rb +63 -0
- data/lib/zatca/ubl/common_aggregate_components/item.rb +21 -0
- data/lib/zatca/ubl/common_aggregate_components/legal_monetary_total.rb +59 -0
- data/lib/zatca/ubl/common_aggregate_components/party.rb +28 -0
- data/lib/zatca/ubl/common_aggregate_components/party_identification.rb +25 -0
- data/lib/zatca/ubl/common_aggregate_components/party_legal_entity.rb +19 -0
- data/lib/zatca/ubl/common_aggregate_components/party_tax_scheme.rb +30 -0
- data/lib/zatca/ubl/common_aggregate_components/postal_address.rb +59 -0
- data/lib/zatca/ubl/common_aggregate_components/price.rb +20 -0
- data/lib/zatca/ubl/common_aggregate_components/tax_category.rb +56 -0
- data/lib/zatca/ubl/common_aggregate_components/tax_total.rb +58 -0
- data/lib/zatca/ubl/common_aggregate_components.rb +2 -0
- data/lib/zatca/ubl/invoice.rb +481 -0
- data/lib/zatca/ubl/invoice_subtype_builder.rb +50 -0
- data/lib/zatca/ubl/signing/cert.rb +48 -0
- data/lib/zatca/ubl/signing/invoice_signed_data_reference.rb +44 -0
- data/lib/zatca/ubl/signing/key_info.rb +25 -0
- data/lib/zatca/ubl/signing/object.rb +20 -0
- data/lib/zatca/ubl/signing/qualifying_properties.rb +27 -0
- data/lib/zatca/ubl/signing/signature.rb +50 -0
- data/lib/zatca/ubl/signing/signature_information.rb +19 -0
- data/lib/zatca/ubl/signing/signature_properties_reference.rb +26 -0
- data/lib/zatca/ubl/signing/signed_info.rb +21 -0
- data/lib/zatca/ubl/signing/signed_properties.rb +81 -0
- data/lib/zatca/ubl/signing/signed_signature_properties.rb +23 -0
- data/lib/zatca/ubl/signing/ubl_document_signatures.rb +25 -0
- data/lib/zatca/ubl/signing/ubl_extension.rb +22 -0
- data/lib/zatca/ubl/signing/ubl_extensions.rb +17 -0
- data/lib/zatca/ubl/signing.rb +2 -0
- data/lib/zatca/ubl.rb +2 -0
- data/lib/zatca/version.rb +1 -1
- data/lib/zatca.rb +27 -3
- data/zatca.gemspec +52 -0
- metadata +165 -10
- data/Gemfile.lock +0 -100
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: dfcfffb30bc80671008a9f4d85bc6300f7bed524f02919a35314be134f6ced61
|
4
|
+
data.tar.gz: b5fdeac07afe47aabcb05284283a4197ddaf55262961d34dd953a7e9d74f6487
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
4
|
+
A Ruby library for generating QR Codes and e-invoices according to the standard created by ZATCA in Saudi Arabia.
|
5
5
|
|
6
|
-
|
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
|
data/lib/zatca/client.rb
ADDED
@@ -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
|
data/lib/zatca/hacks.rb
ADDED
@@ -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
|
-
|
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
|