as2 0.5.2 → 0.7.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/.gitignore +1 -0
- data/CHANGELOG.md +18 -0
- data/README.md +7 -11
- data/as2.gemspec +2 -1
- data/examples/server.rb +84 -12
- data/lib/as2/client.rb +228 -24
- data/lib/as2/config.rb +19 -1
- data/lib/as2/digest_selector.rb +9 -3
- data/lib/as2/message.rb +155 -40
- data/lib/as2/parser/disposition_notification_options.rb +71 -0
- data/lib/as2/parser.rb +11 -0
- data/lib/as2/server.rb +111 -16
- data/lib/as2/version.rb +1 -1
- data/lib/as2.rb +19 -4
- data/tmp/inbox/.keep +0 -0
- metadata +20 -3
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 78765b4a0fba8aaa7e3531ee9e3841a0774d72b6a1d09b18a69b53117cf26e22
|
4
|
+
data.tar.gz: c020c365f57a2dd428501b453bb349483bc98baf6db263dab7dfdfbab33b70a6
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 13155476fe9dab95fa56e29ec8dc842c86d4cdba0bf296aec518801d4ff8ef3f963bdb7e20d9e17bbc58bfdf334d9c760ccd712b267f70882b8e7ec79fa9230a
|
7
|
+
data.tar.gz: 1c6806d55b297222c0133e27f53914143ba0b3b35891c8076e93befe9d375534551b492890092432015a793dd88d97d8489ce69635e6ceeb8495f00880c5bd82
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,21 @@
|
|
1
|
+
## 0.7.0, August 25, 2023
|
2
|
+
|
3
|
+
Two improvements in compatibility with IBM Sterling, which could not understand
|
4
|
+
our existing message & MDN formats.
|
5
|
+
|
6
|
+
These changes are opt-in only, and require a config change to use. See linked PRs for
|
7
|
+
details.
|
8
|
+
|
9
|
+
* Improved formatting of MDN messages. [#25](https://github.com/alexdean/as2/pull/25)
|
10
|
+
* Improved formatting of outbound messages. [#28](https://github.com/alexdean/as2/pull/28)
|
11
|
+
|
12
|
+
## 0.6.0, April 4, 2023
|
13
|
+
|
14
|
+
* allow verification of signed MDNs which use `Content-Transfer-Encoding: binary`. [#22](https://github.com/alexdean/as2/pull/22)
|
15
|
+
* Improve example server to make it more useful for local testing & development. [#17](https://github.com/alexdean/as2/pull/17)
|
16
|
+
* Support `Content-Tranfer-Encoding: binary`. [#11](https://github.com/alexdean/as2/pull/11)
|
17
|
+
* Server can choose MIC algorithm based on HTTP `Disposition-Notification-Options` header. [#20](https://github.com/alexdean/as2/pull/20)
|
18
|
+
|
1
19
|
## 0.5.1, August 10, 2022
|
2
20
|
|
3
21
|
* Any HTTP 2xx status received from a partner should be considered successful. [#12](https://github.com/andjosh/as2/pull/12)
|
data/README.md
CHANGED
@@ -3,10 +3,11 @@
|
|
3
3
|
This is a proof of concept implementation of AS2 protocol: http://www.ietf.org/rfc/rfc4130.txt.
|
4
4
|
|
5
5
|
Tested with the mendelson AS2 implementation from http://as2.mendelson-e-c.com
|
6
|
+
and with [OpenAS2](https://github.com/OpenAS2/OpenAs2App).
|
6
7
|
|
7
8
|
## Build Status
|
8
9
|
|
9
|
-
[![Test Suite](https://github.com/
|
10
|
+
[![Test Suite](https://github.com/alexdean/as2/actions/workflows/test.yml/badge.svg)](https://github.com/alexdean/as2/actions/workflows/test.yml)
|
10
11
|
|
11
12
|
## Known Limitations
|
12
13
|
|
@@ -25,15 +26,10 @@ along.
|
|
25
26
|
4. Use of Synchronous or Asynchronous Receipts: We do not support asynchronous
|
26
27
|
delivery of MDNs.
|
27
28
|
5. Security Formatting: We should be reasonably compliant here.
|
28
|
-
6. Hash Function, Message Digest Choices: We currently always use sha256
|
29
|
-
|
30
|
-
|
31
|
-
|
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.
|
29
|
+
6. Hash Function, Message Digest Choices: We currently always use sha256 for
|
30
|
+
signing. Since [#20](https://github.com/alexdean/as2/pull/20) we have supported
|
31
|
+
allowing partners to request which algorithm we use for MIC generation in MDNs.
|
32
|
+
2. AS2 partners may agree to use separate certificates for data encryption and data signing.
|
37
33
|
We do not support separate certificates for these purposes.
|
38
34
|
|
39
35
|
## Installation
|
@@ -78,7 +74,7 @@ You can run a local server with `bundle exec ruby examples/server.rb` and send i
|
|
78
74
|
|
79
75
|
## Contributing
|
80
76
|
|
81
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
77
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/alexdean/as2.
|
82
78
|
|
83
79
|
|
84
80
|
## License
|
data/as2.gemspec
CHANGED
@@ -32,7 +32,8 @@ Gem::Specification.new do |spec|
|
|
32
32
|
|
33
33
|
spec.add_development_dependency "bundler", ">= 1.10"
|
34
34
|
spec.add_development_dependency "rake", ">= 10.0"
|
35
|
-
spec.add_development_dependency "
|
35
|
+
spec.add_development_dependency "rackup"
|
36
|
+
spec.add_development_dependency "puma"
|
36
37
|
spec.add_development_dependency "minitest"
|
37
38
|
spec.add_development_dependency "minitest-focus"
|
38
39
|
spec.add_development_dependency "webmock"
|
data/examples/server.rb
CHANGED
@@ -1,32 +1,104 @@
|
|
1
|
+
# test server receives files & saves them to the local filesystem
|
2
|
+
#
|
3
|
+
# `bundle exec ruby examples/server.rb`
|
1
4
|
require 'as2'
|
2
|
-
require '
|
5
|
+
require 'rackup'
|
6
|
+
require 'rack/handler/puma'
|
7
|
+
require 'pathname'
|
8
|
+
require 'fileutils'
|
9
|
+
|
10
|
+
this_dir = Pathname.new(File.expand_path('..', __FILE__))
|
11
|
+
root_dir = this_dir.join('..')
|
3
12
|
|
4
13
|
As2.configure do |conf|
|
5
|
-
conf.name = '
|
14
|
+
conf.name = 'RUBYAS2'
|
6
15
|
conf.url = 'http://localhost:3000/as2'
|
7
16
|
conf.certificate = 'test/certificates/server.crt'
|
8
17
|
conf.pkey = 'test/certificates/server.key'
|
9
|
-
conf.domain = '
|
18
|
+
conf.domain = 'localhost'
|
19
|
+
|
10
20
|
conf.add_partner do |partner|
|
11
|
-
partner.name = '
|
21
|
+
partner.name = 'MENDELSON'
|
12
22
|
partner.url = 'http://localhost:8080/as2/HttpReceiver'
|
13
23
|
partner.certificate = 'test/certificates/client.crt'
|
14
24
|
end
|
25
|
+
|
26
|
+
conf.add_partner do |partner|
|
27
|
+
partner.name = 'OPENAS2'
|
28
|
+
partner.url = 'http://localhost:4088'
|
29
|
+
partner.certificate = 'test/certificates/client.crt'
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def log(message, transmission_id: nil)
|
34
|
+
puts "#{Time.now.strftime('%F %T')} [#{transmission_id}] #{message}"
|
15
35
|
end
|
16
36
|
|
17
|
-
|
18
|
-
|
19
|
-
|
20
|
-
|
21
|
-
|
37
|
+
# TODO: there are a lot of potential failure cases we're not handling
|
38
|
+
# (failed decryption, unsigned message, etc), because this script is intended for
|
39
|
+
# local debugging.
|
40
|
+
handler = Proc.new do |env|
|
41
|
+
transmission_id = "#{Time.now.strftime('%Y%m%d_%H%M%S_%L')}_#{SecureRandom.hex(6)}"
|
42
|
+
log("start.", transmission_id: transmission_id)
|
43
|
+
|
44
|
+
server_info = As2::Config.server_info
|
45
|
+
|
46
|
+
partner_name = env['HTTP_AS2_FROM']
|
47
|
+
partner = As2::Config.partners[partner_name]
|
48
|
+
|
49
|
+
log("partner:#{partner_name} known_partner?:#{!!partner}", transmission_id: transmission_id)
|
50
|
+
partner_dir = root_dir.join('tmp/inbox/', partner_name)
|
51
|
+
if !File.exist?(partner_dir)
|
52
|
+
FileUtils.mkdir_p(partner_dir)
|
53
|
+
end
|
54
|
+
|
55
|
+
raw_request_body = env['rack.input'].read
|
56
|
+
|
57
|
+
mic_algorithm = As2.choose_mic_algorithm(env['HTTP_DISPOSITION_NOTIFICATION_OPTIONS'])
|
58
|
+
message = As2::Message.new(raw_request_body, server_info.pkey, server_info.certificate,
|
59
|
+
mic_algorithm: mic_algorithm
|
60
|
+
)
|
61
|
+
|
62
|
+
# do this before writing to disk, in case we have to fix content.
|
63
|
+
# @see https://github.com/alexdean/as2/pull/11
|
64
|
+
valid_signature = message.valid_signature?(partner.certificate)
|
65
|
+
|
66
|
+
original_filename = message.attachment.filename
|
67
|
+
extname = File.extname(original_filename)
|
68
|
+
basename = partner_dir.join(File.basename(message.attachment.filename, extname)).to_s
|
69
|
+
encrypted_filename = "#{basename}.pkcs7" # exactly what we got on the wire
|
70
|
+
decrypted_filename = "#{basename}.mime" # full message, all parts
|
71
|
+
body_filename = "#{basename}#{extname}" # just the body part, w/o signature
|
72
|
+
|
73
|
+
File.open(encrypted_filename, 'wb') { |f| f.write(raw_request_body) }
|
74
|
+
File.open(decrypted_filename, 'wb') { |f| f.write(message.decrypted_message) }
|
75
|
+
File.open(body_filename, 'wb') { |f| f.write(message.attachment.raw_source) }
|
76
|
+
|
77
|
+
# filenames are absolute paths to each file.
|
78
|
+
# when we print output, nicer to read a path relative to the project's root.
|
79
|
+
prefix_length = root_dir.to_s.length + 1
|
80
|
+
verification_error = message.verification_error
|
81
|
+
|
82
|
+
report = <<~EOF
|
83
|
+
filename:#{original_filename}
|
84
|
+
#{encrypted_filename[prefix_length..]}
|
85
|
+
#{decrypted_filename[prefix_length..]}
|
86
|
+
#{body_filename[prefix_length..]}
|
87
|
+
valid_signature?:#{valid_signature}#{verification_error && " error:'#{verification_error}'"}
|
88
|
+
MIC: '#{message.mic}' (#{message.mic_algorithm})
|
89
|
+
EOF
|
90
|
+
log(report, transmission_id: transmission_id)
|
91
|
+
|
92
|
+
server = As2::Server.new(server_info: server_info, partner: partner)
|
93
|
+
server.send_mdn(env, message.mic, message.mic_algorithm, message.verification_error)
|
22
94
|
end
|
23
95
|
|
24
96
|
builder = Rack::Builder.new do
|
25
|
-
use Rack::
|
97
|
+
use Rack::ShowExceptions
|
26
98
|
map '/as2' do
|
27
99
|
run handler
|
28
100
|
end
|
29
101
|
end
|
30
102
|
|
31
|
-
puts "
|
32
|
-
Rack::Handler::
|
103
|
+
puts "ruby-as2 version: #{As2::VERSION}"
|
104
|
+
Rack::Handler::Puma.run builder, Port: 3002, Host: '0.0.0.0'
|
data/lib/as2/client.rb
CHANGED
@@ -4,12 +4,20 @@ module As2
|
|
4
4
|
class Client
|
5
5
|
attr_reader :partner, :server_info
|
6
6
|
|
7
|
+
def self.valid_outbound_formats
|
8
|
+
['v0', 'v1']
|
9
|
+
end
|
10
|
+
|
7
11
|
# @param [As2::Config::Partner,String] partner The partner to send a message to.
|
8
12
|
# If a string is given, it should be a partner name which has been registered
|
9
13
|
# via a call to #add_partner.
|
10
14
|
# @param [As2::Config::ServerInfo,nil] server_info The server info used to identify
|
11
15
|
# this client to the partner. If omitted, the main As2::Config.server_info will be used.
|
12
|
-
|
16
|
+
# @param [Logger, nil] logger If supplied, some additional information about how
|
17
|
+
# messages are processed will be written here.
|
18
|
+
def initialize(partner, server_info: nil, logger: nil)
|
19
|
+
@logger = logger || Logger.new('/dev/null')
|
20
|
+
|
13
21
|
if partner.is_a?(As2::Config::Partner)
|
14
22
|
@partner = partner
|
15
23
|
else
|
@@ -60,18 +68,22 @@ module As2
|
|
60
68
|
req['Message-ID'] = outbound_message_id
|
61
69
|
|
62
70
|
document_content = content || File.read(file_name)
|
71
|
+
outbound_format = @partner&.outbound_format || 'v0'
|
63
72
|
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
73
|
+
if outbound_format == 'v1'
|
74
|
+
format_method = :format_body_v1
|
75
|
+
else
|
76
|
+
format_method = :format_body_v0
|
77
|
+
end
|
78
|
+
|
79
|
+
document_payload, request_body = send(format_method,
|
80
|
+
document_content,
|
81
|
+
content_type: content_type,
|
82
|
+
file_name: file_name
|
83
|
+
)
|
69
84
|
|
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
85
|
cipher = OpenSSL::Cipher::AES256.new(:CBC) # default, but we might have to make this configurable
|
74
|
-
encrypted = OpenSSL::PKCS7.encrypt
|
86
|
+
encrypted = OpenSSL::PKCS7.encrypt([@partner.certificate], request_body, cipher)
|
75
87
|
|
76
88
|
# > HTTP can handle binary data and so there is no need to use the
|
77
89
|
# > content transfer encodings of MIME
|
@@ -119,6 +131,109 @@ module As2
|
|
119
131
|
)
|
120
132
|
end
|
121
133
|
|
134
|
+
# 'original' body formatting
|
135
|
+
#
|
136
|
+
# 1. uses OpenSSL::PKCS7.write_smime to build MIME body
|
137
|
+
# * includes plain-text "this is an S/MIME message" note prior to initial
|
138
|
+
# MIME boundary
|
139
|
+
# 2. uses non-standard application/x-pkcs7-* content types
|
140
|
+
# 3. MIME boundaries and signature have \n line endings
|
141
|
+
#
|
142
|
+
# this format is understood by Mendelson, OpenAS2, and several commercial
|
143
|
+
# products (GoAnywhere MFT). it is not understood by IBM Sterling B2B Integrator.
|
144
|
+
#
|
145
|
+
# @param [String] document_content the content to be transmitted
|
146
|
+
# @param [String] content_type the MIME type for document_content
|
147
|
+
# @param [String] file_name The filename to be transmitted to the partner
|
148
|
+
# @return [Array]
|
149
|
+
# first item is the full document part of the transmission (including) MIME headers.
|
150
|
+
# second item is the complete HTTP body.
|
151
|
+
def format_body_v0(document_content, content_type:, file_name:)
|
152
|
+
document_payload = "Content-Type: #{content_type}\r\n"
|
153
|
+
document_payload << "Content-Transfer-Encoding: base64\r\n"
|
154
|
+
document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
|
155
|
+
document_payload << "\r\n"
|
156
|
+
document_payload << Base64.strict_encode64(document_content)
|
157
|
+
|
158
|
+
signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload)
|
159
|
+
signature.detached = true
|
160
|
+
|
161
|
+
[document_payload, OpenSSL::PKCS7.write_smime(signature, document_payload)]
|
162
|
+
end
|
163
|
+
|
164
|
+
# updated body formatting
|
165
|
+
#
|
166
|
+
# 1. no content before the first MIME boundary
|
167
|
+
# 2. uses standard application/pkcs7-* content types
|
168
|
+
# 3. MIME boundaries and signature have \r\n line endings
|
169
|
+
# 4. adds parameter smime-type=signed-data to the signature's Content-Type
|
170
|
+
#
|
171
|
+
# this format is understood by Mendelson, OpenAS2, and several commercial
|
172
|
+
# products (GoAnywhere MFT) and IBM Sterling B2B Integrator.
|
173
|
+
#
|
174
|
+
# @param [String] document_content the content to be transmitted
|
175
|
+
# @param [String] content_type the MIME type for document_content
|
176
|
+
# @param [String] file_name The filename to be transmitted to the partner
|
177
|
+
# @return [Array]
|
178
|
+
# first item is the full document part of the transmission (including) MIME headers.
|
179
|
+
# second item is the complete HTTP body.
|
180
|
+
def format_body_v1(document_content, content_type:, file_name:)
|
181
|
+
document_payload = "Content-Type: #{content_type}\r\n"
|
182
|
+
document_payload << "Content-Transfer-Encoding: base64\r\n"
|
183
|
+
document_payload << "Content-Disposition: attachment; filename=#{file_name}\r\n"
|
184
|
+
document_payload << "\r\n"
|
185
|
+
document_payload << Base64.strict_encode64(document_content)
|
186
|
+
|
187
|
+
signature = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, document_payload)
|
188
|
+
signature.detached = true
|
189
|
+
|
190
|
+
# PEM (base64-encoded) signature
|
191
|
+
bare_pem_signature = signature.to_pem
|
192
|
+
# strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters
|
193
|
+
bare_pem_signature.gsub!(/^-----[^\n]+\n/, '')
|
194
|
+
# and update to canonical \r\n line endings
|
195
|
+
bare_pem_signature.gsub!(/(?<!\r)\n/, "\r\n")
|
196
|
+
|
197
|
+
# this is a hack until i can determine a better way to get the micalg parameter
|
198
|
+
# from the pkcs7 signature generated above...
|
199
|
+
# https://stackoverflow.com/questions/75934159/how-does-openssl-smime-determine-micalg-parameter
|
200
|
+
#
|
201
|
+
# also tried approach outlined in https://stackoverflow.com/questions/53044007/how-to-use-sha1-digest-during-signing-with-opensslpkcs7-sign-when-creating-smi
|
202
|
+
# but the signature generated by that method lacks some essential data. verifying those
|
203
|
+
# signatures results in an openssl error "unable to find message digest"
|
204
|
+
smime_body = OpenSSL::PKCS7.write_smime(signature, document_payload)
|
205
|
+
micalg = smime_body[/^Content-Type: multipart\/signed.*micalg=\"([^"]+)/m, 1]
|
206
|
+
|
207
|
+
# generate a MIME part boundary
|
208
|
+
#
|
209
|
+
# > A good strategy is to choose a boundary that includes
|
210
|
+
# > a character sequence such as "=_" which can never appear in a
|
211
|
+
# > quoted-printable body.
|
212
|
+
#
|
213
|
+
# https://www.rfc-editor.org/rfc/rfc2045#page-21
|
214
|
+
boundary = "----=_#{SecureRandom.hex(16).upcase}"
|
215
|
+
body_boundary = "--#{boundary}"
|
216
|
+
|
217
|
+
# body's mime headers
|
218
|
+
body = "Content-Type: multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=#{micalg}; boundary=\"#{boundary}\"\r\n"
|
219
|
+
body += "\r\n"
|
220
|
+
|
221
|
+
# first body part: the document
|
222
|
+
body += body_boundary + "\r\n"
|
223
|
+
body += document_payload + "\r\n"
|
224
|
+
|
225
|
+
# second body part: the signature
|
226
|
+
body += body_boundary + "\r\n"
|
227
|
+
body += "Content-Type: application/pkcs7-signature; name=smime.p7s; smime-type=signed-data\r\n"
|
228
|
+
body += "Content-Transfer-Encoding: base64\r\n"
|
229
|
+
body += "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n"
|
230
|
+
body += "\r\n"
|
231
|
+
body += bare_pem_signature
|
232
|
+
body += body_boundary + "--\r\n"
|
233
|
+
|
234
|
+
[document_payload, body]
|
235
|
+
end
|
236
|
+
|
122
237
|
def evaluate_mdn(mdn_body:, mdn_content_type:, original_message_id:, original_body:)
|
123
238
|
report = {
|
124
239
|
signature_verification_error: :not_checked,
|
@@ -132,22 +247,18 @@ module As2
|
|
132
247
|
response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
|
133
248
|
|
134
249
|
if mdn_content_type.start_with?('multipart/signed')
|
135
|
-
|
136
|
-
|
137
|
-
|
138
|
-
|
139
|
-
|
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
|
250
|
+
result = parse_signed_mdn(
|
251
|
+
multipart_signed_message: response_content,
|
252
|
+
certificate: @partner.certificate
|
253
|
+
)
|
254
|
+
mdn_report = result[:mdn_report]
|
255
|
+
report[:signature_verification_error] = result[:signature_verification_error]
|
145
256
|
else
|
146
257
|
# MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
|
147
|
-
|
258
|
+
mdn_report = Mail.new(response_content)
|
148
259
|
end
|
149
260
|
|
150
|
-
|
261
|
+
mdn_report.parts.each do |part|
|
151
262
|
if part.content_type.start_with?('text/plain')
|
152
263
|
report[:plain_text_body] = part.body.to_s.strip
|
153
264
|
elsif part.content_type.start_with?('message/disposition-notification')
|
@@ -163,8 +274,8 @@ module As2
|
|
163
274
|
end
|
164
275
|
end
|
165
276
|
|
166
|
-
report[:disposition] = options['disposition']
|
167
|
-
report[:mid_matched] = original_message_id == options['original-message-id']
|
277
|
+
report[:disposition] = options['disposition'].strip
|
278
|
+
report[:mid_matched] = original_message_id == options['original-message-id'].strip
|
168
279
|
|
169
280
|
if options['received-content-mic']
|
170
281
|
# do mic calc using the algorithm specified by server.
|
@@ -182,5 +293,98 @@ module As2
|
|
182
293
|
end
|
183
294
|
report
|
184
295
|
end
|
296
|
+
|
297
|
+
private
|
298
|
+
|
299
|
+
# extract the MDN body from a multipart/signed wrapper & attempt to verify
|
300
|
+
# the signature
|
301
|
+
#
|
302
|
+
# @param [String] multipart_signed_message The 'outer' MDN body, containing MIME header,
|
303
|
+
# MDN body (which itself is likely a multi-part object) and a signature.
|
304
|
+
# @param [OpenSSL::X509::Certificate] verify that the MDN body was signed using this certificate
|
305
|
+
# @return [Hash] results of the check
|
306
|
+
# * :mdn_mime_body [Mail::Message] The 'inner' MDN body, with signature removed
|
307
|
+
# * :signature_verification_error [String] Any error which resulted when checking the
|
308
|
+
# signature. If this is empty it means the signature was valid.
|
309
|
+
def parse_signed_mdn(multipart_signed_message:, certificate:)
|
310
|
+
smime = nil
|
311
|
+
|
312
|
+
begin
|
313
|
+
# This will fail if the signature is binary-encoded. In that case
|
314
|
+
# we rescue so we can continue to extract other data from the MDN.
|
315
|
+
# User can decide how to proceed after the signature verification failure.
|
316
|
+
#
|
317
|
+
# > The parser assumes that the PKCS7 structure is always base64 encoded
|
318
|
+
# > and will not handle the case where it is in binary format or uses quoted
|
319
|
+
# > printable format.
|
320
|
+
#
|
321
|
+
# https://www.openssl.org/docs/man3.1/man3/SMIME_read_PKCS7.html
|
322
|
+
#
|
323
|
+
# Likely we can resolve this by building a PKCS7 manually from the MDN
|
324
|
+
# payload, rather than using `read_smime`.
|
325
|
+
#
|
326
|
+
# An aside: manually base64-encoding the binary signature allows the MDN
|
327
|
+
# to be parsed & verified via `read_smime`, so that could also be an option.
|
328
|
+
smime = OpenSSL::PKCS7.read_smime(multipart_signed_message)
|
329
|
+
rescue => e
|
330
|
+
@logger.warn "error checking signature using read_smime. #{e.message}"
|
331
|
+
signature_verification_error = e.message
|
332
|
+
end
|
333
|
+
|
334
|
+
if smime
|
335
|
+
# create mail instance before #verify call.
|
336
|
+
# `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
|
337
|
+
mdn_report = Mail.new(smime.data)
|
338
|
+
|
339
|
+
# based on As2::Message version
|
340
|
+
# TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
|
341
|
+
# See notes in As2::Message.verify for reasoning on flag usage
|
342
|
+
smime.verify [certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
|
343
|
+
|
344
|
+
signature_verification_error = smime.error_string
|
345
|
+
else
|
346
|
+
@logger.info "trying fallback sigature verification."
|
347
|
+
# read_smime will fail on binary-encoded MDNs. in this case, we can attempt
|
348
|
+
# to parse the structure using Mail and do signature verification
|
349
|
+
# slightly differently.
|
350
|
+
#
|
351
|
+
# what follows is the same process applied in As2::Message#valid_signature?.
|
352
|
+
# see notes there for more info on "multipart/signed" MIME messages.
|
353
|
+
#
|
354
|
+
# 1. maybe unify these at some point?
|
355
|
+
# 2. maybe always use this process, and drop initial attempt at
|
356
|
+
# `OpenSSL::PKCS7.read_smime` above.
|
357
|
+
#
|
358
|
+
# refactoring to allow using As2::Message#valid_signature? here
|
359
|
+
# would also allow us to utilize the line-ending fixup code there
|
360
|
+
|
361
|
+
# this should have 2 parts. the MDN report (parts[0]) and the signature (parts[1])
|
362
|
+
#
|
363
|
+
# * https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
|
364
|
+
# * see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
|
365
|
+
outer_mail = Mail.new(multipart_signed_message)
|
366
|
+
|
367
|
+
mdn_report = outer_mail.parts[0]
|
368
|
+
|
369
|
+
content = mdn_report.raw_source
|
370
|
+
content = content.gsub(/\A\s+/, '')
|
371
|
+
|
372
|
+
signature = outer_mail.parts[1]
|
373
|
+
signature_text = signature.body.to_s
|
374
|
+
|
375
|
+
result = As2::Message.verify(
|
376
|
+
content: content,
|
377
|
+
signature_text: signature_text,
|
378
|
+
certificate: @partner.certificate
|
379
|
+
)
|
380
|
+
|
381
|
+
signature_verification_error = result[:error]
|
382
|
+
end
|
383
|
+
|
384
|
+
{
|
385
|
+
mdn_report: mdn_report,
|
386
|
+
signature_verification_error: signature_verification_error
|
387
|
+
}
|
388
|
+
end
|
185
389
|
end
|
186
390
|
end
|
data/lib/as2/config.rb
CHANGED
@@ -12,7 +12,7 @@ module As2
|
|
12
12
|
end
|
13
13
|
end
|
14
14
|
|
15
|
-
class Partner < Struct.new :name, :url, :certificate
|
15
|
+
class Partner < Struct.new :name, :url, :certificate, :mdn_format, :outbound_format
|
16
16
|
def url=(url)
|
17
17
|
if url.kind_of? String
|
18
18
|
self['url'] = URI.parse url
|
@@ -21,6 +21,24 @@ module As2
|
|
21
21
|
end
|
22
22
|
end
|
23
23
|
|
24
|
+
def mdn_format=(format)
|
25
|
+
format_s = format.to_s
|
26
|
+
valid_formats = As2::Server.valid_mdn_formats
|
27
|
+
if !valid_formats.include?(format_s)
|
28
|
+
raise ArgumentError, "mdn_format '#{format_s}' must be one of #{valid_formats.inspect}"
|
29
|
+
end
|
30
|
+
self['mdn_format'] = format_s
|
31
|
+
end
|
32
|
+
|
33
|
+
def outbound_format=(format)
|
34
|
+
format_s = format.to_s
|
35
|
+
valid_formats = As2::Client.valid_outbound_formats
|
36
|
+
if !valid_formats.include?(format_s)
|
37
|
+
raise ArgumentError, "outbound_format '#{format_s}' must be one of #{valid_formats.inspect}"
|
38
|
+
end
|
39
|
+
self['outbound_format'] = format_s
|
40
|
+
end
|
41
|
+
|
24
42
|
def certificate=(certificate)
|
25
43
|
self['certificate'] = As2::Config.build_certificate(certificate)
|
26
44
|
end
|
data/lib/as2/digest_selector.rb
CHANGED
@@ -14,11 +14,17 @@ module As2
|
|
14
14
|
@map.keys
|
15
15
|
end
|
16
16
|
|
17
|
+
def self.valid?(code)
|
18
|
+
@map[normalized(code)]
|
19
|
+
end
|
20
|
+
|
17
21
|
def self.for_code(code)
|
18
|
-
|
19
|
-
|
22
|
+
@map[normalized(code)] || OpenSSL::Digest::SHA1
|
23
|
+
end
|
20
24
|
|
21
|
-
|
25
|
+
def self.normalized(code)
|
26
|
+
# we may receive 'sha256', 'sha-256', or 'SHA256'.
|
27
|
+
code.to_s.strip.downcase.gsub(/[^a-z0-9]/, '')
|
22
28
|
end
|
23
29
|
end
|
24
30
|
end
|
data/lib/as2/message.rb
CHANGED
@@ -2,9 +2,10 @@ module As2
|
|
2
2
|
class Message
|
3
3
|
attr_reader :verification_error
|
4
4
|
|
5
|
-
# given multiple parts of a message, choose the one most likely to be the
|
5
|
+
# given multiple parts of a message, choose the one most likely to be the
|
6
|
+
# actual content we care about
|
6
7
|
#
|
7
|
-
# @param [
|
8
|
+
# @param [Mail::PartsList] mail_parts
|
8
9
|
# @return [Mail::Part, nil]
|
9
10
|
def self.choose_attachment(mail_parts)
|
10
11
|
return nil if mail_parts.nil?
|
@@ -16,6 +17,18 @@ module As2
|
|
16
17
|
candidates[0]
|
17
18
|
end
|
18
19
|
|
20
|
+
# return the mail part containing a digital signature
|
21
|
+
#
|
22
|
+
# @param [Mail::PartsList] mail_parts
|
23
|
+
# @return [Mail::Part, nil]
|
24
|
+
def self.choose_signature(mail_parts)
|
25
|
+
return nil if mail_parts.nil?
|
26
|
+
|
27
|
+
mail_parts.find { |part| part.content_type.to_s['pkcs7-signature'] }
|
28
|
+
end
|
29
|
+
|
30
|
+
# calculate the MIC for a given mail part
|
31
|
+
#
|
19
32
|
# @param [Mail::Part] attachment
|
20
33
|
# @param [String] mic_algorithm
|
21
34
|
# @return [String] message integrity check string
|
@@ -24,50 +37,37 @@ module As2
|
|
24
37
|
digest.base64digest(attachment.raw_source.lstrip)
|
25
38
|
end
|
26
39
|
|
27
|
-
|
28
|
-
|
29
|
-
|
30
|
-
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
|
40
|
-
|
41
|
-
|
42
|
-
|
43
|
-
|
44
|
-
|
45
|
-
|
46
|
-
#
|
47
|
-
# > The multipart/signed MIME type has two parts. The first part contains
|
48
|
-
# > the MIME entity that is signed; the second part contains the "detached signature"
|
49
|
-
# > CMS SignedData object in which the encapContentInfo eContent field is absent.
|
50
|
-
#
|
51
|
-
# https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3.1
|
52
|
-
|
53
|
-
# TODO: more robust detection of content vs signature (if they're ever out of order).
|
54
|
-
content = mail.parts[0].raw_source
|
55
|
-
# remove any leading \r\n characters (between headers & body i think).
|
56
|
-
content = content.gsub(/\A\s+/, '')
|
57
|
-
|
58
|
-
signature = OpenSSL::PKCS7.new(mail.parts[1].body.to_s)
|
40
|
+
# Check that the signature is valid.
|
41
|
+
#
|
42
|
+
# This confirms 2 things:
|
43
|
+
#
|
44
|
+
# 1. The `signature_text` is valid for `content`, ie: the `content` has
|
45
|
+
# not been altered.
|
46
|
+
# 2. The `signature_text` was generated by the party who owns `certificate`,
|
47
|
+
# ie: The same private key generated `signature_text` and `certificate`.
|
48
|
+
#
|
49
|
+
# @param [String] content
|
50
|
+
# @param [String] signature_text
|
51
|
+
# @param [OpenSSL::X509::Certificate] certificate
|
52
|
+
# @return [Hash]
|
53
|
+
# * :valid [boolean] was the verification successful or not?
|
54
|
+
# * :error [String, nil] a verification error message.
|
55
|
+
# will be empty when `valid` is true.
|
56
|
+
def self.verify(content:, signature_text:, certificate:)
|
57
|
+
begin
|
58
|
+
signature = OpenSSL::PKCS7.new(signature_text)
|
59
59
|
|
60
60
|
# using an empty CA store. see notes on NOVERIFY flag below.
|
61
61
|
store = OpenSSL::X509::Store.new
|
62
62
|
|
63
|
-
# notes on verification
|
63
|
+
# notes on verification process and flags used
|
64
64
|
#
|
65
65
|
# ## NOINTERN
|
66
66
|
#
|
67
67
|
# > If PKCS7_NOINTERN is set the certificates in the message itself are
|
68
68
|
# > not searched when locating the signer's certificate. This means that
|
69
69
|
# > all the signers certificates must be in the certs parameter.
|
70
|
-
#
|
70
|
+
# >
|
71
71
|
# > One application of PKCS7_NOINTERN is to only accept messages signed
|
72
72
|
# > by a small number of certificates. The acceptable certificates would
|
73
73
|
# > be passed in the certs parameter. In this case if the signer is not
|
@@ -89,10 +89,112 @@ module As2
|
|
89
89
|
# CA (in `store`, which is empty). alternately, we could instead remove
|
90
90
|
# this flag, and add `partner_certificate` to `store`. but what's the point?
|
91
91
|
# we'd only be verifying that `partner_certificate` is connected to `partner_certificate`.
|
92
|
-
|
92
|
+
valid = signature.verify([certificate], store, content, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN)
|
93
93
|
|
94
94
|
# when `signature.verify` fails, signature.error_string will be populated.
|
95
|
-
|
95
|
+
error = signature.error_string
|
96
|
+
rescue => e
|
97
|
+
valid = false
|
98
|
+
error = "#{e.class}: #{e.message}"
|
99
|
+
end
|
100
|
+
|
101
|
+
{
|
102
|
+
valid: valid,
|
103
|
+
error: error
|
104
|
+
}
|
105
|
+
end
|
106
|
+
|
107
|
+
def initialize(message, private_key, public_certificate, mic_algorithm: nil)
|
108
|
+
# TODO: might need to use OpenSSL::PKCS7.read_smime rather than .new sometimes
|
109
|
+
@pkcs7 = OpenSSL::PKCS7.new(message)
|
110
|
+
@private_key = private_key
|
111
|
+
@public_certificate = public_certificate
|
112
|
+
@verification_error = nil
|
113
|
+
|
114
|
+
@mic_algorithm = mic_algorithm || 'sha256'
|
115
|
+
if !As2::DigestSelector.valid?(@mic_algorithm)
|
116
|
+
raise ArgumentError, "'#{@mic_algorithm}' is not a valid MIC algorithm."
|
117
|
+
end
|
118
|
+
end
|
119
|
+
|
120
|
+
def decrypted_message
|
121
|
+
@decrypted_message ||= @pkcs7.decrypt @private_key, @public_certificate
|
122
|
+
end
|
123
|
+
|
124
|
+
def valid_signature?(partner_certificate)
|
125
|
+
content_type = mail.header_fields.find { |h| h.name == 'Content-Type' }.content_type
|
126
|
+
# TODO: substantial overlap between this code & the fallback/rescue code in
|
127
|
+
# As2::Client#verify_mdn_signature
|
128
|
+
if content_type == "multipart/signed"
|
129
|
+
# for a "multipart/signed" message, we will do 'detatched' signature
|
130
|
+
# verification, where we supply the data to be verified as the 3rd parameter
|
131
|
+
# to OpenSSL::PKCS7#verify. this is in keeping with how this content type
|
132
|
+
# is described in the S/MIME RFC.
|
133
|
+
#
|
134
|
+
# > The multipart/signed MIME type has two parts. The first part contains
|
135
|
+
# > the MIME entity that is signed; the second part contains the "detached signature"
|
136
|
+
# > CMS SignedData object in which the encapContentInfo eContent field is absent.
|
137
|
+
#
|
138
|
+
# https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
|
139
|
+
#
|
140
|
+
# see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
|
141
|
+
|
142
|
+
content = attachment.raw_source
|
143
|
+
# remove any leading \r\n characters (between headers & body i think).
|
144
|
+
content = content.gsub(/\A\s+/, '')
|
145
|
+
|
146
|
+
# TODO: why is signature.body.to_s different from signature.body.raw_source?
|
147
|
+
signature_text = signature.body.to_s
|
148
|
+
|
149
|
+
result = self.class.verify(
|
150
|
+
content: content,
|
151
|
+
signature_text: signature_text,
|
152
|
+
certificate: partner_certificate
|
153
|
+
)
|
154
|
+
|
155
|
+
output = result[:valid]
|
156
|
+
@verification_error = result[:error]
|
157
|
+
|
158
|
+
# HACK until https://github.com/mikel/mail/pull/1511 is available
|
159
|
+
#
|
160
|
+
# due to a bug in the mail gem (fixed in PR above), when using
|
161
|
+
# 'Content-Transfer-Encoding: binary', the body given by `attachment.raw_source`
|
162
|
+
# will have all "\n" replaced by "\r\n". This causes a signature verification
|
163
|
+
# failure.
|
164
|
+
#
|
165
|
+
# here, we try reversing this behavior (changing "\r\n" in the body back
|
166
|
+
# to "\n") and re-attempt verification.
|
167
|
+
#
|
168
|
+
# this entire block can should removed once the bugfix in mail gem is
|
169
|
+
# released & integrated into as2.
|
170
|
+
#
|
171
|
+
# we don't really know that verification failed due to line-ending mismatch.
|
172
|
+
# it's only a guess.
|
173
|
+
if !output && attachment.content_transfer_encoding == 'binary'
|
174
|
+
# TODO: log when this happens.
|
175
|
+
# include attachment.content_transfer_encoding, the results of the initial verification
|
176
|
+
# and the results of the re-attempted verification
|
177
|
+
|
178
|
+
body_delimiter = "\r\n\r\n"
|
179
|
+
# split on first occurrence of `body_delimiter`
|
180
|
+
# any trailing occurrences of `body_delimiter` are preserved as part of `body`
|
181
|
+
headers, _, body = content.partition(body_delimiter)
|
182
|
+
|
183
|
+
body.gsub!("\r\n", "\n") # cross fingers...
|
184
|
+
content = headers + body_delimiter + body
|
185
|
+
|
186
|
+
retry_output = self.class.verify(
|
187
|
+
content: content,
|
188
|
+
signature_text: signature_text,
|
189
|
+
certificate: partner_certificate
|
190
|
+
)
|
191
|
+
|
192
|
+
if retry_output[:valid]
|
193
|
+
@attachment = Mail::Part.new(content)
|
194
|
+
@verification_error = retry_output[:error]
|
195
|
+
output = retry_output[:valid]
|
196
|
+
end
|
197
|
+
end
|
96
198
|
|
97
199
|
output
|
98
200
|
else
|
@@ -106,14 +208,27 @@ module As2
|
|
106
208
|
end
|
107
209
|
|
108
210
|
def mic_algorithm
|
109
|
-
|
211
|
+
@mic_algorithm
|
110
212
|
end
|
111
213
|
|
112
214
|
# Return the attached file, use .filename and .body on the return value
|
215
|
+
# This is the content the sender is sending to us.
|
216
|
+
#
|
217
|
+
# @todo maybe rename this to `payload`. 'attachment' sounds very email.
|
218
|
+
# @return [Mail::Part]
|
113
219
|
def attachment
|
114
|
-
self.class.choose_attachment(parts)
|
220
|
+
@attachment ||= self.class.choose_attachment(parts)
|
221
|
+
end
|
222
|
+
|
223
|
+
# Return the digital signature which is part of the incoming message.
|
224
|
+
# Will return `nil` for unsigned messages
|
225
|
+
#
|
226
|
+
# @return [Mail::Part]
|
227
|
+
def signature
|
228
|
+
@signature ||= self.class.choose_signature(parts)
|
115
229
|
end
|
116
230
|
|
231
|
+
# TODO: deprecate this, or make it private
|
117
232
|
def parts
|
118
233
|
mail&.parts
|
119
234
|
end
|
@@ -0,0 +1,71 @@
|
|
1
|
+
module As2
|
2
|
+
module Parser
|
3
|
+
# parse an AS2 HTTP Content-Disposition-Options header
|
4
|
+
# Structure is described in https://datatracker.ietf.org/doc/html/rfc4130#section-7.3
|
5
|
+
#
|
6
|
+
# don't use this directly. use As2.choose_mic_algorithm instead.
|
7
|
+
#
|
8
|
+
# @api private
|
9
|
+
class DispositionNotificationOptions
|
10
|
+
Result = Struct.new(:value, :attributes, :raw, keyword_init: true) do
|
11
|
+
def [](key)
|
12
|
+
normalized = As2::Parser::DispositionNotificationOptions.normalize_key(key)
|
13
|
+
attributes[normalized]
|
14
|
+
end
|
15
|
+
|
16
|
+
def to_s
|
17
|
+
raw.to_s
|
18
|
+
end
|
19
|
+
end
|
20
|
+
|
21
|
+
def self.normalize_key(raw)
|
22
|
+
raw.to_s.downcase
|
23
|
+
end
|
24
|
+
|
25
|
+
# parse a single header body (without the name)
|
26
|
+
#
|
27
|
+
# @example parse('signed-receipt-protocol=required, pkcs7-signature; signed-receipt-micalg=optional, sha1')
|
28
|
+
# @return [As2::Parser::DispositionNotificationOptions::Result]
|
29
|
+
def self.parse(raw_body)
|
30
|
+
value = nil
|
31
|
+
attributes = {}
|
32
|
+
|
33
|
+
body_parts = raw_body.to_s.split(';').map(&:strip)
|
34
|
+
|
35
|
+
body_parts.each do |part|
|
36
|
+
if part.include?('=')
|
37
|
+
part_key, _, part_value = part.partition('=')
|
38
|
+
part_value = split_part(part_value)
|
39
|
+
|
40
|
+
# force lower-case to make access more reliable
|
41
|
+
part_key = normalize_key(part_key)
|
42
|
+
|
43
|
+
attributes[part_key] = part_value
|
44
|
+
else
|
45
|
+
value = split_part(part)
|
46
|
+
end
|
47
|
+
end
|
48
|
+
|
49
|
+
Result.new(raw: raw_body, value: value, attributes: attributes)
|
50
|
+
end
|
51
|
+
|
52
|
+
private
|
53
|
+
|
54
|
+
# convert CSV to array
|
55
|
+
# remove quotes
|
56
|
+
# single value returned as scalar not array
|
57
|
+
def self.split_part(part)
|
58
|
+
part_value = part.split(',').map do |value|
|
59
|
+
out = value.strip
|
60
|
+
# remove quotes
|
61
|
+
if out[0] == out[-1] && (out[0] == "'" || out[0] == '"')
|
62
|
+
out = out[1..-2]
|
63
|
+
end
|
64
|
+
out
|
65
|
+
end
|
66
|
+
|
67
|
+
part_value
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
data/lib/as2/parser.rb
ADDED
@@ -0,0 +1,11 @@
|
|
1
|
+
module As2
|
2
|
+
# namespace to hold parsers for various HTTP headers & perhaps other kinds of
|
3
|
+
# structured content.
|
4
|
+
#
|
5
|
+
# clients should not use this directly. use the public api exposed in the
|
6
|
+
# top-level `As2` module itself.
|
7
|
+
#
|
8
|
+
# @api private
|
9
|
+
module Parser
|
10
|
+
end
|
11
|
+
end
|
data/lib/as2/server.rb
CHANGED
@@ -8,6 +8,11 @@ module As2
|
|
8
8
|
class Server
|
9
9
|
attr_accessor :logger
|
10
10
|
|
11
|
+
# each of these should be understandable by send_mdn
|
12
|
+
def self.valid_mdn_formats
|
13
|
+
['v0', 'v1']
|
14
|
+
end
|
15
|
+
|
11
16
|
# @param [As2::Config::ServerInfo] server_info Config used for naming of this
|
12
17
|
# server and key/certificate selection. If omitted, the main As2::Config.server_info is used.
|
13
18
|
# @param [As2::Config::Partner] partner Which partner to receive messages from.
|
@@ -81,27 +86,61 @@ module As2
|
|
81
86
|
report = MimeGenerator::Part.new
|
82
87
|
report['Content-Type'] = 'multipart/report; report-type=disposition-notification'
|
83
88
|
|
84
|
-
|
85
|
-
|
86
|
-
|
87
|
-
|
88
|
-
report.add_part
|
89
|
+
text_part = MimeGenerator::Part.new
|
90
|
+
text_part['Content-Type'] = 'text/plain'
|
91
|
+
text_part['Content-Transfer-Encoding'] = '7bit'
|
92
|
+
text_part.body = text_body
|
93
|
+
report.add_part text_part
|
89
94
|
|
90
|
-
|
91
|
-
|
92
|
-
|
93
|
-
|
94
|
-
report.add_part
|
95
|
+
notification_part = MimeGenerator::Part.new
|
96
|
+
notification_part['Content-Type'] = 'message/disposition-notification'
|
97
|
+
notification_part['Content-Transfer-Encoding'] = '7bit'
|
98
|
+
notification_part.body = options.map{|n, v| "#{n}: #{v}"}.join("\r\n")
|
99
|
+
report.add_part notification_part
|
95
100
|
|
96
101
|
msg_out = StringIO.new
|
97
|
-
|
98
102
|
report.write msg_out
|
103
|
+
mdn_text = msg_out.string
|
104
|
+
|
105
|
+
mdn_format = @partner&.mdn_format || 'v0'
|
106
|
+
if mdn_format == 'v1'
|
107
|
+
format_method = :format_mdn_v1
|
108
|
+
else
|
109
|
+
format_method = :format_mdn_v0
|
110
|
+
end
|
99
111
|
|
100
|
-
|
112
|
+
headers, body = send(
|
113
|
+
format_method,
|
114
|
+
mdn_text,
|
115
|
+
as2_to: env['HTTP_AS2_FROM']
|
116
|
+
)
|
117
|
+
|
118
|
+
[200, headers, ["\r\n" + body]]
|
119
|
+
end
|
120
|
+
|
121
|
+
# 'original' MDN formatting
|
122
|
+
#
|
123
|
+
# 1. uses OpenSSL::PKCS7.write_smime to build MIME body
|
124
|
+
# * includes MIME headers in HTTP body
|
125
|
+
# * includes plain-text "this is an S/MIME message" note prior to initial
|
126
|
+
# MIME boundary
|
127
|
+
# 2. uses non-standard application/x-pkcs7-* content types
|
128
|
+
# 3. MIME boundaries and signature have \n line endings
|
129
|
+
#
|
130
|
+
# this format is understood by Mendelson, OpenAS2, and several commercial
|
131
|
+
# products (GoAnywhere MFT). it is not understood by IBM Sterling B2B Integrator.
|
132
|
+
#
|
133
|
+
# @param [String] mdn_text MIME multipart/report body containing text/plain
|
134
|
+
# and message/disposition-notification parts
|
135
|
+
# @param [String] mic_algorithm
|
136
|
+
# @param [String] as2_to
|
137
|
+
def format_mdn_v0(mdn_text, as2_to:)
|
138
|
+
pkcs7 = OpenSSL::PKCS7.sign @server_info.certificate, @server_info.pkey, mdn_text
|
101
139
|
pkcs7.detached = true
|
102
|
-
smime_signed = OpenSSL::PKCS7.write_smime pkcs7, msg_out.string
|
103
140
|
|
104
|
-
|
141
|
+
body = OpenSSL::PKCS7.write_smime pkcs7, mdn_text
|
142
|
+
|
143
|
+
content_type = body[/^Content-Type: (.+?)$/m, 1]
|
105
144
|
# smime_signed.sub!(/\A.+?^(?=---)/m, '')
|
106
145
|
|
107
146
|
headers = {}
|
@@ -110,11 +149,67 @@ module As2
|
|
110
149
|
headers['MIME-Version'] = '1.0'
|
111
150
|
headers['Message-ID'] = As2.generate_message_id(@server_info)
|
112
151
|
headers['AS2-From'] = @server_info.name
|
113
|
-
headers['AS2-To'] =
|
152
|
+
headers['AS2-To'] = as2_to
|
153
|
+
headers['AS2-Version'] = '1.0'
|
154
|
+
headers['Connection'] = 'close'
|
155
|
+
|
156
|
+
[headers, body]
|
157
|
+
end
|
158
|
+
|
159
|
+
def format_mdn_v1(mdn_text, as2_to:)
|
160
|
+
pkcs7 = OpenSSL::PKCS7.sign(@server_info.certificate, @server_info.pkey, mdn_text)
|
161
|
+
pkcs7.detached = true
|
162
|
+
|
163
|
+
# PEM (base64-encoded) signature
|
164
|
+
bare_pem_signature = pkcs7.to_pem
|
165
|
+
# strip off the '-----BEGIN PKCS7-----' / '-----END PKCS7-----' delimiters
|
166
|
+
bare_pem_signature.gsub!(/^-----[^\n]+\n/, '')
|
167
|
+
# and update to canonical \r\n line endings
|
168
|
+
bare_pem_signature.gsub!(/(?<!\r)\n/, "\r\n")
|
169
|
+
|
170
|
+
# this is a hack until i can determine a better way to get the micalg parameter
|
171
|
+
# from the pkcs7 signature generated above...
|
172
|
+
# https://stackoverflow.com/questions/75934159/how-does-openssl-smime-determine-micalg-parameter
|
173
|
+
#
|
174
|
+
# also tried approach outlined in https://stackoverflow.com/questions/53044007/how-to-use-sha1-digest-during-signing-with-opensslpkcs7-sign-when-creating-smi
|
175
|
+
# but the signature generated by that method lacks some essential data. verifying those
|
176
|
+
# signatures results in an openssl error "unable to find message digest"
|
177
|
+
smime_body = OpenSSL::PKCS7.write_smime(pkcs7, mdn_text)
|
178
|
+
micalg = smime_body[/^Content-Type: multipart\/signed.*micalg=\"([^"]+)/m, 1]
|
179
|
+
|
180
|
+
# generate a MIME part boundary
|
181
|
+
#
|
182
|
+
# > A good strategy is to choose a boundary that includes
|
183
|
+
# > a character sequence such as "=_" which can never appear in a
|
184
|
+
# > quoted-printable body.
|
185
|
+
#
|
186
|
+
# https://www.rfc-editor.org/rfc/rfc2045#page-21
|
187
|
+
boundary = "----=_#{SecureRandom.hex(16).upcase}"
|
188
|
+
body_boundary = "--#{boundary}"
|
189
|
+
|
190
|
+
headers = {}
|
191
|
+
headers['Content-Type'] = "multipart/signed; protocol=\"application/pkcs7-signature\"; micalg=\"#{micalg}\"; boundary=\"#{boundary}\""
|
192
|
+
headers['MIME-Version'] = '1.0'
|
193
|
+
headers['Message-ID'] = As2.generate_message_id(@server_info)
|
194
|
+
headers['AS2-From'] = @server_info.name
|
195
|
+
headers['AS2-To'] = as2_to
|
114
196
|
headers['AS2-Version'] = '1.0'
|
115
197
|
headers['Connection'] = 'close'
|
116
198
|
|
117
|
-
|
199
|
+
# this is the MDN report, with text/plain and message/disposition-notification parts
|
200
|
+
body = body_boundary + "\r\n"
|
201
|
+
body += mdn_text + "\r\n"
|
202
|
+
|
203
|
+
# this is the signature generated over that report
|
204
|
+
body += body_boundary + "\r\n"
|
205
|
+
body += "Content-Type: application/pkcs7-signature; name=\"smime.p7s\"\r\n"
|
206
|
+
body += "Content-Transfer-Encoding: base64\r\n"
|
207
|
+
body += "Content-Disposition: attachment; filename=\"smime.p7s\"\r\n"
|
208
|
+
body += "\r\n"
|
209
|
+
body += bare_pem_signature
|
210
|
+
body += body_boundary + "--\r\n"
|
211
|
+
|
212
|
+
[headers, body]
|
118
213
|
end
|
119
214
|
|
120
215
|
private
|
data/lib/as2/version.rb
CHANGED
data/lib/as2.rb
CHANGED
@@ -1,12 +1,14 @@
|
|
1
|
-
require 'openssl'
|
2
1
|
require 'mail'
|
2
|
+
require 'openssl'
|
3
3
|
require 'securerandom'
|
4
|
-
|
5
|
-
require 'as2/server'
|
4
|
+
|
6
5
|
require 'as2/client'
|
7
6
|
require 'as2/client/result'
|
7
|
+
require 'as2/config'
|
8
8
|
require 'as2/digest_selector'
|
9
|
-
require
|
9
|
+
require 'as2/parser/disposition_notification_options'
|
10
|
+
require 'as2/server'
|
11
|
+
require 'as2/version'
|
10
12
|
|
11
13
|
module As2
|
12
14
|
def self.configure(&block)
|
@@ -20,4 +22,17 @@ module As2
|
|
20
22
|
def self.generate_message_id(server_info)
|
21
23
|
"<#{server_info.name}-#{Time.now.strftime('%Y%m%d-%H%M%S')}-#{SecureRandom.uuid}@#{server_info.domain}>"
|
22
24
|
end
|
25
|
+
|
26
|
+
# Select which algorithm to use for calculating a MIC, based on preferences
|
27
|
+
# stated by sender & our list of available algorithms.
|
28
|
+
#
|
29
|
+
# @see https://datatracker.ietf.org/doc/html/rfc4130#section-7.3
|
30
|
+
#
|
31
|
+
# @param [String] disposition_notification_options The content of an HTTP
|
32
|
+
# Disposition-Notification-Options header
|
33
|
+
# @return [String, nil] either an algorithm name, or nil if none is found in given header
|
34
|
+
def self.choose_mic_algorithm(disposition_notification_options)
|
35
|
+
parsed = As2::Parser::DispositionNotificationOptions.parse(disposition_notification_options)
|
36
|
+
Array(parsed['signed-receipt-micalg']).find { |m| As2::DigestSelector.valid?(m) }
|
37
|
+
end
|
23
38
|
end
|
data/tmp/inbox/.keep
ADDED
File without changes
|
metadata
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: as2
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.7.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- OfficeLuv
|
@@ -9,7 +9,7 @@ authors:
|
|
9
9
|
autorequire:
|
10
10
|
bindir: exe
|
11
11
|
cert_chain: []
|
12
|
-
date:
|
12
|
+
date: 2023-08-25 00:00:00.000000000 Z
|
13
13
|
dependencies:
|
14
14
|
- !ruby/object:Gem::Dependency
|
15
15
|
name: mail
|
@@ -68,7 +68,21 @@ dependencies:
|
|
68
68
|
- !ruby/object:Gem::Version
|
69
69
|
version: '10.0'
|
70
70
|
- !ruby/object:Gem::Dependency
|
71
|
-
name:
|
71
|
+
name: rackup
|
72
|
+
requirement: !ruby/object:Gem::Requirement
|
73
|
+
requirements:
|
74
|
+
- - ">="
|
75
|
+
- !ruby/object:Gem::Version
|
76
|
+
version: '0'
|
77
|
+
type: :development
|
78
|
+
prerelease: false
|
79
|
+
version_requirements: !ruby/object:Gem::Requirement
|
80
|
+
requirements:
|
81
|
+
- - ">="
|
82
|
+
- !ruby/object:Gem::Version
|
83
|
+
version: '0'
|
84
|
+
- !ruby/object:Gem::Dependency
|
85
|
+
name: puma
|
72
86
|
requirement: !ruby/object:Gem::Requirement
|
73
87
|
requirements:
|
74
88
|
- - ">="
|
@@ -166,8 +180,11 @@ files:
|
|
166
180
|
- lib/as2/digest_selector.rb
|
167
181
|
- lib/as2/message.rb
|
168
182
|
- lib/as2/mime_generator.rb
|
183
|
+
- lib/as2/parser.rb
|
184
|
+
- lib/as2/parser/disposition_notification_options.rb
|
169
185
|
- lib/as2/server.rb
|
170
186
|
- lib/as2/version.rb
|
187
|
+
- tmp/inbox/.keep
|
171
188
|
homepage: https://github.com/alexdean/as2
|
172
189
|
licenses:
|
173
190
|
- MIT
|