Skip to content

Commit

Permalink
Added a class that verifies sns messages.
Browse files Browse the repository at this point in the history
See #709
  • Loading branch information
trevorrowe committed Mar 17, 2015
1 parent 7bc5dfb commit 3153e4f
Show file tree
Hide file tree
Showing 5 changed files with 333 additions and 7 deletions.
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Unreleased Changes
------------------

* Feature - Aws::SNS - Added a utility class that can verify the authenticity
of an SNS message.

```ruby
verifier = Aws::SNS::MessageVerifier.new

verifier.authentic?(message_body)
#=> returns true/false

verifier.authenticate!(message_body)
#=> raises an error on bad message signature
```

See [related GitHub issue #709](https://github.com/aws/aws-sdk-ruby/issues/709).

* Issue - Query Protocol - No longer returning nil for empty maps in
query responses. `Aws::SQS::Client#get_queue_attributes` will always
have a hash a `resp.attributes` instead of a possible `nil` value.
Expand Down
8 changes: 1 addition & 7 deletions FEATURE_REQUESTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,12 +34,6 @@ Adding a retry limit for instance profile credentials might be a possible soluti

See [related GitHub issue #717](https://github.com/aws/aws-sdk-ruby/issues/717).

### Verify SNS Message Authenticity

Version 1 of the AWS SDK for Ruby has an method that can verify the authenticity of an SNS message. No such functionality exists in the v2 SDK. Ideally, this could be ported as a standalone class, such as `Aws::SNS::MessageVerifier`. This could then be exposed from the `Aws::SNS::Message` as a `#verify` method for resource users or called directly for client users.

See [related GitHub issue #709](https://github.com/aws/aws-sdk-ruby/issues/709).

### Signed CloudFront URLs

Amazon CloudFront supports pre-signed URLs, similar to those used by Amazon S3. It would be helpful to have a pre-signed url builder for SDK users.
Expand Down Expand Up @@ -80,6 +74,6 @@ See [related GitHub issue aws/aws-sdk-core-ruby#216](https://github.com/aws/aws-

### Progress callbacks for Amazon S3 Object uploads

To enable users to track file upload process, it would be helpful to support a progress callback for `Aws::S3::Object#upload_file`.
To enable users to track file upload process, it would be helpful to support a progress callback for `Aws::S3::Object#upload_file`.

See [related Github issue #648](https://github.com/aws/aws-sdk-ruby/issues/648#issuecomment-78246370).
7 changes: 7 additions & 0 deletions aws-sdk-resources/lib/aws-sdk-resources/services/sns.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module Aws
module SNS

autoload :MessageVerifier, 'aws-sdk-resources/services/sns/message_verifier'

end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
require 'base64'
require 'multi_json'

module Aws
module SNS

# A utility class that can be used to verify the authenticity of messages
# sent by Amazon SNS.
#
# verifier = Aws::SNS::MessageVerifier.new
#
# # returns true/false
# verifier.authentic?(message_body)
#
# # raises a Aws::SNS::MessageVerifier::VerificationError on failure
# verifier.authenticate!(message_body)
#
# You can re-use a single {MessageVerifier} instance to authenticate
# multiple SNS messages.
class MessageVerifier

class VerificationError < StandardError; end

# @api private
SIGNABLE_KEYS = [
'Message',
'MessageId',
'Subject',
'SubscribeURL',
'Timestamp',
'Token',
'TopicArn',
'Type',
].freeze

# @api private
AWS_HOSTNAMES = [
/^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/
]

def initialize
@cached_pems = {}
end

# @param [String<JSON>] message_body
# @return [Boolean] Returns `true` if the given message has been
# successfully verified. Returns `false` otherwise.
def authentic?(message_body)
authenticate!(message_body)
rescue VerificationError
false
end

# @param [String<JSON>] message_body
# @return [Boolean] Returns `true` when the given message has been
# successfully verified.
# @raise [VerificationError] Raised when the given message has failed
# verification.
def authenticate!(message_body)
msg = MultiJson.load(message_body)
if public_key(msg).verify(sha1, signature(msg), canonical_string(msg))
true
else
msg = 'the authenticity of the message cannot be verified'
raise VerificationError, msg
end
end

private

def sha1
OpenSSL::Digest::SHA1.new
end

def signature(message)
Base64.decode64(message['Signature'])
end

def canonical_string(message)
parts = []
SIGNABLE_KEYS.each do |key|
value = message[key]
unless value.nil? or value.empty?
parts << "#{key}\n#{value}\n"
end
end
parts.join
end

def public_key(message)
x509_url = URI.parse(message['SigningCertURL'])
x509 = OpenSSL::X509::Certificate.new(pem(x509_url))
OpenSSL::PKey::RSA.new(x509.public_key)
end

def pem(uri)
if @cached_pems[uri.to_s]
@cached_pems[uri.to_s]
else
@cached_pems[uri.to_s] = download_pem(uri)
end
end

def download_pem(uri)
verify_uri!(uri)
https_get(uri)
end

def verify_uri!(uri)
verify_https!(uri)
verify_hosted_by_aws!(uri)
verify_pem!(uri)
end

def verify_https!(uri)
unless uri.scheme == 'https'
msg = "the SigningCertURL must be https, got: #{uri}"
raise VerificationError, msg
end
end

def verify_hosted_by_aws!(uri)
unless AWS_HOSTNAMES.any? { |pattern| pattern.match(uri.host) }
msg = "signing cert is not hosted by AWS: #{uri}"
raise VerificationError, msg
end
end

def verify_pem!(uri)
unless File.extname(uri.path) == '.pem'
msg = "the SigningCertURL must link to a .pem file"
raise VerificationError, msg
end
end

def https_get(uri, failed_attempts = 0)
http = Net::HTTP.new(uri.host, uri.port)
http.use_ssl = true
http.verify_mode = OpenSSL::SSL::VERIFY_PEER
http.start
resp = http.request(Net::HTTP::Get.new(uri.request_uri))
http.finish
if resp.code == '200'
resp.body
else
raise VerificationError, resp.body
end
rescue => error
failed_attempts += 1
retry if failed_attempts < 3
raise VerificationError, error.message
end

end
end
end
154 changes: 154 additions & 0 deletions aws-sdk-resources/spec/services/sns/message_verifier_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
require 'spec_helper'
require 'multi_json'

module Aws
module SNS
describe MessageVerifier do

let(:signing_cert_url) {
"https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem"
}

let(:message) { <<-JSON.strip }
{
"Type" : "Notification",
"MessageId" : "5b324425-3d5e-4fdf-a3f6-f46b8f93df79",
"TopicArn" : "arn:aws:sns:eu-west-1:382739154790:for_justeat_aws_specs",
"Subject" : "sdfghdsfg",
"Message" : "dfgdsfg",
"Timestamp" : "2012-04-30T11:07:54.008Z",
"SignatureVersion" : "1",
"Signature" : "CTbst0fA37gbKnC0fiWK6HB0nQOr767MSLCJaWb0GyXc7283m1gozU3lRvOBaKP5Cwcj+clhR+rAN1m0Cp6W63oxBEu9n1Z50oyWx/tWtQd2j+MPaes+tNJSGohjHSe5qAqMwvYFYTZkbgFDFoWuVQLQuRj9I53hR1Eo3waHkJQ=",
"SigningCertURL" : #{signing_cert_url.inspect},
"UnsubscribeURL" : "https://sns.eu-west-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:eu-west-1:382739154790:for_justeat_aws_specs:674f4ab3-2d1d-4df9-b411-b8a336f0ef7d"
}
JSON

let(:cert) { <<-CERT.strip }
-----BEGIN CERTIFICATE-----
MIIE+TCCA+GgAwIBAgIQax6zU8p9DAWTsa4uy9uF1jANBgkqhkiG9w0BAQUFADCB
tTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL
ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug
YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykwOTEvMC0GA1UEAxMm
VmVyaVNpZ24gQ2xhc3MgMyBTZWN1cmUgU2VydmVyIENBIC0gRzIwHhcNMTAxMDA4
MDAwMDAwWhcNMTMxMDA3MjM1OTU5WjBqMQswCQYDVQQGEwJVUzETMBEGA1UECBMK
V2FzaGluZ3RvbjEQMA4GA1UEBxQHU2VhdHRsZTEYMBYGA1UEChQPQW1hem9uLmNv
bSBJbmMuMRowGAYDVQQDFBFzbnMuYW1hem9uYXdzLmNvbTCBnzANBgkqhkiG9w0B
AQEFAAOBjQAwgYkCgYEAv8OHcwOX+SpVUpdS6OtB0FbmX6w7FQIXLJyChbcYQ3Ck
gJnrVJ5OFIMYAc+YMbkikXnvu9+MvZx38ZV8hIYBK4y4YSR/fLMzTIqsQXKW7myq
mIeEGGqGrCVVhs0xusCgfNBi64/zczJ3z/KLLzSXZ2Ln18MCCjQ3A8EcuwFeMTsC
AwEAAaOCAdEwggHNMAkGA1UdEwQCMAAwCwYDVR0PBAQDAgWgMEUGA1UdHwQ+MDww
OqA4oDaGNGh0dHA6Ly9TVlJTZWN1cmUtRzItY3JsLnZlcmlzaWduLmNvbS9TVlJT
ZWN1cmVHMi5jcmwwRAYDVR0gBD0wOzA5BgtghkgBhvhFAQcXAzAqMCgGCCsGAQUF
BwIBFhxodHRwczovL3d3dy52ZXJpc2lnbi5jb20vcnBhMB0GA1UdJQQWMBQGCCsG
AQUFBwMBBggrBgEFBQcDAjAfBgNVHSMEGDAWgBSl7wsRzsBBA6NKZZBIshzgVy19
RzB2BggrBgEFBQcBAQRqMGgwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLnZlcmlz
aWduLmNvbTBABggrBgEFBQcwAoY0aHR0cDovL1NWUlNlY3VyZS1HMi1haWEudmVy
aXNpZ24uY29tL1NWUlNlY3VyZUcyLmNlcjBuBggrBgEFBQcBDARiMGChXqBcMFow
WDBWFglpbWFnZS9naWYwITAfMAcGBSsOAwIaBBRLa7kolgYMu9BSOJsprEsHiyEF
GDAmFiRodHRwOi8vbG9nby52ZXJpc2lnbi5jb20vdnNsb2dvMS5naWYwDQYJKoZI
hvcNAQEFBQADggEBAKcmdO9iRCChdO21L0NaB24f2BFuUZO/y9tsTgC6NJ8p0sJU
+/dKc4p33pnmDE8EGDbImMd/HdVnqQ4nngurjzu7z/mv7247FGaUL/BnqLgOQJiM
YBJtskNd2vKN4kk4I6Z7e2mp2+4tzBL9Sk/x3b297oy4ZXILrBKxr9s9MhyPO1rQ
Mda9v2L3qcjPj38zbNoohEIpu/ilArbbFOUMOqdh7jomDoE3cyBDWMOOBh+t6QQD
kMFvPxlw0XwWsvjTGPFCBIR7NZXnwQfVYbdFu88TjT10wTCZ/E3yCp77aDWD1JLV
2V2EF3v1wPCPCbvEKZKVR5rLVYl2djU9j9d+H30=
-----END CERTIFICATE-----
CERT

let(:verifier) { MessageVerifier.new }

before(:each) do
stub_request(:get, signing_cert_url).to_return(status:200, body:cert)
end

describe '#authenticate!' do

it 'returns true for a valid message' do
expect(verifier.authenticate!(message)).to be(true)
end

it 'raises when the SigningCertURL is not https' do
msg = MultiJson.load(message)
msg['SigningCertURL'] = msg['SigningCertURL'].sub(/https/, 'http')
msg = MultiJson.dump(msg)
expect {
verifier.authenticate!(msg)
}.to raise_error(MessageVerifier::VerificationError, /must be https/)
end

it 'raises when the SigningCertURL is not AWS hosted' do
msg = MultiJson.load(message)
msg['SigningCertURL'] = 'https://internetbadguys.com/cert.pem'
msg = MultiJson.dump(msg)
expect {
verifier.authenticate!(msg)
}.to raise_error(MessageVerifier::VerificationError, /hosted by AWS/)
end

it 'raises when the SigningCertURL is not a pem file' do
msg = MultiJson.load(message)
msg['SigningCertURL'] = msg['SigningCertURL'].sub(/pem$/, 'key')
msg = MultiJson.dump(msg)
expect {
verifier.authenticate!(msg)
}.to raise_error(MessageVerifier::VerificationError, /a \.pem file/)
end

it 'raises when the message signature fails validation' do
msg = MultiJson.load(message)
msg['Signature'] = 'bad'
msg = MultiJson.dump(msg)
expect {
verifier.authenticate!(msg)
}.to raise_error(MessageVerifier::VerificationError, /cannot be verified/)
end

it 'caches the pem file' do
verifier.authenticate!(message)
verifier.authenticate!(message)
expect(a_request(:get, signing_cert_url)).to have_been_made.once
end

it 'attempts to download the cert 3 times' do
stub_request(:get, signing_cert_url).
to_return(status: 500, body: '').
to_return(status: 500, body: '').
to_return(status: 200, body: cert)
verifier.authenticate!(message)
expect(a_request(:get, signing_cert_url)).to have_been_made.times(3)
end

it 'raises when the signing cert can not be downloaded due to networking erros' do
stub_request(:get, signing_cert_url).to_raise(StandardError, 'oops')
expect {
verifier.authenticate!(message)
}.to raise_error(MessageVerifier::VerificationError, 'oops')
end

it 'raises when the signing cert can not be downloaded' do
stub_request(:get, signing_cert_url).to_return(status:500, body:'bad')
expect {
verifier.authenticate!(message)
}.to raise_error(MessageVerifier::VerificationError, 'bad')
end

end

describe '#authentic?' do

it 'returns true if the message can be authenticated' do
expect(verifier.authentic?(message)).to be(true)
end

it 'returns false if the message can not be authenticated' do
msg = MultiJson.load(message)
msg['Signature'] = 'bad'
msg = MultiJson.dump(msg)
expect(verifier.authentic?(msg)).to be(false)
end

end
end
end
end

0 comments on commit 3153e4f

Please sign in to comment.