#!/usr/libexec/platform-python
#
# Test script for shadow utils
#
# Copyright (C) 2021 Olaf Kirch <okir@suse.de>

import susetest

__fips_acceptable_crypt_algos = [
	'SHA256',
	'SHA512',
]

def getpwnam(node, login):
	node.logInfo("Obtaining passwd information for %s" % login)
	passwd = node.requireFile("system-passwd")
	editor = passwd.createReader()

	pwd = editor.lookupEntry(name = login)
	if not pwd:
		node.logFailure("Could not find user %s in /etc/passwd" % login)

	return pwd

def force_shell(node, login, shell):
	node.logInfo("Forcing %s's shell to %s" % (login, shell))
	passwd = node.requireFile("system-passwd")
	editor = passwd.createEditor()

	pwd = editor.lookupEntry(name = login)
	if not pwd:
		node.logFailure("Could not find user %s in /etc/passwd" % login)
		return False

	if pwd.shell != shell:
		node.logInfo(f"Changing shell of user {login} from {pwd.shell} to {shell}")
		pwd.shell = shell
		editor.addOrReplaceEntry(pwd)
		editor.commit()

	return True

def force_gecos(node, login, fullname):
	node.logInfo("Forcing %s's fullname to %s" % (login, fullname))
	passwd = node.requireFile("system-passwd")
	editor = passwd.createEditor()

	pwd = editor.lookupEntry(name = login)
	if not pwd:
		node.logFailure("Could not find user %s in /etc/passwd" % login)
		return False

	if pwd.gecos != fullname:
		node.logInfo(f"Changing fullname of user {login} from {pwd.gecos} to {fullname}")
		pwd.gecos = fullname
		editor.addOrReplaceEntry(pwd)
		editor.commit()

	return True

@susetest.test
def verify_chfn(driver):
	'''shadow.chfn: check if test user can change GECOS information'''
	node = driver.client
	user = node.requireUser("test-user")
	if not user.uid:
		node.logFailure("user %s does not seem to exist" % user.login)
		return

	if not user.password:
		node.logFailure("user %s: password not known" % user.login)
		return

	if not force_gecos(node, user.login, "Jane Testuser"):
		node.logError("Failed to change the user's fullname")
		return

	file = node.requireFile("system-login.defs")
	if file is None:
		node.logError("Cannot locate login.defs")
		return

	editor = file.createEditor()
	editor.addOrReplaceEntry(name = 'CHFN_RESTRICT', value = 'rwh')
	editor.commit()

	chat_script = [
		["assword: ", user.password],
	]

	executable = node.requireExecutable("chfn")

	# evaluate failure conditions specified for chfn (for instance, if SELinux is active,
	# the behavior of chfn depends on the test user's SELinux user.
	driver.predictTestResult(executable)

	# SLES and CentOS use different versions of chfn, and they use different cmdline
	# options for setting the various gecos fields. The only option they share is
	# --home-phone
	st = node.runChatScript("chfn --home-phone 666", chat_script, timeout = 10, user = user.login)
	if not st:
		node.logFailure(f"chfn command failed: {st.message}")
		return

	pwd = getpwnam(node, user.login)
	if not pwd:
		return

	if pwd.gecos_home_phone != '666':
		node.logFailure("Apparently, we failed to change the user info for %s." % user.login)
		return

	node.logInfo("OK, GECOS field was changed to include the phone number 666")

@susetest.test
def verify_chsh(driver):
	'''shadow.chsh: check if test user can change shell'''
	wrong_shell = "/bin/tcsh"
	good_shell = "/bin/bash"

	node = driver.client
	user = node.requireUser("test-user")
	if not user.uid:
		node.logFailure("user %s does not seem to exist" % user.login)
		return

	if not user.password:
		node.logFailure("user %s: password not known" % user.login)
		return

	if not force_shell(node, user.login, wrong_shell):
		node.logError("Failed to change the user's shell to %s" % wrong_shell)
		return

	# The shell we're replacing may have to be in /etc/shells
	file = node.requireFile("system-shells")
	editor = file.createEditor()
	editor.addOrReplaceEntry(name = wrong_shell)
	editor.commit()

	executable = node.requireExecutable("chfn")

	# evaluate failure conditions specified for chfn (for instance, if SELinux is active,
	# the behavior of chfn depends on the test user's SELinux user.
	driver.predictTestResult(executable)

	chat_script = [
		["assword: ", user.password],
	]

	st = node.runChatScript("chsh --shell %s" % good_shell, chat_script, timeout = 10, user = user.login)
	if not st:
		node.logFailure(f"chsh command failed: {st.message}")
		return

	pwd = getpwnam(node, user.login)
	if not pwd:
		return

	if pwd.shell != good_shell:
		node.logFailure("Apparently, we failed to change the shell for %s (found %s instead of %s)." % (user.login, pwd.shell, good_shell))
		return

	node.logInfo("OK, shell was changed to %s." % good_shell)

