Puppet Function: simplib::passgen::legacy::passgen

Defined in:
lib/puppet/functions/simplib/passgen/legacy/passgen.rb
Function type:
Ruby 4.x API

Overview

simplib::passgen::legacy::passgen(String[1] $identifier, Optional[Hash] $modifier_hash)String

Generates/retrieves a random password string or its hash for a passed identifier.

  • Password info is stored in files on the local file system at ‘Puppet.settings/simp/environments/$environment/simp_autofiles/gen_passwd/`.

  • The minimum length password that this function will return is ‘8` characters.

  • Terminates catalog compilation if the password storage directory cannot be created/accessed by the Puppet user, the password cannot be created in the allotted time, or files not owned by the Puppet user are present in the password storage directory.

Parameters:

  • identifier (String[1])

    Unique ‘String` to identify the password usage.

  • modifier_hash (Optional[Hash])

    Options ‘Hash`. May include any of the following options:

    • ‘last` => `false`(*) or `true`

      * Return the last generated password
      
    • ‘length` => `Integer`

      * Length of the new password
      
    • ‘hash` => `false`(*), `true`, `md5`, `sha256` (true), `sha512`

      * Return a `Hash` of the password instead of the password itself
      
    • ‘complexity` => `0`(*), `1`, `2`

      * `0` => Use only Alphanumeric characters in your password (safest)
      * `1` => Add reasonably safe symbols
      * `2` => Printable ASCII
      
    • ‘user` => user for generated files/directories

      * Defaults to the Puppet user.
      * Only useful when running `puppet apply` as the `root` user.
      
    • ‘group => Group for generated files/directories

      * Defaults to the Puppet user.
      * Only useful when running `puppet apply` as the `root` user.
      

    **private options:**

    • ‘password` => contains the string representation of the password to hash (used for testing)

    • ‘salt` => contains the string literal salt to use (used for testing)

    • ‘complex_only` => use only the characters explicitly added by the complexity rules (used for testing)

