as2 0.5.2 → 0.6.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: 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