as2 0.5.2 → 0.6.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 +7 -0
- data/README.md +4 -6
- data/as2.gemspec +2 -1
- data/examples/server.rb +84 -12
- data/lib/as2/client.rb +108 -15
- 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/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: 44bae86d51af027c42242dcc2b746014fc1e5c408bdba4d8fda43373a3b61470
|
4
|
+
data.tar.gz: e7043ac582ceb07fa9527f029ea128015e2f96331c3217758b022e5fe2b64e3a
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8f6beade2e29c96b6e0d65297296f26642a4882b3628b7d43aa2169b7b6b3ddea7abb5e791490b20a1ffb260cfbd214db5c9026a9cb61e34b4a9af05fd6f2d49
|
7
|
+
data.tar.gz: 8bac3d37290848e67e01b689872fb6504c3407c23501040f1647f17bc42ae2101669ceb6b331908f170f2242a6acf3d4cbeeafe4ad3cf2c4f24995422e358e13
|
data/.gitignore
CHANGED
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,10 @@
|
|
1
|
+
## Unreleased (future 0.6.0)
|
2
|
+
|
3
|
+
* allow verification of signed MDNs which use `Content-Transfer-Encoding: binary`. [#22](https://github.com/alexdean/as2/pull/22)
|
4
|
+
* Improve example server to make it more useful for local testing & development. [#17](https://github.com/alexdean/as2/pull/17)
|
5
|
+
* Support `Content-Tranfer-Encoding: binary`. [#11](https://github.com/alexdean/as2/pull/11)
|
6
|
+
* Server can choose MIC algorithm based on HTTP `Disposition-Notification-Options` header. [#20](https://github.com/alexdean/as2/pull/20)
|
7
|
+
|
1
8
|
## 0.5.1, August 10, 2022
|
2
9
|
|
3
10
|
* 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
|
|
@@ -30,10 +31,7 @@ along.
|
|
30
31
|
will see a MIC verification failure. AS2 RFC specifically prefers sha1 and
|
31
32
|
mentions md5. Mendelson AS2 server supports a number of other algorithms.
|
32
33
|
(sha256, sha512, etc)
|
33
|
-
2.
|
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.
|
34
|
+
2. AS2 partners may agree to use separate certificates for data encryption and data signing.
|
37
35
|
We do not support separate certificates for these purposes.
|
38
36
|
|
39
37
|
## Installation
|
@@ -78,7 +76,7 @@ You can run a local server with `bundle exec ruby examples/server.rb` and send i
|
|
78
76
|
|
79
77
|
## Contributing
|
80
78
|
|
81
|
-
Bug reports and pull requests are welcome on GitHub at https://github.com/
|
79
|
+
Bug reports and pull requests are welcome on GitHub at https://github.com/alexdean/as2.
|
82
80
|
|
83
81
|
|
84
82
|
## 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
@@ -9,7 +9,11 @@ module As2
|
|
9
9
|
# via a call to #add_partner.
|
10
10
|
# @param [As2::Config::ServerInfo,nil] server_info The server info used to identify
|
11
11
|
# this client to the partner. If omitted, the main As2::Config.server_info will be used.
|
12
|
-
|
12
|
+
# @param [Logger, nil] logger If supplied, some additional information about how
|
13
|
+
# messages are processed will be written here.
|
14
|
+
def initialize(partner, server_info: nil, logger: nil)
|
15
|
+
@logger = logger || Logger.new('/dev/null')
|
16
|
+
|
13
17
|
if partner.is_a?(As2::Config::Partner)
|
14
18
|
@partner = partner
|
15
19
|
else
|
@@ -132,22 +136,18 @@ module As2
|
|
132
136
|
response_content = "Content-Type: #{mdn_content_type.to_s.strip}\r\n\r\n#{mdn_body}"
|
133
137
|
|
134
138
|
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
|
139
|
+
result = parse_signed_mdn(
|
140
|
+
multipart_signed_message: response_content,
|
141
|
+
certificate: @partner.certificate
|
142
|
+
)
|
143
|
+
mdn_report = result[:mdn_report]
|
144
|
+
report[:signature_verification_error] = result[:signature_verification_error]
|
145
145
|
else
|
146
146
|
# MDN may be unsigned if an error occurred, like if we sent an unrecognized As2-From header.
|
147
|
-
|
147
|
+
mdn_report = Mail.new(response_content)
|
148
148
|
end
|
149
149
|
|
150
|
-
|
150
|
+
mdn_report.parts.each do |part|
|
151
151
|
if part.content_type.start_with?('text/plain')
|
152
152
|
report[:plain_text_body] = part.body.to_s.strip
|
153
153
|
elsif part.content_type.start_with?('message/disposition-notification')
|
@@ -163,8 +163,8 @@ module As2
|
|
163
163
|
end
|
164
164
|
end
|
165
165
|
|
166
|
-
report[:disposition] = options['disposition']
|
167
|
-
report[:mid_matched] = original_message_id == options['original-message-id']
|
166
|
+
report[:disposition] = options['disposition'].strip
|
167
|
+
report[:mid_matched] = original_message_id == options['original-message-id'].strip
|
168
168
|
|
169
169
|
if options['received-content-mic']
|
170
170
|
# do mic calc using the algorithm specified by server.
|
@@ -182,5 +182,98 @@ module As2
|
|
182
182
|
end
|
183
183
|
report
|
184
184
|
end
|
185
|
+
|
186
|
+
private
|
187
|
+
|
188
|
+
# extract the MDN body from a multipart/signed wrapper & attempt to verify
|
189
|
+
# the signature
|
190
|
+
#
|
191
|
+
# @param [String] multipart_signed_message The 'outer' MDN body, containing MIME header,
|
192
|
+
# MDN body (which itself is likely a multi-part object) and a signature.
|
193
|
+
# @param [OpenSSL::X509::Certificate] verify that the MDN body was signed using this certificate
|
194
|
+
# @return [Hash] results of the check
|
195
|
+
# * :mdn_mime_body [Mail::Message] The 'inner' MDN body, with signature removed
|
196
|
+
# * :signature_verification_error [String] Any error which resulted when checking the
|
197
|
+
# signature. If this is empty it means the signature was valid.
|
198
|
+
def parse_signed_mdn(multipart_signed_message:, certificate:)
|
199
|
+
smime = nil
|
200
|
+
|
201
|
+
begin
|
202
|
+
# This will fail if the signature is binary-encoded. In that case
|
203
|
+
# we rescue so we can continue to extract other data from the MDN.
|
204
|
+
# User can decide how to proceed after the signature verification failure.
|
205
|
+
#
|
206
|
+
# > The parser assumes that the PKCS7 structure is always base64 encoded
|
207
|
+
# > and will not handle the case where it is in binary format or uses quoted
|
208
|
+
# > printable format.
|
209
|
+
#
|
210
|
+
# https://www.openssl.org/docs/man3.1/man3/SMIME_read_PKCS7.html
|
211
|
+
#
|
212
|
+
# Likely we can resolve this by building a PKCS7 manually from the MDN
|
213
|
+
# payload, rather than using `read_smime`.
|
214
|
+
#
|
215
|
+
# An aside: manually base64-encoding the binary signature allows the MDN
|
216
|
+
# to be parsed & verified via `read_smime`, so that could also be an option.
|
217
|
+
smime = OpenSSL::PKCS7.read_smime(multipart_signed_message)
|
218
|
+
rescue => e
|
219
|
+
@logger.warn "error checking signature using read_smime. #{e.message}"
|
220
|
+
signature_verification_error = e.message
|
221
|
+
end
|
222
|
+
|
223
|
+
if smime
|
224
|
+
# create mail instance before #verify call.
|
225
|
+
# `smime.data` is emptied if verification fails, which means we wouldn't know disposition & other details.
|
226
|
+
mdn_report = Mail.new(smime.data)
|
227
|
+
|
228
|
+
# based on As2::Message version
|
229
|
+
# TODO: test cases based on valid/invalid responses. (response signed with wrong certificate, etc.)
|
230
|
+
# See notes in As2::Message.verify for reasoning on flag usage
|
231
|
+
smime.verify [certificate], OpenSSL::X509::Store.new, nil, OpenSSL::PKCS7::NOVERIFY | OpenSSL::PKCS7::NOINTERN
|
232
|
+
|
233
|
+
signature_verification_error = smime.error_string
|
234
|
+
else
|
235
|
+
@logger.info "trying fallback sigature verification."
|
236
|
+
# read_smime will fail on binary-encoded MDNs. in this case, we can attempt
|
237
|
+
# to parse the structure using Mail and do signature verification
|
238
|
+
# slightly differently.
|
239
|
+
#
|
240
|
+
# what follows is the same process applied in As2::Message#valid_signature?.
|
241
|
+
# see notes there for more info on "multipart/signed" MIME messages.
|
242
|
+
#
|
243
|
+
# 1. maybe unify these at some point?
|
244
|
+
# 2. maybe always use this process, and drop initial attempt at
|
245
|
+
# `OpenSSL::PKCS7.read_smime` above.
|
246
|
+
#
|
247
|
+
# refactoring to allow using As2::Message#valid_signature? here
|
248
|
+
# would also allow us to utilize the line-ending fixup code there
|
249
|
+
|
250
|
+
# this should have 2 parts. the MDN report (parts[0]) and the signature (parts[1])
|
251
|
+
#
|
252
|
+
# * https://datatracker.ietf.org/doc/html/rfc3851#section-3.4.3
|
253
|
+
# * see also https://datatracker.ietf.org/doc/html/rfc1847#section-2.1
|
254
|
+
outer_mail = Mail.new(multipart_signed_message)
|
255
|
+
|
256
|
+
mdn_report = outer_mail.parts[0]
|
257
|
+
|
258
|
+
content = mdn_report.raw_source
|
259
|
+
content = content.gsub(/\A\s+/, '')
|
260
|
+
|
261
|
+
signature = outer_mail.parts[1]
|
262
|
+
signature_text = signature.body.to_s
|
263
|
+
|
264
|
+
result = As2::Message.verify(
|
265
|
+
content: content,
|
266
|
+
signature_text: signature_text,
|
267
|
+
certificate: @partner.certificate
|
268
|
+
)
|
269
|
+
|
270
|
+
signature_verification_error = result[:error]
|
271
|
+
end
|
272
|
+
|
273
|
+
{
|
274
|
+
mdn_report: mdn_report,
|
275
|
+
signature_verification_error: signature_verification_error
|
276
|
+
}
|
277
|
+
end
|
185
278
|
end
|
186
279
|
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/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.6.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-04-04 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
|