#!/bin/bash
#
# Bash library and executable that provides some "security" features
#
# Copyright © 2021 Jean-Jacques Brucker <jjbrucker@free.fr>
#
# SPDX-License-Identifier: LGPL-3.0-only
#
# shellcheck disable=SC2013 # To read lines rather than words
# shellcheck disable=SC2034 # variable appears unused
# shellcheck disable=SC2046 # Quote this to prevent word splitting
# shellcheck disable=SC2086 # Double quote warning
# shellcheck disable=SC2128 # expanding array without index warning


if [[ "$1" == --bash-completion ]] ; then
	BL_tmp_a=$("${BASH_SOURCE[0]}" --help | sed -nE 's:^ {2,4}([a-z0-9_]+\>).*:\1:p')
	BL_tmp_o=$(for a in $BL_tmp_a ; do
		echo "[$a]=\"$(eval echo $("$BASH_SOURCE" $a --help | sed -n 's,^  ... \(--[a-z_-]\+[^ ]*\).*,\1,p' | sed 's_<_{_ ; s_|_,_g ; s_>_}_ '))\""
	done)

	eval '_bl_security_completion()
	{
		local cur coptions="--help --version"
		local a actions="'$BL_tmp_a'"
		local -A aoptions=('$BL_tmp_o')

		COMPREPLY=()
		cur=${COMP_WORDS[COMP_CWORD]}

		case ${COMP_WORDS[COMP_CWORD-1]} in
			--help|--version)
				# should exit without executing any actions, so do not complete anything
				return 0 ;;
			--dict)
				COMPREPLY=( $(compgen -W "$("'$BASH_SOURCE'" gen_passphrase --list-dicts)" -- $cur ) )
				return 0 ;;
			--frontend)
				COMPREPLY=( $(compgen -W "whiptail dialog zenity NONE" -- $cur ) )
				return 0 ;;
		esac
		case $cur in
			-*)
				if a=$(grep -o "\<\('${BL_tmp_a//$'\n'/\\|}'\)\>" <<< "${COMP_WORDS[@]}") ; then
					COMPREPLY=( $(compgen -W "$coptions ${aoptions[$a]}" -- $cur ) )
					return 0
				fi
				COMPREPLY=( $(compgen -W "$coptions --frontend" -- $cur ) )
				return 0 ;;
		esac
		if grep -q "\<shred_path\>" <<< "${COMP_WORDS[@]}" ; then
			compopt -o plusdirs
			COMPREPLY=( $(compgen -A file -- $cur) )
			return 0
		fi
		# complete actions if none in ${COMP_WORDS[@]}
		grep -q "\<\('${BL_tmp_a//$'\n'/\\|}'\)\>" <<< "${COMP_WORDS[@]}" || COMPREPLY=( $(compgen -W "$actions" -- $cur ) )
		return 0
	}'
	unset BL_tmp_a BL_tmp_o
	complete -F _bl_security_completion "$(basename "$BASH_SOURCE")" "$BASH_SOURCE"
	return 0
fi

# If sourcing while _bl_security_parseoptions is already set, execute the function and return without reloading rest of file.
if [[ "$BASH_SOURCE" != "$0" ]] && [[ "$(type -t _bl_security_parseoptions)" == function ]] ; then
	_bl_security_parseoptions "$@"
	return $?
fi

### Constants ###

BL_SECURITY_NAME="$(basename "$(readlink -f "$BASH_SOURCE")" )"
BL_SECURITY_VERSION="0.3.5-1"
BL_SECURITY_FUNCTIONS=( $(sed -n 's,^\(bl_[^( ]*\) *().*,\1,p' "$BASH_SOURCE") )
readonly BL_SECURITY_NAME BL_SECURITY_VERSION BL_SECURITY_FUNCTIONS

[[ "$BASH_SOURCE" == "$0" ]] && declare -r BL_SECURITY_isprogram=1 || declare -r BL_SECURITY_isprogram=0

