├── spec ├── fixtures │ ├── file1.cer │ ├── file1.p12 │ ├── file1.cer.enc │ └── file1.p12.enc ├── spec_helper.rb ├── export_spec.rb └── crypt_spec.rb ├── .rspec ├── .gitignore ├── Gemfile ├── readme_assets ├── access.png └── example.gif ├── applescript ├── exportation.scpt └── exportation.applescript ├── .travis.yml ├── example.rb ├── Gemfile.lock ├── LICENSE ├── exportation.gemspec ├── bin └── exportation ├── lib └── exportation.rb └── README.md /spec/fixtures/file1.cer: -------------------------------------------------------------------------------- 1 | a_sample_cert 2 | -------------------------------------------------------------------------------- /spec/fixtures/file1.p12: -------------------------------------------------------------------------------- 1 | a_sample_p12 2 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --format documentation 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.gem 3 | 4 | example/ 5 | -------------------------------------------------------------------------------- /spec/fixtures/file1.cer.enc: -------------------------------------------------------------------------------- 1 | U2FsdGVkX18Z4EOQub0nhLeFnqFJ0PZJCw5wQGA0Asc= 2 | -------------------------------------------------------------------------------- /spec/fixtures/file1.p12.enc: -------------------------------------------------------------------------------- 1 | U2FsdGVkX19FwCGoFUiww8y7yNCsjeHi3v4vWbAXqUo= 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | # Specify your gem's dependencies in .gemspec 4 | gemspec 5 | -------------------------------------------------------------------------------- /readme_assets/access.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshdholtz/exportation/HEAD/readme_assets/access.png -------------------------------------------------------------------------------- /readme_assets/example.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshdholtz/exportation/HEAD/readme_assets/example.gif -------------------------------------------------------------------------------- /applescript/exportation.scpt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/joshdholtz/exportation/HEAD/applescript/exportation.scpt -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: ruby 2 | script: 3 | - bundle install --no-deployment 4 | - bundle exec rspec 5 | rvm: 6 | - 2.0.0 7 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | require 'bundler/setup' 2 | Bundler.setup 3 | 4 | require './lib/exportation.rb' 5 | 6 | RSpec.configure do |config| 7 | 8 | end 9 | -------------------------------------------------------------------------------- /example.rb: -------------------------------------------------------------------------------- 1 | require './lib/exportation' 2 | 3 | keychain = Exportation::Keychain.find_or_create_keychain('JoshChain', 'joshiscool', './example') 4 | puts "Keychain - #{keychain}" 5 | 6 | keychain.import_certificate './example/dist.cer' 7 | keychain.import_private_key './example/dist.p12', '' 8 | 9 | keychain.unlock! 10 | 11 | keychain.add_to_keychain_list! 12 | 13 | # Do some iOS building stuff here 14 | 15 | keychain.remove_keychain_from_list! 16 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | exportation (0.2.3) 5 | colorize (~> 0.7) 6 | commander (~> 4.3) 7 | dotenv (~> 0.7) 8 | terminal-notifier (~> 1.6) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | colorize (0.7.5) 14 | commander (4.3.0) 15 | highline (~> 1.6.11) 16 | diff-lcs (1.2.5) 17 | dotenv (0.11.1) 18 | dotenv-deployment (~> 0.0.2) 19 | dotenv-deployment (0.0.2) 20 | highline (1.6.21) 21 | rspec (3.1.0) 22 | rspec-core (~> 3.1.0) 23 | rspec-expectations (~> 3.1.0) 24 | rspec-mocks (~> 3.1.0) 25 | rspec-core (3.1.7) 26 | rspec-support (~> 3.1.0) 27 | rspec-expectations (3.1.2) 28 | diff-lcs (>= 1.2.0, < 2.0) 29 | rspec-support (~> 3.1.0) 30 | rspec-mocks (3.1.3) 31 | rspec-support (~> 3.1.0) 32 | rspec-support (3.1.2) 33 | terminal-notifier (1.6.2) 34 | 35 | PLATFORMS 36 | ruby 37 | 38 | DEPENDENCIES 39 | exportation! 40 | rspec 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Josh Holtz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /exportation.gemspec: -------------------------------------------------------------------------------- 1 | Gem::Specification.new do |s| 2 | s.name = 'exportation' 3 | s.version = '0.2.4' 4 | s.date = '2015-04-04' 5 | s.summary = "CLI tool of easy exporting, encrypting, and decrypting of certificates and private keys" 6 | s.description = "CLI tool of easy exporting, encrypting, and decrypting of certificates and private keys using Keychain Acess and openssl" 7 | s.authors = ["Josh Holtz"] 8 | s.email = 'me@joshholtz.com' 9 | s.homepage = 10 | 'https://github.com/joshdholtz/exportation' 11 | s.license = 'MIT' 12 | 13 | s.files = Dir["lib/**/*"] + %w{ bin/exportation README.md LICENSE applescript/exportation.scpt } 14 | s.executables = s.files.grep(%r{^bin/}) { |f| File.basename(f) } 15 | s.test_files = s.files.grep(%r{^(test|spec|features)/}) 16 | s.require_paths = ["lib"] 17 | 18 | s.add_runtime_dependency 'dotenv', ['>= 0.7', '< 3.0'] 19 | s.add_runtime_dependency 'commander', '~> 4.3' 20 | s.add_runtime_dependency 'colorize', '~> 0.7' 21 | s.add_runtime_dependency 'terminal-notifier', '~> 1.6' 22 | 23 | s.add_development_dependency "rspec" 24 | end 25 | -------------------------------------------------------------------------------- /spec/export_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | describe Exportation::Export do 3 | 4 | describe "#run_command" do 5 | 6 | it "works with all parameters with relative path" do 7 | export = Exportation::Export.new( 8 | path: "path", 9 | filename: "filename", 10 | name: "name", 11 | password: "password" 12 | ) 13 | bash = export.run_command 14 | 15 | path = File.join Dir.pwd, "path" 16 | expect(bash).to eq("osascript #{Exportation.applescript_path} " + 17 | "\"#{path}/\" " + 18 | "\"filename\" " + 19 | "\"name\" " + 20 | "\"password\"") 21 | end 22 | 23 | it "works with all parameters with absolute path" do 24 | export = Exportation::Export.new( 25 | path: "/path", 26 | filename: "filename", 27 | name: "name", 28 | password: "password" 29 | ) 30 | bash = export.run_command 31 | 32 | expect(bash).to eq("osascript #{Exportation.applescript_path} " + 33 | "\"/path/\" " + 34 | "\"filename\" " + 35 | "\"name\" " + 36 | "\"password\"") 37 | end 38 | 39 | it "works with required parameters" do 40 | export = Exportation::Export.new( 41 | name: "name", 42 | ) 43 | bash = export.run_command 44 | 45 | expect(bash).to eq("osascript #{Exportation.applescript_path} " + 46 | "\"#{Dir.pwd}/\" " + 47 | "\"exported\" " + 48 | "\"name\" " + 49 | "\"\"") 50 | end 51 | 52 | it "raises with missing parameters" do 53 | export = Exportation::Export.new({}) 54 | 55 | expect { 56 | bash = export.run_command 57 | }.to raise_exception("name is required") 58 | end 59 | 60 | end 61 | 62 | end 63 | -------------------------------------------------------------------------------- /applescript/exportation.applescript: -------------------------------------------------------------------------------- 1 | on run arguments set cwd to item 1 of arguments set saveas to item 2 of arguments set profile_name to item 3 of arguments set pkey_password to item 4 of arguments do shell script ("rm -f " & cwd & saveas & ".p12") do shell script ("rm -f " & cwd & saveas & ".cer") do shell script "open -a /Applications/Utilities/Keychain\\ Access.app $1" delay 1 tell application "Keychain Access" to activate delay 1 tell application "System Events" tell process "Keychain Access" select row 1 of outline 1 of scroll area 1 of splitter group 1 of splitter group 1 of window "Keychain Access" delay 1 select row 4 of outline 1 of scroll area 2 of splitter group 1 of splitter group 1 of window "Keychain Access" delay 1 set hehe to outline 1 of scroll area 1 of splitter group 1 of window "Keychain Access" -- Create private key repeat with aRow in row of hehe set hoho to value of text field 1 of first UI element of aRow --set hoho to hoho2's first item if hoho contains profile_name then tell text field 1 of first UI element of aRow to perform action "AXShowMenu" delay 1 repeat 4 times key code 125 (* down arrow *) end repeat keystroke return (* confirm choice of menu item *) end if end repeat delay 1 set shh to sheet 1 of window "Keychain Access" set value of text field 1 of shh to "~" delay 2 set value of text field 1 of sheet 1 of shh to ("" & cwd & saveas) delay 2 click button "Go" of sheet 1 of shh delay 2 click pop up button 1 of group 1 of shh delay 0.5 click (menu item 1 where its name starts with "Person") of menu 1 of pop up button 1 of group 1 of shh delay 0.5 click button "Save" of shh delay 1 tell application "System Events" to tell process "SecurityAgent" set value of text field 1 of window 1 to pkey_password set value of text field 2 of window 1 to pkey_password delay 1 click button "OK" of window 1 end tell -- Create cert repeat with aRow in row of hehe set hoho to value of text field 1 of first UI element of aRow --set hoho to hoho2's first item if hoho contains profile_name then tell text field 1 of first UI element of aRow to perform action "AXShowMenu" delay 1 repeat 4 times key code 125 (* down arrow *) end repeat keystroke return (* confirm choice of menu item *) end if end repeat delay 1 set shh to sheet 1 of window "Keychain Access" set value of text field 1 of shh to saveas click pop up button 1 of group 1 of shh delay 0.5 click (menu item 1 where its name starts with "Cert") of menu 1 of pop up button 1 of group 1 of shh delay 0.5 click button "Save" of shh end tell end tell end run -------------------------------------------------------------------------------- /spec/crypt_spec.rb: -------------------------------------------------------------------------------- 1 | require 'spec_helper' 2 | describe Exportation::Crypter do 3 | 4 | describe "Encrypt" do 5 | 6 | describe "#run_commands" do 7 | 8 | it "works with all parameters" do 9 | cypter = Exportation::Crypter.new( 10 | files: ["spec/fixtures/file1.cer", "spec/fixtures/file1.p12"], 11 | password: "password", 12 | output: "output" 13 | ) 14 | commands = cypter.run_commands :en 15 | 16 | expect(commands[0]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.cer -out output/file1.cer.enc -a") 17 | expect(commands[1]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.p12 -out output/file1.p12.enc -a") 18 | end 19 | 20 | it "works with required parameters" do 21 | cypter = Exportation::Crypter.new( 22 | files: ["spec/fixtures/file1.cer", "spec/fixtures/file1.p12"], 23 | password: "password", 24 | ) 25 | commands = cypter.run_commands :en 26 | 27 | expect(commands[0]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.cer -out ./spec/fixtures/file1.cer.enc -a") 28 | expect(commands[1]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.p12 -out ./spec/fixtures/file1.p12.enc -a") 29 | end 30 | 31 | it "raises with missing parameters" do 32 | cypter = Exportation::Crypter.new( 33 | files: ["spec/fixtures/file1.cer", "spec/fixtures/file1.p12"], 34 | ) 35 | 36 | expect { 37 | commands = cypter.run_commands :en 38 | }.to raise_exception("password is required") 39 | end 40 | 41 | end 42 | 43 | end 44 | 45 | describe "Decrypt" do 46 | 47 | describe "#run_commands" do 48 | 49 | it "works with all parameters" do 50 | cypter = Exportation::Crypter.new( 51 | files: ["spec/fixtures/file1.cer.enc", "spec/fixtures/file1.p12.enc"], 52 | password: "password", 53 | output: "output" 54 | ) 55 | commands = cypter.run_commands :de 56 | 57 | expect(commands[0]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.cer.enc -out output/file1.cer -a -d") 58 | expect(commands[1]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.p12.enc -out output/file1.p12 -a -d") 59 | end 60 | 61 | it "works with required parameters" do 62 | cypter = Exportation::Crypter.new( 63 | files: ["spec/fixtures/file1.cer.enc", "spec/fixtures/file1.p12.enc"], 64 | password: "password", 65 | ) 66 | commands = cypter.run_commands :de 67 | 68 | expect(commands[0]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.cer.enc -out ./spec/fixtures/file1.cer -a -d") 69 | expect(commands[1]).to eq("openssl aes-256-cbc -k \"password\" -in ./spec/fixtures/file1.p12.enc -out ./spec/fixtures/file1.p12 -a -d") 70 | end 71 | 72 | it "raises with missing parameters" do 73 | cypter = Exportation::Crypter.new( 74 | files: ["spec/fixtures/file1.cer.enc", "spec/fixtures/file1.p12.enc"], 75 | ) 76 | 77 | expect { 78 | commands = cypter.run_commands :de 79 | }.to raise_exception("password is required") 80 | end 81 | 82 | end 83 | 84 | end 85 | 86 | end 87 | -------------------------------------------------------------------------------- /bin/exportation: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | $LOAD_PATH.push File.expand_path('../../lib', __FILE__) 4 | 5 | require 'exportation' 6 | 7 | require 'rubygems' 8 | require 'commander' 9 | require 'colorize' 10 | require 'terminal-notifier' 11 | 12 | class ExportationApplication 13 | include Commander::Methods 14 | 15 | def is_empty?(str) 16 | str.nil? || str.length == 0 17 | end 18 | 19 | def run 20 | program :name, 'Exportation' 21 | program :version, '0.1.0' 22 | program :description, 'CLI tool of easy exporting, encrypting, and decrypting of certificates and private keys.' 23 | 24 | command :export do |c| 25 | c.syntax = 'exportation export [options]' 26 | c.description = 'Exports certificate and private key from Keychain Access' 27 | c.option '--path STRING', String, 'Path to save certificate and private key' 28 | c.option '--filename STRING', String, 'File name to save certificate and private key as' 29 | c.option '--name STRING', String, 'Common name of the cert as it is displayed in Keychain Access' 30 | c.option '--password STRING', String, 'Password to use for the private key' 31 | c.option '--noprompt', 'Do not prompt for missing options' 32 | c.action do |args, options| 33 | 34 | begin 35 | unless options.noprompt 36 | options.name = ask("Name: ") unless options.name 37 | options.path = ask("Path to save to (default: './'): ") unless options.path 38 | options.filename = ask("Filename to save as (default: 'export'): ") unless options.filename 39 | options.password = ask("Password for private key (default: ''): ") unless options.password 40 | end 41 | 42 | raise "'name' is required" if is_empty?(options.name) 43 | log "Info".blue, "Take all hands off your computer! exportation is going to take control of 'Keychain Access'".blue 44 | 45 | Exportation::Export.new( 46 | path: options.path, 47 | filename: options.filename, 48 | name: options.name, 49 | password: options.password 50 | ).run 51 | 52 | TerminalNotifier.notify('Certificate and private key exported!', title: 'exportation') 53 | rescue Exception => e 54 | TerminalNotifier.notify('Export failed :(', title: 'exportation') 55 | log "Error".red, e.message.red 56 | end 57 | 58 | end 59 | end 60 | 61 | command :encrypt do |c| 62 | c.syntax = 'exportation encrypt [file_path1] [file_path2] ... [options]' 63 | c.description = 'Encrypts certificates, private keys, and provisioning profiles with AES' 64 | c.option '--password STRING', String, 'Password to use for the encryption' 65 | c.option '--output STRING', String, 'Output directory for files (defaults to where original files are located)' 66 | c.option '--force', 'Forces all files to decrypted (will encrypt decrypted files)' 67 | c.option '--noprompt', 'Do not prompt for missing options' 68 | c.action do |args, options| 69 | options.default output: nil 70 | 71 | begin 72 | unless options.noprompt 73 | options.password = ask("Password: ") unless options.password 74 | options.output= ask("Output path (default: location of unencrypted files): ") unless options.output 75 | end 76 | 77 | raise "no files were passed through arguments" if args.empty? 78 | raise "'password' is required" if is_empty?(options.password) 79 | 80 | Exportation::Crypter.new( 81 | files: args, 82 | password: options.password, 83 | output: options.output 84 | ).run :en, options.force 85 | rescue Exception => e 86 | log "Error".red, e.message.red 87 | end 88 | 89 | end 90 | end 91 | 92 | command :decrypt do |c| 93 | c.syntax = 'exportation decrypt [file_path1] [file_path2] ... [options]' 94 | c.description = 'Decrypts certificates, private keys, and provisioning profiles with AES' 95 | c.option '--password STRING', String, 'Password to use for the decryption' 96 | c.option '--output STRING', String, 'Output directory for files (defaults to where original files are located)' 97 | c.option '--force', 'Forces all files to decrypted (will encrypt decrypted files)' 98 | c.option '--noprompt', 'Do not prompt for missing options' 99 | c.action do |args, options| 100 | options.default output: nil 101 | 102 | begin 103 | unless options.noprompt 104 | options.password = ask("Password: ") unless options.password 105 | options.output= ask("Output path (default: location of encrypted files): ") unless options.output 106 | end 107 | 108 | raise "no files were passed through arguments" if args.empty? 109 | raise "'password' is required" if is_empty?(options.password) 110 | 111 | Exportation::Crypter.new( 112 | files: args, 113 | password: options.password, 114 | output: options.output 115 | ).run :de, options.force 116 | rescue Exception => e 117 | log "Error".red, e.message.red 118 | end 119 | 120 | end 121 | end 122 | 123 | run! 124 | end 125 | end 126 | 127 | ExportationApplication.new.run 128 | -------------------------------------------------------------------------------- /lib/exportation.rb: -------------------------------------------------------------------------------- 1 | module Exportation 2 | 3 | def self.gem_path 4 | if Gem::Specification::find_all_by_name('exportation').any? 5 | return Gem::Specification.find_by_name('exportation').gem_dir 6 | else 7 | return './' 8 | end 9 | end 10 | 11 | def self.applescript_path 12 | File.join(gem_path, 'applescript', 'exportation.scpt') 13 | end 14 | 15 | def self.is_empty?(str) 16 | str.nil? || str.length == 0 17 | end 18 | 19 | class Export 20 | 21 | attr_accessor :path, :filename, :name, :password 22 | 23 | def initialize(options) 24 | @path = options[:path] 25 | @filename = options[:filename] 26 | @name = options[:name] 27 | @password = options[:password] 28 | 29 | @path = './' if Exportation.is_empty?(@path) 30 | @filename = 'exported' if Exportation.is_empty?(@filename) 31 | @password = '' if Exportation.is_empty?(@password) 32 | end 33 | 34 | def run 35 | bash = run_command 36 | puts "Running: #{bash}" 37 | `#{bash}` 38 | end 39 | 40 | def run_command 41 | raise "name is required" if Exportation.is_empty?(@name) 42 | 43 | abs_path = File.expand_path path 44 | abs_path += '/' unless abs_path.end_with? '/' 45 | 46 | bash = "osascript #{Exportation.applescript_path} " + 47 | "\"#{abs_path}\" " + 48 | "\"#{filename}\" " + 49 | "\"#{name}\" " + 50 | "\"#{password}\"" 51 | end 52 | 53 | end 54 | 55 | class Crypter 56 | 57 | attr_accessor :files, :password, :output 58 | 59 | def initialize(options) 60 | @files = options[:files] 61 | @password = options[:password] 62 | @output = options[:output] 63 | end 64 | 65 | def run(crypt, force = false) 66 | run_commands(crypt, force).each do |bash| 67 | puts "Running: #{bash}" 68 | `#{bash}` 69 | end 70 | end 71 | 72 | def run_commands(crypt, force = false) 73 | raise "password is required" if Exportation.is_empty?(@password) 74 | 75 | unless force 76 | if crypt == :en 77 | # Verify files are not already encrypted 78 | files.each do |file| 79 | raise 'Some of these files may be encrypted (ending with .enc)' if file.end_with? '.enc' 80 | end 81 | elsif crypt == :de 82 | # Verify files are not already decrypted 83 | files.each do |file| 84 | raise 'Some of these files may be encrypted (ending with .enc)' unless file.end_with? '.enc' 85 | end 86 | end 87 | end 88 | 89 | # Does the stuff 90 | commands = [] 91 | files.each do |file| 92 | file = './' + file unless file.start_with? '/' 93 | if File.exists? file 94 | output_file = file 95 | if !output.nil? && output.length > 0 96 | output_file = File.join(output, File.basename(file)) 97 | end 98 | 99 | decrypt = '' 100 | if crypt == :en 101 | output_file += '.enc' 102 | elsif crypt == :de 103 | decrypt = ' -d' 104 | output_file = output_file.gsub('.enc','') 105 | end 106 | 107 | commands << "openssl aes-256-cbc -k \"#{password}\" -in #{file} -out #{output_file} -a#{decrypt}" 108 | else 109 | raise "File does not exist - #{file}" 110 | end 111 | end 112 | 113 | commands 114 | end 115 | 116 | end 117 | 118 | class Keychain 119 | 120 | attr_accessor :path, :password 121 | 122 | def initialize(options) 123 | @path = options[:path] 124 | @password = options[:password] 125 | end 126 | 127 | def self.find_or_create_keychain(name, password, output_directory='./') 128 | path = chain_path(name, output_directory) 129 | 130 | unless File.exists? path 131 | `security create-keychain -p '#{password}' #{path}` 132 | end 133 | 134 | Keychain.new(path: path, password: password) 135 | end 136 | 137 | def self.login_keychain(password) 138 | path = `security login-keychain`.strip 139 | Keychain.new(path: path, password: password) 140 | end 141 | 142 | def self.list_keychains 143 | # Gets a list of all the user's keychains in an array 144 | # The keychain are paths wrapped in double quotes 145 | (`security list-keychains -d user`).scan(/(?:\w|"[^"]*")+/) 146 | end 147 | 148 | def import_certificate(cer_path) 149 | # Imports a certificate into the keychain 150 | `security import #{cer_path} -k #{@path} -T /usr/bin/codesign` 151 | end 152 | 153 | def import_private_key(key_path, password) 154 | # Imports a private key into the keychain 155 | `security import #{key_path} -k #{@path} -P '#{password}' -T /usr/bin/codesign` 156 | end 157 | 158 | def unlock!(seconds=3600) 159 | # Unlocks the keychain 160 | `security unlock-keychain -p '#{@password}' #{@path}` 161 | `security -v set-keychain-settings -t #{seconds} -l #{@path}` 162 | end 163 | 164 | def add_to_keychain_list! 165 | # Adds the keychain to the search list 166 | keychains = (Keychain.list_keychains - ["\"#{@path}\""]).join(' ') 167 | `security list-keychains -d user -s #{keychains} \"#{@path}\"` 168 | end 169 | 170 | def remove_keychain_from_list! 171 | # Removes the keychain from the search list 172 | keychains = (Keychain.list_keychains - ["\"#{@path}\""]).join(' ') 173 | `security list-keychains -d user -s #{keychains}` 174 | end 175 | 176 | private 177 | 178 | def self.chain_path(name, output_directory='./') 179 | output_directory = File.expand_path output_directory 180 | File.join(output_directory, "#{name}.keychain") 181 | end 182 | 183 | # Creates keychain with cert and private key (this is how Xcode knows how to sign things) 184 | # sh "security create-keychain -p '#{keychain_password}' #{chain_path}" 185 | # sh "security import ./Certs/apple.cer -k #{chain_path} -T /usr/bin/codesign" 186 | # sh "security import ../build/unenc/dist.cer -k #{chain_path} -T /usr/bin/codesign" 187 | # sh "security import ../build/unenc/dist.p12 -k #{chain_path} -P '#{private_key_password}' -T /usr/bin/codesign" 188 | 189 | # sh "security unlock-keychain -p '#{keychain_password}' #{chain_path}" 190 | # sh "security -v set-keychain-settings -t 3600 -l #{chain_path}" 191 | # 192 | # # Add keychain to list (this is literally the key to getting this all working) 193 | # sh "security list-keychains -d user -s #{ENV['ORIGINAL_KEYCHAINS']} \"#{chain_path}\"" 194 | 195 | # Reset keychains to what was originally set for user 196 | # sh "security list-keychains -d user -s #{ENV['ORIGINAL_KEYCHAINS']}" 197 | 198 | end 199 | 200 | end 201 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # exportation (DO NOT USE) 2 | [![Twitter: @joshdholtz](https://img.shields.io/badge/contact-@joshdholtz-blue.svg?style=flat)](https://twitter.com/joshdholtz) 3 | [![License](http://img.shields.io/badge/license-MIT-green.svg?style=flat)](https://github.com/joshdholtz/exportation/blob/master/LICENSE) 4 | [![Gem](https://img.shields.io/gem/v/exportation.svg?style=flat)](http://rubygems.org/gems/exportation) 5 | [![Build Status](https://img.shields.io/travis/joshdholtz/exportation/master.svg?style=flat)](https://travis-ci.org/joshdholtz/exportation) 6 | 7 | CLI tool (and Ruby API) of easy exporting, encrypting, and decrypting of certificates and private keys. It can also add certificates and private keys to an existing or new keychain :grinning: 8 | 9 | - [Installation](#installation) 10 | - [CLI Commands](#cli-commands) 11 | - [Ruby API](#ruby-api) 12 | - [Fastlane Integration](#fastlane-integration) 13 | 14 | **Important:** The `export` command will take control of your "Keychain Access" app so keep all hands off your computer while that command runs 15 | 16 | ### Example usage (with prompt) 17 | ```sh 18 | $: exportation export 19 | Name: RokkinCat LLC 20 | Path to save to (default: './'): ./examples 21 | Filename to save as (default: 'export'): dist 22 | Password for private key (default: ''): shhh 23 | Info Take all hands off your computer! exportation is going to take control of 'Keychain Access' 24 | ``` 25 | 26 | ![Example of exportation](readme_assets/example.gif) 27 | 28 | ### Why 29 | - Export **and** encrypt certificates **and** private keys **into** repos 30 | - CI tools (may need these to distrubute builds to Apple TestFlight Beta :apple:) 31 | - For other developers (for when you are on vacation and they need to make a distribution build :grimacing:) 32 | 33 | ### How 34 | - Makes use of AppleScript to control "Keychain Access" 35 | - Opens "Keychain Access" 36 | - Selects "Login" and "Certificates" for the left side 37 | - Searches for the certificate you are looking for from `--name` 38 | - Exports private key 39 | - Right clicks it and selects export 40 | - Changes the save path to your current directory (by default) or what was passed in through `--path` 41 | - Saves the file to `exported.p12` 42 | - Enters a blank password (by default) or what was passed in through `--password` 43 | - Saves 44 | - Exports certificate 45 | - Right clicks it and selects export 46 | - Changes the save path to your current directory (by default) or what was passed in through `--path` 47 | - Saves the file to `exported.cer` 48 | - Saves 49 | 50 | ### Features in progress 51 | - Integrate with [fastlane](https://github.com/KrauseFx/fastlane) :rocket: 52 | 53 | ### Caveats 54 | - Some phases of the script might run slow due to using AppleScript 55 | - Initial load may take up to 5ish seconds 56 | - Waiting for private key password to be entered may take up to 7ish seconds 57 | - May need to give "Accessibility" access to **ARDAgent** and **Terminal** 58 | 59 | ## Installation 60 | 61 | ### Install gem 62 | ```sh 63 | gem install exportation 64 | ``` 65 | 66 | ### Give "Accessibility" access 67 | - Open up "Security & Privacy" preferences 68 | - Select "Accessibility" 69 | - Add **ARDAgent** and **Terminal** 70 | - Click "+" 71 | - Press CMD+SHIFT+G (to go to specific folder) 72 | - **ARDAgent** should be under `/System/Library/CoreServices/RemoteManagement/` 73 | - **Terminal** should be under `/Applications/Utilities/` 74 | 75 | ![](readme_assets/access.png) 76 | **You won't need to give Heroes, Script Editor, or Steam permissions for exportation** :wink: 77 | 78 | ## CLI Commands 79 | Exportation has three different commands: `export`, `encrypt`, and `decrypt`. 80 | 81 | ### Export from Keychain Access 82 | **Be lazy!** `export` uses AppleScript to control the "Keychain Access" app to export a certificate and private to be used for CI (continuous integration) or for other developers. 83 | ```sh 84 | exportation export --name "Your Company LLC" 85 | ``` 86 | 87 | ### Encrypting certificate and private key 88 | **Be safe!** `encrypt` does exactly what it says - it encrypts. It uses AES-256 to encrypt your certificate, private keys and provisioning profiles (any file really) to store safely in your repository for CIs or other developers to access. All files will be appened with a `.enc` extension. 89 | ```sh 90 | exportation encrypt exported.cer exported.p12 --password dudethis 91 | ``` 92 | 93 | ### Decrypting certificate and private key 94 | **Be awesome!** `decrypt` decrypts your encrypted files to use on your CI or for other developers to install. *BE CAREFUL TO NOT COMMIT THESE BACK INTO YOUR REPO* 95 | ```sh 96 | exportation decrypt exported.cer.enc exported.p12.enc --password dudethis 97 | ``` 98 | 99 | ## Ruby API 100 | 101 | ### Exportation::Export 102 | ```ruby 103 | Exportation::Export.new( 104 | path: "/path/to/export/to", 105 | filename: "base_exported_file_name", #dist.cer and dist.p12 106 | name: "YourCompany LLC", 107 | password: "shhhh" 108 | ).run 109 | ``` 110 | 111 | ### Exportation::Crypter 112 | 113 | #### Encrypt 114 | ```ruby 115 | Exportation::Crypter.new( 116 | files: ["dist.cer","dist.p12"], 117 | password: "shhhh", 118 | output: "./" 119 | ).run :en 120 | ``` 121 | 122 | #### Decrypt 123 | ```ruby 124 | Exportation::Crypter.new( 125 | files: ["dist.cer.enc","dist.p12.enc"], 126 | password: "shhhh", 127 | output: "./" 128 | ).run :de 129 | ``` 130 | 131 | ### Exportation::Keychain 132 | ```ruby 133 | # Create keychain - name of chain, password, output directory 134 | keychain = Exportation::Keychain.find_or_create_keychain('JoshChain', 'joshiscool', './example') 135 | 136 | # Get login keychain 137 | keychain = Exportation::Keychain.login_keychain("password") 138 | 139 | # Import a certificate into keychain 140 | keychain.import_certificate './example/dist.cer' 141 | 142 | # Import a private key into keychain 143 | keychain.import_private_key './example/dist.p12', 'da_password' 144 | 145 | # Unlock keychain 146 | keychain.unlock! 147 | 148 | # Adds keychain to search list 149 | keychain.add_to_keychain_list! 150 | 151 | # Removes keychain from search list 152 | keychain.remove_keychain_from_list! 153 | ``` 154 | 155 | ## Fastlane integration 156 | In this `fastlane` integration, I store my encrypted certificate and private key in the `circle` directory (because I'm using [CircleCI](http://circleci.com). 157 | 158 | The `enc_cert_and_key` lane runs on my local machine where I will export and encrypt the certificate and private key. 159 | 160 | The `ci_build` *can run* on my local machine but is **meant to run** on CircleCI (or TravisCI). This lane decrypts the certifivate and private key from the `circle` directory and puts the decrypted files in the `build/unenc` directory. 161 | 162 | ### Exporting and encrypting 163 | This lane exports the certificate and private key by controling keychain access, encrypts the files, and the removes the unencrypted files. 164 | ```ruby 165 | lane :enc_cert_and_key do 166 | require 'exportation' 167 | 168 | # Runs keychain to export cert and private key 169 | Exportation::Export.new( 170 | path: "../circle", 171 | filename: "dist", 172 | name: "RokkinCat LLC", 173 | password: ENV['PKEY_PASSWORD'] 174 | ).run 175 | 176 | # Encrypts cert and private key for repo storage 177 | Exportation::Crypter.new( 178 | files: ["../circle/dist.cer", "../circle/dist.p12"], 179 | password: ENV['ENC_PASSWORD'], 180 | output: "../circle/" 181 | ).run :en 182 | 183 | # Removes unencrypted cert and private key 184 | sh "rm -f ../circle/dist.cer" 185 | sh "rm -f ../circle/dist.p12" 186 | 187 | end 188 | ``` 189 | 190 | ### Decrypting and importing 191 | This lane decrypts the certificate and private key, creates the keychain, and imports the certificate and private key into the keychain. 192 | It then uses that keychain the `xcodebuild` action to build an archive of the app. 193 | ```ruby 194 | lane :ci_build do 195 | require 'exportation' 196 | 197 | enc_password = ENV['ENC_PASSWORD'] 198 | keychain_password = ENV['KEYCHAIN_PASSWORD'] 199 | private_key_password = ENV['PKEY_PASSWORD'] 200 | 201 | # Cleaning house and making directories 202 | sh "rm -rf ../build" 203 | sh "mkdir ../build" 204 | sh "mkdir ../build/unenc" 205 | 206 | # Decrypting cert and private key 207 | Exportation::Crypter.new( 208 | files: ["../circle/dist.cer.enc", "../circle/dist.p12.enc"], 209 | password: enc_password, 210 | output: "../build/unenc/" 211 | ).run :de 212 | 213 | # Creating keychain to use for xcodebuild 214 | keychain = Exportation::Keychain.find_or_create_keychain 'ios-build', keychain_password, '../build' 215 | 216 | # Importing the Apple certificate and the uncrypted cert and private key 217 | keychain.import_certificate '../circle/apple.cer' 218 | keychain.import_certificate '../build/unenc/dist.cer' 219 | keychain.import_private_key '../build/unenc/dist.p12', private_key_password 220 | 221 | # Unlocking keychain (defaults to 1 hour) and adds keychain to user search list 222 | keychain.unlock! 223 | keychain.add_to_keychain_list! 224 | 225 | # Building archive 226 | xcodebuild( 227 | clean: true, 228 | archive: true, 229 | archive_path: './build/YourApp.xcarchive', 230 | workspace: ENV['WORKSPACE'], 231 | scheme: ENV['SCHEME'], 232 | configuration: 'Release', 233 | sdk: 'iphoneos', 234 | keychain: keychain.path 235 | ) 236 | 237 | # Building IPA 238 | xcodebuild( 239 | export_archive: true, 240 | export_path: './build/YourApp' 241 | ) 242 | 243 | # Send to HockeyApp 244 | hockey({ 245 | api_token: ENV['HOCKEYAPP_API_TOKEN'], 246 | }) 247 | 248 | # Cleaning house again 249 | sh "rm -rf ../circle/unenc" 250 | keychain.remove_keychain_from_list! 251 | 252 | end 253 | ``` 254 | 255 | ## Using the internals 256 | 257 | ### Compiling and running the AppleScript directly 258 | *You shouldn't ever have to do this unless I messed stuff up :)* 259 | 260 | ### Compile 261 | ```sh 262 | osacompile -o applescript/exportation.scpt applescript/exportation.applescript 263 | ``` 264 | 265 | ### Run 266 | Always put all for arguments in strings because I don't do AppleScript well :grimacing: 267 | ```sh 268 | osascript applescript/exportation.scpt "~/directory_you_want_to_export_to/" "dist" "iPhone Distribution: Your Company LLC" "thepassword" 269 | ``` 270 | 271 | ## Author 272 | 273 | Josh Holtz, me@joshholtz.com, [@joshdholtz](https://twitter.com/joshdholtz) 274 | 275 | ## License 276 | 277 | exportation is available under the MIT license. See the LICENSE file for more info. 278 | --------------------------------------------------------------------------------