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.
- checksums.yaml +7 -0
- data/.rspec +3 -0
- data/.rubocop.yml +14 -0
- data/CHANGELOG.md +13 -0
- data/CODE_OF_CONDUCT.md +132 -0
- data/LICENSE.txt +21 -0
- data/README.md +436 -0
- data/Rakefile +12 -0
- data/lib/smart-id-ruby-client.rb +3 -0
- data/lib/smart_id_ruby/callback_url.rb +18 -0
- data/lib/smart_id_ruby/callback_url_util.rb +54 -0
- data/lib/smart_id_ruby/client.rb +124 -0
- data/lib/smart_id_ruby/configuration.rb +184 -0
- data/lib/smart_id_ruby/device_link_builder.rb +301 -0
- data/lib/smart_id_ruby/device_link_interaction.rb +67 -0
- data/lib/smart_id_ruby/errors/certificate_level_mismatch_error.rb +8 -0
- data/lib/smart_id_ruby/errors/document_unusable_error.rb +12 -0
- data/lib/smart_id_ruby/errors/error.rb +8 -0
- data/lib/smart_id_ruby/errors/expected_linked_session_error.rb +15 -0
- data/lib/smart_id_ruby/errors/no_suitable_account_of_requested_type_found_error.rb +10 -0
- data/lib/smart_id_ruby/errors/person_should_view_smart_id_portal_error.rb +8 -0
- data/lib/smart_id_ruby/errors/protocol_failure_error.rb +13 -0
- data/lib/smart_id_ruby/errors/relying_party_account_configuration_error.rb +10 -0
- data/lib/smart_id_ruby/errors/request_setup_error.rb +10 -0
- data/lib/smart_id_ruby/errors/request_validation_error.rb +8 -0
- data/lib/smart_id_ruby/errors/required_interaction_not_supported_by_app_error.rb +13 -0
- data/lib/smart_id_ruby/errors/response_error.rb +8 -0
- data/lib/smart_id_ruby/errors/server_maintenance_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_end_result_error.rb +15 -0
- data/lib/smart_id_ruby/errors/session_not_complete_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_not_found_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_secret_mismatch_error.rb +8 -0
- data/lib/smart_id_ruby/errors/session_timeout_error.rb +12 -0
- data/lib/smart_id_ruby/errors/smart_id_server_error.rb +12 -0
- data/lib/smart_id_ruby/errors/unprocessable_response_error.rb +9 -0
- data/lib/smart_id_ruby/errors/unsupported_client_api_version_error.rb +8 -0
- data/lib/smart_id_ruby/errors/user_account_not_found_error.rb +8 -0
- data/lib/smart_id_ruby/errors/user_account_unusable_error.rb +12 -0
- data/lib/smart_id_ruby/errors/user_refused_cert_choice_error.rb +14 -0
- data/lib/smart_id_ruby/errors/user_refused_confirmation_message_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_confirmation_message_with_verification_choice_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_display_text_and_pin_error.rb +13 -0
- data/lib/smart_id_ruby/errors/user_refused_error.rb +12 -0
- data/lib/smart_id_ruby/errors/user_selected_wrong_verification_code_error.rb +13 -0
- data/lib/smart_id_ruby/errors.rb +31 -0
- data/lib/smart_id_ruby/flows/base_builder.rb +90 -0
- data/lib/smart_id_ruby/flows/certificate_by_document_number_request_builder.rb +130 -0
- data/lib/smart_id_ruby/flows/device_link_authentication_session_request_builder.rb +208 -0
- data/lib/smart_id_ruby/flows/device_link_certificate_choice_session_request_builder.rb +112 -0
- data/lib/smart_id_ruby/flows/device_link_signature_session_request_builder.rb +286 -0
- data/lib/smart_id_ruby/flows/linked_notification_signature_session_request_builder.rb +235 -0
- data/lib/smart_id_ruby/flows/notification_authentication_session_request_builder.rb +184 -0
- data/lib/smart_id_ruby/flows/notification_certificate_choice_session_request_builder.rb +96 -0
- data/lib/smart_id_ruby/flows/notification_signature_session_request_builder.rb +272 -0
- data/lib/smart_id_ruby/models/authentication_identity.rb +19 -0
- data/lib/smart_id_ruby/models/authentication_response.rb +38 -0
- data/lib/smart_id_ruby/models/certificate_choice_response.rb +19 -0
- data/lib/smart_id_ruby/models/device_link_session_response.rb +34 -0
- data/lib/smart_id_ruby/models/notification_authentication_session_response.rb +25 -0
- data/lib/smart_id_ruby/models/notification_certificate_choice_session_response.rb +25 -0
- data/lib/smart_id_ruby/models/notification_signature_session_response.rb +29 -0
- data/lib/smart_id_ruby/models/session_status.rb +261 -0
- data/lib/smart_id_ruby/models/signature_response.rb +38 -0
- data/lib/smart_id_ruby/notification_interaction.rb +70 -0
- data/lib/smart_id_ruby/qr_code_generator.rb +65 -0
- data/lib/smart_id_ruby/rest/connector.rb +364 -0
- data/lib/smart_id_ruby/rest/session_status_poller.rb +125 -0
- data/lib/smart_id_ruby/rp_challenge.rb +37 -0
- data/lib/smart_id_ruby/rp_challenge_generator.rb +28 -0
- data/lib/smart_id_ruby/semantics_identifier.rb +35 -0
- data/lib/smart_id_ruby/validation/authentication_certificate_validator.rb +90 -0
- data/lib/smart_id_ruby/validation/authentication_identity_mapper.rb +227 -0
- data/lib/smart_id_ruby/validation/base_authentication_response_validator.rb +304 -0
- data/lib/smart_id_ruby/validation/certificate_choice_response_validator.rb +104 -0
- data/lib/smart_id_ruby/validation/certificate_validator.rb +170 -0
- data/lib/smart_id_ruby/validation/device_link_authentication_response_validator.rb +76 -0
- data/lib/smart_id_ruby/validation/error_result_handler.rb +88 -0
- data/lib/smart_id_ruby/validation/notification_authentication_response_validator.rb +16 -0
- data/lib/smart_id_ruby/validation/signature_payload_builder.rb +62 -0
- data/lib/smart_id_ruby/validation/signature_response_validator.rb +345 -0
- data/lib/smart_id_ruby/validation/signature_value_validator.rb +76 -0
- data/lib/smart_id_ruby/validation/trusted_ca_cert_store.rb +20 -0
- data/lib/smart_id_ruby/verification_code_calculator.rb +31 -0
- data/lib/smart_id_ruby/version.rb +5 -0
- data/lib/smart_id_ruby.rb +76 -0
- 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
|