├── .rspec ├── .rubocop.yml ├── .gitignore ├── lib ├── cloudfront-signer │ └── version.rb ├── generators │ └── cloudfront │ │ └── install │ │ ├── templates │ │ └── cloudfront_signer.rb │ │ └── install_generator.rb └── cloudfront-signer.rb ├── Gemfile ├── .travis.yml ├── spec ├── files │ ├── rsa-APKAIKUROOUNR2BAFUUU.pem │ ├── custom_policy.json │ ├── private_key.pem │ └── pk-APKAIKUROOUNR2BAFUUU.pem ├── spec_helper.rb └── signer_spec.rb ├── .codeclimate.yml ├── Rakefile ├── LICENSE ├── CHANGELOG.md ├── cloudfront-signer.gemspec └── README.md /.rspec: -------------------------------------------------------------------------------- 1 | --colour 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | TargetRubyVersion: 2.2 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | .bundle 3 | Gemfile.lock 4 | pkg/* 5 | 6 | coverage 7 | doc 8 | -------------------------------------------------------------------------------- /lib/cloudfront-signer/version.rb: -------------------------------------------------------------------------------- 1 | module Aws 2 | module CF 3 | VERSION = '3.0.2'.freeze 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in cloudfront-signer.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | cache: bundler 3 | rvm: 4 | - 2.2.2 5 | script: 6 | - bundle exec rspec 7 | - bundle exec codeclimate-test-reporter 8 | addons: 9 | code_climate: 10 | repo_token: ef3c90e7b5eb13c9242a30d783ed701afa9a8514b6544d5972ca590505d2a12e 11 | -------------------------------------------------------------------------------- /lib/generators/cloudfront/install/templates/cloudfront_signer.rb: -------------------------------------------------------------------------------- 1 | Aws::CF::Signer.configure do |config| 2 | config.key_path = '/path/to/keyfile.pem' 3 | # or config.key = ENV.fetch('PRIVATE_KEY') 4 | config.key_pair_id = 'XXYYZZ' 5 | config.default_expires = 3600 6 | end 7 | -------------------------------------------------------------------------------- /spec/files/rsa-APKAIKUROOUNR2BAFUUU.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PUBLIC KEY----- 2 | MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCp280I7v8JBVJBN7Kdfl4eD+no 3 | yqzbLAsz9mIr07hZQ3PjVa5g3j5Q8oXioU2ycxzXephfPr83l/FTAtPSZQ94Jh6u 4 | /CdoEYXfEtFbJYQ2lHXrra36yVcyyxQ6tAKgUHdWnZ/vbItUhLnhCSqwelTNpgRz 5 | f6AKdVOtQPaZ+bnkQQIDAQAB 6 | -----END PUBLIC KEY----- 7 | -------------------------------------------------------------------------------- /spec/files/custom_policy.json: -------------------------------------------------------------------------------- 1 | { 2 | "Statement": [ 3 | { 4 | "Resource": "https://d84l721fxaaqy9.cloudfront.net/downloads/", 5 | "Condition": { 6 | "DateLessThan": { "AWS:EpochTime": 1255674716 }, 7 | "DateGreaterThan": {"AWS:EpochTime": 1241073790 }, 8 | "IpAddress": { "AWS:SourceIp": "216.98.35.1/32" } 9 | } 10 | } 11 | ] 12 | } 13 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | bundler-audit: 4 | enabled: true 5 | duplication: 6 | enabled: true 7 | config: 8 | languages: 9 | - ruby 10 | fixme: 11 | enabled: true 12 | markdownlint: 13 | enabled: true 14 | reek: 15 | enabled: true 16 | rubocop: 17 | enabled: true 18 | ratings: 19 | paths: 20 | - Gemfile.lock 21 | - "**.rb" 22 | exclude_paths: 23 | - coverage/ 24 | -------------------------------------------------------------------------------- /lib/generators/cloudfront/install/install_generator.rb: -------------------------------------------------------------------------------- 1 | require 'rails' 2 | require 'rails/generators' 3 | 4 | module Cloudfront 5 | class InstallGenerator < Rails::Generators::Base 6 | source_root File.expand_path('../templates', __FILE__) 7 | 8 | desc 'This generator creates an initializer file at config/initializers' 9 | def add_initializer 10 | template 'cloudfront_signer.rb', 11 | 'config/initializers/cloudfront_signer.rb' 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'bundler/gem_tasks' 2 | 3 | require 'rspec/core/rake_task' 4 | 5 | RSpec::Core::RakeTask.new(:spec) do |t| 6 | t.pattern = 'spec/**/*_spec.rb' 7 | t.rspec_opts = ['--colour', '--format', 'nested'] 8 | end 9 | 10 | task default: :spec 11 | 12 | require 'rdoc/task' 13 | 14 | Rake::RDocTask.new do |rdoc| 15 | rdoc.main = 'README.md' 16 | rdoc.rdoc_files.include %w(README.md LICENSE lib/cloudfront-signer.rb) 17 | rdoc.rdoc_dir = 'doc' 18 | rdoc.options << '--line-numbers' 19 | rdoc.options << '--coverage-report' 20 | rdoc.markup = 'markdown' 21 | end 22 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'simplecov' 2 | SimpleCov.start 3 | 4 | $LOAD_PATH.unshift(File.join(File.dirname(__FILE__), '..', 'lib')) 5 | $LOAD_PATH.unshift(File.dirname(__FILE__)) 6 | 7 | require 'rspec' 8 | require 'cloudfront-signer' 9 | 10 | module URLHelpers 11 | def get_query_value(url, key) 12 | query_string = url.slice((url =~ /\?/) + 1..-1) 13 | pairs = query_string.split('&') 14 | pairs.each do |item| 15 | return item.split('=')[1] if item.start_with?(key) 16 | end 17 | end 18 | end 19 | 20 | RSpec.configure do |config| 21 | config.include URLHelpers 22 | end 23 | -------------------------------------------------------------------------------- /spec/files/private_key.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXgIBAAKBgQCp280I7v8JBVJBN7Kdfl4eD+noyqzbLAsz9mIr07hZQ3PjVa5g 3 | 3j5Q8oXioU2ycxzXephfPr83l/FTAtPSZQ94Jh6u/CdoEYXfEtFbJYQ2lHXrra36 4 | yVcyyxQ6tAKgUHdWnZ/vbItUhLnhCSqwelTNpgRzf6AKdVOtQPaZ+bnkQQIDAQAB 5 | AoGAXWSPTbQq4gjc+yLmwJW0pg7V67tUY4XJ+x4jSDm3CM1/sKVxpa1M0jEm0D8k 6 | e1Ozrf6oPOZBOQ4AEEZjtTD/2Yi8U0bwG97fg9NlZddGNN2jj8pEOWY53/iVWcfb 7 | VGXVDlhUA0uIZhKK3Sl2SW9t/8p7affjJmGKn2nGLieRKIkCQQDQmExXqRnVNtCz 8 | qjTPt81MU4cIrzXr/tUC9s6An8OcgiTDjiIOnY3XB/F19lpMQIMEzrB7f04GrpkQ 9 | 0w6p/3NXAkEA0HXjiSyZaEoXoR2e/dTZrKw8npnjjW0CpKeSf8PK8qpFPK0UJOk7 10 | aU0rStQmoAmygcHiw3hJ7slyVS8f9zn+JwJBAMMVbHCfadWKSm19RZ7um0ZC6Asr 11 | MhbgYX9AK6kHwf3hiViK2TcqCrmMaDqWh6TAwMgCNfOKAAMnz2d4vEIo8kkCQQCl 12 | qnq4gkQsWG2s8jBvg1+2VW8bkCsCMvbdyfqoJP69mUnK7bXLm7tGdTiJkE5d8zb0 13 | 3hQLyiXfaiK9xeS+gk0TAkEAtuFcd+taoBnjhVL6q0OhNuA1T1+qYr5fyzQWKKKC 14 | +WMRi2/JCJCL/SX12q5hMq759VnzfnbgqwAq6MlPUZKEBQ== 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /spec/files/pk-APKAIKUROOUNR2BAFUUU.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIICXgIBAAKBgQCp280I7v8JBVJBN7Kdfl4eD+noyqzbLAsz9mIr07hZQ3PjVa5g 3 | 3j5Q8oXioU2ycxzXephfPr83l/FTAtPSZQ94Jh6u/CdoEYXfEtFbJYQ2lHXrra36 4 | yVcyyxQ6tAKgUHdWnZ/vbItUhLnhCSqwelTNpgRzf6AKdVOtQPaZ+bnkQQIDAQAB 5 | AoGAXWSPTbQq4gjc+yLmwJW0pg7V67tUY4XJ+x4jSDm3CM1/sKVxpa1M0jEm0D8k 6 | e1Ozrf6oPOZBOQ4AEEZjtTD/2Yi8U0bwG97fg9NlZddGNN2jj8pEOWY53/iVWcfb 7 | VGXVDlhUA0uIZhKK3Sl2SW9t/8p7affjJmGKn2nGLieRKIkCQQDQmExXqRnVNtCz 8 | qjTPt81MU4cIrzXr/tUC9s6An8OcgiTDjiIOnY3XB/F19lpMQIMEzrB7f04GrpkQ 9 | 0w6p/3NXAkEA0HXjiSyZaEoXoR2e/dTZrKw8npnjjW0CpKeSf8PK8qpFPK0UJOk7 10 | aU0rStQmoAmygcHiw3hJ7slyVS8f9zn+JwJBAMMVbHCfadWKSm19RZ7um0ZC6Asr 11 | MhbgYX9AK6kHwf3hiViK2TcqCrmMaDqWh6TAwMgCNfOKAAMnz2d4vEIo8kkCQQCl 12 | qnq4gkQsWG2s8jBvg1+2VW8bkCsCMvbdyfqoJP69mUnK7bXLm7tGdTiJkE5d8zb0 13 | 3hQLyiXfaiK9xeS+gk0TAkEAtuFcd+taoBnjhVL6q0OhNuA1T1+qYr5fyzQWKKKC 14 | +WMRi2/JCJCL/SX12q5hMq759VnzfnbgqwAq6MlPUZKEBQ== 15 | -----END RSA PRIVATE KEY----- 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015 Leonel Galán 2 | Portions Copyright (c) 2011 Anthony Bouch 3 | Portions Copyright (c) 2011 Dylan Vaughn 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining 6 | a copy of this software and associated documentation files (the 7 | "Software"), to deal in the Software without restriction, including 8 | without limitation the rights to use, copy, modify, merge, publish, 9 | distribute, sublicense, and/or sell copies of the Software, and to 10 | permit persons to whom the Software is furnished to do so, subject to 11 | the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be 14 | included in all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 17 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 18 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 19 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 20 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 21 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 22 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## 3.0.2 / 2017-06-22 4 | 5 | * Provides an option to URI escape the path before signing it. Issue and accepted PR from [@mynock](https://github.com/mynock) 6 | * Replaces Fixnum with Integer for Ruby 2.4.1. Issue and accepted PR from [@scott-knight](https://github.com/scott-knight) 7 | 8 | ## 3.0.1 / 2017-01-20 9 | 10 | * Supports signing frozen strings. Bug reported by [@alexandermayr](https://github.com/alexandermayr). 11 | 12 | ## 3.0.0 / 2015-03-14 13 | 14 | * Renames namespace to `Aws`. Matches used by latest [https://github.com/aws/aws-sdk-ruby](https://github.com/aws/aws-sdk-ruby). 15 | Change proposed by [@tennantje](https://github.com/tennantje) 16 | * Renames `sign` to `build_url` to better communicate method intent. 17 | 18 | ## 2.2.0 / 2015-04-29 19 | 20 | * Accepted merge request from [@leonelgalan](https://github.com/leonelgalan) - 21 | `sign_params` method returns raw params to be used in urls or cookies. 22 | 23 | ## 2.1.2 / 2015-04-16 24 | 25 | * Accepted merge request from [@tuvistavie](https://github.com/tuvistavie) - 26 | fixing custom policy bug. 27 | 28 | ## 2.1.1 / 2013-10-31 29 | 30 | * Added changelog file 31 | * Aceppted merge request from [@bullfight](https://github.com/bullfight), 32 | Refactored configuration to allow for key to be passed in directly. 33 | -------------------------------------------------------------------------------- /cloudfront-signer.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | $LOAD_PATH.push File.expand_path('../lib', __FILE__) 3 | require 'cloudfront-signer/version' 4 | 5 | Gem::Specification.new do |s| 6 | s.name = 'cloudfront-signer' 7 | s.version = Aws::CF::VERSION 8 | s.authors = ['Anthony Bouch', 'Leonel Galan'] 9 | s.email = ['tony@58bits.com', 'leonelgalan@gmail.com'] 10 | s.homepage = 'http://github.com/leonelgalan/cloudfront-signer' 11 | s.summary = 'A gem to sign url and stream paths for Amazon CloudFront ' \ 12 | 'private content.' 13 | s.description = 'A gem to sign url and stream paths for Amazon CloudFront ' \ 14 | 'private content. Includes specific signing methods for ' \ 15 | "both url and streaming paths, including html 'safe' " \ 16 | 'escaped versions of each.' 17 | s.license = 'MIT' 18 | 19 | s.rubyforge_project = 'cloudfront-signer' 20 | s.add_development_dependency 'rspec', '~> 3.5' 21 | s.add_development_dependency 'codeclimate-test-reporter', '>=1.0' 22 | s.files = `git ls-files`.split("\n") 23 | s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n") 24 | s.executables = `git ls-files -- bin/*`.split("\n") 25 | .map { |f| File.basename f } 26 | s.require_paths = ['lib'] 27 | end 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # cloudfront-signer 2 | 3 | [![Build Status](https://travis-ci.org/leonelgalan/cloudfront-signer.svg)](https://travis-ci.org/leonelgalan/cloudfront-signer) 4 | [![Code Climate](https://codeclimate.com/github/leonelgalan/cloudfront-signer/badges/gpa.svg)](https://codeclimate.com/github/leonelgalan/cloudfront-signer) 5 | [![Test Coverage](https://codeclimate.com/github/leonelgalan/cloudfront-signer/badges/coverage.svg)](https://codeclimate.com/github/leonelgalan/cloudfront-signer/coverage) 6 | [![Gem Version](https://badge.fury.io/rb/cloudfront-signer.svg)](http://badge.fury.io/rb/cloudfront-signer) 7 | [![Dependency Status](https://gemnasium.com/leonelgalan/cloudfront-signer.svg)](https://gemnasium.com/leonelgalan/cloudfront-signer) 8 | 9 | See the [CHANGELOG](https://github.com/leonelgalan/cloudfront-signer/blob/master/CHANGELOG.md) 10 | for details of this release. 11 | 12 | See Amazon docs for [Serving Private Content through CloudFront](http://docs.amazonwebservices.com/AmazonCloudFront/latest/DeveloperGuide/index.html?PrivateContent.html) 13 | 14 | A fork and rewrite started by [Anthony Bouch](https://github.com/58bits) of 15 | Dylan Vaughn's [aws_cf_signer](https://github.com/dylanvaughn/aws_cf_signer). 16 | 17 | This version uses all class methods and a configure method to set options. 18 | 19 | Separate helper methods exist for safe signing of urls and stream paths, each of 20 | which has slightly different requirements. For example, urls must not contain 21 | any spaces, whereas a stream path might. Also we might not want to html escape a 22 | url or path if it is being supplied to a JavaScript block or Flash object. 23 | 24 | ## Installation 25 | 26 | This gem has been published as _cloudfront-signer_. Use `gem install 27 | cloudfront-signer` to install this gem. 28 | 29 | The signing class must be configured - supplying the path to a signing key, or 30 | supplying the signing key directly as a string along with the `key_pair_id`. 31 | Create the initializer by running: 32 | 33 | ```sh 34 | bundle exec rails generate cloudfront:install 35 | ``` 36 | 37 | Customize the resulting *config/initializers/cloudfront\_signer.rb* file. 38 | 39 | ### Generated *cloudfront\_signer.rb* 40 | 41 | ```ruby 42 | Aws::CF::Signer.configure do |config| 43 | config.key_path = '/path/to/keyfile.pem' 44 | # or config.key = ENV.fetch('PRIVATE_KEY') 45 | config.key_pair_id = 'XXYYZZ' 46 | config.default_expires = 3600 47 | end 48 | ``` 49 | 50 | ## Usage 51 | 52 | Call the class `sign_url` or `sign_path` method with optional policy settings. 53 | 54 | ```ruby 55 | Aws::CF::Signer.sign_url 'http://mydomain/path/to/my/content' 56 | ``` 57 | 58 | ```ruby 59 | Aws::CF::Signer.sign_path 'path/to/my/content', expires: Time.now + 600 60 | ``` 61 | 62 | Both `sign_url` and `sign_path` have _safe_ versions that HTML encode the result 63 | allowing signed paths or urls to be placed in HTML markup. The 'non'-safe 64 | versions can be used for placing signed urls or paths in JavaScript blocks or 65 | Flash params. 66 | 67 | ___ 68 | 69 | Call class method `signed_params` to get raw parameters. These values can be 70 | used to set signing cookies ( 71 | [Serving Private Content through CloudFront: Using Signed Cookies](http://docs.aws.amazon.com/AmazonCloudFront/latest/DeveloperGuide/private-content-signed-cookies.html) 72 | ). See [commit message](https://github.com/leonelgalan/cloudfront-signer/commit/fedcc3182e32133e4bd0ad0b79c0106168896c91) 73 | for additional details. 74 | 75 | ```ruby 76 | Aws::CF::Signer.signed_params 'path/to/my/content' 77 | ``` 78 | 79 | ### Custom Policies 80 | 81 | See Example Custom Policy 1 at above AWS doc link 82 | 83 | ```ruby 84 | url = Aws::CF::Signer.sign_url 'http://d604721fxaaqy9.cloudfront.net/training/orientation.avi', 85 | expires: 'Sat, 14 Nov 2009 22:20:00 GMT', 86 | resource: 'http://d604721fxaaqy9.cloudfront.net/training/*', 87 | ip_range: '145.168.143.0/24' 88 | ``` 89 | 90 | See Example Custom Policy 2 at above AWS doc link 91 | 92 | ```ruby 93 | Aws::CF::Signer.sign_url 'http://d84l721fxaaqy9.cloudfront.net/downloads/pictures.tgz', 94 | starting: 'Thu, 30 Apr 2009 06:43:10 GMT', 95 | expires: 'Fri, 16 Oct 2009 06:31:56 GMT', 96 | resource: 'http://*', 97 | ip_range: '216.98.35.1/32' 98 | ``` 99 | 100 | You can also pass in a path to a policy file. This will supersede any other 101 | policy options 102 | 103 | ```ruby 104 | Aws::CF::Signer.sign_url 'http://d84l721fxaaqy9.cloudfront.net/downloads/pictures.tgz', 105 | policy_file: '/path/to/policy/file.txt' 106 | ``` 107 | 108 | ## Patches/Pull Requests 109 | 110 | * Fork the project. 111 | * Make your feature addition or bug fix. 112 | * Add tests for it. 113 | * Commit 114 | * Send me a pull request. Bonus points for topic branches. 115 | 116 | ## Attributions 117 | 118 | Hat tip to [Anthony Bouch](https://github.com/58bits) for contributing to 119 | Dylan's effort. Only reading both gem's code I was able to figure out the 120 | signing needed for the newly introduced signed cookies. 121 | 122 | > Dylan blazed a trail here - however, after several attempts, I was unable to 123 | contact Dylan in order to suggest that we combine our efforts to produce a 124 | single gem - hence the re-write and new gem here. - _Anthony Bouch_ 125 | 126 | Parts of signing code taken from a question on 127 | [Stack Overflow](http://stackoverflow.com/questions/2632457/create-signed-urls-for-cloudfront-with-ruby) 128 | asked by [Ben Wiseley](http://stackoverflow.com/users/315829/ben-wiseley), and 129 | answered by [Blaz Lipuscek](http://stackoverflow.com/users/267804/blaz-lipuscek) 130 | and [Manual M](http://stackoverflow.com/users/327914/manuel-m). 131 | 132 | ## License 133 | 134 | _cloudfront-signer_ is distributed under the MIT License, portions copyright © 135 | 2015 Dylan Vaughn, STL, Anthony Bouch, Leonel Galán 136 | -------------------------------------------------------------------------------- /spec/signer_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | 3 | RSpec.shared_examples 'is configured' do 4 | it 'is configured' do 5 | expect(Aws::CF::Signer.is_configured?).to be true 6 | end 7 | 8 | it 'sets the private_key' do 9 | expect(Aws::CF::Signer.send(:private_key)).to( 10 | be_an_instance_of(OpenSSL::PKey::RSA) 11 | ) 12 | end 13 | end 14 | 15 | FILES_PATH = File.expand_path(File.dirname(__FILE__) + '/files') 16 | KEY_PAIR_ID = 'APKAIKUROOUNR2BAFUUU'.freeze 17 | 18 | RSpec.describe Aws::CF::Signer do 19 | let(:key_path) { FILES_PATH + "/pk-#{KEY_PAIR_ID}.pem" } 20 | let(:other_key_path) { FILES_PATH + '/private_key.pem' } 21 | let(:key) { File.readlines(key_path).join '' } 22 | 23 | describe 'Errors' do 24 | it 'raises ArgumentError when invalid path is passed to key_path' do 25 | expect do 26 | Aws::CF::Signer.configure { |config| config.key_path = 'foo/bar' } 27 | end.to raise_error ArgumentError 28 | end 29 | 30 | it 'raises OpenSSL::PKey::RSAError when invalid key is passed' do 31 | expect do 32 | Aws::CF::Signer.configure { |config| config.key = '' } 33 | end.to raise_error OpenSSL::PKey::RSAError 34 | end 35 | 36 | it 'raises ArgumentError when no key is provided through private_key' do 37 | expect do 38 | Aws::CF::Signer.configure { |_config| } 39 | end.to raise_error ArgumentError 40 | end 41 | 42 | it "raises ArgumentError when no key is provided through key_path doesn't" \ 43 | 'allow to guess key_pair_id' do 44 | expect do 45 | Aws::CF::Signer.configure { |config| config.key_path = other_key_path } 46 | end.to raise_error ArgumentError 47 | end 48 | end 49 | 50 | describe 'Defaults' do 51 | it 'expire urls and paths in one hour by default' do 52 | expect(Aws::CF::Signer.default_expires).to eq 3600 53 | end 54 | 55 | it 'expires when specified' do 56 | Aws::CF::Signer.default_expires = 600 57 | expect(Aws::CF::Signer.default_expires).to eq 600 58 | Aws::CF::Signer.default_expires = nil 59 | end 60 | end 61 | 62 | context 'When configured with key and key_pair_id' do 63 | before do 64 | Aws::CF::Signer.configure do |config| 65 | config.key_pair_id = KEY_PAIR_ID 66 | config.key = key 67 | end 68 | end 69 | 70 | include_examples 'is configured' 71 | end 72 | 73 | context 'When configured with key_path' do 74 | before(:each) do 75 | Aws::CF::Signer.configure { |config| config.key_path = key_path } 76 | end 77 | 78 | describe 'before default use' do 79 | include_examples 'is configured' 80 | end 81 | 82 | describe 'when signing a url' do 83 | let(:url) { 'https://example.com/somerésource?opt1=one&opt2=two' } 84 | let(:url_with_spaces) { 'http://example.com/sign me' } 85 | 86 | it "doesn't modifies the passed url" do 87 | url = 'http://example.com/'.freeze 88 | expect(Aws::CF::Signer.sign_url(url)).not_to match(/\s/) 89 | end 90 | 91 | it 'removes spaces' do 92 | expect(Aws::CF::Signer.sign_url(url_with_spaces)).not_to match(/\s/) 93 | end 94 | 95 | it "doesn't HTML encode the signed url by default" do 96 | expect(Aws::CF::Signer.sign_url(url)).to match(/\?|=|&/) 97 | end 98 | 99 | it 'HTML encodes the signed url when using sign_url_safe' do 100 | expect(Aws::CF::Signer.sign_url_safe(url)).not_to match(/\?|=|&/) 101 | end 102 | 103 | it 'URL encodes the signed URL when using sign_url_escaped' do 104 | expect(Aws::CF::Signer.sign_url_escaped(url)).not_to match(/é/) 105 | end 106 | end 107 | 108 | describe 'when signing a path' do 109 | it "doesn't remove spaces" do 110 | path = '/prefix/sign me' 111 | expect(Aws::CF::Signer.sign_path(path)).to match(/\s/) 112 | end 113 | 114 | it 'HTML encodes the signed path when using sign_path_safe' do 115 | path = '/prefix/sign me?' 116 | expect(Aws::CF::Signer.sign_path_safe(path)).not_to match(/\?|=|&/) 117 | end 118 | 119 | it 'URL encodes the signed path when using sign_path_escaped' do 120 | path = '/préfix/sign me?' 121 | expect(Aws::CF::Signer.sign_path_escaped(path)).not_to match(/[é ]+/) 122 | end 123 | end 124 | 125 | describe ':expires option' do 126 | subject(:sign_url) { Aws::CF::Signer.sign_url '', expires: expires } 127 | 128 | { 'Time' => Time.now, 129 | 'String' => '2018-01-01', 130 | 'Integer' => 1_514_782_800, 131 | 'NilClass' => nil }.each do |klass, value| 132 | context "as a #{klass}" do 133 | let(:expires) { value } 134 | it "doesn't raise an error" do 135 | expect { subject }.not_to raise_error 136 | end 137 | end 138 | end 139 | 140 | context 'not as a String, Integer or Time' do 141 | let(:expires) { [[], {}, true, 1.0].sample } 142 | it 'raises ArgumentError' do 143 | expect { subject }.to raise_error ArgumentError 144 | end 145 | end 146 | end 147 | 148 | describe 'Custom Policy' do 149 | it 'builds policy from policy_options' do 150 | signed_url = Aws::CF::Signer.sign_url( 151 | 'https://d84l721fxaaqy9.cloudfront.net/downloads/pictures.tgz', 152 | starting: 'Thu, 30 Apr 2009 06:43:10 GMT', 153 | expires: 'Fri, 16 Oct 2009 06:31:56 GMT', 154 | resource: 'https://d84l721fxaaqy9.cloudfront.net/downloads/', 155 | ip_range: '216.98.35.1/32' 156 | ) 157 | policy_value = get_query_value(signed_url, 'Policy') 158 | expect(policy_value).not_to be_empty 159 | end 160 | 161 | it 'builds policy from policy_file' do 162 | signed_url = Aws::CF::Signer.sign_url( 163 | 'https://d84l721fxaaqy9.cloudfront.net/downloads/pictures.tgz', 164 | policy_file: FILES_PATH + '/custom_policy.json' 165 | ) 166 | policy_value = get_query_value(signed_url, 'Policy') 167 | expect(policy_value).not_to be_empty 168 | end 169 | end 170 | end 171 | end 172 | -------------------------------------------------------------------------------- /lib/cloudfront-signer.rb: -------------------------------------------------------------------------------- 1 | # A re-write of https://github.com/stlondemand/aws_cf_signer 2 | # 3 | require 'openssl' 4 | require 'time' 5 | require 'base64' 6 | require 'cloudfront-signer/version' 7 | require 'json' 8 | 9 | module Aws 10 | module CF 11 | class Signer 12 | # Public non-inheritable class accessors 13 | class << self 14 | # Public: Provides a configuration option to set the key_pair_id if it 15 | # has not been inferred from the key_path 16 | # 17 | # Examples 18 | # 19 | # Aws::CF::Signer.configure do |config| 20 | # config.key_pair_id = "XXYYZZ" 21 | # end 22 | # 23 | # Returns a String value indicating the current setting 24 | attr_accessor :key_pair_id 25 | 26 | # Public: Provides a configuration option that sets the key_path 27 | # 28 | # Examples 29 | # 30 | # Aws::CF::Signer.configure do |config| 31 | # config.key_path = "/path/to/your/keyfile.pem" 32 | # end 33 | # 34 | # Returns nothing. 35 | def key_path=(path) 36 | unless File.exist?(path) 37 | fail ArgumentError, 38 | "The signing key could not be found at #{path}" 39 | end 40 | @key_path = path 41 | self.key = File.readlines(path).join('') 42 | end 43 | 44 | # Public: Provides a configuration option to set the key directly as a 45 | # string e.g. as an ENV var 46 | # 47 | # Examples 48 | # 49 | # Aws::CF::Signer.configure do |config| 50 | # config.key = ENV.fetch('KEY') 51 | # end 52 | # Returns nothing. 53 | def key=(key) 54 | @key = OpenSSL::PKey::RSA.new(key) 55 | end 56 | 57 | # Public: Provides an accessor to the key_path 58 | # 59 | # Returns a String value indicating the current setting 60 | attr_reader :key_path 61 | 62 | # Public: Provides a configuration option that sets the default_expires 63 | # in milliseconds 64 | # 65 | # Examples 66 | # 67 | # Aws::CF::Signer.configure do |config| 68 | # config.default_expires = 3600 69 | # end 70 | # 71 | # Returns nothing. 72 | attr_writer :default_expires 73 | 74 | # Public: Provides an accessor to the default_expires value 75 | # 76 | # Returns an Integer value indicating the current setting 77 | def default_expires 78 | @default_expires ||= 3600 79 | end 80 | 81 | private 82 | 83 | # Private: Provides an accessor to the RSA key value 84 | # 85 | # Returns an RSA key pair. 86 | def private_key 87 | @key 88 | end 89 | end 90 | 91 | # Public: Provides a simple way to configure the signing class. 92 | # 93 | # Yields self. 94 | # 95 | # Examples 96 | # 97 | # Aws::CF::Signer.configure do |config| 98 | # config.key_path = "/path/to/yourkeyfile.pem" 99 | # config.key_pair_id = "XXYYZZ" 100 | # config.default_expires = 3600 101 | # end 102 | # 103 | # Returns nothing. 104 | def self.configure 105 | yield self if block_given? 106 | 107 | unless key_path || private_key 108 | fail ArgumentError, 109 | 'You must supply the path to a PEM format RSA key pair.' 110 | end 111 | 112 | unless @key_pair_id 113 | @key_pair_id = extract_key_pair_id(key_path) 114 | fail ArgumentError, 115 | 'The Cloudfront signing key id could not be inferred from ' \ 116 | "#{key_path}. Please supply the key pair id as a " \ 117 | 'configuration argument.' unless @key_pair_id 118 | end 119 | end 120 | 121 | # Public: Provides a configuration check method which tests to see 122 | # that the key_path, key_pair_id and private key values have all been set. 123 | # 124 | # Returns a Boolean value indicating that settings are present. 125 | def self.is_configured? 126 | (key_pair_id.nil? || private_key.nil?) ? false : true 127 | end 128 | 129 | # Public: Sign a url - encoding any spaces in the url before signing. 130 | # CloudFront stipulates that signed URLs must not contain spaces (as 131 | # opposed to stream paths/filenames which CAN contain spaces). 132 | # 133 | # Returns a String 134 | def self.sign_url(subject, policy_options = {}) 135 | build_url subject, { remove_spaces: true }, policy_options 136 | end 137 | 138 | # Public: Sign a url (as above) and HTML encode the result. 139 | # 140 | # Returns a String 141 | def self.sign_url_safe(subject, policy_options = {}) 142 | build_url subject, { remove_spaces: true, html_escape: true }, policy_options 143 | end 144 | 145 | # Public: Sign a url (as above) but URI encode the string first. 146 | # 147 | # Returns a String 148 | def self.sign_url_escaped(subject, policy_options = {}) 149 | build_url subject, { uri_escape: true }, policy_options 150 | end 151 | 152 | # Public: Sign a stream path part or filename (spaces are allowed in 153 | # stream paths and so are not removed). 154 | # 155 | # Returns a String 156 | def self.sign_path(subject, policy_options = {}) 157 | build_url subject, { remove_spaces: false }, policy_options 158 | end 159 | 160 | # Public: Sign a stream path or filename and HTML encode the result. 161 | # 162 | # Returns a String 163 | def self.sign_path_safe(subject, policy_options = {}) 164 | build_url subject, 165 | { remove_spaces: false, html_escape: true }, 166 | policy_options 167 | end 168 | 169 | # Public: Sign a stream path or filename but URI encode the string first 170 | # 171 | # Returns a String 172 | def self.sign_path_escaped(subject, policy_options = {}) 173 | build_url subject, { uri_escape: true }, policy_options 174 | end 175 | 176 | # Public: Builds a signed url or stream resource name with optional 177 | # configuration and policy options 178 | # 179 | # Returns a String 180 | def self.build_url(original_subject, configuration_options = {}, policy_options = {}) 181 | subject = original_subject.dup 182 | # If the url or stream path already has a query string parameter - 183 | # append to that. 184 | separator = subject =~ /\?/ ? '&' : '?' 185 | 186 | subject.gsub!(/\s/, '%20') if configuration_options[:remove_spaces] 187 | subject = URI.escape(subject) if configuration_options[:uri_escape] 188 | 189 | result = subject + 190 | separator + 191 | signed_params(subject, policy_options).collect do |key, value| 192 | "#{key}=#{value}" 193 | end.join('&') 194 | 195 | if configuration_options[:html_escape] 196 | return html_encode(result) 197 | else 198 | return result 199 | end 200 | end 201 | 202 | # Public: Sign a subject url or stream resource name with optional policy 203 | # options. It returns raw params to be used in urls or cookies 204 | # 205 | # Returns a Hash 206 | def self.signed_params(subject, policy_options = {}) 207 | result = {} 208 | 209 | if policy_options[:policy_file] 210 | policy = IO.read(policy_options[:policy_file]) 211 | result['Policy'] = encode_policy(policy) 212 | else 213 | policy_options[:expires] = epoch_time(policy_options[:expires] || 214 | Time.now + default_expires) 215 | 216 | if policy_options.keys.size <= 1 217 | # Canned Policy - shorter URL 218 | expires_at = policy_options[:expires] 219 | policy = %{{"Statement":[{"Resource":"#{subject}","Condition":{"DateLessThan":{"AWS:EpochTime":#{expires_at}}}}]}} 220 | result['Expires'] = expires_at 221 | else 222 | # Custom Policy 223 | resource = policy_options[:resource] || subject 224 | policy = generate_custom_policy(resource, policy_options) 225 | result['Policy'] = encode_policy(policy) 226 | end 227 | end 228 | 229 | result.merge 'Signature' => create_signature(policy), 230 | 'Key-Pair-Id' => @key_pair_id 231 | end 232 | 233 | private 234 | 235 | def self.generate_custom_policy(resource, options) 236 | conditions = { 237 | 'DateLessThan' => { 238 | 'AWS:EpochTime' => epoch_time(options[:expires]) 239 | } 240 | } 241 | 242 | conditions['DateGreaterThan'] = { 243 | 'AWS:EpochTime' => epoch_time(options[:starting]) 244 | } if options[:starting] 245 | 246 | conditions['IpAddress'] = { 247 | 'AWS:SourceIp' => options[:ip_range] 248 | } if options[:ip_range] 249 | 250 | { 251 | 'Statement' => [{ 252 | 'Resource' => resource, 253 | 'Condition' => conditions 254 | }] 255 | }.to_json 256 | end 257 | 258 | def self.epoch_time(timelike) 259 | case timelike 260 | when String then Time.parse(timelike).to_i 261 | when Time then timelike.to_i 262 | when Integer then timelike 263 | else fail ArgumentError, 264 | 'Invalid argument - String, Integer or Time required - ' \ 265 | "#{timelike.class} passed." 266 | end 267 | end 268 | 269 | def self.encode_policy(policy) 270 | url_encode Base64.encode64(policy) 271 | end 272 | 273 | def self.create_signature(policy) 274 | url_encode Base64.encode64( 275 | private_key.sign(OpenSSL::Digest::SHA1.new, (policy)) 276 | ) 277 | end 278 | 279 | def self.extract_key_pair_id(key_path) 280 | File.basename(key_path) =~ /^pk-(.*).pem$/ ? Regexp.last_match[1] : nil 281 | end 282 | 283 | def self.url_encode(s) 284 | s.gsub('+', '-').gsub('=', '_').gsub('/', '~').gsub(/\n/, '') 285 | .gsub(' ', '') 286 | end 287 | 288 | def self.html_encode(s) 289 | s.gsub('?', '%3F').gsub('=', '%3D').gsub('&', '%26') 290 | end 291 | end 292 | end 293 | end 294 | --------------------------------------------------------------------------------