### Others Globals ###

if ((BL_SECURITY_isprogram)) ; then
	TEXTDOMAIN="bashlibs"
	TEXTDOMAINDIR="$(dirname "$(readlink -f "$BASH_SOURCE")" )/../share/locale"
	# Require Bash 5.2. - https://www.kurokatta.org/grumble/2023/11/bash-translated-strings#fnref3
	shopt -s noexpand_translation
fi

BL_SECURITY_chelpmsg="
"$"Provide some cybersecurity features.""

MAIN OPTIONS:
  -f, --frontend PROGRAM   "$"Select a frontend program"" {NONE,whiptail,dialog,zenity} - "$"Environment variable: ""BL_INTERACTIVE_FRONTEND"

### external functions ###

# shellcheck source=bl-interactive
source "$(dirname "$BASH_SOURCE")"/bl-interactive --

### internal functions ###

_bl_security_parseoptions() {
	local npp=$# frontend
	for ((;$#;)) ; do
		case "$1" in
			-f|--frontend) shift; frontend="$1";; # Will be passed to bl-interactive
			-h|--help) printf "%s\n%s%s\n" "$BL_SECURITY_usage" "$BL_SECURITY_chelpmsg" "$BL_SECURITY_shelpmsg" ; return 1 ;;
			-V|--version) printf "%s %s\n" "$BL_SECURITY_NAME" "$BL_SECURITY_VERSION" ; return 1 ;;
			--) shift ; break ;;
			-*) printf "%s: unrecognized option '%s'\n\nTry '%s --help' for more information.\n" "$BL_SECURITY_NAME" "$1" "$BASH_SOURCE" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	BL_SECURITY_NOPTIONS=$((npp-$#))

	if [[ "$frontend" ]] ; then
		source "$(dirname "$BASH_SOURCE")"/bl-interactive --frontend "$frontend" --
	fi
}

### public functions / program actions ###

bl_urandom() {
	local name
	((BL_SECURITY_isprogram)) && name="$BL_SECURITY_NAME ${FUNCNAME:3}" || name="$FUNCNAME"
	local usage="Usage: $name [MODULO]"
	local helpmsg="
"$"Output a good random number between 0 and 1<<32 (4294967296).""
.. or between 0 and (MODULO - 1) if MODULO is given.
"
	local r=$SRANDOM s=$SRANDOM
	[[ "$r" == "$s"  ]] || helpmsg+="
"$"NOTE: Since GNU bash version 5.1.0 introduce SRANDOM variable,""
"$"this function became useless and will be deprecated.""
"
	for ((;$#;)) ; do
		case "$1" in
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_SECURITY_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	local m=$1
	[[ "$r" != "$s"  ]] || r=$(od -An -N4 -t u4 < /dev/urandom)
	echo $((m?r%m:r))
}

bl_shred_path() {
	local name
	((BL_SECURITY_isprogram)) && name="$BL_SECURITY_NAME ${FUNCNAME:3}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS] PATHS..."
	local helpmsg="
"$"Recursively shred all files in given path(|s).""
OPTIONS:
  -f, --force          "$"Change file permissions if necessary, never prompt""
  -v, --verbose        "$"Explain what is being done (stdout)""
  -r, --remove         "$"Also recursively remove given path(|s)""
  -n, --iterations N   "$"Overwrite N times instead of the shred's default""
  -z, --zero           "$"Add a final overwrite with zeros to hide shredding""
"
	local farg varg extrasargs files file rmcmd=true
	for ((;$#;)) ; do
		case "$1" in
			-f|--force) farg="-f" ;;
			-v|--verb*) varg="-v" ;;
			-r|--remove) rmcmd="rm" ;;
			-n|--iterations) shift ; extrasargs+=" -n $1" ;;
			-z|--zero) extrasargs+=" -z" ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_SECURITY_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	(($#)) || { printf "%s: Error: no PATH given as argument\nTry '%s --help' for more information.\n" "$FUNCNAME" "$name" >&2 ; return 2 ; }

	mapfile -t files < <(find "$@" -type f)

	if [[ -z "${files[*]}" ]] ; then
		printf "%s: Error: no file to shred\n" "$FUNCNAME" >&2
		return 2
	else
		if [[ "$farg" != -f ]] ; then
			bl_yesno --default=no --text "$(printf "%s\n" "${files[@]@Q}")" "Shred this ${#files[@]} file(s)" || return 1
		fi

		for file in "${files[@]}" ; do
			# Don't pass $varg to shred as it use to write too many things, and moreover on stderr instead of stdin
			! [[ "$varg" ]] || echo "shredding:" "${file@Q}"
			shred $farg $extrasargs "${file}" || return $?
			# If we have to delete files, first overwrite it with empty file to not loosing time shredding them again. Notably if a previous bl_shred_path --remove was interrupted.
			[[ "$rmcmd" == true ]] || echo -n > "${file}" || return $?
		done
	fi
	$rmcmd -r $farg $varg "$@" || return $?
}

bl_gen_passphrase() {
	local dict=${LANG::2} nw=3 interactive=false sedcmd sd ret
	local name delim="_" showsize=false listdicts=false
	((BL_SECURITY_isprogram)) && name="$BL_SECURITY_NAME ${FUNCNAME:3}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS]"
	local helpmsg="
"$"Generate a good random passphrase.""
OPTIONS:
  -i, --interactive         "$"Ask language and passphrase agreement""
  -l, --list-dicts          "$"List available dictionaries (aspell) and return""
  -D, --dict DICTIONARY     "$"Dictionary to use""(ignored if --interactive, default: ${dict:=en})
  -n, --num NUMBER          "$"Number of words in passphrase""(default: $nw)
  -d, --delimiter STRING    "$"Separate words by this STRING""(default: '$delim')
  -S, --show-size           "$"Also output size of dictionary"" (number of usable words)
"
	for ((;$#;)) ; do
		case "$1" in
			-i|--interactive) interactive=true ;;
			-l|--list-dict*) listdicts=true ;;
			-D|--dict*) shift ; dict="$1" ;;
			-n|--num*) shift ; nw="$1" ;;
			-d|--delim*) shift ; delim="$1" ;;
			-S|--show-size) showsize=true ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_SECURITY_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	((! $#)) || printf "$FUNCNAME: Warning: "$"Ignoring extra args '%s'...""\n" "$1" >&2

	if $listdicts ; then
		if $showsize ; then
			aspell dump dicts | while read -r dict ; do
				echo "$(aspell -d "$dict" dump master | grep --count --invert-match "'") $dict"
			done
			return ${PIPESTATUS[0]}
		else
			aspell dump dicts
			return $?
		fi
	fi

	((nw>0)) || { printf "$FUNCNAME: Error: "$"Invalid number of words"" (%s).\n" "$nw" >&2 ; return 2 ; }
	while ((nw-- > 0)) ; do
		sedcmd+="\$(bl_urandom \$sd)p ; "
	done

	if $interactive ; then
		local aspell_dicts
		mapfile -t aspell_dicts < <(aspell dump dicts | grep "^[a-z]\+$")
		dict=$(bl_radiolist -n 5 -v -p $"Prefered language to generate a good passphrase:" "${aspell_dicts[@]}") || return $?
	fi
	# We use multiple time aspell dump, to avoid a big variable (using some Mo of RAM)
	sd=$(aspell -d "$dict" dump master | grep --count --invert-match "'" ; exit ${PIPESTATUS[0]}) || return $?
	while true ; do
		ret="$(aspell -d "$dict" dump master | grep -v "'" | eval sed -n \""$sedcmd"\" | sed ':a;N;$!ba;s/\n/'"$delim"'/g')"
		$interactive || break
		! bl_yesno --default=yes --text $"Random passphrase: ""$ret" $"Agree" || break
	done
	! $showsize || echo -n "$sd "
	echo "$ret"
	return 0
}

bl_new_password() {
	local name
	local preamble dict=${LANG::2} nw=3
	local question=$"Password"
	((BL_SECURITY_isprogram)) && name="$BL_SECURITY_NAME ${FUNCNAME:3}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS] [QUESTION]..."
	local helpmsg="
"$"Ask to enter a new password and force retyping it.""
"$"Output the new password.""
("$"Default QUESTION: ""'$question')

OPTIONS:
  -t, --text TEXT           "$"Text to display as preamble. If none generate a passphrase suggestion.""
  -D, --dict DICTIONARY     "$"Dictionary for passphrase suggestion"" - "$"Default: ""${dict:=en}
  -n, --num NUMBER          "$"Number of words for passphrase suggestion"" - "$"Default: ""$nw
"
	for ((;$#;)) ; do
		case "$1" in
			-t|--text|--preamble) shift ; preamble="$1" ;;
			-D|--dict*) shift ; dict="$1" ;;
			-n|--num*) shift ; nw="$1" ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) echo "$FUNCNAME $BL_SECURITY_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done
	((! $#)) || question="$*"

	local passwd passwd2

	[[ "$preamble" ]] || preamble=$"Suggestion: "$(bl_gen_passphrase --delimiter " " --dict "$dict" --num "$nw")
	passwd2=$(bl_input --password --text "$preamble" "$question") || return $?
	preamble=$(printf $"Retype %s" "$question")
	while true ; do
		passwd=$(bl_input --password --text "$preamble" "$question") || return $?
		[[ "$passwd2" != $passwd ]] || break
		preamble=$"Passwords do not match. Please retype"
		passwd2=$passwd
	done
	echo "$passwd"
}

bl_shrink_num() {
	local name
	((BL_PGPID_isprogram)) && name="$BL_PGPID_NAME ${FUNCNAME:9}" || name="$FUNCNAME"
	local usage="Usage: $name [OPTIONS]... SIZE [FILE]"
	local helpmsg="
"$"Shrink input data to a number limited by SIZE.""
"$"Usefull to make hash distributed into a lesser space whith (uncommon) SIZE not matching 2^n.""
"$"This function XOR sucessive 64bits words of raw input (or given FILE), and apply 'modulo SIZE' at the end.""
"$"Then Maximum SIZE is 2^64-1. (And avoid to get close to this limit if you need a good number dispatching).""
"$"About encodings: check basenc manual from GNU coreutils.""

OPTIONS:
  -i, --input-encoding ENCODING  Eg: raw(default), base64, base64url, base32, base32hex, base16, base2msbf or base2lsbf.
  -g, --ignore-garbage           "$"When decoding (a 'baseXXX' input), ignore non-alphabet characters.""
  -o, --output-encoding ENCODING Eg: raw, base64, base64url, base32, base32hex, base16, base10(default), base2msbf or base2lsbf.
"
	local ienc="raw" oenc="base10" iextropts=""
	for ((;$#;)) ; do
		case "$1" in
			-i|--input-encoding)
				shift ; ienc=$1 ;;
			-o|--ouput-encoding)
				shift ; oenc=$1 ;;
			-g|--ignore-garbage)
				iextraopts+="--ignore-garbage " ;;
			-h|--help) printf "%s\n%s\n" "$usage" "$helpmsg" ; return ;;
			-V|--version) printf "%s %s\n" "$FUNCNAME" "$BL_PGPID_VERSION" ; return ;;
			--) shift ; break ;;
			-*) printf "$FUNCNAME: Error: "$"Unrecognized option"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
			*) break ;;
		esac
		shift
	done

	if ! [[ "$1" =~ ^[1-9][0-9]* ]] ; then
		printf "$usage\n" >&2
		return 2
	fi
	size="$1"
	shift

	local decoder offset hex dec=0 char encoder
	case "$ienc" in
		raw)
			decoder="cat" ;;
		base64|base64url|base32|base32hex|base16|base2msbf|base2lsbf)
			decoder="basenc --decode --$ienc $iextraopts" ;;
		*)
			printf "$FUNCNAME: Error: "$"Unrecognized encoding"" '$ienc'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
	esac

	while read offset hex char; do
		#printf "%016X\n" $((0x$hex))
		dec=$((0x$hex ^ dec))
		#printf "%016X\n" $((dec))
	done < <( $decoder "$@" | xxd -c 8 -g 0 )

	# Simple dec=$((dec%size)) doesn't work as (( builtin use only signed integer, then number > 0x0fff ffff ffff ffff are negative
	# And with negative number '%', which is the reminder operator, differs from the mathematical modulo operation.
	# see https://torstencurdt.com/tech/posts/modulo-of-negative-numbers/
	dec=$(( ((dec % size) + size) % size ))

	hex=$(printf "%02X\n" $dec)

	case "$oenc" in
		raw)
			echo "$hex" | xxd -r -p ;;
		base10)
			printf "%u\n" "$dec" ;;
		base16)
			echo "$hex" ;;
		base64|base64url|base32|base32hex|base2msbf|base2lsbf)
			echo "$hex" | xxd -r -p | basenc --$oenc ;;
		*)
			printf "$FUNCNAME: Error: "$"Unrecognized encoding"" '$ienc'.\n\n"$"Try '%s --help' for more information"".\n" "$name" >&2 ; return 2 ;;
	esac
}

