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 +4 -4
- data/.github/workflows/test.yml +34 -0
- data/.gitignore +2 -0
- data/CHANGELOG.md +24 -0
- data/Gemfile +9 -0
- data/README.md +34 -0
- data/as2.gemspec +7 -4
- data/lib/as2/client/result.rb +40 -0
- data/lib/as2/client.rb +108 -69
- data/lib/as2/config.rb +29 -4
- data/lib/as2/digest_selector.rb +23 -0
- data/lib/as2/message.rb +73 -20
- data/lib/as2/mime_generator.rb +1 -0
- data/lib/as2/server.rb +41 -54
- data/lib/as2/version.rb +1 -1
- data/lib/as2.rb +11 -0
- metadata +58 -12
- data/Gemfile.lock +0 -35
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: '094edd2c08516e42061e5623b49e4b2f355a3ca3a19a6ed17bc2764ea1073f9c'
|
4
|
+
data.tar.gz: c7678c44a070eda090eec89d3e3bacfdc2a1400b880bb39d52f2ba3e08ffe300
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
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", "
|
34
|
-
spec.add_development_dependency "rake", "
|
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
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
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
|
-
|
21
|
+
|
22
|
+
@server_info = server_info || Config.server_info
|
11
23
|
end
|
12
24
|
|
13
|
-
|
14
|
-
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
22
|
-
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
|
47
|
-
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
52
|
-
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
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
|
-
|
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
|
-
|
77
|
-
|
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
|
-
|
89
|
-
|
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
|
-
|
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'] =
|
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'] =
|
39
|
+
self['certificate'] = As2::Config.build_certificate(certificate)
|
29
40
|
end
|
30
41
|
|
31
|
-
def pkey=(
|
32
|
-
|
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 :
|
3
|
+
attr_reader :verification_error
|
6
4
|
|
7
5
|
def initialize(message, private_key, public_certificate)
|
8
|
-
|
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 ||=
|
14
|
+
@decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
|
15
15
|
end
|
16
16
|
|
17
17
|
def valid_signature?(partner_certificate)
|
18
|
-
|
19
|
-
|
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
|
-
|
22
|
-
|
23
|
-
|
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.
|
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
|
data/lib/as2/mime_generator.rb
CHANGED
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
|
-
|
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
|
-
@
|
24
|
-
@
|
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'] != @
|
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
|
-
|
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
|
-
|
38
|
-
message = Message.new(
|
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 @
|
41
|
-
@
|
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' => @
|
102
|
-
'Original-Recipient' => "rfc822; #{@
|
103
|
-
'Final-Recipient' => "rfc822; #{@
|
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 @
|
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'] =
|
131
|
-
headers['AS2-From'] = @
|
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
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.
|
4
|
+
version: 0.4.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- OfficeLuv
|
8
|
-
|
8
|
+
- Alex Dean
|
9
|
+
autorequire:
|
9
10
|
bindir: exe
|
10
11
|
cert_chain: []
|
11
|
-
date:
|
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
|
-
|
145
|
-
|
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
|