zatca-sdk 1.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.rspec +2 -0
- data/Gemfile +8 -0
- data/LICENSE +21 -0
- data/README.md +60 -0
- data/Rakefile +4 -0
- data/bin/console +15 -0
- data/bin/setup +8 -0
- data/lib/zatca/client.rb +211 -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 +28 -0
- 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 +44 -0
- data/lib/zatca/tags.rb +46 -0
- data/lib/zatca/tags_schema.rb +22 -0
- 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 +5 -0
- data/lib/zatca.rb +48 -0
- data/zatca_sdk.gemspec +52 -0
- metadata +301 -0
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA256:
|
3
|
+
metadata.gz: fdfd39a00f730f2ea0756c860af60ed558c3ca6b85d232cfa2aeb8dfddd82de3
|
4
|
+
data.tar.gz: 232904047e0134193d9d7af053eb541274088fa2de5e990e3053e3c804a5fbf9
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: 40979739078ed6468c27df72ad6814a89b96f40595e9df89862a8d08d11515656afcab048922e55f666a14fc5d8f3202ecd5c42d5d62d64c04ed3cbbab4e848e
|
7
|
+
data.tar.gz: 7774f40179b60921c575d081a9b633ef7fddea630b61a827d89a2bb75c9c8b4a1be997508ab65345aa43afcc924091721774facbb07139c286e9ecb3481b4c46
|
data/.rspec
ADDED
data/Gemfile
ADDED
data/LICENSE
ADDED
@@ -0,0 +1,21 @@
|
|
1
|
+
MIT License
|
2
|
+
|
3
|
+
Copyright (c) 2021 Mrsool
|
4
|
+
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
7
|
+
in the Software without restriction, including without limitation the rights
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
10
|
+
furnished to do so, subject to the following conditions:
|
11
|
+
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
13
|
+
copies or substantial portions of the Software.
|
14
|
+
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
@@ -0,0 +1,60 @@
|
|
1
|
+
# zatca
|
2
|
+
![](https://img.shields.io/gem/v/zatca) ![](https://img.shields.io/github/actions/workflow/status/obahareth/zatca/test.yml?branch=main)
|
3
|
+
|
4
|
+
A Ruby library for generating QR Codes and e-invoices according to the standard created by ZATCA in Saudi Arabia.
|
5
|
+
|
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/obahareth/zatca/issues/new) you find.
|
7
|
+
|
8
|
+
# Installation
|
9
|
+
|
10
|
+
## Rubygems
|
11
|
+
```sh
|
12
|
+
gem install zatca
|
13
|
+
```
|
14
|
+
|
15
|
+
## Bundler
|
16
|
+
```sh
|
17
|
+
bundle add zatca
|
18
|
+
```
|
19
|
+
|
20
|
+
# Usage
|
21
|
+
|
22
|
+
## Phase 1
|
23
|
+
```rb
|
24
|
+
require "zatca"
|
25
|
+
|
26
|
+
tags = {
|
27
|
+
seller_name: "Mrsool",
|
28
|
+
vat_registration_number: "310228833400003",
|
29
|
+
timestamp: "2021-10-20T19:29:32+03:00",
|
30
|
+
vat_total: "15",
|
31
|
+
invoice_total: "115",
|
32
|
+
}
|
33
|
+
|
34
|
+
ZATCA.render_qr_code(tags: tags)
|
35
|
+
# => data:image/png;base64,...
|
36
|
+
# Hint (Try pasting the above into your web browser's address bar)
|
37
|
+
```
|
38
|
+
|
39
|
+
If you'd like to customize the size of the QR Code you can manually use the generator like so:
|
40
|
+
|
41
|
+
```rb
|
42
|
+
require "zatca"
|
43
|
+
|
44
|
+
tags = ZATCA::Tags.new({
|
45
|
+
seller_name: "Mrsool",
|
46
|
+
vat_registration_number: "310228833400003",
|
47
|
+
timestamp: "2021-10-20T19:29:32+03:00",
|
48
|
+
vat_total: "15",
|
49
|
+
invoice_total: "115",
|
50
|
+
})
|
51
|
+
|
52
|
+
generator = ZATCA::QRCodeGenerator.new(tags: tags)
|
53
|
+
generator.render(size: 512)
|
54
|
+
```
|
55
|
+
|
56
|
+
## Phase 2
|
57
|
+
Documentation lives in the [wiki](https://github.com/obahareth/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.
|
data/Rakefile
ADDED
data/bin/console
ADDED
@@ -0,0 +1,15 @@
|
|
1
|
+
#!/usr/bin/env ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
require "bundler/setup"
|
5
|
+
require_relative "../lib/zatca.rb"
|
6
|
+
|
7
|
+
# You can add fixtures and/or initialization code here to make experimenting
|
8
|
+
# with your gem easier. You can also use a different console, if you like.
|
9
|
+
|
10
|
+
# (If you use this, don't forget to add pry to your Gemfile!)
|
11
|
+
# require "pry"
|
12
|
+
# Pry.start
|
13
|
+
|
14
|
+
require "irb"
|
15
|
+
IRB.start(__FILE__)
|
data/bin/setup
ADDED
data/lib/zatca/client.rb
ADDED
@@ -0,0 +1,211 @@
|
|
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
|
+
attr_accessor :before_submitting_request, :before_parsing_response
|
8
|
+
|
9
|
+
# API URLs are not present in developer portal, they can only be found in a PDF
|
10
|
+
# called Fatoora Portal User Manual, here:
|
11
|
+
# https://zatca.gov.sa/en/E-Invoicing/Introduction/Guidelines/Documents/Fatoora%20portal%20user%20manual.pdf
|
12
|
+
PRODUCTION_BASE_URL = "https://gw-fatoora.zatca.gov.sa/e-invoicing/core".freeze
|
13
|
+
SANDBOX_BASE_URL = "https://gw-apic-gov.gazt.gov.sa/e-invoicing/developer-portal".freeze
|
14
|
+
SIMULATION_BASE_URL = "https://gw-fatoora.zatca.gov.sa/e-invoicing/simulation".freeze
|
15
|
+
|
16
|
+
ENVIRONMENTS_TO_URLS_MAP = {
|
17
|
+
production: PRODUCTION_BASE_URL,
|
18
|
+
sandbox: SANDBOX_BASE_URL,
|
19
|
+
simulation: SIMULATION_BASE_URL
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
DEFAULT_API_VERSION = "V2".freeze
|
23
|
+
LANGUAGES = %w[ar en].freeze
|
24
|
+
|
25
|
+
def initialize(
|
26
|
+
username:,
|
27
|
+
password:,
|
28
|
+
language: "ar",
|
29
|
+
version: DEFAULT_API_VERSION,
|
30
|
+
environment: :production,
|
31
|
+
verbose: false,
|
32
|
+
before_submitting_request: nil,
|
33
|
+
before_parsing_response: nil
|
34
|
+
)
|
35
|
+
raise "Invalid language: #{language}, Please use one of: #{LANGUAGES}" unless LANGUAGES.include?(language)
|
36
|
+
|
37
|
+
@username = username
|
38
|
+
@password = password
|
39
|
+
@language = language
|
40
|
+
@version = version
|
41
|
+
@verbose = verbose
|
42
|
+
|
43
|
+
@base_url = ENVIRONMENTS_TO_URLS_MAP[environment.to_sym] || PRODUCTION_BASE_URL
|
44
|
+
|
45
|
+
@before_submitting_request = before_submitting_request
|
46
|
+
@before_parsing_response = before_parsing_response
|
47
|
+
end
|
48
|
+
|
49
|
+
# Reporting API
|
50
|
+
def report_invoice(uuid:, invoice_hash:, invoice:, cleared:)
|
51
|
+
request(
|
52
|
+
path: "invoices/reporting/single",
|
53
|
+
method: :post,
|
54
|
+
body: {
|
55
|
+
uuid: uuid,
|
56
|
+
invoiceHash: invoice_hash,
|
57
|
+
invoice: invoice
|
58
|
+
},
|
59
|
+
headers: {
|
60
|
+
"Clearance-Status" => cleared ? "1" : "0"
|
61
|
+
}
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
# Clearance API
|
66
|
+
def clear_invoice(uuid:, invoice_hash:, invoice:, cleared:)
|
67
|
+
request(
|
68
|
+
path: "invoices/clearance/single",
|
69
|
+
method: :post,
|
70
|
+
body: {
|
71
|
+
uuid: uuid,
|
72
|
+
invoiceHash: invoice_hash,
|
73
|
+
invoice: invoice
|
74
|
+
},
|
75
|
+
headers: {
|
76
|
+
"Clearance-Status" => cleared ? "1" : "0"
|
77
|
+
}
|
78
|
+
)
|
79
|
+
end
|
80
|
+
|
81
|
+
# Compliance CSID API
|
82
|
+
# This should be used to obtain credentials to issue a certificate in the next
|
83
|
+
# request (issue_production_csid)
|
84
|
+
#
|
85
|
+
# csid stands for Cryptographic Stamp Identifier
|
86
|
+
#
|
87
|
+
# csr stands for Certificate Signing Request
|
88
|
+
# You should generate this via the ZATCA::Signing::CSR class
|
89
|
+
#
|
90
|
+
# otp stands for One Time Password.
|
91
|
+
# You can get this from the fatoora portal
|
92
|
+
# Returns:
|
93
|
+
# {
|
94
|
+
# "binarySecurityToken": "string" # To be used as a username in next request
|
95
|
+
# "secret": "string" # To be used as a password in next request
|
96
|
+
# }
|
97
|
+
def issue_csid(csr:, otp:)
|
98
|
+
request(
|
99
|
+
path: "compliance",
|
100
|
+
method: :post,
|
101
|
+
body: {csr: csr},
|
102
|
+
headers: {"OTP" => otp},
|
103
|
+
authenticated: false
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Compliance Invoice API
|
108
|
+
def compliance_check(uuid:, invoice_hash:, invoice:)
|
109
|
+
request(
|
110
|
+
path: "compliance/invoices",
|
111
|
+
method: :post,
|
112
|
+
body: {
|
113
|
+
uuid: uuid,
|
114
|
+
invoiceHash: invoice_hash,
|
115
|
+
invoice: invoice
|
116
|
+
}
|
117
|
+
)
|
118
|
+
end
|
119
|
+
|
120
|
+
# Production CSID (Onboarding) API
|
121
|
+
# This endpoint gives you the Base64-encoded certificate back
|
122
|
+
# compliance_request_id is retrieved from the issue_csid request, and is
|
123
|
+
# in the response as responseID
|
124
|
+
def issue_production_csid(compliance_request_id:)
|
125
|
+
request(
|
126
|
+
path: "production/csids",
|
127
|
+
method: :post,
|
128
|
+
body: {compliance_request_id: compliance_request_id}
|
129
|
+
)
|
130
|
+
end
|
131
|
+
|
132
|
+
# Production CSID (Renewal) API
|
133
|
+
# csr stands for Certificate Signing Request
|
134
|
+
# otp stands for One Time Password
|
135
|
+
def renew_production_csid(otp:, csr:)
|
136
|
+
request(
|
137
|
+
path: "production/csids",
|
138
|
+
method: :patch,
|
139
|
+
body: {csr: csr},
|
140
|
+
headers: {"OTP" => otp}
|
141
|
+
)
|
142
|
+
end
|
143
|
+
|
144
|
+
private
|
145
|
+
|
146
|
+
def request(method:, path:, body: {}, headers: {}, authenticated: true)
|
147
|
+
url = "#{@base_url}/#{path}"
|
148
|
+
headers = default_headers.merge(headers)
|
149
|
+
|
150
|
+
before_submitting_request&.call(method, url, body, headers)
|
151
|
+
log("Requesting #{method} #{url} with\n\nbody: #{body}\n\nheaders: #{headers}\n")
|
152
|
+
|
153
|
+
client = if authenticated
|
154
|
+
authenticated_request_cilent
|
155
|
+
else
|
156
|
+
unauthenticated_request_client
|
157
|
+
end
|
158
|
+
|
159
|
+
response = client.send(method, url, json: body, headers: headers)
|
160
|
+
before_parsing_response&.call(response)
|
161
|
+
log("Raw response: #{response}")
|
162
|
+
|
163
|
+
if response.instance_of?(HTTPX::ErrorResponse)
|
164
|
+
return {
|
165
|
+
message: response.error&.message,
|
166
|
+
details: response.to_s
|
167
|
+
}
|
168
|
+
end
|
169
|
+
|
170
|
+
response_body = response.body.to_s
|
171
|
+
|
172
|
+
parsed_body = if response.headers["Content-Type"] == "application/json"
|
173
|
+
parse_json_or_return_string(response_body)
|
174
|
+
else
|
175
|
+
response_body
|
176
|
+
end
|
177
|
+
|
178
|
+
log("Response body: #{parsed_body}")
|
179
|
+
|
180
|
+
parsed_body
|
181
|
+
end
|
182
|
+
|
183
|
+
def authenticated_request_cilent
|
184
|
+
HTTPX.plugin(:basic_authentication).basic_auth(@username, @password)
|
185
|
+
end
|
186
|
+
|
187
|
+
def unauthenticated_request_client
|
188
|
+
HTTPX
|
189
|
+
end
|
190
|
+
|
191
|
+
def default_headers
|
192
|
+
{
|
193
|
+
"Accept-Language" => @language,
|
194
|
+
"Content-Type" => "application/json",
|
195
|
+
"Accept-Version" => @version
|
196
|
+
}
|
197
|
+
end
|
198
|
+
|
199
|
+
def parse_json_or_return_string(json)
|
200
|
+
JSON.parse(json)
|
201
|
+
rescue JSON::ParserError
|
202
|
+
json
|
203
|
+
end
|
204
|
+
|
205
|
+
def log(message)
|
206
|
+
return unless @verbose
|
207
|
+
message = "\n\n---------------------\n\n#{message}\n\n"
|
208
|
+
|
209
|
+
puts message
|
210
|
+
end
|
211
|
+
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
|
@@ -0,0 +1,28 @@
|
|
1
|
+
require "rqrcode"
|
2
|
+
|
3
|
+
module ZATCA
|
4
|
+
class QRCodeGenerator
|
5
|
+
def initialize(tags: nil, base64: nil)
|
6
|
+
@tags = tags
|
7
|
+
@base64 = base64
|
8
|
+
end
|
9
|
+
|
10
|
+
def render(size: 256)
|
11
|
+
qr_code = generate
|
12
|
+
|
13
|
+
qr_code.as_png(size: size, border_modules: 2)&.to_data_url
|
14
|
+
end
|
15
|
+
|
16
|
+
private
|
17
|
+
|
18
|
+
def generate
|
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
|
26
|
+
end
|
27
|
+
end
|
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
|