arcanus 0.1.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.
@@ -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