├── .gitignore ├── lib ├── push_package │ └── version.rb └── push_package.rb ├── spec ├── fixtures │ ├── signature │ ├── self-signed.p12 │ ├── intermediate.crt │ ├── iconset │ │ ├── icon_16x16.png │ │ ├── icon_32x32.png │ │ ├── icon_128x128.png │ │ ├── icon_16x16@2x.png │ │ ├── icon_32x32@2x.png │ │ └── icon_128x128@2x.png │ ├── manifest.json │ ├── localhost.csr │ ├── localhost.crt │ └── localhost.key ├── spec_helper.rb └── push_package_spec.rb ├── Gemfile ├── .travis.yml ├── Rakefile ├── LICENSE.txt ├── push_package.gemspec ├── bin └── push_package └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | 3 | -------------------------------------------------------------------------------- /lib/push_package/version.rb: -------------------------------------------------------------------------------- 1 | class PushPackage 2 | VERSION = '0.4.0'.freeze 3 | end 4 | -------------------------------------------------------------------------------- /spec/fixtures/signature: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/signature -------------------------------------------------------------------------------- /spec/fixtures/self-signed.p12: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/self-signed.p12 -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in zero_push.gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /spec/fixtures/intermediate.crt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/intermediate.crt -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_16x16.png -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_32x32.png -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_128x128.png -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_16x16@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_16x16@2x.png -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_32x32@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_32x32@2x.png -------------------------------------------------------------------------------- /spec/fixtures/iconset/icon_128x128@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Xzya/push_package/master/spec/fixtures/iconset/icon_128x128@2x.png -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'minitest/spec' 2 | require 'minitest/autorun' 3 | require 'push_package' 4 | 5 | def fixture_path(*paths) 6 | File.join(File.dirname(__FILE__), 'fixtures', *paths) 7 | end 8 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | --- 2 | language: ruby 3 | rvm: 4 | - 2.1.5 5 | - 2.2.3 6 | - 2.3.0 7 | - jruby-19mode 8 | sudo: false 9 | notifications: 10 | email: 11 | - adam.v.duke@gmail.com 12 | - stefan.natchev@gmail.com 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require "rake/testtask" 2 | require "bundler/gem_tasks" 3 | 4 | task default: :test 5 | 6 | Rake::TestTask.new do |t| 7 | t.libs << 'lib' << 'spec' 8 | t.test_files = Dir["spec/**/*_spec.rb"] 9 | t.verbose = true 10 | end 11 | 12 | -------------------------------------------------------------------------------- /spec/fixtures/manifest.json: -------------------------------------------------------------------------------- 1 | {"icon.iconset/icon_128x128.png":"28969578f1788252807a7d8205db269cb7699fa8","icon.iconset/icon_128x128@2x.png":"dd2bf0e3cb998467b0e5f5ae11675a454ad77601","icon.iconset/icon_16x16.png":"48e791d0c88b92fae51ffa8363821857210fca01","icon.iconset/icon_16x16@2x.png":"5a74d295cc09ca5896a4ceb7cac0d030cc85e894","icon.iconset/icon_32x32.png":"8c71bc22f4cfe12ad98aabe94da6a70fe9f15741","icon.iconset/icon_32x32@2x.png":"750e080d38efe1c227b2498f73f006007f3da24b","website.json":"3eaed6475443b895a49e3a1220e547f2be90434a"} -------------------------------------------------------------------------------- /spec/fixtures/localhost.csr: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE REQUEST----- 2 | MIICmTCCAYECAQAwVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAk1BMRIwEAYDVQQH 3 | EwlDYW1icmlkZ2UxEDAOBgNVBAoTB1R3aXR0ZXIxEjAQBgNVBAMTCWxvY2FsaG9z 4 | dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOEGnmjMEgF9ZH9NVXE+ 5 | IH9uBaL9eafkLQkEcBR/9KNof5XexB23p3+x8L5nvf66cGTZwHMrQgvpOKaiAG3u 6 | mMn+hyLxO8BqiGO/VMuoHJnN+EX5BNt4lP+UPIQ426enm4bc15tvCTyENG4dE/+A 7 | dfjfU4XWKysk1Zdx1tpboP/6bh6p0dCnbOo8J/F0KLFeKfnbShoVsSMqm547BVxM 8 | CbaVN4tusSG9/YbtplaRxVhS9v+GoIxu7hlKM4I0c6iddyx6oLE0RepyIncJtB4f 9 | O/6JHUsAlQDNAlP6vNnqsPWd7rcgtJGjuMbLB+yL9b68I20yc5ZnBpPCoP6cCzBm 10 | puECAwEAAaAAMA0GCSqGSIb3DQEBCwUAA4IBAQBF8S5/uUn3DnwaHJkkkUYIffKv 11 | 7LuIebyesIWRu03ed80wT2Sy12kIQK/Y9vvRUtdSLI0pi2SCR15oeDwDvdVD/+kQ 12 | 5sXuECfvby4bDgMSiHhE0IAwNYxIo4SOyBbkIV3ohcVaeVBlmwU/jkmm9fRJcWQe 13 | ns52HdWvsKKaP5+KibnEW8cQGbxsSqpl2oPPf1N9SRMGOctj++JlomYPsM8AfZ8b 14 | dlTik8W5NMroTDjDixpztybE0VPywp1iadc3ivZu93zi2pBrSou0e1Xt7TOuJpjr 15 | MwKZ1+SJOxIxP8dBDJtXE/LpKFoyBLm6oIFkAXWBL1d9ajrVqjm7jV9ODx// 16 | -----END CERTIFICATE REQUEST----- 17 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2013 Symmetric Infinity 2 | 3 | MIT License 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 | -------------------------------------------------------------------------------- /spec/fixtures/localhost.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDJDCCAgwCCQD1K/B5O4YXDzANBgkqhkiG9w0BAQUFADBUMQswCQYDVQQGEwJV 3 | UzELMAkGA1UECBMCTUExEjAQBgNVBAcTCUNhbWJyaWRnZTEQMA4GA1UEChMHVHdp 4 | dHRlcjESMBAGA1UEAxMJbG9jYWxob3N0MB4XDTE1MTIxMTE2MDUzNVoXDTI1MTIw 5 | ODE2MDUzNVowVDELMAkGA1UEBhMCVVMxCzAJBgNVBAgTAk1BMRIwEAYDVQQHEwlD 6 | YW1icmlkZ2UxEDAOBgNVBAoTB1R3aXR0ZXIxEjAQBgNVBAMTCWxvY2FsaG9zdDCC 7 | ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOEGnmjMEgF9ZH9NVXE+IH9u 8 | BaL9eafkLQkEcBR/9KNof5XexB23p3+x8L5nvf66cGTZwHMrQgvpOKaiAG3umMn+ 9 | hyLxO8BqiGO/VMuoHJnN+EX5BNt4lP+UPIQ426enm4bc15tvCTyENG4dE/+Adfjf 10 | U4XWKysk1Zdx1tpboP/6bh6p0dCnbOo8J/F0KLFeKfnbShoVsSMqm547BVxMCbaV 11 | N4tusSG9/YbtplaRxVhS9v+GoIxu7hlKM4I0c6iddyx6oLE0RepyIncJtB4fO/6J 12 | HUsAlQDNAlP6vNnqsPWd7rcgtJGjuMbLB+yL9b68I20yc5ZnBpPCoP6cCzBmpuEC 13 | AwEAATANBgkqhkiG9w0BAQUFAAOCAQEAkJYdqe18eotSa0Z/YfHFH4KTJDK0LDu5 14 | 1ZD5yrr7LfFRt4OA51mG7ALcDpDKpdPlHVEJkZHHPXL0ykRnE3i8/t8RNE7E7SDV 15 | 1sEJNPFZ/j2HR+eoUjYbT0oMt9+atj+M8Xqdj0EhBUuutf9aLFbyEPiN0ThFxoHx 16 | /UeFPKwmAcL7qhKGCAOL399AAm7SO7g5S0TNGdRCvi+Nt+3xi35T6r3xaEjFcG3v 17 | J3w4uXLNsG4A4888WkxdfPqDNVoYB1ddapLIiYSKqJQ68z2epuwI1H5fOeEnq3o8 18 | WbXpGdELZuViIjFTgN2p23uR7vdHkPvBQgEpoLlZd5O/2IRHnjQpKQ== 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /push_package.gemspec: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | lib = File.expand_path('../lib', __FILE__) 3 | $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) 4 | require 'push_package/version' 5 | 6 | Gem::Specification.new do |gem| 7 | gem.name = "push_package" 8 | gem.version = PushPackage::VERSION.dup 9 | gem.authors = ["Stefan Natchev", "Adam Duke"] 10 | gem.email = ["stefan.natchev@gmail.com", "adam.v.duke@gmail.com"] 11 | gem.summary = %q{A gem for creating Safari push notification push packages.} 12 | gem.description = %q{As of OSX 10.9 Safari can receive push notifications when it is closed.} 13 | gem.homepage = "https://github.com/symmetricinfinity/push_package" 14 | gem.license = 'MIT' 15 | 16 | gem.files = `git ls-files`.split($/) 17 | gem.executables = gem.files.grep(%r{^bin/}).map{ |f| File.basename(f) } 18 | gem.test_files = gem.files.grep(%r{^(test|spec|features)/}) 19 | gem.require_paths = ["lib"] 20 | gem.required_ruby_version = '>= 1.9' 21 | 22 | gem.add_runtime_dependency 'rubyzip', '~> 1.1', '>= 1.1.0' 23 | 24 | gem.add_development_dependency 'minitest', '~> 5.8.4', '>= 5.8.4' 25 | gem.add_development_dependency 'rake', '~> 10.0', '>= 10.0.3' 26 | gem.add_development_dependency 'pry', '~> 0' 27 | end 28 | -------------------------------------------------------------------------------- /spec/fixtures/localhost.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA4QaeaMwSAX1kf01VcT4gf24Fov15p+QtCQRwFH/0o2h/ld7E 3 | Hbenf7Hwvme9/rpwZNnAcytCC+k4pqIAbe6Yyf6HIvE7wGqIY79Uy6gcmc34RfkE 4 | 23iU/5Q8hDjbp6ebhtzXm28JPIQ0bh0T/4B1+N9ThdYrKyTVl3HW2lug//puHqnR 5 | 0Kds6jwn8XQosV4p+dtKGhWxIyqbnjsFXEwJtpU3i26xIb39hu2mVpHFWFL2/4ag 6 | jG7uGUozgjRzqJ13LHqgsTRF6nIidwm0Hh87/okdSwCVAM0CU/q82eqw9Z3utyC0 7 | kaO4xssH7Iv1vrwjbTJzlmcGk8Kg/pwLMGam4QIDAQABAoIBAQCxyy38NUkAvlda 8 | MfsRZ3n71S1E86tcmO9wUX5Q5Cyhs94ixwkDRelk/m3ts0At9Jb7SAVDuekMSjBF 9 | kHhwY3V7G80gMaP5SvMKhND1vJUkfNXuS2uoEKUBLtmFyvrag5ZhvznniJquovJe 10 | EkGLva74fVObynT/OLI+X2bXzk/uDy/BiMrYyXWebAO/FLkSja5YECAFOr47B44+ 11 | jhhy1v5WdCisD+AfRkYhIQaXgY/Ni+sDImF9WwZQzkZ+/vztpYmzfELCe+9sy95B 12 | TjeiuzJDZtROM//WfGx+9XpBl5BSQx0eyLunYmryP8jewRUWubOOddjSsxcl0HZF 13 | RCHF1t/xAoGBAPm2iodooCCJ0QPpYoISbq1OJuW6qWa4DMyKImHPzd9w3zDb1bzg 14 | gzevHC8rAxbZOGVrP0lSDd8x/dhIWWPu1W6LBTxhyzoZ8FhWSJ/fFUC0zgdkFHBg 15 | QtDy5Xy7zOFC6hIBqzn9qrNrKTXQqnOd/x8JnqvCjA/c8I15ceCBXM51AoGBAOaw 16 | 9oGtILuSCEgfGu4HderDQUw3ES/s2a+Bl48AlVEHvygo8+BepNFEMpW3QuKcDn9z 17 | HjWYk9+L93pfCAvcg9Dq9GoJV4uSMpVG3UjJCfJve2FDAEO4ydDlrG3J/fGBWvqV 18 | 1qTfW2DOjy6AtP1BsgaE3M95GwXHVxETBJuVLwE9AoGAK6GchNpgGC9caP2Pa8DC 19 | u99K5pr93GfOPLLqHQMNKrxzEvtmVjE4XDNpjkhKquRbeUUK8sm2iMgYXYEOtWpW 20 | upDp4koIE2fS2eyBlgWJlhvBYAM5mwUGx7GZLXk27Ckf8vN9so1DFURlF/UUw0zY 21 | 9dhddA9zH5ZwJZgRsLOJvr0CgYEAgDaKSrg2IQmgoLTo+rIaz0eF2x4f50r0EZ8s 22 | 5MWmN0re6ysXn/P6FnSYyKz594hUZnFMsO0EApKAEPsQNcdxW4O6I9TdWyz+AAlK 23 | o1FYve1H3V6nnvvs7rU3iC1jff4u2ma8zRV2a+9hlK7j6H3Cu+zL7GOaqTOJKGwx 24 | e2cGoOECgYEA2nG7rX7Xq8xa5WjeN1nnFVzmlj0iPqBEJNAXNQ+o9tsBPFm1rGlg 25 | VszYIRfrtPFVtacCXOxBqGgIpFYKJr3MF+AEhU3e2ilJgO98HGBLRcX6AM73wfOq 26 | SnyuQs9m2EyJyxoT17rm5TiSRRfnLxKeSSv/lpPV4kqkxLY3nreI0uY= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /bin/push_package: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require 'json' 4 | require 'optparse' 5 | require 'push_package' 6 | 7 | # $> push_package --website-json=./website.json --iconset=~/project/iconset --certificate=./Certificate.p12 --output-dir=./ 8 | options = {} 9 | options_parser = OptionParser.new do |opts| 10 | opts.banner = 'Usage: push_package [options]' 11 | 12 | opts.on('-w', '--website-json required', 'The path to the file containing the website.json') do |opt| 13 | options[:website_json_path] = opt 14 | end 15 | 16 | opts.on('-i', '--iconset-path required', 'The path to the directory containing the iconset') do |opt| 17 | options[:iconset_path] = opt 18 | end 19 | 20 | opts.on('-c', '--certificate required', 'The path to the p12 file that will be used to sign the manifest.json') do |opt| 21 | options[:certificate_path] = opt 22 | end 23 | 24 | opts.on('-p', '--password optional', 'The password to read the p12 file') do |opt| 25 | options[:password] = opt 26 | end 27 | 28 | opts.on('-I', '--intermediate-certificate optional', 'The path to the Apple WWDR Intermediate Certificate used to sign the manifest.json') do |opt| 29 | options[:intermediate_certificate_path] = opt 30 | end 31 | 32 | opts.on('-o', '--output-dir optional', 'The desired output path for the pushPackage.zip file') do |opt| 33 | options[:output_directory] = opt 34 | end 35 | end 36 | 37 | options_parser.parse! 38 | 39 | # check the required options 40 | [:website_json_path, :certificate_path].each do |opt| 41 | unless File.file?(options[opt].to_s) 42 | puts options_parser.help 43 | exit 1 44 | end 45 | end 46 | 47 | unless File.directory?(options[:iconset_path].to_s) 48 | puts options_parser.help 49 | exit 1 50 | end 51 | 52 | options[:output_directory] = `pwd`.strip unless options[:output_directory] 53 | 54 | website_params = JSON.load(File.open(options[:website_json_path], 'r')) 55 | 56 | packager = PushPackage.new(website_params, options[:iconset_path], options[:certificate_path], options[:password], options[:intermediate_certificate_path]) 57 | 58 | packager.save(options[:output_directory] + '/pushPackage.zip') 59 | 60 | 61 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Push Package 2 | 3 | Build Status: [![Build Status](https://travis-ci.org/SymmetricInfinity/push_package.png?branch=master)](https://travis-ci.org/SymmetricInfinity/push_package) 4 | 5 | ## Purpose 6 | 7 | This gem provides a Ruby library and command line tool for creating a push package to be used for [Safari Push Notifications](https://developer.apple.com/library/mac/documentation/NetworkingInternet/Conceptual/NotificationProgrammingGuideForWebsites/PushNotifications/PushNotifications.html#//apple_ref/doc/uid/TP40013225-CH3-SW24). 8 | 9 | ## Features 10 | 11 | * Validates push package contents 12 | * Generates manifest.json 13 | * Signs package with required signature 14 | * Creates pushPackage.zip 15 | 16 | ## Installation 17 | 18 | ```gem install push_package``` 19 | 20 | ## Notes: 21 | 22 | You must obtain a Website Push certificate from apple which requires a iOS developer license or a Mac developer license 23 | 24 | [Starting February 2016](https://developer.apple.com/support/certificates/expiration/), you will also need a copy of the 25 | Apple intermediate cert ([WWDR Certificate, expiring 02/07/23](https://developer.apple.com/certificationauthority/AppleWWDRCA.cer)) 26 | 27 | ```ruby 28 | require 'push_package' 29 | 30 | website_params = { 31 | websiteName: "Bay Airlines", 32 | websitePushID: "web.com.example.domain", 33 | allowedDomains: ["http://domain.example.com"], 34 | urlFormatString: "http://domain.example.com/%@/?flight=%@", 35 | authenticationToken: "19f8d7a6e9fb8a7f6d9330dabe", 36 | webServiceURL: "https://example.com/push" 37 | } 38 | 39 | iconset_path = 'path/to/iconset' 40 | certificate = 'path/to/certificate.p12' # or certificate_string 41 | intermediate_cert = 'path/to/AppleWWDRCA.cer' 42 | package = PushPackage.new(website_params, iconset_path, certificate, 'optional cert password', intermediate_cert) 43 | package.save('path/to/save') 44 | 45 | ``` 46 | 47 | ```shell 48 | $> push_package --website-json=./website.json --iconset-path=~/project/iconset --output-dir=./ --certificate=./Certificate.p12 49 | wrote: ./pushPackage.zip 50 | ``` 51 | 52 | ## Development/Test Certificates 53 | 54 | ```shell 55 | # verify the localhost.crt 56 | openssl verify spec/fixtures/localhost.crt 57 | 58 | # verify the localhost.csr 59 | openssl req -text -noout -verify -in spec/fixtures/localhost.csr 60 | 61 | # verify the localhost.key 62 | openssl rsa -in spec/fixtures/localhost.key -check -noout 63 | 64 | # print information about the certificate to STDOUT 65 | openssl x509 -in spec/fixtures/localhost.crt -text -noout 66 | 67 | # generate a new rsa key 68 | openssl genrsa -out spec/fixtures/localhost.key 2048 69 | 70 | # generate a new csr from using an existing key 71 | openssl req -new -sha256 -key spec/fixtures/localhost.key -out spec/fixtures/localhost.csr 72 | 73 | # generate a new certificate using an existing csr and private key 74 | openssl x509 -req -days 3650 -in spec/fixtures/localhost.csr -signkey spec/fixtures/localhost.key -out spec/fixtures/localhost.crt 75 | 76 | # export the certificate as a p12 77 | # make sure to set the passphrase to 'testing' because the specs depend on it 78 | openssl pkcs12 -export -out spec/fixtures/self-signed.p12 -inkey spec/fixtures/localhost.key -in spec/fixtures/localhost.crt 79 | 80 | ``` 81 | 82 | ## Contributing 83 | 84 | 1. Fork it 85 | 1. Create your feature branch (`git checkout -b my-new-feature`) 86 | 1. Write tests for your feature 87 | 1. Commit your changes (`git commit -am 'Add some feature'`) 88 | 1. Push to the branch (`git push origin my-new-feature`) 89 | 1. Create new Pull Request 90 | -------------------------------------------------------------------------------- /lib/push_package.rb: -------------------------------------------------------------------------------- 1 | require 'push_package/version' 2 | require 'json' 3 | require 'fileutils' 4 | require 'tmpdir' 5 | require 'tempfile' 6 | require 'openssl' 7 | require 'zip' 8 | 9 | class PushPackage 10 | class InvalidIconsetError < StandardError; end 11 | class InvalidParameterError < StandardError; end 12 | 13 | REQUIRED_WEBSITE_PARAMS = ["websiteName", "websitePushID", "allowedDomains", "urlFormatString", "authenticationToken", "webServiceURL"] 14 | REQUIRED_ICONSET_FILES = ["icon_16x16.png", "icon_16x16@2x.png", "icon_32x32.png", "icon_32x32@2x.png", "icon_128x128.png", "icon_128x128@2x.png" ] 15 | 16 | attr_reader :p12 17 | 18 | def initialize(website_params, iconset_path, certificate, password = nil, intermediate_cert = nil) 19 | raise InvalidParameterError unless valid_website_params?(website_params) 20 | raise InvalidIconsetError unless valid_iconset?(iconset_path) 21 | raise ArgumentError unless certificate 22 | 23 | @website_params = website_params 24 | @iconset_path = iconset_path.to_s 25 | 26 | if certificate.respond_to?(:read) 27 | cert_data = certificate.read 28 | certificate.rewind if certificate.respond_to?(:rewind) 29 | else 30 | cert_data = File.read(certificate) 31 | end 32 | 33 | if defined?(JRUBY_VERSION) 34 | #ensure binary data for jruby. 35 | cert_data.force_encoding(Encoding::ASCII_8BIT) 36 | end 37 | @p12 = OpenSSL::PKCS12.new(cert_data, password) 38 | 39 | if intermediate_cert 40 | intermediate_cert_data = File.read(intermediate_cert) 41 | @extra_certs = [OpenSSL::X509::Certificate.new(intermediate_cert_data)] 42 | end 43 | end 44 | 45 | def save(output_path = nil) 46 | 47 | @working_dir = Dir.mktmpdir('pushPackage') 48 | 49 | if output_path 50 | output_path = File.expand_path(output_path) 51 | else 52 | output_path = Dir.tmpdir + '/pushPackage.zip' 53 | end 54 | 55 | ## overwrite existing push packages 56 | File.delete(output_path) if File.exists?(output_path) 57 | 58 | zip = Zip::File.open(output_path, Zip::File::CREATE) 59 | 60 | File.open(@working_dir + '/website.json', 'w+') do |json| 61 | json.write(JSON.dump(@website_params)) 62 | end 63 | 64 | Dir.mkdir(File.join(@working_dir,'icon.iconset')) 65 | Dir.glob(@iconset_path + '/*.png').each do |icon| 66 | FileUtils.cp(icon, @working_dir + '/icon.iconset/') 67 | end 68 | 69 | File.open(@working_dir + '/manifest.json', 'w+') do |manifest| 70 | manifest.write(manifest_data) 71 | end 72 | 73 | File.open(@working_dir + '/signature', 'wb+') do |file| 74 | file.write(signature.to_der) 75 | end 76 | 77 | Dir.glob(@working_dir + '/**/*').each do |file| 78 | next if File.directory?(file) 79 | zip.add(file.gsub("#{@working_dir}/", ''), file) 80 | end 81 | 82 | zip.close 83 | 84 | #clean up the temporary directory 85 | FileUtils.remove_entry_secure(@working_dir) 86 | 87 | #re-open the file for reading 88 | File.open(output_path, 'r') 89 | end 90 | 91 | private 92 | 93 | def signature 94 | #use the certificate to create a pkcs7 detached signature 95 | OpenSSL::PKCS7::sign(@p12.certificate, @p12.key, manifest_data, @extra_certs, OpenSSL::PKCS7::BINARY | OpenSSL::PKCS7::DETACHED) 96 | end 97 | 98 | def manifest_data 99 | manifest_keys = REQUIRED_ICONSET_FILES.map{|f| 'icon.iconset/' + f } 100 | manifest_keys << 'website.json' 101 | manifest_values = manifest_keys.map {|file| Digest::SHA1.file(File.join(@working_dir, file)).hexdigest } 102 | Hash[manifest_keys.zip(manifest_values)].to_json 103 | end 104 | 105 | def valid_website_params?(params) 106 | REQUIRED_WEBSITE_PARAMS.all? do |required_param| 107 | params.keys.map(&:to_s).include?(required_param) 108 | end 109 | end 110 | 111 | def valid_iconset?(iconset_path) 112 | REQUIRED_ICONSET_FILES.all? do |file| 113 | File.exist?(File.join(iconset_path, file)) 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /spec/push_package_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | require 'fileutils' 3 | 4 | describe PushPackage do 5 | 6 | let(:iconset_path) { fixture_path 'iconset' } 7 | 8 | let(:website_params) do 9 | { 10 | 'websiteName' => 'Push Package Test', 11 | 'websitePushID' => 'web.com.symmetricinfinity.push_package', 12 | 'allowedDomains' => ['http://symmetricinfinity.com/push_package/', 'http://lvh.me'], 13 | 'urlFormatString' => 'http://symmetricinfinity.com/push_package/?%@=%@', 14 | 'authenticationToken' => 'nr2o1spn515949r5q54so22o8rq95575', 15 | 'webServiceURL' => 'https://api.zeropush.com/safari' 16 | } 17 | end 18 | 19 | let(:website_params_symbol_keys) do 20 | { 21 | websiteName: 'Push Package Test', 22 | websitePushID: 'web.com.symmetricinfinity.push_package', 23 | allowedDomains: ['http://symmetricinfinity.com/push_package/', 'http://lvh.me'], 24 | urlFormatString: 'http://symmetricinfinity.com/push_package/?%@=%@', 25 | authenticationToken: 'nr2o1spn515949r5q54so22o8rq95575', 26 | webServiceURL: 'https://api.zeropush.com/safari' 27 | } 28 | end 29 | 30 | let(:expected_manifest) do 31 | { 32 | 'icon.iconset/icon_128x128.png' => '28969578f1788252807a7d8205db269cb7699fa8', 33 | 'icon.iconset/icon_128x128@2x.png' => 'dd2bf0e3cb998467b0e5f5ae11675a454ad77601', 34 | 'icon.iconset/icon_16x16.png' => '48e791d0c88b92fae51ffa8363821857210fca01', 35 | 'icon.iconset/icon_16x16@2x.png' => '5a74d295cc09ca5896a4ceb7cac0d030cc85e894', 36 | 'icon.iconset/icon_32x32.png' => '8c71bc22f4cfe12ad98aabe94da6a70fe9f15741', 37 | 'icon.iconset/icon_32x32@2x.png' => '750e080d38efe1c227b2498f73f006007f3da24b', 38 | 'website.json' => '3eaed6475443b895a49e3a1220e547f2be90434a' 39 | } 40 | end 41 | 42 | let(:certificate) { File.open(fixture_path('self-signed.p12')) } 43 | 44 | describe 'the truth' do 45 | it 'should pass' do 46 | 'test'.must_equal('test') 47 | end 48 | end 49 | 50 | describe '.new' do 51 | it 'should check website_params' do 52 | lambda do 53 | PushPackage.new({}, iconset_path, certificate, 'testing') 54 | end.must_raise(PushPackage::InvalidParameterError) 55 | end 56 | 57 | describe 'website params with string keys' do 58 | 59 | it 'must have a valid iconset' do 60 | lambda do 61 | PushPackage.new(website_params, '/tmp', certificate, 'testing') 62 | end.must_raise(PushPackage::InvalidIconsetError) 63 | end 64 | 65 | it 'should support a certificate path' do 66 | lambda do 67 | PushPackage.new(website_params, iconset_path, nil) 68 | end.must_raise(ArgumentError) 69 | end 70 | 71 | it 'should support certificate path' do 72 | lambda do 73 | PushPackage.new(website_params, iconset_path, '/some/file.p12') 74 | end.must_raise(Errno::ENOENT) 75 | end 76 | 77 | it 'should support intermediate_cert path' do 78 | lambda do 79 | PushPackage.new(website_params, iconset_path, certificate, 'testing', '/some/file.crt') 80 | end.must_raise(Errno::ENOENT) 81 | end 82 | end 83 | 84 | describe 'website params with string keys' do 85 | it 'must have a valid iconset' do 86 | lambda do 87 | PushPackage.new(website_params_symbol_keys, '/tmp', certificate, 'testing') 88 | end.must_raise(PushPackage::InvalidIconsetError) 89 | end 90 | 91 | it 'should support a certificate path' do 92 | lambda do 93 | PushPackage.new(website_params_symbol_keys, iconset_path, nil) 94 | end.must_raise(ArgumentError) 95 | end 96 | 97 | it 'should support certificate path' do 98 | lambda do 99 | PushPackage.new(website_params_symbol_keys, iconset_path, '/some/file.p12') 100 | end.must_raise(Errno::ENOENT) 101 | end 102 | 103 | it 'should support intermediate_cert path' do 104 | lambda do 105 | PushPackage.new(website_params_symbol_keys, iconset_path, certificate, 'testing', '/some/file.crt') 106 | end.must_raise(Errno::ENOENT) 107 | end 108 | end 109 | end 110 | 111 | describe '#save' do 112 | let(:output_path) { '/tmp/pushPackage.zip' } 113 | let(:tmp_path) { '/tmp/pushPackage' } 114 | let(:extracted_package) do 115 | `unzip #{output_path} -d #{tmp_path}` 116 | Dir.glob(tmp_path + '/**/*').map do |d| 117 | d.gsub(tmp_path + '/', '') 118 | end 119 | end 120 | 121 | let(:push_package) { PushPackage.new(website_params, iconset_path, certificate, 'testing') } 122 | 123 | before do 124 | push_package.save(output_path) 125 | end 126 | 127 | after do 128 | File.delete(output_path) if File.exist?(output_path) 129 | FileUtils.rm_rf(tmp_path) 130 | end 131 | 132 | it 'should save to the file system' do 133 | File.exist?(output_path).must_equal true 134 | end 135 | 136 | it 'should save with a relative path' do 137 | push_package.save('pushPackage.zip') 138 | File.exist?('./pushPackage.zip').must_equal true 139 | File.delete('./pushPackage.zip') 140 | end 141 | 142 | it 'should save to a temporary path' do 143 | file = push_package.save 144 | File.exist?(file.path).must_equal true 145 | File.delete(file.path) 146 | end 147 | 148 | it 'supports using a Pathname for iconset_path' do 149 | iconset_pathname = Pathname.new(iconset_path) 150 | push_package = PushPackage.new(website_params, iconset_pathname, certificate, 'testing') 151 | file = push_package.save 152 | File.exist?(file.path).must_equal true 153 | File.delete(file.path) 154 | end 155 | 156 | it 'should return the file handle' do 157 | file = push_package.save(output_path) 158 | file.must_be_instance_of File 159 | File.exists?(file.path).must_equal true 160 | end 161 | 162 | it 'should be a zip file' do 163 | extracted_package.wont_be_empty 164 | $?.success?.must_equal true 165 | end 166 | 167 | it 'should have a valid manifest.json file' do 168 | extracted_package.must_include('manifest.json') 169 | manifest = JSON.load(File.open(tmp_path + '/manifest.json', 'r')) 170 | manifest.sort.must_equal(expected_manifest.sort) 171 | end 172 | 173 | it 'should have the iconset in icon.iconset subdirectory' do 174 | icons = extracted_package.select {|file| file.start_with?('icon.iconset/') } 175 | icons = icons.map {|i| i.gsub('icon.iconset/', '') } 176 | icons.sort.must_equal(PushPackage::REQUIRED_ICONSET_FILES.sort) 177 | end 178 | 179 | it 'should have a website.json file' do 180 | extracted_package.must_include('website.json') 181 | end 182 | 183 | it 'should have a valid signature' do 184 | extracted_package.must_include('signature') 185 | signature = File.read(tmp_path + '/signature') 186 | p7 = OpenSSL::PKCS7.new(signature) 187 | store = OpenSSL::X509::Store.new 188 | store.add_cert(push_package.p12.certificate) 189 | p7.verify( 190 | [push_package.p12.certificate], 191 | store, 192 | File.read(tmp_path + '/manifest.json'), 193 | OpenSSL::PKCS7::DETACHED 194 | ).must_equal true 195 | end 196 | 197 | it 'should have no extra certs in signature' do 198 | extracted_package.must_include('signature') 199 | signature = File.read(tmp_path + '/signature') 200 | p7 = OpenSSL::PKCS7.new(signature) 201 | p7.certificates().size.must_equal 1 202 | end 203 | 204 | describe 'when intermediate_cert given' do 205 | describe 'when intermediate_cert is a string' do 206 | let(:intermediate_cert) { fixture_path('intermediate.crt') } 207 | let(:push_package) { PushPackage.new(website_params, iconset_path, certificate, 'testing', intermediate_cert) } 208 | 209 | it 'should have one extra cert in signature' do 210 | extracted_package.must_include('signature') 211 | signature = File.read(tmp_path + '/signature') 212 | p7 = OpenSSL::PKCS7.new(signature) 213 | p7.certificates().size.must_equal 2 214 | end 215 | end 216 | describe 'when intermediate_cert is a Pathname' do 217 | let(:intermediate_cert) { Pathname.new(fixture_path('intermediate.crt')) } 218 | let(:push_package) { PushPackage.new(website_params, iconset_path, certificate, 'testing', intermediate_cert) } 219 | 220 | it 'should have one extra cert in signature' do 221 | extracted_package.must_include('signature') 222 | signature = File.read(tmp_path + '/signature') 223 | p7 = OpenSSL::PKCS7.new(signature) 224 | p7.certificates().size.must_equal 2 225 | end 226 | end 227 | end 228 | end 229 | end 230 | --------------------------------------------------------------------------------