Returns:

  • (String)

    Password or password hash specified. If no ‘modifier_hash` or an invalid `modifier_hash` is provided, it will return the currently stored/generated password.



13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
# File 'lib/puppet/functions/simplib/passgen/legacy/passgen.rb', line 13

Puppet::Functions.create_function(:'simplib::passgen::legacy::passgen') do

  # @param identifier Unique `String` to identify the password usage.
  #
  # @param modifier_hash Options `Hash`. May include any
  #   of the following options:
  #   * `last` => `false`(*) or `true`
  #        * Return the last generated password
  #   * `length` => `Integer`
  #        * Length of the new password
  #   * `hash` => `false`(*), `true`, `md5`, `sha256` (true), `sha512`
  #        * Return a `Hash` of the password instead of the password itself
  #   * `complexity` => `0`(*), `1`, `2`
  #        * `0` => Use only Alphanumeric characters in your password (safest)
  #        * `1` => Add reasonably safe symbols
  #        * `2` => Printable ASCII
  #   * `user` => user for generated files/directories
  #        * Defaults to the Puppet user.
  #        * Only useful when running `puppet apply` as the `root` user.
  #   * `group => Group for generated files/directories
  #        * Defaults to the Puppet user.
  #        * Only useful when running `puppet apply` as the `root` user.
  #   **private options:**
  #   * `password` => contains the string representation of the password to hash (used for testing)
  #   * `salt` => contains the string literal salt to use (used for testing)
  #   * `complex_only` => use only the characters explicitly added by the complexity rules (used for testing)
  #
  # @return [String] Password or password hash specified. If no
  #   `modifier_hash` or an invalid `modifier_hash` is provided,
  #   it will return the currently stored/generated password.
  #
  dispatch :passgen do
    required_param 'String[1]', :identifier
    optional_param 'Hash',      :modifier_hash
  end

  def initialize(closure_scope, loader)
    super

    require 'puppet/util/symbolic_file_mode'
    # mixin Puppet::Util::SymbolicFileMode module for symbolic_mode_to_int()
    self.extend(Puppet::Util::SymbolicFileMode)
  end

  def passgen(identifier, modifier_hash={})
    require 'etc'
    require 'timeout'

    scope = closure_scope

    settings = {}

    user = modifier_hash['user'] || Puppet.settings[:user]
    group = modifier_hash['group'] || Puppet.settings[:group]
    begin
      Etc.getpwnam(user)
    rescue ArgumentError
      debug_msg = "simpkv::passgen (legacy): Puppet user '#{user}' not found on system, "
      user = Etc.getpwuid(Process.uid).name
      debug_msg += "defaulting to process owner uid (#{user})"
    end
    begin
      Etc.getgrnam(group)
    rescue ArgumentError
      debug_msg = "simpkv::passgen (legacy): Puppet group '#{group}' not found on system, "
      group = Etc.getgrgid(Process.gid).name
      debug_msg += "defaulting to process owner gid (#{group})"
    end
    settings['user'] = user
    settings['group'] = group

    settings['keydir'] = File.join(Puppet.settings[:vardir], 'simp',
      'environments', scope.lookupvar('::environment'),
      'simp_autofiles', 'gen_passwd'
    )
    settings['min_password_length'] = 8
    settings['default_password_length'] = 32
    settings['crypt_map'] = {
      'md5'     => '1',
      'sha256'  => '5',
      'sha512'  => '6'
    }

    base_options = {
      'return_current'      => false,
      'last'                => false,
      'length'              => settings['default_password_length'],
      'hash'                => false,
      'complexity'          => 0,
      'complex_only'        => false,
      'gen_timeout_seconds' => 30
    }

    options = build_options(base_options, modifier_hash, settings)

    unless File.directory?(settings['keydir'])
      begin
        FileUtils.mkdir_p(settings['keydir'],{:mode => 0750})
        # This chown is applicable as long as it is applied
        # by puppet, not puppetserver.
        FileUtils.chown(settings['user'],
         settings['group'], settings['keydir']
       )
      rescue SystemCallError => e
        err_msg = "simplib::passgen: Could not make directory" +
         " #{settings['keydir']}:  #{e.message}. Ensure that" +
         " #{File.dirname(settings['keydir'])} is writable by" +
         " '#{settings['user']}'"
        fail(err_msg)
      end
    end

    if options['last']
      passwd,salt = get_last_password(identifier, options, settings)
    else
      passwd,salt = get_current_password(identifier, options, settings)
    end

    lockdown_stored_password_perms(settings)

    # Return the hash, not the password
    if options['hash']
      return passwd.crypt("$#{settings['crypt_map'][options['hash']]}$#{salt}")
    else
      return passwd
    end
  rescue Timeout::Error => e
    fail("simplib::passgen timed out for #{identifier}!")
  end


  # Build a merged options hash and validate the options
  # raises ArgumentError if any option in the modifier_hash is invalid
  def build_options(base_options, modifier_hash, settings)
    options = base_options.dup
    if modifier_hash.empty?
      options['return_current'] = true
      return options
    end

    options.merge!(modifier_hash)

    if options['length'].to_s !~ /^\d+$/
      raise ArgumentError,
        "simplib::passgen: Error: Length '#{options['length']}' must be an integer!"
    else
      options['length'] = options['length'].to_i
      if options['length'] == 0
        options['length'] = settings['default_password_length']
      elsif options['length'] < settings['min_password_length']
        options['length'] = settings['min_password_length']
      end
    end

    if options['complexity'].to_s !~ /^\d+$/
      raise ArgumentError,
        "simplib::passgen: Error: Complexity '#{options['complexity']}' must be an integer!"
    else
      options['complexity'] = options['complexity'].to_i
    end

    if options['gen_timeout_seconds'].to_s !~ /^\d+$/
      raise ArgumentError,
        "simplib::passgen: Error: Password generation timeout '#{options['gen_timeout_seconds']}' must be an integer!"
    else
      options['gen_timeout_seconds'] = options['gen_timeout_seconds'].to_i
    end

    # Make sure a valid hash was passed if one was passed.
    if options['hash'] == true
      options['hash'] = 'sha256'
    end
    if options['hash'] and !settings['crypt_map'].keys.include?(options['hash'])
      raise ArgumentError,
       "simplib::passgen: Error: '#{options['hash']}' is not a valid hash."
    end
    return options
  end

  # Generate a password
  def gen_password(options)
    call_function('simplib::gen_random_password',
      options['length'],
      options['complexity'],
      options['complex_only'],
      options['gen_timeout_seconds']
    )
  end

  # Generate the salt to be used to encrypt a password
  def gen_salt(options)
    call_function('simplib::passgen::gen_salt',
      options['gen_timeout_seconds']
    )
  end

  # Retrieve or generate a current password
  #
  # If the password file doesn't exist, the file is empty, or the
  # length of the password that was read from the file is not equal
  # to the length of the expected password, then build a new password
  # file.
  #
  # If no options were passed, and the file exists, then just throw
  # back the value in the file.  If the file is empty, create the new
  # password anyway.
  #
  # Rotate if you're creating a new password.
  #
  # Add an associated 'salt' file for returning crypted passwords.
  def get_current_password(identifier, options, settings)
    # Open the file in append + read mode to prepare for what is to
    # come.
    tgt = File.new("#{settings['keydir']}/#{identifier}","a+")
    tgt_hash = File.new("#{tgt.path}.salt","a+")

    # These chowns are applicable as long as they are applied
    # by puppet, not puppetserver.
    FileUtils.chown(settings['user'],settings['group'],tgt.path)
    FileUtils.chown(settings['user'],settings['group'],tgt_hash.path)

    passwd = ''
    salt = ''

    # Create salt file if not there, no matter what, just in case we have an
    # upgraded system.
    if tgt_hash.stat.size.zero?
      if options.key?('salt')
        salt = options['salt']
      else
        salt = gen_salt(options)
      end
      tgt_hash.puts(salt)
      tgt_hash.rewind
    end

    if tgt.stat.size.zero?
      if options.key?('password')
        passwd = options['password']
      else
        passwd = gen_password(options)
      end
      tgt.puts(passwd)
    else
      passwd = tgt.gets.chomp
      salt = tgt_hash.gets.chomp

      if !options['return_current'] and passwd.length != options['length']
        tgt_last = File.new("#{tgt.path}.last","w+")
        tgt_last.puts(passwd)
        tgt_last.chmod(0640)
        tgt_last.flush
        tgt_last.close

        tgt_hash_last = File.new("#{tgt_hash.path}.last","w+")
        tgt_hash_last.puts(salt)
        tgt_hash_last.chmod(0640)
        tgt_hash_last.flush
        tgt_hash_last.close

        tgt.rewind
        tgt.truncate(0)
        passwd = gen_password(options)
        salt = gen_salt(options)

        tgt.puts(passwd)
        tgt_hash.puts(salt)
      end
    end

    tgt.chmod(0640)
    tgt.flush
    tgt.close

    [passwd, salt]
  end

  # Try to get the last password entry, if it exists.  If it doesn't
  # use the current entry, the 'password' in options, or a freshly-
  # generated password, in that order of precedence.  Also, warn the
  # user about manifest ordering problems, if we had to use the
  # 'password' in options or had to generate a password.
  def get_last_password(identifier, options, settings)
    toread = nil
    if File.exists?("#{settings['keydir']}/#{identifier}.last")
      toread = "#{settings['keydir']}/#{identifier}.last"
    else
      toread = "#{settings['keydir']}/#{identifier}"
    end

    passwd = ''
    salt = ''
    if File.exists?(toread)
      passwd = IO.readlines(toread)[0].to_s.chomp
      sf = "#{File.dirname(toread)}/#{File.basename(toread,'.last')}.salt.last"
      saltfile = File.open(sf,'a+',0640)
      if saltfile.stat.size.zero?
        if options.key?('salt')
          salt = options['salt']
        else
          salt = gen_salt(options)
        end
        saltfile.puts(salt)
        saltfile.close
      end
      salt = IO.readlines(sf)[0].to_s.chomp
    else
      warn_msg = "Could not find a primary or 'last' file for " +
        "#{identifier}, please ensure that you have included this" +
        " function in the proper order in your manifest!"
      Puppet.warning warn_msg
      if options.key?('password')
        passwd = options['password']
      else
        #FIXME?  Why doesn't this persist the password?
        passwd = gen_password(options)
      end
    end
    [passwd, salt]
  end

  # Ensure that the password space is readable and writable by the
  # Puppet user and no other users.
  # Fails if any file/directory not owned by the Puppet user is found.
  def lockdown_stored_password_perms(settings)
    unowned_files = []
    Find.find(settings['keydir']) do |file|
      file_stat = File.stat(file)

      # Does the Puppet user own this file?
      begin
        file_owner = Etc.getpwuid(file_stat.uid).name
        file_group = Etc.getgrgid(file_stat.gid).name

        unowned_files << file unless (file_owner == settings['user'] || file_group == settings['group'] )
      rescue ArgumentError => e
        debug("simplib::passgen: Error getting UID/GID for #{file}: #{e}")

        unowned_files << file
      end

      # Ignore any file/directory that we don't own
      Find.prune if unowned_files.last == file

      FileUtils.chown(settings['user'], settings['group'], file)

      file_mode = file_stat.mode
      desired_mode = symbolic_mode_to_int('u+rwX,g+rX,o-rwx',file_mode,File.directory?(file))

      unless (file_mode & 007777) == desired_mode
        FileUtils.chmod(desired_mode,file)
      end
    end

    unless unowned_files.empty?
      err_msg = <<-EOM.gsub(/^\s+/,'')
        simplib::passgen: Error: Could not verify ownership by '#{settings['user']}' on the following files:
        * #{unowned_files.join("\n* ")}
      EOM

      fail(err_msg)
    end
  end
end