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.
- checksums.yaml +7 -0
- data/bin/arcanus +7 -0
- data/lib/arcanus.rb +13 -0
- data/lib/arcanus/chest.rb +137 -0
- data/lib/arcanus/cli.rb +64 -0
- data/lib/arcanus/command/base.rb +93 -0
- data/lib/arcanus/command/edit.rb +66 -0
- data/lib/arcanus/command/export.rb +55 -0
- data/lib/arcanus/command/help.rb +37 -0
- data/lib/arcanus/command/setup.rb +97 -0
- data/lib/arcanus/command/shared/ensure_key.rb +14 -0
- data/lib/arcanus/command/show.rb +34 -0
- data/lib/arcanus/command/unlock.rb +48 -0
- data/lib/arcanus/command/version.rb +9 -0
- data/lib/arcanus/configuration.rb +92 -0
- data/lib/arcanus/constants.rb +11 -0
- data/lib/arcanus/error_handler.rb +50 -0
- data/lib/arcanus/errors.rb +32 -0
- data/lib/arcanus/input.rb +24 -0
- data/lib/arcanus/key.rb +55 -0
- data/lib/arcanus/output.rb +25 -0
- data/lib/arcanus/repo.rb +84 -0
- data/lib/arcanus/subprocess.rb +53 -0
- data/lib/arcanus/ui.rb +132 -0
- data/lib/arcanus/utils.rb +30 -0
- data/lib/arcanus/version.rb +4 -0
- metadata +99 -0
@@ -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,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
|