as2 0.2.4 → 0.4.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: 7cb3eb0153dae7ea4c9ddba6a2843c86982fb6a035bddf9f0638655d0e3c3cda
4
- data.tar.gz: b4a7d09a451eb131ca1febc572e243144014029c9ac82a35ea43da12db5d3519
3
+ metadata.gz: '094edd2c08516e42061e5623b49e4b2f355a3ca3a19a6ed17bc2764ea1073f9c'
4
+ data.tar.gz: c7678c44a070eda090eec89d3e3bacfdc2a1400b880bb39d52f2ba3e08ffe300
5
5
  SHA512:
6
- metadata.gz: ea8bb601045953e40aca4a13193f8377b2fdeacae2d0b143adfda5e434025d7ed38b7db3aca8e11612f38cb431fab80d590d13923c29a52c51ea118c2b2c2ce1
7
- data.tar.gz: aa7b396aa6de6271ae86bc8094ed72f7dce59ed996198f1f31f21c3438de585cee5083a466720e593f80c85474f24fb276f31faa0be8b70813248efc8129db8d
6
+ metadata.gz: c4ecd6838eb1e62174c584638141ba0f2af696bfd906765a0f3ffd4928e03ed3d203e9160c2ce5731439e282283fbf2b511b891719c98eeff262834b9bb1ec11
7
+ data.tar.gz: 852b03c4eebc90ea86447a4074afa417556b89f8613851eb59364dc6f8b4f9f1904452da3c4160929992c2263085774ff6c8cdf46345a92e75411daf945b8123
@@ -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,24 @@
1
+ ## 0.4.0, March 3, 2022
2
+
3
+ * client: correct MIC & signature verification when processing MDN response [#7](https://github.com/andjosh/as2/pull/7)
4
+ * also improves detection of successful & unsuccessful transmissions.
5
+ * client can transmit content which is not in a local file [#5](https://github.com/andjosh/as2/pull/5)
6
+ * also enables `As2::Client` and `As2::Server` can be used without reference to
7
+ the `As2::Config` global configuration.
8
+ * This allows certificate selection to be determined at runtime, making certificate
9
+ expiration & changeover much easier to orchestrate.
10
+
11
+ ## 0.3.0, Dec 22, 2021
12
+
13
+ * fix MIC calculation. [#1](https://github.com/andjosh/as2/pull/1)
14
+ * allow loading of private key and certificates without local files. [#2](https://github.com/andjosh/as2/pull/2)
15
+ * fix signature verification. [#3](https://github.com/andjosh/as2/pull/3)
16
+
17
+ ### breaking changes
18
+
19
+ * removed `As2::Message#original_message`
20
+ * removed `As2::Server::HEADER_MAP`
21
+
22
+ ## prior to 0.3.0
23
+
24
+ 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 sha1. If a
29
+ partner asks for a different algorithm, we'll always use sha1 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,8 +6,8 @@ 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}
@@ -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,135 @@ 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
9
20
  end
10
- @info = Config.server_info
21
+
22
+ @server_info = server_info || Config.server_info
11
23
  end
12
24
 
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
25
+ # Send a file to a partner
26
+ #
27
+ # * If the content parameter is omitted, then `file_name` must be a path
28
+ # to a local file, whose contents will be sent to the partner.
29
+ # * If content parameter is specified, file_name is only used to tell the
30
+ # partner the original name of the file.
31
+ #
32
+ # @param [String] file_name
33
+ # @param [String] content
34
+ # @return [As2::Client::Result]
35
+ def send_file(file_name, content: nil)
36
+ outbound_message_id = As2.generate_message_id(@server_info)
37
+
38
+ req = Net::HTTP::Post.new @partner.url.path
39
+ req['AS2-Version'] = '1.2'
40
+ req['AS2-From'] = @server_info.name
41
+ req['AS2-To'] = @partner.name
42
+ req['Subject'] = 'AS2 EDI Transaction'
43
+ req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m'
44
+ req['Disposition-Notification-To'] = @server_info.url.to_s
45
+ req['Disposition-Notification-Options'] = 'signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, sha1'
46
+ req['Content-Disposition'] = 'attachment; filename="smime.p7m"'
47
+ req['Recipient-Address'] = @server_info.url.to_s
48
+ req['Content-Transfer-Encoding'] = 'base64'
49
+ req['Message-ID'] = outbound_message_id
50
+
51
+ document_content = content || File.read(file_name)
52
+
53
+ document_payload = "Content-Type: application/EDI-Consent\r\n"
54
+ document_payload << "Content-Transfer-Encoding: base64\r\n"
55
+ document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
56
+ document_payload << "\r\n"
57
+ document_payload << Base64.strict_encode64(document_content)
58
+
59
+ signature = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, document_payload
60
+ signature.detached = true
61
+ container = OpenSSL::PKCS7.write_smime signature, document_payload
62
+ encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container
63
+ smime_encrypted = OpenSSL::PKCS7.write_smime encrypted
64
+
65
+ req.body = smime_encrypted.sub(/^.+?\n\n/m, '')
66
+
67
+ resp = nil
68
+ signature_verification_error = :not_checked
69
+ exception = nil
70
+ mic_matched = nil
71
+ mid_matched = nil
72
+ disposition = nil
73
+ plain_text_body = nil
74
+
75
+ begin
76
+ http = Net::HTTP.new(@partner.url.host, @partner.url.port)
77
+ http.use_ssl = @partner.url.scheme == 'https'
78
+ # http.set_debug_output $stderr
79
+ http.start do
80
+ resp = http.request(req)
81
+ end
82
+
83
+ if resp.code == '200'
84
+ response_content = "Content-Type: #{resp['Content-Type']}\r\n#{resp.body}"
85
+ smime = OpenSSL::PKCS7.read_smime response_content
86
+ # based on As2::Message version
87
+ # TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
88
+ smime.verify [@partner.certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
89
+ signature_verification_error = smime.error_string
62
90
 
63
91
  mail = Mail.new smime.data
64
92
  mail.parts.each do |part|
65
93
  case part.content_type
66
94
  when 'text/plain'
67
- body = part.body
95
+ plain_text_body = part.body
68
96
  when 'message/disposition-notification'
97
+ # "The rules for constructing the AS2-disposition-notification content..."
98
+ # https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3
99
+
69
100
  options = {}
101
+ # TODO: can we use Mail built-ins for this?
70
102
  part.body.to_s.lines.each do |line|
71
103
  if line =~ /^([^:]+): (.+)$/
72
104
  options[$1] = $2
73
105
  end
74
106
  end
75
107
 
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
108
+ disposition = options['Disposition']
109
+ mid_matched = req['Message-ID'] == options['Original-Message-ID']
87
110
 
88
- disp_code = options['Disposition']
89
- success = disp_code.end_with?('processed')
111
+ # do mic calc using the algorithm specified by server.
112
+ # (even if we specify sha1, server may send back MIC using a different algo.)
113
+ received_mic, micalg = options['Received-Content-MIC'].split(',').map(&:strip)
114
+ micalg ||= 'sha1'
115
+ mic = As2::DigestSelector.for_code(micalg).base64digest(document_payload)
116
+ mic_matched = received_mic == mic
90
117
  end
91
118
  end
92
119
  end
93
- Result.new success, resp, mic_matched, mid_matched, body, disp_code
120
+ rescue => e
121
+ exception = e
94
122
  end
123
+
124
+ Result.new(
125
+ response: resp,
126
+ mic_matched: mic_matched,
127
+ mid_matched: mid_matched,
128
+ body: plain_text_body,
129
+ disposition: disposition,
130
+ signature_verification_error: signature_verification_error,
131
+ exception: exception,
132
+ outbound_message_id: outbound_message_id
133
+ )
95
134
  end
96
135
  end
97
136
  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
@@ -77,6 +97,11 @@ module As2
77
97
  def store
78
98
  @store ||= OpenSSL::X509::Store.new
79
99
  end
100
+
101
+ def reset!
102
+ @partners = {}
103
+ @store = OpenSSL::X509::Store.new
104
+ end
80
105
  end
81
106
  end
82
107
  end
@@ -0,0 +1,23 @@
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
+ normalized = code.strip.downcase.gsub(/[^a-z0-9]/, '')
19
+
20
+ @map[normalized] || OpenSSL::Digest::SHA1
21
+ end
22
+ end
23
+ end
data/lib/as2/message.rb CHANGED
@@ -1,49 +1,102 @@
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
20
40
 
21
- smime = Base64Helper.ensure_body_base64(decrypted_message)
22
- message = read_smime(smime)
23
- message.verify [partner_certificate], store
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)
71
+
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
+ # TODO: could use As2::DigestSelector if a different algo is needed.
84
+ OpenSSL::Digest::SHA1.base64digest(attachment.raw_source.strip)
24
85
  end
25
86
 
26
87
  # Return the attached file, use .filename and .body on the return value
27
88
  def attachment
28
89
  if mail.has_attachments?
29
- mail.attachments.find{|a| a.content_type == "application/edi-consent"}
90
+ mail.parts.find{|a| a.content_type == "application/edi-consent"}
30
91
  else
31
92
  mail
32
93
  end
33
94
  end
34
95
 
35
96
  private
97
+
36
98
  def mail
37
99
  @mail ||= Mail.new(decrypted_message)
38
100
  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
101
  end
49
102
  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,32 +55,7 @@ module As2
54
55
  end
55
56
  end
56
57
 
57
- send_mdn(env, mic)
58
- end
59
-
60
- private
61
- def build_smime_text(env)
62
- request = Rack::Request.new(env)
63
- smime_data = StringIO.new
64
-
65
- HEADER_MAP.each do |name, value|
66
- smime_data.puts "#{name}: #{env[value]}"
67
- end
68
-
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
58
+ send_mdn(env, message.mic)
83
59
  end
84
60
 
85
61
  def send_mdn(env, mic, failed = nil)
@@ -98,9 +74,9 @@ module As2
98
74
  notification['Content-Transfer-Encoding'] = '7bit'
99
75
 
100
76
  options = {
101
- 'Reporting-UA' => @info.name,
102
- 'Original-Recipient' => "rfc822; #{@info.name}",
103
- 'Final-Recipient' => "rfc822; #{@info.name}",
77
+ 'Reporting-UA' => @server_info.name,
78
+ 'Original-Recipient' => "rfc822; #{@server_info.name}",
79
+ 'Final-Recipient' => "rfc822; #{@server_info.name}",
104
80
  'Original-Message-ID' => env['HTTP_MESSAGE_ID']
105
81
  }
106
82
  if failed
@@ -117,7 +93,7 @@ module As2
117
93
 
118
94
  report.write msg_out
119
95
 
120
- pkcs7 = OpenSSL::PKCS7.sign @info.certificate, @info.pkey, msg_out.string
96
+ pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, msg_out.string
121
97
  pkcs7.detached = true
122
98
  smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
123
99
 
@@ -127,13 +103,24 @@ module As2
127
103
  headers = {}
128
104
  headers['Content-Type'] = content_type
129
105
  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
106
+ headers['Message-ID'] = As2.generate_message_id(@server_info)
107
+ headers['AS2-From'] = @server_info.name
132
108
  headers['AS2-To'] = env['HTTP_AS2_FROM']
133
109
  headers['AS2-Version'] = '1.2'
134
110
  headers['Connection'] = 'close'
135
111
 
136
112
  [200, headers, ["\r\n" + smime_signed]]
137
113
  end
114
+
115
+ private
116
+
117
+ def logger(env)
118
+ @logger ||= Logger.new env['rack.errors']
119
+ end
120
+
121
+ def send_error(env, msg)
122
+ logger(env).error msg
123
+ send_mdn env, nil, msg
124
+ end
138
125
  end
139
126
  end
data/lib/as2/version.rb CHANGED
@@ -1,3 +1,3 @@
1
1
  module As2
2
- VERSION = "0.2.4"
2
+ VERSION = "0.4.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.4
4
+ version: 0.4.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-03 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,7 +161,9 @@ 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
@@ -126,7 +173,7 @@ 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