arcanus 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|