as2 0.3.0 → 0.5.1

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: 3c28a94ec5915cd8930ba91814dea748017f86d0cbe2be94719fa54df5e6774f
4
- data.tar.gz: 47239349434164df4c1833561b6942e458f9b919415d221b9ae376b4a28755fe
3
+ metadata.gz: 0225c8faac2c9bb92938878b8f2f2f919c50589d368fe4480d5ad96541d3e0ca
4
+ data.tar.gz: 897d1831f060e7ee248e2a0a6031d5b76d47b5f099d3d46ef93a000a78712873
5
5
  SHA512:
6
- metadata.gz: b7f77c9176b2f279d5a678f72494f746b687432487df3b2c1d338754b89e5942e9af4798360a39528514cb826cc923148ac4a812cc64d65b7f9f4a104eb71b2f
7
- data.tar.gz: efdc7d01786e56ad1aabbbd9c373aa9d835bb299c775423df0470e622916efe080a0abc3b7bc6173e39b80223824d3c3bb885b6fe8d6b93405329dcaf9f82fd5
6
+ metadata.gz: 0c2a13a733a35bf8a284077258a52ed3e1da95d4c70b23053b220ff89050178be6a4478f859aba6c341866295dc1abf501ddae322f83ddcc38a297adea4c6559
7
+ data.tar.gz: af1bfdcad847e185db4cb1b0be09b2c49e8b6d8d8cf77c2abe8357fcaae1e8f5018688088eb8161e30fcf3c820b5fc6c0c5e3ade286123f2a51087697f105089
@@ -0,0 +1,34 @@
1
+ # This workflow uses actions that are not certified by GitHub.
2
+ # They are provided by a third-party and are governed by
3
+ # separate terms of service, privacy policy, and support
4
+ # documentation.
5
+ # This workflow will download a prebuilt Ruby version, install dependencies and run tests with Rake
6
+ # For more information see: https://github.com/marketplace/actions/setup-ruby-jruby-and-truffleruby
7
+
8
+ name: test suite
9
+
10
+ on:
11
+ push:
12
+ branches: '**'
13
+
14
+ jobs:
15
+ test:
16
+ strategy:
17
+ fail-fast: false
18
+ matrix:
19
+ ruby: ['2.5', '2.6', '2.7', '3.0', '3.1']
20
+
21
+ runs-on: ubuntu-latest
22
+
23
+ steps:
24
+ - uses: actions/checkout@v2
25
+ - name: Set up Ruby
26
+ # To automatically get bug fixes and new Ruby versions for ruby/setup-ruby,
27
+ # change this to (see https://github.com/ruby/setup-ruby#versioning):
28
+ uses: ruby/setup-ruby@v1
29
+ with:
30
+ ruby-version: ${{ matrix.ruby }}
31
+ - name: Install dependencies
32
+ run: bundle install
33
+ - name: Run tests
34
+ run: bundle exec rake test
data/.gitignore CHANGED
@@ -1,3 +1,4 @@
1
1
  certs
2
2
  .DS_Store
3
3
  Gemfile.lock
