zatca-sdk 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +2 -0
  3. data/Gemfile +8 -0
  4. data/LICENSE +21 -0
  5. data/README.md +60 -0
  6. data/Rakefile +4 -0
  7. data/bin/console +15 -0
  8. data/bin/setup +8 -0
  9. data/lib/zatca/client.rb +211 -0
  10. data/lib/zatca/hacks.rb +45 -0
  11. data/lib/zatca/hashing.rb +18 -0
  12. data/lib/zatca/qr_code_extractor.rb +31 -0
  13. data/lib/zatca/qr_code_generator.rb +28 -0
  14. data/lib/zatca/signing/certificate.rb +78 -0
  15. data/lib/zatca/signing/csr.rb +220 -0
  16. data/lib/zatca/signing/ecdsa.rb +59 -0
  17. data/lib/zatca/tag.rb +44 -0
  18. data/lib/zatca/tags.rb +46 -0
  19. data/lib/zatca/tags_schema.rb +22 -0
  20. data/lib/zatca/types.rb +7 -0
  21. data/lib/zatca/ubl/base_component.rb +142 -0
  22. data/lib/zatca/ubl/builder.rb +166 -0
  23. data/lib/zatca/ubl/common_aggregate_components/allowance_charge.rb +64 -0
  24. data/lib/zatca/ubl/common_aggregate_components/classified_tax_category.rb +25 -0
  25. data/lib/zatca/ubl/common_aggregate_components/delivery.rb +27 -0
  26. data/lib/zatca/ubl/common_aggregate_components/invoice_line.rb +63 -0
  27. data/lib/zatca/ubl/common_aggregate_components/item.rb +21 -0
  28. data/lib/zatca/ubl/common_aggregate_components/legal_monetary_total.rb +59 -0
  29. data/lib/zatca/ubl/common_aggregate_components/party.rb +28 -0
  30. data/lib/zatca/ubl/common_aggregate_components/party_identification.rb +25 -0
  31. data/lib/zatca/ubl/common_aggregate_components/party_legal_entity.rb +19 -0
  32. data/lib/zatca/ubl/common_aggregate_components/party_tax_scheme.rb +30 -0
  33. data/lib/zatca/ubl/common_aggregate_components/postal_address.rb +59 -0
  34. data/lib/zatca/ubl/common_aggregate_components/price.rb +20 -0
  35. data/lib/zatca/ubl/common_aggregate_components/tax_category.rb +56 -0
  36. data/lib/zatca/ubl/common_aggregate_components/tax_total.rb +58 -0
  37. data/lib/zatca/ubl/common_aggregate_components.rb +2 -0
  38. data/lib/zatca/ubl/invoice.rb +481 -0
  39. data/lib/zatca/ubl/invoice_subtype_builder.rb +50 -0
  40. data/lib/zatca/ubl/signing/cert.rb +48 -0
  41. data/lib/zatca/ubl/signing/invoice_signed_data_reference.rb +44 -0
  42. data/lib/zatca/ubl/signing/key_info.rb +25 -0
  43. data/lib/zatca/ubl/signing/object.rb +20 -0
  44. data/lib/zatca/ubl/signing/qualifying_properties.rb +27 -0
  45. data/lib/zatca/ubl/signing/signature.rb +50 -0
  46. data/lib/zatca/ubl/signing/signature_information.rb +19 -0
  47. data/lib/zatca/ubl/signing/signature_properties_reference.rb +26 -0
  48. data/lib/zatca/ubl/signing/signed_info.rb +21 -0
  49. data/lib/zatca/ubl/signing/signed_properties.rb +81 -0
  50. data/lib/zatca/ubl/signing/signed_signature_properties.rb +23 -0
  51. data/lib/zatca/ubl/signing/ubl_document_signatures.rb +25 -0
  52. data/lib/zatca/ubl/signing/ubl_extension.rb +22 -0
  53. data/lib/zatca/ubl/signing/ubl_extensions.rb +17 -0
  54. data/lib/zatca/ubl/signing.rb +2 -0
  55. data/lib/zatca/ubl.rb +2 -0
  56. data/lib/zatca/version.rb +5 -0
  57. data/lib/zatca.rb +48 -0
  58. data/zatca_sdk.gemspec +52 -0
  59. metadata +301 -0
