Class: Maquina::Credentials

Inherits:
Object
  • Object
show all
Defined in:
lib/maquina/credentials.rb,
lib/maquina/credentials/cli.rb,
lib/maquina_credentials/version.rb,
sig/maquina_credentials.rbs

Defined Under Namespace

Classes: CLI, DecryptionFailed, MasterKeyMissing

Constant Summary collapse

DEFAULT_CREDENTIALS_PATH =
"credentials.yml.enc"
ENV_KEY =
"MAQUINA_MASTER_KEY"
FILE_ENV_KEY =
"MAQUINA_CREDENTIALS_FILE"
CIPHER =
"aes-256-gcm"
KEY_LENGTH =
32
IV_LENGTH =
12
AUTH_TAG_LENGTH =
16
HKDF_INFO =
"maquina-credentials-v1"
VERSION =

Returns:

  • (String)
"0.2.0"

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(credentials_path: nil) ⇒ Credentials

Returns a new instance of Credentials.

Parameters:

  • credentials_path: (String? credentials_path) (defaults to: nil)


148
149
150
151
152
# File 'lib/maquina/credentials.rb', line 148

def initialize(credentials_path: nil)
  @credentials_path = self.class.resolve_credentials_path(credentials_path)
  @credentials = nil
  @loaded = false
end

Class Method Details

.decrypt(encrypted, raw_key) ⇒ String

Parameters:

  • encrypted (String)
  • raw_key (String)

Returns:

  • (String)


67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
# File 'lib/maquina/credentials.rb', line 67

def self.decrypt(encrypted, raw_key)
  decoded = strict_base64_decode(encrypted)
  raise DecryptionFailed if decoded.bytesize < IV_LENGTH + AUTH_TAG_LENGTH

  iv = decoded.byteslice(0, IV_LENGTH)
  auth_tag = decoded.byteslice(-AUTH_TAG_LENGTH, AUTH_TAG_LENGTH)
  ciphertext = decoded.byteslice(IV_LENGTH, decoded.bytesize - IV_LENGTH - AUTH_TAG_LENGTH)

  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.decrypt
  cipher.key = derive_key(raw_key)
  cipher.iv = iv
  cipher.auth_tag = auth_tag
  cipher.auth_data = ""
  cipher.update(ciphertext) + cipher.final
rescue ArgumentError, OpenSSL::Cipher::CipherError
  raise DecryptionFailed
end

.deep_merge(base, override) ⇒ Object



127
128
129
130
131
132
133
134
135
# File 'lib/maquina/credentials.rb', line 127

def self.deep_merge(base, override)
  base.merge(override) do |_key, base_value, override_value|
    if base_value.is_a?(Hash) && override_value.is_a?(Hash)
      deep_merge(base_value, override_value)
    else
      override_value
    end
  end
end

.deep_stringify(obj) ⇒ Object



137
138
139
140
141
142
143
144
145
146
# File 'lib/maquina/credentials.rb', line 137

def self.deep_stringify(obj)
  case obj
  when Hash
    obj.transform_keys(&:to_s).transform_values { |value| deep_stringify(value) }
  when Array
    obj.map { |value| deep_stringify(value) }
  else
    obj
  end
end

.derive_key(raw_key) ⇒ Object



98
99
100
101
102
103
104
105
106
107
108
109
# File 'lib/maquina/credentials.rb', line 98

def self.derive_key(raw_key)
  raw_key = raw_key.b
  return raw_key[0, KEY_LENGTH] if raw_key.bytesize == KEY_LENGTH

  OpenSSL::KDF.hkdf(
    raw_key,
    salt: "",
    info: HKDF_INFO,
    length: KEY_LENGTH,
    hash: "SHA256"
  )
end

.encrypt(payload, raw_key) ⇒ String

Parameters:

  • payload (String)
  • raw_key (String)

Returns:

  • (String)


55
56
57
58
59
60
61
62
63
64
65
# File 'lib/maquina/credentials.rb', line 55

