shhh 1.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +25 -0
  3. data/.gitignore +12 -0
  4. data/.rspec +2 -0
  5. data/.rubocop.yml +1156 -0
  6. data/.travis.yml +13 -0
  7. data/Gemfile +6 -0
  8. data/LICENSE +22 -0
  9. data/MANAGING-KEYS.md +67 -0
  10. data/README.md +328 -0
  11. data/Rakefile +13 -0
  12. data/bin/console +14 -0
  13. data/bin/setup +8 -0
  14. data/exe/keychain +38 -0
  15. data/exe/shhh +8 -0
  16. data/lib/shhh/app/args.rb +17 -0
  17. data/lib/shhh/app/cli.rb +150 -0
  18. data/lib/shhh/app/commands/command.rb +68 -0
  19. data/lib/shhh/app/commands/delete_keychain_item.rb +17 -0
  20. data/lib/shhh/app/commands/encrypt_decrypt.rb +26 -0
  21. data/lib/shhh/app/commands/generate_key.rb +41 -0
  22. data/lib/shhh/app/commands/open_editor.rb +96 -0
  23. data/lib/shhh/app/commands/print_key.rb +18 -0
  24. data/lib/shhh/app/commands/show_examples.rb +64 -0
  25. data/lib/shhh/app/commands/show_help.rb +16 -0
  26. data/lib/shhh/app/commands/show_version.rb +14 -0
  27. data/lib/shhh/app/commands.rb +55 -0
  28. data/lib/shhh/app/input/handler.rb +35 -0
  29. data/lib/shhh/app/keychain.rb +135 -0
  30. data/lib/shhh/app/output/file.rb +23 -0
  31. data/lib/shhh/app/output/stdout.rb +11 -0
  32. data/lib/shhh/app/private_key/base64_decoder.rb +17 -0
  33. data/lib/shhh/app/private_key/decryptor.rb +50 -0
  34. data/lib/shhh/app/private_key/detector.rb +34 -0
  35. data/lib/shhh/app/private_key/handler.rb +34 -0
  36. data/lib/shhh/app.rb +45 -0
  37. data/lib/shhh/cipher_handler.rb +45 -0
  38. data/lib/shhh/configuration.rb +23 -0
  39. data/lib/shhh/data/decoder.rb +28 -0
  40. data/lib/shhh/data/encoder.rb +24 -0
  41. data/lib/shhh/data/wrapper_struct.rb +43 -0
  42. data/lib/shhh/data.rb +23 -0
  43. data/lib/shhh/errors.rb +27 -0
  44. data/lib/shhh/extensions/class_methods.rb +12 -0
  45. data/lib/shhh/extensions/instance_methods.rb +114 -0
  46. data/lib/shhh/version.rb +3 -0
  47. data/lib/shhh.rb +73 -0
  48. data/shhh.gemspec +33 -0
  49. metadata +249 -0
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env ruby
2
+ require 'slop'
3
+ require 'shhh'
4
+ require 'colored2'
5
+ require 'yaml'
6
+ require 'openssl'
7
+ require 'shhh/app'
8
+ require 'shhh/errors'
9
+ require 'shhh/app/commands'
10
+ require 'shhh/app/keychain'
11
+ require 'shhh/app/private_key/handler'
12
+ require 'highline'
13
+
14
+ require_relative 'output/file'
15
+ require_relative 'output/stdout'
16
+
17
+ module Shhh
18
+ module App
19
+ class CLI
20
+
21
+ attr_accessor :opts, :output_proc, :print_proc, :write_proc,
22
+ :action, :password, :key
23
+
24
+ def initialize(argv)
25
+ begin
26
+ self.opts = parse(argv.dup)
27
+ rescue StandardError => e
28
+ error exception: e
29
+ return
30
+ end
31
+ configure_color(argv)
32
+ select_output_stream
33
+ self.action = { opts[:encrypt] => :encr, opts[:decrypt] => :decr }[true]
34
+ end
35
+
36
+ def run
37
+ return Shhh::App.exit_code if Shhh::App.exit_code != 0
38
+
39
+ self.key = PrivateKey::Handler.new(self.opts).key unless opts[:generate]
40
+
41
+ return self.output_proc.call(command.run) if command
42
+
43
+ # command was not found. Reset output to printing, and return an error.
44
+ self.output_proc = print_proc
45
+ command_not_found_error!
46
+
47
+ rescue ::OpenSSL::Cipher::CipherError => e
48
+ error type: 'Cipher Error',
49
+ details: e.message,
50
+ reason: 'Perhaps either the secret is invalid, or encrypted data is corrupt.',
51
+ exception: e
52
+
53
+ rescue Shhh::Errors::InvalidEncodingPrivateKey => e
54
+ error type: 'Private Key Error',
55
+ details: 'Private key does not appear to be properly encoded. ',
56
+ reason: (opts[:password] ? nil : 'Perhaps the key is password-protected?'),
57
+ exception: e
58
+
59
+ rescue Shhh::Errors::Error => e
60
+ error type: 'Error',
61
+ details: e.message,
62
+ exception: e
63
+
64
+ rescue StandardError => e
65
+ error exception: e
66
+ end
67
+
68
+ def error(hash)
69
+ Shhh::App.error(hash.merge(config: (opts ? opts.to_hash : {})))
70
+ end
71
+
72
+ def editor
73
+ ENV['EDITOR'] || '/bin/vi'
74
+ end
75
+
76
+ def command
77
+ @command_class ||= Shhh::App::Commands.find_command_class(opts)
78
+ @command ||= @command_class.new(self) if @command_class
79
+ end
80
+
81
+ private
82
+
83
+ def select_output_stream
84
+ self.print_proc = Shhh::App::Output::Stdout.new(self).output_proc
85
+ self.write_proc = Shhh::App::Output::File.new(self).output_proc
86
+ self.output_proc = opts[:output] ? self.write_proc : self.print_proc
87
+ end
88
+
89
+
90
+ def configure_color(argv)
91
+ if opts[:no_color]
92
+ Colored2.disable! # reparse options without the colors to create new help msg
93
+ self.opts = parse(argv.dup)
94
+ end
95
+ end
96
+
97
+ def command_not_found_error!
98
+ if key
99
+ h = opts.to_hash
100
+ supplied_opts = h.keys.select { |k| h[k] }.join(', ')
101
+ error type: 'Options Error',
102
+ details: 'Unable to determined what command to run',
103
+ reason: "You provided the following options: #{supplied_opts.bold.yellow}"
104
+ output_proc.call(opts.to_s)
105
+ else
106
+ raise Shhh::Errors::NoPrivateKeyFound.new('Private key is required')
107
+ end
108
+ end
109
+
110
+ def parse(arguments)
111
+ Slop.parse(arguments) do |o|
112
+ o.banner = 'Usage:'.bold.yellow
113
+ o.separator ' shhh [options]'.bold.green
114
+ o.separator 'Modes:'.bold.yellow
115
+ o.bool '-h', '--help', ' show help'
116
+ o.bool '-d', '--decrypt', ' decrypt mode'
117
+ o.bool '-t', '--edit', ' decrypt, open an encr. file in ' + editor
118
+ o.separator 'Create a private key:'.bold.yellow
119
+ o.bool '-g', '--generate', ' generate a new private key'
120
+ o.bool '-p', '--password', ' encrypt the key with a password'
121
+ o.bool '-c', '--copy', ' copy the new key to the clipboard'
122
+ o.separator 'Provide a private key:'.bold.yellow
123
+ o.bool '-i', '--interactive', ' Paste or type the key interactively'
124
+ o.string '-k', '--private-key', '[key] '.bold.blue + ' private key as a string'
125
+ o.string '-K', '--keyfile', '[key-file]'.bold.blue + ' private key from a file'
126
+ if Shhh::App.is_osx?
127
+ o.string '-x', '--keychain', '[key-name] '.bold.blue + 'private key to/from a password entry'
128
+ o.string '--keychain-del', '[key-name] '.bold.blue + 'delete keychain entry with that name'
129
+ end
130
+ o.separator 'Data:'.bold.yellow
131
+ o.string '-s', '--string', '[string]'.bold.blue + ' specify a string to encrypt/decrypt'
132
+ o.string '-f', '--file', '[file] '.bold.blue + ' filename to read from'
133
+ o.string '-o', '--output', '[file] '.bold.blue + ' filename to write to'
134
+ o.bool '-b', '--backup', ' create a backup file in the edit mode'
135
+ o.separator 'Flags:'.bold.yellow
136
+ o.bool '-v', '--verbose', ' show additional information'
137
+ o.bool '-T', '--trace', ' print a backtrace of any errors'
138
+ o.bool '-E', '--examples', ' show several examples'
139
+ o.bool '-V', '--version', ' print library version'
140
+ o.bool '-N', '--no-color', ' disable color output'
141
+ o.bool '-e', '--encrypt', ' encrypt mode'
142
+ o.separator ''
143
+ end
144
+ rescue StandardError => e
145
+ error exception: e
146
+ raise(e)
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,68 @@
1
+ require 'active_support/inflector'
2
+ module Shhh
3
+ module App
4
+ module Commands
5
+ class Command
6
+
7
+ def self.inherited(klass)
8
+ klass.instance_eval do
9
+ class << self
10
+ attr_accessor :required, :incompatible
11
+
12
+ def try_after(*dependencies)
13
+ Shhh::App::Commands.order(self, dependencies)
14
+ end
15
+
16
+ def required_options(*args)
17
+ self.required ||= Set.new
18
+ required.merge(args) if args
19
+ required
20
+ end
21
+
22
+ def incompatible_options(*args)
23
+ self.incompatible ||= Set.new
24
+ incompatible.merge(args) if args
25
+ incompatible
26
+ end
27
+
28
+ def short_name
29
+ name.split(/::/)[-1].underscore.to_sym
30
+ end
31
+
32
+ def options_satisfied_by?(opts_hash)
33
+ proc = required_options.find { |option| option.is_a?(Proc) }
34
+ return true if proc && proc.call(opts_hash)
35
+ return false if incompatible_options.any? { |option| opts_hash[option] }
36
+ required_options.to_a.delete_if { |o| o.is_a?(Proc) }.all? { |o|
37
+ o.is_a?(Array) ? o.any? { |opt| opts_hash[opt] } : opts_hash[o]
38
+ }
39
+ end
40
+ end
41
+
42
+ # Register this command with the global list.
43
+ Shhh::App::Commands.register klass
44
+ end
45
+ end
46
+
47
+ attr_accessor :cli
48
+
49
+ def initialize(cli)
50
+ self.cli = cli
51
+ end
52
+
53
+ def opts
54
+ cli.opts
55
+ end
56
+
57
+ def key
58
+ @key ||= cli.key
59
+ end
60
+
61
+ def run
62
+ raise Shhh::Errors::AbstractMethodCalled.new(:run)
63
+ end
64
+
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,17 @@
1
+ require_relative 'command'
2
+ require 'shhh/app/keychain'
3
+ module Shhh
4
+ module App
5
+ module Commands
6
+ class DeleteKeychainItem < Command
7
+
8
+ required_options :keychain_del
9
+ try_after :generate_key, :open_editor, :encrypt_decrypt
10
+
11
+ def run
12
+ Shhh::App::KeyChain.new(opts[:keychain_del]).delete
13
+ end
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,26 @@
1
+ require_relative 'command'
2
+ module Shhh
3
+ module App
4
+ module Commands
5
+ class EncryptDecrypt < Command
6
+ include Shhh
7
+
8
+ required_options [ :private_key, :keyfile, :keychain, :interactive ],
9
+ [ :encrypt, :decrypt ],
10
+ [ :file, :string ]
11
+
12
+ try_after :generate_key
13
+
14
+ def run
15
+ send(cli.action, content, cli.key)
16
+ end
17
+
18
+ private
19
+
20
+ def content
21
+ @content ||= (opts[:string] || (opts[:file].eql?('-') ? STDIN.read : File.read(opts[:file])))
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,41 @@
1
+ require_relative 'command'
2
+ require 'shhh/app/keychain'
3
+ module Shhh
4
+ module App
5
+ module Commands
6
+ class GenerateKey < Command
7
+ include Shhh
8
+
9
+ required_options :generate
10
+
11
+ def run
12
+ retries ||= 0
13
+ new_private_key = self.class.create_private_key
14
+
15
+ if opts[:password]
16
+ new_private_key = encr_password(new_private_key,
17
+ Shhh::App::Input::Handler.new_password)
18
+ end
19
+
20
+ clipboard_copy(new_private_key) if opts[:copy]
21
+
22
+ if opts[:keychain] && Shhh::App.is_osx?
23
+ Shhh::App::KeyChain.new(opts[:keychain]).add(new_private_key)
24
+ end
25
+
26
+ new_private_key
27
+ rescue Shhh::Errors::PasswordsDontMatch, Shhh::Errors::PasswordTooShort => e
28
+ STDERR.puts e.message.bold
29
+ retry if (retries += 1) < 3
30
+ end
31
+
32
+ private
33
+
34
+ def clipboard_copy(key)
35
+ require 'clipboard'
36
+ Clipboard.copy(key)
37
+ end
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,96 @@
1
+ require 'digest'
2
+ require 'fileutils'
3
+ require 'tempfile'
4
+ require 'shhh'
5
+ require 'shhh/errors'
6
+ require_relative 'command'
7
+ module Shhh
8
+ module App
9
+ module Commands
10
+ class OpenEditor < Command
11
+ include Shhh
12
+
13
+ required_options [ :private_key, :keyfile, :keychain, :interactive ],
14
+ :edit,
15
+ :file
16
+
17
+ try_after :generate_key, :encrypt_decrypt
18
+
19
+ attr_accessor :tempfile
20
+
21
+ def run
22
+ begin
23
+ self.tempfile = ::Tempfile.new(::Base64.urlsafe_encode64(opts[:file]))
24
+ decrypt_content(self.tempfile)
25
+ result = process launch_editor
26
+ ensure
27
+ self.tempfile.close if tempfile
28
+ self.tempfile.unlink rescue nil
29
+ end
30
+ result
31
+ end
32
+
33
+ def launch_editor
34
+ system("#{cli.editor} #{tempfile.path}")
35
+ end
36
+
37
+ private
38
+
39
+ def decrypt_content(file)
40
+ file.open
41
+ file.write(content)
42
+ file.flush
43
+ end
44
+
45
+ def content
46
+ @content ||= decr(File.read(opts[:file]), key)
47
+ end
48
+
49
+ def timestamp
50
+ @timestamp ||= Time.now.to_a.select { |d| d.is_a?(Fixnum) }.map { |d| '%02d' % d }[0..-3].reverse.join
51
+ end
52
+
53
+ def process(code)
54
+ if code == true
55
+ content_edited = File.read(tempfile.path)
56
+ md5 = ::Base64.encode64(Digest::MD5.new.digest(content))
57
+ md5_edited = ::Base64.encode64(Digest::MD5.new.digest(content_edited))
58
+ return 'No changes have been made.' if md5 == md5_edited
59
+
60
+ FileUtils.cp opts[:file], "#{opts[:file]}.#{timestamp}" if opts[:backup]
61
+
62
+ diff = compute_diff
63
+
64
+ File.open(opts[:file], 'w') { |f| f.write(encr(content_edited, key)) }
65
+
66
+ out = ''
67
+ if opts[:verbose]
68
+ out << "Saved encrypted/compressed content to #{opts[:file].bold.blue}" +
69
+ " (#{File.size(opts[:file]) / 1024}Kb), unencrypted size #{content.length / 1024}Kb."
70
+ out << (opts[:backup] ? ",\nbacked up the last version to #{backup_file.bold.blue}." : '.')
71
+ end
72
+ out << "\n\nDiff:\n#{diff}"
73
+ out
74
+ else
75
+ raise Shhh::Errors::EditorExitedAbnormally.new("#{cli.editor} exited with #{$<}")
76
+ end
77
+ end
78
+
79
+ # Computes the diff between two unencrypted versions
80
+ def compute_diff
81
+ original_content_file = Tempfile.new(rand(1024).to_s)
82
+ original_content_file.open
83
+ original_content_file.write(content)
84
+ original_content_file.flush
85
+ diff = `diff #{original_content_file.path} #{tempfile.path}`
86
+ diff.gsub!(/> (.*\n)/m, '\1'.green)
87
+ diff.gsub!(/< (.*\n)/m, '\1'.red)
88
+ ensure
89
+ original_content_file.close
90
+ original_content_file.unlink
91
+ diff
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,18 @@
1
+ require_relative 'command'
2
+ require 'shhh/app/keychain'
3
+ module Shhh
4
+ module App
5
+ module Commands
6
+ class PrintKey < Command
7
+ include Shhh
8
+ required_options [ :keychain, :keyfile ]
9
+
10
+ try_after :show_examples
11
+
12
+ def run
13
+ cli.key
14
+ end
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,64 @@
1
+ require 'colored2'
2
+ require_relative 'command'
3
+ module Shhh
4
+ module App
5
+ module Commands
6
+ class ShowExamples < Command
7
+ required_options :examples
8
+ try_after :show_help
9
+
10
+ def run
11
+ output = []
12
+
13
+ output << example(comment: 'generate a new private key into an environment variable:',
14
+ command: 'export KEY=$(shhh -g)',
15
+ echo: 'echo $KEY',
16
+ result: '75ngenJpB6zL47/8Wo7Ne6JN1pnOsqNEcIqblItpfg4='.green)
17
+
18
+ output << example(comment: 'generate a new password-protected key, copy to the clipboard & save to a file',
19
+ command: 'shhh -gpc -o ~/.key',
20
+ echo: 'New Password : ' + '••••••••••'.green,
21
+ result: 'Confirm Password : ' + '••••••••••'.green)
22
+
23
+ output << example(comment: 'encrypt a plain text string with a key, and save the output to a file',
24
+ command: 'shhh -e -s ' + '"secret string"'.bold.yellow + ' -k $KEY -o file.enc',
25
+ echo: 'cat file.enc',
26
+ result: 'Y09MNDUyczU1S0UvelgrLzV0RTYxZz09CkBDMEw4Q0R0TmpnTm9md1QwNUNy%T013PT0K'.green)
27
+
28
+ output << example(comment: 'decrypt a previously encrypted string:',
29
+ command: 'shhh -d -s $(cat file.enc) -k $KEY',
30
+ result: 'secret string'.green)
31
+
32
+ output << example(comment: 'encrypt shhh.yml and save it to shhh.enc:',
33
+ command: 'shhh -e -f shhh.yml -o shhh.enc -k $KEY')
34
+
35
+ output << example(comment: 'decrypt an encrypted file and print it to STDOUT:',
36
+ command: 'shhh -df shhh.enc -k $KEY')
37
+
38
+ output << example(comment: 'edit an encrypted file in $EDITOR, ask for key, create a backup',
39
+ command: 'shhh -tibf ecrets.enc',
40
+ result: '
41
+ Private Key: ••••••••••••••••••••••••••••••••••••••••••••
42
+ Saved encrypted content to shhh.enc.
43
+
44
+ Diff:
45
+ 3c3
46
+ '.white.dark + '# (c) 2015 Konstantin Gredeskoul. All rights reserved.'.red.bold + '
47
+ ---' + '
48
+ # (c) 2016 Konstantin Gredeskoul. All rights reserved.'.green.bold)
49
+
50
+ output.flatten.compact.join("\n")
51
+ end
52
+
53
+ def example(comment: nil, command: nil, echo: nil, result: nil)
54
+ out = []
55
+ out << "# #{comment}".white.dark.italic if comment
56
+ out << "#{command}" if command
57
+ out << "#{echo}" if echo
58
+ out << "#{result}" if result
59
+ out << '—'*80
60
+ end
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,16 @@
1
+ require_relative 'command'
2
+ module Shhh
3
+ module App
4
+ module Commands
5
+ class ShowHelp < Command
6
+
7
+ required_options :help, ->(opts) { opts.keys.all? { |k| !opts[k] } }
8
+ try_after :generate_key, :open_editor, :encrypt_decrypt
9
+
10
+ def run
11
+ opts.to_s
12
+ end
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,14 @@
1
+ require_relative 'command'
2
+ module Shhh
3
+ module App
4
+ module Commands
5
+ class ShowVersion < Command
6
+ required_options :version
7
+ try_after :show_help
8
+ def run
9
+ "shhh (version #{Shhh::VERSION})"
10
+ end
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,55 @@
1
+ require 'active_support/inflector'
2
+ require 'tsort'
3
+ require 'pp'
4
+ module Shhh
5
+ module App
6
+ module Commands
7
+
8
+ class DependencyResolver < Hash
9
+ include TSort
10
+ alias tsort_each_node each_key
11
+
12
+ def tsort_each_child(node, &block)
13
+ fetch(node).each(&block)
14
+ end
15
+ end
16
+
17
+ @dependency = DependencyResolver.new
18
+ @commands = Set.new
19
+
20
+ class << self
21
+ attr_accessor :commands, :dependency
22
+
23
+ def register(command_class)
24
+ self.commands << command_class
25
+ self.dependency[command_class.short_name] ||= []
26
+ end
27
+
28
+ def order(command_class, after)
29
+ self.dependency[command_class.short_name].unshift(after) if after
30
+ self.dependency[command_class.short_name].flatten!
31
+ end
32
+
33
+ def dependencies
34
+ @dependencies ||= self.dependency.tsort
35
+ @dependencies
36
+ end
37
+
38
+ # Sort commands based on the #dependencies array, which itself is sorted
39
+ # based on command dependencies.
40
+ def sorted_commands
41
+ @sorted_commands ||= self.commands.to_a.sort_by{|klass| dependencies.index(klass.short_name) }
42
+ @sorted_commands
43
+ end
44
+
45
+ def find_command_class(opts)
46
+ self.sorted_commands.each do |command_class|
47
+ return command_class if command_class.options_satisfied_by?(opts.to_hash)
48
+ end
49
+ nil
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
@@ -0,0 +1,35 @@
1
+ require 'shhh/errors'
2
+
3
+ module Shhh
4
+ module App
5
+ module Input
6
+ class Handler
7
+ def self.ask
8
+ retries ||= 0
9
+ prompt('Password: ', :green)
10
+ rescue ::OpenSSL::Cipher::CipherError
11
+ STDERR.puts 'Invalid password. Please try again.'
12
+ retry if (retries += 1) < 3
13
+ nil
14
+ end
15
+
16
+ def self.prompt(message, color)
17
+ HighLine.new(STDIN, STDERR).ask(message.bold) { |q| q.echo = '•'.send(color) }
18
+ end
19
+
20
+ def self.new_password
21
+ password = prompt('New Password : ', :blue)
22
+ password_confirm = prompt('Confirm Password : ', :blue)
23
+
24
+ raise Shhh::Errors::PasswordsDontMatch.new(
25
+ 'The passwords you entered do not match.') if password != password_confirm
26
+
27
+ raise Shhh::Errors::PasswordTooShort.new(
28
+ 'Minimum length is 7 characters.') if password.length < 7
29
+
30
+ password
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end