as2 0.5.2 → 0.7.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: 2cc88a7c3befd19715a142f886fcac0429911b1b2c6d4df8e5c6f6797c6469c2
4
- data.tar.gz: ba6943a989242be148d7e39c896f61747b8455888883b60ff179d6bf53e25309
3
+ metadata.gz: 78765b4a0fba8aaa7e3531ee9e3841a0774d72b6a1d09b18a69b53117cf26e22
4
+ data.tar.gz: c020c365f57a2dd428501b453bb349483bc98baf6db263dab7dfdfbab33b70a6
5
5
  SHA512:
6
- metadata.gz: 14dc1ecb69d8678db913d9ee2425ae35f3e2e1b9a927a3a573e36e9a704dd4a7ab994d7c98182bf0053f40520040f0001fbc8b1390c82ebd57aa7e5c3495e99c
7
- data.tar.gz: e43327ebbeac9bd46a2ccc4e3cd13f6354de46a3507fe01b134ecf33feef6643555c63e2ad2310debf8b3a23ec88c985d869087bf4cce29a2c54d8d06cfa753a
6
+ metadata.gz: 13155476fe9dab95fa56e29ec8dc842c86d4cdba0bf296aec518801d4ff8ef3f963bdb7e20d9e17bbc58bfdf334d9c760ccd712b267f70882b8e7ec79fa9230a
7
+ data.tar.gz: 1c6806d55b297222c0133e27f53914143ba0b3b35891c8076e93befe9d375534551b492890092432015a793dd88d97d8489ce69635e6ceeb8495f00880c5bd82
data/.gitignore CHANGED
@@ -2,3 +2,4 @@ certs
2
2
  .DS_Store
3
3
  Gemfile.lock
4
4
  pkg
5
+ tmp/inbox/*
data/CHANGELOG.md CHANGED
@@ -1,3 +1,21 @@
1
+ ## 0.7.0, August 25, 2023
2
+
3
+ Two improvements in compatibility with IBM Sterling, which could not understand
4
+ our existing message & MDN formats.
5
+
6
+ These changes are opt-in only, and require a config change to use. See linked PRs for
7
+ details.
8
+
9
+ * Improved formatting of MDN messages. [#25](https://github.com/alexdean/as2/pull/25)
10
+ * Improved formatting of outbound messages. [#28](https://github.com/alexdean/as2/pull/28)
11
+
12
+ ## 0.6.0, April 4, 2023
13
+
14
+ * allow verification of signed MDNs which use `Content-Transfer-Encoding: binary`. [#22](https://github.com/alexdean/as2/pull/22)
15
+ * Improve example server to make it more useful for local testing & development. [#17](https://github.com/alexdean/as2/pull/17)
16
+ * Support `Content-Tranfer-Encoding: binary`. [#11](https://github.com/alexdean/as2/pull/11)
17
+ * Server can choose MIC algorithm based on HTTP `Disposition-Notification-Options` header. [#20](https://github.com/alexdean/as2/pull/20)
18
+
1
19
  ## 0.5.1, August 10, 2022
2
20
 
3
21
  * Any HTTP 2xx status received from a partner should be considered successful. [#12](https://github.com/andjosh/as2/pull/12)
data/README.md CHANGED
@@ -3,10 +3,11 @@
3
3
  This is a proof of concept implementation of AS2 protocol: http://www.ietf.org/rfc/rfc4130.txt.
4
4
 
5
5
  Tested with the mendelson AS2 implementation from http://as2.mendelson-e-c.com
6
+ and with [OpenAS2](https://github.com/OpenAS2/OpenAs2App).
6
7
 
7
8
  ## Build Status
8
9
 
9
- [![Test Suite](https://github.com/andjosh/as2/actions/workflows/test.yml/badge.svg)](https://github.com/andjosh/as2/actions/workflows/test.yml)
10
+ [![Test Suite](https://github.com/alexdean/as2/actions/workflows/test.yml/badge.svg)](https://github.com/alexdean/as2/actions/workflows/test.yml)
10
11
 
11
12
  ## Known Limitations
12
13
 
@@ -25,15 +26,10 @@ along.
25
26
  4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
26
27
  delivery of MDNs.
27
28
  5. Security Formatting: We should be reasonably compliant here.
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
- will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
31
- mentions md5. Mendelson AS2 server supports a number of other algorithms.
32
- (sha256, sha512, etc)
33
- 2. Payload bodies can have a few different mime types. We expect a type that
34
- matches `application/EDI-*`. We're unable to receive content that has any other
35
- mime type. https://datatracker.ietf.org/doc/html/rfc1767#section-1
36
- 3. AS2 partners may agree to use separate certificates for data encryption and data signing.
29
+ 6. Hash Function, Message Digest Choices: We currently always use sha256 for
30
+ signing. Since [#20](https://github.com/alexdean/as2/pull/20) we have supported
31
+ allowing partners to request which algorithm we use for MIC generation in MDNs.
32
+ 2. AS2 partners may agree to use separate certificates for data encryption and data signing.
37
33
  We do not support separate certificates for these purposes.
38
34
 
39
35
  ## Installation
@@ -78,7 +74,7 @@ You can run a local server with `bundle exec ruby examples/server.rb` and send i
78
74
 
79
75
  ## Contributing
80
76
 
81
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/as2.
77
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alexdean/as2.
82
78
 
83
79
 
84
80
  ## License
data/as2.gemspec CHANGED
@@ -32,7 +32,8 @@ Gem::Specification.new do |spec|
32
32
 
33
33
  spec.add_development_dependency "bundler", ">= 1.10"
34
34
  spec.add_development_dependency "rake", ">= 10.0"
35
- spec.add_development_dependency "thin"
35
+ spec.add_development_dependency "rackup"
36
+ spec.add_development_dependency "puma"
36
37
  spec.add_development_dependency "minitest"
37
38
  spec.add_development_dependency "minitest-focus"
38
39
  spec.add_development_dependency "webmock"
data/examples/server.rb CHANGED
@@ -1,32 +1,104 @@
1
+ # test server receives files & saves them to the local filesystem
2
+ #
3
+ # `bundle exec ruby examples/server.rb`
1
4
  require 'as2'
2
- require 'rack'
5
+ require 'rackup'
6
+ require 'rack/handler/puma'
7
+ require 'pathname'
8
+ require 'fileutils'
9
+
10
+ this_dir = Pathname.new(File.expand_path('..', __FILE__))
11
+ root_dir = this_dir.join('..')
3
12
 
4
13
  As2.configure do |conf|
5
- conf.name = 'MyServer'
14
+ conf.name = 'RUBYAS2'
6
15
  conf.url = 'http://localhost:3000/as2'
7
16
  conf.certificate = 'test/certificates/server.crt'
8
17
  conf.pkey = 'test/certificates/server.key'
9
- conf.domain = 'mydomain.com'
18
+ conf.domain = 'localhost'
19
+
10
20
  conf.add_partner do |partner|
11
- partner.name = 'MyClient'
21
+ partner.name = 'MENDELSON'
12
22
  partner.url = 'http://localhost:8080/as2/HttpReceiver'
13
23
  partner.certificate = 'test/certificates/client.crt'
14
24
  end
25
+
26
+ conf.add_partner do |partner|
27
+ partner.name = 'OPENAS2'
28
+ partner.url = 'http://localhost:4088'
29
+ partner.certificate = 'test/certificates/client.crt'
30
+ end
31
+ end
32
+
33
+ def log(message, transmission_id: nil)
34
+ puts "#{Time.now.strftime('%F %T')} [#{transmission_id}] #{message}"
15
35
  end
16
36
 
17
- handler = As2::Server.new do |filename, body|
18
- puts "SUCCESSFUL DOWNLOAD"
19
- puts "FILENAME: #{filename}"
20
- puts
21
- puts body
37
+ # TODO: there are a lot of potential failure cases we're not handling
38
+ # (failed decryption, unsigned message, etc), because this script is intended for
39
+ # local debugging.
40
+ handler = Proc.new do |env|
41
+ transmission_id = "#{Time.now.strftime('%Y%m%d_%H%M%S_%L')}_#{SecureRandom.hex(6)}"
42
+ log("start.", transmission_id: transmission_id)
43
+
44
+ server_info = As2::Config.server_info
45
+
46
+ partner_name = env['HTTP_AS2_FROM']
47
+ partner = As2::Config.partners[partner_name]
48
+
49
+ log("partner:#{partner_name} known_partner?:#{!!partner}", transmission_id: transmission_id)
50
+ partner_dir = root_dir.join('tmp/inbox/', partner_name)
51
+ if !File.exist?(partner_dir)
52
+ FileUtils.mkdir_p(partner_dir)
53
+ end
54
+
55
+ raw_request_body = env['rack.input'].read
56
+
57
+ mic_algorithm = As2.choose_mic_algorithm(env['HTTP_DISPOSITION_NOTIFICATION_OPTIONS'])
58
+ message = As2::Message.new(raw_request_body, server_info.pkey, server_info.certificate,
59
+ mic_algorithm: mic_algorithm
60
+ )
61
+
62
+ # do this before writing to disk, in case we have to fix content.
63
+ # @see https://github.com/alexdean/as2/pull/11
64
+ valid_signature = message.valid_signature?(partner.certificate)
65
+
66
+ original_filename = message.attachment.filename
67
+ extname = File.extname(original_filename)
68
+ basename = partner_dir.join(File.basename(message.attachment.filename, extname)).to_s
69
+ encrypted_filename = "#{basename}.pkcs7" # exactly what we got on the wire
70
+ decrypted_filename = "#{basename}.mime" # full message, all parts
71
+ body_filename = "#{basename}#{extname}" # just the body part, w/o signature
72
+
73
+ File.open(encrypted_filename, 'wb') { |f| f.write(raw_request_body) }
74
+ File.open(decrypted_filename, 'wb') { |f| f.write(message.decrypted_message) }
75
+ File.open(body_filename, 'wb') { |f| f.write(message.attachment.raw_source) }
76
+
77
+ # filenames are absolute paths to each file.
78
+ # when we print output, nicer to read a path relative to the project's root.
79
+ prefix_length = root_dir.to_s.length + 1
80
+ verification_error = message.verification_error
81
+
82
+ report = <<~EOF
83
+ filename:#{original_filename}
84
+ #{encrypted_filename[prefix_length..]}
85
+ #{decrypted_filename[prefix_length..]}
86
+ #{body_filename[prefix_length..]}
87
+ valid_signature?:#{valid_signature}#{verification_error && " error:'#{verification_error}'"}
88
+ MIC: '#{message.mic}' (#{message.mic_algorithm})
89
+ EOF
90
+ log(report, transmission_id: transmission_id)
91
+
92
+ server = As2::Server.new(server_info: server_info, partner: partner)
93
+ server.send_mdn(env, message.mic, message.mic_algorithm, message.verification_error)
22
94
  end
23
95
 
24
96
  builder = Rack::Builder.new do
25
- use Rack::CommonLogger
97
+ use Rack::ShowExceptions
26
98
  map '/as2' do
27
99
  run handler
28
100
  end
29
101
  end
30
102
 
31
- puts "As2 version: #{As2::VERSION}"
32
- Rack::Handler::Thin.run builder, Port: 3000
103
+ puts "ruby-as2 version: #{As2::VERSION}"
104
+ Rack::Handler::Puma.run builder, Port: 3002, Host: '0.0.0.0'
data/lib/as2/client.rb CHANGED
@@ -4,12 +4,20 @@ module As2
4
4
  class Client
5
5
  attr_reader :partner, :server_info
6
6
 
7
+ def self.valid_outbound_formats
8
+ ['v0', 'v1']
9
+ end
10
+
7
11
  # @param [As2::Config::Partner,String] partner The partner to send a message to.
8
12
  # If a string is given, it should be a partner name which has been registered
9
13
  # via a call to #add_partner.
10
14
  # @param [As2::Config::ServerInfo,nil] server_info The server info used to identify
11
15
  # this client to the partner. If omitted, the main As2::Config.server_info will be used.
12
- def initialize(partner, server_info: nil)
16
+ # @param [Logger, nil] logger If supplied, some additional information about how
17
+ # messages are processed will be written here.
18
+ def initialize(partner, server_info: nil, logger: nil)
19
+ @logger = logger || Logger.new('/dev/null')
20
+
13
21
  if partner.is_a?(As2::Config::Partner)
14
22
  @partner = partner
15
23
  else
@@ -60,18 +68,22 @@ module As2
60
68
  req['Message-ID'] = outbound_message_id
61
69
 
62
70
  document_content = content || File.read(file_name)
71
+ outbound_format = @partner&.outbound_format || 'v0'
63
72
 
64
- document_payload = "Content-Type: #{content_type}\r\n"
65
- document_payload << "Content-Transfer-Encoding: base64\r\n"
66
- document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
67
- document_payload << "\r\n"
68
- document_payload << Base64.strict_encode64(document_content)
73
+ if outbound_format == 'v1'
74
+ format_method = :format_body_v1
75
+ else
76
+ format_method = :format_body_v0
77
+ end
78
+
79
+ document_payload, request_body = send(format_method,
80
+ document_content,
81
+ content_type: content_type,
82
+ file_name: file_name
83
+ )
69
84
 
70
- signature = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, document_payload
71
- signature.detached = true
72
- container = OpenSSL::PKCS7.write_smime signature, document_payload
73
85
  cipher = OpenSSL::Cipher::AES256.new(:CBC) # default, but we might have to make this configurable
74
- encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container, cipher
86
+ encrypted = OpenSSL::PKCS7.encrypt([@partner.certificate], request_body, cipher)
75
87
 
76
88
  # > HTTP can handle binary data and so there is no need to use the
77
89
  # > content transfer encodings of MIME
@@ -119,6 +131,109 @@ module As2
119
131
  )
120
132
  end
121
133
 
134
+ # 'original' body formatting
135
+ #
136
+ # 1. uses OpenSSL::PKCS7.write_smime to build MIME body
137
+ # * includes plain-text "this is an S/MIME message" note prior to initial
138
+ # MIME boundary
139
+ # 2. uses non-standard application/x-pkcs7-* content types
140
+ # 3. MIME boundaries and signature have \n line endings
141
+ #
142
+ # this format is understood by Mendelson, OpenAS2, and several commercial
143
+ # products (GoAnywhere MFT). it is not understood by IBM Sterling B2B Integrator.
144
+ #
145
+ # @param [String] document_content the content to be transmitted
146
+ # @param [String] content_type the MIME type for document_content
147
+ # @param [String] file_name The filename to be transmitted to the partner
148
+ # @return [Array]
149
+ # first item is the full document part of the transmission (including) MIME headers.
150
+ # second item is the complete HTTP body.
151
+ def format_body_v0(document_content, content_type:, file_name:)
152
+ document_payload = "Content-Type: #{content_type}\r\n"
153
+ document_payload << "Content-Transfer-Encoding: base64\r\n"
154
+ document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
155
+ document_payload << "\r\n"
156
+ document_payload << Base64.strict_encode64(document_content)
157
+
158
+ signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload)
159
+ signature.detached = true
160
+
161
+ [document_payload, OpenSSL::PKCS7.write_smime(signature, document_payload)]
162
+ end
163
+
164
+ # updated body formatting
165
+ #
166
+ # 1. no content before the first MIME boundary
167
+ # 2. uses standard application/pkcs7-* content types
168
+ # 3. MIME boundaries and signature have \r\n line endings
169
+ # 4. adds parameter smime-type=signed-data to the signature's Content-Type
170
+ #
171
+ # this format is understood by Mendelson, OpenAS2, and several commercial
172
+ # products (GoAnywhere MFT) and IBM Sterling B2B Integrator.
173
+ #
174
+ # @param [String] document_content the content to be transmitted
175
+ # @param [String] content_type the MIME type for document_content
176
+ # @param [String] file_name The filename to be transmitted to the partner
177
+ # @return [Array]
178
+ # first item is the full document part of the transmission (including) MIME headers.
179
+ # second item is the complete HTTP body.
180
+ def format_body_v1(document_content, content_type:, file_name:)
181
+ document_payload = "Content-Type: #{content_type}\r\n"
182
+ document_payload << "Content-Transfer-Encoding: base64\r\n"
183
+ document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
184
+ document_payload << "\r\n"
185
+ document_payload << Base64.strict_encode64(document_content)
186
+
187
+ signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload)
188
+ signature.detached = true
189
+
190
+ # PEM (base64-encoded) signature
191
+ bare_pem_signature = signature.to_pem
192
+ # strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters
193
+ bare_pem_signature.gsub!(/^-----[^\n]+\n/, '')
194
+ # and update to canonical \r\n line endings
195
+ bare_pem_signature.gsub!(/(?<!\r)\n/, "\r\n")
196
+
197
+ # this is a hack until i can determine a better way to get the micalg parameter
198
+ # from the pkcs7 signature generated above...
199
+ # https://stackoverflow.com/questions/75934159/how-does-openssl-smime-determine-micalg-parameter
200
+ #
201
+ # also tried approach outlined in https://stackoverflow.com/questions/53044007/how-to-use-sha1-digest-during-signing-with-opensslpkcs7-sign-when-creating-smi
202
+ # but the signature generated by that method lacks some essential data. verifying those
203
+ # signatures results in an openssl error "unable to find message digest"
204
+ smime_body = OpenSSL::PKCS7.write_smime(signature, document_payload)
205
+ micalg = smime_body[/^Content-Type: multipart\/signed.*micalg=\"([^"]+)/m, 1]
206
+
207
+ # generate a MIME part boundary
208
+ #
209
+ # > A good strategy is to choose a boundary that includes
210
+ # > a character sequence such as "=_" which can never appear in a
211
+ # > quoted-printable body.
212
+ #
213
+ # https://www.rfc-editor.org/rfc/rfc2045#page-21
214
+ boundary = "----=_#{SecureRandom.hex(16).upcase}"
215
+ body_boundary = "--#{boundary}"
216
+
217
+ # body's mime headers
218
+ body = "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=#{micalg}; boundary=\"#{boundary}\"\r\n"
219
+ body += "\r\n"
220
+
221
+ # first body part: the document
222
+ body += body_boundary + "\r\n"
223
+ body += document_payload + "\r\n"
224
+
225
+ # second body part: the signature
226
+ body += body_boundary + "\r\n"
227
+ body += "Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data\r\n"
228
+ body += "Content-Transfer-Encoding: base64\r\n"
229
+ body += "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n"
230
+ body += "\r\n"
231
+ body += bare_pem_signature
232
+ body += body_boundary + "--\r\n"
233
+
234
+ [document_payload, body]
235
+ end
236
+
122
237
  def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:)
123
238
  report = {
124
239
  signature_verification_error: :not_checked,
@@ -132,22 +247,18 @@ module As2
132
247
  response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
133
248
 
134
249
  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
250
+ result = parse_signed_mdn(
251
+ multipart_signed_message: response_content,
252
+ certificate: @partner.certificate
253
+ )
254
+ mdn_report = result[:mdn_report]
255
+ report[:signature_verification_error] = result[:signature_verification_error]
145
256
  else
146
257
  # MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
147
- mail = Mail.new(response_content)
258
+ mdn_report = Mail.new(response_content)
148
259
  end
149
260
 
150
- mail.parts.each do |part|
261
+ mdn_report.parts.each do |part|
151
262
  if part.content_type.start_with?('text/plain')
152
263
  report[:plain_text_body] = part.body.to_s.strip
153
264
  elsif part.content_type.start_with?('message/disposition-notification')
@@ -163,8 +274,8 @@ module As2
163
274
  end
164
275
  end
165
276
 
166
- report[:disposition] = options['disposition']
167
- report[:mid_matched] = original_message_id == options['original-message-id']
277
+ report[:disposition] = options['disposition'].strip
278
+ report[:mid_matched] = original_message_id == options['original-message-id'].strip
168
279
 
169
280
  if options['received-content-mic']
170
281
  # do mic calc using the algorithm specified by server.
@@ -182,5 +293,98 @@ module As2
182
293
  end
183
294
  report
184
295
  end
296
+
297
+ private
298
+
299
+ # extract the MDN body from a multipart/signed wrapper & attempt to verify
300
+ # the signature
301
+ #
302
+ # @param [String] multipart_signed_message The 'outer' MDN body, containing MIME header,
303
+ # MDN body (which itself is likely a multi-part object) and a signature.
304
+ # @param [OpenSSL::X509::Certificate] verify that the MDN body was signed using this certificate
305
+ # @return [Hash] results of the check
306
+ # * :mdn_mime_body [Mail::Message] The 'inner' MDN body, with signature removed
307
+ # * :signature_verification_error [String] Any error which resulted when checking the
308
+ # signature. If this is empty it means the signature was valid.
309
+ def parse_signed_mdn(multipart_signed_message:, certificate:)
310
+ smime = nil
311
+
312
+ begin
313
+ # This will fail if the signature is binary-encoded. In that case
314
+ # we rescue so we can continue to extract other data from the MDN.
315
+ # User can decide how to proceed after the signature verification failure.
316
+ #
317
+ # > The parser assumes that the PKCS7 structure is always base64 encoded
318
+ # > and will not handle the case where it is in binary format or uses quoted
319
+ # > printable format.
320
+ #
321
+ # https://www.openssl.org/docs/man3.1/man3/SMIME_read_PKCS7.html
322
+ #
323
+ # Likely we can resolve this by building a PKCS7 manually from the MDN
324
+ # payload, rather than using `read_smime`.
325
+ #
326
+ # An aside: manually base64-encoding the binary signature allows the MDN
327
+ # to be parsed & verified via `read_smime`, so that could also be an option.
328
+ smime = OpenSSL::PKCS7.read_smime(multipart_signed_message)
329
+ rescue => e
330
+ @logger.warn "error checking signature using read_smime. #{e.message}"
331
+ signature_verification_error = e.message
332
+ end
333
+
334
+ if smime
335
+ # create mail instance before #verify call.
336
+ # `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
337
+ mdn_report = Mail.new(smime.data)
338
+
339
+ # based on As2::Message version
340
+ # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
341
+ # See notes in As2::Message.verify for reasoning on flag usage
342
+ smime.verify [certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
343
+
344
+ signature_verification_error = smime.error_string
345
+ else
346
+ @logger.info "trying fallback sigature verification."
347
+ # read_smime will fail on binary-encoded MDNs. in this case, we can attempt
348
+ # to parse the structure using Mail and do signature verification
349
+ # slightly differently.
350
+ #
351
+ # what follows is the same process applied in As2::Message#valid_signature?.
352
+ # see notes there for more info on "multipart/signed" MIME messages.
353
+ #
354
+ # 1. maybe unify these at some point?
355
+ # 2. maybe always use this process, and drop initial attempt at
356
+ # `OpenSSL::PKCS7.read_smime` above.
357
+ #
358
+ # refactoring to allow using As2::Message#valid_signature? here
359
+ # would also allow us to utilize the line-ending fixup code there
360
+
361
+ # this should have 2 parts. the MDN report (parts[0]) and the signature (parts[1])
362
+ #
363
+ # * https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
364
+ # * see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
365
+ outer_mail = Mail.new(multipart_signed_message)
366
+
367
+ mdn_report = outer_mail.parts[0]
368
+
369
+ content = mdn_report.raw_source
370
+ content = content.gsub(/\A\s+/, '')
371
+
372
+ signature = outer_mail.parts[1]
373
+ signature_text = signature.body.to_s
374
+
375
+ result = As2::Message.verify(
376
+ content: content,
377
+ signature_text: signature_text,
378
+ certificate: @partner.certificate
379
+ )
380
+
381
+ signature_verification_error = result[:error]
382
+ end
383
+
384
+ {
385
+ mdn_report: mdn_report,
386
+ signature_verification_error: signature_verification_error
387
+ }
388
+ end
185
389
  end
186
390
  end
data/lib/as2/config.rb CHANGED
@@ -12,7 +12,7 @@ module As2
12
12
  end
13
13
  end
14
14
 
15
- class Partner < Struct.new :name, :url, :certificate
15
+ class Partner < Struct.new :name, :url, :certificate, :mdn_format, :outbound_format
16
16
  def url=(url)
17
17
  if url.kind_of? String
18
18
  self['url'] = URI.parse url
@@ -21,6 +21,24 @@ module As2
21
21
  end
22
22
  end
23
23
 
24
+ def mdn_format=(format)
25
+ format_s = format.to_s
26
+ valid_formats = As2::Server.valid_mdn_formats
27
+ if !valid_formats.include?(format_s)
28
+ raise ArgumentError, "mdn_format '#{format_s}' must be one of #{valid_formats.inspect}"
29
+ end
30
+ self['mdn_format'] = format_s
31
+ end
32
+
33
+ def outbound_format=(format)
34
+ format_s = format.to_s
35
+ valid_formats = As2::Client.valid_outbound_formats
36
+ if !valid_formats.include?(format_s)
37
+ raise ArgumentError, "outbound_format '#{format_s}' must be one of #{valid_formats.inspect}"
38
+ end
39
+ self['outbound_format'] = format_s
40
+ end
41
+
24
42
  def certificate=(certificate)
25
43
  self['certificate'] = As2::Config.build_certificate(certificate)
26
44
  end
@@ -14,11 +14,17 @@ module As2
14
14
  @map.keys
15
15
  end
16
16
 
17
+ def self.valid?(code)
18
+ @map[normalized(code)]
19
+ end
20
+
17
21
  def self.for_code(code)
18
- # we may receive 'sha256', 'sha-256', or 'SHA256'.
19
- normalized = code.strip.downcase.gsub(/[^a-z0-9]/, '')
22
+ @map[normalized(code)] || OpenSSL::Digest::SHA1
23
+ end
20
24
 
21
- @map[normalized] || OpenSSL::Digest::SHA1
25
+ def self.normalized(code)
26
+ # we may receive 'sha256', 'sha-256', or 'SHA256'.
27
+ code.to_s.strip.downcase.gsub(/[^a-z0-9]/, '')
22
28
  end
23
29
  end
24
30
  end
data/lib/as2/message.rb CHANGED
@@ -2,9 +2,10 @@ module As2
2
2
  class Message
3
3
  attr_reader :verification_error
4
4
 
5
- # given multiple parts of a message, choose the one most likely to be the actual content we care about
5
+ # given multiple parts of a message, choose the one most likely to be the
6
+ # actual content we care about
6
7
  #
7
- # @param [Array<Mail::Part>]
8
+ # @param [Mail::PartsList] mail_parts
8
9
  # @return [Mail::Part, nil]
9
10
  def self.choose_attachment(mail_parts)
10
11
  return nil if mail_parts.nil?
@@ -16,6 +17,18 @@ module As2
16
17
  candidates[0]
17
18
  end
18
19
 
20
+ # return the mail part containing a digital signature
21
+ #
22
+ # @param [Mail::PartsList] mail_parts
23
+ # @return [Mail::Part, nil]
24
+ def self.choose_signature(mail_parts)
25
+ return nil if mail_parts.nil?
26
+
27
+ mail_parts.find { |part| part.content_type.to_s['pkcs7-signature'] }
28
+ end
29
+
30
+ # calculate the MIC for a given mail part
31
+ #
19
32
  # @param [Mail::Part] attachment
20
33
  # @param [String] mic_algorithm
21
34
  # @return [String] message integrity check string
@@ -24,50 +37,37 @@ module As2
24
37
  digest.base64digest(attachment.raw_source.lstrip)
25
38
  end
26
39
 
27
- def initialize(message, private_key, public_certificate)
28
- # TODO: might need to use OpenSSL::PKCS7.read_smime rather than .new sometimes
29
- @pkcs7 = OpenSSL::PKCS7.new(message)
30
- @private_key = private_key
31
- @public_certificate = public_certificate
32
- @verification_error = nil
33
- end
34
-
35
- def decrypted_message
36
- @decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
37
- end
38
-
39
- def valid_signature?(partner_certificate)
40
- content_type = mail.header_fields.find { |h| h.name == 'Content-Type' }.content_type
41
- if content_type == "multipart/signed"
42
- # for a "multipart/signed" message, we will do 'detatched' signature
43
- # verification, where we supply the data to be verified as the 3rd parameter
44
- # to OpenSSL::PKCS7#verify. this is in keeping with how this content type
45
- # is described in the S/MIME RFC.
46
- #
47
- # > The multipart/signed MIME type has two parts. The first part contains
48
- # > the MIME entity that is signed; the second part contains the "detached signature"
49
- # > CMS SignedData object in which the encapContentInfo eContent field is absent.
50
- #
51
- # https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3.1
52
-
53
- # TODO: more robust detection of content vs signature (if they're ever out of order).
54
- content = mail.parts[0].raw_source
55
- # remove any leading \r\n characters (between headers & body i think).
56
- content = content.gsub(/\A\s+/, '')
57
-
58
- signature = OpenSSL::PKCS7.new(mail.parts[1].body.to_s)
40
+ # Check that the signature is valid.
41
+ #
42
+ # This confirms 2 things:
43
+ #
44
+ # 1. The `signature_text` is valid for `content`, ie: the `content` has
45
+ # not been altered.
46
+ # 2. The `signature_text` was generated by the party who owns `certificate`,
47
+ # ie: The same private key generated `signature_text` and `certificate`.
48
+ #
49
+ # @param [String] content
50
+ # @param [String] signature_text
51
+ # @param [OpenSSL::X509::Certificate] certificate
52
+ # @return [Hash]
53
+ # * :valid [boolean] was the verification successful or not?
54
+ # * :error [String, nil] a verification error message.
55
+ # will be empty when `valid` is true.
56
+ def self.verify(content:, signature_text:, certificate:)
57
+ begin
58
+ signature = OpenSSL::PKCS7.new(signature_text)
59
59
 
60
60
  # using an empty CA store. see notes on NOVERIFY flag below.
61
61
  store = OpenSSL::X509::Store.new
62
62
 
63
- # notes on verification proces and flags used
63
+ # notes on verification process and flags used
64
64
  #
65
65
  # ## NOINTERN
66
66
  #
67
67
  # > If PKCS7_NOINTERN is set the certificates in the message itself are
68
68
  # > not searched when locating the signer's certificate. This means that
69
69
  # > all the signers certificates must be in the certs parameter.
70
- #
70
+ # >
71
71
  # > One application of PKCS7_NOINTERN is to only accept messages signed
72
72
  # > by a small number of certificates. The acceptable certificates would
73
73
  # > be passed in the certs parameter. In this case if the signer is not
@@ -89,10 +89,112 @@ module As2
89
89
  # CA (in `store`, which is empty). alternately, we could instead remove
90
90
  # this flag, and add `partner_certificate` to `store`. but what's the point?
91
91
  # we'd only be verifying that `partner_certificate` is connected to `partner_certificate`.
92
- output = signature.verify([partner_certificate], store, content, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN)
92
+ valid = signature.verify([certificate], store, content, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN)
93
93
 
94
94
  # when `signature.verify` fails, signature.error_string will be populated.
95
- @verification_error = signature.error_string
95
+ error = signature.error_string
96
+ rescue => e
97
+ valid = false
98
+ error = "#{e.class}: #{e.message}"
99
+ end
100
+
101
+ {
102
+ valid: valid,
103
+ error: error
104
+ }
105
+ end
106
+
107
+ def initialize(message, private_key, public_certificate, mic_algorithm: nil)
108
+ # TODO: might need to use OpenSSL::PKCS7.read_smime rather than .new sometimes
109
+ @pkcs7 = OpenSSL::PKCS7.new(message)
110
+ @private_key = private_key
111
+ @public_certificate = public_certificate
112
+ @verification_error = nil
113
+
114
+ @mic_algorithm = mic_algorithm || 'sha256'
115
+ if !As2::DigestSelector.valid?(@mic_algorithm)
116
+ raise ArgumentError, "'#{@mic_algorithm}' is not a valid MIC algorithm."
117
+ end
118
+ end
119
+
120
+ def decrypted_message
121
+ @decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
122
+ end
123
+
124
+ def valid_signature?(partner_certificate)
125
+ content_type = mail.header_fields.find { |h| h.name == 'Content-Type' }.content_type
126
+ # TODO: substantial overlap between this code & the fallback/rescue code in
127
+ # As2::Client#verify_mdn_signature
128
+ if content_type == "multipart/signed"
129
+ # for a "multipart/signed" message, we will do 'detatched' signature
130
+ # verification, where we supply the data to be verified as the 3rd parameter
131
+ # to OpenSSL::PKCS7#verify. this is in keeping with how this content type
132
+ # is described in the S/MIME RFC.
133
+ #
134
+ # > The multipart/signed MIME type has two parts. The first part contains
135
+ # > the MIME entity that is signed; the second part contains the "detached signature"
136
+ # > CMS SignedData object in which the encapContentInfo eContent field is absent.
137
+ #
138
+ # https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
139
+ #
140
+ # see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
141
+
142
+ content = attachment.raw_source
143
+ # remove any leading \r\n characters (between headers & body i think).
144
+ content = content.gsub(/\A\s+/, '')
145
+
146
+ # TODO: why is signature.body.to_s different from signature.body.raw_source?
147
+ signature_text = signature.body.to_s
148
+
149
+ result = self.class.verify(
150
+ content: content,
151
+ signature_text: signature_text,
152
+ certificate: partner_certificate
153
+ )
154
+
155
+ output = result[:valid]
156
+ @verification_error = result[:error]
157
+
158
+ # HACK until https://github.com/mikel/mail/pull/1511 is available
159
+ #
160
+ # due to a bug in the mail gem (fixed in PR above), when using
161
+ # 'Content-Transfer-Encoding: binary', the body given by `attachment.raw_source`
162
+ # will have all "\n" replaced by "\r\n". This causes a signature verification
163
+ # failure.
164
+ #
165
+ # here, we try reversing this behavior (changing "\r\n" in the body back
166
+ # to "\n") and re-attempt verification.
167
+ #
168
+ # this entire block can should removed once the bugfix in mail gem is
169
+ # released & integrated into as2.
170
+ #
171
+ # we don't really know that verification failed due to line-ending mismatch.
172
+ # it's only a guess.
173
+ if !output && attachment.content_transfer_encoding == 'binary'
174
+ # TODO: log when this happens.
175
+ # include attachment.content_transfer_encoding, the results of the initial verification
176
+ # and the results of the re-attempted verification
177
+
178
+ body_delimiter = "\r\n\r\n"
179
+ # split on first occurrence of `body_delimiter`
180
+ # any trailing occurrences of `body_delimiter` are preserved as part of `body`
181
+ headers, _, body = content.partition(body_delimiter)
182
+
183
+ body.gsub!("\r\n", "\n") # cross fingers...
184
+ content = headers + body_delimiter + body
185
+
186
+ retry_output = self.class.verify(
187
+ content: content,
188
+ signature_text: signature_text,
189
+ certificate: partner_certificate
190
+ )
191
+
192
+ if retry_output[:valid]
193
+ @attachment = Mail::Part.new(content)
194
+ @verification_error = retry_output[:error]
195
+ output = retry_output[:valid]
196
+ end
197
+ end
96
198
 
97
199
  output
98
200
  else
@@ -106,14 +208,27 @@ module As2
106
208
  end
107
209
 
108
210
  def mic_algorithm
109
- 'sha256'
211
+ @mic_algorithm
110
212
  end
111
213
 
112
214
  # Return the attached file, use .filename and .body on the return value
215
+ # This is the content the sender is sending to us.
216
+ #
217
+ # @todo maybe rename this to `payload`. 'attachment' sounds very email.
218
+ # @return [Mail::Part]
113
219
  def attachment
114
- self.class.choose_attachment(parts)
220
+ @attachment ||= self.class.choose_attachment(parts)
221
+ end
222
+
223
+ # Return the digital signature which is part of the incoming message.
224
+ # Will return `nil` for unsigned messages
225
+ #
226
+ # @return [Mail::Part]
227
+ def signature
228
+ @signature ||= self.class.choose_signature(parts)
115
229
  end
116
230
 
231
+ # TODO: deprecate this, or make it private
117
232
  def parts
118
233
  mail&.parts
119
234
  end
@@ -0,0 +1,71 @@
1
+ module As2
2
+ module Parser
3
+ # parse an AS2 HTTP Content-Disposition-Options header
4
+ # Structure is described in https://datatracker.ietf.org/doc/html/rfc4130#section-7.3
5
+ #
6
+ # don't use this directly. use As2.choose_mic_algorithm instead.
7
+ #
8
+ # @api private
9
+ class DispositionNotificationOptions
10
+ Result = Struct.new(:value, :attributes, :raw, keyword_init: true) do
11
+ def [](key)
12
+ normalized = As2::Parser::DispositionNotificationOptions.normalize_key(key)
13
+ attributes[normalized]
14
+ end
15
+
16
+ def to_s
17
+ raw.to_s
18
+ end
19
+ end
20
+
21
+ def self.normalize_key(raw)
22
+ raw.to_s.downcase
23
+ end
24
+
25
+ # parse a single header body (without the name)
26
+ #
27
+ # @example parse('signed-receipt-protocol=required, pkcs7-signature; signed-receipt-micalg=optional, sha1')
28
+ # @return [As2::Parser::DispositionNotificationOptions::Result]
29
+ def self.parse(raw_body)
30
+ value = nil
31
+ attributes = {}
32
+
33
+ body_parts = raw_body.to_s.split(';').map(&:strip)
34
+
35
+ body_parts.each do |part|
36
+ if part.include?('=')
37
+ part_key, _, part_value = part.partition('=')
38
+ part_value = split_part(part_value)
39
+
40
+ # force lower-case to make access more reliable
41
+ part_key = normalize_key(part_key)
42
+
43
+ attributes[part_key] = part_value
44
+ else
45
+ value = split_part(part)
46
+ end
47
+ end
48
+
49
+ Result.new(raw: raw_body, value: value, attributes: attributes)
50
+ end
51
+
52
+ private
53
+
54
+ # convert CSV to array
55
+ # remove quotes
56
+ # single value returned as scalar not array
57
+ def self.split_part(part)
58
+ part_value = part.split(',').map do |value|
59
+ out = value.strip
60
+ # remove quotes
61
+ if out[0] == out[-1] && (out[0] == "'" || out[0] == '"')
62
+ out = out[1..-2]
63
+ end
64
+ out
65
+ end
66
+
67
+ part_value
68
+ end
69
+ end
70
+ end
71
+ end
data/lib/as2/parser.rb ADDED
@@ -0,0 +1,11 @@
1
+ module As2
2
+ # namespace to hold parsers for various HTTP headers & perhaps other kinds of
3
+ # structured content.
4
+ #
5
+ # clients should not use this directly. use the public api exposed in the
6
+ # top-level `As2` module itself.
7
+ #
8
+ # @api private
9
+ module Parser
10
+ end
11
+ end
data/lib/as2/server.rb CHANGED
@@ -8,6 +8,11 @@ module As2
8
8
  class Server
9
9
  attr_accessor :logger
10
10
 
11
+ # each of these should be understandable by send_mdn
12
+ def self.valid_mdn_formats
13
+ ['v0', 'v1']
14
+ end
15
+
11
16
  # @param [As2::Config::ServerInfo] server_info Config used for naming of this
12
17
  # server and key/certificate selection. If omitted, the main As2::Config.server_info is used.
13
18
  # @param [As2::Config::Partner] partner Which partner to receive messages from.
@@ -81,27 +86,61 @@ module As2
81
86
  report = MimeGenerator::Part.new
82
87
  report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
83
88
 
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
+ text_part = MimeGenerator::Part.new
90
+ text_part['Content-Type'] = 'text/plain'
91
+ text_part['Content-Transfer-Encoding'] = '7bit'
92
+ text_part.body = text_body
93
+ report.add_part text_part
89
94
 
90
- notification = MimeGenerator::Part.new
91
- notification['Content-Type'] = 'message/disposition-notification'
92
- notification['Content-Transfer-Encoding'] = '7bit'
93
- notification.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
94
- report.add_part notification
95
+ notification_part = MimeGenerator::Part.new
96
+ notification_part['Content-Type'] = 'message/disposition-notification'
97
+ notification_part['Content-Transfer-Encoding'] = '7bit'
98
+ notification_part.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
99
+ report.add_part notification_part
95
100
 
96
101
  msg_out = StringIO.new
97
-
98
102
  report.write msg_out
103
+ mdn_text = msg_out.string
104
+
105
+ mdn_format = @partner&.mdn_format || 'v0'
106
+ if mdn_format == 'v1'
107
+ format_method = :format_mdn_v1
108
+ else
109
+ format_method = :format_mdn_v0
110
+ end
99
111
 
100
- pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, msg_out.string
112
+ headers, body = send(
113
+ format_method,
114
+ mdn_text,
115
+ as2_to: env['HTTP_AS2_FROM']
116
+ )
117
+
118
+ [200, headers, ["\r\n" + body]]
119
+ end
120
+
121
+ # 'original' MDN formatting
122
+ #
123
+ # 1. uses OpenSSL::PKCS7.write_smime to build MIME body
124
+ # * includes MIME headers in HTTP body
125
+ # * includes plain-text "this is an S/MIME message" note prior to initial
126
+ # MIME boundary
127
+ # 2. uses non-standard application/x-pkcs7-* content types
128
+ # 3. MIME boundaries and signature have \n line endings
129
+ #
130
+ # this format is understood by Mendelson, OpenAS2, and several commercial
131
+ # products (GoAnywhere MFT). it is not understood by IBM Sterling B2B Integrator.
132
+ #
133
+ # @param [String] mdn_text MIME multipart/report body containing text/plain
134
+ # and message/disposition-notification parts
135
+ # @param [String] mic_algorithm
136
+ # @param [String] as2_to
137
+ def format_mdn_v0(mdn_text, as2_to:)
138
+ pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, mdn_text
101
139
  pkcs7.detached = true
102
- smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
103
140
 
104
- content_type = smime_signed[/^Content-Type: (.+?)$/m, 1]
141
+ body = OpenSSL::PKCS7.write_smime pkcs7, mdn_text
142
+
143
+ content_type = body[/^Content-Type: (.+?)$/m, 1]
105
144
  # smime_signed.sub!(/\A.+?^(?=---)/m, '')
106
145
 
107
146
  headers = {}
@@ -110,11 +149,67 @@ module As2
110
149
  headers['MIME-Version'] = '1.0'
111
150
  headers['Message-ID'] = As2.generate_message_id(@server_info)
112
151
  headers['AS2-From'] = @server_info.name
113
- headers['AS2-To'] = env['HTTP_AS2_FROM']
152
+ headers['AS2-To'] = as2_to
153
+ headers['AS2-Version'] = '1.0'
154
+ headers['Connection'] = 'close'
155
+
156
+ [headers, body]
157
+ end
158
+
159
+ def format_mdn_v1(mdn_text, as2_to:)
160
+ pkcs7 = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, mdn_text)
161
+ pkcs7.detached = true
162
+
163
+ # PEM (base64-encoded) signature
164
+ bare_pem_signature = pkcs7.to_pem
165
+ # strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters
166
+ bare_pem_signature.gsub!(/^-----[^\n]+\n/, '')
167
+ # and update to canonical \r\n line endings
168
+ bare_pem_signature.gsub!(/(?<!\r)\n/, "\r\n")
169
+
170
+ # this is a hack until i can determine a better way to get the micalg parameter
171
+ # from the pkcs7 signature generated above...
172
+ # https://stackoverflow.com/questions/75934159/how-does-openssl-smime-determine-micalg-parameter
173
+ #
174
+ # also tried approach outlined in https://stackoverflow.com/questions/53044007/how-to-use-sha1-digest-during-signing-with-opensslpkcs7-sign-when-creating-smi
175
+ # but the signature generated by that method lacks some essential data. verifying those
176
+ # signatures results in an openssl error "unable to find message digest"
177
+ smime_body = OpenSSL::PKCS7.write_smime(pkcs7, mdn_text)
178
+ micalg = smime_body[/^Content-Type: multipart\/signed.*micalg=\"([^"]+)/m, 1]
179
+
180
+ # generate a MIME part boundary
181
+ #
182
+ # > A good strategy is to choose a boundary that includes
183
+ # > a character sequence such as "=_" which can never appear in a
184
+ # > quoted-printable body.
185
+ #
186
+ # https://www.rfc-editor.org/rfc/rfc2045#page-21
187
+ boundary = "----=_#{SecureRandom.hex(16).upcase}"
188
+ body_boundary = "--#{boundary}"
189
+
190
+ headers = {}
191
+ headers['Content-Type'] = "multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"#{micalg}\"; boundary=\"#{boundary}\""
192
+ headers['MIME-Version'] = '1.0'
193
+ headers['Message-ID'] = As2.generate_message_id(@server_info)
194
+ headers['AS2-From'] = @server_info.name
195
+ headers['AS2-To'] = as2_to
114
196
  headers['AS2-Version'] = '1.0'
115
197
  headers['Connection'] = 'close'
116
198
 
117
- [200, headers, ["\r\n" + smime_signed]]
199
+ # this is the MDN report, with text/plain and message/disposition-notification parts
200
+ body = body_boundary + "\r\n"
201
+ body += mdn_text + "\r\n"
202
+
203
+ # this is the signature generated over that report
204
+ body += body_boundary + "\r\n"
205
+ body += "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n"
206
+ body += "Content-Transfer-Encoding: base64\r\n"
207
+ body += "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n"
208
+ body += "\r\n"
209
+ body += bare_pem_signature
210
+ body += body_boundary + "--\r\n"
211
+
212
+ [headers, body]
118
213
  end
119
214
 
120
215
  private
data/lib/as2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.5.2"
2
+ VERSION = "0.7.0"
3
3
  end
data/lib/as2.rb CHANGED
@@ -1,12 +1,14 @@
1
- require 'openssl'
2
1
  require 'mail'
2
+ require 'openssl'
3
3
  require 'securerandom'
4
- require 'as2/config'
5
- require 'as2/server'
4
+
6
5
  require 'as2/client'
7
6
  require 'as2/client/result'
7
+ require 'as2/config'
8
8
  require 'as2/digest_selector'
9
- require "as2/version"
9
+ require 'as2/parser/disposition_notification_options'
10
+ require 'as2/server'
11
+ require 'as2/version'
10
12
 
11
13
  module As2
12
14
  def self.configure(&block)
@@ -20,4 +22,17 @@ module As2
20
22
  def self.generate_message_id(server_info)
21
23
  "<#{server_info.name}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.uuid}@#{server_info.domain}>"
22
24
  end
25
+
26
+ # Select which algorithm to use for calculating a MIC, based on preferences
27
+ # stated by sender & our list of available algorithms.
28
+ #
29
+ # @see https://datatracker.ietf.org/doc/html/rfc4130#section-7.3
30
+ #
31
+ # @param [String] disposition_notification_options The content of an HTTP
32
+ # Disposition-Notification-Options header
33
+ # @return [String, nil] either an algorithm name, or nil if none is found in given header
34
+ def self.choose_mic_algorithm(disposition_notification_options)
35
+ parsed = As2::Parser::DispositionNotificationOptions.parse(disposition_notification_options)
36
+ Array(parsed['signed-receipt-micalg']).find { |m| As2::DigestSelector.valid?(m) }
37
+ end
23
38
  end
data/tmp/inbox/.keep ADDED
File without changes
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.5.2
4
+ version: 0.7.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-09-15 00:00:00.000000000 Z
12
+ date: 2023-08-25 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mail
@@ -68,7 +68,21 @@ dependencies:
68
68
  - !ruby/object:Gem::Version
69
69
  version: '10.0'
70
70
  - !ruby/object:Gem::Dependency
71
- name: thin
71
+ name: rackup
72
+ requirement: !ruby/object:Gem::Requirement
73
+ requirements:
74
+ - - ">="
75
+ - !ruby/object:Gem::Version
76
+ version: '0'
77
+ type: :development
78
+ prerelease: false
79
+ version_requirements: !ruby/object:Gem::Requirement
80
+ requirements:
81
+ - - ">="
82
+ - !ruby/object:Gem::Version
83
+ version: '0'
84
+ - !ruby/object:Gem::Dependency
85
+ name: puma
72
86
  requirement: !ruby/object:Gem::Requirement
73
87
  requirements:
74
88
  - - ">="
@@ -166,8 +180,11 @@ files:
166
180
  - lib/as2/digest_selector.rb
167
181
  - lib/as2/message.rb
168
182
  - lib/as2/mime_generator.rb
183
+ - lib/as2/parser.rb
184
+ - lib/as2/parser/disposition_notification_options.rb
169
185
  - lib/as2/server.rb
170
186
  - lib/as2/version.rb
187
+ - tmp/inbox/.keep
171
188
  homepage: https://github.com/alexdean/as2
172
189
  licenses:
173
190
  - MIT