as2 0.2.5 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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