@susetest.test
def verify_passwd(driver):
	'''shadow.passwd: check if test user can change password'''
	new_password = "$up3r/3l1t3/PAssw0rd"

	node = driver.client
	user = node.requireUser("test-user")
	if not user.uid:
		node.logFailure("user %s does not seem to exist" % user.login)
		return

	if not user.password:
		node.logFailure("user %s: password not known" % user.login)
		return

	node.logInfo("backing up shadow file")
	node.run("ln /etc/shadow /etc/shadow.twopence")

	chat_script = [
		["urrent password: ", user.password],
		["ew password: ", new_password],
		["password: ", new_password],
	]

	st = node.runChatScript("passwd", chat_script, timeout = 10, user = user.login)
	if not st:
		node.logFailure(f"passwd command failed: {st.message}")
	else:
		pass

	node.run("mv /etc/shadow.twopence /etc/shadow")
	node.logInfo("restored shadow file")

##################################################################
# IN FIPS mode, login.defs should specify the default password
# encryption method as SHA256 or SHA512
##################################################################
@susetest.test
def verify_encryption_method(driver):
	'''shadow.encryption_method: check default encyption algorithm'''

	node = driver.client
	file = node.requireFile("system-login.defs")
	if not file:
		node.logError("File login.defs does not seem to exist")
		return

	reader = file.createReader()
	entry = reader.lookupEntry(name = 'ENCRYPT_METHOD')
	if entry is None:
		node.logFailure(f"{logindefs.path} does not seem to define ENCRYPT_METHOD")
		return

	algorithm = entry.value
	if algorithm not in ('DES', 'MD5', 'SHA256', 'SHA512', 'BCRYPT'):
		node.logFailure(f"{logindefs.path} specifies unknown encryption algorithm {algorithm}")
		return

	if node.testFeature('fips') and algorithm not in __fips_acceptable_crypt_algos:
		node.logFailure(f"{logindefs.path} specifies encryption algorithm {algorithm} (forbidden by FIPS 140-2)")
		return

	node.logInfo(f"Okay, encryption algorithm {algorithm} looks good")

##################################################################
# FIPS mode forbids DES and MD5 hash algorithms.
# It seems we're not fully disabling these; and there's probably
# a reason to keep accepting existing passwords even when hashed
# with a bad algorithm. However, we should probably make sure we
# do not accept bad algorithms when setting passwords...
# So make sure chpasswd -c $ALGO does the right thing.
#
# Note: verify_password is provided by farthings.
##################################################################
def __verify_crypt_algo(driver, args):
	'''chpasswd.@ARGS: check whether crypt(3) digest @ARGS works'''

	node = driver.client
	algo = args[0]

	chpasswd = node.requireExecutable("chpasswd")
	if chpasswd is None:
		node.logError("Unable to find chpasswd")
		return

	# FIXME it would be nice if our resource handling would properly
	# reset resources after use. Eg. restore the password after we've
	# messed with it
	user = node.requireUser("test-user")

	# evaluate failure conditions specified for chpasswd (specifcally,
	# for algorithms disabled by FIPS)
	print(f"Trying to predict outcome for algo {algo}")
	driver.predictTestResult(chpasswd, algorithm = algo)

	data = f"{user.login}:{user.password}".encode('utf-8')
	st = chpasswd.run(f"-c {algo}", stdin = data, quiet = True)
	if not st:
		node.logFailure(f"chpasswd with crypt algorithm {algo} failed: {st.message}")
		return

	verify = node.requireExecutable("verify_password")
	if verify is None:
		node.logError("Unable to find verify_password executable");
		return

	st = verify.run(f"--algorithm {algo} {user.login} '{user.password}'")
	if not st:
		node.logFailure("Unable to verify password");
		return

susetest.define_parameterized(__verify_crypt_algo, "DES")
susetest.define_parameterized(__verify_crypt_algo, "MD5")
susetest.define_parameterized(__verify_crypt_algo, "SHA256")
susetest.define_parameterized(__verify_crypt_algo, "SHA512")

# boilerplate tests
susetest.template('selinux-verify-executable', 'passwd')
susetest.template('selinux-verify-executable', 'chsh')
susetest.template('selinux-verify-executable', 'chfn')

# susetest.template('verify-subsystem', 'shadow')
susetest.template('verify-file', 'system-shadow')
susetest.template('verify-file', 'system-passwd')
susetest.template('verify-file', 'system-group')

if __name__ == '__main__':
	susetest.perform()
