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
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
|