as2 0.2.5 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8a3998531af86ec28183bebe5adaa77a0cba1b66f43d9b2270b53470b34f5d76
4
- data.tar.gz: '079dfb30bfb10cb2a22d33502c155dda1d6c7069a87436b9a62e58d1521d2fa6'
3
+ metadata.gz: 40b3957adcdaa34f7df9fe0ee83966f7076e80bc9dc9c02b8f27a4fb22395fda
4
+ data.tar.gz: 1d8002959f6b4afa70c94fd363c9d97c15b2f20a82ee96b1bc39ebba8a55972e
5
5
  SHA512:
6
- metadata.gz: f968aeb0ab8f1836bbcad82eec78c8a13f5507edef264df0cc397b767524628ef5936f64fa6b84cb8bffec1c4eae492336c9f64c24376d6fc42a17911ece450c
7
- data.tar.gz: e05c3239d2a17c7f5c236544d184fdb038c80d4917c11187b54b77091fc17efc9e9048428384976c2c45a28f955d6ba321f63c576b98220e647ac8b16dc3a860
6
+ metadata.gz: bb04b23ab84f14e131e74e3a213e71ae5f3009f805af08b7cc1c88dc3798745639e4cffea1a85811df51a522776797f236da98c82ab84863dd5891c0b37fc243
7
+ data.tar.gz: a71c9ea6c4c82637103ae1353e5b54cee88ad4f52b388883b0b409c24722247bbd0ead903ca5ef00ba1df41403ab11f1d6b4b03f9d03f58af39ccb399848adf0
@@ -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,2 +1,4 @@
1
1
  certs
2
2
  .DS_Store