def self.encrypt(payload, raw_key)
  cipher = OpenSSL::Cipher.new(CIPHER)
  cipher.encrypt
  cipher.key = derive_key(raw_key)
  iv = cipher.random_iv
  cipher.iv = iv
  cipher.auth_data = ""

  ciphertext = cipher.update(payload) + cipher.final
  strict_base64_encode(iv + ciphertext + cipher.auth_tag)
end

.master_keyObject

Raises:



111
112
113
114
115
116
# File 'lib/maquina/credentials.rb', line 111

def self.master_key
  key = ENV[ENV_KEY]
  raise MasterKeyMissing if key.nil? || key.empty?

  key
end

.merge(hash, credentials_path: nil) ⇒ Object



36
37
38
39
40
# File 'lib/maquina/credentials.rb', line 36

def self.merge(hash, credentials_path: nil)
  credentials_path = resolve_credentials_path(credentials_path)
  existing = read_all(credentials_path: credentials_path)
  write(deep_merge(existing, deep_stringify(hash)), credentials_path: credentials_path)
end

.read_all(credentials_path: nil) ⇒ Object



42
43
44
45
46
47
48
49
50
51
52
53
# File 'lib/maquina/credentials.rb', line 42

def self.read_all(credentials_path: nil)
  credentials_path = resolve_credentials_path(credentials_path)
  return {} unless File.exist?(credentials_path)

  decrypted = decrypt(File.read(credentials_path), master_key)
  loaded = YAML.safe_load(decrypted, permitted_classes: [], symbolize_names: false)
  raise DecryptionFailed unless loaded.is_a?(Hash)

  deep_stringify(loaded)
rescue Psych::Exception
  raise DecryptionFailed
end

.resolve_credentials_path(credentials_path = nil) ⇒ String

Parameters:

  • credentials_path (String, nil) (defaults to: nil)

Returns:

  • (String)


118
119
120
121
122
123
124
125
# File 'lib/maquina/credentials.rb', line 118

def self.resolve_credentials_path(credentials_path = nil)
  return credentials_path unless credentials_path.nil? || credentials_path.empty?

  env_path = ENV[FILE_ENV_KEY]
  return env_path unless env_path.nil? || env_path.empty?

  File.join(Dir.pwd, DEFAULT_CREDENTIALS_PATH)
end

.strict_base64_decode(encoded) ⇒ Object



90
91
92
93
94
95
96
# File 'lib/maquina/credentials.rb', line 90

def self.strict_base64_decode(encoded)
  unless encoded.match?(/\A(?:[A-Za-z0-9+\/]{4})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?\z/)
    raise ArgumentError
  end

  encoded.unpack1("m0")
end

.strict_base64_encode(bytes) ⇒ Object



86
87
88
# File 'lib/maquina/credentials.rb', line 86

def self.strict_base64_encode(bytes)
  [bytes].pack("m0")
end

.write(hash, credentials_path: nil) ⇒ void

This method returns an undefined value.

Parameters:

  • hash (Hash[untyped, untyped])
  • credentials_path: (String? credentials_path) (defaults to: nil)


22
23
24
25
26
27
28
29
30
31
32
33
34
# File 'lib/maquina/credentials.rb', line 22

def self.write(hash, credentials_path: nil)
  credentials_path = resolve_credentials_path(credentials_path)
  payload = YAML.dump(deep_stringify(hash))
  encrypted = encrypt(payload, master_key)
  tmp_path = "#{credentials_path}.tmp.#{Process.pid}"

  FileUtils.mkdir_p(File.dirname(credentials_path))
  File.write(tmp_path, encrypted)
  File.rename(tmp_path, credentials_path)
  File.chmod(0o600, credentials_path)
ensure
  File.delete(tmp_path) if tmp_path && File.exist?(tmp_path)
end

Instance Method Details

#read(path) ⇒ String

Parameters:

  • path (String, nil)

Returns:

  • (String)


154
155
156
157
158
159
160
161
162
163
164
# File 'lib/maquina/credentials.rb', line 154

def read(path)
  return "" if path.nil? || path.empty?

  value = path.split(".").reduce(credentials) do |current, key|
    break unless current.is_a?(Hash)

    current[key]
  end

  value.nil? ? "" : value.to_s
end