as2 0.3.0 → 0.5.1
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 +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
|
+
[![Test Suite](https://github.com/andjosh/as2/actions/workflows/test.yml/badge.svg)](https://github.com/andjosh/as2/actions/workflows/test.yml)
|
10
|
+
|
11
|
+
## Known Limitations
|
12
|
+
|
13
|
+
These limitations may be removed over time as demand (and pull requests!) come
|
14
|
+
along.
|
15
|
+
|
16
|
+
1. RFC defines a number of optional features that partners can pick and choose
|
17
|
+
amongst. We currently have hard-coded options for many of these. Our current
|
18
|
+
choices are likely the most common ones in use, but we do not offer all the
|
19
|
+
configuration options needed for a fully-compliant implementation. https://datatracker.ietf.org/doc/html/rfc4130#section-2.4.2
|
20
|
+
1. Encrypted or Unencrypted Data: We assume all messages are encrypted. An
|
21
|
+
error will result if partner sends us an unencrypted message.
|
22
|
+
2. Signed or Unsigned Data: We error if partner sends an unsigned message.
|
23
|
+
Partners can request unsigned MDNs, but we always send signed MDNs.
|
24
|
+
3. Optional Use of Receipt: We always send a receipt.
|
25
|
+
4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
|
26
|
+
delivery of MDNs.
|
27
|
+
5. Security Formatting: We should be reasonably compliant here.
|
28
|
+
6. Hash Function, Message Digest Choices: We currently always use sha256. If a
|
29
|
+
partner asks for a different algorithm, we'll always use sha256 and partner
|
30
|
+
will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
|
31
|
+
mentions md5. Mendelson AS2 server supports a number of other algorithms.
|
32
|
+
(sha256, sha512, etc)
|
33
|
+
2. Payload bodies 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:
|