smart-id-ruby-client 0.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 (86) hide show
  1. checksums.yaml +7 -0
  2. data/.rspec +3 -0
  3. data/.rubocop.yml +14 -0
  4. data/CHANGELOG.md +13 -0
  5. data/CODE_OF_CONDUCT.md +132 -0
  6. data/LICENSE.txt +21 -0
  7. data/README.md +436 -0
  8. data/Rakefile +12 -0
  9. data/lib/smart-id-ruby-client.rb +3 -0
  10. data/lib/smart_id_ruby/callback_url.rb +18 -0
  11. data/lib/smart_id_ruby/callback_url_util.rb +54 -0
  12. data/lib/smart_id_ruby/client.rb +124 -0
  13. data/lib/smart_id_ruby/configuration.rb +184 -0
  14. data/lib/smart_id_ruby/device_link_builder.rb +301 -0
  15. data/lib/smart_id_ruby/device_link_interaction.rb +67 -0
  16. data/lib/smart_id_ruby/errors/certificate_level_mismatch_error.rb +8 -0
  17. data/lib/smart_id_ruby/errors/document_unusable_error.rb +12 -0
  18. data/lib/smart_id_ruby/errors/error.rb +8 -0
  19. data/lib/smart_id_ruby/errors/expected_linked_session_error.rb +15 -0
  20. data/lib/smart_id_ruby/errors/no_suitable_account_of_requested_type_found_error.rb +10 -0
  21. data/lib/smart_id_ruby/errors/person_should_view_smart_id_portal_error.rb +8 -0
  22. data/lib/smart_id_ruby/errors/protocol_failure_error.rb +13 -0
  23. data/lib/smart_id_ruby/errors/relying_party_account_configuration_error.rb +10 -0
  24. data/lib/smart_id_ruby/errors/request_setup_error.rb +10 -0
  25. data/lib/smart_id_ruby/errors/request_validation_error.rb +8 -0
  26. data/lib/smart_id_ruby/errors/required_interaction_not_supported_by_app_error.rb +13 -0
  27. data/lib/smart_id_ruby/errors/response_error.rb +8 -0
  28. data/lib/smart_id_ruby/errors/server_maintenance_error.rb +8 -0
  29. data/lib/smart_id_ruby/errors/session_end_result_error.rb +15 -0
  30. data/lib/smart_id_ruby/errors/session_not_complete_error.rb +8 -0
  31. data/lib/smart_id_ruby/errors/session_not_found_error.rb +8 -0
  32. data/lib/smart_id_ruby/errors/session_secret_mismatch_error.rb +8 -0
  33. data/lib/smart_id_ruby/errors/session_timeout_error.rb +12 -0
  34. data/lib/smart_id_ruby/errors/smart_id_server_error.rb +12 -0
  35. data/lib/smart_id_ruby/errors/unprocessable_response_error.rb +9 -0
  36. data/lib/smart_id_ruby/errors/unsupported_client_api_version_error.rb +8 -0
  37. data/lib/smart_id_ruby/errors/user_account_not_found_error.rb +8 -0
  38. data/lib/smart_id_ruby/errors/user_account_unusable_error.rb +12 -0
  39. data/lib/smart_id_ruby/errors/user_refused_cert_choice_error.rb +14 -0
  40. data/lib/smart_id_ruby/errors/user_refused_confirmation_message_error.rb +13 -0
  41. data/lib/smart_id_ruby/errors/user_refused_confirmation_message_with_verification_choice_error.rb +13 -0
  42. data/lib/smart_id_ruby/errors/user_refused_display_text_and_pin_error.rb +13 -0
  43. data/lib/smart_id_ruby/errors/user_refused_error.rb +12 -0
  44. data/lib/smart_id_ruby/errors/user_selected_wrong_verification_code_error.rb +13 -0
  45. data/lib/smart_id_ruby/errors.rb +31 -0
  46. data/lib/smart_id_ruby/flows/base_builder.rb +90 -0
  47. data/lib/smart_id_ruby/flows/certificate_by_document_number_request_builder.rb +130 -0
  48. data/lib/smart_id_ruby/flows/device_link_authentication_session_request_builder.rb +208 -0
  49. data/lib/smart_id_ruby/flows/device_link_certificate_choice_session_request_builder.rb +112 -0
  50. data/lib/smart_id_ruby/flows/device_link_signature_session_request_builder.rb +286 -0
  51. data/lib/smart_id_ruby/flows/linked_notification_signature_session_request_builder.rb +235 -0
  52. data/lib/smart_id_ruby/flows/notification_authentication_session_request_builder.rb +184 -0
  53. data/lib/smart_id_ruby/flows/notification_certificate_choice_session_request_builder.rb +96 -0
  54. data/lib/smart_id_ruby/flows/notification_signature_session_request_builder.rb +272 -0
  55. data/lib/smart_id_ruby/models/authentication_identity.rb +19 -0
  56. data/lib/smart_id_ruby/models/authentication_response.rb +38 -0
  57. data/lib/smart_id_ruby/models/certificate_choice_response.rb +19 -0
  58. data/lib/smart_id_ruby/models/device_link_session_response.rb +34 -0
  59. data/lib/smart_id_ruby/models/notification_authentication_session_response.rb +25 -0
  60. data/lib/smart_id_ruby/models/notification_certificate_choice_session_response.rb +25 -0
  61. data/lib/smart_id_ruby/models/notification_signature_session_response.rb +29 -0
  62. data/lib/smart_id_ruby/models/session_status.rb +261 -0
  63. data/lib/smart_id_ruby/models/signature_response.rb +38 -0
  64. data/lib/smart_id_ruby/notification_interaction.rb +70 -0
  65. data/lib/smart_id_ruby/qr_code_generator.rb +65 -0
  66. data/lib/smart_id_ruby/rest/connector.rb +364 -0
  67. data/lib/smart_id_ruby/rest/session_status_poller.rb +125 -0
  68. data/lib/smart_id_ruby/rp_challenge.rb +37 -0
  69. data/lib/smart_id_ruby/rp_challenge_generator.rb +28 -0
  70. data/lib/smart_id_ruby/semantics_identifier.rb +35 -0
  71. data/lib/smart_id_ruby/validation/authentication_certificate_validator.rb +90 -0
  72. data/lib/smart_id_ruby/validation/authentication_identity_mapper.rb +227 -0
  73. data/lib/smart_id_ruby/validation/base_authentication_response_validator.rb +304 -0
  74. data/lib/smart_id_ruby/validation/certificate_choice_response_validator.rb +104 -0
  75. data/lib/smart_id_ruby/validation/certificate_validator.rb +170 -0
  76. data/lib/smart_id_ruby/validation/device_link_authentication_response_validator.rb +76 -0
  77. data/lib/smart_id_ruby/validation/error_result_handler.rb +88 -0
  78. data/lib/smart_id_ruby/validation/notification_authentication_response_validator.rb +16 -0
  79. data/lib/smart_id_ruby/validation/signature_payload_builder.rb +62 -0
  80. data/lib/smart_id_ruby/validation/signature_response_validator.rb +345 -0
  81. data/lib/smart_id_ruby/validation/signature_value_validator.rb +76 -0
  82. data/lib/smart_id_ruby/validation/trusted_ca_cert_store.rb +20 -0
  83. data/lib/smart_id_ruby/verification_code_calculator.rb +31 -0
  84. data/lib/smart_id_ruby/version.rb +5 -0
  85. data/lib/smart_id_ruby.rb +76 -0
  86. metadata +173 -0
