# -*- coding: utf-8 -*- ''' Manage Mac OSX local directory passwords and policies. Note that it is usually better to apply password policies through the creation of a configuration profile. Tech Notes: Usually when a password is changed by the system, there's a responsibility to check the hash list and generate hashes for each. Many osx password changing scripts/modules only deal with the SHA-512 PBKDF2 hash when working with the local node. ''' # Authentication concepts reference: # https://developer.apple.com/library/mac/documentation/Networking/Conceptual/Open_Directory/openDirectoryConcepts/openDirectoryConcepts.html#//apple_ref/doc/uid/TP40000917-CH3-CIFCAIBB from __future__ import absolute_import import logging log = logging.getLogger(__name__) # Start logging import os import base64 import salt.utils import string import binascii import salt.exceptions try: from passlib.utils import pbkdf2, ab64_encode, ab64_decode HAS_PASSLIB = True except ImportError: HAS_PASSLIB = False def __virtual__(): if HAS_PASSLIB and salt.utils.platform.is_darwin(): return True else: return False def _pl_salted_sha512_pbkdf2_from_string(strvalue, salt_bin=None, iterations=1000): ''' Create a PBKDF2-SHA512 hash with a 128 byte key length. The standard passlib.hash.pbkdf2_sha512 functions assume a 64 byte key length which does not match OSX's implementation. :param strvalue: The string to derive the hash from :param salt: The (randomly generated) salt :param iterations: The number of iterations, for Mac OS X it's normally between 23000-25000? need to confirm. :return: (binary digest, binary salt, number of iterations used) ''' if salt_bin is None: salt_bin = os.urandom(32) key_length = 128 hmac_sha512, dsize = pbkdf2.get_prf("hmac-sha512") digest_bin = pbkdf2.pbkdf2(strvalue, salt_bin, iterations, key_length, hmac_sha512) return digest_bin, salt_bin, iterations def _extract_authdata(item): ''' Extract version, authority tag, and authority data from a single array item of AuthenticationAuthority item The NSString instance representing the authority string returns version (default 1.0.0), tag, data as a tuple ''' parts = string.split(item, ';', 2) if not parts[0]: parts[0] = '1.0.0' return { 'version': parts[0], 'tag': parts[1], 'data': parts[2] } def authorities(name): ''' Read the list of authentication authorities for the given user. name Short username of the local user. ''' authorities_plist = __salt__['cmd.run']('/usr/bin/dscl -plist . read /Users/{0} AuthenticationAuthority'.format(name)) plist = __salt__['plist.parse_string'](authorities_plist) authorities_list = [_extract_authdata(item) for item in plist.objectForKey_('dsAttrTypeStandard:AuthenticationAuthority')] return authorities_list def user_shadowhash(name): ''' Read the existing hash for the named user. Returns a dict with the ShadowHash content for the named user in the form: { 'HASH_TYPE': { 'entropy': <base64 hash>, 'salt': <base64 salt>, 'iterations': <n iterations> }} Hash types are hard coded to SALTED-SHA-PBKDF2, CRAM-MD5, NT, RECOVERABLE. In future releases the AuthenticationAuthority property should be checked for the hash list name The username associated with the local directory user. ''' # We have to strip the output string, convert hex back to binary data, read that plist and get our specific # key/value property to find the hash. I.E there's a lot of unwrapping to do. log.debug('Reading ShadowHashData') data = __salt__['dscl.read']('.', '/Users/{0}'.format(name), 'ShadowHashData') log.debug('Got ShadowHashData') log.debug(data) if data is None: log.debug('No such record/attribute found, returning None') return None if 'dsAttrTypeNative:ShadowHashData' not in data: raise salt.exceptions.SaltInvocationError( 'Expected to find ShadowHashData in user record: {0}'.format(name) ) plist_hex = string.replace(data['dsAttrTypeNative:ShadowHashData'], ' ', '') plist_bin = binascii.unhexlify(plist_hex) # plistlib is not used, because mavericks ships without binary plist support from plistlib. plist = __salt__['plist.parse_string'](plist_bin) log.debug(plist) pbkdf = plist.objectForKey_('SALTED-SHA512-PBKDF2') cram_md5 = plist.objectForKey_('CRAM-MD5') nt = plist.objectForKey_('NT') recoverable = plist.objectForKey_('RECOVERABLE') hashes = {} if pbkdf is not None: hashes['SALTED-SHA512-PBKDF2'] = { 'entropy': pbkdf.objectForKey_('entropy').base64EncodedStringWithOptions_(0), 'salt': pbkdf.objectForKey_('salt').base64EncodedStringWithOptions_(0), 'iterations': pbkdf.objectForKey_('iterations') } if cram_md5 is not None: hashes['CRAM-MD5'] = cram_md5.base64EncodedStringWithOptions_(0) if nt is not None: hashes['NT'] = nt.base64EncodedStringWithOptions_(0) if recoverable is not None: hashes['RECOVERABLE'] = recoverable.base64EncodedStringWithOptions_(0) return hashes def info(name): ''' Return information for the specified user CLI Example: .. code-block:: bash salt '*' mac_shadow.info admin ''' # dscl -plist . -read /Users/<User> ShadowHashData # Read out name from dscl # Read out passwd hash from decrypted ShadowHashData in dslocal # Read out lstchg/min/max/warn/inact/expire from PasswordPolicy pass def gen_password(password, salt=None, iterations=None): ''' Generate hashed (PBKDF2-SHA512) password Returns a dict containing values for 'entropy', 'salt' and 'iterations'. password Plaintext password to be hashed. salt Cryptographic salt (base64 encoded). If not given, a random 32-character salt will be generated. (32 bytes is the standard salt length for OSX) iterations Number of iterations for the key derivation function, default is 1000 CLI Example: .. code-block:: bash salt '*' mac_shadow.gen_password 'I_am_password' salt '*' mac_shadow.gen_password 'I_am_password' 'Ausrbk5COuB9V4ata6muoj+HPjA92pefPfbW9QPnv9M=' 23000 ''' if iterations is None: iterations = 1000 if salt is None: salt_bin = os.urandom(32) else: salt_bin = base64.b64decode(salt, '+/') entropy, used_salt, used_iterations = _pl_salted_sha512_pbkdf2_from_string(password, salt_bin, iterations) result = { 'entropy': base64.b64encode(entropy, '+/'), 'salt': base64.b64encode(used_salt, '+/'), 'iterations': used_iterations } return {'SALTED-SHA512-PBKDF2': result} def set_password_hash(name, hashtype, hash, salt=None, iterations=None): ''' Set the given hash as the shadow hash data for the named user. name The name of the local user, which is assumed to be in the local directory service. hashtype A valid hash type, one of: PBKDF2, CRAM-MD5, NT, RECOVERABLE hash The computed hash salt (optional) The salt to use, if applicable. iterations The number of iterations to use, if applicable. ''' # current_hashes = user_shadowhash(name) # current_pbkdf2 = current_hashes['SALTED-SHA512-PBKDF2'] # # log.debug('Current ShadowHashdata follows') # log.debug(current_hashes) shd = {'SALTED-SHA512-PBKDF2': {'entropy': hash, 'salt': salt, 'iterations': iterations}} log.debug('Encoding following dict as bplist') log.debug(shd) # if shd['SALTED-SHA512-PBKDF2']['entropy'] == current_pbkdf2['entropy']: # log.debug('Entropy IS EQUAL!') shd_bplist = __salt__['plist.gen_string'](shd, 'binary') shd_bplist_b64 = base64.b64encode(shd_bplist, '+/') log.debug('Flushing directory services cache') __salt__['dscl.flushcache']() log.debug('Writing directly to dslocal') __salt__['plist.append_key']('/var/db/dslocal/nodes/Default/users/{0}.plist'.format(name), 'ShadowHashData', 'data', shd_bplist_b64) log.debug('Flushing directory services cache') __salt__['dscl.flushcache']() return True def set_password(name, password, salt=None, iterations=None): ''' Set the password for a named user (insecure). Use mac_shadow.set_password_hash to supply pre-computed hash values. For the moment this sets only the PBKDF2-SHA512 salted hash. To be a good citizen we should set every hash in the authority list. name The name of the local user, which is assumed to be in the local directory service. password The plaintext password to set (warning: insecure, used for testing) salt The salt to use, defaults to automatically generated. iterations The number of iterations to use, defaults to an automatically generated random number. CLI Example: .. code-block:: bash salt '*' mac_shadow.set_password macuser macpassword ''' #current_hashes = user_shadowhash(name) #current_pbkdf2 = current_hashes['SALTED-SHA512-PBKDF2'] # hash = gen_password(password, current_pbkdf2['salt'], current_pbkdf2['iterations']) hash = gen_password(password, salt, iterations) # # log.debug('Current ShadowHashData follows') # if current_hashes: # log.debug(current_hashes) # # if hash['SALTED-SHA512-PBKDF2']['entropy'] == current_pbkdf2['entropy']: # return False # No change required # else: # log.debug('No Shadow Hash Data exists for User: {0}'.format(name)) set_password_hash( name, 'PBKDF2', hash['SALTED-SHA512-PBKDF2']['entropy'], hash['SALTED-SHA512-PBKDF2']['salt'], hash['SALTED-SHA512-PBKDF2']['iterations'] ) return True def del_password(name): ''' Delete the password from name user CLI Example: .. code-block:: bash salt '*' shadow.del_password username ''' pass # Re-order authentication authority and remove ShadowHashData