#!/bin/sh
# Apply ism-1558 word-count enforcement to a Debian 13 (trixie) host.
#
# Usage: apply-wordcount.sh <NC|OS|P|S|TS> [module|exec]
#
# The first argument is the system's ISM applicability code, which sets the
# minimum word count: 4 on NC, OS and P, 5 on S, 6 on TS.
#
# The second selects the implementation:
#   module  (default) pam_ism_wordcount.so -- in-process, the candidate never
#           leaves the PAM process. Needs the module built; see src/Makefile.
#   exec    pam_exec.so running a shell check -- no build step, but the cleartext
#           candidate crosses a pipe to a child process.
#
# Requires debian-13-pwquality-base to be applied first. The check reads the new
# password but never asks for it; pam_pwquality, earlier in the stack, is what
# obtains it. Without such a module pam-auth-update still gives pam_unix
# use_authtok, there is no token to use, and every password change fails.
#
# Idempotent: safe to re-run.

set -eu

USAGE="usage: $0 <NC|OS|P|S|TS> [module|exec]"

APPLICABILITY=$(printf '%s' "${1:-}" | tr '[:lower:]' '[:upper:]')
case "$APPLICABILITY" in
	NC|OS|P) MIN_WORDS=4 ;;
	S)       MIN_WORDS=5 ;;
	TS)      MIN_WORDS=6 ;;
	*)       echo "$USAGE" >&2; exit 2 ;;
esac

WORDCOUNT_MODE="${2:-module}"
case "$WORDCOUNT_MODE" in
	module|exec) ;;
	*) echo "$USAGE" >&2; exit 2 ;;
esac

[ "$(id -u)" -eq 0 ] || { echo "error: must run as root" >&2; exit 1; }

BASE_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)"

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 magic, class, byte order and machine. A module PAM cannot load is otherwise
# only discovered at the next password change, as "Module is unknown".
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
}

# --- Preflight: nothing up to here modifies the host --------------------------
SECDIR=$(pam_securedir) || {
	echo "error: cannot locate the PAM module directory (pam_unix.so from libpam-modules)" >&2
	exit 1
}

grep -qE '^[^#]*pam_pwquality\.so' /etc/pam.d/common-password || {
	echo "error: requires debian-13-pwquality-base, and pam_pwquality.so is not in /etc/pam.d/common-password." >&2
	echo "       Without a module that obtains the new password ahead of this check," >&2
	echo "       every password change fails. Apply debian-13-pwquality-base first." >&2
	exit 1
}

# pam_ism_wordcount.so is excluded: this run replaces it, which is how a host left
# broken by an earlier install is repaired.
MISSING=$(unresolved_modules | grep -vx 'pam_ism_wordcount\.so' || true)
[ -z "$MISSING" ] || {
	echo "error: /etc/pam.d/common-password already names modules PAM cannot find:" >&2
	printf '         %s\n' $MISSING >&2
	echo "       Refusing to modify a password stack that is already broken." >&2
	exit 1
}

if [ "$WORDCOUNT_MODE" = "module" ]; then
	MODULE="$BASE_DIR/src/pam_ism_wordcount.so"
	if [ ! -f "$MODULE" ]; then
		command -v cc >/dev/null 2>&1 || {
			echo "error: pam_ism_wordcount.so is not built and no compiler is present." >&2
			echo "       Build it on a build host with 'make -C src' and ship the .so," >&2
			echo "       or re-run with 'exec' to use the pam_exec fallback." >&2
			exit 1
		}
		make -C "$BASE_DIR/src" >/dev/null
	fi
	[ "$(elf_id "$MODULE")" = "$(elf_id "$SECDIR/pam_unix.so")" ] || {
		echo "error: $MODULE is not built for this host ($(dpkg --print-architecture))." >&2
		echo "       PAM could not load it, and every password change would fail." >&2
		exit 1
	}
fi

# Switching modes removes the other profile, so only one enforcement path is live.
echo "==> installing word-count enforcement ($WORDCOUNT_MODE, minimum $MIN_WORDS words)"
rm -f /usr/share/pam-configs/ism-wordcount /usr/share/pam-configs/ism-wordcount-module

if [ "$WORDCOUNT_MODE" = "module" ]; then
	install -m 0644 "$MODULE" "$SECDIR/pam_ism_wordcount.so"
	sed "s/minwords=4/minwords=$MIN_WORDS/g" \
		"$BASE_DIR/config/pam-configs/ism-wordcount-module" >/usr/share/pam-configs/ism-wordcount-module
	chmod 0644 /usr/share/pam-configs/ism-wordcount-module
	PROFILE=ism-wordcount-module
else
	install -m 0755 "$BASE_DIR/scripts/ism-wordcount-check" /usr/local/sbin/ism-wordcount-check
	printf 'MIN_WORDS=%s\n' "$MIN_WORDS" >/etc/security/ism-wordcount.conf
	chmod 0644 /etc/security/ism-wordcount.conf
	install -m 0644 "$BASE_DIR/config/pam-configs/ism-wordcount" /usr/share/pam-configs/ism-wordcount
	PROFILE=ism-wordcount
fi

echo "==> enabling $PROFILE in the PAM stack"
pam-auth-update --package --enable "$PROFILE"

# Preflight established the stack was sound, so anything unresolved now is ours.
MISSING=$(unresolved_modules)
[ -z "$MISSING" ] || {
	pam-auth-update --package --remove "$PROFILE"
	echo "error: after enabling $PROFILE, PAM cannot find:" >&2
	printf '         %s\n' $MISSING >&2
	echo "       $PROFILE has been removed from the stack again." >&2
	exit 1
}

echo "==> applied. Verify with: scripts/verify-wordcount.sh --with-test-account"