### Init ###

if ((BL_SECURITY_isprogram)) ; then
	BL_SECURITY_usage="Usage: $BASH_SOURCE [MAIN_OPTIONS]... ACTION [OPTIONS]... [ARGUMENTS]..."
	BL_SECURITY_shelpmsg="
  -h, --help               "$"Show help and exit.""
  -V, --version            "$"Show version and exit.""

ACTIONS:
$(for f in "${BL_SECURITY_FUNCTIONS[@]}" ; do printf "   %-19s%s\n" "${f:3}" "$($f --help | sed -n '/^$/{n;p;q}')" ; done)

"$"All actions support a --help option, eg:""
$ $BASH_SOURCE ${BL_SECURITY_FUNCTIONS:3} --help

$BL_SECURITY_NAME is also bash library, see:
$ source $BASH_SOURCE --help"
else
	BL_SECURITY_usage="Usage: source $BASH_SOURCE [MAIN_OPTIONS]..."
	BL_SECURITY_shelpmsg="
      --bash-completion    set completion for $BASH_SOURCE program and return (without loading anything else)

Functions:
$(for f in "${BL_SECURITY_FUNCTIONS[@]}" ; do printf "   %-19s%s\n" "$f" "$($f --help | sed -n '/^$/{n;p;q}')" ; done)

"$"Reminder: when used as a library, all functions calls share the same environment variables, i.e. the same global options."""
fi

# Parse Options
_BL_SECURITY_RETVAL=0
_bl_security_parseoptions "$@" || _BL_SECURITY_RETVAL=$?

# Do nothing else if sourced
[[ "$BASH_SOURCE" == "$0" ]] || return $_BL_SECURITY_RETVAL

[[ "$_BL_SECURITY_RETVAL" != "1"  ]] || exit 0
[[ "$_BL_SECURITY_RETVAL" < 2 ]] || exit $_BL_SECURITY_RETVAL

### Run ###
set -e
shift $BL_SECURITY_NOPTIONS
if function=$(grep -o "\<bl_$1\>" <<< "${BL_SECURITY_FUNCTIONS[@]}") ; then
	shift
	$function "$@"
	exit $?
else
	printf "$BL_SECURITY_NAME: Error: "$"Unrecognized action"" '$1'.\n\n"$"Try '%s --help' for more information"".\n" "$BASH_SOURCE" >&2
	exit 2
fi