4
+ pkg
data/CHANGELOG.md CHANGED
@@ -1,9 +1,34 @@
1
+ ## 0.5.1, August 10, 2022
2
+
3
+ * Any HTTP 2xx status received from a partner should be considered successful. [#12](https://github.com/andjosh/as2/pull/12)
4
+
5
+ ## 0.5.0, March 21, 2022
6
+
7
+ * improvements to `As2::Client`. improve compatibility with non-Mendelson AS2 servers. [#8](https://github.com/andjosh/as2/pull/8)
8
+ * improve MDN generation, especially when an error occurs. [#9](https://github.com/andjosh/as2/pull/9)
9
+ * successfully parse unsigned MDNs. [#10](https://github.com/andjosh/as2/pull/10)
10
+
11
+ ## 0.4.0, March 3, 2022
12
+
13
+ * client: correct MIC & signature verification when processing MDN response [#7](https://github.com/andjosh/as2/pull/7)
14
+ * also improves detection of successful & unsuccessful transmissions.
15
+ * client can transmit content which is not in a local file [#5](https://github.com/andjosh/as2/pull/5)
16
+ * also enables `As2::Client` and `As2::Server` can be used without reference to
17
+ the `As2::Config` global configuration.
18
+ * This allows certificate selection to be determined at runtime, making certificate
19
+ expiration & changeover much easier to orchestrate.
20
+
1
21
  ## 0.3.0, Dec 22, 2021
2
22
 
3
23
  * fix MIC calculation. [#1](https://github.com/andjosh/as2/pull/1)
4
24
  * allow loading of private key and certificates without local files. [#2](https://github.com/andjosh/as2/pull/2)
5
25
  * fix signature verification. [#3](https://github.com/andjosh/as2/pull/3)
6
26
 
27
+ ### breaking changes
28
+
29
+ * removed `As2::Message#original_message`
30
+ * removed `As2::Server::HEADER_MAP`
31
+
7
32
  ## prior to 0.3.0
8
33
 
9
34
  Initial work by [@andjosh](https://github.com/andjosh) and [@datanoise](https://github.com/datanoise).
data/Gemfile CHANGED
@@ -2,3 +2,12 @@ source 'https://rubygems.org'
2
2
 
3
3
  # Specify your gem's dependencies in as2.gemspec
4
4
  gemspec
5
+
6
+ # from https://github.com/rails/rails/pull/42308/files
7
+ if RUBY_VERSION >= "3.1"
8
+ # net-smtp, net-imap and net-pop were removed from default gems in Ruby 3.1, but is used by the `mail` gem.
9
+ # So we need to add them as dependencies until `mail` is fixed: https://github.com/mikel/mail/pull/1439
10
+ gem "net-smtp", require: false
11
+ gem "net-imap", require: false
12
+ gem "net-pop", require: false
13
+ end
data/README.md CHANGED
@@ -4,6 +4,38 @@ This is a proof of concept implementation of AS2 protocol: http://www.ietf.org/r
4
4
 
5
5
  Tested with the mendelson AS2 implementation from http://as2.mendelson-e-c.com
6
6
 
7
+ ## Build Status
8
+
9
+ [![Test Suite](https://github.com/andjosh/as2/actions/workflows/test.yml/badge.svg)](https://github.com/andjosh/as2/actions/workflows/test.yml)
10
+
11
+ ## Known Limitations
12
+
13
+ These limitations may be removed over time as demand (and pull requests!) come
14
+ along.
15
+
16
+ 1. RFC defines a number of optional features that partners can pick and choose
17
+ amongst. We currently have hard-coded options for many of these. Our current
18
+ choices are likely the most common ones in use, but we do not offer all the
19
+ configuration options needed for a fully-compliant implementation. https://datatracker.ietf.org/doc/html/rfc4130#section-2.4.2
20
+ 1. Encrypted or Unencrypted Data: We assume all messages are encrypted. An
21
+ error will result if partner sends us an unencrypted message.
22
+ 2. Signed or Unsigned Data: We error if partner sends an unsigned message.
23
+ Partners can request unsigned MDNs, but we always send signed MDNs.
24
+ 3. Optional Use of Receipt: We always send a receipt.
25
+ 4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
26
+ delivery of MDNs.
27
+ 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.
37
+ We do not support separate certificates for these purposes.
38
+
7
39
  ## Installation
8
40
 
9
41
  Add this line to your application's Gemfile:
data/as2.gemspec CHANGED
@@ -6,12 +6,12 @@ require 'as2/version'
6
6
  Gem::Specification.new do |spec|
7
7
  spec.name = "as2"
8
8
  spec.version = As2::VERSION
9
- spec.authors = ["OfficeLuv", "Transfix"]
10
- spec.email = ["development@officeluv.com", "alexdean@transfix.io"]
9
+ spec.authors = ["OfficeLuv", "Alex Dean"]
10
+ spec.email = ["development@officeluv.com", "github@mostlyalex.com"]
11
11
 
12
12
  spec.summary = %q{Simple AS2 server and client implementation}
13
13
  spec.description = %q{Simple AS2 server and client implementation. Follows the AS2 implementation from http://as2.mendelson-e-c.com}
14
- spec.homepage = "https://github.com/officeluv/as2"
14
+ spec.homepage = "https://github.com/andjosh/as2"
15
15
  spec.license = "MIT"
16
16
 
17
17
  # Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
@@ -34,4 +34,7 @@ Gem::Specification.new do |spec|
34
34
  spec.add_development_dependency "rake", ">= 10.0"
35
35
  spec.add_development_dependency "thin"
36
36
  spec.add_development_dependency "minitest"
37
+ spec.add_development_dependency "minitest-focus"
38
+ spec.add_development_dependency "webmock"
39
+ spec.add_development_dependency "pry"
37
40
  end
@@ -0,0 +1,40 @@
1
+ module As2
2
+ class Client
3
+ class Result
4
+ attr_reader :response, :mic_matched, :mid_matched, :body, :disposition, :signature_verification_error, :exception, :outbound_message_id
5
+
6
+ def initialize(response:, mic_matched:, mid_matched:, body:, disposition:, signature_verification_error:, exception:, outbound_message_id:)
7
+ @response = response
8
+ @mic_matched = mic_matched
9
+ @mid_matched = mid_matched
10
+ @body = body
11
+ @disposition = disposition
12
+ @signature_verification_error = signature_verification_error
13
+ @exception = exception
14
+ @outbound_message_id = outbound_message_id
15
+ end
16
+
17
+ def signature_verified
18
+ self.signature_verification_error.nil?
19
+ end
20
+
21
+ # legacy name. accessor for backwards-compatibility.
22
+ def disp_code
23
+ self.disposition
24
+ end
25
+
26
+ def success
27
+ # 'processed' is good (though may include warnings.)
28
+ # 'processed/error' is not.
29
+ downcased_disposition = self.disposition.to_s.downcase
30
+
31
+ # TODO: we'll never have success if MDN is unsigned.
32
+ self.signature_verified &&
33
+ self.mic_matched &&
34
+ self.mid_matched &&
35
+ downcased_disposition.include?('processed') &&
36
+ !downcased_disposition.include?('processed/error')
37
+ end
38
+ end
39
+ end
40
+ end
data/lib/as2/client.rb CHANGED
@@ -2,96 +2,185 @@ require 'net/http'
2
2
 
3
3
  module As2
4
4
  class Client
5
- def initialize(partner_name)
6
- @partner = Config.partners[partner_name]
7
- unless @partner
8
- raise "Partner #{partner_name} is not registered"
5
+ attr_reader :partner, :server_info
6
+
7
+ # @param [As2::Config::Partner,String] partner The partner to send a message to.
8
+ # If a string is given, it should be a partner name which has been registered
9
+ # via a call to #add_partner.
10
+ # @param [As2::Config::ServerInfo,nil] server_info The server info used to identify
11
+ # this client to the partner. If omitted, the main As2::Config.server_info will be used.
12
+ def initialize(partner, server_info: nil)
13
+ if partner.is_a?(As2::Config::Partner)
14
+ @partner = partner
15
+ else
16
+ @partner = Config.partners[partner]
17
+ unless @partner
18
+ raise "Partner #{partner} is not registered"
19
+ end
20
+ end
21
+
22
+ @server_info = server_info || Config.server_info
23
+ end
24
+
25
+ def as2_to
26
+ @partner.name
27
+ end
28
+
29
+ def as2_from
30
+ @server_info.name
31
+ end
32
+
33
+ # Send a file to a partner
34
+ #
35
+ # * If the content parameter is omitted, then `file_name` must be a path
36
+ # to a local file, whose contents will be sent to the partner.
37
+ # * If content parameter is specified, file_name is only used to tell the
38
+ # partner the original name of the file.
39
+ #
40
+ # @param [String] file_name
41
+ # @param [String] content
42
+ # @param [String] content_type This is the MIME Content-Type describing the `content` param,
43
+ # and will be included in the SMIME payload. It is not the HTTP Content-Type.
44
+ # @return [As2::Client::Result]
45
+ def send_file(file_name, content: nil, content_type: 'application/EDI-Consent')
46
+ outbound_mic_algorithm = 'sha256'
47
+ outbound_message_id = As2.generate_message_id(@server_info)
48
+
49
+ req = Net::HTTP::Post.new @partner.url.path
50
+ req['AS2-Version'] = '1.0' # 1.1 includes compression support, which we dont implement.
51
+ req['AS2-From'] = as2_from
52
+ req['AS2-To'] = as2_to
53
+ req['Subject'] = 'AS2 Transaction'
54
+ req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m'
55
+ req['Date'] = Time.now.rfc2822
56
+ req['Disposition-Notification-To'] = @server_info.url.to_s
57
+ req['Disposition-Notification-Options'] = "signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, #{outbound_mic_algorithm}"
58
+ req['Content-Disposition'] = 'attachment; filename="smime.p7m"'
59
+ req['Recipient-Address'] = @partner.url.to_s
60
+ req['Message-ID'] = outbound_message_id
61
+
62
+ document_content = content || File.read(file_name)
63
+
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)
69
+
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
+ cipher = OpenSSL::Cipher::AES256.new(:CBC) # default, but we might have to make this configurable
74
+ encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container, cipher
75
+
76
+ # > HTTP can handle binary data and so there is no need to use the
77
+ # > content transfer encodings of MIME
78
+ #
79
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-5.2.1
80
+ req.body = encrypted.to_der
81
+
82
+ resp = nil
83
+ exception = nil
84
+ mdn_report = {}
85
+
86
+ begin
87
+ # note: to pass this traffic through a debugging proxy (like Charles)
88
+ # set ENV['http_proxy'].
89
+ http = Net::HTTP.new(@partner.url.host, @partner.url.port)
90
+ http.use_ssl = @partner.url.scheme == 'https'
91
+ # http.set_debug_output $stderr
92
+ # http.verify_mode = OpenSSL::SSL::VERIFY_NONE
93
+
94
+ http.start do
95
+ resp = http.request(req)
96
+ end
97
+
98
+ if resp && resp.code.start_with?('2')
99
+ mdn_report = evaluate_mdn(
100
+ mdn_content_type: resp['Content-Type'],
101
+ mdn_body: resp.body,
102
+ original_message_id: req['Message-ID'],
103
+ original_body: document_payload
104
+ )
105
+ end
106
+ rescue => e
107
+ exception = e
9
108
  end
10
- @info = Config.server_info
109
+
110
+ Result.new(
111
+ response: resp,
112
+ mic_matched: mdn_report[:mic_matched],
113
+ mid_matched: mdn_report[:mid_matched],
114
+ body: mdn_report[:plain_text_body],
115
+ disposition: mdn_report[:disposition],
116
+ signature_verification_error: mdn_report[:signature_verification_error],
117
+ exception: exception,
118
+ outbound_message_id: outbound_message_id
119
+ )
11
120
  end
12
121
 
13
- Result = Struct.new :success, :response, :mic_matched, :mid_matched, :body, :disp_code
14
-
15
- def send_file(file_name)
16
- http = Net::HTTP.new(@partner.url.host, @partner.url.port)
17
- http.use_ssl = @partner.url.scheme == 'https'
18
- # http.set_debug_output $stderr
19
- http.start do
20
- req = Net::HTTP::Post.new @partner.url.path
21
- req['AS2-Version'] = '1.2'
22
- req['AS2-From'] = @info.name
23
- req['AS2-To'] = @partner.name
24
- req['Subject'] = 'AS2 EDI Transaction'
25
- req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m'
26
- req['Disposition-Notification-To'] = @info.url.to_s
27
- req['Disposition-Notification-Options'] = 'signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha1'
28
- req['Content-Disposition'] = 'attachment; filename="smime.p7m"'
29
- req['Recipient-Address'] = @info.url.to_s
30
- req['Content-Transfer-Encoding'] = 'base64'
31
- req['Message-ID'] = "<#{@info.name}-#{Time.now.strftime('%Y%m%d%H%M%S')}@#{@info.url.host}>"
32
-
33
- body = StringIO.new
34
- body.puts "Content-Type: application/EDI-Consent"
35
- body.puts "Content-Transfer-Encoding: base64"
36
- body.puts "Content-Disposition: attachment; filename=#{file_name}"
37
- body.puts
38
- body.puts [File.read(file_name)].pack("m*")
39
-
40
- mic = OpenSSL::Digest::SHA1.base64digest(body.string.gsub(/\n/, "\r\n"))
41
-
42
- pkcs7 = OpenSSL::PKCS7.sign @info.certificate, @info.pkey, body.string
43
- pkcs7.detached = true
44
- smime_signed = OpenSSL::PKCS7.write_smime pkcs7, body.string
45
- pkcs7 = OpenSSL::PKCS7.encrypt [@partner.certificate], smime_signed
46
- smime_encrypted = OpenSSL::PKCS7.write_smime pkcs7
47
-
48
- req.body = smime_encrypted.sub(/^.+?\n\n/m, '')
49
-
50
- resp = http.request(req)
51
-
52
- success = resp.code == '200'
53
- mic_matched = false
54
- mid_matched = false
55
- disp_code = nil
56
- body = nil
57
- if success
58
- body = resp.body
59
-
60
- smime = OpenSSL::PKCS7.read_smime "Content-Type: #{resp['Content-Type']}\r\n#{body}"
61
- smime.verify [@partner.certificate], Config.store
62
-
63
- mail = Mail.new smime.data
64
- mail.parts.each do |part|
65
- case part.content_type
66
- when 'text/plain'
67
- body = part.body
68
- when 'message/disposition-notification'
69
- options = {}
70
- part.body.to_s.lines.each do |line|
71
- if line =~ /^([^:]+): (.+)$/
72
- options[$1] = $2
73
- end
74
- end
75
-
76
- if req['Message-ID'] == options['Original-Message-ID']
77
- mid_matched = true
78
- else
79
- success = false
80
- end
81
-
82
- if options['Received-Content-MIC'].start_with?(mic)
83
- mic_matched = true
84
- else
85
- success = false
86
- end
87
-
88
- disp_code = options['Disposition']
89
- success = disp_code.end_with?('processed')
122
+ def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:)
123
+ report = {
124
+ signature_verification_error: :not_checked,
125
+ mic_matched: nil,
126
+ mid_matched: nil,
127
+ disposition: nil,
128
+ plain_text_body: nil
129
+ }
130
+
131
+ # MDN bodies we've seen so far don't include Content-Type, which causes `read_smime` to fail.
132
+ response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
133
+
134
+ if mdn_content_type.start_with?('multipart/signed')
135
+ smime = OpenSSL::PKCS7.read_smime(response_content)
136
+
137
+ # create mail instance before #verify call.
138
+ # `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
139
+ mail = Mail.new(smime.data)
140
+
141
+ # based on As2::Message version
142
+ # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
143
+ smime.verify [@partner.certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
144
+ report[:signature_verification_error] = smime.error_string
145
+ else
146
+ # MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
147
+ mail = Mail.new(response_content)
148
+ end
149
+
150
+ mail.parts.each do |part|
151
+ if part.content_type.start_with?('text/plain')
152
+ report[:plain_text_body] = part.body.to_s.strip
153
+ elsif part.content_type.start_with?('message/disposition-notification')
154
+ # "The rules for constructing the AS2-disposition-notification content..."
155
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3
156
+
157
+ options = {}
158
+ # TODO: can we use Mail built-ins for this?
159
+ part.body.to_s.lines.each do |line|
160
+ if line =~ /^([^:]+): (.+)$/
161
+ # downcase because we've seen both 'Disposition' and 'disposition'
162
+ options[$1.to_s.downcase] = $2
90
163
  end
91
164
  end
165
+
166
+ report[:disposition] = options['disposition']
167
+ report[:mid_matched] = original_message_id == options['original-message-id']
168
+
169
+ if options['received-content-mic']
170
+ # do mic calc using the algorithm specified by server.
171
+ # (even if we specify sha1, server may send back MIC using a different algo.)
172
+ received_mic, micalg = options['received-content-mic'].split(',').map(&:strip)
173
+
174
+ # if they don't specify, we'll use the algorithm we specified in the outbound transmission.
175
+ # but it's only a guess & may fail.
176
+ micalg ||= outbound_mic_algorithm
177
+
178
+ mic = As2::DigestSelector.for_code(micalg).base64digest(original_body)
179
+ report[:mic_matched] = received_mic == mic
180
+ end
92
181
  end
93
- Result.new success, resp, mic_matched, mid_matched, body, disp_code
94
182
  end
183
+ report
95
184
  end
96
185
  end
97
186
  end
data/lib/as2/config.rb CHANGED
@@ -97,6 +97,11 @@ module As2
97
97
  def store
98
98
  @store ||= OpenSSL::X509::Store.new
99
99
  end
100
+
101
+ def reset!
102
+ @partners = {}
103
+ @store = OpenSSL::X509::Store.new
104
+ end
100
105
  end
101
106
  end
102
107
  end
@@ -0,0 +1,24 @@
1
+ require 'openssl'
2
+
3
+ module As2
4
+ class DigestSelector
5
+ @map = {
6
+ 'sha1' => OpenSSL::Digest::SHA1,
7
+ 'sha256' => OpenSSL::Digest::SHA256,
8
+ 'sha384' => OpenSSL::Digest::SHA384,
9
+ 'sha512' => OpenSSL::Digest::SHA512,
10
+ 'md5' => OpenSSL::Digest::MD5
11
+ }
12
+
13
+ def self.valid_codes
14
+ @map.keys
15
+ end
16
+
17
+ def self.for_code(code)
18
+ # we may receive 'sha256', 'sha-256', or 'SHA256'.
19
+ normalized = code.strip.downcase.gsub(/[^a-z0-9]/, '')
20
+
21
+ @map[normalized] || OpenSSL::Digest::SHA1
22
+ end
23
+ end
24
+ end
data/lib/as2/message.rb CHANGED
@@ -29,7 +29,10 @@ module As2
29
29
  # https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3.1
30
30
 
31
31
  # TODO: more robust detection of content vs signature (if they're ever out of order).
32
- content = mail.parts[0].raw_source.strip
32
+ content = mail.parts[0].raw_source
33
+ # remove any leading \r\n characters (between headers & body i think).
34
+ content = content.gsub(/\A\s+/, '')
35
+
33
36
  signature = OpenSSL::PKCS7.new(mail.parts[1].body.to_s)
34
37
 
35
38
  # using an empty CA store. see notes on NOVERIFY flag below.
@@ -77,13 +80,20 @@ module As2
77
80
  end
78
81
 
79
82
  def mic
80
- OpenSSL::Digest::SHA1.base64digest(attachment.raw_source.strip)
83
+ digest = As2::DigestSelector.for_code(mic_algorithm)
84
+ digest.base64digest(attachment.raw_source.strip)
85
+ end
86
+
87
+ def mic_algorithm
88
+ 'sha256'
81
89
  end
82
90
 
83
91
  # Return the attached file, use .filename and .body on the return value
84
92
  def attachment
85
93
  if mail.has_attachments?
86
- mail.attachments.find{|a| a.content_type == "application/edi-consent"}
94
+ # TODO: match 'application/edi*', test with 'application/edi-x12'
95
+ # test also with "application/edi-consent; name=this_is_a_filename.txt"
96
+ mail.parts.find{ |a| a.content_type.match(/^application\/edi/) }
87
97
  else
88
98
  mail
89
99
  end
@@ -5,6 +5,7 @@ module As2
5
5
  @parts = []
6
6
  @body = ""
7
7
  @headers = {}
8
+ @id = nil
8
9
  end
9
10
 
10
11
  def [](name)
data/lib/as2/server.rb CHANGED
@@ -8,28 +8,36 @@ module As2
8
8
  class Server
9
9
  attr_accessor :logger
10
10
 
11
- def initialize(options = {}, &block)
11
+ # @param [As2::Config::ServerInfo] server_info Config used for naming of this
12
+ # server and key/certificate selection. If omitted, the main As2::Config.server_info is used.
13
+ # @param [As2::Config::Partner] partner Which partner to receive messages from.
14
+ # If omitted, the partner is determined by incoming HTTP headers.
15
+ # @param [Proc] on_signature_failure A proc which will be called if signature verification fails.
16
+ # @param [Proc] block A proc which will be called with file_name and file content.
17
+ def initialize(server_info: nil, partner: nil, on_signature_failure: nil, &block)
12
18
  @block = block
13
- @info = Config.server_info
14
- @options = options
19
+ @server_info = server_info || Config.server_info
20
+ @partner = partner
21
+ @signature_failure_handler = on_signature_failure
15
22
  end
16
23
 
17
24
  def call(env)
18
- if env['HTTP_AS2_TO'] != @info.name
25
+ if env['HTTP_AS2_TO'] != @server_info.name
19
26
  return send_error(env, "Invalid destination name #{env['HTTP_AS2_TO']}")
20
27
  end
21
28
 
22
- partner = Config.partners[env['HTTP_AS2_FROM']]
23
- unless partner
29
+ partner = @partner || Config.partners[env['HTTP_AS2_FROM']]
30
+
31
+ if !partner || env['HTTP_AS2_FROM'] != partner.name
24
32
  return send_error(env, "Invalid partner name #{env['HTTP_AS2_FROM']}")
25
33
  end
26
34
 
27
35
  request = Rack::Request.new(env)
28
- message = Message.new(request.body.read, @info.pkey, @info.certificate)
36
+ message = Message.new(request.body.read, @server_info.pkey, @server_info.certificate)
29
37
 
30
38
  unless message.valid_signature?(partner.certificate)
31
- if @options[:on_signature_failure]
32
- @options[:on_signature_failure].call({
39
+ if @signature_failure_handler
40
+ @signature_failure_handler.call({
33
41
  env: env,
34
42
  smime_string: message.decrypted_message,
35
43
  verification_error: message.verification_error
@@ -47,48 +55,41 @@ module As2
47
55
  end
48
56
  end
49
57
 
50
- send_mdn(env, message.mic)
58
+ send_mdn(env, message.mic, message.mic_algorithm)
51
59
  end
52
60
 
53
- private
54
-
55
- def logger(env)
56
- @logger ||= Logger.new env['rack.errors']
57
- end
61
+ def send_mdn(env, mic, mic_algorithm, failed = nil)
62
+ # rules for MDN construction are covered in
63
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.2
58
64
 
59
- def send_error(env, msg)
60
- logger(env).error msg
61
- send_mdn env, nil, msg
62
- end
65
+ options = {
66
+ 'Reporting-UA' => @server_info.name,
67
+ 'Original-Recipient' => "rfc822; #{@server_info.name}",
68
+ 'Final-Recipient' => "rfc822; #{@server_info.name}",
69
+ 'Original-Message-ID' => env['HTTP_MESSAGE_ID']
70
+ }
71
+ if failed
72
+ options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
73
+ options['Failure'] = failed
74
+ text_body = "There was an error with the AS2 transmission.\r\n\r\n#{failed}"
75
+ else
76
+ options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
77
+ text_body = "The AS2 message has been received successfully"
78
+ end
79
+ options['Received-Content-MIC'] = "#{mic}, #{mic_algorithm}" if mic
63
80
 
64
- def send_mdn(env, mic, failed = nil)
65
81
  report = MimeGenerator::Part.new
66
82
  report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
67
83
 
68
84
  text = MimeGenerator::Part.new
69
85
  text['Content-Type'] = 'text/plain'
70
86
  text['Content-Transfer-Encoding'] = '7bit'
71
- text.body = "The AS2 message has been received successfully"
72
-
87
+ text.body = text_body
73
88
  report.add_part text
74
89
 
75
90
  notification = MimeGenerator::Part.new
76
91
  notification['Content-Type'] = 'message/disposition-notification'
77
92
  notification['Content-Transfer-Encoding'] = '7bit'
78
-
79
- options = {
80
- 'Reporting-UA' => @info.name,
81
- 'Original-Recipient' => "rfc822; #{@info.name}",
82
- 'Final-Recipient' => "rfc822; #{@info.name}",
83
- 'Original-Message-ID' => env['HTTP_MESSAGE_ID']
84
- }
85
- if failed
86
- options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
87
- options['Failure'] = failed
88
- else
89
- options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
90
- end
91
- options['Received-Content-MIC'] = "#{mic}, sha1" if mic
92
93
  notification.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
93
94
  report.add_part notification
94
95
 
@@ -96,23 +97,35 @@ module As2
96
97
 
97
98
  report.write msg_out
98
99
 
99
- pkcs7 = OpenSSL::PKCS7.sign @info.certificate, @info.pkey, msg_out.string
100
+ pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, msg_out.string
100
101
  pkcs7.detached = true
101
102
  smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
102
103
 
103
104
  content_type = smime_signed[/^Content-Type: (.+?)$/m, 1]
104
- smime_signed.sub!(/\A.+?^(?=---)/m, '')
105
+ # smime_signed.sub!(/\A.+?^(?=---)/m, '')
105
106
 
106
107
  headers = {}
107
108
  headers['Content-Type'] = content_type
109
+ # TODO: if MIME-Version header is actually needed, should extract it out of smime_signed.
108
110
  headers['MIME-Version'] = '1.0'
109
- headers['Message-ID'] = "<#{@info.name}-#{Time.now.strftime('%Y%m%d%H%M%S')}@#{@info.domain}>"
110
- headers['AS2-From'] = @info.name
111
+ headers['Message-ID'] = As2.generate_message_id(@server_info)
112
+ headers['AS2-From'] = @server_info.name
111
113
  headers['AS2-To'] = env['HTTP_AS2_FROM']
112
- headers['AS2-Version'] = '1.2'
114
+ headers['AS2-Version'] = '1.0'
113
115
  headers['Connection'] = 'close'
114
116
 
115
117
  [200, headers, ["\r\n" + smime_signed]]
116
118
  end
119
+
120
+ private
121
+
122
+ def logger(env)
123
+ @logger ||= Logger.new env['rack.errors']
124
+ end
125
+
126
+ def send_error(env, msg)
127
+ logger(env).error msg
128
+ send_mdn env, nil, 'sha1', msg
129
+ end
117
130
  end
118
131
  end
data/lib/as2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.3.0"
2
+ VERSION = "0.5.1"
3
3
  end
data/lib/as2.rb CHANGED
@@ -1,12 +1,23 @@
1
1
  require 'openssl'
2
2
  require 'mail'
3
+ require 'securerandom'
3
4
  require 'as2/config'
4
5
  require 'as2/server'
5
6
  require 'as2/client'
7
+ require 'as2/client/result'
8
+ require 'as2/digest_selector'
6
9
  require "as2/version"
7
10
 
8
11
  module As2
9
12
  def self.configure(&block)
10
13
  Config.configure(&block)
11
14
  end
15
+
16
+ def self.reset_config!
17
+ Config.reset!
18
+ end
19
+
20
+ def self.generate_message_id(server_info)
21
+ "<#{server_info.name}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.uuid}@#{server_info.domain}>"
22
+ end
12
23
  end
metadata CHANGED
@@ -1,15 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: as2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - OfficeLuv
8
- - Transfix
8
+ - Alex Dean
9
9
  autorequire:
10
10
  bindir: exe
11
11
  cert_chain: []
12
- date: 2021-12-22 00:00:00.000000000 Z
12
+ date: 2022-08-10 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: mail
@@ -95,15 +95,58 @@ dependencies:
95
95
  - - ">="
96
96
  - !ruby/object:Gem::Version
97
97
  version: '0'
98
+ - !ruby/object:Gem::Dependency
99
+ name: minitest-focus
100
+ requirement: !ruby/object:Gem::Requirement
101
+ requirements:
102
+ - - ">="
103
+ - !ruby/object:Gem::Version
104
+ version: '0'
105
+ type: :development
106
+ prerelease: false
107
+ version_requirements: !ruby/object:Gem::Requirement
108
+ requirements:
109
+ - - ">="
110
+ - !ruby/object:Gem::Version
111
+ version: '0'
112
+ - !ruby/object:Gem::Dependency
113
+ name: webmock
114
+ requirement: !ruby/object:Gem::Requirement
115
+ requirements:
116
+ - - ">="
117
+ - !ruby/object:Gem::Version
118
+ version: '0'
119
+ type: :development
120
+ prerelease: false
121
+ version_requirements: !ruby/object:Gem::Requirement
122
+ requirements:
123
+ - - ">="
124
+ - !ruby/object:Gem::Version
125
+ version: '0'
126
+ - !ruby/object:Gem::Dependency
127
+ name: pry
128
+ requirement: !ruby/object:Gem::Requirement
129
+ requirements:
130
+ - - ">="
131
+ - !ruby/object:Gem::Version
132
+ version: '0'
133
+ type: :development
134
+ prerelease: false
135
+ version_requirements: !ruby/object:Gem::Requirement
136
+ requirements:
137
+ - - ">="
138
+ - !ruby/object:Gem::Version
139
+ version: '0'
98
140
  description: Simple AS2 server and client implementation. Follows the AS2 implementation
99
141
  from http://as2.mendelson-e-c.com
100
142
  email:
101
143
  - development@officeluv.com
102
- - alexdean@transfix.io
144
+ - github@mostlyalex.com
103
145
  executables: []
104
146
  extensions: []
105
147
  extra_rdoc_files: []
106
148
  files:
149
+ - ".github/workflows/test.yml"
107
150
  - ".gitignore"
108
151
  - CHANGELOG.md
109
152
  - Gemfile
@@ -118,12 +161,14 @@ files:
118
161
  - lib/as2.rb
119
162
  - lib/as2/base64_helper.rb
120
163
  - lib/as2/client.rb
164
+ - lib/as2/client/result.rb
121
165
  - lib/as2/config.rb
166
+ - lib/as2/digest_selector.rb
122
167
  - lib/as2/message.rb
123
168
  - lib/as2/mime_generator.rb
124
169
  - lib/as2/server.rb
125
170
  - lib/as2/version.rb
126
- homepage: https://github.com/officeluv/as2
171
+ homepage: https://github.com/andjosh/as2
127
172
  licenses:
128
173
  - MIT
129
174
  metadata: