arcanus 0.1.0

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