as2 0.3.0 → 0.5.1

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: 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: