as2 0.5.2 → 0.7.0

Sign up to get free protection for your applications and to get access to all the features.
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