as2 0.4.0 → 0.5.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: '094edd2c08516e42061e5623b49e4b2f355a3ca3a19a6ed17bc2764ea1073f9c'
4
- data.tar.gz: c7678c44a070eda090eec89d3e3bacfdc2a1400b880bb39d52f2ba3e08ffe300
3
+ metadata.gz: 40b3957adcdaa34f7df9fe0ee83966f7076e80bc9dc9c02b8f27a4fb22395fda
4
+ data.tar.gz: 1d8002959f6b4afa70c94fd363c9d97c15b2f20a82ee96b1bc39ebba8a55972e
5
5
  SHA512:
6
- metadata.gz: c4ecd6838eb1e62174c584638141ba0f2af696bfd906765a0f3ffd4928e03ed3d203e9160c2ce5731439e282283fbf2b511b891719c98eeff262834b9bb1ec11
7
- data.tar.gz: 852b03c4eebc90ea86447a4074afa417556b89f8613851eb59364dc6f8b4f9f1904452da3c4160929992c2263085774ff6c8cdf46345a92e75411daf945b8123
6
+ metadata.gz: bb04b23ab84f14e131e74e3a213e71ae5f3009f805af08b7cc1c88dc3798745639e4cffea1a85811df51a522776797f236da98c82ab84863dd5891c0b37fc243
7
+ data.tar.gz: a71c9ea6c4c82637103ae1353e5b54cee88ad4f52b388883b0b409c24722247bbd0ead903ca5ef00ba1df41403ab11f1d6b4b03f9d03f58af39ccb399848adf0
data/CHANGELOG.md CHANGED
@@ -1,3 +1,9 @@
1
+ ## 0.5.0, March 21, 2022
2
+
3
+ * improvements to `As2::Client`. improve compatibility with non-Mendelson AS2 servers. [#8](https://github.com/andjosh/as2/pull/8)
4
+ * improve MDN generation, especially when an error occurs. [#9](https://github.com/andjosh/as2/pull/9)
5
+ * successfully parse unsigned MDNs. [#10](https://github.com/andjosh/as2/pull/10)
6
+
1
7
  ## 0.4.0, March 3, 2022
2
8
 
3
9
  * client: correct MIC & signature verification when processing MDN response [#7](https://github.com/andjosh/as2/pull/7)
data/README.md CHANGED
@@ -25,8 +25,8 @@ along.
25
25
  4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
26
26
  delivery of MDNs.
27
27
  5. Security Formatting: We should be reasonably compliant here.
28
- 6. Hash Function, Message Digest Choices: We currently always use sha1. If a
29
- partner asks for a different algorithm, we'll always use sha1 and partner
28
+ 6. Hash Function, Message Digest Choices: We currently always use sha256. If a
29
+ partner asks for a different algorithm, we'll always use sha256 and partner
30
30
  will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
31
31
  mentions md5. Mendelson AS2 server supports a number of other algorithms.
32
32
  (sha256, sha512, etc)
data/as2.gemspec CHANGED
@@ -11,7 +11,7 @@ Gem::Specification.new do |spec|
11
11
 
12
12
  spec.summary = %q{Simple AS2 server and client implementation}
13
13
  spec.description = %q{Simple AS2 server and client implementation. Follows the AS2 implementation from http://as2.mendelson-e-c.com}
14
- spec.homepage = "https://github.com/officeluv/as2"
14
+ spec.homepage = "https://github.com/andjosh/as2"
15
15
  spec.license = "MIT"
16
16
 
17
17
  # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
data/lib/as2/client.rb CHANGED
@@ -22,6 +22,14 @@ module As2
22
22
  @server_info = server_info || Config.server_info
23
23
  end
24
24
 
25
+ def as2_to
26
+ @partner.name
27
+ end
28
+
29
+ def as2_from
30
+ @server_info.name
31
+ end
32
+
25
33
  # Send a file to a partner
26
34
  #
27
35
  # * If the content parameter is omitted, then `file_name` must be a path
@@ -31,26 +39,29 @@ module As2
31
39
  #
32
40
  # @param [String] file_name
33
41
  # @param [String] content
42
+ # @param [String] content_type This is the MIME Content-Type describing the `content` param,
43
+ # and will be included in the SMIME payload. It is not the HTTP Content-Type.
34
44
  # @return [As2::Client::Result]
35
- def send_file(file_name, content: nil)
45
+ def send_file(file_name, content: nil, content_type: 'application/EDI-Consent')
46
+ outbound_mic_algorithm = 'sha256'
36
47
  outbound_message_id = As2.generate_message_id(@server_info)
37
48
 
38
49
  req = Net::HTTP::Post.new @partner.url.path
39
- req['AS2-Version'] = '1.2'
40
- req['AS2-From'] = @server_info.name
41
- req['AS2-To'] = @partner.name
42
- req['Subject'] = 'AS2 EDI Transaction'
50
+ req['AS2-Version'] = '1.0' # 1.1 includes compression support, which we dont implement.
51
+ req['AS2-From'] = as2_from
52
+ req['AS2-To'] = as2_to
53
+ req['Subject'] = 'AS2 Transaction'
43
54
  req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m'
55
+ req['Date'] = Time.now.rfc2822
44
56
  req['Disposition-Notification-To'] = @server_info.url.to_s
45
- req['Disposition-Notification-Options'] = 'signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha1'
57
+ req['Disposition-Notification-Options'] = "signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, #{outbound_mic_algorithm}"
46
58
  req['Content-Disposition'] = 'attachment; filename="smime.p7m"'
47
- req['Recipient-Address'] = @server_info.url.to_s
48
- req['Content-Transfer-Encoding'] = 'base64'
59
+ req['Recipient-Address'] = @partner.url.to_s
49
60
  req['Message-ID'] = outbound_message_id
50
61
 
51
62
  document_content = content || File.read(file_name)
52
63
 
53
- document_payload = "Content-Type: application/EDI-Consent\r\n"
64
+ document_payload = "Content-Type: #{content_type}\r\n"
54
65
  document_payload << "Content-Transfer-Encoding: base64\r\n"
55
66
  document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
56
67
  document_payload << "\r\n"
@@ -59,63 +70,38 @@ module As2
59
70
  signature = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, document_payload
60
71
  signature.detached = true
61
72
  container = OpenSSL::PKCS7.write_smime signature, document_payload
62
- encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container
63
- smime_encrypted = OpenSSL::PKCS7.write_smime encrypted
73
+ cipher = OpenSSL::Cipher::AES256.new(:CBC) # default, but we might have to make this configurable
74
+ encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container, cipher
64
75
 
65
- req.body = smime_encrypted.sub(/^.+?\n\n/m, '')
76
+ # > HTTP can handle binary data and so there is no need to use the
77
+ # > content transfer encodings of MIME
78
+ #
79
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-5.2.1
80
+ req.body = encrypted.to_der
66
81
 
67
82
  resp = nil
68
- signature_verification_error = :not_checked
69
83
  exception = nil
70
- mic_matched = nil
71
- mid_matched = nil
72
- disposition = nil
73
- plain_text_body = nil
84
+ mdn_report = {}
74
85
 
75
86
  begin
87
+ # note: to pass this traffic through a debugging proxy (like Charles)
88
+ # set ENV['http_proxy'].
76
89
  http = Net::HTTP.new(@partner.url.host, @partner.url.port)
77
90
  http.use_ssl = @partner.url.scheme == 'https'
78
91
  # http.set_debug_output $stderr
92
+ # http.verify_mode = OpenSSL::SSL::VERIFY_NONE
93
+
79
94
  http.start do
80
95
  resp = http.request(req)
81
96
  end
82
97
 
83
98
  if resp.code == '200'
84
- response_content = "Content-Type: #{resp['Content-Type']}\r\n#{resp.body}"
85
- smime = OpenSSL::PKCS7.read_smime response_content
86
- # based on As2::Message version
87
- # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
88
- smime.verify [@partner.certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
89
- signature_verification_error = smime.error_string
90
-
91
- mail = Mail.new smime.data
92
- mail.parts.each do |part|
93
- case part.content_type
94
- when 'text/plain'
95
- plain_text_body = part.body
96
- when 'message/disposition-notification'
97
- # "The rules for constructing the AS2-disposition-notification content..."
98
- # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3
99
-
100
- options = {}
101
- # TODO: can we use Mail built-ins for this?
102
- part.body.to_s.lines.each do |line|
103
- if line =~ /^([^:]+): (.+)$/
104
- options[$1] = $2
105
- end
106
- end
107
-
108
- disposition = options['Disposition']
109
- mid_matched = req['Message-ID'] == options['Original-Message-ID']
110
-
111
- # do mic calc using the algorithm specified by server.
112
- # (even if we specify sha1, server may send back MIC using a different algo.)
113
- received_mic, micalg = options['Received-Content-MIC'].split(',').map(&:strip)
114
- micalg ||= 'sha1'
115
- mic = As2::DigestSelector.for_code(micalg).base64digest(document_payload)
116
- mic_matched = received_mic == mic
117
- end
118
- end
99
+ mdn_report = evaluate_mdn(
100
+ mdn_content_type: resp['Content-Type'],
101
+ mdn_body: resp.body,
102
+ original_message_id: req['Message-ID'],
103
+ original_body: document_payload
104
+ )
119
105
  end
120
106
  rescue => e
121
107
  exception = e
@@ -123,14 +109,78 @@ module As2
123
109
 
124
110
  Result.new(
125
111
  response: resp,
126
- mic_matched: mic_matched,
127
- mid_matched: mid_matched,
128
- body: plain_text_body,
129
- disposition: disposition,
130
- signature_verification_error: signature_verification_error,
112
+ mic_matched: mdn_report[:mic_matched],
113
+ mid_matched: mdn_report[:mid_matched],
114
+ body: mdn_report[:plain_text_body],
115
+ disposition: mdn_report[:disposition],
116
+ signature_verification_error: mdn_report[:signature_verification_error],
131
117
  exception: exception,
132
118
  outbound_message_id: outbound_message_id
133
119
  )
134
120
  end
121
+
122
+ def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:)
123
+ report = {
124
+ signature_verification_error: :not_checked,
125
+ mic_matched: nil,
126
+ mid_matched: nil,
127
+ disposition: nil,
128
+ plain_text_body: nil
129
+ }
130
+
131
+ # MDN bodies we've seen so far don't include Content-Type, which causes `read_smime` to fail.
132
+ response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
133
+
134
+ if mdn_content_type.start_with?('multipart/signed')
135
+ smime = OpenSSL::PKCS7.read_smime(response_content)
136
+
137
+ # create mail instance before #verify call.
138
+ # `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
139
+ mail = Mail.new(smime.data)
140
+
141
+ # based on As2::Message version
142
+ # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
143
+ smime.verify [@partner.certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
144
+ report[:signature_verification_error] = smime.error_string
145
+ else
146
+ # MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
147
+ mail = Mail.new(response_content)
148
+ end
149
+
150
+ mail.parts.each do |part|
151
+ if part.content_type.start_with?('text/plain')
152
+ report[:plain_text_body] = part.body.to_s.strip
153
+ elsif part.content_type.start_with?('message/disposition-notification')
154
+ # "The rules for constructing the AS2-disposition-notification content..."
155
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3
156
+
157
+ options = {}
158
+ # TODO: can we use Mail built-ins for this?
159
+ part.body.to_s.lines.each do |line|
160
+ if line =~ /^([^:]+): (.+)$/
161
+ # downcase because we've seen both 'Disposition' and 'disposition'
162
+ options[$1.to_s.downcase] = $2
163
+ end
164
+ end
165
+
166
+ report[:disposition] = options['disposition']
167
+ report[:mid_matched] = original_message_id == options['original-message-id']
168
+
169
+ if options['received-content-mic']
170
+ # do mic calc using the algorithm specified by server.
171
+ # (even if we specify sha1, server may send back MIC using a different algo.)
172
+ received_mic, micalg = options['received-content-mic'].split(',').map(&:strip)
173
+
174
+ # if they don't specify, we'll use the algorithm we specified in the outbound transmission.
175
+ # but it's only a guess & may fail.
176
+ micalg ||= outbound_mic_algorithm
177
+
178
+ mic = As2::DigestSelector.for_code(micalg).base64digest(original_body)
179
+ report[:mic_matched] = received_mic == mic
180
+ end
181
+ end
182
+ end
183
+ report
184
+ end
135
185
  end
136
186
  end
@@ -15,6 +15,7 @@ module As2
15
15
  end
16
16
 
17
17
  def self.for_code(code)
18
+ # we may receive 'sha256', 'sha-256', or 'SHA256'.
18
19
  normalized = code.strip.downcase.gsub(/[^a-z0-9]/, '')
19
20
 
20
21
  @map[normalized] || OpenSSL::Digest::SHA1
data/lib/as2/message.rb CHANGED
@@ -80,14 +80,20 @@ module As2
80
80
  end
81
81
 
82
82
  def mic
83
- # TODO: could use As2::DigestSelector if a different algo is needed.
84
- OpenSSL::Digest::SHA1.base64digest(attachment.raw_source.strip)
83
+ digest = As2::DigestSelector.for_code(mic_algorithm)
84
+ digest.base64digest(attachment.raw_source.strip)
85
+ end
86
+
87
+ def mic_algorithm
88
+ 'sha256'
85
89
  end
86
90
 
87
91
  # Return the attached file, use .filename and .body on the return value
88
92
  def attachment
89
93
  if mail.has_attachments?
90
- mail.parts.find{|a| a.content_type == "application/edi-consent"}
94
+ # TODO: match 'application/edi*', test with 'application/edi-x12'
95
+ # test also with "application/edi-consent; name=this_is_a_filename.txt"
96
+ mail.parts.find{ |a| a.content_type.match(/^application\/edi/) }
91
97
  else
92
98
  mail
93
99
  end
data/lib/as2/server.rb CHANGED
@@ -55,23 +55,12 @@ module As2
55
55
  end
56
56
  end
57
57
 
58
- send_mdn(env, message.mic)
58
+ send_mdn(env, message.mic, message.mic_algorithm)
59
59
  end
60
60
 
61
- def send_mdn(env, mic, failed = nil)
62
- report = MimeGenerator::Part.new
63
- report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
64
-
65
- text = MimeGenerator::Part.new
66
- text['Content-Type'] = 'text/plain'
67
- text['Content-Transfer-Encoding'] = '7bit'
68
- text.body = "The AS2 message has been received successfully"
69
-
70
- report.add_part text
71
-
72
- notification = MimeGenerator::Part.new
73
- notification['Content-Type'] = 'message/disposition-notification'
74
- notification['Content-Transfer-Encoding'] = '7bit'
61
+ def send_mdn(env, mic, mic_algorithm, failed = nil)
62
+ # rules for MDN construction are covered in
63
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.2
75
64
 
76
65
  options = {
77
66
  'Reporting-UA' => @server_info.name,
@@ -82,10 +71,25 @@ module As2
82
71
  if failed
83
72
  options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
84
73
  options['Failure'] = failed
74
+ text_body = "There was an error with the AS2 transmission.\r\n\r\n#{failed}"
85
75
  else
86
76
  options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
77
+ text_body = "The AS2 message has been received successfully"
87
78
  end
88
- options['Received-Content-MIC'] = "#{mic}, sha1" if mic
79
+ options['Received-Content-MIC'] = "#{mic}, #{mic_algorithm}" if mic
80
+
81
+ report = MimeGenerator::Part.new
82
+ report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
83
+
84
+ text = MimeGenerator::Part.new
85
+ text['Content-Type'] = 'text/plain'
86
+ text['Content-Transfer-Encoding'] = '7bit'
87
+ text.body = text_body
88
+ report.add_part text
89
+
90
+ notification = MimeGenerator::Part.new
91
+ notification['Content-Type'] = 'message/disposition-notification'
92
+ notification['Content-Transfer-Encoding'] = '7bit'
89
93
  notification.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
90
94
  report.add_part notification
91
95
 
@@ -98,15 +102,16 @@ module As2
98
102
  smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
99
103
 
100
104
  content_type = smime_signed[/^Content-Type: (.+?)$/m, 1]
101
- smime_signed.sub!(/\A.+?^(?=---)/m, '')
105
+ # smime_signed.sub!(/\A.+?^(?=---)/m, '')
102
106
 
103
107
  headers = {}
104
108
  headers['Content-Type'] = content_type
109
+ # TODO: if MIME-Version header is actually needed, should extract it out of smime_signed.
105
110
  headers['MIME-Version'] = '1.0'
106
111
  headers['Message-ID'] = As2.generate_message_id(@server_info)
107
112
  headers['AS2-From'] = @server_info.name
108
113
  headers['AS2-To'] = env['HTTP_AS2_FROM']
109
- headers['AS2-Version'] = '1.2'
114
+ headers['AS2-Version'] = '1.0'
110
115
  headers['Connection'] = 'close'
111
116
 
112
117
  [200, headers, ["\r\n" + smime_signed]]
@@ -120,7 +125,7 @@ module As2
120
125
 
121
126
  def send_error(env, msg)
122
127
  logger(env).error msg
123
- send_mdn env, nil, msg
128
+ send_mdn env, nil, 'sha1', msg
124
129
  end
125
130
  end
126
131
  end
data/lib/as2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.4.0"
2
+ VERSION = "0.5.0"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: as2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.4.0
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OfficeLuv
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2022-03-03 00:00:00.000000000 Z
12
+ date: 2022-03-21 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mail
@@ -168,7 +168,7 @@ files:
168
168
  - lib/as2/mime_generator.rb
169
169
  - lib/as2/server.rb
170
170
  - lib/as2/version.rb
171
- homepage: https://github.com/officeluv/as2
171
+ homepage: https://github.com/andjosh/as2
172
172
  licenses:
173
173
  - MIT
174
174
  metadata: