class Arcanus::Chest

Encapsulates the collection of encrypted secrets managed by Arcanus.

Constants

ENCRYPTION_CIPHER
SIGNATURE_SIZE_BITS

Public Class Methods

new(key:, chest_file_path:) click to toggle source
# File lib/arcanus/chest.rb, line 12
def initialize(key:, chest_file_path:)
  @key = key
  @chest_file_path = chest_file_path
  @original_encrypted_hash = YAML.load_file(chest_file_path).to_hash
  @original_decrypted_hash = decrypt_hash(@original_encrypted_hash)
  @hash = Utils.deep_dup(@original_decrypted_hash)
end

Public Instance Methods

[](key) click to toggle source

Access the chest as if it were a hash.

@param key [String] @return [Object]

# File lib/arcanus/chest.rb, line 24
def [](key)
  if @hash.key?(key)
    value = @hash[key]
    if value.is_a?(Hash)
      Item.new(value, [key])
    else
      value
    end
  else
    raise KeyError,
          "Key '#{key}' does not exist in this Arcanus chest",
          caller
  end
end
fetch(*args) click to toggle source

Fetch key from the chest as if it were a hash.

# File lib/arcanus/chest.rb, line 57
def fetch(*args)
  @hash.fetch(*args)
end
get(key_path) click to toggle source

Get value at the specified key path.

@param key_path [String] @return [Object]

# File lib/arcanus/chest.rb, line 88
def get(key_path)
  keys = key_path.split('.')
  keys.inject(@hash) { |hash, key| hash[key] }
rescue NoMethodError
  raise Arcanus::Errors::InvalidKeyPathError,
        "Key path '#{key_path}' does not correspond to an actual key"
end
method_missing(method_sym, *args) click to toggle source
Calls superclass method
# File lib/arcanus/chest.rb, line 39
def method_missing(method_sym, *args)
  method_name = method_sym.to_s
  if @hash.key?(method_name)
    self[method_name]
  else
    super
  end
end
respond_to?(method_sym, *) click to toggle source
Calls superclass method
# File lib/arcanus/chest.rb, line 52
def respond_to?(method_sym, *)
  @hash.key?(method_sym.to_s) || super
end
respond_to_missing?(method_name, *args) click to toggle source
Calls superclass method
# File lib/arcanus/chest.rb, line 48
def respond_to_missing?(method_name, *args)
  @hash.key?(method_name.to_s) ? true : super
end
save() click to toggle source

For each key in the chest, encrypt the new value if it has changed.

The goal is to create a file where the only lines that differ are the keys that changed.

# File lib/arcanus/chest.rb, line 104
def save
  modified_hash =
    process_hash_changes(@original_encrypted_hash, @original_decrypted_hash, @hash)

  File.open(@chest_file_path, 'w') do |f|
    f.puts('# Do not edit this file directly! Run `arcanus edit`')
    f.write(modified_hash.to_yaml)
  end
end
set(key_path, value) click to toggle source

Set value for the specified key path.

@param key_path [String] @param value [Object]

# File lib/arcanus/chest.rb, line 75
def set(key_path, value)
  keys = key_path.split('.')
  nested_hash = keys[0..-2].inject(@hash) { |hash, key| hash[key] }
  nested_hash[keys[-1]] = value
rescue NoMethodError
  raise Arcanus::Errors::InvalidKeyPathError,
        "Key path '#{key_path}' does not correspond to an actual key"
end
to_hash() click to toggle source

Returns contents of the chest as a hash.

# File lib/arcanus/chest.rb, line 62
def to_hash
  @hash.dup
end
to_yaml() click to toggle source

Returns contents of the chest as YAML.

# File lib/arcanus/chest.rb, line 67
def to_yaml
  @hash.to_yaml
end
update(new_hash) click to toggle source
# File lib/arcanus/chest.rb, line 96
def update(new_hash)
  @hash = new_hash
end

Private Instance Methods

decrypt_hash(hash) click to toggle source
# File lib/arcanus/chest.rb, line 148
def decrypt_hash(hash)
  hash.each_with_object({}) do |(key, value), decrypted_hash|
    begin
      decrypted_hash[key] = value.is_a?(Hash) ? decrypt_hash(value) : decrypt_value(value)
    rescue Errors::DecryptionError => ex
      raise Errors::DecryptionError,
            "Problem decrypting value for key '#{key}': #{ex.message}"
    end
  end
end
decrypt_value(blob) click to toggle source
# File lib/arcanus/chest.rb, line 179
def decrypt_value(blob) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
  unless blob.is_a?(String)
    raise Errors::DecryptionError,
          "Expecting an encrypted blob but got '#{blob}'"
  end

  iv_b64, enc_sym_key_b64, encrypted_value_b64, salt, signature = blob.split(':')

  if signature.nil? || salt.nil? || encrypted_value_b64.nil? ||
     enc_sym_key_b64.nil? || iv_b64.nil?
    raise Errors::DecryptionError,
          "Invalid blob format '#{blob}'. " \
          'Did you encrypt this with an older version of Arcanus?'
  end

  iv = Base64.decode64(iv_b64)
  sym_key = @key.decrypt(Base64.decode64(enc_sym_key_b64))
  ENCRYPTION_CIPHER.reset.decrypt
  ENCRYPTION_CIPHER.iv = iv
  ENCRYPTION_CIPHER.key = sym_key
  dumped_value = ENCRYPTION_CIPHER.update(Base64.decode64(encrypted_value_b64)) +
    ENCRYPTION_CIPHER.final

  actual_signature = Digest::SHA2.new(SIGNATURE_SIZE_BITS).tap do |digest|
    digest << salt
    digest << dumped_value
  end.to_s

  if signature != actual_signature
    raise Errors::DecryptionError,
          'Signature of decrypted value does not match: ' \
          "expected #{signature} but got #{actual_signature}"
  end

  Marshal.load(dumped_value) # rubocop:disable MarshalLoad
end
encrypt_value(value) click to toggle source
# File lib/arcanus/chest.rb, line 159
def encrypt_value(value)
  dumped_value = Marshal.dump(value)

  # Create a random symmetric key so we can encrypt plaintext of arbitrary length
  sym_key = ENCRYPTION_CIPHER.reset.encrypt.random_key
  iv = Base64.encode64(ENCRYPTION_CIPHER.random_iv)
  enc_sym_key = Base64.encode64(@key.encrypt(sym_key))
  encrypted_value = Base64.encode64(ENCRYPTION_CIPHER.update(dumped_value) +
                                    ENCRYPTION_CIPHER.final)

  salt = SecureRandom.hex(8)

  signature = Digest::SHA2.new(SIGNATURE_SIZE_BITS).tap do |digest|
    digest << salt
    digest << dumped_value
  end.to_s

  "#{iv}:#{enc_sym_key}:#{encrypted_value}:#{salt}:#{signature}"
end
process_hash_changes(original_encrypted, original_decrypted, current) click to toggle source
# File lib/arcanus/chest.rb, line 116
def process_hash_changes(original_encrypted, original_decrypted, current) # rubocop:disable Metrics/MethodLength, Metrics/LineLength
  result = {}

  current.keys.each do |key|
    value = current[key]

    result[key] =
      if original_encrypted.key?(key)
        # Key still exists; check if modified.
        if value.is_a?(Hash)
          if original_encrypted[key].is_a?(Hash)
            process_hash_changes(original_encrypted[key], original_decrypted[key], value)
          else
            # Key changed from single value to hash, so no previous has to compare against
            process_hash_changes({}, {}, value)
          end
        elsif original_decrypted[key] != value
          # Value was changed; encrypt the new value
          encrypt_value(value)
        else
          # Value wasn't changed; keep original encrypted blob
          original_encrypted[key]
        end
      else
        # Key was added
        value.is_a?(Hash) ? process_hash_changes({}, {}, value) : encrypt_value(value)
      end
  end

  result
end