@@ -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
@@ -0,0 +1,59 @@
1
+ require "starkbank-ecdsa"
2
+
3
+ class ZATCA::Signing::ECDSA
4
+ def self.sign(content:, private_key: nil, private_key_path: nil, decode_from_base64: false)
5
+ private_key = parse_private_key(key: private_key, key_path: private_key_path, decode_from_base64: decode_from_base64)
6
+
7
+ ecdsa_signature = EllipticCurve::Ecdsa.sign(content, private_key)
8
+
9
+ {
10
+ base64: ecdsa_signature.toBase64,
11
+ bytes: ecdsa_signature.toDer
12
+ }
13
+ end
14
+
15
+ def self.add_header_blocks(key_content)
16
+ # If key is missing header blocks, add them, otherwise return it as is
17
+ header = "-----BEGIN EC PRIVATE KEY-----"
18
+ footer = "-----END EC PRIVATE KEY-----"
19
+
20
+ unless key_content.include?(header) && key_content.include?(footer)
21
+ key_content = "#{header}\n#{key_content}\n#{footer}"
22
+ end
23
+
24
+ key_content
25
+ end
26
+
27
+ def self.read_private_key_from_pem(pem)
28
+ EllipticCurve::PrivateKey.fromPem(add_header_blocks(pem))
29
+ end
30
+
31
+ # Returns a parsed private key
32
+ # If decode_from_base64 is set to true, the key will be decoded from base64
33
+ # before passing to OpenSSL to read it. # This is necessary because that's how
34
+ # ZATCA's sample key is provided.
35
+ def self.parse_private_key(key: nil, key_path: nil, decode_from_base64: false)
36
+ parsed_key = if key.is_a?(EllipticCurve::PrivateKey)
37
+ key
38
+ elsif key.is_a?(String)
39
+ key_content = if decode_from_base64
40
+ Base64.decode64(key)
41
+ else
42
+ key
43
+ end
44
+
45
+ read_private_key_from_pem(key_content)
46
+ elsif key_path.present?
47
+ key_content = File.read(key_path)
48
+ key_content = Base64.decode64(key_content) if decode_from_base64
49
+
50
+ read_private_key_from_pem(key_content)
51
+ end
52
+
53
+ if parsed_key.blank?
54
+ raise ArgumentError.new("private_key or private_key_path is required")
55
+ end
56
+
57
+ parsed_key
58
+ end
59
+ end
data/lib/zatca/tag.rb ADDED
@@ -0,0 +1,44 @@
1
+ module ZATCA
2
+ class Tag
3
+ TAG_IDS = {
4
+ seller_name: 1,
5
+ vat_registration_number: 2,
6
+ timestamp: 3,
7
+ invoice_total: 4,
8
+ vat_total: 5,
9
+ xml_invoice_hash: 6,
10
+ ecdsa_signature: 7,
11
+ ecdsa_public_key: 8,
12
+ ecdsa_stamp_signature: 9 # TODO: is this needed ?
13
+ }.freeze
14
+
15
+ PHASE_1_TAGS = [
16
+ :seller_name,
17
+ :vat_registration_number,
18
+ :timestamp,
19
+ :invoice_total,
20
+ :vat_total
21
+ ].freeze
22
+
23
+ attr_accessor :id, :key, :value
24
+
25
+ def initialize(key:, value:)
26
+ @id = TAG_IDS[key]
27
+ @key = key
28
+ @value = value.to_s
29
+ end
30
+
31
+ def to_h
32
+ {id: @id, key: @key, value: @value}
33
+ end
34
+
35
+ def should_be_utf8_encoded?
36
+ PHASE_1_TAGS.include?(key)
37
+ end
38
+
39
+ def to_tlv
40
+ tlv = @id.chr + @value.bytesize.chr + @value
41
+ tlv.force_encoding("ASCII-8BIT")
42
+ end
43
+ end
44
+ end
data/lib/zatca/tags.rb ADDED
@@ -0,0 +1,46 @@
1
+ require "base64"
2
+
3
+ module ZATCA
4
+ class Tags
5
+ def initialize(tags)
6
+ keys_to_coerce_to_string = tags.except(:timestamp).keys
7
+ stringified_tags = tags.map do |key, value|
8
+ if keys_to_coerce_to_string.include?(key)
9
+ [key, value.to_s]
10
+ else
11
+ [key, value]
12
+ end
13
+ end.to_h
14
+
15
+ schema_result = TagsSchema.call(stringified_tags)
16
+ if schema_result.failure?
17
+ raise(Error, "Parsing tags failed due to:\n#{schema_result.errors(full: true).to_h}")
18
+ end
19
+
20
+ stringified_tags[:timestamp] = tags[:timestamp].to_s
21
+
22
+ # Create tags, then sort them by ID
23
+ @tags = stringified_tags.map do |key, value|
24
+ Tag.new(key: key, value: value)
25
+ end.sort_by(&:id)
26
+ end
27
+
28
+ def [](index)
29
+ @tags[index]
30
+ end
31
+
32
+ def to_base64
33
+ Base64.strict_encode64(to_tlv)
34
+ end
35
+
36
+ # This is helpful for debugging only, for ZATCA's requirements just call `to_base64`
37
+ def to_hex_tlv
38
+ to_tlv.unpack1("H*")
39
+ end
40
+
41
+ # This is helpful for debugging only, for ZATCA's requirements just call `to_base64`
42
+ def to_tlv
43
+ @tags.map(&:to_tlv).join("")
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,22 @@
1
+ require "dry-schema"
2
+
3
+ module ZATCA
4
+ TagsSchema = Dry::Schema.Params do
5
+ required(:seller_name).filled(:string)
6
+ required(:vat_registration_number).filled(:string)
7
+
8
+ required(:timestamp).filled([:date_time, :string])
9
+
10
+ # Using strings to avoid any floating point approximations, we're not doing
11
+ # any calculations with these values, it's for display purposes only. A
12
+ # string is a good fit.
13
+ required(:invoice_total).filled(:string)
14
+ required(:vat_total).filled(:string)
15
+
16
+ # Data types required for Phase 2 by 1 January 2023
17
+ optional(:xml_invoice_hash).filled(:string)
18
+ optional(:ecdsa_signature).filled(:string)
19
+ optional(:ecdsa_public_key).filled(:string)
20
+ optional(:ecdsa_stamp_signature).filled(:string)
21
+ end
22
+ end
@@ -0,0 +1,7 @@
1
+ require "dry-types"
2
+
3
+ module ZATCA
4
+ module Types
5
+ include Dry.Types()
6
+ end
7
+ end
@@ -0,0 +1,142 @@
1
+ require "dry-initializer"
2
+ require_relative "../types"
3
+
4
+ class ZATCA::UBL::BaseComponent
5
+ extend Dry::Initializer
6
+ attr_accessor :elements, :attributes, :name, :value, :index
7
+
8
+ # def self.guard_dig(obj)
9
+ # unless obj.respond_to?(:dig)
10
+ # raise TypeError, "#{obj.class.name} does not have #dig method"
11
+ # end
12
+ # end
13
+
14
+ ArrayOfBaseComponentOrNil = ZATCA::Types::Array.of(ZATCA::Types.Instance(ZATCA::UBL::BaseComponent))
15
+ .default { [] }
16
+ .constructor { |value| value.blank? ? [] : value.compact }
17
+
18
+ option :elements,
19
+ type: ArrayOfBaseComponentOrNil,
20
+ default: proc { [] },
21
+ optional: true
22
+
23
+ option :attributes, type: ZATCA::Types::Strict::Hash, default: proc { {} }, optional: true
24
+ option :value, type: ZATCA::Types::Coercible::String, default: proc { "" }, optional: true
25
+ option :name, type: ZATCA::Types::Strict::String, default: proc { "" }, optional: true
26
+ option :index, type: ZATCA::Types::Coercible::Integer.optional, default: proc {}, optional: true
27
+
28
+ # def initialize(elements: [], attributes: {}, value: "", name: "", index: nil)
29
+ # @elements = elements
30
+ # @attributes = attributes
31
+ # @value = value
32
+ # @name = name
33
+
34
+ # # HACK: Add a nil index property to be used for cases where we need
35
+ # # sequential IDs, this list can be populated after the array is built
36
+ # @index = index
37
+ # end
38
+
39
+ # There are cases where we end up constructing elements with no content
40
+ # and we don't want to include them in the final XML.
41
+ #
42
+ # This method helps us to return nil if the element has no attributes,
43
+ # elements or value.
44
+ #
45
+ # which is then caught in the `build_xml` method (using `elements.compact`)
46
+ # and ignored.
47
+ def self.build(elements: [], attributes: {}, value: "", name: "", index: nil)
48
+ return nil if elements.blank? && attributes.blank? && value.blank?
49
+
50
+ new(elements: elements, attributes: attributes, value: value, name: name, index: index)
51
+ end
52
+
53
+ def [](name)
54
+ elements.find { |element| element.name == name }
55
+ end
56
+
57
+ def dig(key, *args)
58
+ value = self[key]
59
+ return value if args.length == 0 || value.nil?
60
+ # DigRb.guard_dig(value)
61
+ value.dig(*args)
62
+ end
63
+
64
+ # TODO: Under Active Development
65
+ def find_nested_element_by_path(path)
66
+ path_parts = path.split("/")
67
+ nested_element = self
68
+
69
+ path_parts.each_with_index do |path_part, index|
70
+ # byebug
71
+ # next_path_part = path_parts[index + 1]
72
+ # found_element = nested_element[path_part]
73
+ # found_next_path_part = found_element[next_path_part]
74
+
75
+ # element_with_next_path_part = found_element.find do |child_element|
76
+ # child_element.name == next_path_part
77
+ # end
78
+ # byebug
79
+ # nested_element.elements.each do |element|
80
+ # byebug
81
+ # next_element = element[path_part]
82
+
83
+ # if next_element && next_element[next_path_part]
84
+ # nested_element = next_element[next_path_part]
85
+ # break
86
+ # elsif next_element.present?
87
+ # nested_element = next_element
88
+ # end
89
+ # end
90
+
91
+ # nested_element = found_element if found_element.present?
92
+ end
93
+
94
+ nested_element
95
+ end
96
+
97
+ def schema
98
+ self.class.schema
99
+ end
100
+
101
+ def to_h
102
+ {
103
+ name => {
104
+ attributes: attributes,
105
+ **elements.map(&:to_h),
106
+ value: value
107
+ }
108
+ }
109
+ end
110
+
111
+ def to_xml
112
+ builder = Nokogiri::XML::Builder.new(encoding: "UTF-8") do |xml|
113
+ xml.root { build_xml(xml) }
114
+ end
115
+
116
+ builder.to_xml
117
+ end
118
+
119
+ def build_xml(xml)
120
+ xml.send(name, attributes) do
121
+ if elements.length > 0
122
+ elements.compact.each { |element| element.build_xml(xml) }
123
+ elsif value.present?
124
+ xml.text(value)
125
+ end
126
+ end
127
+ end
128
+
129
+ def generate_xml(
130
+ canonicalized: false,
131
+ spaces: 2,
132
+ apply_invoice_hacks: false,
133
+ remove_root_xml_tag: false
134
+ )
135
+ ZATCA::UBL::Builder.new(element: self).build(
136
+ canonicalized: canonicalized,
137
+ spaces: spaces,
138
+ apply_invoice_hacks: apply_invoice_hacks,
139
+ remove_root_xml_tag: remove_root_xml_tag
140
+ )
141
+ end
142
+ end