arcanus 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,37 @@
1
+ module Arcanus::Command
2
+ class Help < Base
3
+ description 'Displays help documentation'
4
+
5
+ def execute
6
+ ui.print 'Arcanus is a tool for managing encrypted secrets in a repository.'
7
+ ui.newline
8
+
9
+ ui.print 'Usage: ', newline: false
10
+ ui.info 'arcanus [command]'
11
+ ui.newline
12
+
13
+ command_classes.each do |command_class|
14
+ ui.info command_class.short_name.ljust(12, ' '), newline: false
15
+ ui.print command_class.description
16
+ end
17
+
18
+ ui.newline
19
+ ui.print "See #{Arcanus::REPO_URL}#usage for full documentation"
20
+ end
21
+
22
+ private
23
+
24
+ def command_classes
25
+ command_files =
26
+ Dir[File.join(File.dirname(__FILE__), '*.rb')]
27
+ .select { |path| File.basename(path, '.rb') != 'base' }
28
+
29
+ command_files.map do |file|
30
+ require file
31
+
32
+ basename = File.basename(file, '.rb')
33
+ Arcanus::Command.const_get(Arcanus::Utils.camel_case(basename))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,97 @@
1
+ module Arcanus::Command
2
+ class Setup < Base
3
+ description 'Create a chest to store encrypted secrets in the current repository'
4
+
5
+ def execute
6
+ return if already_has_key?
7
+
8
+ ui.info 'This repository does not have an Arcanus key.'
9
+ ui.info "Let's generate one for you."
10
+ ui.newline
11
+
12
+ create_key
13
+ create_chest
14
+ update_gitignore
15
+
16
+ ui.newline
17
+ ui.success 'You can safely commit the following files:'
18
+ ui.info Arcanus::CHEST_FILE_NAME
19
+ ui.info Arcanus::LOCKED_KEY_NAME
20
+ ui.success 'You must never commit the unlocked key file:'
21
+ ui.info Arcanus::UNLOCKED_KEY_NAME
22
+ end
23
+
24
+ private
25
+
26
+ def already_has_key?
27
+ return false unless repo.has_locked_key?
28
+
29
+ ui.warning 'Arcanus already initialized in this repository.'
30
+
31
+ unless repo.has_unlocked_key?
32
+ ui.newline
33
+ ui.warning 'However, your key is still protected by a password.'
34
+
35
+ if ui.ask('Do you want to unlock your key? (y/n)')
36
+ .argument(:required)
37
+ .default('y')
38
+ .modify(:downcase)
39
+ .read_string == 'y'
40
+ ui.newline
41
+ execute_command(%w[unlock])
42
+ end
43
+ end
44
+
45
+ true
46
+ end
47
+
48
+ def create_key
49
+ password = ask_password
50
+
51
+ start_time = Time.now
52
+ ui.spinner('Generating key...') do
53
+ key = Arcanus::Key.generate
54
+ key.save(key_file_path: repo.locked_key_path, password: password)
55
+ key.save(key_file_path: repo.unlocked_key_path)
56
+ end
57
+ end_time = Time.now
58
+
59
+ ui.success "Key generated in #{end_time - start_time} seconds"
60
+ end
61
+
62
+ def ask_password
63
+ password = nil
64
+ confirmed_password = false
65
+
66
+ ui.print 'Enter a password to lock the key with.'
67
+ ui.print 'Any new developer will need to be given this password to work with this repo.'
68
+ ui.print 'You should store the password in a secure place.'
69
+
70
+ loop do
71
+ ui.info 'Password: ', newline: false
72
+ password = ui.secret_user_input
73
+ ui.newline
74
+ ui.info 'Confirm Password: ', newline: false
75
+ confirmed_password = ui.secret_user_input
76
+ ui.newline
77
+
78
+ if password == confirmed_password
79
+ break
80
+ else
81
+ ui.error 'Passwords do not match. Try again.'
82
+ ui.newline
83
+ end
84
+ end
85
+
86
+ password
87
+ end
88
+
89
+ def create_chest
90
+ File.open(repo.chest_file_path, 'w') { |f| f.write({}.to_yaml) }
91
+ end
92
+
93
+ def update_gitignore
94
+ File.open(repo.gitignore_file_path, 'a') { |f| f.write(Arcanus::UNLOCKED_KEY_NAME) }
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,14 @@
1
+ module Arcanus::Command::Shared
2
+ module EnsureKey
3
+ # Ensures the key is unlocked
4
+ def ensure_key_unlocked
5
+ if !repo.has_locked_key?
6
+ execute_command(%w[setup])
7
+ ui.newline
8
+ elsif !repo.has_unlocked_key?
9
+ execute_command(%w[unlock])
10
+ ui.newline
11
+ end
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,34 @@
1
+ require_relative 'shared/ensure_key'
2
+
3
+ module Arcanus::Command
4
+ class Show < Base
5
+ include Shared::EnsureKey
6
+
7
+ description 'Shows the decrypted contents of the chest'
8
+
9
+ def execute
10
+ ensure_key_unlocked
11
+
12
+ chest = Arcanus::Chest.new(key_file_path: repo.unlocked_key_path,
13
+ chest_file_path: repo.chest_file_path)
14
+
15
+ output_colored_hash(chest.contents)
16
+ end
17
+
18
+ private
19
+
20
+ def output_colored_hash(hash, indent = 0)
21
+ indentation = ' ' * indent
22
+ hash.each do |key, value|
23
+ ui.info "#{indentation}#{key}:", newline: false
24
+
25
+ if value.is_a?(Hash)
26
+ ui.newline
27
+ output_colored_hash(value, indent + 2)
28
+ else
29
+ ui.print " #{value}"
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,48 @@
1
+ module Arcanus::Command
2
+ class Unlock < Base
3
+ description 'Unlocks key so secrets can be encrypted/decrypted'
4
+
5
+ def execute
6
+ return if already_unlocked?
7
+
8
+ ui.print "This repository's Arcanus key is locked by a password."
9
+ ui.print "Until you unlock it, you won't be able to view/edit secrets."
10
+
11
+ unlock_key
12
+
13
+ ui.success "Key unlocked and saved in #{repo.unlocked_key_path}"
14
+ ui.newline
15
+ ui.print 'You can now view secrets with:'
16
+ ui.info ' arcanus show'
17
+ ui.print '...or edit secrets with:'
18
+ ui.info ' arcanus edit'
19
+ end
20
+
21
+ private
22
+
23
+ def already_unlocked?
24
+ return unless repo.has_unlocked_key?
25
+
26
+ ui.warning "This repository's key is already unlocked."
27
+ ui.print 'You can view secrets by running:'
28
+ ui.info 'arcanus show'
29
+
30
+ true
31
+ end
32
+
33
+ def unlock_key
34
+ loop do
35
+ ui.print 'Enter password:', newline: false
36
+ password = ui.secret_user_input
37
+
38
+ begin
39
+ key = Key.from_protected_file(repo.locked_key_path, password)
40
+ key.save(key_file_path: repo.unlocked_key_path)
41
+ break # Key unlocked successfully
42
+ rescue Arcanus::Errors::DecryptionError => ex
43
+ ui.error ex.message
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,9 @@
1
+ module Arcanus::Command
2
+ class Version < Base
3
+ description 'Displays version information'
4
+
5
+ def execute
6
+ ui.info Arcanus::VERSION
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,92 @@
1
+ require 'pathname'
2
+ require 'yaml'
3
+
4
+ module Arcanus
5
+ # Stores runtime configuration for the application.
6
+ #
7
+ # This is intended to define helper methods for accessing configuration so
8
+ # this logic can be shared amongst the various components of the system.
9
+ class Configuration
10
+ # Name of the configuration file.
11
+ FILE_NAME = '.arcanus.yaml'
12
+
13
+ class << self
14
+ # Loads appropriate configuration file given the current working
15
+ # directory.
16
+ #
17
+ # @return [Arcanus::Configuration]
18
+ def load_applicable
19
+ current_directory = File.expand_path(Dir.pwd)
20
+ config_file = applicable_config_file(current_directory)
21
+
22
+ if config_file
23
+ from_file(config_file)
24
+ else
25
+ # No configuration file, so assume defaults
26
+ new({})
27
+ end
28
+ end
29
+
30
+ # Loads a configuration from a file.
31
+ #
32
+ # @return [Arcanus::Configuration]
33
+ def from_file(config_file)
34
+ options =
35
+ if yaml = YAML.load_file(config_file)
36
+ yaml.to_hash
37
+ else
38
+ {}
39
+ end
40
+
41
+ new(options)
42
+ end
43
+
44
+ private
45
+
46
+ # Returns the first valid configuration file found, starting from the
47
+ # current working directory and ascending to ancestor directories.
48
+ #
49
+ # @param directory [String]
50
+ # @return [String, nil]
51
+ def applicable_config_file(directory)
52
+ Pathname.new(directory)
53
+ .enum_for(:ascend)
54
+ .map { |dir| dir + FILE_NAME }
55
+ .find do |config_file|
56
+ config_file if config_file.exist?
57
+ end
58
+ end
59
+ end
60
+
61
+ # Creates a configuration from the given options hash.
62
+ #
63
+ # @param options [Hash]
64
+ def initialize(options)
65
+ @options = options
66
+ end
67
+
68
+ # Access the configuration as if it were a hash.
69
+ #
70
+ # @param key [String, Symbol]
71
+ # @return [Array, Hash, Number, String]
72
+ def [](key)
73
+ @options[key.to_s]
74
+ end
75
+
76
+ # Access the configuration as if it were a hash.
77
+ #
78
+ # @param key [String, Symbol]
79
+ # @return [Array, Hash, Number, String]
80
+ def fetch(key, *args)
81
+ @options.fetch(key.to_s, *args)
82
+ end
83
+
84
+ # Compares this configuration with another.
85
+ #
86
+ # @param other [HamlLint::Configuration]
87
+ # @return [true,false] whether the given configuration is equivalent
88
+ def ==(other)
89
+ super || @options == other.instance_variable_get('@options')
90
+ end
91
+ end
92
+ end
@@ -0,0 +1,11 @@
1
+ # Global application constants.
2
+ module Arcanus
3
+ EXECUTABLE_NAME = 'arcanus'
4
+
5
+ CHEST_FILE_NAME = '.arcanus.chest'
6
+ LOCKED_KEY_NAME = '.arcanus.pem'
7
+ UNLOCKED_KEY_NAME = '.arcanus.key'
8
+
9
+ REPO_URL = 'https://github.com/sds/arcanus'
10
+ BUG_REPORT_URL = "#{REPO_URL}/issues"
11
+ end
@@ -0,0 +1,50 @@
1
+ module Arcanus
2
+ # Central location of all logic for how exceptions are presented to the user.
3
+ class ErrorHandler
4
+ # Creates exception handler that can display output to user via the given
5
+ # user interface.
6
+ #
7
+ # @param [Arcanus::UI] user interface to print output to
8
+ def initialize(ui)
9
+ @ui = ui
10
+ end
11
+
12
+ # Display appropriate output to the user for the given exception, returning
13
+ # a semantic exit status code.
14
+ #
15
+ # @return [Integer] exit status code
16
+ def handle(ex)
17
+ case ex
18
+ when Errors::CommandFailedError,
19
+ Errors::DecryptionError
20
+ ui.error ex.message
21
+ CLI::ExitCodes::ERROR
22
+ when Errors::UsageError
23
+ ui.error ex.message
24
+ CLI::ExitCodes::USAGE
25
+ when Errors::ConfigurationError
26
+ ui.error ex.message
27
+ CLI::ExitCodes::CONFIG
28
+ else
29
+ print_unexpected_exception(ex)
30
+ CLI::ExitCodes::SOFTWARE
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ attr_reader :ui
37
+
38
+ def print_unexpected_exception(ex)
39
+ ui.bold_error ex.message
40
+ ui.error ex.backtrace.join("\n")
41
+ ui.warning 'Report this bug at ', newline: false
42
+ ui.info BUG_REPORT_URL
43
+ ui.newline
44
+ ui.info 'To help fix this issue, please include:'
45
+ ui.print '- The above stack trace'
46
+ ui.print '- Ruby version: ', newline: false
47
+ ui.info RUBY_VERSION
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,32 @@
1
+ # Collection of errors that can be thrown by the application.
2
+ #
3
+ # This implements an exception hierarchy which exceptions to be grouped by type
4
+ # so the {ExceptionHandler} can display them appropriately.
5
+ module Arcanus::Errors
6
+ # Base class for all errors reported by this tool.
7
+ class ArcanusError < StandardError; end
8
+
9
+ # Base class for all errors that are a result of incorrect user usage.
10
+ class UsageError < ArcanusError; end
11
+
12
+ # Base class for all configuration-related errors.
13
+ class ConfigurationError < ArcanusError; end
14
+
15
+ # Raised when something is incorrect with the configuration.
16
+ class ConfigurationInvalidError < ConfigurationError; end
17
+
18
+ # Raised when a configuration file is not present.
19
+ class ConfigurationMissingError < ConfigurationError; end
20
+
21
+ # Raised when there was a problem decrypting a value.
22
+ class DecryptionError < StandardError; end
23
+
24
+ # Raised when a command has failed due to user error.
25
+ class CommandFailedError < UsageError; end
26
+
27
+ # Raised when invalid/non-existent command was used.
28
+ class CommandInvalidError < UsageError; end
29
+
30
+ # Raised when run in a directory not part of a valid git repository.
31
+ class InvalidGitRepoError < UsageError; end
32
+ end
@@ -0,0 +1,24 @@
1
+ require 'io/console'
2
+
3
+ module Arcanus
4
+ # Provides interface for collecting input from the user.
5
+ class Input
6
+ # Creates an {Arcanus::Input} wrapping the given IO stream.
7
+ #
8
+ # @param [IO] input the input stream
9
+ def initialize(input)
10
+ @input = input
11
+ end
12
+
13
+ # Blocks until a line of input is returned from the input source.
14
+ #
15
+ # @return [String, nil]
16
+ def get(noecho: false)
17
+ if noecho
18
+ @input.noecho(&:gets)
19
+ else
20
+ @input.gets
21
+ end
22
+ end
23
+ end
24
+ end