In case it helps someone:
I also tried a third-party plugin called openvpn-auth-radius ref:
https://github.com/brainly/openvpn-auth-radius
On Debian, this is in current packages: apt-get install openvpn-auth-radius
# path to resulting plugin (on current Debian): /usr/lib/openvpn/radiusplugin.so
# path to base config: /usr/share/doc/openvpn-auth-radius/examples/radiusplugin.cnf
# for convenience, radiusplugin.cnf may be copied to the OpenVPN configuration directory and configured
# the plugin reference in OpenVPN server.conf becomes:
plugin /usr/lib/openvpn/radiusplugin.so /etc/openvpn/server/radiusplugin.cnf
When tested, authentication might have been faster, but openvpn-auth-radius doesn't appear to handle either static-challenge or dynamic-challenge, so might be viable where no second factor is required. It does not appear to require PAM though, so setup is somewhat simpler. It seems better to use the OpenVPN provided openvpn-plugin-auth-pam.so unless avoiding PAM.
I also tested OpenVPN Management Interface, which reportedly handles dynamic-challenge, but it seems this is designed to be used with custom authentication scripts, which doesn't work for my requirements, so I didn't pursue that. Docs for Management Interface are kind of terse, but the section on dynamic-challenge were practically non-existent.
My requirements have evolved such that TOTP PINs of different lengths must be supported. Under the circumstances, my RADIUS requires separator characters between password and TOTP. So, a new variant of openvpn-plugin-auth-pam.so has been generated to support static-challenge with separator [source: openvpn-2.5.6/src/plugins/auth-pam/auth-pam.c, split_scrv1_password function]:
/*
* Split and decode up->password in the form SCRV1:base64_pass:base64_response
* into pass and response and save in up->password and up->response.
* If the password is not in the expected format, input is not changed.
*/
static void
split_scrv1_password(struct user_pass *up)
{
const int skip = strlen("SCRV1:");
if (strncmp(up->password, "SCRV1:", skip) != 0)
{
return;
}
char *tmp = strdup(up->password);
if (!tmp)
{
plugin_log(PLOG_ERR, MODULE, "out of memory parsing static challenge password");
goto out;
}
char *pass = tmp + skip;
char *resp = strchr(pass, ':');
if (!resp) /* string not in SCRV1:xx:yy format */
{
goto out;
}
*resp++ = '\0';
int n = plugin_base64_decode(pass, up->password, sizeof(up->password)-1);
if (n >= 0)
{
up->password[n] = '\0';
n = plugin_base64_decode(resp, up->response, sizeof(up->response)-1);
if (n >= 0)
{
up->response[n] = '\0';
if (DEBUG(up->verb))
{
plugin_log(PLOG_NOTE, MODULE, "BACKGROUND: parsed static challenge password");
}
// modification: concatenate decoded pass, separator, and response, then push result to up
char separator[7];
strcpy(separator,"||||||");
char construct[128];
strcpy(construct,up->password);
strcat(construct,separator);
strcat(construct,up->response);
strcpy(up->password, construct);
// end modification
goto out;
}
}
/* decode error: reinstate original value of up->password and return */
plugin_secure_memzero(up->password, sizeof(up->password));
plugin_secure_memzero(up->response, sizeof(up->response));
strcpy(up->password, tmp); /* tmp is guaranteed to fit in up->password */
plugin_log(PLOG_ERR, MODULE, "base64 decode error while parsing static challenge password");
out:
if (tmp)
{
plugin_secure_memzero(tmp, strlen(tmp));
free(tmp);
}
}
It is still a bad idea to fork code for this, but this code is close to supportable for feature-request. If the option to support concatenation for static-challenge, option to use a separator, and the value of the separator may be read in from an associated config file or passed on the server-config line where the plugin is called, the difficulty of password+TOTP via separate fields might be resolved. [I get that it's easier said than done.]
Alternately, supporting dynamic-challenge via openvpn-plugin-auth-pam.so would resolve the difficulty, but I guess that is a more difficult problem.