as2 0.3.0 → 0.5.1
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 +4 -4
- data/.github/workflows/test.yml +34 -0
- data/.gitignore +1 -0
- data/CHANGELOG.md +25 -0
- data/Gemfile +9 -0
- data/README.md +32 -0
- data/as2.gemspec +6 -3
- data/lib/as2/client/result.rb +40 -0
- data/lib/as2/client.rb +172 -83
- data/lib/as2/config.rb +5 -0
- data/lib/as2/digest_selector.rb +24 -0
- data/lib/as2/message.rb +13 -3
- data/lib/as2/mime_generator.rb +1 -0
- data/lib/as2/server.rb +54 -41
- data/lib/as2/version.rb +1 -1
- data/lib/as2.rb +11 -0
- metadata +50 -5
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 0225c8faac2c9bb92938878b8f2f2f919c50589d368fe4480d5ad96541d3e0ca
|
4
|
+
data.tar.gz: 897d1831f060e7ee248e2a0a6031d5b76d47b5f099d3d46ef93a000a78712873
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0c2a13a733a35bf8a284077258a52ed3e1da95d4c70b23053b220ff89050178be6a4478f859aba6c341866295dc1abf501ddae322f83ddcc38a297adea4c6559
|
7
|
+
data.tar.gz: af1bfdcad847e185db4cb1b0be09b2c49e8b6d8d8cf77c2abe8357fcaae1e8f5018688088eb8161e30fcf3c820b5fc6c0c5e3ade286123f2a51087697f105089
|
@@ -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
CHANGED
@@ -1,9 +1,34 @@
|
|
1
|
+
## 0.5.1, August 10, 2022
|
2
|
+
|
3
|
+
* Any HTTP 2xx status received from a partner should be considered successful. [#12](https://github.com/andjosh/as2/pull/12)
|
4
|
+
|
5
|
+
## 0.5.0, March 21, 2022
|
6
|
+
|
7
|
+
* improvements to `As2::Client`. improve compatibility with non-Mendelson AS2 servers. [#8](https://github.com/andjosh/as2/pull/8)
|
8
|
+
* improve MDN generation, especially when an error occurs. [#9](https://github.com/andjosh/as2/pull/9)
|
9
|
+
* successfully parse unsigned MDNs. [#10](https://github.com/andjosh/as2/pull/10)
|
10
|
+
|
11
|
+
## 0.4.0, March 3, 2022
|
12
|
+
|
13
|
+
* client: correct MIC & signature verification when processing MDN response [#7](https://github.com/andjosh/as2/pull/7)
|
14
|
+
* also improves detection of successful & unsuccessful transmissions.
|
15
|
+
* client can transmit content which is not in a local file [#5](https://github.com/andjosh/as2/pull/5)
|
16
|
+
* also enables `As2::Client` and `As2::Server` can be used without reference to
|
17
|
+
the `As2::Config` global configuration.
|
18
|
+
* This allows certificate selection to be determined at runtime, making certificate
|
19
|
+
expiration & changeover much easier to orchestrate.
|
20
|
+
|
1
21
|
## 0.3.0, Dec 22, 2021
|
2
22
|
|
3
23
|
* fix MIC calculation. [#1](https://github.com/andjosh/as2/pull/1)
|
4
24
|
* allow loading of private key and certificates without local files. [#2](https://github.com/andjosh/as2/pull/2)
|
5
25
|
* fix signature verification. [#3](https://github.com/andjosh/as2/pull/3)
|
6
26
|
|
27
|
+
### breaking changes
|
28
|
+
|
29
|
+
* removed `As2::Message#original_message`
|
30
|
+
* removed `As2::Server::HEADER_MAP`
|
31
|
+
|
7
32
|
## prior to 0.3.0
|
8
33
|
|
9
34
|
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,38 @@ 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
|
+
[](https://github.com/andjosh/as2/actions/workflows/test.yml)
|
10
|
+
|
11
|
+
## Known Limitations
|
12
|
+
|
13
|
+
These limitations may be removed over time as demand (and pull requests!) come
|
14
|
+
along.
|
15
|
+
|
16
|
+
1. RFC defines a number of optional features that partners can pick and choose
|
17
|
+
amongst. We currently have hard-coded options for many of these. Our current
|
18
|
+
choices are likely the most common ones in use, but we do not offer all the
|
19
|
+
configuration options needed for a fully-compliant implementation. https://datatracker.ietf.org/doc/html/rfc4130#section-2.4.2
|
20
|
+
1. Encrypted or Unencrypted Data: We assume all messages are encrypted. An
|
21
|
+
error will result if partner sends us an unencrypted message.
|
22
|
+
2. Signed or Unsigned Data: We error if partner sends an unsigned message.
|
23
|
+
Partners can request unsigned MDNs, but we always send signed MDNs.
|
24
|
+
3. Optional Use of Receipt: We always send a receipt.
|
25
|
+
4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
|
26
|
+
delivery of MDNs.
|
27
|
+
5. Security Formatting: We should be reasonably compliant here.
|
28
|
+
6. Hash Function, Message Digest Choices: We currently always use sha256. If a
|
29
|
+
partner asks for a different algorithm, we'll always use sha256 and partner
|
30
|
+
will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
|
31
|
+
mentions md5. Mendelson AS2 server supports a number of other algorithms.
|
32
|
+
(sha256, sha512, etc)
|
33
|
+
2. Payload bodies can have a few different mime types. We expect a type that
|
34
|
+
matches `application/EDI-*`. We're unable to receive content that has any other
|
35
|
+
mime type. https://datatracker.ietf.org/doc/html/rfc1767#section-1
|
36
|
+
3. AS2 partners may agree to use separate certificates for data encryption and data signing.
|
37
|
+
We do not support separate certificates for these purposes.
|
38
|
+
|
7
39
|
## Installation
|
8
40
|
|
9
41
|
Add this line to your application's Gemfile:
|
data/as2.gemspec
CHANGED
@@ -6,12 +6,12 @@ require 'as2/version'
|
|
6
6
|
Gem::Specification.new do |spec|
|
7
7
|
spec.name = "as2"
|
8
8
|
spec.version = As2::VERSION
|
9
|
-
spec.authors = ["OfficeLuv", "
|
10
|
-
spec.email = ["development@officeluv.com", "
|
9
|
+
spec.authors = ["OfficeLuv", "Alex Dean"]
|
10
|
+
spec.email = ["development@officeluv.com", "github@mostlyalex.com"]
|
11
11
|
|
12
12
|
spec.summary = %q{Simple AS2 server and client implementation}
|
13
13
|
spec.description = %q{Simple AS2 server and client implementation. Follows the AS2 implementation from http://as2.mendelson-e-c.com}
|
14
|
-
spec.homepage = "https://github.com/
|
14
|
+
spec.homepage = "https://github.com/andjosh/as2"
|
15
15
|
spec.license = "MIT"
|
16
16
|
|
17
17
|
# Prevent pushing this gem to RubyGems.org by setting 'allowed_push_host', or
|
@@ -34,4 +34,7 @@ Gem::Specification.new do |spec|
|
|
34
34
|
spec.add_development_dependency "rake", ">= 10.0"
|
35
35
|
spec.add_development_dependency "thin"
|
36
36
|
spec.add_development_dependency "minitest"
|
37
|
+
spec.add_development_dependency "minitest-focus"
|
38
|
+
spec.add_development_dependency "webmock"
|
39
|
+
spec.add_development_dependency "pry"
|
37
40
|
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
module As2
|
2
|
+
class Client
|
3
|
+
class Result
|
4
|
+
attr_reader :response, :mic_matched, :mid_matched, :body, :disposition, :signature_verification_error, :exception, :outbound_message_id
|
5
|
+
|
6
|
+
def initialize(response:, mic_matched:, mid_matched:, body:, disposition:, signature_verification_error:, exception:, outbound_message_id:)
|
7
|
+
@response = response
|
8
|
+
@mic_matched = mic_matched
|
9
|
+
@mid_matched = mid_matched
|
10
|
+
@body = body
|
11
|
+
@disposition = disposition
|
12
|
+
@signature_verification_error = signature_verification_error
|
13
|
+
@exception = exception
|
14
|
+
@outbound_message_id = outbound_message_id
|
15
|
+
end
|
16
|
+
|
17
|
+
def signature_verified
|
18
|
+
self.signature_verification_error.nil?
|
19
|
+
end
|
20
|
+
|
21
|
+
# legacy name. accessor for backwards-compatibility.
|
22
|
+
def disp_code
|
23
|
+
self.disposition
|
24
|
+
end
|
25
|
+
|
26
|
+
def success
|
27
|
+
# 'processed' is good (though may include warnings.)
|
28
|
+
# 'processed/error' is not.
|
29
|
+
downcased_disposition = self.disposition.to_s.downcase
|
30
|
+
|
31
|
+
# TODO: we'll never have success if MDN is unsigned.
|
32
|
+
self.signature_verified &&
|
33
|
+
self.mic_matched &&
|
34
|
+
self.mid_matched &&
|
35
|
+
downcased_disposition.include?('processed') &&
|
36
|
+
!downcased_disposition.include?('processed/error')
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
end
|
data/lib/as2/client.rb
CHANGED
@@ -2,96 +2,185 @@ require 'net/http'
|
|
2
2
|
|
3
3
|
module As2
|
4
4
|
class Client
|
5
|
-
|
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
|
20
|
+
end
|
21
|
+
|
22
|
+
@server_info = server_info || Config.server_info
|
23
|
+
end
|
24
|
+
|
25
|
+
def as2_to
|
26
|
+
@partner.name
|
27
|
+
end
|
28
|
+
|
29
|
+
def as2_from
|
30
|
+
@server_info.name
|
31
|
+
end
|
32
|
+
|
33
|
+
# Send a file to a partner
|
34
|
+
#
|
35
|
+
# * If the content parameter is omitted, then `file_name` must be a path
|
36
|
+
# to a local file, whose contents will be sent to the partner.
|
37
|
+
# * If content parameter is specified, file_name is only used to tell the
|
38
|
+
# partner the original name of the file.
|
39
|
+
#
|
40
|
+
# @param [String] file_name
|
41
|
+
# @param [String] content
|
42
|
+
# @param [String] content_type This is the MIME Content-Type describing the `content` param,
|
43
|
+
# and will be included in the SMIME payload. It is not the HTTP Content-Type.
|
44
|
+
# @return [As2::Client::Result]
|
45
|
+
def send_file(file_name, content: nil, content_type: 'application/EDI-Consent')
|
46
|
+
outbound_mic_algorithm = 'sha256'
|
47
|
+
outbound_message_id = As2.generate_message_id(@server_info)
|
48
|
+
|
49
|
+
req = Net::HTTP::Post.new @partner.url.path
|
50
|
+
req['AS2-Version'] = '1.0' # 1.1 includes compression support, which we dont implement.
|
51
|
+
req['AS2-From'] = as2_from
|
52
|
+
req['AS2-To'] = as2_to
|
53
|
+
req['Subject'] = 'AS2 Transaction'
|
54
|
+
req['Content-Type'] = 'application/pkcs7-mime; smime-type=enveloped-data; name=smime.p7m'
|
55
|
+
req['Date'] = Time.now.rfc2822
|
56
|
+
req['Disposition-Notification-To'] = @server_info.url.to_s
|
57
|
+
req['Disposition-Notification-Options'] = "signed-receipt-protocol=optional, pkcs7-signature; signed-receipt-micalg=optional, #{outbound_mic_algorithm}"
|
58
|
+
req['Content-Disposition'] = 'attachment; filename="smime.p7m"'
|
59
|
+
req['Recipient-Address'] = @partner.url.to_s
|
60
|
+
req['Message-ID'] = outbound_message_id
|
61
|
+
|
62
|
+
document_content = content || File.read(file_name)
|
63
|
+
|
64
|
+
document_payload = "Content-Type: #{content_type}\r\n"
|
65
|
+
document_payload << "Content-Transfer-Encoding: base64\r\n"
|
66
|
+
document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
|
67
|
+
document_payload << "\r\n"
|
68
|
+
document_payload << Base64.strict_encode64(document_content)
|
69
|
+
|
70
|
+
signature = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, document_payload
|
71
|
+
signature.detached = true
|
72
|
+
container = OpenSSL::PKCS7.write_smime signature, document_payload
|
73
|
+
cipher = OpenSSL::Cipher::AES256.new(:CBC) # default, but we might have to make this configurable
|
74
|
+
encrypted = OpenSSL::PKCS7.encrypt [@partner.certificate], container, cipher
|
75
|
+
|
76
|
+
# > HTTP can handle binary data and so there is no need to use the
|
77
|
+
# > content transfer encodings of MIME
|
78
|
+
#
|
79
|
+
# https://datatracker.ietf.org/doc/html/rfc4130#section-5.2.1
|
80
|
+
req.body = encrypted.to_der
|
81
|
+
|
82
|
+
resp = nil
|
83
|
+
exception = nil
|
84
|
+
mdn_report = {}
|
85
|
+
|
86
|
+
begin
|
87
|
+
# note: to pass this traffic through a debugging proxy (like Charles)
|
88
|
+
# set ENV['http_proxy'].
|
89
|
+
http = Net::HTTP.new(@partner.url.host, @partner.url.port)
|
90
|
+
http.use_ssl = @partner.url.scheme == 'https'
|
91
|
+
# http.set_debug_output $stderr
|
92
|
+
# http.verify_mode = OpenSSL::SSL::VERIFY_NONE
|
93
|
+
|
94
|
+
http.start do
|
95
|
+
resp = http.request(req)
|
96
|
+
end
|
97
|
+
|
98
|
+
if resp && resp.code.start_with?('2')
|
99
|
+
mdn_report = evaluate_mdn(
|
100
|
+
mdn_content_type: resp['Content-Type'],
|
101
|
+
mdn_body: resp.body,
|
102
|
+
original_message_id: req['Message-ID'],
|
103
|
+
original_body: document_payload
|
104
|
+
)
|
105
|
+
end
|
106
|
+
rescue => e
|
107
|
+
exception = e
|
9
108
|
end
|
10
|
-
|
109
|
+
|
110
|
+
Result.new(
|
111
|
+
response: resp,
|
112
|
+
mic_matched: mdn_report[:mic_matched],
|
113
|
+
mid_matched: mdn_report[:mid_matched],
|
114
|
+
body: mdn_report[:plain_text_body],
|
115
|
+
disposition: mdn_report[:disposition],
|
116
|
+
signature_verification_error: mdn_report[:signature_verification_error],
|
117
|
+
exception: exception,
|
118
|
+
outbound_message_id: outbound_message_id
|
119
|
+
)
|
11
120
|
end
|
12
121
|
|
13
|
-
|
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
|
-
mid_matched = false
|
55
|
-
disp_code = nil
|
56
|
-
body = nil
|
57
|
-
if success
|
58
|
-
body = resp.body
|
59
|
-
|
60
|
-
smime = OpenSSL::PKCS7.read_smime "Content-Type: #{resp['Content-Type']}\r\n#{body}"
|
61
|
-
smime.verify [@partner.certificate], Config.store
|
62
|
-
|
63
|
-
mail = Mail.new smime.data
|
64
|
-
mail.parts.each do |part|
|
65
|
-
case part.content_type
|
66
|
-
when 'text/plain'
|
67
|
-
body = part.body
|
68
|
-
when 'message/disposition-notification'
|
69
|
-
options = {}
|
70
|
-
part.body.to_s.lines.each do |line|
|
71
|
-
if line =~ /^([^:]+): (.+)$/
|
72
|
-
options[$1] = $2
|
73
|
-
end
|
74
|
-
end
|
75
|
-
|
76
|
-
if req['Message-ID'] == options['Original-Message-ID']
|
77
|
-
mid_matched = true
|
78
|
-
else
|
79
|
-
success = false
|
80
|
-
end
|
81
|
-
|
82
|
-
if options['Received-Content-MIC'].start_with?(mic)
|
83
|
-
mic_matched = true
|
84
|
-
else
|
85
|
-
success = false
|
86
|
-
end
|
87
|
-
|
88
|
-
disp_code = options['Disposition']
|
89
|
-
success = disp_code.end_with?('processed')
|
122
|
+
def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:)
|
123
|
+
report = {
|
124
|
+
signature_verification_error: :not_checked,
|
125
|
+
mic_matched: nil,
|
126
|
+
mid_matched: nil,
|
127
|
+
disposition: nil,
|
128
|
+
plain_text_body: nil
|
129
|
+
}
|
130
|
+
|
131
|
+
# MDN bodies we've seen so far don't include Content-Type, which causes `read_smime` to fail.
|
132
|
+
response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
|
133
|
+
|
134
|
+
if mdn_content_type.start_with?('multipart/signed')
|
135
|
+
smime = OpenSSL::PKCS7.read_smime(response_content)
|
136
|
+
|
137
|
+
# create mail instance before #verify call.
|
138
|
+
# `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
|
139
|
+
mail = Mail.new(smime.data)
|
140
|
+
|
141
|
+
# based on As2::Message version
|
142
|
+
# TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
|
143
|
+
smime.verify [@partner.certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
|
144
|
+
report[:signature_verification_error] = smime.error_string
|
145
|
+
else
|
146
|
+
# MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
|
147
|
+
mail = Mail.new(response_content)
|
148
|
+
end
|
149
|
+
|
150
|
+
mail.parts.each do |part|
|
151
|
+
if part.content_type.start_with?('text/plain')
|
152
|
+
report[:plain_text_body] = part.body.to_s.strip
|
153
|
+
elsif part.content_type.start_with?('message/disposition-notification')
|
154
|
+
# "The rules for constructing the AS2-disposition-notification content..."
|
155
|
+
# https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.3
|
156
|
+
|
157
|
+
options = {}
|
158
|
+
# TODO: can we use Mail built-ins for this?
|
159
|
+
part.body.to_s.lines.each do |line|
|
160
|
+
if line =~ /^([^:]+): (.+)$/
|
161
|
+
# downcase because we've seen both 'Disposition' and 'disposition'
|
162
|
+
options[$1.to_s.downcase] = $2
|
90
163
|
end
|
91
164
|
end
|
165
|
+
|
166
|
+
report[:disposition] = options['disposition']
|
167
|
+
report[:mid_matched] = original_message_id == options['original-message-id']
|
168
|
+
|
169
|
+
if options['received-content-mic']
|
170
|
+
# do mic calc using the algorithm specified by server.
|
171
|
+
# (even if we specify sha1, server may send back MIC using a different algo.)
|
172
|
+
received_mic, micalg = options['received-content-mic'].split(',').map(&:strip)
|
173
|
+
|
174
|
+
# if they don't specify, we'll use the algorithm we specified in the outbound transmission.
|
175
|
+
# but it's only a guess & may fail.
|
176
|
+
micalg ||= outbound_mic_algorithm
|
177
|
+
|
178
|
+
mic = As2::DigestSelector.for_code(micalg).base64digest(original_body)
|
179
|
+
report[:mic_matched] = received_mic == mic
|
180
|
+
end
|
92
181
|
end
|
93
|
-
Result.new success, resp, mic_matched, mid_matched, body, disp_code
|
94
182
|
end
|
183
|
+
report
|
95
184
|
end
|
96
185
|
end
|
97
186
|
end
|
data/lib/as2/config.rb
CHANGED
@@ -0,0 +1,24 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
|
3
|
+
module As2
|
4
|
+
class DigestSelector
|
5
|
+
@map = {
|
6
|
+
'sha1' => OpenSSL::Digest::SHA1,
|
7
|
+
'sha256' => OpenSSL::Digest::SHA256,
|
8
|
+
'sha384' => OpenSSL::Digest::SHA384,
|
9
|
+
'sha512' => OpenSSL::Digest::SHA512,
|
10
|
+
'md5' => OpenSSL::Digest::MD5
|
11
|
+
}
|
12
|
+
|
13
|
+
def self.valid_codes
|
14
|
+
@map.keys
|
15
|
+
end
|
16
|
+
|
17
|
+
def self.for_code(code)
|
18
|
+
# we may receive 'sha256', 'sha-256', or 'SHA256'.
|
19
|
+
normalized = code.strip.downcase.gsub(/[^a-z0-9]/, '')
|
20
|
+
|
21
|
+
@map[normalized] || OpenSSL::Digest::SHA1
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
data/lib/as2/message.rb
CHANGED
@@ -29,7 +29,10 @@ module As2
|
|
29
29
|
# https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3.1
|
30
30
|
|
31
31
|
# TODO: more robust detection of content vs signature (if they're ever out of order).
|
32
|
-
content = mail.parts[0].raw_source
|
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
|
+
|
33
36
|
signature = OpenSSL::PKCS7.new(mail.parts[1].body.to_s)
|
34
37
|
|
35
38
|
# using an empty CA store. see notes on NOVERIFY flag below.
|
@@ -77,13 +80,20 @@ module As2
|
|
77
80
|
end
|
78
81
|
|
79
82
|
def mic
|
80
|
-
|
83
|
+
digest = As2::DigestSelector.for_code(mic_algorithm)
|
84
|
+
digest.base64digest(attachment.raw_source.strip)
|
85
|
+
end
|
86
|
+
|
87
|
+
def mic_algorithm
|
88
|
+
'sha256'
|
81
89
|
end
|
82
90
|
|
83
91
|
# Return the attached file, use .filename and .body on the return value
|
84
92
|
def attachment
|
85
93
|
if mail.has_attachments?
|
86
|
-
|
94
|
+
# TODO: match 'application/edi*', test with 'application/edi-x12'
|
95
|
+
# test also with "application/edi-consent; name=this_is_a_filename.txt"
|
96
|
+
mail.parts.find{ |a| a.content_type.match(/^application\/edi/) }
|
87
97
|
else
|
88
98
|
mail
|
89
99
|
end
|
data/lib/as2/mime_generator.rb
CHANGED
data/lib/as2/server.rb
CHANGED
@@ -8,28 +8,36 @@ module As2
|
|
8
8
|
class Server
|
9
9
|
attr_accessor :logger
|
10
10
|
|
11
|
-
|
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)
|
12
18
|
@block = block
|
13
|
-
@
|
14
|
-
@
|
19
|
+
@server_info = server_info || Config.server_info
|
20
|
+
@partner = partner
|
21
|
+
@signature_failure_handler = on_signature_failure
|
15
22
|
end
|
16
23
|
|
17
24
|
def call(env)
|
18
|
-
if env['HTTP_AS2_TO'] != @
|
25
|
+
if env['HTTP_AS2_TO'] != @server_info.name
|
19
26
|
return send_error(env, "Invalid destination name #{env['HTTP_AS2_TO']}")
|
20
27
|
end
|
21
28
|
|
22
|
-
partner = Config.partners[env['HTTP_AS2_FROM']]
|
23
|
-
|
29
|
+
partner = @partner || Config.partners[env['HTTP_AS2_FROM']]
|
30
|
+
|
31
|
+
if !partner || env['HTTP_AS2_FROM'] != partner.name
|
24
32
|
return send_error(env, "Invalid partner name #{env['HTTP_AS2_FROM']}")
|
25
33
|
end
|
26
34
|
|
27
35
|
request = Rack::Request.new(env)
|
28
|
-
message = Message.new(request.body.read, @
|
36
|
+
message = Message.new(request.body.read, @server_info.pkey, @server_info.certificate)
|
29
37
|
|
30
38
|
unless message.valid_signature?(partner.certificate)
|
31
|
-
if @
|
32
|
-
@
|
39
|
+
if @signature_failure_handler
|
40
|
+
@signature_failure_handler.call({
|
33
41
|
env: env,
|
34
42
|
smime_string: message.decrypted_message,
|
35
43
|
verification_error: message.verification_error
|
@@ -47,48 +55,41 @@ module As2
|
|
47
55
|
end
|
48
56
|
end
|
49
57
|
|
50
|
-
send_mdn(env, message.mic)
|
58
|
+
send_mdn(env, message.mic, message.mic_algorithm)
|
51
59
|
end
|
52
60
|
|
53
|
-
|
54
|
-
|
55
|
-
|
56
|
-
@logger ||= Logger.new env['rack.errors']
|
57
|
-
end
|
61
|
+
def send_mdn(env, mic, mic_algorithm, failed = nil)
|
62
|
+
# rules for MDN construction are covered in
|
63
|
+
# https://datatracker.ietf.org/doc/html/rfc4130#section-7.4.2
|
58
64
|
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
65
|
+
options = {
|
66
|
+
'Reporting-UA' => @server_info.name,
|
67
|
+
'Original-Recipient' => "rfc822; #{@server_info.name}",
|
68
|
+
'Final-Recipient' => "rfc822; #{@server_info.name}",
|
69
|
+
'Original-Message-ID' => env['HTTP_MESSAGE_ID']
|
70
|
+
}
|
71
|
+
if failed
|
72
|
+
options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
|
73
|
+
options['Failure'] = failed
|
74
|
+
text_body = "There was an error with the AS2 transmission.\r\n\r\n#{failed}"
|
75
|
+
else
|
76
|
+
options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
|
77
|
+
text_body = "The AS2 message has been received successfully"
|
78
|
+
end
|
79
|
+
options['Received-Content-MIC'] = "#{mic}, #{mic_algorithm}" if mic
|
63
80
|
|
64
|
-
def send_mdn(env, mic, failed = nil)
|
65
81
|
report = MimeGenerator::Part.new
|
66
82
|
report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
|
67
83
|
|
68
84
|
text = MimeGenerator::Part.new
|
69
85
|
text['Content-Type'] = 'text/plain'
|
70
86
|
text['Content-Transfer-Encoding'] = '7bit'
|
71
|
-
text.body =
|
72
|
-
|
87
|
+
text.body = text_body
|
73
88
|
report.add_part text
|
74
89
|
|
75
90
|
notification = MimeGenerator::Part.new
|
76
91
|
notification['Content-Type'] = 'message/disposition-notification'
|
77
92
|
notification['Content-Transfer-Encoding'] = '7bit'
|
78
|
-
|
79
|
-
options = {
|
80
|
-
'Reporting-UA' => @info.name,
|
81
|
-
'Original-Recipient' => "rfc822; #{@info.name}",
|
82
|
-
'Final-Recipient' => "rfc822; #{@info.name}",
|
83
|
-
'Original-Message-ID' => env['HTTP_MESSAGE_ID']
|
84
|
-
}
|
85
|
-
if failed
|
86
|
-
options['Disposition'] = 'automatic-action/MDN-sent-automatically; failed'
|
87
|
-
options['Failure'] = failed
|
88
|
-
else
|
89
|
-
options['Disposition'] = 'automatic-action/MDN-sent-automatically; processed'
|
90
|
-
end
|
91
|
-
options['Received-Content-MIC'] = "#{mic}, sha1" if mic
|
92
93
|
notification.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
|
93
94
|
report.add_part notification
|
94
95
|
|
@@ -96,23 +97,35 @@ module As2
|
|
96
97
|
|
97
98
|
report.write msg_out
|
98
99
|
|
99
|
-
pkcs7 = OpenSSL::PKCS7.sign @
|
100
|
+
pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, msg_out.string
|
100
101
|
pkcs7.detached = true
|
101
102
|
smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
|
102
103
|
|
103
104
|
content_type = smime_signed[/^Content-Type: (.+?)$/m, 1]
|
104
|
-
smime_signed.sub!(/\A.+?^(?=---)/m, '')
|
105
|
+
# smime_signed.sub!(/\A.+?^(?=---)/m, '')
|
105
106
|
|
106
107
|
headers = {}
|
107
108
|
headers['Content-Type'] = content_type
|
109
|
+
# TODO: if MIME-Version header is actually needed, should extract it out of smime_signed.
|
108
110
|
headers['MIME-Version'] = '1.0'
|
109
|
-
headers['Message-ID'] =
|
110
|
-
headers['AS2-From'] = @
|
111
|
+
headers['Message-ID'] = As2.generate_message_id(@server_info)
|
112
|
+
headers['AS2-From'] = @server_info.name
|
111
113
|
headers['AS2-To'] = env['HTTP_AS2_FROM']
|
112
|
-
headers['AS2-Version'] = '1.
|
114
|
+
headers['AS2-Version'] = '1.0'
|
113
115
|
headers['Connection'] = 'close'
|
114
116
|
|
115
117
|
[200, headers, ["\r\n" + smime_signed]]
|
116
118
|
end
|
119
|
+
|
120
|
+
private
|
121
|
+
|
122
|
+
def logger(env)
|
123
|
+
@logger ||= Logger.new env['rack.errors']
|
124
|
+
end
|
125
|
+
|
126
|
+
def send_error(env, msg)
|
127
|
+
logger(env).error msg
|
128
|
+
send_mdn env, nil, 'sha1', msg
|
129
|
+
end
|
117
130
|
end
|
118
131
|
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,15 +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.5.1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- OfficeLuv
|
8
|
-
-
|
8
|
+
- Alex Dean
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2022-08-10 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: mail
|
@@ -95,15 +95,58 @@ dependencies:
|
|
95
95
|
- - ">="
|
96
96
|
- !ruby/object:Gem::Version
|
97
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'
|
98
140
|
description: Simple AS2 server and client implementation. Follows the AS2 implementation
|
99
141
|
from http://as2.mendelson-e-c.com
|
100
142
|
email:
|
101
143
|
- development@officeluv.com
|
102
|
-
-
|
144
|
+
- github@mostlyalex.com
|
103
145
|
executables: []
|
104
146
|
extensions: []
|
105
147
|
extra_rdoc_files: []
|
106
148
|
files:
|
149
|
+
- ".github/workflows/test.yml"
|
107
150
|
- ".gitignore"
|
108
151
|
- CHANGELOG.md
|
109
152
|
- Gemfile
|
@@ -118,12 +161,14 @@ files:
|
|
118
161
|
- lib/as2.rb
|
119
162
|
- lib/as2/base64_helper.rb
|
120
163
|
- lib/as2/client.rb
|
164
|
+
- lib/as2/client/result.rb
|
121
165
|
- lib/as2/config.rb
|
166
|
+
- lib/as2/digest_selector.rb
|
122
167
|
- lib/as2/message.rb
|
123
168
|
- lib/as2/mime_generator.rb
|
124
169
|
- lib/as2/server.rb
|
125
170
|
- lib/as2/version.rb
|
126
|
-
homepage: https://github.com/
|
171
|
+
homepage: https://github.com/andjosh/as2
|
127
172
|
licenses:
|
128
173
|
- MIT
|
129
174
|
metadata:
|