as2 0.5.2 → 0.6.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: 44bae86d51af027c42242dcc2b746014fc1e5c408bdba4d8fda43373a3b61470
4
+ data.tar.gz: e7043ac582ceb07fa9527f029ea128015e2f96331c3217758b022e5fe2b64e3a
5
5
  SHA512:
6
- metadata.gz: 14dc1ecb69d8678db913d9ee2425ae35f3e2e1b9a927a3a573e36e9a704dd4a7ab994d7c98182bf0053f40520040f0001fbc8b1390c82ebd57aa7e5c3495e99c
7
- data.tar.gz: e43327ebbeac9bd46a2ccc4e3cd13f6354de46a3507fe01b134ecf33feef6643555c63e2ad2310debf8b3a23ec88c985d869087bf4cce29a2c54d8d06cfa753a
6
+ metadata.gz: 8f6beade2e29c96b6e0d65297296f26642a4882b3628b7d43aa2169b7b6b3ddea7abb5e791490b20a1ffb260cfbd214db5c9026a9cb61e34b4a9af05fd6f2d49
7
+ data.tar.gz: 8bac3d37290848e67e01b689872fb6504c3407c23501040f1647f17bc42ae2101669ceb6b331908f170f2242a6acf3d4cbeeafe4ad3cf2c4f24995422e358e13
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,10 @@
1
+ ## Unreleased (future 0.6.0)
2
+
3
+ * allow verification of signed MDNs which use `Content-Transfer-Encoding: binary`. [#22](https://github.com/alexdean/as2/pull/22)
4
+ * Improve example server to make it more useful for local testing & development. [#17](https://github.com/alexdean/as2/pull/17)
5
+ * Support `Content-Tranfer-Encoding: binary`. [#11](https://github.com/alexdean/as2/pull/11)
6
+ * Server can choose MIC algorithm based on HTTP `Disposition-Notification-Options` header. [#20](https://github.com/alexdean/as2/pull/20)
7
+
1
8
  ## 0.5.1, August 10, 2022
2
9
 
3
10
  * 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
 
@@ -30,10 +31,7 @@ along.
30
31
  will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
31
32
  mentions md5. Mendelson AS2 server supports a number of other algorithms.
32
33
  (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.
34
+ 2. AS2 partners may agree to use separate certificates for data encryption and data signing.
37
35
  We do not support separate certificates for these purposes.
38
36
 
39
37
  ## Installation
@@ -78,7 +76,7 @@ You can run a local server with `bundle exec ruby examples/server.rb` and send i
78
76
 
79
77
  ## Contributing
80
78
 
81
- Bug reports and pull requests are welcome on GitHub at https://github.com/[USERNAME]/as2.
79
+ Bug reports and pull requests are welcome on GitHub at https://github.com/alexdean/as2.
82
80
 
83
81
 
84
82
  ## 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
@@ -9,7 +9,11 @@ module As2
9
9
  # via a call to #add_partner.
10
10
  # @param [As2::Config::ServerInfo,nil] server_info The server info used to identify
11
11
  # this client to the partner. If omitted, the main As2::Config.server_info will be used.
12
- def initialize(partner, server_info: nil)
12
+ # @param [Logger, nil] logger If supplied, some additional information about how
13
+ # messages are processed will be written here.
14
+ def initialize(partner, server_info: nil, logger: nil)
15
+ @logger = logger || Logger.new('/dev/null')
16
+
13
17
  if partner.is_a?(As2::Config::Partner)
14
18
  @partner = partner
15
19
  else
@@ -132,22 +136,18 @@ module As2
132
136
  response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
133
137
 
134
138
  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
139
+ result = parse_signed_mdn(
140
+ multipart_signed_message: response_content,
141
+ certificate: @partner.certificate
142
+ )
143
+ mdn_report = result[:mdn_report]
144
+ report[:signature_verification_error] = result[:signature_verification_error]
145
145
  else
146
146
  # MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
147
- mail = Mail.new(response_content)
147
+ mdn_report = Mail.new(response_content)
148
148
  end
149
149
 
150
- mail.parts.each do |part|
150
+ mdn_report.parts.each do |part|
151
151
  if part.content_type.start_with?('text/plain')
152
152
  report[:plain_text_body] = part.body.to_s.strip
153
153
  elsif part.content_type.start_with?('message/disposition-notification')
@@ -163,8 +163,8 @@ module As2
163
163
  end
164
164
  end
165
165
 
166
- report[:disposition] = options['disposition']
167
- report[:mid_matched] = original_message_id == options['original-message-id']
166
+ report[:disposition] = options['disposition'].strip
167
+ report[:mid_matched] = original_message_id == options['original-message-id'].strip
168
168
 
169
169
  if options['received-content-mic']
170
170
  # do mic calc using the algorithm specified by server.
@@ -182,5 +182,98 @@ module As2
182
182
  end
183
183
  report
184
184
  end
185
+
186
+ private
187
+
188
+ # extract the MDN body from a multipart/signed wrapper & attempt to verify
189
+ # the signature
190
+ #
191
+ # @param [String] multipart_signed_message The 'outer' MDN body, containing MIME header,
192
+ # MDN body (which itself is likely a multi-part object) and a signature.
193
+ # @param [OpenSSL::X509::Certificate] verify that the MDN body was signed using this certificate
194
+ # @return [Hash] results of the check
195
+ # * :mdn_mime_body [Mail::Message] The 'inner' MDN body, with signature removed
196
+ # * :signature_verification_error [String] Any error which resulted when checking the
197
+ # signature. If this is empty it means the signature was valid.
198
+ def parse_signed_mdn(multipart_signed_message:, certificate:)
199
+ smime = nil
200
+
201
+ begin
202
+ # This will fail if the signature is binary-encoded. In that case
203
+ # we rescue so we can continue to extract other data from the MDN.
204
+ # User can decide how to proceed after the signature verification failure.
205
+ #
206
+ # > The parser assumes that the PKCS7 structure is always base64 encoded
207
+ # > and will not handle the case where it is in binary format or uses quoted
208
+ # > printable format.
209
+ #
210
+ # https://www.openssl.org/docs/man3.1/man3/SMIME_read_PKCS7.html
211
+ #
212
+ # Likely we can resolve this by building a PKCS7 manually from the MDN
213
+ # payload, rather than using `read_smime`.
214
+ #
215
+ # An aside: manually base64-encoding the binary signature allows the MDN
216
+ # to be parsed & verified via `read_smime`, so that could also be an option.
217
+ smime = OpenSSL::PKCS7.read_smime(multipart_signed_message)
218
+ rescue => e
219
+ @logger.warn "error checking signature using read_smime. #{e.message}"
220
+ signature_verification_error = e.message
221
+ end
222
+
223
+ if smime
224
+ # create mail instance before #verify call.
225
+ # `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
226
+ mdn_report = Mail.new(smime.data)
227
+
228
+ # based on As2::Message version
229
+ # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
230
+ # See notes in As2::Message.verify for reasoning on flag usage
231
+ smime.verify [certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
232
+
233
+ signature_verification_error = smime.error_string
234
+ else
235
+ @logger.info "trying fallback sigature verification."
236
+ # read_smime will fail on binary-encoded MDNs. in this case, we can attempt
237
+ # to parse the structure using Mail and do signature verification
238
+ # slightly differently.
239
+ #
240
+ # what follows is the same process applied in As2::Message#valid_signature?.
241
+ # see notes there for more info on "multipart/signed" MIME messages.
242
+ #
243
+ # 1. maybe unify these at some point?
244
+ # 2. maybe always use this process, and drop initial attempt at
245
+ # `OpenSSL::PKCS7.read_smime` above.
246
+ #
247
+ # refactoring to allow using As2::Message#valid_signature? here
248
+ # would also allow us to utilize the line-ending fixup code there
249
+
250
+ # this should have 2 parts. the MDN report (parts[0]) and the signature (parts[1])
251
+ #
252
+ # * https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
253
+ # * see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
254
+ outer_mail = Mail.new(multipart_signed_message)
255
+
256
+ mdn_report = outer_mail.parts[0]
257
+
258
+ content = mdn_report.raw_source
259
+ content = content.gsub(/\A\s+/, '')
260
+
261
+ signature = outer_mail.parts[1]
262
+ signature_text = signature.body.to_s
263
+
264
+ result = As2::Message.verify(
265
+ content: content,
266
+ signature_text: signature_text,
267
+ certificate: @partner.certificate
268
+ )
269
+
270
+ signature_verification_error = result[:error]
271
+ end
272
+
273
+ {
274
+ mdn_report: mdn_report,
275
+ signature_verification_error: signature_verification_error
276
+ }
277
+ end
185
278
  end
186
279
  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/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.5.2"
2
+ VERSION = "0.6.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.6.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-04-04 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