@@ -0,0 +1,227 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "date"
4
+ require "openssl"
5
+
6
+ module SmartIdRuby
7
+ module Validation
8
+ # Maps authentication certificates to typed identity models.
9
+ class AuthenticationIdentityMapper
10
+ # OID for dateOfBirth attribute inside subjectDirectoryAttributes extension (id-pda-dateOfBirth).
11
+ DATE_OF_BIRTH_OID = "1.3.6.1.5.5.7.9.1"
12
+ # OID for subjectDirectoryAttributes certificate extension.
13
+ SUBJECT_DIRECTORY_ATTRIBUTES_OID = "subjectDirectoryAttributes"
14
+ # OID for X.509 Subject Directory Attributes extension (2.5.29.9).
15
+ SUBJECT_DIRECTORY_ATTRIBUTES_EXTENSION_OID = "2.5.29.9"
16
+
17
+ # Builds an {SmartIdRuby::Models::AuthenticationIdentity} from an
18
+ # X.509 authentication certificate.
19
+ #
20
+ # Extracts given name, surname, national identity number, country and
21
+ # date of birth (either from certificate extensions or by parsing the
22
+ # national identity number as a fallback).
23
+ #
24
+ # @param certificate [OpenSSL::X509::Certificate]
25
+ # @return [SmartIdRuby::Models::AuthenticationIdentity]
26
+ def from(certificate)
27
+ attrs = extract_subject_attributes(certificate)
28
+
29
+ raw_given_name = attrs["GN"] || attrs["givenName"] || attrs["GIVENNAME"]
30
+ raw_surname = attrs["SN"] || attrs["surname"] || attrs["SURNAME"]
31
+ identity_number = normalize_identity_number(attrs["serialNumber"] || attrs["SERIALNUMBER"])
32
+ country = attrs["C"]
33
+
34
+ given_name = normalize_diacritics(raw_given_name)
35
+ surname = normalize_diacritics(raw_surname)
36
+
37
+ log_debug_identity_attrs(raw_given_name, given_name, raw_surname, surname, identity_number, country)
38
+
39
+ SmartIdRuby::Models::AuthenticationIdentity.new(
40
+ given_name: given_name,
41
+ surname: surname,
42
+ identity_number: identity_number,
43
+ country: country,
44
+ auth_certificate: certificate,
45
+ date_of_birth: extract_date_of_birth_from_certificate(certificate) || fallback_date_of_birth(country, identity_number)
46
+ )
47
+ end
48
+
49
+ private
50
+
51
+ # Extracts subject DN attributes from the certificate into a simple Hash.
52
+ #
53
+ # @param certificate [OpenSSL::X509::Certificate]
54
+ # @return [Hash{String => String}]
55
+ def extract_subject_attributes(certificate)
56
+ certificate.subject.to_a.each_with_object({}) do |entry, attrs|
57
+ key, value = entry[0], entry[1]
58
+ attrs[key] = value
59
+ end
60
+ end
61
+
62
+ def normalize_identity_number(serial_number)
63
+ return nil if serial_number.nil?
64
+
65
+ serial_number.split("-", 2).last
66
+ end
67
+
68
+ def fallback_date_of_birth(country, identity_number)
69
+ return nil if blank?(country) || blank?(identity_number)
70
+
71
+ case country.to_s.upcase
72
+ when "EE", "LT"
73
+ parse_ee_lt_date_of_birth(identity_number)
74
+ when "LV"
75
+ parse_lv_date_of_birth(identity_number)
76
+ end
77
+ end
78
+
79
+ def parse_ee_lt_date_of_birth(identity_number)
80
+ first_digit = identity_number[0]
81
+ birth_date_part = identity_number[1, 6]
82
+ century = case first_digit
83
+ when "1", "2" then "18"
84
+ when "3", "4" then "19"
85
+ when "5", "6" then "20"
86
+ else
87
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
88
+ "Could not parse birthdate from nationalIdentityNumber=#{identity_number}"
89
+ end
90
+ Date.strptime("#{century}#{birth_date_part}", "%Y%m%d")
91
+ rescue ArgumentError
92
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
93
+ "Could not parse birthdate from nationalIdentityNumber=#{identity_number}"
94
+ end
95
+
96
+ def parse_lv_date_of_birth(identity_number)
97
+ birth_day = identity_number[0, 2]
98
+ return nil if birth_day.match?(/\A3[2-9]\z/)
99
+
100
+ birth_month = identity_number[2, 2]
101
+ birth_year_two_digit = identity_number[4, 2]
102
+ century_marker = identity_number[7]
103
+ century = case century_marker
104
+ when "0" then "18"
105
+ when "1" then "19"
106
+ when "2" then "20"
107
+ else
108
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Invalid personal code: #{identity_number}"
109
+ end
110
+
111
+ Date.strptime("#{century}#{birth_year_two_digit}#{birth_month}#{birth_day}", "%Y%m%d")
112
+ rescue ArgumentError
113
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
114
+ "Unable get birthdate from Latvian personal code #{identity_number}"
115
+ end
116
+
117
+ def extract_date_of_birth_from_certificate(certificate)
118
+ extension = certificate.extensions.find do |ext|
119
+ [SUBJECT_DIRECTORY_ATTRIBUTES_OID, SUBJECT_DIRECTORY_ATTRIBUTES_EXTENSION_OID].include?(ext.oid)
120
+ end
121
+ return nil if extension.nil?
122
+
123
+ generalized_time = find_date_of_birth_generalized_time(extension)
124
+ return nil if generalized_time.nil?
125
+
126
+ parse_generalized_time(generalized_time)
127
+ rescue OpenSSL::ASN1::ASN1Error, TypeError
128
+ nil
129
+ end
130
+
131
+ def find_date_of_birth_generalized_time(extension)
132
+ decoded = OpenSSL::ASN1.decode(extension.to_der)
133
+ octet_string = decoded.value.find { |node| node.is_a?(OpenSSL::ASN1::OctetString) }
134
+ return nil if octet_string.nil?
135
+
136
+ inner = OpenSSL::ASN1.decode(octet_string.value)
137
+ find_date_of_birth_in_node(inner)
138
+ end
139
+
140
+ def find_date_of_birth_in_node(node)
141
+ return nil unless node.respond_to?(:value)
142
+
143
+ value = node.value
144
+ return nil unless value.is_a?(Array)
145
+
146
+ value.each_with_index do |item, index|
147
+ if item.is_a?(OpenSSL::ASN1::ObjectId) && item.value == DATE_OF_BIRTH_OID
148
+ return extract_generalized_time(value[index + 1])
149
+ end
150
+ found = find_date_of_birth_in_node(item)
151
+ return found if found
152
+ end
153
+ nil
154
+ end
155
+
156
+ def extract_generalized_time(node)
157
+ return nil if node.nil? || !node.respond_to?(:value)
158
+
159
+ if node.is_a?(OpenSSL::ASN1::GeneralizedTime)
160
+ return node.value
161
+ end
162
+
163
+ value = node.value
164
+ return nil unless value.is_a?(Array)
165
+
166
+ value.each do |child|
167
+ found = extract_generalized_time(child)
168
+ return found if found
169
+ end
170
+ nil
171
+ end
172
+
173
+ def parse_generalized_time(value)
174
+ parsed = if value.respond_to?(:to_time)
175
+ value.to_time.utc
176
+ else
177
+ Time.strptime(value.to_s, "%Y%m%d%H%M%SZ").utc
178
+ end
179
+ parsed.to_date
180
+ rescue ArgumentError
181
+ nil
182
+ end
183
+
184
+ def blank?(value)
185
+ value.nil? || value.to_s.strip.empty?
186
+ end
187
+
188
+ # Converts legacy escaped byte sequences inside DN values to proper UTF-8.
189
+ # Example:
190
+ # "J\\xC4\\x81nis B\\xC4\\x93rzi\\xC5\\x86\\xC5\\xA1" (ASCII-8BIT)
191
+ # becomes:
192
+ # "Jānis Bērziņš" (UTF-8)
193
+ def normalize_diacritics(value)
194
+ return nil if value.nil?
195
+
196
+ text = value.to_s.dup
197
+
198
+ # Legacy certificates / libraries sometimes encode diacritics as \xNN escape
199
+ # sequences in the distinguished name string. Convert those into bytes first.
200
+ if text.include?("\\x")
201
+ text = text.gsub(/\\x([0-9A-Fa-f]{2})/) { Regexp.last_match(1).hex.chr }
202
+ end
203
+
204
+ # Then interpret as UTF-8 and scrub only truly invalid byte sequences,
205
+ # preserving valid characters like Õ, Ä, Ö, Ü, etc.
206
+ text.force_encoding(Encoding::UTF_8).scrub
207
+ end
208
+
209
+ def log_debug_identity_attrs(raw_given_name, given_name, raw_surname, surname, identity_number, country)
210
+ logger = SmartIdRuby.logger
211
+
212
+ logger.debug(
213
+ "Smart-ID identity mapping: " \
214
+ "raw_given_name=#{raw_given_name}, " \
215
+ "normalized_given_name=#{given_name}, " \
216
+ "raw_surname=#{raw_surname}, " \
217
+ "normalized_surname=#{surname}, " \
218
+ "identity_number_suffix=#{identity_number && identity_number[-4, 4]}, " \
219
+ "country=#{country}"
220
+ )
221
+ rescue StandardError
222
+ # Logging must never break authentication flow
223
+ nil
224
+ end
225
+ end
226
+ end
227
+ end
@@ -0,0 +1,304 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module SmartIdRuby
7
+ module Validation
8
+ # Shared authentication response validation logic for device-link and notification flows.
9
+ class BaseAuthenticationResponseValidator
10
+ BASE64_FORMAT_PATTERN = /\A[a-zA-Z0-9+\/]+={0,2}\z/.freeze
11
+ USER_CHALLENGE_PATTERN = /\A[a-zA-Z0-9\-_]{43}\z/.freeze
12
+ MINIMUM_SERVER_RANDOM_LENGTH = 24
13
+ SUPPORTED_FLOW_TYPES = %w[QR Web2App App2App Notification].freeze
14
+ SUPPORTED_SIGNATURE_ALGORITHM = "rsassa-pss"
15
+ SUPPORTED_HASH_ALGORITHM_OCTET_LENGTH = {
16
+ "SHA-256" => 32,
17
+ "SHA-384" => 48,
18
+ "SHA-512" => 64,
19
+ "SHA3-256" => 32,
20
+ "SHA3-384" => 48,
21
+ "SHA3-512" => 64
22
+ }.freeze
23
+ SUPPORTED_MASK_GEN_ALGORITHM = "id-mgf1"
24
+ SUPPORTED_TRAILER_FIELD = "0xbc"
25
+
26
+ def initialize(signature_value_validator: SignatureValueValidator.new,
27
+ certificate_validator: AuthenticationCertificateValidator.new,
28
+ authentication_identity_mapper: AuthenticationIdentityMapper.new)
29
+ @signature_value_validator = signature_value_validator
30
+ @certificate_validator = certificate_validator
31
+ @authentication_identity_mapper = authentication_identity_mapper
32
+ end
33
+
34
+ def validate(session_status, authentication_session_request, user_challenge_verifier = nil, schema_name = nil,
35
+ brokered_rp_name = nil)
36
+ status = normalize_status(session_status)
37
+ validate_inputs(status, authentication_session_request, schema_name)
38
+ validate_complete_state(status)
39
+ validate_result(status.result)
40
+ validate_signature_protocol(status)
41
+ validate_signature(status.signature)
42
+ validate_user_challenge(user_challenge_verifier, status.signature)
43
+ certificate = @certificate_validator.validate(
44
+ cert: status.cert,
45
+ requested_level: requested_certificate_level(authentication_session_request)
46
+ )
47
+ validate_signature_value(status, authentication_session_request, schema_name, brokered_rp_name, certificate)
48
+ validate_interaction_type(status)
49
+
50
+ @authentication_identity_mapper.from(certificate)
51
+ end
52
+
53
+ private
54
+
55
+ def normalize_status(session_status)
56
+ return session_status if session_status.is_a?(SmartIdRuby::Models::SessionStatus)
57
+ return SmartIdRuby::Models::SessionStatus.from_h(session_status) if session_status.is_a?(Hash)
58
+
59
+ nil
60
+ end
61
+
62
+ def validate_inputs(session_status, authentication_session_request, schema_name)
63
+ raise SmartIdRuby::Errors::RequestSetupError, "Parameter 'sessionStatus' is not provided" if session_status.nil?
64
+ if authentication_session_request.nil?
65
+ raise SmartIdRuby::Errors::RequestSetupError, "Parameter 'authenticationSessionRequest' is not provided"
66
+ end
67
+ return unless blank?(schema_name)
68
+
69
+ raise SmartIdRuby::Errors::RequestSetupError, "Parameter 'schemaName' is not provided"
70
+ end
71
+
72
+ def validate_complete_state(session_status)
73
+ return if session_status.complete?
74
+
75
+ raise SmartIdRuby::Errors::SessionNotCompleteError,
76
+ "Authentication session is not complete. Current state: '#{session_status.state}'"
77
+ end
78
+
79
+ def validate_result(result)
80
+ if result.nil?
81
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Authentication session status field 'result' is empty"
82
+ end
83
+ if blank?(result.end_result)
84
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Authentication session status field 'result.endResult' is empty"
85
+ end
86
+
87
+ ErrorResultHandler.handle(result) if result.end_result != "OK"
88
+ return unless blank?(result.document_number)
89
+
90
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Authentication session status field 'result.documentNumber' is empty"
91
+ end
92
+
93
+ def validate_signature_protocol(session_status)
94
+ if blank?(session_status.signature_protocol)
95
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Authentication session status field 'signatureProtocol' is empty"
96
+ end
97
+ return if session_status.signature_protocol == "ACSP_V2"
98
+
99
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
100
+ "Authentication session status field 'signatureProtocol' has unsupported value"
101
+ end
102
+
103
+ def validate_signature(signature)
104
+ if signature.nil?
105
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
106
+ "Authentication session status field 'signature' is missing"
107
+ end
108
+ validate_base64_field(signature.value, "signature.value")
109
+ validate_server_random(signature.server_random)
110
+ validate_user_challenge_format(signature.user_challenge)
111
+ validate_non_empty(signature.flow_type, "signature.flowType")
112
+ unless SUPPORTED_FLOW_TYPES.include?(signature.flow_type)
113
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
114
+ "Authentication session status field 'signature.flowType' has unsupported value"
115
+ end
116
+ validate_non_empty(signature.signature_algorithm, "signature.signatureAlgorithm")
117
+ unless signature.signature_algorithm == SUPPORTED_SIGNATURE_ALGORITHM
118
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
119
+ "Authentication session status field 'signature.signatureAlgorithm' has unsupported value"
120
+ end
121
+ validate_signature_algorithm_parameters(signature.signature_algorithm_parameters)
122
+ end
123
+
124
+ def validate_interaction_type(session_status)
125
+ return unless blank?(session_status.interaction_type_used)
126
+
127
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
128
+ "Authentication session status field 'interactionTypeUsed' is empty"
129
+ end
130
+
131
+ def validate_base64_field(value, field_name)
132
+ validate_non_empty(value, field_name)
133
+ return if BASE64_FORMAT_PATTERN.match?(value)
134
+
135
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
136
+ "Authentication session status field '#{field_name}' does not have Base64-encoded value"
137
+ end
138
+
139
+ def validate_server_random(value)
140
+ validate_non_empty(value, "signature.serverRandom")
141
+ if value.length < MINIMUM_SERVER_RANDOM_LENGTH
142
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
143
+ "Authentication session status field 'signature.serverRandom' value length is less than required"
144
+ end
145
+ validate_base64_field(value, "signature.serverRandom")
146
+ end
147
+
148
+ def validate_user_challenge_format(value)
149
+ validate_non_empty(value, "signature.userChallenge")
150
+ return if USER_CHALLENGE_PATTERN.match?(value)
151
+
152
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
153
+ "Authentication session status field 'signature.userChallenge' value does not match required pattern"
154
+ end
155
+
156
+ def validate_user_challenge(_user_challenge_verifier, _signature); end
157
+
158
+ def validate_signature_algorithm_parameters(signature_algorithm_parameters)
159
+ if signature_algorithm_parameters.nil?
160
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
161
+ "Authentication session status field 'signature.signatureAlgorithmParameters' is missing"
162
+ end
163
+
164
+ hash_algorithm = signature_algorithm_parameters.hash_algorithm
165
+ if blank?(hash_algorithm)
166
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
167
+ "Authentication session status field 'signature.signatureAlgorithmParameters.hashAlgorithm' is empty"
168
+ end
169
+ unless SUPPORTED_HASH_ALGORITHM_OCTET_LENGTH.key?(hash_algorithm)
170
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
171
+ "Authentication session status field 'signature.signatureAlgorithmParameters.hashAlgorithm' has unsupported value"
172
+ end
173
+
174
+ mask_gen_algorithm = signature_algorithm_parameters.mask_gen_algorithm
175
+ if mask_gen_algorithm.nil?
176
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
177
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm' is missing"
178
+ end
179
+ mask_gen_algorithm_value = fetch_hash_value(mask_gen_algorithm, :algorithm)
180
+ if blank?(mask_gen_algorithm_value)
181
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
182
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm.algorithm' is empty"
183
+ end
184
+ unless mask_gen_algorithm_value == SUPPORTED_MASK_GEN_ALGORITHM
185
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
186
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm' has unsupported value"
187
+ end
188
+
189
+ mask_gen_parameters = fetch_hash_value(mask_gen_algorithm, :parameters)
190
+ if mask_gen_parameters.nil?
191
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
192
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm.parameters' is missing"
193
+ end
194
+ mask_hash_algorithm = fetch_hash_value(mask_gen_parameters, :hashAlgorithm)
195
+ if blank?(mask_hash_algorithm)
196
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
197
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm.parameters.hashAlgorithm' is empty"
198
+ end
199
+ unless SUPPORTED_HASH_ALGORITHM_OCTET_LENGTH.key?(mask_hash_algorithm)
200
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
201
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm.parameters.hashAlgorithm' has unsupported value"
202
+ end
203
+ unless hash_algorithm == mask_hash_algorithm
204
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
205
+ "Authentication session status field 'signature.signatureAlgorithmParameters.maskGenAlgorithm.hashAlgorithm' value does not match 'signature.signatureAlgorithmParameters.hashAlgorithm' value"
206
+ end
207
+
208
+ salt_length = signature_algorithm_parameters.salt_length
209
+ if salt_length.nil?
210
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
211
+ "Authentication session status field 'signature.signatureAlgorithmParameters.saltLength' is empty"
212
+ end
213
+ unless salt_length == SUPPORTED_HASH_ALGORITHM_OCTET_LENGTH[hash_algorithm]
214
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
215
+ "Authentication session status field 'signature.signatureAlgorithmParameters.saltLength' has invalid value"
216
+ end
217
+
218
+ trailer_field = signature_algorithm_parameters.trailer_field
219
+ if blank?(trailer_field)
220
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
221
+ "Authentication session status field 'signature.signatureAlgorithmParameters.trailerField' is empty"
222
+ end
223
+ return if trailer_field == SUPPORTED_TRAILER_FIELD
224
+
225
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
226
+ "Authentication session status field 'signature.signatureAlgorithmParameters.trailerField' has unsupported value"
227
+ end
228
+
229
+ def validate_signature_value(session_status, authentication_session_request, schema_name, brokered_rp_name, certificate)
230
+ payload = build_signature_payload(session_status, authentication_session_request, schema_name, brokered_rp_name)
231
+ @signature_value_validator.validate(
232
+ signature_value: session_status.signature.value,
233
+ payload: payload,
234
+ certificate: certificate,
235
+ signature_algorithm_parameters: session_status.signature.signature_algorithm_parameters
236
+ )
237
+ end
238
+
239
+ def build_signature_payload(session_status, authentication_session_request, schema_name, brokered_rp_name)
240
+ signature_protocol_parameters = fetch_request_value(authentication_session_request, :signatureProtocolParameters)
241
+ rp_challenge = fetch_hash_value(signature_protocol_parameters, :rpChallenge)
242
+ relying_party_name = fetch_request_value(authentication_session_request, :relyingPartyName)
243
+ interactions = fetch_request_value(authentication_session_request, :interactions)
244
+
245
+ [
246
+ schema_name,
247
+ "ACSP_V2",
248
+ session_status.signature.server_random,
249
+ rp_challenge,
250
+ session_status.signature.user_challenge || "",
251
+ to_base64_utf8(relying_party_name),
252
+ blank?(brokered_rp_name) ? "" : to_base64_utf8(brokered_rp_name),
253
+ calculate_interactions_digest(interactions),
254
+ session_status.interaction_type_used,
255
+ callback_url_segment(session_status, authentication_session_request),
256
+ session_status.signature.flow_type
257
+ ].join("|")
258
+ end
259
+
260
+ def callback_url_segment(_session_status, _authentication_session_request)
261
+ ""
262
+ end
263
+
264
+ def requested_certificate_level(authentication_session_request)
265
+ value = fetch_request_value(authentication_session_request, :certificateLevel)
266
+ return "QUALIFIED" if blank?(value)
267
+
268
+ value
269
+ end
270
+
271
+ def fetch_request_value(payload, key)
272
+ return nil unless payload.respond_to?(:[])
273
+
274
+ payload[key] || payload[key.to_s]
275
+ end
276
+
277
+ def fetch_hash_value(payload, key)
278
+ return nil unless payload.respond_to?(:[])
279
+
280
+ payload[key] || payload[key.to_s]
281
+ end
282
+
283
+ def calculate_interactions_digest(interactions)
284
+ digest = OpenSSL::Digest::SHA256.digest(interactions.to_s)
285
+ Base64.strict_encode64(digest)
286
+ end
287
+
288
+ def to_base64_utf8(input)
289
+ Base64.strict_encode64(input.to_s.encode("UTF-8"))
290
+ end
291
+
292
+ def validate_non_empty(value, field_name)
293
+ return unless blank?(value)
294
+
295
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
296
+ "Authentication session status field '#{field_name}' is empty"
297
+ end
298
+
299
+ def blank?(value)
300
+ value.nil? || value.to_s.strip.empty?
301
+ end
302
+ end
303
+ end
304
+ end
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "base64"
4
+ require "openssl"
5
+
6
+ module SmartIdRuby
7
+ module Validation
8
+ # Validates certificate choice response data.
9
+ class CertificateChoiceResponseValidator
10
+ CERTIFICATE_LEVEL_ORDER = { "ADVANCED" => 1, "QUALIFIED" => 2, "QSCD" => 2 }.freeze
11
+
12
+ def initialize(certificate_validator: CertificateValidator.new)
13
+ @certificate_validator = certificate_validator
14
+ end
15
+
16
+ def validate(session_status, requested_certificate_level = "QUALIFIED")
17
+ status = normalize_status(session_status)
18
+ raise SmartIdRuby::Errors::RequestSetupError, "Parameter 'sessionStatus' is not provided" if status.nil?
19
+ if requested_certificate_level.nil?
20
+ raise SmartIdRuby::Errors::RequestSetupError, "Parameter 'requestedCertificateLevel' is not provided"
21
+ end
22
+
23
+ validate_result(status.result)
24
+ certificate_level, certificate = validate_session_status_certificate(status.cert, requested_certificate_level)
25
+ to_certificate_choice_response(status, certificate, certificate_level)
26
+ end
27
+
28
+ private
29
+
30
+ def validate_result(session_result)
31
+ if session_result.nil?
32
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate choice session status field 'result' is missing"
33
+ end
34
+ if blank?(session_result.end_result)
35
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate choice session status field 'result.endResult' is empty"
36
+ end
37
+
38
+ ErrorResultHandler.handle(session_result) unless session_result.end_result == "OK"
39
+ return unless blank?(session_result.document_number)
40
+
41
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
42
+ "Certificate choice session status field 'result.documentNumber' is empty"
43
+ end
44
+
45
+ def validate_session_status_certificate(session_certificate, requested_certificate_level)
46
+ if session_certificate.nil?
47
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate choice session status field 'cert' is missing"
48
+ end
49
+ if blank?(session_certificate.value)
50
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate choice session status field 'cert.value' has empty value"
51
+ end
52
+ if blank?(session_certificate.certificate_level)
53
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate choice session status field 'cert.certificateLevel' has empty value"
54
+ end
55
+
56
+ unless CERTIFICATE_LEVEL_ORDER.key?(session_certificate.certificate_level)
57
+ raise SmartIdRuby::Errors::UnprocessableResponseError,
58
+ "Certificate choice session status field 'cert.certificateLevel' has unsupported value"
59
+ end
60
+
61
+ response_level = session_certificate.certificate_level
62
+ requested_level = requested_certificate_level.to_s
63
+ requested_level = "QUALIFIED" if requested_level.strip.empty?
64
+ if CERTIFICATE_LEVEL_ORDER[response_level] < CERTIFICATE_LEVEL_ORDER.fetch(requested_level, CERTIFICATE_LEVEL_ORDER["QUALIFIED"])
65
+ raise SmartIdRuby::Errors::CertificateLevelMismatchError,
66
+ "Certificate choice session status response certificate level is lower than requested"
67
+ end
68
+
69
+ certificate = parse_certificate(session_certificate.value)
70
+ @certificate_validator&.validate(certificate)
71
+ [response_level, certificate]
72
+ end
73
+
74
+ def parse_certificate(cert_base64)
75
+ decoded = Base64.decode64(cert_base64.to_s)
76
+ OpenSSL::X509::Certificate.new(decoded)
77
+ rescue OpenSSL::X509::CertificateError, ArgumentError
78
+ raise SmartIdRuby::Errors::UnprocessableResponseError, "Certificate is invalid"
79
+ end
80
+
81
+ def to_certificate_choice_response(status, certificate, certificate_level)
82
+ SmartIdRuby::Models::CertificateChoiceResponse.new(
83
+ end_result: status.result.end_result,
84
+ document_number: status.result.document_number,
85
+ certificate: certificate,
86
+ certificate_level: certificate_level,
87
+ interaction_flow_used: status.interaction_type_used,
88
+ device_ip_address: status.device_ip_address
89
+ )
90
+ end
91
+
92
+ def normalize_status(session_status)
93
+ return session_status if session_status.respond_to?(:result)
94
+ return SmartIdRuby::Models::SessionStatus.from_h(session_status) if session_status.is_a?(Hash)
95
+
96
+ nil
97
+ end
98
+
99
+ def blank?(value)
100
+ value.nil? || value.to_s.strip.empty?
101
+ end
102
+ end
103
+ end
104
+ end