├── locallibs ├── __init__.py ├── productbuild.py ├── wrappers.py ├── kcpassword.py ├── arc4random.py ├── shadowhash.py ├── plistutils.py ├── userplist.py ├── userpkg.py └── pbkdf2.py ├── pkg_scripts ├── createuser └── postinstall ├── createuser ├── .gitignore ├── createuser │ └── main.m └── createuser.xcodeproj │ └── project.pbxproj ├── .gitignore ├── LICENSE.md ├── README.md └── createuserpkg.py /locallibs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pkg_scripts/createuser: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gregneagle/pycreateuserpkg/HEAD/pkg_scripts/createuser -------------------------------------------------------------------------------- /createuser/.gitignore: -------------------------------------------------------------------------------- 1 | # .DS_Store files! 2 | .DS_Store 3 | 4 | # Xcode user data 5 | *.xcodeproj/project.xcworkspace/ 6 | *.xcodeproj/xcuserdata/ 7 | 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # .DS_Store files! 2 | .DS_Store 3 | 4 | # don't track .pyc files 5 | *.pyc 6 | 7 | # don't track packages that get built here 8 | *.pkg 9 | 10 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Licensed under the Apache License, Version 2.0 (the "License"); 2 | you may not use this source code except in compliance with the License. 3 | You may obtain a copy of the License at 4 | 5 | https://www.apache.org/licenses/LICENSE-2.0 6 | 7 | Unless required by applicable law or agreed to in writing, software 8 | distributed under the License is distributed on an "AS IS" BASIS, 9 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 10 | See the License for the specific language governing permissions and 11 | limitations under the License. 12 | -------------------------------------------------------------------------------- /locallibs/productbuild.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | 3 | import os 4 | import shutil 5 | import subprocess 6 | import sys 7 | 8 | class ProductBuildException(Exception): 9 | '''Error when creating pkg''' 10 | pass 11 | 12 | def generate(info, createuserpkg_dir): 13 | # Hide and rename created component package 14 | component_pkg = os.path.dirname(info[u'destination_path']) + "/.tmp.pkg" 15 | shutil.move(os.path.expanduser(info[u'destination_path']), component_pkg) 16 | 17 | # Create distribution package 18 | try: 19 | cmd = ['/usr/bin/productbuild', 20 | '--package', component_pkg, 21 | '--identifier', info[u'pkgid'], 22 | '--version', info[u'version'], 23 | os.path.expanduser(info[u'destination_path']) 24 | ] 25 | retcode = subprocess.call(cmd) 26 | if retcode: 27 | raise PkgException('Product creation failed') 28 | except (OSError, IOError) as err: 29 | raise ProductBuildException(err) 30 | finally: 31 | os.remove(component_pkg) 32 | -------------------------------------------------------------------------------- /locallibs/wrappers.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # Copyright 2022 Greg Neagle. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | '''Wrappers for Py2 vs Py3 stuff''' 17 | 18 | from __future__ import absolute_import 19 | 20 | def unicodify(something, encoding="UTF-8"): 21 | '''Makes sure the string is unicode''' 22 | try: 23 | # Python 2 24 | if isinstance(something, str): 25 | return unicode(something, encoding) 26 | return unicode(something) 27 | except NameError: 28 | # Python 3 29 | if isinstance(something, bytes): 30 | return str(something, encoding) 31 | return str(something) -------------------------------------------------------------------------------- /locallibs/kcpassword.py: -------------------------------------------------------------------------------- 1 | '''kcpassword encoder''' 2 | 3 | # Port of Gavin Brock's Perl kcpassword generator to Python, by Tom Taylor 4 | # . 5 | # Perl version: http://www.brock-family.org/gavin/perl/kcpassword.html 6 | 7 | # This version: 8 | # https://gitlab.cates.io/packer/osx-vm-templates/blob/1790a91e95af3f24ca464a28297cb66d29e1b5be/scripts/support/set_kcpassword.py 9 | 10 | from __future__ import absolute_import 11 | 12 | import sys 13 | import os 14 | 15 | 16 | def generate(passwd): 17 | '''Given a password, generates the data for the kcpasswd file used 18 | by autologin.''' 19 | # The magic 11 bytes - these are just repeated 20 | # 0x7D 0x89 0x52 0x23 0xD2 0xBC 0xDD 0xEA 0xA3 0xB9 0x1F 21 | key = [125,137,82,35,210,188,221,234,163,185,31] 22 | key_len = len(key) 23 | block_size=key_len+1 24 | 25 | #convert to array add zero to end of password 26 | passwd = [ord(x) for x in list(passwd)] + [0] 27 | 28 | # pad passwd length out to an even multiple of block_size 29 | r = len(passwd) % (block_size) 30 | if (r > 0): 31 | passwd = passwd + [0] * (block_size - r) 32 | for n in range(0, len(passwd), len(key)): 33 | ki = 0 34 | for j in range(n, min(n+len(key), len(passwd))): 35 | passwd[j] = passwd[j] ^ key[ki] 36 | ki += 1 37 | 38 | if (len(passwd) == 0): 39 | passwd = [125, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0] 40 | 41 | passwd = [chr(x) for x in passwd] 42 | return "".join(passwd) 43 | 44 | -------------------------------------------------------------------------------- /locallibs/arc4random.py: -------------------------------------------------------------------------------- 1 | '''py-arc4random 2 | 3 | Basic python 2.7/3 implementation of OpenBSDs arc4random PRNG. 4 | 5 | https://github.com/rolandshoemaker/py-arc4random 6 | 7 | Examples 8 | >>> import arc4random 9 | >>> arc4random.rand() 10 | 2057591911 11 | >>> arc4random.randrange(50,100) 12 | 75 13 | >>> arc4random.randsample(0,1, 10) 14 | [1, 1, 0, 1, 1, 0, 1, 1, 1, 1] 15 | ''' 16 | from __future__ import absolute_import 17 | 18 | import random 19 | 20 | def rand(): 21 | key = random.sample(list(range(256)), 256) # something 22 | seeds = _RC4PRGA(_RC4keySchedule(key)) 23 | return (seeds[0]<<24)|(seeds[1]<<16)|(seeds[2]<<8)|seeds[3] 24 | 25 | def randrange(x, y=None): 26 | if y: 27 | return (rand()%((y-x)+1))+x 28 | else: 29 | return rand()%(x+1) 30 | 31 | def randsample(Rmin, Rmax, size): 32 | sample = [] 33 | for i in range(size): 34 | sample.append((rand()%((Rmax-Rmin)+1))+Rmin) 35 | return sample 36 | 37 | def _RC4keySchedule(key): 38 | sbox = list(range(256)) 39 | x = 0 40 | keySize = len(key) 41 | for i in sbox: 42 | x = (x+i+key[i%keySize])%256 43 | _swap(sbox, i, x) 44 | return sbox 45 | 46 | def _RC4PRGA(state): 47 | x, y = 0, 0 48 | seeds = [] 49 | # Discard first 1536 bytes of the keystream according to RFC4345 as they may reveal information 50 | # about key used (a set of these keys could reveal information about the source for our key) 51 | for i in range((1536//4)+4): 52 | x = (x+1)%256 53 | y = (y+state[x])%256 54 | _swap(state, x, y) 55 | if i >= (1536//4): 56 | seeds.append(state[(state[x]+state[y])%256]) 57 | return seeds 58 | 59 | def _swap(listy, n1, n2): 60 | listy[n1], listy[n2] = listy[n2], listy[n1] -------------------------------------------------------------------------------- /locallibs/shadowhash.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Greg Neagle. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | '''Functions for generating ShadowHashData''' 16 | 17 | from __future__ import absolute_import 18 | 19 | import hashlib 20 | 21 | from . import arc4random 22 | from . import pbkdf2 23 | 24 | from . import plistutils 25 | 26 | # remap buffer in Python 3 27 | try: 28 | _ = buffer 29 | except NameError: 30 | buffer = memoryview 31 | 32 | 33 | def make_salt(saltlen): 34 | '''Generate a random salt''' 35 | salt = bytearray() 36 | for char in arc4random.randsample(0, 255, saltlen): 37 | salt.append(char) 38 | return bytes(salt) 39 | 40 | 41 | def generate(password): 42 | '''Generate a ShadowHashData structure as used by macOS 10.8+''' 43 | iterations = arc4random.randrange(30000, 50000) 44 | salt = make_salt(32) 45 | keylen = 128 46 | try: 47 | entropy = hashlib.pbkdf2_hmac( 48 | 'sha512', password.encode("UTF-8"), salt, iterations, dklen=keylen) 49 | except AttributeError: 50 | # old Python, do it a different way 51 | entropy = pbkdf2.pbkdf2_bin( 52 | password.encode("UTF-8"), salt, iterations=iterations, keylen=keylen, 53 | hashfunc=hashlib.sha512) 54 | 55 | data = { 56 | 'SALTED-SHA512-PBKDF2': { 57 | 'entropy': buffer(entropy), 58 | 'iterations': iterations, 59 | 'salt': buffer(salt) 60 | }, 61 | } 62 | return plistutils.write_plist(data, plist_format='binary') 63 | -------------------------------------------------------------------------------- /locallibs/plistutils.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Greg Neagle. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | '''plist utility functions''' 16 | 17 | from __future__ import absolute_import 18 | 19 | # PyLint cannot properly find names inside Cocoa libraries, so issues bogus 20 | # No name 'Foo' in module 'Bar' warnings. Disable them. 21 | # pylint: disable=E0611 22 | from Foundation import NSPropertyListSerialization 23 | from Foundation import NSPropertyListXMLFormat_v1_0 24 | from Foundation import NSPropertyListBinaryFormat_v1_0 25 | # pylint: enable=E0611 26 | 27 | 28 | class FoundationPlistException(Exception): 29 | """Basic exception for plist errors""" 30 | pass 31 | 32 | 33 | def write_plist(data_object, pathname=None, plist_format=None): 34 | ''' 35 | Write 'rootObject' as a plist to pathname or return as a string. 36 | ''' 37 | if plist_format == 'binary': 38 | plist_format = NSPropertyListBinaryFormat_v1_0 39 | else: 40 | plist_format = NSPropertyListXMLFormat_v1_0 41 | 42 | plist_data, error = ( 43 | NSPropertyListSerialization. 44 | dataFromPropertyList_format_errorDescription_( 45 | data_object, plist_format, None)) 46 | if plist_data is None: 47 | if error: 48 | error = error.encode('ascii', 'ignore') 49 | else: 50 | error = "Unknown error" 51 | raise FoundationPlistException(error) 52 | if pathname: 53 | if plist_data.writeToFile_atomically_(pathname, True): 54 | return 55 | else: 56 | raise FoundationPlistException( 57 | "Failed to write plist data to %s" % pathname) 58 | else: 59 | return plist_data 60 | -------------------------------------------------------------------------------- /locallibs/userplist.py: -------------------------------------------------------------------------------- 1 | # encoding: utf-8 2 | # Copyright 2017 Greg Neagle. 3 | # 4 | # Licensed under the Apache License, Version 2.0 (the "License"); 5 | # you may not use this file except in compliance with the License. 6 | # You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software 11 | # distributed under the License is distributed on an "AS IS" BASIS, 12 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | # See the License for the specific language governing permissions and 14 | # limitations under the License. 15 | 16 | '''Generates our user account plist''' 17 | 18 | from __future__ import absolute_import 19 | 20 | import uuid 21 | 22 | 23 | class UserPlistException(Exception): 24 | '''Error when creating user plist''' 25 | pass 26 | 27 | 28 | def generate(user_dict): 29 | '''Generates a local directory services user account plist''' 30 | user = {} 31 | required_keys = [u'name', u'uid', u'ShadowHashData'] 32 | writers_keys = [u'_writers_hint', u'_writers_jpegphoto', u'_writers_passwd', 33 | u'_writers_picture', u'_writers_realname', 34 | u'_writers_UserCertificate'] 35 | for key in required_keys: 36 | if key not in user_dict: 37 | raise UserPlistException(u'Missing %s!' % key) 38 | user[u'name'] = [user_dict[u'name']] 39 | user[u'uid'] = [user_dict[u'uid']] 40 | user[u'gid'] = [user_dict.get(u'gid', u'20')] 41 | user[u'home'] = [user_dict.get(u'home', u'/Users/%s' % user_dict[u'name'])] 42 | user[u'realname'] = [user_dict.get(u'realname', user_dict[u'name'])] 43 | user[u'shell'] = [user_dict.get(u'shell', u'/bin/bash')] 44 | user[u'generateduid'] = [user_dict.get(u'uuid', str(uuid.uuid4()).upper())] 45 | user[u'passwd'] = [u'********'] 46 | user[u'authentication_authority'] = [ 47 | ';ShadowHash;HASHLIST:'] 48 | user[u'ShadowHashData'] = [user_dict['ShadowHashData']] 49 | for key in writers_keys: 50 | user[key] = [user_dict[u'name']] 51 | if u'image_path' in user_dict: 52 | user[u'picture'] = [user_dict[u'image_path']] 53 | if u'image_data' in user_dict: 54 | user[u'jpegphoto'] = [user_dict[u'image_data']] 55 | if u'IsHidden' in user_dict: 56 | user[u'IsHidden'] = [user_dict[u'IsHidden']] 57 | if u'hint' in user_dict: 58 | user[u'hint'] = [user_dict[u'hint']] 59 | return user 60 | -------------------------------------------------------------------------------- /pkg_scripts/postinstall: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # postinstall for local account install 4 | # 5 | # PlistArrayAdd() by Per Olofsson (aka MagerValp); parts of the rest of the 6 | # script also indebited to work by Per 7 | 8 | 9 | PlistArrayAdd() { 10 | # Add $value to $array_name in $plist_path, creating if necessary 11 | local plist_path="$1" 12 | local array_name="$2" 13 | local value="$3" 14 | local old_values 15 | local item 16 | 17 | old_values=$(/usr/libexec/PlistBuddy -c "Print :$array_name" "$plist_path" 2>/dev/null) 18 | if [[ $? == 1 ]]; then 19 | # Array doesn't exist, create it 20 | /usr/libexec/PlistBuddy -c "Add :$array_name array" "$plist_path" 21 | else 22 | # Array already exists, check if array already contains value 23 | IFS=$'\012' 24 | for item in $old_values; do 25 | unset IFS 26 | if [[ "$item" =~ ^\ *$value$ ]]; then 27 | # Array already contains value 28 | return 0 29 | fi 30 | done 31 | unset IFS 32 | fi 33 | # Add item to array 34 | /usr/libexec/PlistBuddy -c "Add :$array_name: string \"$value\"" "$plist_path" 35 | } 36 | 37 | 38 | # set our current directory (the one this script is in) 39 | # dirname is not available in Recovery, so we use this cryptic shell-ism 40 | MYDIR=${0%/*} 41 | 42 | # find and source our config file to set our variables 43 | source "$MYDIR/config" 44 | 45 | if [ "$3" == "/" ]; then 46 | # create user using OpenDirectory APIs 47 | "$MYDIR"/createuser "$MYDIR/$USERNAME.plist" 48 | if [ "$?" -ne 0 ] ; then 49 | # createuser failed, can't continue 50 | exit -1 51 | fi 52 | # we're operating on the boot volume 53 | if [ "$USER_IS_ADMIN" == "True" ]; then 54 | # add $USERNAME to admin group 55 | /usr/sbin/dseditgroup -o edit -a "$USERNAME" -t user admin 56 | else 57 | # remove $USERNAME from admin group 58 | /usr/sbin/dseditgroup -o edit -d "$USERNAME" -t user admin 59 | fi 60 | if [ "$ENABLE_AUTOLOGIN" == "True" ]; then 61 | # copy kcpassword into place 62 | /bin/cp "$MYDIR/kcpassword" /private/etc/ 63 | /bin/chmod 600 /private/etc/kcpassword 64 | # set AutoLogin preference, working around path issue with 'defaults' 65 | /usr/bin/defaults write "/Library/Preferences/com.apple.loginwindow" autoLoginUser "$USERNAME" 66 | fi 67 | else 68 | # we're installing to non-boot volume; probably from Recovery-like or 69 | # AutoDMG environment - so we can just copy the user plist into place 70 | /bin/cp "$MYDIR/$USERNAME.plist" "$3/private/var/db/dslocal/nodes/Default/users/" 71 | /bin/chmod 600 "$3/private/var/db/dslocal/nodes/Default/users/$USERNAME.plist" 72 | if [ "$USER_IS_ADMIN" == "True" ]; then 73 | # can't use dseditgroup in this environment, so we wrap PlistBuddy to 74 | # add $USERNAME to the admin group by directly editing admin.plist 75 | PlistArrayAdd "$3/private/var/db/dslocal/nodes/Default/groups/admin.plist" users "$USERNAME" && \ 76 | PlistArrayAdd "$3/private/var/db/dslocal/nodes/Default/groups/admin.plist" groupmembers "$UUID" 77 | fi 78 | if [ "$ENABLE_AUTOLOGIN" == "True" ]; then 79 | # copy kcpassword into place 80 | /bin/mkdir -m 755 -p "$3/private/etc" 81 | /bin/cp "$MYDIR/kcpassword" "$3/private/etc/" 82 | /bin/chmod 600 "$3/private/etc/kcpassword" 83 | # set AutoLogin preference 84 | /usr/bin/defaults write "$3/Library/Preferences/com.apple.loginwindow" autoLoginUser "$USERNAME" 85 | fi 86 | fi 87 | exit 0 88 | -------------------------------------------------------------------------------- /locallibs/userpkg.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Greg Neagle. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | '''Functions for generating our package''' 16 | 17 | # Much borrowed from https://github.com/MagerValp/CreateUserPkg/ 18 | 19 | from __future__ import absolute_import 20 | 21 | import io 22 | import os 23 | import shutil 24 | import subprocess 25 | import sys 26 | import tempfile 27 | 28 | from . import plistutils 29 | from .wrappers import unicodify 30 | 31 | 32 | class PkgException(Exception): 33 | '''Error when creating pkg''' 34 | pass 35 | 36 | 37 | def make_config_file(scripts_path, pkg_info): 38 | """Write out a config file the postinstall script can source""" 39 | user_plist = pkg_info['user_plist'] 40 | username = unicodify(user_plist[u'name'][0]) 41 | uuid = unicodify(user_plist[u'generateduid'][0]) 42 | user_is_admin = pkg_info.get('is_admin', False) 43 | enable_autologin = (pkg_info.get('kcpassword') != None) 44 | config_content = u""" 45 | USERNAME="%s" 46 | UUID=%s 47 | USER_IS_ADMIN=%s 48 | ENABLE_AUTOLOGIN=%s 49 | """ % (username, uuid, user_is_admin, enable_autologin) 50 | config_path = os.path.join(scripts_path, u"config") 51 | try: 52 | fileref = io.open(config_path, 'w', encoding="utf-8") 53 | fileref.write(config_content) 54 | fileref.close() 55 | os.chmod(config_path, 0o755) 56 | except (OSError, IOError) as err: 57 | raise PkgException(err) 58 | 59 | 60 | def generate(info, createuserpkg_dir): 61 | '''Build a package''' 62 | required_keys = [ 63 | u'version', u'pkgid', u'destination_path', u'user_plist'] 64 | for key in required_keys: 65 | if key not in info: 66 | raise PkgException(u'Missing %s in pkg info!' % key) 67 | username = info.get(u'name', info[u'user_plist'][u'name'][0]) 68 | # Create a package with the plist for our user and a shadow hash file. 69 | tmp_path = tempfile.mkdtemp() 70 | try: 71 | # Create a root for the package. 72 | pkg_root_path = os.path.join(tmp_path, u"create_user") 73 | os.mkdir(pkg_root_path) 74 | # Create package structure inside root for psuedo-payload-free pkg 75 | os.makedirs(os.path.join(pkg_root_path, u"private/tmp")) 76 | os.chmod(os.path.join(pkg_root_path, u"private/tmp"), 0o1777) 77 | # Create scripts directory 78 | scripts_path = os.path.join(tmp_path, u'scripts') 79 | os.makedirs(scripts_path, 0o755) 80 | # Save user plist. 81 | user_plist_name = "%s.plist" % username 82 | user_plist_path = os.path.join(scripts_path, user_plist_name) 83 | plistutils.write_plist(info[u'user_plist'], pathname=user_plist_path) 84 | os.chmod(user_plist_path, 0o600) 85 | # Save kcpassword. 86 | if info.get('kcpassword'): 87 | kcpassword_path = os.path.join(scripts_path, u'kcpassword') 88 | fileref = open(kcpassword_path, 'w') 89 | fileref.write(info.get('kcpassword')) 90 | fileref.close() 91 | os.chmod(kcpassword_path, 0o600) 92 | # now the config file 93 | make_config_file(scripts_path, info) 94 | # now copy postinstall and create_user.py to scripts dir 95 | # pkg_scripts should be in the same directory as createuserpkg 96 | pkg_scripts_dir = os.path.join(createuserpkg_dir, u'pkg_scripts') 97 | for script in [u'createuser', u'postinstall']: 98 | source = os.path.join(pkg_scripts_dir, script) 99 | dest = os.path.join(scripts_path, script) 100 | shutil.copyfile(source, dest) 101 | os.chmod(dest, 0o755) 102 | cmd = ['/usr/bin/pkgbuild', 103 | '--ownership', 'recommended', 104 | '--identifier', info[u'pkgid'], 105 | '--version', info[u'version'], 106 | '--root', pkg_root_path, 107 | '--scripts', scripts_path, 108 | os.path.expanduser(info[u'destination_path']) 109 | ] 110 | retcode = subprocess.call(cmd) 111 | if retcode: 112 | raise PkgException(u'Package creation failed') 113 | except (OSError, IOError) as err: 114 | raise PkgException(err) 115 | finally: 116 | shutil.rmtree(tmp_path, ignore_errors=True) 117 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Tool for generating packages that create macOS user accounts on 2 | 10.8-12.x 3 | 4 | ## macOS and Python notes 5 | 6 | pycreateuserpkg requires Python and PyObjC. It also uses several command-line tools available on macOS. There is no support for running it on Windows or Linux. 7 | 8 | In macOS 12.3, Apple removed the Python 2.7 install. Out-of-the-box, there is no Python installed. You'll need to provide your own Python and PyObjC to use pycreateuserpkg. It should run under Python 2.7, and Python 3.6-3.9 without issue. 9 | 10 | Some options for providing an appropriate Python: 11 | 12 | 1) If you also use Munki, use Munki's bundled Python, available at /usr/local/munki/munki-python 13 | 2) Install Python from macadmins-python (https://github.com/macadmins/python). 14 | 3) Install Python from https://www.python.org. 15 | 5) There are other ways to install Python, inlcuding Homebrew (https://brew.sh), my relocatable-python tool (https://github.com/gregneagle/relocatable-python), using the python3 bundled with Xcode, and more. 16 | 6) Notably, if you don't use options 1 or 2 above, and install Python some other way, you'll also need to install PyObjC. 17 | 18 | You might ask "Why not change the shebang to `#!/usr/bin/env python3` or even `#!/usr/bin/python3`? That could break many current users of the tool who _haven't_ upgraded to macOS 12.3 and don't have Xcode and/or the Command line development tools installed. If/when you upgrade to macOS 12.3, you'll need to take some action anyway. No need to punish everyone else. 19 | 20 | #### NEW 13-March-2022: 21 | - Due to the removal of Python 2 in macOS 12.3, the tool has been updated for compatibility with Python 3. There is still a dependency on PyObjC for plist writing, so this won't work out-of-the-box with the Python 3 installed as part of Xcode or the Command Line Development Tools (as these do not include PyObjC). This tool will work with Munki's Python, autopkg's Python, or the MacAdmins Python. 22 | - Extensive testing has not been done under Python 3; it's possible there are new bugs caused by the changes. Please create issues for any new bugs discovered. 23 | - "createuserpkg" renamed to "createuserpkg.py" and shebang line removed. Call the tool with the desired Python: for example `munki-python createuserpkg.py [options]` 24 | 25 | #### NEW 12-Oct-2019: 26 | More changes when updating existing accounts to work better with FileVault-enabled accounts. Existing authentication_authority attributes other than the ShadowHash are now preserved, and the generateduid is not changed/updated. 27 | This means when updating existing accounts there are three attributes that will not be changed/updated: uid, home, and generateduid. If you require these to be consistent across all machines you manage, consider _deleting_ the account before installing the new package. 28 | 29 | #### NEW 13-Aug-2019: 30 | The create_user.py tool in the Scripts directory of the expanded package has been replaced by a compiled createuser tool written in Objective-C. (See the createuser directory for the source). This eliminates the dependency on Apple Python for the package itself to work on the current boot volume. 31 | 32 | #### Note: 33 | in 10.14+ when updating an existing account, the following attributes will _NOT_ be updated: `uid` and `home`. This is due to new restrictions in Mojave. 34 | 35 | 36 | 37 | ``` 38 | $ python ./createuserpkg --help 39 | Usage: createuserpkg [options] /path/to/output.pkg 40 | 41 | Options: 42 | -h, --help show this help message and exit 43 | 44 | Required User Options: 45 | -n NAME, --name=NAME 46 | User shortname. REQUIRED. 47 | -u UID, --uid=UID User uid. REQUIRED. 48 | 49 | Required Package Options: 50 | -V VERSION, --version=VERSION 51 | Package version number. REQUIRED. 52 | -i IDENTIFIER, --identifier=IDENTIFIER 53 | Package identifier. REQUIRED. 54 | 55 | Optional User Options: 56 | -p PASSWORD, --password=PASSWORD 57 | User password. If this is not provided, interactively 58 | prompt for password. 59 | -f FULLNAME, --fullname=FULLNAME 60 | User full name. Optional. 61 | -g GID, --gid=GID User gid. Optional. 62 | -G GENERATEDUID, --generateduid=GENERATEDUID 63 | GeneratedUID (UUID). Optional. 64 | -H HOME, --home=HOME 65 | Path to user home directory. Optional. 66 | -s SHELL, --shell=SHELL 67 | User shell path. Optional. 68 | -a, --admin User account should be added to admin group. 69 | -A, --autologin User account should automatically login. 70 | --hidden User account should be hidden. 71 | 72 | Optional Package Options: 73 | -d, --distribution Creates a distribution-style package for use with 74 | startosinstall 75 | ``` 76 | 77 | #### Example: 78 | 79 | Making a local admin pkg with shortname "localadmin" and uid 501: 80 | 81 | ``` 82 | $ python ./createuserpkg -n localadmin -u 501 -a -i com.foo.localadminpkg -V 1.0 localadmin.pkg 83 | Password: ******** 84 | Password (again): ******** 85 | pkgbuild: Inferring bundle components from contents of /var/folders/tc/sd4_mtvj14jdy7cg21m2gmcw000495/T/tmpj0FQ8n/create_user 86 | pkgbuild: Adding top-level postinstall script 87 | pkgbuild: Wrote package to localadmin.pkg 88 | ``` 89 | -------------------------------------------------------------------------------- /locallibs/pbkdf2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | pbkdf2 4 | ~~~~~~ 5 | This module implements pbkdf2 for Python. It also has some basic 6 | tests that ensure that it works. The implementation is straightforward 7 | and uses stdlib only stuff and can be easily be copy/pasted into 8 | your favourite application. 9 | Use this as replacement for bcrypt that does not need a c implementation 10 | of a modified blowfish crypto algo. 11 | Example usage: 12 | >>> pbkdf2_hex('what i want to hash', 'the random salt') 13 | 'fa7cc8a2b0a932f8e6ea42f9787e9d36e592e0c222ada6a9' 14 | How to use this: 15 | 1. Use a constant time string compare function to compare the stored hash 16 | with the one you're generating:: 17 | def safe_str_cmp(a, b): 18 | if len(a) != len(b): 19 | return False 20 | rv = 0 21 | for x, y in izip(a, b): 22 | rv |= ord(x) ^ ord(y) 23 | return rv == 0 24 | 2. Use `os.urandom` to generate a proper salt of at least 8 byte. 25 | Use a unique salt per hashed password. 26 | 3. Store ``algorithm$salt:costfactor$hash`` in the database so that 27 | you can upgrade later easily to a different algorithm if you need 28 | one. For instance ``PBKDF2-256$thesalt:10000$deadbeef...``. 29 | :copyright: (c) Copyright 2011 by Armin Ronacher. 30 | :license: BSD, see LICENSE for more details. 31 | """ 32 | # https://github.com/mitsuhiko/python-pbkdf2 33 | 34 | from __future__ import absolute_import, print_function 35 | 36 | import binascii 37 | import hmac 38 | import hashlib 39 | from struct import Struct 40 | from operator import xor 41 | from itertools import starmap 42 | 43 | 44 | try: 45 | from itertools import izip as zip 46 | except ImportError: 47 | pass # py3 48 | 49 | try: 50 | range = xrange 51 | except NameError: 52 | pass # py3 53 | 54 | _pack_int = Struct('>I').pack 55 | 56 | 57 | def pbkdf2_hex(data, salt, iterations=1000, keylen=24, hashfunc=None): 58 | """Like :func:`pbkdf2_bin` but returns a hex encoded string.""" 59 | return binascii.hexlify(pbkdf2_bin(data, salt, iterations, keylen, hashfunc)) 60 | 61 | 62 | def pbkdf2_bin(data, salt, iterations=1000, keylen=24, hashfunc=None): 63 | """Returns a binary digest for the PBKDF2 hash algorithm of `data` 64 | with the given `salt`. It iterates `iterations` time and produces a 65 | key of `keylen` bytes. By default SHA-1 is used as hash function, 66 | a different hashlib `hashfunc` can be provided. 67 | """ 68 | hashfunc = hashfunc or hashlib.sha1 69 | mac = hmac.new(data, None, hashfunc) 70 | 71 | buf = [] 72 | for block in range(1, -(-keylen // mac.digest_size) + 1): 73 | h = mac.copy() 74 | h.update(salt + _pack_int(block)) 75 | u = h.digest() 76 | rv = list(bytearray(u)) # needs further testing on py2 and could possibly be more performant 77 | for i in range(iterations - 1): 78 | h = mac.copy() 79 | h.update(bytes(u)) 80 | u = h.digest() 81 | rv = starmap(xor, zip(rv, list(bytearray(u)))) 82 | 83 | buf.extend(rv) 84 | return ''.join(map(chr, buf))[:keylen] 85 | 86 | 87 | def test(): 88 | failed = [] 89 | def check(data, salt, iterations, keylen, expected): 90 | rv = pbkdf2_hex(data, salt, iterations, keylen) 91 | if rv != expected: 92 | print('Test failed:') 93 | print(' Expected: %s' % expected) 94 | print(' Got: %s' % rv) 95 | print(' Parameters:') 96 | print(' data=%s' % data) 97 | print(' salt=%s' % salt) 98 | print(' iterations=%d' % iterations) 99 | print() 100 | failed.append(1) 101 | 102 | # From RFC 6070 103 | check('password', 'salt', 1, 20, 104 | '0c60c80f961f0e71f3a9b524af6012062fe037a6') 105 | check('password', 'salt', 2, 20, 106 | 'ea6c014dc72d6f8ccd1ed92ace1d41f0d8de8957') 107 | check('password', 'salt', 4096, 20, 108 | '4b007901b765489abead49d926f721d065a429c1') 109 | check('passwordPASSWORDpassword', 'saltSALTsaltSALTsaltSALTsaltSALTsalt', 110 | 4096, 25, '3d2eec4fe41c849b80c8d83662c0e44a8b291a964cf2f07038') 111 | check('pass\x00word', 'sa\x00lt', 4096, 16, 112 | '56fa6aa75548099dcc37d7f03425e0c3') 113 | # This one is from the RFC but it just takes for ages 114 | ##check('password', 'salt', 16777216, 20, 115 | ## 'eefe3d61cd4da4e4e9945b3d6ba2158c2634e984') 116 | 117 | # From Crypt-PBKDF2 118 | check('password', 'ATHENA.MIT.EDUraeburn', 1, 16, 119 | 'cdedb5281bb2f801565a1122b2563515') 120 | check('password', 'ATHENA.MIT.EDUraeburn', 1, 32, 121 | 'cdedb5281bb2f801565a1122b25635150ad1f7a04bb9f3a333ecc0e2e1f70837') 122 | check('password', 'ATHENA.MIT.EDUraeburn', 2, 16, 123 | '01dbee7f4a9e243e988b62c73cda935d') 124 | check('password', 'ATHENA.MIT.EDUraeburn', 2, 32, 125 | '01dbee7f4a9e243e988b62c73cda935da05378b93244ec8f48a99e61ad799d86') 126 | check('password', 'ATHENA.MIT.EDUraeburn', 1200, 32, 127 | '5c08eb61fdf71e4e4ec3cf6ba1f5512ba7e52ddbc5e5142f708a31e2e62b1e13') 128 | check('X' * 64, 'pass phrase equals block size', 1200, 32, 129 | '139c30c0966bc32ba55fdbf212530ac9c5ec59f1a452f5cc9ad940fea0598ed1') 130 | check('X' * 65, 'pass phrase exceeds block size', 1200, 32, 131 | '9ccad6d468770cd51b10e6a68721be611a8b4d282601db3b36be9246915ec82a') 132 | 133 | raise SystemExit(bool(failed)) 134 | 135 | 136 | if __name__ == '__main__': 137 | test() -------------------------------------------------------------------------------- /createuserpkg.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Greg Neagle. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | '''createuserpkg.py 16 | A tool for creating Apple installer packages that create/update user accounts 17 | on macOS. Much code borrowed and/or inpsired by Per Olofsson's CreateUserPkg''' 18 | 19 | from __future__ import absolute_import, print_function 20 | 21 | 22 | import optparse 23 | import sys 24 | import getpass 25 | import os 26 | 27 | from locallibs import kcpassword 28 | from locallibs import shadowhash 29 | from locallibs import userplist 30 | from locallibs import userpkg 31 | from locallibs import productbuild 32 | from locallibs.wrappers import unicodify 33 | 34 | def main(): 35 | '''Main''' 36 | usage = "usage: %prog [options] /path/to/output.pkg" 37 | 38 | parser = optparse.OptionParser(usage=usage) 39 | required_user_options = optparse.OptionGroup( 40 | parser, 'Required User Options') 41 | required_user_options.add_option( 42 | '--name', '-n', help='User shortname. REQUIRED.') 43 | required_user_options.add_option( 44 | '--uid', '-u', help='User uid. REQUIRED.') 45 | 46 | required_package_options = optparse.OptionGroup( 47 | parser, 'Required Package Options') 48 | required_package_options.add_option( 49 | '--version', '-V', help='Package version number. REQUIRED.') 50 | required_package_options.add_option( 51 | '--identifier', '-i', help='Package identifier. REQUIRED.') 52 | 53 | optional_user_options = optparse.OptionGroup( 54 | parser, 'Optional User Options') 55 | optional_user_options.add_option( 56 | '--password', '-p', 57 | help='User password. If this is not provided, interactively prompt for ' 58 | 'password.') 59 | optional_user_options.add_option( 60 | '--fullname', '-f', help='User full name. Optional.') 61 | optional_user_options.add_option('--gid', '-g', help='User gid. Optional.') 62 | optional_user_options.add_option( 63 | '--generateduid', '-G', help='GeneratedUID (UUID). Optional.') 64 | optional_user_options.add_option( 65 | '--hint', help='User password hint. Optional.') 66 | 67 | optional_user_options.add_option( 68 | '--home', '-H', help='Path to user home directory. Optional.') 69 | optional_user_options.add_option( 70 | '--shell', '-s', help='User shell path. Optional.') 71 | optional_user_options.add_option( 72 | '--admin', '-a', action='store_true', 73 | help='User account should be added to admin group.') 74 | optional_user_options.add_option( 75 | '--autologin', '-A', action='store_true', 76 | help='User account should automatically login.') 77 | optional_user_options.add_option('--hidden', action='store_true', 78 | help='User account should be hidden.') 79 | 80 | optional_package_options = optparse.OptionGroup( 81 | parser, 'Optional Package Options') 82 | optional_package_options.add_option('--distribution', '-d', action='store_true', help='Creates a distribution-style package for use with startosinstall') 83 | 84 | parser.add_option_group(required_user_options) 85 | parser.add_option_group(required_package_options) 86 | parser.add_option_group(optional_user_options) 87 | parser.add_option_group(optional_package_options) 88 | 89 | options, arguments = parser.parse_args() 90 | 91 | # verify options and arguments 92 | required_options = ('name', 'uid', 'version', 'identifier') 93 | missing_required_options = False 94 | for option in required_options: 95 | if not hasattr(options, option) or getattr(options, option) is None: 96 | print("Missing required option: %s" % option, file=sys.stderr) 97 | missing_required_options = True 98 | if missing_required_options: 99 | parser.print_help() 100 | exit(-1) 101 | 102 | if len(arguments) != 1: 103 | print("Must provide exactly one filename!", file=sys.stderr) 104 | parser.print_help() 105 | exit(-1) 106 | filename = arguments[0] 107 | 108 | if options.password == 'none': 109 | password="" 110 | elif options.password: 111 | password = unicodify(options.password) 112 | else: 113 | password = getpass.getpass('Password: ') 114 | password_again = getpass.getpass('Password (again): ') 115 | if password != password_again: 116 | print("Password mismatch!", file=sys.stderr) 117 | exit(-1) 118 | password = unicodify(password) 119 | 120 | # make user plist 121 | user_data = {'name': unicodify(options.name), 122 | 'uid': unicodify(options.uid), 123 | 'ShadowHashData': shadowhash.generate(password)} 124 | if options.fullname: 125 | user_data['realname'] = unicodify(options.fullname) 126 | if options.gid: 127 | user_data['gid'] = unicodify(options.gid) 128 | if options.generateduid: 129 | user_data['uuid'] = unicodify(options.generateduid) 130 | if options.home: 131 | user_data['home'] = unicodify(options.home) 132 | if options.shell: 133 | user_data['shell'] = unicodify(options.shell) 134 | if options.hidden: 135 | user_data['IsHidden'] = u'YES' 136 | if options.hint: 137 | user_data['hint'] = unicodify(options.hint) 138 | 139 | user_plist = userplist.generate(user_data) 140 | 141 | 142 | # set up package options/choices 143 | pkg_data = {'version': unicodify(options.version), 144 | 'pkgid': unicodify(options.identifier), 145 | 'destination_path': filename, 146 | 'user_plist': user_plist} 147 | if options.autologin: 148 | pkg_data['kcpassword'] = kcpassword.generate(password) 149 | if options.admin: 150 | pkg_data['is_admin'] = True 151 | 152 | createuserpkg_dir = os.path.dirname(os.path.realpath(__file__)) 153 | 154 | # build the package 155 | userpkg.generate(pkg_data, createuserpkg_dir) 156 | 157 | if options.distribution: 158 | productbuild.generate(pkg_data, createuserpkg_dir) 159 | 160 | if __name__ == '__main__': 161 | main() 162 | -------------------------------------------------------------------------------- /createuser/createuser/main.m: -------------------------------------------------------------------------------- 1 | // 2 | // main.m 3 | // createuser 4 | // 5 | // Created by Greg Neagle on 8/13/19. 6 | // 7 | // Copyright 2019 Greg Neagle. 8 | // 9 | // Licensed under the Apache License, Version 2.0 (the "License"); 10 | // you may not use this file except in compliance with the License. 11 | // You may obtain a copy of the License at 12 | // 13 | // http://www.apache.org/licenses/LICENSE-2.0 14 | // 15 | // Unless required by applicable law or agreed to in writing, software 16 | // distributed under the License is distributed on an "AS IS" BASIS, 17 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 18 | // See the License for the specific language governing permissions and 19 | // limitations under the License. 20 | 21 | 22 | #include // for basename() 23 | 24 | #import 25 | #import 26 | 27 | NSString * kAuthenticationAuthorityKey = @"authentication_authority"; 28 | 29 | ODNode * localDSNode(void) { 30 | // returns local DS node 31 | ODSession * mySession = [ODSession defaultSession]; 32 | if (mySession == nil) { 33 | return nil; 34 | } 35 | NSError * err; 36 | ODNode *node = [ODNode nodeWithSession:mySession name:@"/Local/Default" error: &err]; 37 | if (err != nil) { 38 | NSLog(@"Could not get local DS node: %@", err); 39 | } 40 | return node; 41 | } 42 | 43 | ODRecord * getUserRecord(NSString * userName) { 44 | // Returns a user record 45 | ODRecord * record; 46 | NSError * err; 47 | ODNode * node = localDSNode(); 48 | if (node != nil) { 49 | record = [node recordWithRecordType:kODRecordTypeUsers name:userName attributes:nil error: &err]; 50 | if (err != nil) { 51 | NSLog(@"Could not get user record for %@: %@", userName, err); 52 | } 53 | } 54 | return record; 55 | } 56 | 57 | ODRecord * createUserRecord(NSString * userName) { 58 | // Creates a user record and returns it 59 | ODRecord * record; 60 | NSError * err; 61 | ODNode * node = localDSNode(); 62 | if (node != nil) { 63 | record = [node createRecordWithRecordType:kODRecordTypeUsers name:userName attributes:nil error: &err]; 64 | if (err != nil) { 65 | NSLog(@"Could not create user record for %@: %@", userName, err); 66 | } 67 | } 68 | return record; 69 | } 70 | 71 | NSArray * getAttributeForUser(NSString * attr, ODRecord * userRecord) { 72 | // Returns value of an attribute for userRecord 73 | NSError * err; 74 | NSArray * values = [userRecord valuesForAttribute:(ODAttributeType)attr error:&err]; 75 | if (err != nil) { 76 | NSLog(@"Error retreiving attribute %@: %@", attr, err); 77 | } 78 | return values; 79 | } 80 | 81 | NSString * authAuthorityType(NSString * auth_authority_item) { 82 | // Returns 'type' of an authentication authority item 83 | // ie: ShadowHash, Kerberosv5, SecureToken, etc 84 | NSArray * items = [auth_authority_item componentsSeparatedByString:@";"]; 85 | if (items.count < 2) { 86 | NSLog(@"Unexpected authentication authority item format: %@", auth_authority_item); 87 | exit(-1); 88 | } 89 | return items[1]; 90 | } 91 | 92 | NSArray * mergeAuthenticationAuthorities(NSArray * managed_auth_authority, ODRecord * userRecord) { 93 | // Merge two authentication_authority values, giving precedence to the 94 | // managed_auth_authority 95 | NSMutableArray * mergedAuthAuthorities = [managed_auth_authority mutableCopy]; 96 | NSArray * existingAuthAuthority = getAttributeForUser(kAuthenticationAuthorityKey, userRecord); 97 | if (existingAuthAuthority != nil) { 98 | NSMutableArray * managedTypes = [NSMutableArray arrayWithCapacity: [managed_auth_authority count]]; 99 | for (id item in managed_auth_authority) [managedTypes addObject: authAuthorityType((NSString *)item)]; 100 | for (id item in existingAuthAuthority) { 101 | if (![managedTypes containsObject: authAuthorityType((NSString *)item)]) { 102 | // add this item to the array of authentication authorities 103 | [mergedAuthAuthorities addObject: item]; 104 | } 105 | } 106 | } 107 | return (NSArray *)mergedAuthAuthorities; 108 | } 109 | 110 | BOOL setAttributesForUser(NSDictionary * attrs, ODRecord * userRecord, NSArray * attrsToSkip) { 111 | // Sets attributes for user record 112 | if (attrsToSkip == nil) { 113 | attrsToSkip = @[]; 114 | } 115 | NSError * err; 116 | if (userRecord != nil) { 117 | for (NSString * key in attrs) { 118 | id value = attrs[key]; 119 | if ([key isEqualToString: kAuthenticationAuthorityKey]) { 120 | // preserve any pre-exisiting authentication_authority items we 121 | // don't have in our managed plist (SecureToken being the really 122 | // important one here) 123 | value = mergeAuthenticationAuthorities((NSArray *)value, userRecord); 124 | } 125 | if (![attrsToSkip containsObject: key]){ 126 | Boolean success = [userRecord setValue:value forAttribute:key error: &err]; 127 | if (!success) { 128 | NSLog(@"Could not set attribute %@ to %@: %@", key, attrs[key], err); 129 | return NO; 130 | } 131 | } 132 | } 133 | return YES; 134 | } 135 | return NO; 136 | } 137 | 138 | 139 | id readPlist(NSString * filename) { 140 | // returns data structure from a plist file 141 | // attempt to read the file 142 | NSData * plistData = [NSData dataWithContentsOfFile: filename]; 143 | if (plistData == nil) { 144 | NSLog(@"Could not read data from %@!", filename); 145 | return nil; 146 | } 147 | // attempt to parse it as a plist 148 | NSPropertyListFormat fileFormat = 0; 149 | NSError * err; 150 | id plist = [NSPropertyListSerialization propertyListWithData:plistData options:NSPropertyListImmutable format:&fileFormat error:&err]; 151 | if (err != nil) { 152 | NSLog(@"Could not parse plist data from %@: %@", filename, err); 153 | return nil; 154 | } 155 | return plist; 156 | } 157 | 158 | int main(int argc, const char * argv[]) { 159 | @autoreleasepool { 160 | if (argc != 2) { 161 | fprintf(stderr, "Error: %s takes exactly one argument: a path to an Open Directory user plist to import.\n", basename((char *)argv[0])); 162 | return -1; 163 | } 164 | // attempt to read in the passed-in file 165 | NSString * filename = @(argv[1]); 166 | id userdata = readPlist(filename); 167 | if (userdata == nil) { 168 | return -1; 169 | } 170 | NSArray * usernameList = [(NSDictionary *)userdata objectForKey: @"name"]; 171 | if (!usernameList.count) { 172 | NSLog(@"Could not get user name from plist - missing key 'name'"); 173 | return -1; 174 | } 175 | NSString * username = usernameList[0]; 176 | if (!username.length) { 177 | NSLog(@"Could not get user name from plist!"); 178 | return -1; 179 | } 180 | NSArray * attrsToSkip = @[]; 181 | ODRecord * record = getUserRecord(username); 182 | if (record == nil) { 183 | // create the record 184 | record = createUserRecord(username); 185 | if (record == nil) { 186 | NSLog(@"Failed to create user record for %@", username); 187 | return -1; 188 | } 189 | } else { 190 | // updating existing user record 191 | // in Mojave + we are not allowed to update the uid or home without 192 | // user approval; updating generateduid on an existing account 193 | // breaks FileVault for that account 194 | attrsToSkip = @[@"uid", @"home", @"generateduid"]; 195 | } 196 | BOOL success = setAttributesForUser((NSDictionary *)userdata, record, attrsToSkip); 197 | if (!success) { 198 | return -1; 199 | } 200 | } 201 | return 0; 202 | } 203 | -------------------------------------------------------------------------------- /createuser/createuser.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 50; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | C0506B4B230319FA0058BCA7 /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = C0506B4A230319FA0058BCA7 /* main.m */; }; 11 | /* End PBXBuildFile section */ 12 | 13 | /* Begin PBXCopyFilesBuildPhase section */ 14 | C0506B45230319FA0058BCA7 /* CopyFiles */ = { 15 | isa = PBXCopyFilesBuildPhase; 16 | buildActionMask = 2147483647; 17 | dstPath = /usr/share/man/man1/; 18 | dstSubfolderSpec = 0; 19 | files = ( 20 | ); 21 | runOnlyForDeploymentPostprocessing = 1; 22 | }; 23 | /* End PBXCopyFilesBuildPhase section */ 24 | 25 | /* Begin PBXFileReference section */ 26 | C0506B47230319FA0058BCA7 /* createuser */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = createuser; sourceTree = BUILT_PRODUCTS_DIR; }; 27 | C0506B4A230319FA0058BCA7 /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = ""; }; 28 | /* End PBXFileReference section */ 29 | 30 | /* Begin PBXFrameworksBuildPhase section */ 31 | C0506B44230319FA0058BCA7 /* Frameworks */ = { 32 | isa = PBXFrameworksBuildPhase; 33 | buildActionMask = 2147483647; 34 | files = ( 35 | ); 36 | runOnlyForDeploymentPostprocessing = 0; 37 | }; 38 | /* End PBXFrameworksBuildPhase section */ 39 | 40 | /* Begin PBXGroup section */ 41 | C0506B3E230319FA0058BCA7 = { 42 | isa = PBXGroup; 43 | children = ( 44 | C0506B49230319FA0058BCA7 /* createuser */, 45 | C0506B48230319FA0058BCA7 /* Products */, 46 | ); 47 | sourceTree = ""; 48 | }; 49 | C0506B48230319FA0058BCA7 /* Products */ = { 50 | isa = PBXGroup; 51 | children = ( 52 | C0506B47230319FA0058BCA7 /* createuser */, 53 | ); 54 | name = Products; 55 | sourceTree = ""; 56 | }; 57 | C0506B49230319FA0058BCA7 /* createuser */ = { 58 | isa = PBXGroup; 59 | children = ( 60 | C0506B4A230319FA0058BCA7 /* main.m */, 61 | ); 62 | path = createuser; 63 | sourceTree = ""; 64 | }; 65 | /* End PBXGroup section */ 66 | 67 | /* Begin PBXNativeTarget section */ 68 | C0506B46230319FA0058BCA7 /* createuser */ = { 69 | isa = PBXNativeTarget; 70 | buildConfigurationList = C0506B4E230319FA0058BCA7 /* Build configuration list for PBXNativeTarget "createuser" */; 71 | buildPhases = ( 72 | C0506B43230319FA0058BCA7 /* Sources */, 73 | C0506B44230319FA0058BCA7 /* Frameworks */, 74 | C0506B45230319FA0058BCA7 /* CopyFiles */, 75 | ); 76 | buildRules = ( 77 | ); 78 | dependencies = ( 79 | ); 80 | name = createuser; 81 | productName = createuser; 82 | productReference = C0506B47230319FA0058BCA7 /* createuser */; 83 | productType = "com.apple.product-type.tool"; 84 | }; 85 | /* End PBXNativeTarget section */ 86 | 87 | /* Begin PBXProject section */ 88 | C0506B3F230319FA0058BCA7 /* Project object */ = { 89 | isa = PBXProject; 90 | attributes = { 91 | LastUpgradeCheck = 1030; 92 | TargetAttributes = { 93 | C0506B46230319FA0058BCA7 = { 94 | CreatedOnToolsVersion = 10.3; 95 | }; 96 | }; 97 | }; 98 | buildConfigurationList = C0506B42230319FA0058BCA7 /* Build configuration list for PBXProject "createuser" */; 99 | compatibilityVersion = "Xcode 9.3"; 100 | developmentRegion = en; 101 | hasScannedForEncodings = 0; 102 | knownRegions = ( 103 | en, 104 | ); 105 | mainGroup = C0506B3E230319FA0058BCA7; 106 | productRefGroup = C0506B48230319FA0058BCA7 /* Products */; 107 | projectDirPath = ""; 108 | projectRoot = ""; 109 | targets = ( 110 | C0506B46230319FA0058BCA7 /* createuser */, 111 | ); 112 | }; 113 | /* End PBXProject section */ 114 | 115 | /* Begin PBXSourcesBuildPhase section */ 116 | C0506B43230319FA0058BCA7 /* Sources */ = { 117 | isa = PBXSourcesBuildPhase; 118 | buildActionMask = 2147483647; 119 | files = ( 120 | C0506B4B230319FA0058BCA7 /* main.m in Sources */, 121 | ); 122 | runOnlyForDeploymentPostprocessing = 0; 123 | }; 124 | /* End PBXSourcesBuildPhase section */ 125 | 126 | /* Begin XCBuildConfiguration section */ 127 | C0506B4C230319FA0058BCA7 /* Debug */ = { 128 | isa = XCBuildConfiguration; 129 | buildSettings = { 130 | ALWAYS_SEARCH_USER_PATHS = NO; 131 | CLANG_ANALYZER_NONNULL = YES; 132 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 133 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 134 | CLANG_CXX_LIBRARY = "libc++"; 135 | CLANG_ENABLE_MODULES = YES; 136 | CLANG_ENABLE_OBJC_ARC = YES; 137 | CLANG_ENABLE_OBJC_WEAK = YES; 138 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 139 | CLANG_WARN_BOOL_CONVERSION = YES; 140 | CLANG_WARN_COMMA = YES; 141 | CLANG_WARN_CONSTANT_CONVERSION = YES; 142 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 143 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 144 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 145 | CLANG_WARN_EMPTY_BODY = YES; 146 | CLANG_WARN_ENUM_CONVERSION = YES; 147 | CLANG_WARN_INFINITE_RECURSION = YES; 148 | CLANG_WARN_INT_CONVERSION = YES; 149 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 150 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 151 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 152 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 153 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 154 | CLANG_WARN_STRICT_PROTOTYPES = YES; 155 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 156 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 157 | CLANG_WARN_UNREACHABLE_CODE = YES; 158 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 159 | CODE_SIGN_IDENTITY = ""; 160 | CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; 161 | CODE_SIGN_STYLE = Manual; 162 | COPY_PHASE_STRIP = NO; 163 | DEBUG_INFORMATION_FORMAT = dwarf; 164 | ENABLE_STRICT_OBJC_MSGSEND = YES; 165 | ENABLE_TESTABILITY = YES; 166 | GCC_C_LANGUAGE_STANDARD = gnu11; 167 | GCC_DYNAMIC_NO_PIC = NO; 168 | GCC_NO_COMMON_BLOCKS = YES; 169 | GCC_OPTIMIZATION_LEVEL = 0; 170 | GCC_PREPROCESSOR_DEFINITIONS = ( 171 | "DEBUG=1", 172 | "$(inherited)", 173 | ); 174 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 175 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 176 | GCC_WARN_UNDECLARED_SELECTOR = YES; 177 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 178 | GCC_WARN_UNUSED_FUNCTION = YES; 179 | GCC_WARN_UNUSED_VARIABLE = YES; 180 | MACOSX_DEPLOYMENT_TARGET = 10.12; 181 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 182 | MTL_FAST_MATH = YES; 183 | ONLY_ACTIVE_ARCH = YES; 184 | SDKROOT = macosx; 185 | }; 186 | name = Debug; 187 | }; 188 | C0506B4D230319FA0058BCA7 /* Release */ = { 189 | isa = XCBuildConfiguration; 190 | buildSettings = { 191 | ALWAYS_SEARCH_USER_PATHS = NO; 192 | CLANG_ANALYZER_NONNULL = YES; 193 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 194 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++14"; 195 | CLANG_CXX_LIBRARY = "libc++"; 196 | CLANG_ENABLE_MODULES = YES; 197 | CLANG_ENABLE_OBJC_ARC = YES; 198 | CLANG_ENABLE_OBJC_WEAK = YES; 199 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 200 | CLANG_WARN_BOOL_CONVERSION = YES; 201 | CLANG_WARN_COMMA = YES; 202 | CLANG_WARN_CONSTANT_CONVERSION = YES; 203 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 204 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 205 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 206 | CLANG_WARN_EMPTY_BODY = YES; 207 | CLANG_WARN_ENUM_CONVERSION = YES; 208 | CLANG_WARN_INFINITE_RECURSION = YES; 209 | CLANG_WARN_INT_CONVERSION = YES; 210 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 211 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 212 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 213 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 214 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 215 | CLANG_WARN_STRICT_PROTOTYPES = YES; 216 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 217 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 218 | CLANG_WARN_UNREACHABLE_CODE = YES; 219 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 220 | CODE_SIGN_IDENTITY = ""; 221 | CODE_SIGN_INJECT_BASE_ENTITLEMENTS = NO; 222 | CODE_SIGN_STYLE = Manual; 223 | COPY_PHASE_STRIP = NO; 224 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 225 | ENABLE_NS_ASSERTIONS = NO; 226 | ENABLE_STRICT_OBJC_MSGSEND = YES; 227 | GCC_C_LANGUAGE_STANDARD = gnu11; 228 | GCC_NO_COMMON_BLOCKS = YES; 229 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 230 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 231 | GCC_WARN_UNDECLARED_SELECTOR = YES; 232 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 233 | GCC_WARN_UNUSED_FUNCTION = YES; 234 | GCC_WARN_UNUSED_VARIABLE = YES; 235 | MACOSX_DEPLOYMENT_TARGET = 10.12; 236 | MTL_ENABLE_DEBUG_INFO = NO; 237 | MTL_FAST_MATH = YES; 238 | SDKROOT = macosx; 239 | }; 240 | name = Release; 241 | }; 242 | C0506B4F230319FA0058BCA7 /* Debug */ = { 243 | isa = XCBuildConfiguration; 244 | buildSettings = { 245 | CODE_SIGN_IDENTITY = ""; 246 | CODE_SIGN_STYLE = Manual; 247 | DEVELOPMENT_TEAM = ""; 248 | PRODUCT_NAME = "$(TARGET_NAME)"; 249 | PROVISIONING_PROFILE_SPECIFIER = ""; 250 | }; 251 | name = Debug; 252 | }; 253 | C0506B50230319FA0058BCA7 /* Release */ = { 254 | isa = XCBuildConfiguration; 255 | buildSettings = { 256 | CODE_SIGN_IDENTITY = ""; 257 | CODE_SIGN_STYLE = Manual; 258 | DEVELOPMENT_TEAM = ""; 259 | PRODUCT_NAME = "$(TARGET_NAME)"; 260 | PROVISIONING_PROFILE_SPECIFIER = ""; 261 | }; 262 | name = Release; 263 | }; 264 | /* End XCBuildConfiguration section */ 265 | 266 | /* Begin XCConfigurationList section */ 267 | C0506B42230319FA0058BCA7 /* Build configuration list for PBXProject "createuser" */ = { 268 | isa = XCConfigurationList; 269 | buildConfigurations = ( 270 | C0506B4C230319FA0058BCA7 /* Debug */, 271 | C0506B4D230319FA0058BCA7 /* Release */, 272 | ); 273 | defaultConfigurationIsVisible = 0; 274 | defaultConfigurationName = Release; 275 | }; 276 | C0506B4E230319FA0058BCA7 /* Build configuration list for PBXNativeTarget "createuser" */ = { 277 | isa = XCConfigurationList; 278 | buildConfigurations = ( 279 | C0506B4F230319FA0058BCA7 /* Debug */, 280 | C0506B50230319FA0058BCA7 /* Release */, 281 | ); 282 | defaultConfigurationIsVisible = 0; 283 | defaultConfigurationName = Release; 284 | }; 285 | /* End XCConfigurationList section */ 286 | }; 287 | rootObject = C0506B3F230319FA0058BCA7 /* Project object */; 288 | } 289 | --------------------------------------------------------------------------------