Class: As2::Client
- Inherits:
-
Object
- Object
- As2::Client
- Defined in:
- lib/as2/client.rb,
lib/as2/client/result.rb
Defined Under Namespace
Classes: Result
Instance Attribute Summary collapse
-
#partner ⇒ Object
readonly
Returns the value of attribute partner.
-
#server_info ⇒ Object
readonly
Returns the value of attribute server_info.
Class Method Summary collapse
Instance Method Summary collapse
- #as2_from ⇒ Object
- #as2_to ⇒ Object
- #evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:) ⇒ Object
-
#format_body_v0(document_content, content_type:, file_name:) ⇒ Array
‘original’ body formatting.
-
#format_body_v1(document_content, content_type:, file_name:) ⇒ Array
updated body formatting.
-
#initialize(partner, server_info: nil, logger: nil) ⇒ Client
constructor
A new instance of Client.
-
#send_file(file_name, content: nil, content_type: 'application/EDI-Consent') ⇒ As2::Client::Result
Send a file to a partner.
Constructor Details
#initialize(partner, server_info: nil, logger: nil) ⇒ Client
Returns a new instance of Client.
22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
# File 'lib/as2/client.rb', line 22 def initialize(partner, server_info: nil, logger: nil) @logger = logger || Logger.new('/dev/null') if partner.is_a?(As2::Config::Partner) @partner = partner else @partner = Config.partners[partner] unless @partner raise "Partner #{partner} is not registered" end end @server_info = server_info || Config.server_info end |
Instance Attribute Details
#partner ⇒ Object (readonly)
Returns the value of attribute partner.
5 6 7 |
# File 'lib/as2/client.rb', line 5 def partner @partner end |
#server_info ⇒ Object (readonly)
Returns the value of attribute server_info.
5 6 7 |
# File 'lib/as2/client.rb', line 5 def server_info @server_info end |
Class Method Details
.valid_encryption_ciphers ⇒ Object
11 12 13 |
# File 'lib/as2/client.rb', line 11 def self.valid_encryption_ciphers OpenSSL::Cipher.ciphers end |
.valid_outbound_formats ⇒ Object
7 8 9 |
# File 'lib/as2/client.rb', line 7 def self.valid_outbound_formats ['v0', 'v1'] end |
Instance Method Details
#as2_from ⇒ Object
41 42 43 |
# File 'lib/as2/client.rb', line 41 def as2_from @server_info.name end |
#as2_to ⇒ Object
37 38 39 |
# File 'lib/as2/client.rb', line 37 def as2_to @partner.name end |
#evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:) ⇒ Object
256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 |
# File 'lib/as2/client.rb', line 256 def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:) report = { signature_verification_error: :not_checked, mic_matched: nil, mid_matched: nil, disposition: nil, plain_text_body: nil } # MDN bodies we've seen so far don't include Content-Type, which causes `read_smime` to fail. response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}" if mdn_content_type.start_with?('multipart/signed') result = parse_signed_mdn( multipart_signed_message: response_content, signing_certificate: @partner.signing_certificate ) mdn_report = result[:mdn_report] report[:signature_verification_error] = result[:signature_verification_error] else # MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header. mdn_report = Mail.new(response_content) end mdn_report.parts.each do |part| if part.content_type.start_with?('text/plain') report[:plain_text_body] = part.body.to_s.strip elsif part.content_type.start_with?('message/disposition-notification') # "The rules for constructing the AS2-disposition-notification content..." # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3 = {} # TODO: can we use Mail built-ins for this? part.body.to_s.lines.each do |line| if line =~ /^([^:]+): (.+)$/ # downcase because we've seen both 'Disposition' and 'disposition' [$1.to_s.downcase] = $2 end end report[:disposition] = ['disposition'].strip report[:mid_matched] = == ['original-message-id'].strip if ['received-content-mic'] # do mic calc using the algorithm specified by server. # (even if we specify sha1, server may send back MIC using a different algo.) received_mic, micalg = ['received-content-mic'].split(',').map(&:strip) # if they don't specify, we'll use the algorithm we specified in the outbound transmission. # but it's only a guess & may fail. micalg ||= outbound_mic_algorithm mic = As2::DigestSelector.for_code(micalg).base64digest(original_body) report[:mic_matched] = received_mic == mic end end end report end |
#format_body_v0(document_content, content_type:, file_name:) ⇒ Array
‘original’ body formatting
-
uses OpenSSL::PKCS7.write_smime to build MIME body
* includes plain-text "this is an S/MIME message" note prior to initial
MIME boundary
-
uses non-standard application/x-pkcs7-* content types
-
MIME boundaries and signature have n line endings
this format is understood by Mendelson, OpenAS2, and several commercial products (GoAnywhere MFT). it is not understood by IBM Sterling B2B Integrator.
170 171 172 173 174 175 176 177 178 179 180 181 |
# File 'lib/as2/client.rb', line 170 def format_body_v0(document_content, content_type:, file_name:) document_payload = "Content-Type: #{content_type}\r\n" document_payload << "Content-Transfer-Encoding: base64\r\n" document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n" document_payload << "\r\n" document_payload << base64_encode(document_content) signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload) signature.detached = true [document_payload, OpenSSL::PKCS7.write_smime(signature, document_payload)] end |
#format_body_v1(document_content, content_type:, file_name:) ⇒ Array
updated body formatting
-
no content before the first MIME boundary
-
uses standard application/pkcs7-* content types
-
MIME boundaries and signature have rn line endings
-
adds parameter smime-type=signed-data to the signature’s Content-Type
this format is understood by Mendelson, OpenAS2, and several commercial products (GoAnywhere MFT) and IBM Sterling B2B Integrator.
199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 |
# File 'lib/as2/client.rb', line 199 def format_body_v1(document_content, content_type:, file_name:) document_payload = "Content-Type: #{content_type}\r\n" document_payload << "Content-Transfer-Encoding: base64\r\n" document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n" document_payload << "\r\n" document_payload << base64_encode(document_content) signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload) signature.detached = true # PEM (base64-encoded) signature = signature.to_pem # strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters .gsub!(/^-----[^\n]+\n/, '') # and update to canonical \r\n line endings = As2.canonicalize_line_endings() # this is a hack until i can determine a better way to get the micalg parameter # from the pkcs7 signature generated above... # https://stackoverflow.com/questions/75934159/how-does-openssl-smime-determine-micalg-parameter # # also tried approach outlined in https://stackoverflow.com/questions/53044007/how-to-use-sha1-digest-during-signing-with-opensslpkcs7-sign-when-creating-smi # but the signature generated by that method lacks some essential data. verifying those # signatures results in an openssl error "unable to find message digest" smime_body = OpenSSL::PKCS7.write_smime(signature, document_payload) micalg = smime_body[/^Content-Type: multipart\/signed.*micalg=\"([^"]+)/m, 1] # generate a MIME part boundary # # > A good strategy is to choose a boundary that includes # > a character sequence such as "=_" which can never appear in a # > quoted-printable body. # # https://www.rfc-editor.org/rfc/rfc2045#page-21 boundary = "----=_#{SecureRandom.hex(16).upcase}" body_boundary = "--#{boundary}" # body's mime headers body = "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=#{micalg}; boundary=\"#{boundary}\"\r\n" body += "\r\n" # first body part: the document body += body_boundary + "\r\n" body += document_payload + "\r\n" # second body part: the signature body += body_boundary + "\r\n" body += "Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data\r\n" body += "Content-Transfer-Encoding: base64\r\n" body += "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n" body += "\r\n" body += body += body_boundary + "--\r\n" [document_payload, body] end |
#send_file(file_name, content: nil, content_type: 'application/EDI-Consent') ⇒ As2::Client::Result
Send a file to a partner
* If the content parameter is omitted, then `file_name` must be a path
to a local file, whose contents will be sent to the partner.
* If content parameter is specified, file_name is only used to tell the
partner the original name of the file.
TODO: refactor to separate “build an outbound message” from “send an outbound message” main benefit would be allowing the test suite to be more straightforward. (wouldn’t need webmock just to verify what kind of message we built…)
61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 |
# File 'lib/as2/client.rb', line 61 def send_file(file_name, content: nil, content_type: 'application/EDI-Consent') outbound_mic_algorithm = 'sha256' = As2.(@server_info) req = Net::HTTP::Post.new @partner.url.path req['AS2-Version'] = '1.0' # 1.1 includes compression support, which we dont implement. req['AS2-From'] = As2.quoted_system_identifier(as2_from) req['AS2-To'] = As2.quoted_system_identifier(as2_to) req['Subject'] = 'AS2 Transaction' req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m' req['Date'] = Time.now.rfc2822 req['Disposition-Notification-To'] = @server_info.url.to_s req['Disposition-Notification-Options'] = "signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, #{outbound_mic_algorithm}" req['Content-Disposition'] = 'attachment; filename="smime.p7m"' req['Recipient-Address'] = @partner.url.to_s req['Message-ID'] = document_content = content || File.read(file_name) outbound_format = @partner&.outbound_format || 'v0' if outbound_format == 'v1' format_method = :format_body_v1 else format_method = :format_body_v0 end document_payload, request_body = send(format_method, document_content, content_type: content_type, file_name: file_name ) encrypted = OpenSSL::PKCS7.encrypt( [@partner.encryption_certificate], request_body, @partner.encryption_cipher_instance ) # > HTTP can handle binary data and so there is no need to use the # > content transfer encodings of MIME # # https://datatracker.ietf.org/doc/html/rfc4130#section-5.2.1 req.body = encrypted.to_der resp = nil exception = nil mdn_report = {} begin # note: to pass this traffic through a debugging proxy (like Charles) # set ENV['http_proxy']. http = Net::HTTP.new(@partner.url.host, @partner.url.port) use_ssl = @partner.url.scheme == 'https' http.use_ssl = use_ssl if use_ssl if @partner.tls_verify_mode http.verify_mode = @partner.tls_verify_mode end end # http.set_debug_output $stderr http.start do resp = http.request(req) end if resp && resp.code.start_with?('2') mdn_report = evaluate_mdn( mdn_content_type: resp['Content-Type'], mdn_body: resp.body, original_message_id: req['Message-ID'], original_body: document_payload ) end rescue => e exception = e end Result.new( request: req, response: resp, mic_matched: mdn_report[:mic_matched], mid_matched: mdn_report[:mid_matched], body: mdn_report[:plain_text_body], disposition: mdn_report[:disposition], signature_verification_error: mdn_report[:signature_verification_error], exception: exception, outbound_message_id: ) end |