Securing SSH access to embedded Linux devices has become increasingly important. While password authentication provides a basic level of security, Time-based One-Time Passwords (TOTP) offer an additional layer of protection against unauthorized access. This tutorial demonstrates how to integrate TOTP-based multi-factor authentication into Yocto Project images using PAM and google-authenticator-libpam.
In the context of a connected device, there is a special twist which you can use: generating the TOTP verification codes directly on the device. Why that? By displaying them on the display for example, you can make sure that whoever logs into the device also has either direct physical access, or at least somebody with access to the device is actively helping in the access. Combining that with pre-conditions, such a mechanism can be used to allow remote logins only if the device in a safe state, using standardized mechanisms, avoiding error-prone custom additions to the authentification process.
Prerequisites
This tutorial assumes you have:
- A configured Yocto Project build environment
- Basic familiarity with PAM authentication concepts
- The
meta-openembeddedrepository added to your build (specificallymeta-oeandmeta-pythonlayers)
If you need help setting up your Yocto environment, refer to the custom images tutorial.
The Problem
Standard password authentication for SSH access presents several vulnerabilities:
- Passwords can be compromised through various attack vectors
- Shared passwords across multiple devices create cascade security risks
- No protection against credential theft or brute-force attacks
Additionally, a password is time- and situation independent, making it possible to log in regardless of the current state of the device, and without physical access.
Multi-factor authentication addresses these concerns by requiring a time-based verification code in addition to the password. Leaking a verification code is under most circumstances unproblematic as it is valid for a short period of time only (like 30 seconds), and obtaining a new one requires accees to the specific generator instance.
The Solution
The implementation combines three components:
- google-authenticator-libpam: A PAM module that validates TOTP codes
- mintotp: A minimal Python library for generating TOTP codes without external dependencies
- Automated setup service: A systemd service that initializes MFA configuration on first boot
Implementation
PAM Configuration
The Linux Pluggable Authentication Modules (PAM) system provides a flexible mechanism for authenticating users. The /etc/pam.d/common-auth file defines authentication settings common to all services—this is where we integrate the google-authenticator module.
Create a bbappend for libpam:
meta-yourproject/recipes-extended/pam/libpam_%.bbappend
FILESEXTRAPATHS:prepend := "${THISDIR}/${PN}:"
do_install:append() {
# Add Google Authenticator PAM module to common-auth
echo "auth required pam_google_authenticator.so nullok" >> ${D}${sysconfdir}/pam.d/common-auth
}
RDEPENDS:${PN}-runtime += "pam-google-authenticator"
This configuration adds the google-authenticator module to the PAM authentication stack. The nullok option is significant: it allows users to log in without an OTP if they haven’t configured MFA yet. Without this option, authentication would fail for any user without a .google_authenticator configuration file.
The PAM stack processes modules in order. Since we’re using required, authentication must pass both the standard password check and the TOTP verification. During the rollout phase (before users have configured MFA), nullok prevents lockouts.
Python TOTP Library
The mintotp library provides TOTP generation capabilities without requiring heavy dependencies. This makes it ideal for embedded systems where minimizing footprint matters.
meta-yourproject/recipes-devtools/python/python3-mintotp_0.3.0.bb
SUMMARY = "Minimal TOTP Generator"
DESCRIPTION = "MinTOTP is a minimal TOTP generator written in Python with no external dependencies"
HOMEPAGE = "https://github.com/susam/mintotp"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://PKG-INFO;md5=5f3d44a5b9e94a2936dc849bcaed5a4a"
SRC_URI[sha256sum] = "d0f4db5edb38a7481120176a526e8c29539b9e80581dd2dcc1811557d77cfad5"
inherit pypi python_setuptools_build_meta
# Requires base64 and hmac from stdlib
RDEPENDS:${PN} += "python3-netclient"
BBCLASSEXTEND = "native nativesdk"
Using mintotp in Python is straightforward:
import mintotp
# Your Base32-encoded secret (from Google Authenticator setup)
secret = "JBSWY3DPEHPK3PXP" # Example secret
# Generate current TOTP code (valid for 30 seconds)
code = mintotp.totp(secret)
print(f"Current TOTP code: {code}")
# To verify a code, simply compare it with the generated value
user_input = "123456"
if user_input == code:
print("Authentication successful")
else:
print("Invalid code")
This demonstrates the core functionality—given a shared secret, mintotp generates the same six-digit code that your authenticator app would display.
Automated Setup Service
Rather than requiring manual configuration on each device, we automate MFA initialization with a systemd service that runs on first boot.
meta-yourproject/recipes-core/mender-mfa-setup/mender-mfa-setup_1.0.bb
SUMMARY = "Mender MFA Setup Service"
DESCRIPTION = "Systemd service to automatically configure Google Authenticator TOTP for the mender user on first boot"
LICENSE = "MIT"
LIC_FILES_CHKSUM = "file://${COMMON_LICENSE_DIR}/MIT;md5=0835ade698e0bcf8506ecda2f7b4f302"
inherit systemd
SRC_URI = " \
file://mender-mfa-setup.sh \
file://mender-mfa-setup.service \
"
S = "${WORKDIR}"
SYSTEMD_AUTO_ENABLE = "enable"
SYSTEMD_SERVICE:${PN} = "mender-mfa-setup.service"
# Runtime dependencies
RDEPENDS:${PN} += " \
pam-google-authenticator \
python3-mintotp \
"
do_install() {
# Install the setup script
install -d ${D}${bindir}
install -m 0755 ${WORKDIR}/mender-mfa-setup.sh ${D}${bindir}/mender-mfa-setup.sh
# Install the systemd service
install -d ${D}${systemd_system_unitdir}
install -m 0644 ${WORKDIR}/mender-mfa-setup.service ${D}${systemd_system_unitdir}/mender-mfa-setup.service
}
FILES:${PN} += " \
${systemd_system_unitdir}/mender-mfa-setup.service \
${bindir}/mender-mfa-setup.sh \
"
The systemd service configuration:
meta-yourproject/recipes-core/mender-mfa-setup/files/mender-mfa-setup.service
[Unit]
Description=Mender MFA Setup Service
Documentation=https://github.com/google/google-authenticator-libpam
After=basic.target
Before=getty@.service
ConditionPathExists=!/home/mender/.google_authenticator
[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/mender-mfa-setup.sh
StandardOutput=journal+console
StandardError=journal+console
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
The ConditionPathExists=!/home/mender/.google_authenticator line is the key to single-execution behavior—the service only runs if the configuration file doesn’t exist yet.
The setup script itself:
meta-yourproject/recipes-core/mender-mfa-setup/files/mender-mfa-setup.sh
#!/bin/sh
# SPDX-License-Identifier: MIT
# Mender MFA Setup Service
# Sets up Google Authenticator TOTP for the 'mender' user
set -e
MENDER_HOME="/home/mender"
GA_CONFIG="${MENDER_HOME}/.google_authenticator"
# Check if configuration already exists
if [ -f "${GA_CONFIG}" ]; then
echo "MFA already configured, skipping setup."
exit 0
fi
echo ""
echo "=========================================="
echo " Mender MFA Setup"
echo "=========================================="
echo ""
# Generate secure random base32 secret (exactly 32 characters, A-Z and 2-7)
SECRET=$(tr -dc 'A-Z2-7' </dev/urandom 2>/dev/null | dd bs=32 count=1 2>/dev/null)
# Generate 5 emergency scratch codes (8-digit random numbers)
SCRATCH1=$(tr -dc '0-9' </dev/urandom 2>/dev/null | dd bs=8 count=1 2>/dev/null)
SCRATCH2=$(tr -dc '0-9' </dev/urandom 2>/dev/null | dd bs=8 count=1 2>/dev/null)
SCRATCH3=$(tr -dc '0-9' </dev/urandom 2>/dev/null | dd bs=8 count=1 2>/dev/null)
SCRATCH4=$(tr -dc '0-9' </dev/urandom 2>/dev/null | dd bs=8 count=1 2>/dev/null)
SCRATCH5=$(tr -dc '0-9' </dev/urandom 2>/dev/null | dd bs=8 count=1 2>/dev/null)
# Create .google_authenticator file with proper format
cat > "${GA_CONFIG}" <<EOF
${SECRET}
" RATE_LIMIT 3 30
" TOTP_AUTH
" DISALLOW_REUSE
" WINDOW_SIZE 3
${SCRATCH1}
${SCRATCH2}
${SCRATCH3}
${SCRATCH4}
${SCRATCH5}
EOF
# Set proper ownership and permissions
chown mender:mender "${GA_CONFIG}"
chmod 0600 "${GA_CONFIG}"
echo "Configuration Details:"
echo "----------------------------------------"
echo ""
echo " TOTP Secret: ${SECRET}"
echo ""
echo " Emergency Scratch Codes:"
echo " ${SCRATCH1} ${SCRATCH2} ${SCRATCH3}"
echo " ${SCRATCH4} ${SCRATCH5}"
echo ""
# Generate current TOTP code for verification
if command -v mintotp >/dev/null 2>&1; then
CURRENT_TOTP=$(echo "${SECRET}" | mintotp 2>/dev/null)
echo " Current TOTP: ${CURRENT_TOTP}"
echo " (changes every 30 seconds)"
echo ""
fi
echo "----------------------------------------"
echo ""
echo "Setup complete! Configuration saved to:"
echo " ${GA_CONFIG}"
echo ""
echo "Add to your authenticator app:"
echo " Account: mender@$(hostname)"
echo " Secret: ${SECRET}"
echo ""
echo "=========================================="
echo ""
exit 0
This script generates a cryptographically random Base32 secret and five emergency scratch codes. The .google_authenticator file format is specific—the first line contains the secret, followed by configuration options (prefixed with "), and finally the scratch codes.
The WINDOW_SIZE 3 setting allows some clock drift between the device and the authenticator app (tolerating ±90 seconds of skew). The RATE_LIMIT 3 30 prevents brute-force attacks by allowing only 3 login attempts per 30 seconds.
Testing the Implementation
Add the MFA packages to your image recipe:
meta-yourproject/recipes-core/images/my-image.bb
require recipes-core/images/core-image-minimal.bb
IMAGE_INSTALL += " \
openssh \
mender-mfa-setup \
pam-google-authenticator \
python3-mintotp \
"
Build your image:
bitbake my-image
On first boot, the setup service executes and displays output via the console showing:
- The generated TOTP secret
- Five emergency scratch codes
- The current TOTP code (for immediate verification)
To configure your authenticator app (Google Authenticator, Authy, etc.):
- Select “Add account” or “Scan QR code”
- Choose “Enter key manually”
- Input the displayed secret
- Set time-based, 6-digit code
For subsequent SSH logins, you’ll be prompted for:
- Your password (standard authentication)
- Verification code (six-digit TOTP from your authenticator app)
If you lose access to your authenticator app, use one of the emergency scratch codes instead of the TOTP. Each scratch code is single-use.
Security Considerations for Production
This implementation is suitable for development and testing but requires modifications for production deployments.
Console Output Exposure
The setup script displays the TOTP secret on the console during first boot. This is acceptable for testing but presents a security risk in production:
- Console output may be logged to persistent storage
- Serial consoles might be accessible to unauthorized personnel
- Boot logs could be extracted from device storage
For production systems, consider:
- Generating secrets in a secure environment before deployment
- Using QR code generation and displaying it briefly on device screens (if available)
- Provisioning secrets through secure channels during manufacturing
- Storing secrets in hardware security modules (TPM, secure elements)
The nullok Option
The nullok PAM option creates a grace period where users without MFA configuration can still authenticate. This is useful during phased rollout but should be removed once all devices have been configured:
# Production configuration after MFA rollout
echo "auth required pam_google_authenticator.so" >> ${D}${sysconfdir}/pam.d/common-auth
Without nullok, authentication will fail immediately for any account lacking a .google_authenticator file, enforcing MFA universally.
Time Synchronization
TOTP depends on accurate time. Devices must maintain clock synchronization within the window size tolerance (default ±90 seconds). For embedded systems:
- Ensure NTP client is installed and configured
- Consider using
systemd-timesyncdorchrony - Monitor clock drift, especially on devices without real-time clock hardware
- Increase
WINDOW_SIZEcautiously if dealing with unreliable time sources (though this weakens security)
Production Recommendations
For production deployments:
- Secret Management: Store secrets in secure, tamper-resistant storage
- Audit Logging: Log all MFA authentication attempts for security monitoring
- Secret Rotation: Implement policies for periodic secret rotation
- Recovery Mechanism: Design secure recovery procedures for lost authenticators
- Backup Authentication: Consider SMS backup codes or hardware tokens as alternatives
- Rate Limiting: The built-in rate limiting (3 attempts per 30 seconds) helps prevent brute-force attacks but should be monitored
Troubleshooting
Authentication Fails Despite Correct Code
Time Synchronization Issues: TOTP codes are time-based. If your device clock differs from the authenticator app by more than 90 seconds (with default WINDOW_SIZE 3), codes will fail.
Check device time:
date
Verify NTP synchronization:
systemctl status systemd-timesyncd
# or
timedatectl status
Locked Out After Enabling MFA
Recovery Using Scratch Codes: If you’ve lost access to your authenticator app, use one of the five emergency scratch codes displayed during initial setup. These are single-use—once used, they’re removed from the configuration file.
Recovery Without Scratch Codes: If you don’t have scratch codes, you’ll need physical access to the device to remove or modify the .google_authenticator file:
# Via serial console or physical access
rm /home/mender/.google_authenticator
# Then the setup service will run again on next boot
PAM Configuration Errors
If authentication fails completely (even with correct password and TOTP):
Check PAM configuration syntax:
cat /etc/pam.d/common-auth
Review authentication logs:
journalctl -u ssh
# or
cat /var/log/auth.log
Common issues:
- The
pam_google_authenticator.somodule must be present in/lib/security/ - The
pam-google-authenticatorpackage must be installed - File permissions on
.google_authenticatormust be0600owned by the user
Conclusion
Integrating TOTP-based multi-factor authentication into Yocto Project images provides an additional security layer for SSH access. The combination of PAM, google-authenticator-libpam, and automated setup creates a deployable solution with minimal manual configuration.
The combination of MFA-PAM and OTP generator on the same device allows to enforce physical conditions, effectively tying connected device security to access control procedures.
The implementation demonstrated here prioritizes ease of development and testing. Production deployments require additional security hardening, particularly around secret generation, storage, and distribution. The flexibility of PAM and systemd allows these enhancements to be integrated as your security requirements evolve.