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
checksums.yaml
ADDED
@@ -0,0 +1,7 @@
|
|
1
|
+
---
|
2
|
+
SHA1:
|
3
|
+
metadata.gz: 52e5895e0c0560d9a912260b996068dce2b1a775
|
4
|
+
data.tar.gz: d80b64ee9c1774a87b472d08f2780e65971fb075
|
5
|
+
SHA512:
|
6
|
+
metadata.gz: df1be1a9de6798d5ca787f6a1d5d8e92e29533afbfd7a83ef05ad879a40560377f6a6bfb4d98013471250f8433df5cf9afb84a9229a041d89233d9fc9747188f
|
7
|
+
data.tar.gz: 04ce87f5fe98d26de59e1ac8e4e1d15dd9e308a595bdc313d4a51fbcfad2ab22dcdf46169ed188f55f182fb9dab8edeb31ab01ef633ba41826a18f320d1a5dd5
|
data/bin/arcanus
ADDED
data/lib/arcanus.rb
ADDED
@@ -0,0 +1,13 @@
|
|
1
|
+
require 'arcanus/constants'
|
2
|
+
require 'arcanus/errors'
|
3
|
+
require 'arcanus/error_handler'
|
4
|
+
require 'arcanus/configuration'
|
5
|
+
require 'arcanus/input'
|
6
|
+
require 'arcanus/output'
|
7
|
+
require 'arcanus/ui'
|
8
|
+
require 'arcanus/repo'
|
9
|
+
require 'arcanus/subprocess'
|
10
|
+
require 'arcanus/key'
|
11
|
+
require 'arcanus/chest'
|
12
|
+
require 'arcanus/utils'
|
13
|
+
require 'arcanus/version'
|
@@ -0,0 +1,137 @@
|
|
1
|
+
require 'base64'
|
2
|
+
require 'digest'
|
3
|
+
require 'securerandom'
|
4
|
+
require 'yaml'
|
5
|
+
|
6
|
+
module Arcanus
|
7
|
+
# Encapsulates the collection of encrypted secrets managed by Arcanus.
|
8
|
+
class Chest
|
9
|
+
SIGNATURE_SIZE_BITS = 256
|
10
|
+
|
11
|
+
def initialize(key_file_path:, chest_file_path:)
|
12
|
+
@key = Key.from_file(key_file_path)
|
13
|
+
@chest_file_path = chest_file_path
|
14
|
+
@original_encrypted_hash = YAML.load_file(chest_file_path).to_hash
|
15
|
+
@original_decrypted_hash = decrypt_hash(@original_encrypted_hash)
|
16
|
+
@hash = @original_decrypted_hash.dup
|
17
|
+
end
|
18
|
+
|
19
|
+
# Access the collection as if it were a hash.
|
20
|
+
#
|
21
|
+
# @param key [String]
|
22
|
+
# @return [Object]
|
23
|
+
def [](key)
|
24
|
+
@hash[key]
|
25
|
+
end
|
26
|
+
|
27
|
+
# Returns the contents of the chest as a hash.
|
28
|
+
def contents
|
29
|
+
@hash
|
30
|
+
end
|
31
|
+
|
32
|
+
def update(new_hash)
|
33
|
+
@hash = new_hash
|
34
|
+
end
|
35
|
+
|
36
|
+
# For each key in the chest, encrypt the new value if it has changed.
|
37
|
+
#
|
38
|
+
# The goal is to create a file where the only lines that differ are the keys
|
39
|
+
# that changed.
|
40
|
+
def save
|
41
|
+
modified_hash =
|
42
|
+
process_hash_changes(@original_encrypted_hash, @original_decrypted_hash, @hash)
|
43
|
+
|
44
|
+
File.open(@chest_file_path, 'w') { |f| f.write(modified_hash.to_yaml) }
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def process_hash_changes(original_encrypted, original_decrypted, current)
|
50
|
+
result = {}
|
51
|
+
|
52
|
+
current.keys.each do |key|
|
53
|
+
value = current[key]
|
54
|
+
|
55
|
+
result[key] =
|
56
|
+
if original_encrypted.key?(key)
|
57
|
+
# Key still exists; check if modified.
|
58
|
+
if original_encrypted[key].is_a?(Hash) && value.is_a?(Hash)
|
59
|
+
process_hash_changes(original_encrypted[key], original_decrypted[key], current[key])
|
60
|
+
elsif value != original_decrypted[key]
|
61
|
+
# Value was changed; encrypt the new value
|
62
|
+
encrypt_value(value)
|
63
|
+
else
|
64
|
+
# Value wasn't changed; keep original encrypted blob
|
65
|
+
original_encrypted[key]
|
66
|
+
end
|
67
|
+
else
|
68
|
+
# Key was added
|
69
|
+
value.is_a?(Hash) ? process_hash_changes({}, {}, value) : encrypt_value(value)
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
result
|
74
|
+
end
|
75
|
+
|
76
|
+
def decrypt_hash(hash)
|
77
|
+
decrypted_hash = {}
|
78
|
+
|
79
|
+
hash.each do |key, value|
|
80
|
+
begin
|
81
|
+
if value.is_a?(Hash)
|
82
|
+
decrypted_hash[key] = decrypt_hash(value)
|
83
|
+
else
|
84
|
+
decrypted_hash[key] = decrypt_value(value)
|
85
|
+
end
|
86
|
+
rescue Errors::DecryptionError => ex
|
87
|
+
raise Errors::DecryptionError,
|
88
|
+
"Problem decrypting value for key '#{key}': #{ex.message}"
|
89
|
+
end
|
90
|
+
end
|
91
|
+
|
92
|
+
decrypted_hash
|
93
|
+
end
|
94
|
+
|
95
|
+
def encrypt_value(value)
|
96
|
+
dumped_value = Marshal.dump(value)
|
97
|
+
encrypted_value = Base64.encode64(@key.encrypt(dumped_value))
|
98
|
+
salt = SecureRandom.hex(8)
|
99
|
+
|
100
|
+
signature = Digest::SHA2.new(SIGNATURE_SIZE_BITS).tap do |digest|
|
101
|
+
digest << salt
|
102
|
+
digest << dumped_value
|
103
|
+
end.to_s
|
104
|
+
|
105
|
+
"#{encrypted_value}:#{salt}:#{signature}"
|
106
|
+
end
|
107
|
+
|
108
|
+
def decrypt_value(blob)
|
109
|
+
unless blob.is_a?(String)
|
110
|
+
raise Errors::DecryptionError,
|
111
|
+
"Expecting an encrypted blob but got '#{blob}'"
|
112
|
+
end
|
113
|
+
|
114
|
+
encrypted_value, salt, signature = blob.split(':')
|
115
|
+
|
116
|
+
if signature.nil? || salt.nil? || encrypted_value.nil?
|
117
|
+
raise Errors::DecryptionError,
|
118
|
+
"Invalid blob format '#{blob}' (must be of the form 'signature:salt:ciphertext')"
|
119
|
+
end
|
120
|
+
|
121
|
+
dumped_value = @key.decrypt(Base64.decode64(encrypted_value))
|
122
|
+
|
123
|
+
actual_signature = Digest::SHA2.new(SIGNATURE_SIZE_BITS).tap do |digest|
|
124
|
+
digest << salt
|
125
|
+
digest << dumped_value
|
126
|
+
end.to_s
|
127
|
+
|
128
|
+
if signature != actual_signature
|
129
|
+
raise Errors::DecryptionError,
|
130
|
+
'Signature of decrypted value does not match: ' \
|
131
|
+
"expected #{signature} but got #{actual_signature}"
|
132
|
+
end
|
133
|
+
|
134
|
+
Marshal.load(dumped_value)
|
135
|
+
end
|
136
|
+
end
|
137
|
+
end
|
data/lib/arcanus/cli.rb
ADDED
@@ -0,0 +1,64 @@
|
|
1
|
+
require 'arcanus'
|
2
|
+
|
3
|
+
module Arcanus
|
4
|
+
# Command line application interface.
|
5
|
+
class CLI
|
6
|
+
# Set of semantic exit codes we can return.
|
7
|
+
#
|
8
|
+
# @see http://www.gsp.com/cgi-bin/man.cgi?section=3&topic=sysexits
|
9
|
+
module ExitCodes
|
10
|
+
OK = 0 # Successful execution
|
11
|
+
ERROR = 1 # Generic error
|
12
|
+
USAGE = 64 # User error (bad command line or invalid input)
|
13
|
+
SOFTWARE = 70 # Internal software error (bug)
|
14
|
+
CONFIG = 78 # Configuration error (invalid file or options)
|
15
|
+
end
|
16
|
+
|
17
|
+
# Create a CLI that outputs to the given output destination.
|
18
|
+
#
|
19
|
+
# @param input [Arcanus::Input]
|
20
|
+
# @param output [Arcanus::Output]
|
21
|
+
def initialize(input:, output:)
|
22
|
+
@ui = UI.new(input, output)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Parses the given command-line arguments and executes appropriate logic
|
26
|
+
# based on those arguments.
|
27
|
+
#
|
28
|
+
# @param [Array<String>] arguments
|
29
|
+
# @return [Integer] exit status code
|
30
|
+
def run(arguments)
|
31
|
+
config = Configuration.load_applicable
|
32
|
+
run_command(config, arguments)
|
33
|
+
|
34
|
+
ExitCodes::OK
|
35
|
+
rescue => ex
|
36
|
+
ErrorHandler.new(@ui).handle(ex)
|
37
|
+
end
|
38
|
+
|
39
|
+
private
|
40
|
+
|
41
|
+
# Executes the appropriate command given the list of command line arguments.
|
42
|
+
#
|
43
|
+
# @param config [Arcanus::Configuration]
|
44
|
+
# @param ui [Arcanus::UI]
|
45
|
+
# @param arguments [Array<String>]
|
46
|
+
# @raise [Arcanus::Errors::ArcanusError] when any exceptional circumstance occurs
|
47
|
+
def run_command(config, arguments)
|
48
|
+
arguments = convert_arguments(arguments)
|
49
|
+
|
50
|
+
require 'arcanus/command/base'
|
51
|
+
Command::Base.from_arguments(config, @ui, arguments).run
|
52
|
+
end
|
53
|
+
|
54
|
+
def convert_arguments(arguments)
|
55
|
+
# Display all open changes by default
|
56
|
+
return ['help'] if arguments.empty? # TODO: Evaluate repo and recommend next step
|
57
|
+
|
58
|
+
return ['help'] if %w[-h --help].include?(arguments.first)
|
59
|
+
return ['version'] if %w[-v --version].include?(arguments.first)
|
60
|
+
|
61
|
+
arguments
|
62
|
+
end
|
63
|
+
end
|
64
|
+
end
|
@@ -0,0 +1,93 @@
|
|
1
|
+
module Arcanus::Command
|
2
|
+
# Abstract base class of all commands.
|
3
|
+
#
|
4
|
+
# @abstract
|
5
|
+
class Base
|
6
|
+
include Arcanus::Utils
|
7
|
+
|
8
|
+
class << self
|
9
|
+
# Create a command from a list of arguments.
|
10
|
+
#
|
11
|
+
# @param config [Arcanus::Configuration]
|
12
|
+
# @param ui [Arcanus::UI]
|
13
|
+
# @param arguments [Array<String>]
|
14
|
+
# @return [Arcanus::Command::Base] appropriate command for the given
|
15
|
+
# arguments
|
16
|
+
def from_arguments(config, ui, arguments)
|
17
|
+
cmd = arguments.first
|
18
|
+
|
19
|
+
begin
|
20
|
+
require "arcanus/command/#{Arcanus::Utils.snake_case(cmd)}"
|
21
|
+
rescue LoadError
|
22
|
+
raise Arcanus::Errors::CommandInvalidError,
|
23
|
+
"`arcanus #{cmd}` is not a valid command"
|
24
|
+
end
|
25
|
+
|
26
|
+
Arcanus::Command.const_get(Arcanus::Utils.camel_case(cmd)).new(config, ui, arguments)
|
27
|
+
end
|
28
|
+
|
29
|
+
def description(desc = nil)
|
30
|
+
@description = desc if desc
|
31
|
+
@description
|
32
|
+
end
|
33
|
+
|
34
|
+
def short_name
|
35
|
+
name.split('::').last.downcase
|
36
|
+
end
|
37
|
+
end
|
38
|
+
|
39
|
+
# @param config [Arcanus::Configuration]
|
40
|
+
# @param ui [Arcanus::UI]
|
41
|
+
# @param arguments [Array<String>]
|
42
|
+
def initialize(config, ui, arguments)
|
43
|
+
@config = config
|
44
|
+
@ui = ui
|
45
|
+
@arguments = arguments
|
46
|
+
end
|
47
|
+
|
48
|
+
# Parses arguments and executes the command.
|
49
|
+
def run
|
50
|
+
# TODO: include a parse step here and remove duplicate parsing code from
|
51
|
+
# individual commands
|
52
|
+
execute
|
53
|
+
end
|
54
|
+
|
55
|
+
# Executes the command given the previously-parsed arguments.
|
56
|
+
def execute
|
57
|
+
raise NotImplementedError, 'Define `execute` in Command subclass'
|
58
|
+
end
|
59
|
+
|
60
|
+
# Executes another command from the same context as this command.
|
61
|
+
#
|
62
|
+
# @param command_arguments [Array<String>]
|
63
|
+
def execute_command(command_arguments)
|
64
|
+
self.class.from_arguments(config, ui, command_arguments).execute
|
65
|
+
end
|
66
|
+
|
67
|
+
private
|
68
|
+
|
69
|
+
# @return [Array<String>]
|
70
|
+
attr_reader :arguments
|
71
|
+
|
72
|
+
# @return [Arcanus::Configuration]
|
73
|
+
attr_reader :config
|
74
|
+
|
75
|
+
# @return [Arcanus::UI]
|
76
|
+
attr_reader :ui
|
77
|
+
|
78
|
+
# Returns information about this repository.
|
79
|
+
#
|
80
|
+
# @return [Arcanus::Repo]
|
81
|
+
def repo
|
82
|
+
@repo ||= Arcanus::Repo.new(@config)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Execute a process and return the result including status and output.
|
86
|
+
#
|
87
|
+
# @param args [Array<String>]
|
88
|
+
# @return [#status, #stdout, #stderr]
|
89
|
+
def spawn(args)
|
90
|
+
Arcanus::Subprocess.spawn(args)
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
@@ -0,0 +1,66 @@
|
|
1
|
+
require_relative 'shared/ensure_key'
|
2
|
+
require 'tempfile'
|
3
|
+
|
4
|
+
module Arcanus::Command
|
5
|
+
class Edit < Base
|
6
|
+
include Shared::EnsureKey
|
7
|
+
|
8
|
+
description 'Opens $EDITOR to modify the contents of the chest'
|
9
|
+
|
10
|
+
def execute
|
11
|
+
ensure_key_unlocked
|
12
|
+
|
13
|
+
unless editor_defined?
|
14
|
+
raise Arcanus::Errors::ConfigurationError,
|
15
|
+
'$EDITOR environment variable is not defined'
|
16
|
+
end
|
17
|
+
|
18
|
+
chest = Arcanus::Chest.new(key_file_path: repo.unlocked_key_path,
|
19
|
+
chest_file_path: repo.chest_file_path)
|
20
|
+
|
21
|
+
::Tempfile.new('arcanus-chest').tap do |file|
|
22
|
+
file.sync = true
|
23
|
+
file.write(chest.contents.to_yaml)
|
24
|
+
edit_until_done(chest, file.path)
|
25
|
+
end
|
26
|
+
end
|
27
|
+
|
28
|
+
private
|
29
|
+
|
30
|
+
def editor_defined?
|
31
|
+
!ENV['EDITOR'].strip.empty?
|
32
|
+
end
|
33
|
+
|
34
|
+
def edit_until_done(chest, tempfile_path)
|
35
|
+
# Keep editing until there are no syntax errors or user decides to quit
|
36
|
+
loop do
|
37
|
+
unless system(ENV['EDITOR'], tempfile_path)
|
38
|
+
ui.error 'Editor exited unsuccessfully; ignoring any changes made.'
|
39
|
+
break
|
40
|
+
end
|
41
|
+
|
42
|
+
begin
|
43
|
+
update_chest(chest, tempfile_path)
|
44
|
+
ui.success 'Chest updated successfully'
|
45
|
+
break
|
46
|
+
rescue => ex
|
47
|
+
ui.error "Error occurred while modifying the chest: #{ex.message}"
|
48
|
+
|
49
|
+
unless ui.ask('Do you want to try editing the same file again? (y/n)')
|
50
|
+
.argument(:required)
|
51
|
+
.default('y')
|
52
|
+
.modify('downcase')
|
53
|
+
.read_string == 'y'
|
54
|
+
break
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def update_chest(chest, tempfile_path)
|
61
|
+
changed_hash = YAML.load_file(tempfile_path).to_hash
|
62
|
+
chest.update(changed_hash)
|
63
|
+
chest.save # TODO: Show diff and let user accept/reject before saving
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
@@ -0,0 +1,55 @@
|
|
1
|
+
require_relative 'shared/ensure_key'
|
2
|
+
require 'shellwords'
|
3
|
+
|
4
|
+
module Arcanus::Command
|
5
|
+
class Export < Base
|
6
|
+
include Shared::EnsureKey
|
7
|
+
|
8
|
+
description 'Outputs the decrypted values in a format suitable for ' \
|
9
|
+
'consumption by other programs'
|
10
|
+
|
11
|
+
def execute
|
12
|
+
ensure_key_unlocked
|
13
|
+
|
14
|
+
chest = Arcanus::Chest.new(key_file_path: repo.unlocked_key_path,
|
15
|
+
chest_file_path: repo.chest_file_path)
|
16
|
+
|
17
|
+
env_vars = extract_env_vars(chest.contents)
|
18
|
+
|
19
|
+
output_lines =
|
20
|
+
case arguments[1]
|
21
|
+
when nil
|
22
|
+
env_vars.map { |var, val| "#{var}=#{val.to_s.shellescape}" }
|
23
|
+
when '--shell'
|
24
|
+
env_vars.map { |var, val| "export #{var}=#{val.to_s.shellescape}" }
|
25
|
+
when '--docker'
|
26
|
+
# Docker env files don't need any escaping
|
27
|
+
env_vars.map { |var, val| "#{var}=#{val}" }
|
28
|
+
else
|
29
|
+
raise Arcanus::Errors::UsageError, "Unknown export flag #{arguments[1]}"
|
30
|
+
end
|
31
|
+
|
32
|
+
ui.print output_lines.join("\n")
|
33
|
+
end
|
34
|
+
|
35
|
+
private
|
36
|
+
|
37
|
+
def normalize_key(key)
|
38
|
+
key.upcase.tr('-', '_')
|
39
|
+
end
|
40
|
+
|
41
|
+
def extract_env_vars(hash, prefix = '')
|
42
|
+
output = []
|
43
|
+
|
44
|
+
hash.each do |key, value|
|
45
|
+
if value.is_a?(Hash)
|
46
|
+
output += extract_env_vars(value, "#{prefix}#{normalize_key(key)}_")
|
47
|
+
else
|
48
|
+
output << ["#{prefix}#{normalize_key(key)}", value]
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
output
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|