as2 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +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
|
-
[](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
|