shhh 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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