Class: As2::Client

Inherits:
Object
  • Object
show all
Defined in:
lib/as2/client.rb,
lib/as2/client/result.rb

Defined Under Namespace

Classes: Result

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(partner, server_info: nil, logger: nil) ⇒ Client

Returns a new instance of Client.

Parameters:

  • partner (As2::Config::Partner, String)

    The partner to send a message to. If a string is given, it should be a partner name which has been registered via a call to #add_partner.

  • server_info (As2::Config::ServerInfo, nil) (defaults to: nil)

    The server info used to identify this client to the partner. If omitted, the main As2::Config.server_info will be used.

  • logger (Logger, nil) (defaults to: nil)

    If supplied, some additional information about how messages are processed will be written here.



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

#partnerObject (readonly)

Returns the value of attribute partner.



5
6
7
# File 'lib/as2/client.rb', line 5

def partner
  @partner
end

#server_infoObject (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_ciphersObject



11
12
13
# File 'lib/as2/client.rb', line 11

def self.valid_encryption_ciphers
  OpenSSL::Cipher.ciphers
end

.valid_outbound_formatsObject



7
8
9
# File 'lib/as2/client.rb', line 7

def self.valid_outbound_formats
  ['v0', 'v1']
end

Instance Method Details

#as2_fromObject



41
42
43
# File 'lib/as2/client.rb', line 41

def as2_from
  @server_info.name
end

#as2_toObject



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

      options = {}
      # 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'
          options[$1.to_s.downcase] = $2
        end
      end

      report[:disposition] = options['disposition'].strip
      report[:mid_matched] = original_message_id == options['original-message-id'].strip

      if options['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 = options['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

  1. uses OpenSSL::PKCS7.write_smime to build MIME body

* includes plain-text "this is an S/MIME message" note prior to initial
  MIME boundary
  1. uses non-standard application/x-pkcs7-* content types

  2. 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.

Parameters:

  • document_content (String)

    the content to be transmitted

  • content_type (String)

    the MIME type for document_content

  • file_name (String)

    The filename to be transmitted to the partner

Returns:

  • (Array)

    first item is the full document part of the transmission (including) MIME headers. second item is the complete HTTP body.



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

  1. no content before the first MIME boundary

  2. uses standard application/pkcs7-* content types

  3. MIME boundaries and signature have rn line endings

  4. 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.

Parameters:

  • document_content (String)

    the content to be transmitted

  • content_type (String)

    the MIME type for document_content

  • file_name (String)

    The filename to be transmitted to the partner

Returns:

  • (Array)

    first item is the full document part of the transmission (including) MIME headers. second item is the complete HTTP body.



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
  bare_pem_signature = signature.to_pem
  # strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters
  bare_pem_signature.gsub!(/^-----[^\n]+\n/, '')
  # and update to canonical \r\n line endings
  bare_pem_signature = As2.canonicalize_line_endings(bare_pem_signature)

  # 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 += bare_pem_signature
  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…)

Parameters:

  • file_name (String)
  • content (String) (defaults to: nil)
  • content_type (String) (defaults to: 'application/EDI-Consent')

    This is the MIME Content-Type describing the ‘content` param, and will be included in the SMIME payload. It is not the HTTP Content-Type.

Returns:



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'
  outbound_message_id = As2.generate_message_id(@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'] = outbound_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: outbound_message_id
  )
end