as2 0.2.4 → 0.4.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: 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