isds 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 +44 -0
- data/.ruby-version +1 -0
- data/LICENSE.txt +21 -0
- data/README.md +346 -0
- data/Rakefile +16 -0
- data/lib/isds/attachment.rb +66 -0
- data/lib/isds/authentication/access_interface.rb +20 -0
- data/lib/isds/authentication/base.rb +34 -0
- data/lib/isds/authentication/basic.rb +17 -0
- data/lib/isds/authentication/certificate.rb +58 -0
- data/lib/isds/client.rb +81 -0
- data/lib/isds/configuration.rb +154 -0
- data/lib/isds/connection.rb +113 -0
- data/lib/isds/databox.rb +121 -0
- data/lib/isds/digital_signature/verifier.rb +64 -0
- data/lib/isds/error.rb +59 -0
- data/lib/isds/large_messages/downloader.rb +61 -0
- data/lib/isds/large_messages/uploader.rb +65 -0
- data/lib/isds/message.rb +208 -0
- data/lib/isds/search.rb +111 -0
- data/lib/isds/status.rb +65 -0
- data/lib/isds/timestamp/verifier.rb +36 -0
- data/lib/isds/types.rb +19 -0
- data/lib/isds/user.rb +71 -0
- data/lib/isds/version.rb +5 -0
- data/lib/isds.rb +57 -0
- metadata +213 -0
data/lib/isds/client.rb
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
class Client
|
|
5
|
+
attr_reader :configuration, :connection
|
|
6
|
+
|
|
7
|
+
def initialize(config = nil, &block)
|
|
8
|
+
@configuration = config || ISDS.configuration
|
|
9
|
+
yield(@configuration) if block
|
|
10
|
+
@connection = Connection.new(@configuration)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def authenticate!
|
|
14
|
+
configuration.validate!
|
|
15
|
+
# Perform a simple operation to verify credentials
|
|
16
|
+
connection.call(:info, :get_owner_info_from_login)
|
|
17
|
+
@authenticated = true
|
|
18
|
+
rescue ISDS::Error
|
|
19
|
+
@authenticated = false
|
|
20
|
+
raise
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def connected?
|
|
24
|
+
@authenticated == true
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def disconnect
|
|
28
|
+
@authenticated = false
|
|
29
|
+
@connection = Connection.new(configuration)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# --- Message operations ---
|
|
33
|
+
|
|
34
|
+
def send_message(to_databox_id, subject:, attachments: [], **options)
|
|
35
|
+
message = Message.new(connection: connection)
|
|
36
|
+
message.create(to_databox_id, subject: subject, attachments: attachments, **options)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def receive_messages(limit: 50, unread_only: true, **filters)
|
|
40
|
+
Message.received(connection: connection, limit: limit, unread_only: unread_only, **filters)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def sent_messages(limit: 50, **filters)
|
|
44
|
+
Message.sent(connection: connection, limit: limit, **filters)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def find_message(message_id)
|
|
48
|
+
Message.find(message_id, connection: connection)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def mark_messages_read(message_ids)
|
|
52
|
+
Array(message_ids).each do |id|
|
|
53
|
+
Message.mark_as_read(id, connection: connection)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# --- Databox operations ---
|
|
58
|
+
|
|
59
|
+
def find_databox(databox_id)
|
|
60
|
+
Databox.find(databox_id, connection: connection)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def search_databoxes(query, **filters)
|
|
64
|
+
Search.databoxes(query, connection: connection, **filters)
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def check_databox_status(databox_id)
|
|
68
|
+
Databox.check_status(databox_id, connection: connection)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# --- Info operations ---
|
|
72
|
+
|
|
73
|
+
def owner_info
|
|
74
|
+
connection.call(:info, :get_owner_info_from_login)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def password_info
|
|
78
|
+
connection.call(:access, :get_password_info)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
end
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
class Configuration
|
|
5
|
+
VALID_ENVIRONMENTS = %i[test production].freeze
|
|
6
|
+
VALID_AUTH_METHODS = %i[basic certificate access_interface].freeze
|
|
7
|
+
DEFAULT_TIMEOUT = 30
|
|
8
|
+
DEFAULT_RETRIES = 3
|
|
9
|
+
DEFAULT_POOL_SIZE = 5
|
|
10
|
+
DEFAULT_LARGE_MESSAGE_CHUNK_SIZE = 10 * 1024 * 1024 # 10MB
|
|
11
|
+
|
|
12
|
+
attr_accessor :environment, :timeout, :retries, :auth_method,
|
|
13
|
+
:username, :password,
|
|
14
|
+
:certificate_path, :private_key_path, :key_password,
|
|
15
|
+
:logger, :log_level, :enable_request_logging,
|
|
16
|
+
:connection_pool_size, :request_timeout, :large_message_chunk_size,
|
|
17
|
+
:endpoints, :ssl_verify_peer, :ssl_ca_file, :ssl_version,
|
|
18
|
+
:on_error, :retry_on,
|
|
19
|
+
:cache_store, :cache_ttl
|
|
20
|
+
|
|
21
|
+
def initialize(**options)
|
|
22
|
+
assign_core_options(options)
|
|
23
|
+
assign_auth_options(options)
|
|
24
|
+
assign_logging_options(options)
|
|
25
|
+
assign_connection_options(options)
|
|
26
|
+
assign_ssl_options(options)
|
|
27
|
+
assign_advanced_options(options)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def valid?
|
|
31
|
+
validate!
|
|
32
|
+
true
|
|
33
|
+
rescue ConfigurationError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def validate!
|
|
38
|
+
validate_environment!
|
|
39
|
+
validate_auth_method!
|
|
40
|
+
validate_credentials!
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def endpoint_urls
|
|
44
|
+
@endpoints || ISDS::ENDPOINTS.fetch(environment)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def endpoint_for(service)
|
|
48
|
+
urls = endpoint_urls
|
|
49
|
+
|
|
50
|
+
# Direct key lookup (backward compat with custom endpoints)
|
|
51
|
+
return urls[service] if urls.key?(service)
|
|
52
|
+
|
|
53
|
+
# Map through SERVICE_ENDPOINT_MAP
|
|
54
|
+
endpoint_key = ISDS::SERVICE_ENDPOINT_MAP[service]
|
|
55
|
+
raise ConfigurationError, "Unknown service: #{service}" unless endpoint_key
|
|
56
|
+
|
|
57
|
+
urls.fetch(endpoint_key) do
|
|
58
|
+
raise ConfigurationError, "No endpoint configured for #{endpoint_key} (service: #{service})"
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def base_url
|
|
63
|
+
endpoint_urls[:base_url]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def test?
|
|
67
|
+
environment == :test
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def production?
|
|
71
|
+
environment == :production
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def validate_environment!
|
|
77
|
+
return if VALID_ENVIRONMENTS.include?(environment)
|
|
78
|
+
|
|
79
|
+
raise ConfigurationError, "Invalid environment: #{environment}. Must be one of: #{VALID_ENVIRONMENTS.join(', ')}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def validate_auth_method!
|
|
83
|
+
return if VALID_AUTH_METHODS.include?(auth_method)
|
|
84
|
+
|
|
85
|
+
raise ConfigurationError,
|
|
86
|
+
"Invalid auth_method: #{auth_method}. Must be one of: #{VALID_AUTH_METHODS.join(', ')}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_credentials!
|
|
90
|
+
case auth_method
|
|
91
|
+
when :basic, :access_interface
|
|
92
|
+
validate_basic_credentials!
|
|
93
|
+
when :certificate
|
|
94
|
+
validate_certificate_credentials!
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def assign_core_options(options)
|
|
99
|
+
@environment = options.fetch(:environment, :test)
|
|
100
|
+
@timeout = options.fetch(:timeout, DEFAULT_TIMEOUT)
|
|
101
|
+
@retries = options.fetch(:retries, DEFAULT_RETRIES)
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def assign_auth_options(options)
|
|
105
|
+
@auth_method = options.fetch(:auth_method, :basic)
|
|
106
|
+
@username = options[:username]
|
|
107
|
+
@password = options[:password]
|
|
108
|
+
@certificate_path = options[:certificate_path]
|
|
109
|
+
@private_key_path = options[:private_key_path]
|
|
110
|
+
@key_password = options[:key_password]
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def assign_logging_options(options)
|
|
114
|
+
@logger = options[:logger]
|
|
115
|
+
@log_level = options.fetch(:log_level, :info)
|
|
116
|
+
@enable_request_logging = options.fetch(:enable_request_logging, false)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def assign_connection_options(options)
|
|
120
|
+
@connection_pool_size = options.fetch(:connection_pool_size, DEFAULT_POOL_SIZE)
|
|
121
|
+
@request_timeout = options.fetch(:request_timeout, 120)
|
|
122
|
+
@large_message_chunk_size = options.fetch(:large_message_chunk_size, DEFAULT_LARGE_MESSAGE_CHUNK_SIZE)
|
|
123
|
+
@endpoints = options[:endpoints]
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def assign_ssl_options(options)
|
|
127
|
+
@ssl_verify_peer = options.fetch(:ssl_verify_peer, true)
|
|
128
|
+
@ssl_ca_file = options[:ssl_ca_file]
|
|
129
|
+
@ssl_version = options[:ssl_version]
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def assign_advanced_options(options)
|
|
133
|
+
@on_error = options[:on_error]
|
|
134
|
+
@retry_on = options[:retry_on]
|
|
135
|
+
@cache_store = options[:cache_store]
|
|
136
|
+
@cache_ttl = options.fetch(:cache_ttl, 3600)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def validate_basic_credentials!
|
|
140
|
+
raise ConfigurationError, 'Username is required for basic authentication' if username.nil? || username.empty?
|
|
141
|
+
raise ConfigurationError, 'Password is required for basic authentication' if password.nil? || password.empty?
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def validate_certificate_credentials!
|
|
145
|
+
if certificate_path.nil? || certificate_path.empty?
|
|
146
|
+
raise ConfigurationError,
|
|
147
|
+
'Certificate path is required for certificate authentication'
|
|
148
|
+
end
|
|
149
|
+
return unless private_key_path.nil? || private_key_path.empty?
|
|
150
|
+
|
|
151
|
+
raise ConfigurationError, 'Private key path is required for certificate authentication'
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
end
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
class Connection
|
|
5
|
+
attr_reader :configuration, :authenticator
|
|
6
|
+
|
|
7
|
+
def initialize(configuration)
|
|
8
|
+
@configuration = configuration
|
|
9
|
+
@authenticator = Authentication::Base.build(configuration)
|
|
10
|
+
@soap_clients = {}
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def call(service, operation, message = {})
|
|
14
|
+
client = soap_client_for(service)
|
|
15
|
+
response = client.call(operation, message: message)
|
|
16
|
+
parse_response(response)
|
|
17
|
+
rescue Savon::SOAPFault => e
|
|
18
|
+
handle_soap_fault(e)
|
|
19
|
+
rescue Savon::HTTPError => e
|
|
20
|
+
handle_http_error(e)
|
|
21
|
+
rescue Net::OpenTimeout, Net::ReadTimeout => e
|
|
22
|
+
raise TimeoutError.new("Request timed out: #{e.message}", original_error: e)
|
|
23
|
+
rescue Errno::ECONNREFUSED, Errno::ECONNRESET, SocketError => e
|
|
24
|
+
raise ConnectionError.new("Connection failed: #{e.message}", original_error: e)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def soap_client_for(service)
|
|
28
|
+
@soap_clients[service] ||= build_soap_client(service)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def build_soap_client(service)
|
|
34
|
+
endpoint = configuration.endpoint_for(service)
|
|
35
|
+
|
|
36
|
+
options = base_savon_options(endpoint)
|
|
37
|
+
options = authenticator.apply(options)
|
|
38
|
+
Savon.client(**options)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def base_savon_options(endpoint) # rubocop:disable Metrics/AbcSize
|
|
42
|
+
options = {
|
|
43
|
+
endpoint: endpoint,
|
|
44
|
+
namespace: ISDS::ISDS_NAMESPACE,
|
|
45
|
+
namespace_identifier: :isds,
|
|
46
|
+
env_namespace: :soapenv,
|
|
47
|
+
open_timeout: configuration.timeout,
|
|
48
|
+
read_timeout: configuration.request_timeout,
|
|
49
|
+
ssl_verify_mode: configuration.ssl_verify_peer ? :peer : :none,
|
|
50
|
+
log: configuration.enable_request_logging,
|
|
51
|
+
pretty_print_xml: configuration.test?,
|
|
52
|
+
convert_response_tags_to: ->(tag) { tag.snakecase.to_sym }
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
options[:ssl_ca_cert_file] = configuration.ssl_ca_file if configuration.ssl_ca_file
|
|
56
|
+
if configuration.logger
|
|
57
|
+
options[:logger] = configuration.logger
|
|
58
|
+
options[:log_level] = configuration.log_level
|
|
59
|
+
end
|
|
60
|
+
options
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def parse_response(response)
|
|
64
|
+
body = response.body
|
|
65
|
+
check_isds_status!(body)
|
|
66
|
+
body
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def check_isds_status!(body)
|
|
70
|
+
status = extract_status(body)
|
|
71
|
+
return if status.nil?
|
|
72
|
+
|
|
73
|
+
code = status[:dm_status_code] || status[:db_status_code]
|
|
74
|
+
message = status[:dm_status_message] || status[:db_status_message]
|
|
75
|
+
return if code == '0000'
|
|
76
|
+
|
|
77
|
+
raise ErrorMapper.map(code, message)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def extract_status(body)
|
|
81
|
+
return nil unless body.is_a?(Hash)
|
|
82
|
+
|
|
83
|
+
body.each_value do |value|
|
|
84
|
+
next unless value.is_a?(Hash)
|
|
85
|
+
|
|
86
|
+
return value[:dm_status] if value[:dm_status]
|
|
87
|
+
return value[:db_status] if value[:db_status]
|
|
88
|
+
end
|
|
89
|
+
nil
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def handle_soap_fault(error)
|
|
93
|
+
fault_message = error.to_hash.dig(:fault, :faultstring) || error.message
|
|
94
|
+
|
|
95
|
+
if fault_message.match?(/authentication|unauthorized|login/i)
|
|
96
|
+
raise InvalidCredentialsError.new(fault_message, original_error: error)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
raise ISDS::Error.new("SOAP Fault: #{fault_message}", original_error: error)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def handle_http_error(error)
|
|
103
|
+
case error.http.code
|
|
104
|
+
when 401, 403
|
|
105
|
+
raise InvalidCredentialsError.new("Authentication failed (HTTP #{error.http.code})", original_error: error)
|
|
106
|
+
when 503
|
|
107
|
+
raise ServiceUnavailableError.new('ISDS service unavailable', original_error: error)
|
|
108
|
+
else
|
|
109
|
+
raise NetworkError.new("HTTP error #{error.http.code}", original_error: error)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
data/lib/isds/databox.rb
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
class Databox
|
|
5
|
+
attr_reader :id, :name, :type, :ico, :address, :status,
|
|
6
|
+
:pdz_enabled, :ovm, :raw_data
|
|
7
|
+
|
|
8
|
+
def initialize(**attributes)
|
|
9
|
+
@id = attributes[:id]
|
|
10
|
+
@name = attributes[:name]
|
|
11
|
+
@type = attributes[:type]
|
|
12
|
+
@ico = attributes[:ico]
|
|
13
|
+
@address = attributes[:address]
|
|
14
|
+
@status = attributes[:status]
|
|
15
|
+
@pdz_enabled = attributes[:pdz_enabled]
|
|
16
|
+
@ovm = attributes[:ovm]
|
|
17
|
+
@raw_data = attributes[:raw_data]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def active?
|
|
21
|
+
status&.to_i == 1
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def accessible?
|
|
25
|
+
active?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def can_receive_pdt?
|
|
29
|
+
pdz_enabled == true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def ovm?
|
|
33
|
+
ovm == true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.find(databox_id, connection:)
|
|
37
|
+
validate_databox_id!(databox_id)
|
|
38
|
+
|
|
39
|
+
response = connection.call(:search, :find_data_box, {
|
|
40
|
+
'isds:dbOwnerInfo' => {
|
|
41
|
+
'isds:dbID' => databox_id
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
from_response(response)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def self.find_by_ico(ico, connection:)
|
|
49
|
+
response = connection.call(:search, :find_data_box, {
|
|
50
|
+
'isds:dbOwnerInfo' => {
|
|
51
|
+
'isds:ic' => ico
|
|
52
|
+
}
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
from_response(response)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def self.check_status(databox_id, connection:)
|
|
59
|
+
validate_databox_id!(databox_id)
|
|
60
|
+
|
|
61
|
+
response = connection.call(:search, :check_data_box, {
|
|
62
|
+
'isds:dbID' => databox_id
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
extract_status(response)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def self.validate_databox_id!(databox_id)
|
|
69
|
+
return if databox_id.is_a?(String) && databox_id.match?(/\A[a-z0-9]{7}\z/i)
|
|
70
|
+
|
|
71
|
+
raise InvalidMessageError, "Invalid databox ID format: #{databox_id}. Must be 7 alphanumeric characters."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private_class_method def self.from_response(response)
|
|
75
|
+
records = extract_records(response)
|
|
76
|
+
return nil if records.nil? || records.empty?
|
|
77
|
+
|
|
78
|
+
records = [records] unless records.is_a?(Array)
|
|
79
|
+
records.map { |record| from_record(record) }
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private_class_method def self.extract_records(response)
|
|
83
|
+
key = response.keys.first
|
|
84
|
+
response.dig(key, :db_results, :db_owner_info) ||
|
|
85
|
+
response.dig(key, :db_owner_info)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
private_class_method def self.from_record(record)
|
|
89
|
+
new(
|
|
90
|
+
id: record[:db_id],
|
|
91
|
+
name: [record[:firm_name], record[:pn_first_name], record[:pn_last_name]].compact.join(' ').strip,
|
|
92
|
+
type: record[:db_type],
|
|
93
|
+
ico: record[:ic],
|
|
94
|
+
address: build_address(record),
|
|
95
|
+
status: record[:db_state],
|
|
96
|
+
pdz_enabled: record[:db_open_addressing],
|
|
97
|
+
ovm: record[:db_type]&.start_with?('OVM'),
|
|
98
|
+
raw_data: record
|
|
99
|
+
)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private_class_method def self.build_address(record)
|
|
103
|
+
parts = [
|
|
104
|
+
record[:ad_street],
|
|
105
|
+
record[:ad_number_in_street],
|
|
106
|
+
record[:ad_city],
|
|
107
|
+
record[:ad_zip_code]
|
|
108
|
+
].compact
|
|
109
|
+
parts.join(', ')
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
private_class_method def self.extract_status(response)
|
|
113
|
+
key = response.keys.first
|
|
114
|
+
state = response.dig(key, :db_state)
|
|
115
|
+
{
|
|
116
|
+
status: state&.to_i,
|
|
117
|
+
active: state&.to_i == 1
|
|
118
|
+
}
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
|
|
5
|
+
module ISDS
|
|
6
|
+
module DigitalSignature
|
|
7
|
+
class Verifier
|
|
8
|
+
attr_reader :connection
|
|
9
|
+
|
|
10
|
+
def initialize(connection:)
|
|
11
|
+
@connection = connection
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def verify_message(message_id)
|
|
15
|
+
response = connection.call(:info, :signed_message_download, {
|
|
16
|
+
'isds:dmID' => message_id
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
signature_data = response.dig(:signed_message_download_response, :dm_signature)
|
|
20
|
+
return { valid: false, error: 'No signature found' } unless signature_data
|
|
21
|
+
|
|
22
|
+
verify_pkcs7_signature(Base64.decode64(signature_data))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def verify_delivery_info(message_id)
|
|
26
|
+
response = connection.call(:info, :get_signed_delivery_info, {
|
|
27
|
+
'isds:dmID' => message_id
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
signed_info = response.dig(:get_signed_delivery_info_response, :dm_signature)
|
|
31
|
+
return { valid: false, error: 'No delivery signature found' } unless signed_info
|
|
32
|
+
|
|
33
|
+
verify_pkcs7_signature(Base64.decode64(signed_info))
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def verify_pkcs7_signature(data)
|
|
39
|
+
pkcs7 = OpenSSL::PKCS7.new(data)
|
|
40
|
+
store = OpenSSL::X509::Store.new
|
|
41
|
+
store.set_default_paths
|
|
42
|
+
|
|
43
|
+
valid = pkcs7.verify([], store, nil, OpenSSL::PKCS7::NOVERIFY)
|
|
44
|
+
{
|
|
45
|
+
valid: valid,
|
|
46
|
+
signer: extract_signer_info(pkcs7),
|
|
47
|
+
signed_at: extract_signing_time(pkcs7)
|
|
48
|
+
}
|
|
49
|
+
rescue OpenSSL::PKCS7::PKCS7Error => e
|
|
50
|
+
{ valid: false, error: e.message }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def extract_signer_info(pkcs7)
|
|
54
|
+
cert = pkcs7.certificates&.first
|
|
55
|
+
cert&.subject&.to_s
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def extract_signing_time(pkcs7)
|
|
59
|
+
signer = pkcs7.signers&.first
|
|
60
|
+
signer&.signed_time
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/isds/error.rb
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
class Error < StandardError
|
|
5
|
+
attr_reader :code, :original_error
|
|
6
|
+
|
|
7
|
+
def initialize(message = nil, code: nil, original_error: nil)
|
|
8
|
+
@code = code
|
|
9
|
+
@original_error = original_error
|
|
10
|
+
super(message)
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
class ConfigurationError < Error; end
|
|
15
|
+
|
|
16
|
+
# Network and connection errors
|
|
17
|
+
class NetworkError < Error; end
|
|
18
|
+
class TimeoutError < NetworkError; end
|
|
19
|
+
class ConnectionError < NetworkError; end
|
|
20
|
+
|
|
21
|
+
# Authentication errors
|
|
22
|
+
class AuthenticationError < Error; end
|
|
23
|
+
class InvalidCredentialsError < AuthenticationError; end
|
|
24
|
+
class CertificateError < AuthenticationError; end
|
|
25
|
+
|
|
26
|
+
# Business logic errors
|
|
27
|
+
class DataboxNotFoundError < Error; end
|
|
28
|
+
class MessageNotFoundError < Error; end
|
|
29
|
+
class PermissionDeniedError < Error; end
|
|
30
|
+
class InvalidMessageError < Error; end
|
|
31
|
+
|
|
32
|
+
# ISDS service errors
|
|
33
|
+
class ServiceUnavailableError < Error; end
|
|
34
|
+
class TemporaryUnavailableError < Error; end
|
|
35
|
+
class QuotaExceededError < Error; end
|
|
36
|
+
|
|
37
|
+
# File handling errors
|
|
38
|
+
class AttachmentError < Error; end
|
|
39
|
+
class FileSizeExceededError < AttachmentError; end
|
|
40
|
+
class UnsupportedFormatError < AttachmentError; end
|
|
41
|
+
|
|
42
|
+
# Maps ISDS status codes to error classes
|
|
43
|
+
class ErrorMapper
|
|
44
|
+
STATUS_MAP = {
|
|
45
|
+
'0001' => InvalidCredentialsError,
|
|
46
|
+
'0002' => PermissionDeniedError,
|
|
47
|
+
'0003' => DataboxNotFoundError,
|
|
48
|
+
'0004' => MessageNotFoundError,
|
|
49
|
+
'0005' => InvalidMessageError,
|
|
50
|
+
'0006' => QuotaExceededError,
|
|
51
|
+
'9999' => ServiceUnavailableError
|
|
52
|
+
}.freeze
|
|
53
|
+
|
|
54
|
+
def self.map(status_code, status_message)
|
|
55
|
+
error_class = STATUS_MAP.fetch(status_code, Error)
|
|
56
|
+
error_class.new(status_message, code: status_code)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module ISDS
|
|
4
|
+
module LargeMessages
|
|
5
|
+
class Downloader
|
|
6
|
+
attr_reader :connection
|
|
7
|
+
|
|
8
|
+
def initialize(connection:)
|
|
9
|
+
@connection = connection
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def download(message_id, attachment_id, target_path, &progress_callback)
|
|
13
|
+
info = get_attachment_info(message_id, attachment_id)
|
|
14
|
+
total_chunks = info[:chunks_total].to_i
|
|
15
|
+
|
|
16
|
+
File.open(target_path, 'wb') do |file|
|
|
17
|
+
(1..total_chunks).each do |chunk_num|
|
|
18
|
+
chunk_data = download_chunk(message_id, attachment_id, chunk_num)
|
|
19
|
+
file.write(chunk_data)
|
|
20
|
+
|
|
21
|
+
progress_callback&.call(chunk_num, total_chunks)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
verify_integrity(target_path, info[:hash]) if info[:hash]
|
|
26
|
+
target_path
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def get_attachment_info(message_id, attachment_id)
|
|
32
|
+
response = connection.call(:large_messages, :get_attachment_info, {
|
|
33
|
+
'isds:dmID' => message_id,
|
|
34
|
+
'isds:dmFileID' => attachment_id
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
key = response.keys.first
|
|
38
|
+
response[key] || {}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def download_chunk(message_id, attachment_id, chunk_number)
|
|
42
|
+
response = connection.call(:large_messages, :download_chunk, {
|
|
43
|
+
'isds:dmID' => message_id,
|
|
44
|
+
'isds:dmFileID' => attachment_id,
|
|
45
|
+
'isds:dmChunkSeq' => chunk_number
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
key = response.keys.first
|
|
49
|
+
encoded = response.dig(key, :dm_encoded_content)
|
|
50
|
+
Base64.decode64(encoded)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def verify_integrity(file_path, expected_hash)
|
|
54
|
+
actual_hash = Digest::SHA256.file(file_path).hexdigest
|
|
55
|
+
return if actual_hash == expected_hash
|
|
56
|
+
|
|
57
|
+
raise AttachmentError, "File integrity check failed. Expected: #{expected_hash}, got: #{actual_hash}"
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|