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,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: 52e5895e0c0560d9a912260b996068dce2b1a775
4
+ data.tar.gz: d80b64ee9c1774a87b472d08f2780e65971fb075
5
+ SHA512:
6
+ metadata.gz: df1be1a9de6798d5ca787f6a1d5d8e92e29533afbfd7a83ef05ad879a40560377f6a6bfb4d98013471250f8433df5cf9afb84a9229a041d89233d9fc9747188f
7
+ data.tar.gz: 04ce87f5fe98d26de59e1ac8e4e1d15dd9e308a595bdc313d4a51fbcfad2ab22dcdf46169ed188f55f182fb9dab8edeb31ab01ef633ba41826a18f320d1a5dd5
@@ -0,0 +1,7 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'arcanus/cli'
4
+
5
+ input = Arcanus::Input.new(STDIN)
6
+ output = Arcanus::Output.new(STDOUT)
7
+ exit Arcanus::CLI.new(input: input, output: output).run(ARGV)
@@ -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
@@ -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