#!/bin/sh
# Verify ism-1558 word-count enforcement on a Debian 13 (trixie) host.
#
# By default only wiring is inspected, and no account is created or modified, so
# this is safe on a production host. That cannot show the word count is enforced,
# so no ism-1558 result is reported without --with-test-account, which exercises
# it through a real password change on a throwaway account created and deleted by
# this script.
#
# Test candidates are long enough for any applicability's minimum length, so a
# rejection is attributable to the word count and not to another component's
# policy.
#
# Exit status: 0 if nothing FAILs, 1 otherwise.

set -u

WITH_TEST_ACCOUNT=0
[ "${1:-}" = "--with-test-account" ] && WITH_TEST_ACCOUNT=1

PASS_COUNT=0
FAIL_COUNT=0
PARTIAL_COUNT=0

report() {
	case "$1" in
		PASS) PASS_COUNT=$((PASS_COUNT + 1)) ;;
		PARTIAL) PARTIAL_COUNT=$((PARTIAL_COUNT + 1)) ;;
		*) FAIL_COUNT=$((FAIL_COUNT + 1)) ;;
	esac
	printf '%-8s %-13s %s\n' "$1" "$2" "$3"
}

pam_securedir() {
	unix=$(dpkg -L "libpam-modules:$(dpkg --print-architecture)" 2>/dev/null |
		grep '/security/pam_unix\.so$' | head -n 1)
	[ -n "$unix" ] && [ -f "$unix" ] || return 1
	dirname "$unix"
}

elf_id() {
	{ od -An -tx1 -N6 "$1" && od -An -tx1 -j18 -N2 "$1"; } 2>/dev/null | tr -d ' \n'
}

unresolved_modules() {
	grep -v '^[[:space:]]*#' /etc/pam.d/common-password |
		grep -oE '[^][:space:]]+\.so([[:space:]]|$)' | tr -d ' \t' |
		while IFS= read -r mod; do
			case "$mod" in
				/*) [ -f "$mod" ] || echo "$mod" ;;
				*)  [ -f "$SECDIR/$mod" ] || echo "$mod" ;;
			esac
		done
}

SECDIR=$(pam_securedir) || SECDIR=
CHECK=/usr/local/sbin/ism-wordcount-check
MIN_WORDS=4

if grep -q 'pam_ism_wordcount\.so' /etc/pam.d/common-password; then
	WC_MODE=module
	MIN_WORDS=$(sed -n 's/.*pam_ism_wordcount\.so.*minwords=\([0-9]*\).*/\1/p' /etc/pam.d/common-password | head -1)
	: "${MIN_WORDS:=4}"
elif grep -q 'pam_exec\.so.*ism-wordcount-check' /etc/pam.d/common-password; then
	WC_MODE=exec
	[ -r /etc/security/ism-wordcount.conf ] && . /etc/security/ism-wordcount.conf
else
	WC_MODE=none
fi

echo "Mode:       $WC_MODE"
echo "Min words:  $MIN_WORDS"
echo

# Distinct words with digits, so no dictionary, sequence or repeat rule rejects
# them, and every candidate is longer than the highest minimum length (20).
short_seq=''
i=1
while [ "$i" -lt "$MIN_WORDS" ]; do
	short_seq="${short_seq}otter${i}mango "
	i=$((i + 1))
done
full_seq="${short_seq}quiltraven"
single_token=vaultottermangoquiltravenfjordzebraplinth

# --- dependency and wiring -----------------------------------------------------
if grep -qE '^[^#]*pam_pwquality\.so' /etc/pam.d/common-password; then
	report PASS "dependency" "pam_pwquality.so obtains the new password ahead of the word-count check"
else
	report FAIL "dependency" "pam_pwquality.so is absent; with nothing to obtain the new password, every password change fails"
fi

case "$WC_MODE" in
	module)
		if [ -z "$SECDIR" ] || [ ! -f "$SECDIR/pam_ism_wordcount.so" ]; then
			report FAIL "module" "wired but not installed in ${SECDIR:-the PAM module directory}; password changes will fail"
		elif [ "$(elf_id "$SECDIR/pam_ism_wordcount.so")" != "$(elf_id "$SECDIR/pam_unix.so")" ]; then
			report FAIL "module" "$SECDIR/pam_ism_wordcount.so is not built for this architecture"
		else
			report PASS "module" "pam_ism_wordcount.so wired with minwords=$MIN_WORDS and installed in $SECDIR"
		fi
		;;
	exec)
		[ -x "$CHECK" ] &&
			report PASS "exec" "pam_exec wired to $CHECK with MIN_WORDS=$MIN_WORDS" ||
			report FAIL "exec" "pam_exec wired, but $CHECK is missing or not executable"
		;;
	*)
		report FAIL "pam-stack" "no word-count enforcement is present in /etc/pam.d/common-password"
		;;
esac

if [ -n "$SECDIR" ] && [ -z "$(unresolved_modules)" ]; then
	report PASS "pam-stack" "every module in /etc/pam.d/common-password resolves"
else
	report FAIL "pam-stack" "PAM cannot find: $(echo $(unresolved_modules)); every password change will fail"
fi

# --- ism-1558 : word count, by behaviour ---------------------------------------
if [ "$WITH_TEST_ACCOUNT" -eq 1 ] && [ "$WC_MODE" != "none" ]; then
	TEST_USER="ismwc$$"
	set_password() { echo "$TEST_USER:$1" | chpasswd >/dev/null 2>&1; }
	if useradd -M -s /usr/sbin/nologin "$TEST_USER" >/dev/null 2>&1; then
		SINGLE=0 SHORT=0 FULL=0
		set_password "$single_token" && SINGLE=1
		set_password "$short_seq" && SHORT=1
		set_password "$full_seq" && FULL=1
		userdel -f "$TEST_USER" >/dev/null 2>&1
		if [ "$SINGLE" -eq 1 ] && [ "$SHORT" -eq 0 ] && [ "$FULL" -eq 1 ]; then
			report PARTIAL "ism-1558" \
				"$((MIN_WORDS - 1))-word sequence rejected, $MIN_WORDS words and single tokens accepted ($WC_MODE); randomness of word choice is not enforced"
		else
			report FAIL "ism-1558" \
				"word count not enforced as claimed: single token $([ $SINGLE -eq 1 ] && echo accepted || echo rejected), $((MIN_WORDS - 1)) words $([ $SHORT -eq 1 ] && echo accepted || echo rejected), $MIN_WORDS words $([ $FULL -eq 1 ] && echo accepted || echo rejected)"
		fi
	else
		report FAIL "ism-1558" "could not create a throwaway account to test with"
	fi
elif [ "$WC_MODE" != "none" ]; then
	echo "(ism-1558 not assessed: re-run with --with-test-account to exercise the word count)"
fi

echo
echo "passed: $PASS_COUNT  partial: $PARTIAL_COUNT  failed: $FAIL_COUNT"
[ "$FAIL_COUNT" -eq 0 ] || exit 1
