diff --git a/sshmngr/Config.in b/sshmngr/Config.in index c623dd825..721e4716f 100644 --- a/sshmngr/Config.in +++ b/sshmngr/Config.in @@ -28,4 +28,15 @@ config SSHMNGR_SFTP default y help Enable this option to support the SFTP protocol. + +config SSHMNGR_SECURITY_MFA + bool "MFA using PAM and Google authenticator" + depends on SSHMNGR_BACKEND_OPENSSH_PAM + default n + help + Enable this option to use MFA with PAM based Google authenticator. + +config SSHMNGR_VENDOR_PREFIX + string "Package specific datamodel Vendor Prefix for TR181 extensions" + default "" endif diff --git a/sshmngr/Makefile b/sshmngr/Makefile index a9a6aabe9..2c20d2c10 100644 --- a/sshmngr/Makefile +++ b/sshmngr/Makefile @@ -34,6 +34,7 @@ define Package/sshmngr DEPENDS+=+SSHMNGR_BACKEND_OPENSSH_PAM:openssh-server-pam +SSHMNGR_BACKEND_OPENSSH_PAM:openssh-client-utils DEPENDS+=+SSHMNGR_BACKEND_DROPBEAR:dropbear DEPENDS+=+SSHMNGR_SFTP:openssh-sftp-server + DEPENDS+=+SSHMNGR_SECURITY_MFA:google-authenticator-libpam endef define Package/sshmngr/description @@ -44,6 +45,13 @@ define Package/$(PKG_NAME)/config source "$(SOURCE)/Config.in" endef +ifeq ($(CONFIG_SSHMNGR_VENDOR_PREFIX),"") +VENDOR_PREFIX = $(CONFIG_BBF_VENDOR_PREFIX) +else +VENDOR_PREFIX = $(CONFIG_SSHMNGR_VENDOR_PREFIX) +endif + + ifeq ($(LOCAL_DEV),1) define Build/Prepare $(CP) -rf ./sshmngr/* $(PKG_BUILD_DIR)/ @@ -67,6 +75,16 @@ endif $(BBFDM_REGISTER_SERVICES) ./bbfdm_service.json $(1) $(PKG_NAME) $(BBFDM_INSTALL_MS_DM) $(PKG_BUILD_DIR)/src/libsshmngr.so $(1) $(PKG_NAME) +ifeq ($(CONFIG_SSHMNGR_BACKEND_OPENSSH_PAM),y) + $(INSTALL_DIR) $(1)/etc/uci-defaults + $(INSTALL_DATA) ./files/openssh_backend/lib/sshmngr/pam_config.sh $(1)/lib/sshmngr/ + $(INSTALL_BIN) ./files/openssh_backend/etc/uci-defaults/91-set-sshd-pam $(1)/etc/uci-defaults/ +ifeq ($(CONFIG_SSHMNGR_SECURITY_MFA),y) + $(INSTALL_BIN) ./files/openssh_backend/etc/uci-defaults/92-set-ssh-mfa $(1)/etc/uci-defaults/ + $(BBFDM_INSTALL_MS_PLUGIN) -v ${VENDOR_PREFIX} ./files/openssh_backend/ssh_mfa_override.json $(1) $(PKG_NAME) +endif +endif + ifeq ($(CONFIG_PACKAGE_fail2ban),y) $(INSTALL_DIR) $(1)/etc/fail2ban/jail.d $(INSTALL_DIR) $(1)/etc/fail2ban/filter.d/ diff --git a/sshmngr/files/common/usr/libexec/rpcd/sshmngr b/sshmngr/files/common/usr/libexec/rpcd/sshmngr index b84845bf6..f8dd69e85 100755 --- a/sshmngr/files/common/usr/libexec/rpcd/sshmngr +++ b/sshmngr/files/common/usr/libexec/rpcd/sshmngr @@ -3,6 +3,8 @@ . /usr/share/libubox/jshn.sh . /lib/sshmngr/backend.sh +MFA_SECRET_FILE="/etc/security/mfa_secret" + add_server_name() { local server_sec="${1}" @@ -44,7 +46,7 @@ get_pid() case "$1" in list) - echo '{ "dump" : {"server_name":"string"}, "kill_session" : {"session_pid":"string","server_name":"string"}, "list_keys" : {}, "add_pubkey" : {"current_key":"string","new_key":"string"}, "remove_pubkey" : {"key":"string"} }' + echo '{ "dump" : {"server_name":"string"}, "kill_session" : {"session_pid":"string","server_name":"string"}, "list_keys" : {}, "add_pubkey" : {"current_key":"string","new_key":"string"}, "remove_pubkey" : {"key":"string"}, "get_mfa_key" : {}, "get_mfa_recovery" : {} }' ;; call) case "$2" in @@ -221,6 +223,31 @@ case "$1" in fi echo '{}' ;; + + get_mfa_key) + mfa_key="" + if [ -f "${MFA_SECRET_FILE}" ]; then + mfa_key="$(head -n 1 "$MFA_SECRET_FILE" 2>/dev/null)" + fi + + json_init + json_add_string "mfa_key" "${mfa_key}" + json_dump + ;; + + get_mfa_recovery) + mfa_recovery_codes="" + + if [ -f "${MFA_SECRET_FILE}" ]; then + mfa_recovery_codes="$(tail -n 3 "$MFA_SECRET_FILE" 2>/dev/null | tr '\n' ',')" + # remove trailing comma + mfa_recovery_codes="${mfa_recovery_codes%,}" + fi + + json_init + json_add_string "recovery_codes" "${mfa_recovery_codes}" + json_dump + ;; esac ;; esac diff --git a/sshmngr/files/openssh_backend/etc/uci-defaults/91-set-sshd-pam b/sshmngr/files/openssh_backend/etc/uci-defaults/91-set-sshd-pam new file mode 100644 index 000000000..6daeb5f12 --- /dev/null +++ b/sshmngr/files/openssh_backend/etc/uci-defaults/91-set-sshd-pam @@ -0,0 +1,36 @@ +#!/bin/sh + +# create or over-write our desired file +# /etc/pam.d/sshd +cat << 'EOF' > /etc/pam.d/sshd +auth required pam_env.so +auth include sshd-auth +account required pam_nologin.so +account include sshd-account +session include common-session +session required pam_limits.so +password include sshd-password +EOF + +# /etc/pam.d/sshd-auth +cat << 'EOF' > /etc/pam.d/sshd-auth +auth [success=1 default=ignore] pam_unix.so nullok_secure +auth requisite pam_deny.so +auth required pam_permit.so +EOF + +# /etc/pam.d/sshd-password +cat << 'EOF' > /etc/pam.d/sshd-password +password [success=1 default=ignore] pam_unix.so sha512 +password requisite pam_deny.so +password required pam_permit.so +EOF + +# /etc/pam.d/sshd-account +cat << 'EOF' > /etc/pam.d/sshd-account +account [success=1 new_authtok_reqd=done default=ignore] pam_unix.so +account requisite pam_deny.so +account required pam_permit.so +EOF + +exit 0 diff --git a/sshmngr/files/openssh_backend/etc/uci-defaults/92-set-ssh-mfa b/sshmngr/files/openssh_backend/etc/uci-defaults/92-set-ssh-mfa new file mode 100644 index 000000000..0e76a8f24 --- /dev/null +++ b/sshmngr/files/openssh_backend/etc/uci-defaults/92-set-ssh-mfa @@ -0,0 +1,10 @@ +#!/bin/sh + +if [ -f /etc/config/sshd ]; then + # make sure not to change already existing setting + if ! uci -q get sshd.@sshd[0].UseMFA 2>&1 > /dev/null; then + uci -q set sshd.@sshd[0].UseMFA=1 + fi +fi + +exit 0 diff --git a/sshmngr/files/openssh_backend/lib/sshmngr/pam_config.sh b/sshmngr/files/openssh_backend/lib/sshmngr/pam_config.sh new file mode 100755 index 000000000..0b0aa66c3 --- /dev/null +++ b/sshmngr/files/openssh_backend/lib/sshmngr/pam_config.sh @@ -0,0 +1,228 @@ +#!/bin/sh + +# List of required .so files +REQUIRED_MODULES=" +/usr/lib/security/pam_faildelay.so +/usr/lib/security/pam_faillock.so +/usr/lib/security/pam_unix.so +/usr/lib/security/pam_deny.so +/usr/lib/security/pam_permit.so +/usr/lib/security/pam_passwdqc.so +" + +MFA_APP="google-authenticator" +MFA_LIB="/usr/lib/security/pam_google_authenticator.so" +MFA_DIR="/etc/security" +MFA_SECRET_FILE="${MFA_DIR}/mfa_secret" +MFA_QR_FILE="${MFA_DIR}/mfa_qr" + +log() { + echo "$*" | logger -t sshd.init -p info +} + +log_err() { + echo "$*" | logger -t sshd.init -p err +} + +check_required_modules() { + for mod in $REQUIRED_MODULES; do + if [ ! -f "$mod" ]; then + log_err "ERROR: Cannot setup security policy, missing PAM module: $mod" + return 1 + fi + done + + return 0 +} + +write_line() { + local filepath="$1" + local line="$2" + + echo "$line" >> "$filepath" +} + +compare_and_replace() { + local src dst + src="$1" + dst="$2" + + if [ ! -f "$dst" ] || ! cmp -s "$src" "$dst"; then + cp "$src" "$dst" + log "Updated $dst" + fi +} + +setup_mfa() { + mkdir -p "$MFA_DIR" + + if [ -f "${MFA_SECRET_FILE}" ] && [ -f "${MFA_QR_FILE}" ]; then + log "Not updating MFA files, they already exist" + return + fi + + touch "$MFA_SECRET_FILE" + touch "$MFA_QR_FILE" + chmod 600 "$MFA_SECRET_FILE" + chmod 600 "$MFA_QR_FILE" + + local cmd="$MFA_APP -f -s $MFA_SECRET_FILE -C -t -d -r 3 -R 30 -w 3 -Q UTF8 -e 3" + + log "Running MFA app: $cmd" + # if the google-authenticator senses that the output is not going to a terminal + # then it does not print out the QR, so simple redirection does not work + # we have to run the command inside a pseudo-terminal using script command + # and then redirect that output to a file + cmd="script -q -c \"$cmd\" $MFA_QR_FILE > /dev/null" + eval "$cmd" +} + +update_auth() { + # Write /etc/pam.d/sshd-auth + local tmp_file pam_file + tmp_file="/tmp/sshd-auth" + pam_file="/etc/pam.d/sshd-auth" + + local auth_enabled="${1}" + local enabled="${2}" + local mfa_enabled="${3}" + + local faildelay="$(uci -q get users.authentication_policy.fail_delay || echo 3)" + local faillock_lockout_time="$(uci -q get users.authentication_policy.faillock_lockout_time || echo 300)" + local faillock_attempts="$(uci -q get users.authentication_policy.faillock_attempts || echo 6)" + + # Convert seconds to microseconds for pam_faildelay + local faildelay_usec=$((faildelay * 1000000)) + + rm -f "$tmp_file" + touch "$tmp_file" + + if [ "${auth_enabled}" -eq 1 ] && [ "${enabled}" -eq 1 ]; then + write_line "$tmp_file" "auth optional pam_faildelay.so delay=$faildelay_usec" + write_line "$tmp_file" "auth required pam_faillock.so preauth deny=$faillock_attempts even_deny_root unlock_time=$faillock_lockout_time" + fi + + write_line "$tmp_file" "auth [success=1 default=bad] pam_unix.so nullok_secure" + write_line "$tmp_file" "auth [default=die] pam_faillock.so authfail audit deny=$faillock_attempts even_deny_root unlock_time=$faillock_lockout_time" + + if [ "$mfa_enabled" -eq 1 ]; then + write_line "$tmp_file" "auth [success=1 default=bad] pam_google_authenticator.so secret=$MFA_SECRET_FILE" + write_line "$tmp_file" "auth [default=die] pam_faillock.so authfail audit deny=$faillock_attempts even_deny_root unlock_time=$faillock_lockout_time" + fi + + write_line "$tmp_file" "auth sufficient pam_faillock.so authsucc audit deny=$faillock_attempts even_deny_root unlock_time=$faillock_lockout_time" + + write_line "$tmp_file" "auth requisite pam_deny.so" + + compare_and_replace "$tmp_file" "$pam_file" +} + +build_pam_passwdqc_line() { + local base="password requisite pam_passwdqc.so" + local k v line + + for line in $(uci show users.passwdqc 2>/dev/null); do + case "$line" in + users.passwdqc=*) continue ;; + users.passwdqc.enabled=*) continue ;; + esac + + k="${line%%=*}" + k="${k#users.passwdqc.}" + v="${line#*=}" + v="${v%\'}" + v="${v#\'}" + base="$base $k=$v" + done + + base="$base match=0" + + echo "$base" +} + +# NOTE: +# for some reason setting min 8 makes passwdqc accept minimum 12 letter password with this configuration +# if we set it to 12 then we need atleast 16 characters and so on +# passphrase = 0 means no space separated words +# passphrase = N means the number of words required for a passphrase or 0 to disable the support for user-chosen passphrases. +# rest can be figured out from passwdqc man page +update_password() { + local tmp_file pam_file enabled line + tmp_file="/tmp/sshd-password" + pam_file="/etc/pam.d/sshd-password" + + local auth_enabled="${1}" + + rm -f "$tmp_file" + touch "$tmp_file" + + # Check if section exists + if uci -q get users.passwdqc >/dev/null 2>&1; then + # if enabled is not present it is assumed to be 0 + enabled=$(uci -q get users.passwdqc.enabled || echo "0") + if [ "${auth_enabled}" -eq 1 ] && [ "${enabled}" -eq 1 ]; then + line="$(build_pam_passwdqc_line)" + write_line "$tmp_file" "$line" + fi + fi + + write_line "$tmp_file" "password [success=1 default=ignore] pam_unix.so sha512" + write_line "$tmp_file" "" + write_line "$tmp_file" "password requisite pam_deny.so" + write_line "$tmp_file" "password required pam_permit.so" + + compare_and_replace "$tmp_file" "$pam_file" +} + +update_account() { + # Write /etc/pam.d/sshd-account + local tmp_file pam_file + tmp_file="/tmp/sshd-account" + pam_file="/etc/pam.d/sshd-account" + + local auth_enabled="${1}" + local enabled="${2}" + + rm -f "$tmp_file" + touch "$tmp_file" + + if [ "${auth_enabled}" -eq 1 ] && [ "${enabled}" -eq 1 ]; then + write_line "$tmp_file" "account required pam_faillock.so" + fi + + write_line "$tmp_file" "account [success=1 new_authtok_reqd=done default=ignore] pam_unix.so" + write_line "$tmp_file" "" + write_line "$tmp_file" "account requisite pam_deny.so" + write_line "$tmp_file" "account required pam_permit.so" + + compare_and_replace "$tmp_file" "$pam_file" +} + +handle_security_policy() { + local auth_enabled enabled + local use_mfa mfa_enabled + + # Read UCI values + auth_enabled="$(uci -q get users.users.auth_policy_enable || echo 0)" + enabled="$(uci -q get users.authentication_policy.enabled || echo 0)" + use_mfa="$(uci -q get sshd.@sshd[0].UseMFA || echo 0)" + + # if any .so files are missing, then we cannot setup security + if ! check_required_modules; then + return + fi + + # Detect and enable MFA only if requested and module exists + if which "$MFA_APP" > /dev/null 2>&1 && [ "$use_mfa" = "1" ] && [ -f "$MFA_LIB" ]; then + mfa_enabled=1 + setup_mfa + else + rm -rf "${MFA_QR_FILE}" + rm -rf "${MFA_SECRET_FILE}" + mfa_enabled=0 + fi + + update_auth "${auth_enabled}" "${enabled}" "${mfa_enabled}" + update_account "${auth_enabled}" "${enabled}" + update_password "${auth_enabled}" +} diff --git a/sshmngr/files/openssh_backend/ssh_mfa_override.json b/sshmngr/files/openssh_backend/ssh_mfa_override.json new file mode 100644 index 000000000..b9415fc36 --- /dev/null +++ b/sshmngr/files/openssh_backend/ssh_mfa_override.json @@ -0,0 +1,68 @@ +{ + "json_plugin_version": 2, + "Device.SSH.Server.{i}.": { + "type": "object", + "protocols": [ + "cwmp", + "usp" + ], + "access": false, + "array": false, + "{BBF_VENDOR_PREFIX}UseMFA": { + "type": "boolean", + "read": true, + "write": true, + "protocols": [ + "cwmp", + "usp" + ], + "mapping": [ + { + "data": "@Parent", + "type": "uci_sec", + "key": "UseMFA" + } + ] + }, + "{BBF_VENDOR_PREFIX}MFA_Key": { + "type": "string", + "read": true, + "write": false, + "protocols": [ + "cwmp", + "usp" + ], + "mapping": [ + { + "type": "ubus", + "ubus": { + "object": "sshmngr", + "method": "get_mfa_key", + "args": {}, + "key": "mfa_key" + } + } + ] + }, + "{BBF_VENDOR_PREFIX}MFA_Recovery_Codes": { + "type": "string", + "read": true, + "write": false, + "protocols": [ + "cwmp", + "usp" + ], + "mapping": [ + { + "type": "ubus", + "ubus": { + "object": "sshmngr", + "method": "get_mfa_recovery", + "args": {}, + "key": "recovery_codes" + } + } + ] + } + } +}