3
+ Gemfile.lock
4
+ pkg
data/CHANGELOG.md ADDED
@@ -0,0 +1,30 @@
1
+ ## 0.5.0, March 21, 2022
2
+
3
+ * improvements to `As2::Client`. improve compatibility with non-Mendelson AS2 servers. [#8](https://github.com/andjosh/as2/pull/8)
4
+ * improve MDN generation, especially when an error occurs. [#9](https://github.com/andjosh/as2/pull/9)
5
+ * successfully parse unsigned MDNs. [#10](https://github.com/andjosh/as2/pull/10)
6
+
7
+ ## 0.4.0, March 3, 2022
8
+
9
+ * client: correct MIC & signature verification when processing MDN response [#7](https://github.com/andjosh/as2/pull/7)
10
+ * also improves detection of successful & unsuccessful transmissions.
11
+ * client can transmit content which is not in a local file [#5](https://github.com/andjosh/as2/pull/5)
12
+ * also enables `As2::Client` and `As2::Server` can be used without reference to
13
+ the `As2::Config` global configuration.
14
+ * This allows certificate selection to be determined at runtime, making certificate
15
+ expiration & changeover much easier to orchestrate.
16
+
17
+ ## 0.3.0, Dec 22, 2021
18
+
19
+ * fix MIC calculation. [#1](https://github.com/andjosh/as2/pull/1)
20
+ * allow loading of private key and certificates without local files. [#2](https://github.com/andjosh/as2/pull/2)
21
+ * fix signature verification. [#3](https://github.com/andjosh/as2/pull/3)
22
+
23
+ ### breaking changes
24
+
25
+ * removed `As2::Message#original_message`
26
+ * removed `As2::Server::HEADER_MAP`
27
+
28
+ ## prior to 0.3.0
29
+
30
+ 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,40 @@ 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 (typically EDI files) can be binary or base64 encoded. We
34
+ error if the body is not base64-encoded.
35
+ 3. Payload bodies can have a few different mime types. We expect only
36
+ `application/EDI-Consent`. We're unable to receive content that has any other
37
+ mime type. https://datatracker.ietf.org/doc/html/rfc1767#section-1
38
+ 4. AS2 partners may agree to use separate certificates for data encryption and data signing.
39
+ We do not support separate certificates for these purposes.
40
+
7
41
  ## Installation
8
42
 
9
43
  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"]
10
- spec.email = ["development@officeluv.com"]
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
@@ -30,8 +30,11 @@ Gem::Specification.new do |spec|
30
30
  spec.add_dependency "mail"
31
31
  spec.add_dependency "rack"
32
32
 
33
- spec.add_development_dependency "bundler", "~> 1.10"
34
- spec.add_development_dependency "rake", "~> 10.0"
33
+ spec.add_development_dependency "bundler", ">= 1.10"
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.code == '200'
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
@@ -1,6 +1,17 @@
1
1
  require 'uri'
2
2
  module As2
3
3
  module Config
4
+ def self.build_certificate(input)
5
+ if input.kind_of? OpenSSL::X509::Certificate
6
+ input
7
+ elsif input.kind_of? String
8
+ OpenSSL::X509::Certificate.new File.read(input)
9
+ else
10
+ raise ArgumentError, "Invalid certificate. Provide a string (file path)" \
11
+ " or an OpenSSL::X509::Certificate instance. Got a #{input.class} instead."
12
+ end
13
+ end
14
+
4
15
  class Partner < Struct.new :name, :url, :certificate
5
16
  def url=(url)
6
17
  if url.kind_of? String
@@ -11,7 +22,7 @@ module As2
11
22
  end
12
23
 
13
24
  def certificate=(certificate)
14
- self['certificate'] = OpenSSL::X509::Certificate.new File.read(certificate)
25
+ self['certificate'] = As2::Config.build_certificate(certificate)
15
26
  end
16
27
  end
17
28
 
@@ -25,11 +36,20 @@ module As2
25
36
  end
26
37
 
27
38
  def certificate=(certificate)
28
- self['certificate'] = OpenSSL::X509::Certificate.new File.read(certificate)
39
+ self['certificate'] = As2::Config.build_certificate(certificate)
29
40
  end
30
41
 
31
- def pkey=(pkey)
32
- self['pkey'] = OpenSSL::PKey.read File.read(pkey)
42
+ def pkey=(input)
43
+ # looks like even though you OpenSSL::PKey.new, you still end up with
44
+ # an instance which is an OpenSSL::PKey::PKey.
45
+ if input.kind_of? OpenSSL::PKey::PKey
46
+ self['pkey'] = input
47
+ elsif input.kind_of? String
48
+ self['pkey'] = OpenSSL::PKey.read File.read(input)
49
+ else
50
+ raise ArgumentError, "Invalid private key. Provide a string (file path)" \
51
+ " or an OpenSSL::PKey instance. Got a #{input.class} instead."
52
+ end
33
53
  end
34
54
 
35
55
  def add_partner
@@ -67,12 +87,7 @@ module As2
67
87
  unless @server_info.domain
68
88
  raise 'Your domain name is required'
69
89
  end
70
- begin
71
- store.add_cert @server_info.certificate
72
- rescue OpenSSL::X509::StoreError => err
73
- # ignore duplicate certs
74
- raise unless err.message == 'cert already in hash table'
75
- end
90
+ store.add_cert @server_info.certificate
76
91
  end
77
92
 
78
93
  def partners
@@ -82,6 +97,11 @@ module As2
82
97
  def store
83
98
  @store ||= OpenSSL::X509::Store.new
84
99
  end
100
+
101
+ def reset!
102
+ @partners = {}
103
+ @store = OpenSSL::X509::Store.new
104
+ end
85
105
  end
86
106
  end
87
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
@@ -1,49 +1,108 @@
1
- require 'as2/base64_helper'
2
-
3
1
  module As2
4
2
  class Message
5
- attr_reader :original_message
3
+ attr_reader :verification_error
6
4
 
7
5
  def initialize(message, private_key, public_certificate)
8
- @original_message = message
6
+ # TODO: might need to use OpenSSL::PKCS7.read_smime rather than .new sometimes
7
+ @pkcs7 = OpenSSL::PKCS7.new(message)
9
8
  @private_key = private_key
10
9
  @public_certificate = public_certificate
10
+ @verification_error = nil
11
11
  end
12
12
 
13
13
  def decrypted_message
14
- @decrypted_message ||= decrypt_smime(original_message)
14
+ @decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
15
15
  end
16
16
 
17
17
  def valid_signature?(partner_certificate)
18
- store = OpenSSL::X509::Store.new
19
- store.add_cert(partner_certificate)
18
+ content_type = mail.header_fields.find { |h| h.name == 'Content-Type' }.content_type
19
+ if content_type == "multipart/signed"
20
+ # for a "multipart/signed" message, we will do 'detatched' signature
21
+ # verification, where we supply the data to be verified as the 3rd parameter
22
+ # to OpenSSL::PKCS7#verify. this is in keeping with how this content type
23
+ # is described in the S/MIME RFC.
24
+ #
25
+ # > The multipart/signed MIME type has two parts. The first part contains
26
+ # > the MIME entity that is signed; the second part contains the "detached signature"
27
+ # > CMS SignedData object in which the encapContentInfo eContent field is absent.
28
+ #
29
+ # https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3.1
30
+
31
+ # TODO: more robust detection of content vs signature (if they're ever out of order).
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
+
36
+ signature = OpenSSL::PKCS7.new(mail.parts[1].body.to_s)
37
+
38
+ # using an empty CA store. see notes on NOVERIFY flag below.
39
+ store = OpenSSL::X509::Store.new
40
+
41
+ # notes on verification proces and flags used
42
+ #
43
+ # ## NOINTERN
44
+ #
45
+ # > If PKCS7_NOINTERN is set the certificates in the message itself are
46
+ # > not searched when locating the signer's certificate. This means that
47
+ # > all the signers certificates must be in the certs parameter.
48
+ #
49
+ # > One application of PKCS7_NOINTERN is to only accept messages signed
50
+ # > by a small number of certificates. The acceptable certificates would
51
+ # > be passed in the certs parameter. In this case if the signer is not
52
+ # > one of the certificates supplied in certs then the verify will fail
53
+ # > because the signer cannot be found.
54
+ #
55
+ # https://www.openssl.org/docs/manmaster/man3/PKCS7_verify.html
56
+ #
57
+ # we want this so we can be sure that the `partner_certificate` we supply
58
+ # was actually used to sign the message. otherwise we could get a positive
59
+ # verification even if `partner_certificate` didn't sign the message
60
+ # we're checking.
61
+ #
62
+ # ## NOVERIFY
63
+ #
64
+ # > If PKCS7_NOVERIFY is set the signer's certificates are not chain verified.
65
+ #
66
+ # ie: we won't attempt to connect signer (in the first param) to a root
67
+ # CA (in `store`, which is empty). alternately, we could instead remove
68
+ # this flag, and add `partner_certificate` to `store`. but what's the point?
69
+ # we'd only be verifying that `partner_certificate` is connected to `partner_certificate`.
70
+ output = signature.verify([partner_certificate], store, content, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN)
20
71
 
21
- smime = Base64Helper.ensure_body_base64(decrypted_message)
22
- message = read_smime(smime)
23
- message.verify [partner_certificate], store
72
+ # when `signature.verify` fails, signature.error_string will be populated.
73
+ @verification_error = signature.error_string
74
+
75
+ output
76
+ else
77
+ # TODO: how to log this?
78
+ false
79
+ end
80
+ end
81
+
82
+ def mic
83
+ digest = As2::DigestSelector.for_code(mic_algorithm)
84
+ digest.base64digest(attachment.raw_source.strip)
85
+ end
86
+
87
+ def mic_algorithm
88
+ 'sha256'
24
89
  end
25
90
 
26
91
  # Return the attached file, use .filename and .body on the return value
27
92
  def attachment
28
93
  if mail.has_attachments?
29
- 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/) }
30
97
  else
31
98
  mail
32
99
  end
33
100
  end
34
101
 
35
102
  private
103
+
36
104
  def mail
37
105
  @mail ||= Mail.new(decrypted_message)
38
106
  end
39
-
40
- def read_smime(smime)
41
- OpenSSL::PKCS7.read_smime(smime)
42
- end
43
-
44
- def decrypt_smime(smime)
45
- message = read_smime(smime)
46
- message.decrypt @private_key, @public_certificate
47
- end
48
107
  end
49
108
  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
@@ -2,50 +2,51 @@ require 'rack'
2
2
  require 'logger'
3
3
  require 'stringio'
4
4
  require 'as2/mime_generator'
5
- require 'as2/base64_helper'
6
5
  require 'as2/message'
7
6
 
8
7
  module As2
9
8
  class Server
10
- HEADER_MAP = {
11
- 'To' => 'HTTP_AS2_TO',
12
- 'From' => 'HTTP_AS2_FROM',
13
- 'Subject' => 'HTTP_SUBJECT',
14
- 'MIME-Version' => 'HTTP_MIME_VERSION',
15
- 'Content-Disposition' => 'HTTP_CONTENT_DISPOSITION',
16
- 'Content-Type' => 'CONTENT_TYPE',
17
- }
18
-
19
9
  attr_accessor :logger
20
10
 
21
- 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)
22
18
  @block = block
23
- @info = Config.server_info
24
- @options = options
19
+ @server_info = server_info || Config.server_info
20
+ @partner = partner
21
+ @signature_failure_handler = on_signature_failure
25
22
  end
26
23
 
27
24
  def call(env)
28
- if env['HTTP_AS2_TO'] != @info.name
25
+ if env['HTTP_AS2_TO'] != @server_info.name
29
26
  return send_error(env, "Invalid destination name #{env['HTTP_AS2_TO']}")
30
27
  end
31
28
 
32
- partner = Config.partners[env['HTTP_AS2_FROM']]
33
- unless partner
29
+ partner = @partner || Config.partners[env['HTTP_AS2_FROM']]
30
+
31
+ if !partner || env['HTTP_AS2_FROM'] != partner.name
34
32
  return send_error(env, "Invalid partner name #{env['HTTP_AS2_FROM']}")
35
33
  end
36
34
 
37
- smime_string = build_smime_text(env)
38
- message = Message.new(smime_string, @info.pkey, @info.certificate)
35
+ request = Rack::Request.new(env)
36
+ message = Message.new(request.body.read, @server_info.pkey, @server_info.certificate)
37
+
39
38
  unless message.valid_signature?(partner.certificate)
40
- if @options[:on_signature_failure]
41
- @options[:on_signature_failure].call({env: env, smime_string: smime_string})
39
+ if @signature_failure_handler
40
+ @signature_failure_handler.call({
41
+ env: env,
42
+ smime_string: message.decrypted_message,
43
+ verification_error: message.verification_error
44
+ })
42
45
  else
43
46
  raise "Could not verify signature"
44
47
  end
45
48
  end
46
49
 
47
- mic = OpenSSL::Digest::SHA1.base64digest(message.decrypted_message)
48
-
49
50
  if @block
50
51
  begin
51
52
  @block.call message.attachment.filename, message.attachment.body
@@ -54,62 +55,41 @@ module As2
54
55
  end
55
56
  end
56
57
 
57
- send_mdn(env, mic)
58
+ send_mdn(env, message.mic, message.mic_algorithm)
58
59
  end
59
60
 
60
- private
61
- def build_smime_text(env)
62
- request = Rack::Request.new(env)
63
- smime_data = StringIO.new
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
64
64
 
65
- HEADER_MAP.each do |name, value|
66
- smime_data.puts "#{name}: #{env[value]}"
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"
67
78
  end
79
+ options['Received-Content-MIC'] = "#{mic}, #{mic_algorithm}" if mic
68
80
 
69
- smime_data.puts 'Content-Transfer-Encoding: base64'
70
- smime_data.puts
71
- smime_data.puts Base64Helper.ensure_base64(request.body.read)
72
-
73
- return smime_data.string
74
- end
75
-
76
- def logger(env)
77
- @logger ||= Logger.new env['rack.errors']
78
- end
79
-
80
- def send_error(env, msg)
81
- logger(env).error msg
82
- send_mdn env, nil, msg
83
- end
84
-
85
- def send_mdn(env, mic, failed = nil)
86
81
  report = MimeGenerator::Part.new
87
82
  report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
88
83
 
89
84
  text = MimeGenerator::Part.new
90
85
  text['Content-Type'] = 'text/plain'
91
86
  text['Content-Transfer-Encoding'] = '7bit'
92
- text.body = "The AS2 message has been received successfully"
93
-
87
+ text.body = text_body
94
88
  report.add_part text
95
89
 
96
90
  notification = MimeGenerator::Part.new
97
91
  notification['Content-Type'] = 'message/disposition-notification'
98
92
  notification['Content-Transfer-Encoding'] = '7bit'
99
-
100
- options = {
101
- 'Reporting-UA' => @info.name,
102
- 'Original-Recipient' => "rfc822; #{@info.name}",
103
- 'Final-Recipient' => "rfc822; #{@info.name}",
104
- 'Original-Message-ID' => env['HTTP_MESSAGE_ID']
105
- }
106
- if failed
107
- options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
108
- options['Failure'] = failed
109
- else
110
- options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
111
- end
112
- options['Received-Content-MIC'] = "#{mic}, sha1" if mic
113
93
  notification.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
114
94
  report.add_part notification
115
95
 
@@ -117,23 +97,35 @@ module As2
117
97
 
118
98
  report.write msg_out
119
99
 
120
- 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
121
101
  pkcs7.detached = true
122
102
  smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
123
103
 
124
104
  content_type = smime_signed[/^Content-Type: (.+?)$/m, 1]
125
- smime_signed.sub!(/\A.+?^(?=---)/m, '')
105
+ # smime_signed.sub!(/\A.+?^(?=---)/m, '')
126
106
 
127
107
  headers = {}
128
108
  headers['Content-Type'] = content_type
109
+ # TODO: if MIME-Version header is actually needed, should extract it out of smime_signed.
129
110
  headers['MIME-Version'] = '1.0'
130
- headers['Message-ID'] = "<#{@info.name}-#{Time.now.strftime('%Y%m%d%H%M%S')}@#{@info.domain}>"
131
- headers['AS2-From'] = @info.name
111
+ headers['Message-ID'] = As2.generate_message_id(@server_info)
112
+ headers['AS2-From'] = @server_info.name
132
113
  headers['AS2-To'] = env['HTTP_AS2_FROM']
133
- headers['AS2-Version'] = '1.2'
114
+ headers['AS2-Version'] = '1.0'
134
115
  headers['Connection'] = 'close'
135
116
 
136
117
  [200, headers, ["\r\n" + smime_signed]]
137
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
138
130
  end
139
131
  end
data/lib/as2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.2.5"
2
+ VERSION = "0.5.0"
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,14 +1,15 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: as2
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - OfficeLuv
8
- autorequire:
8
+ - Alex Dean
9
+ autorequire:
9
10
  bindir: exe
10
11
  cert_chain: []
11
- date: 2019-03-12 00:00:00.000000000 Z
12
+ date: 2022-03-21 00:00:00.000000000 Z
12
13
  dependencies:
13
14
  - !ruby/object:Gem::Dependency
14
15
  name: mail
@@ -42,28 +43,28 @@ dependencies:
42
43
  name: bundler
43
44
  requirement: !ruby/object:Gem::Requirement
44
45
  requirements:
45
- - - "~>"
46
+ - - ">="
46
47
  - !ruby/object:Gem::Version
47
48
  version: '1.10'
48
49
  type: :development
49
50
  prerelease: false
50
51
  version_requirements: !ruby/object:Gem::Requirement
51
52
  requirements:
52
- - - "~>"
53
+ - - ">="
53
54
  - !ruby/object:Gem::Version
54
55
  version: '1.10'
55
56
  - !ruby/object:Gem::Dependency
56
57
  name: rake
57
58
  requirement: !ruby/object:Gem::Requirement
58
59
  requirements:
59
- - - "~>"
60
+ - - ">="
60
61
  - !ruby/object:Gem::Version
61
62
  version: '10.0'
62
63
  type: :development
63
64
  prerelease: false
64
65
  version_requirements: !ruby/object:Gem::Requirement
65
66
  requirements:
66
- - - "~>"
67
+ - - ">="
67
68
  - !ruby/object:Gem::Version
68
69
  version: '10.0'
69
70
  - !ruby/object:Gem::Dependency
@@ -94,17 +95,61 @@ dependencies:
94
95
  - - ">="
95
96
  - !ruby/object:Gem::Version
96
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'
97
140
  description: Simple AS2 server and client implementation. Follows the AS2 implementation
98
141
  from http://as2.mendelson-e-c.com
99
142
  email:
100
143
  - development@officeluv.com
144
+ - github@mostlyalex.com
101
145
  executables: []
102
146
  extensions: []
103
147
  extra_rdoc_files: []
104
148
  files:
149
+ - ".github/workflows/test.yml"
105
150
  - ".gitignore"
151
+ - CHANGELOG.md
106
152
  - Gemfile
107
- - Gemfile.lock
108
153
  - LICENSE.txt
109
154
  - README.md
110
155
  - Rakefile
@@ -116,17 +161,19 @@ files:
116
161
  - lib/as2.rb
117
162
  - lib/as2/base64_helper.rb
118
163
  - lib/as2/client.rb
164
+ - lib/as2/client/result.rb
119
165
  - lib/as2/config.rb
166
+ - lib/as2/digest_selector.rb
120
167
  - lib/as2/message.rb
121
168
  - lib/as2/mime_generator.rb
122
169
  - lib/as2/server.rb
123
170
  - lib/as2/version.rb
124
- homepage: https://github.com/officeluv/as2
171
+ homepage: https://github.com/andjosh/as2
125
172
  licenses:
126
173
  - MIT
127
174
  metadata:
128
175
  allowed_push_host: https://rubygems.org/
129
- post_install_message:
176
+ post_install_message:
130
177
  rdoc_options: []
131
178
  require_paths:
132
179
  - lib
@@ -141,9 +188,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
141
188
  - !ruby/object:Gem::Version
142
189
  version: '0'
143
190
  requirements: []
144
- rubyforge_project:
145
- rubygems_version: 2.7.6
146
- signing_key:
191
+ rubygems_version: 3.1.4
192
+ signing_key:
147
193
  specification_version: 4
148
194
  summary: Simple AS2 server and client implementation
149
195
  test_files: []
data/Gemfile.lock DELETED
@@ -1,35 +0,0 @@
1
- PATH
2
- remote: .
3
- specs:
4
- as2 (0.2.4)
5
- mail
6
- rack
7
-
8
- GEM
9
- remote: https://rubygems.org/
10
- specs:
11
- daemons (1.2.3)
12
- eventmachine (1.0.8)
13
- mail (2.6.3)
14
- mime-types (>= 1.16, < 3)
15
- mime-types (2.6.2)
16
- minitest (5.8.1)
17
- rack (1.6.4)
18
- rake (10.4.2)
19
- thin (1.6.4)
20
- daemons (~> 1.0, >= 1.0.9)
21
- eventmachine (~> 1.0, >= 1.0.4)
22
- rack (~> 1.0)
23
-
24
- PLATFORMS
25
- ruby
26
-
27
- DEPENDENCIES
28
- as2!
29
- bundler (~> 1.10)
30
- minitest
31
- rake (~> 10.0)
32
- thin
33
-
34
- BUNDLED WITH
35
- 1.10.6