]> git.ipfire.org Git - thirdparty/openldap.git/commitdiff
ITS#9437 Add otp_2fa overlay
authorOndřej Kuzník <ondra@mistotebe.net>
Mon, 25 Jan 2021 14:22:23 +0000 (14:22 +0000)
committerQuanah Gibson-Mount <quanah@openldap.org>
Wed, 31 Mar 2021 14:57:56 +0000 (14:57 +0000)
13 files changed:
configure.ac
doc/man/man5/slapo-otp_2fa.5 [new file with mode: 0644]
servers/slapd/overlays/Makefile.in
servers/slapd/overlays/otp_2fa.c [new file with mode: 0644]
tests/data/otp_2fa/hotp.ldif [new file with mode: 0644]
tests/data/otp_2fa/test001-out.ldif [new file with mode: 0644]
tests/data/otp_2fa/totp.ldif [new file with mode: 0644]
tests/run.in
tests/scripts/all
tests/scripts/defines.sh
tests/scripts/test080-hotp [new file with mode: 0755]
tests/scripts/test081-totp [new file with mode: 0755]
tests/scripts/test081-totp.py [new file with mode: 0755]

index 67b63fe2fc8d2da28c6c2d0756db248948dfdb58..458e0209ba93b7548140e2797f7ee7f03698ee1c 100644 (file)
@@ -351,6 +351,7 @@ Overlays="accesslog \
        dynlist \
        homedir \
        memberof \
+       otp \
        ppolicy \
        proxycache \
        refint \
@@ -393,6 +394,8 @@ OL_ARG_ENABLE(homedir, [AS_HELP_STRING([--enable-homedir], [Home Directory Manag
        no, [no yes mod], ol_enable_overlays)
 OL_ARG_ENABLE(memberof, [AS_HELP_STRING([--enable-memberof], [Reverse Group Membership overlay])],
        no, [no yes mod], ol_enable_overlays)
+OL_ARG_ENABLE(otp, [AS_HELP_STRING([--enable-otp], [OTP 2-factor authentication overlay])],
+       no, [no yes mod], ol_enable_overlays)
 OL_ARG_ENABLE(ppolicy, [AS_HELP_STRING([--enable-ppolicy], [Password Policy overlay])],
        no, [no yes mod], ol_enable_overlays)
 OL_ARG_ENABLE(proxycache, [AS_HELP_STRING([--enable-proxycache], [Proxy Cache overlay])],
@@ -593,6 +596,7 @@ BUILD_DYNLIST=no
 BUILD_LASTMOD=no
 BUILD_HOMEDIR=no
 BUILD_MEMBEROF=no
+BUILD_OTP=no
 BUILD_PPOLICY=no
 BUILD_PROXYCACHE=no
 BUILD_REFINT=no
@@ -2867,6 +2871,22 @@ if test "$ol_enable_memberof" != no ; then
        AC_DEFINE_UNQUOTED(SLAPD_OVER_MEMBEROF,$MFLAG,[define for Reverse Group Membership overlay])
 fi
 
+if test "$ol_enable_otp" != no ; then
+       if test $ol_with_tls = no ; then
+               AC_MSG_ERROR([--enable-otp=$ol_enable_otp requires --with-tls])
+       fi
+
+       BUILD_OTP=$ol_enable_otp
+       if test "$ol_enable_otp" = mod ; then
+               MFLAG=SLAPD_MOD_DYNAMIC
+               SLAPD_DYNAMIC_OVERLAYS="$SLAPD_DYNAMIC_OVERLAYS otp_2fa.la"
+       else
+               MFLAG=SLAPD_MOD_STATIC
+               SLAPD_STATIC_OVERLAYS="$SLAPD_STATIC_OVERLAYS otp_2fa.o"
+       fi
+       AC_DEFINE_UNQUOTED(SLAPD_OVER_OTP,$MFLAG,[define for OTP 2-factor Authentication overlay])
+fi
+
 if test "$ol_enable_ppolicy" != no ; then
        BUILD_PPOLICY=$ol_enable_ppolicy
        if test "$ol_enable_ppolicy" = mod ; then
@@ -3142,6 +3162,7 @@ dnl overlays
   AC_SUBST(BUILD_LASTMOD)
   AC_SUBST(BUILD_HOMEDIR)
   AC_SUBST(BUILD_MEMBEROF)
+  AC_SUBST(BUILD_OTP)
   AC_SUBST(BUILD_PPOLICY)
   AC_SUBST(BUILD_PROXYCACHE)
   AC_SUBST(BUILD_REFINT)
diff --git a/doc/man/man5/slapo-otp_2fa.5 b/doc/man/man5/slapo-otp_2fa.5
new file mode 100644 (file)
index 0000000..205cd19
--- /dev/null
@@ -0,0 +1,134 @@
+.TH PW-TOTP 5 "2018/6/29" "SLAPO-OTP_2FA"
+.\" Copyright 2015-2021 The OpenLDAP Foundation.
+.\" Portions Copyright 2015 by Howard Chu, Symas Corp. All rights reserved.
+.\" Portions Copyright 2018 by Ondřej Kuzník, Symas Corp. All rights reserved.
+.\" Copying restrictions apply.  See COPYRIGHT/LICENSE.
+.SH NAME
+slapo-otp \- Two factor authentication module
+.SH SYNOPSIS
+.B moduleload
+.I otp_2fa.la
+.SH DESCRIPTION
+The
+.B otp_2fa
+module allows time-based one-time password, AKA "authenticator-style", and
+HMAC-based one-time password authentication to be used in applications that use
+LDAP for authentication. In most cases no changes to the applications are
+needed to switch to this type of authentication.
+
+With this module, users would use their password, followed with the one-time
+password in the password prompt to authenticate.
+
+The password needed for a user to authenticate is calculated based on a counter
+(current time in case of TOTP) and a key that is referenced in the user's LDAP
+entry. Since the password is based on the time or number of uses, it changes
+periodically. Once used, it cannot be used again so keyloggers and
+shoulder-surfers are thwarted. A mobile phone application, such as the Google
+Authenticator or YubiKey (a
+.BR prover ),
+can be used to calculate the user's current one-time password, which is
+expressed as a (usually six-digit) number.
+
+Alternatively, the value can be calculated by some other application with
+access to the user's key and delivered to the user through SMS or some other
+channel. When prompted to authenticate, the user merely appends the code
+provided by the prover at the end of their password when authenticating.
+
+This implementation complies with
+.B RFC 4226 HOTP HMAC-Based One Time Passwords
+and
+.B RFC 6238 TOTP Time-based One Time Passwords
+and includes support for the SHA-1, SHA-256, and SHA-512 HMAC
+algorithms.
+
+The HMAC key used in the OTP computation is stored in the oathOTPToken entry referenced in
+the user's LDAP entry and the parameters are stored in the oathOTPParams LDAP
+entry referenced in the token.
+
+.SH CONFIGURATION
+Once the module is configured on the database, it will intercept LDAP simple
+binds for users whose LDAP entry has any of the
+.B oathOTPUser
+derived objectlasses attached to it. The attributes linking the user and the
+shared secret are:
+
+.RS
+.TP
+.B oathTOTPToken: <dn>
+Mandatory for
+.BR oathTOTPUser ,
+indicates that the named entry is designated to hold the time-based one-time
+password shared secret and the last password used.
+.TP
+.B oathHOTPToken: <dn>
+Mandatory for
+.BR oathHOTPUser ,
+indicates that the named entry is designated to hold the one-time password
+shared secret and the last password used.
+.TP
+.B oathTOTPParams: <dn>
+Mandatory for
+.BR oathTOTPToken ,
+indicates that the named entry is designated to hold the parameters to generate
+time-based one-time password shared secret: its length and algorithm to use as
+well as the length of each time step and the grace period.
+.TP
+.B oathHOTPParams: <dn>
+Mandatory for
+.BR oathHOTPToken ,
+indicates that the named entry is designated to hold the parameters to generate
+one-time password shared secret: its length and algorithm to use as well as the
+permitted number of passwords to skip.
+.RE
+
+The following parts of the OATH-LDAP schema are implemented.
+
+General attributes:
+
+.RS
+.TP
+.B oathSecret: <data>
+The shared secret is stored here as raw bytes.
+.TP
+.B oathOTPLength: <length>
+The password length, usually 6.
+.TP
+.B oathHMACAlgorithm: <OID>
+The OID of the hash algorithm to use as defined in RFC 8018.
+Supported algorithms include SHA1, SHA224, SHA256, SHA384 and SHA512.
+.RE
+
+The HOTP attributes:
+
+.RS
+.TP
+.B oathHOTPLookAhead: <number>
+The number of successive HOTP tokens that can be skipped.
+.TP
+.B oathHOTPCounter: <number>
+The order of the last HOTP token successfully redeemed by the user.
+.RE
+
+The TOTP attributes:
+
+.RS
+.TP
+.B oathTOTPTimeStepPeriod: <seconds>
+The length of the time-step period for TOTP calculation.
+.TP
+.B oathTOTPLastTimeStep: <number>
+The order of the last TOTP token successfully redeemed by the user.
+.TP
+.B oathTOTPGrace: <number>
+The number of time periods around the current time to try when checking the
+password provided by the user.
+.RE
+
+.SH "SEE ALSO"
+.BR slapd\-config (5).
+
+.SH ACKNOWLEDGEMENT
+This work was developed by Ondřej Kuzník and Howard Chu of Symas Corporation
+for inclusion in OpenLDAP Software.
+
+This work reuses the OATH-LDAP schema developed by Michael Ströder.
index 12ee746c88f374e3a7a8cdbe21b4f963b0ef44a5..d71f7c3d7a6f894a3579512fad14513b2f7fbefd 100644 (file)
@@ -24,6 +24,7 @@ SRCS = overlays.c \
        dynlist.c \
        homedir.c \
        memberof.c \
+       otp_2fa.c \
        pcache.c \
        collect.c \
        ppolicy.c \
@@ -95,6 +96,9 @@ homedir.la : homedir.lo
 memberof.la : memberof.lo
        $(LTLINK_MOD) -module -o $@ memberof.lo version.lo $(LINK_LIBS)
 
+otp_2fa.la : otp_2fa.lo
+       $(LTLINK_MOD) -module -o $@ otp_2fa.lo version.lo $(LINK_LIBS)
+
 pcache.la : pcache.lo
        $(LTLINK_MOD) -module -o $@ pcache.lo version.lo $(LINK_LIBS)
 
diff --git a/servers/slapd/overlays/otp_2fa.c b/servers/slapd/overlays/otp_2fa.c
new file mode 100644 (file)
index 0000000..e511780
--- /dev/null
@@ -0,0 +1,950 @@
+/* otp_2fa.c - OATH 2-factor authentication module */
+/* $OpenLDAP$ */
+/* This work is part of OpenLDAP Software <http://www.openldap.org/>.
+ *
+ * Copyright 2015-2021 The OpenLDAP Foundation.
+ * Portions Copyright 2015 by Howard Chu, Symas Corp.
+ * Portions Copyright 2016-2017 by Michael Ströder <michael@stroeder.com>
+ * Portions Copyright 2018 by Ondřej Kuzník, Symas Corp.
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted only as authorized by the OpenLDAP
+ * Public License.
+ *
+ * A copy of this license is available in the file LICENSE in the
+ * top-level directory of the distribution or, alternatively, at
+ * <http://www.OpenLDAP.org/license.html>.
+ */
+/* ACKNOWLEDGEMENTS:
+ * This work includes code from the lastbind overlay.
+ */
+
+#include <portable.h>
+
+#ifdef SLAPD_OVER_OTP
+
+#if HAVE_STDINT_H
+#include <stdint.h>
+#endif
+
+#include <lber.h>
+#include <lber_pvt.h>
+#include "lutil.h"
+#include <ac/stdlib.h>
+#include <ac/ctype.h>
+#include <ac/string.h>
+/* include socket.h to get sys/types.h and/or winsock2.h */
+#include <ac/socket.h>
+
+#if HAVE_OPENSSL
+#include <openssl/sha.h>
+#include <openssl/hmac.h>
+
+#define TOTP_SHA512_DIGEST_LENGTH SHA512_DIGEST_LENGTH
+#define TOTP_SHA1 EVP_sha1()
+#define TOTP_SHA224 EVP_sha224()
+#define TOTP_SHA256 EVP_sha256()
+#define TOTP_SHA384 EVP_sha384()
+#define TOTP_SHA512 EVP_sha512()
+#define TOTP_HMAC_CTX HMAC_CTX *
+
+#if OPENSSL_VERSION_NUMBER < 0x10100000L
+static HMAC_CTX *
+HMAC_CTX_new( void )
+{
+       HMAC_CTX *ctx = OPENSSL_malloc( sizeof(*ctx) );
+       if ( ctx != NULL ) {
+               HMAC_CTX_init( ctx );
+       }
+       return ctx;
+}
+
+static void
+HMAC_CTX_free( HMAC_CTX *ctx )
+{
+       if ( ctx != NULL ) {
+               HMAC_CTX_cleanup( ctx );
+               OPENSSL_free( ctx );
+       }
+}
+#endif /* OPENSSL_VERSION_NUMBER < 0x10100000L */
+
+#define HMAC_setup( ctx, key, len, hash ) \
+       ctx = HMAC_CTX_new(); \
+       HMAC_Init_ex( ctx, key, len, hash, 0 )
+#define HMAC_crunch( ctx, buf, len ) HMAC_Update( ctx, buf, len )
+#define HMAC_finish( ctx, dig, dlen ) \
+       HMAC_Final( ctx, dig, &dlen ); \
+       HMAC_CTX_free( ctx )
+
+#elif HAVE_GNUTLS
+#include <nettle/hmac.h>
+
+#define TOTP_SHA512_DIGEST_LENGTH SHA512_DIGEST_SIZE
+#define TOTP_SHA1 &nettle_sha1
+#define TOTP_SHA224 &nettle_sha224
+#define TOTP_SHA256 &nettle_sha256
+#define TOTP_SHA384 &nettle_sha384
+#define TOTP_SHA512 &nettle_sha512
+#define TOTP_HMAC_CTX struct hmac_sha512_ctx
+
+#define HMAC_setup( ctx, key, len, hash ) \
+       const struct nettle_hash *h = hash; \
+       hmac_set_key( &ctx.outer, &ctx.inner, &ctx.state, h, len, key )
+#define HMAC_crunch( ctx, buf, len ) hmac_update( &ctx.state, h, len, buf )
+#define HMAC_finish( ctx, dig, dlen ) \
+       hmac_digest( &ctx.outer, &ctx.inner, &ctx.state, h, h->digest_size, dig ); \
+       dlen = h->digest_size
+
+#else
+#error Unsupported crypto backend.
+#endif
+
+#include "slap.h"
+#include "slap-config.h"
+
+/* Schema from OATH-LDAP project by Michael Ströder */
+
+static struct {
+       char *name, *oid;
+} otp_oid[] = {
+       { "oath-ldap", "1.3.6.1.4.1.5427.1.389.4226" },
+       { "oath-ldap-at", "oath-ldap:4" },
+       { "oath-ldap-oc", "oath-ldap:6" },
+       { NULL }
+};
+
+AttributeDescription *ad_oathOTPToken;
+AttributeDescription *ad_oathSecret;
+AttributeDescription *ad_oathOTPLength;
+AttributeDescription *ad_oathHMACAlgorithm;
+
+AttributeDescription *ad_oathHOTPParams;
+AttributeDescription *ad_oathHOTPToken;
+AttributeDescription *ad_oathHOTPCounter;
+AttributeDescription *ad_oathHOTPLookahead;
+
+AttributeDescription *ad_oathTOTPTimeStepPeriod;
+AttributeDescription *ad_oathTOTPParams;
+AttributeDescription *ad_oathTOTPToken;
+AttributeDescription *ad_oathTOTPLastTimeStep;
+AttributeDescription *ad_oathTOTPTimeStepWindow;
+
+static struct otp_at {
+       char                                    *schema;
+       AttributeDescription    **adp;
+} otp_at[] = {
+       { "( oath-ldap-at:1 "
+               "NAME 'oathSecret' "
+               "DESC 'OATH-LDAP: Shared Secret (possibly encrypted with public key in oathEncKey)' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY octetStringMatch "
+               "SUBSTR octetStringSubstringsMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.40 )",
+               &ad_oathSecret },
+
+       { "( oath-ldap-at:2 "
+               "NAME 'oathTokenSerialNumber' "
+               "DESC 'OATH-LDAP: Proprietary hardware token serial number assigned by vendor' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY caseIgnoreMatch "
+               "SUBSTR caseIgnoreSubstringsMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{64})" },
+
+       { "( oath-ldap-at:3 "
+               "NAME 'oathTokenIdentifier' "
+               "DESC 'OATH-LDAP: Globally unique OATH token identifier' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY caseIgnoreMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )" },
+
+       { "( oath-ldap-at:4 "
+               "NAME 'oathParamsEntry' "
+               "DESC 'OATH-LDAP: DN pointing to OATH parameter/policy object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "SUP distinguishedName )" },
+       { "( oath-ldap-at:4.1 "
+               "NAME 'oathTOTPTimeStepPeriod' "
+               "DESC 'OATH-LDAP: Time window for TOTP (seconds)' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY integerMatch "
+               "ORDERING integerOrderingMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )",
+               &ad_oathTOTPTimeStepPeriod },
+
+       { "( oath-ldap-at:5 "
+               "NAME 'oathOTPLength' "
+               "DESC 'OATH-LDAP: Length of OTP (number of digits)' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY integerMatch "
+               "ORDERING integerOrderingMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )",
+               &ad_oathOTPLength },
+       { "( oath-ldap-at:5.1 "
+               "NAME 'oathHOTPParams' "
+               "DESC 'OATH-LDAP: DN pointing to HOTP parameter object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+        "SUP oathParamsEntry )",
+               &ad_oathHOTPParams },
+       { "( oath-ldap-at:5.2 "
+               "NAME 'oathTOTPParams' "
+               "DESC 'OATH-LDAP: DN pointing to TOTP parameter object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "SUP oathParamsEntry )",
+               &ad_oathTOTPParams },
+
+       { "( oath-ldap-at:6 "
+               "NAME 'oathHMACAlgorithm' "
+               "DESC 'OATH-LDAP: HMAC algorithm used for generating OTP values' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY objectIdentifierMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )",
+               &ad_oathHMACAlgorithm },
+
+       { "( oath-ldap-at:7 "
+               "NAME 'oathTimestamp' "
+               "DESC 'OATH-LDAP: Timestamp (not directly used).' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY generalizedTimeMatch "
+               "ORDERING generalizedTimeOrderingMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 )" },
+       { "( oath-ldap-at:7.1 "
+        "NAME 'oathLastFailure' "
+        "DESC 'OATH-LDAP: Timestamp of last failed OATH validation' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathTimestamp )" },
+       { "( oath-ldap-at:7.2 "
+        "NAME 'oathLastLogin' "
+        "DESC 'OATH-LDAP: Timestamp of last successful OATH validation' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathTimestamp )" },
+       { "( oath-ldap-at:7.3 "
+        "NAME 'oathSecretTime' "
+        "DESC 'OATH-LDAP: Timestamp of generation of oathSecret attribute.' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathTimestamp )" },
+
+       { "( oath-ldap-at:8 "
+               "NAME 'oathSecretMaxAge' "
+               "DESC 'OATH-LDAP: Time in seconds for which the shared secret (oathSecret) will be valid from oathSecretTime value.' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY integerMatch "
+               "ORDERING integerOrderingMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )" },
+
+       { "( oath-ldap-at:9 "
+               "NAME 'oathToken' "
+               "DESC 'OATH-LDAP: DN pointing to OATH token object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "SUP distinguishedName )" },
+       { "( oath-ldap-at:9.1 "
+        "NAME 'oathHOTPToken' "
+        "DESC 'OATH-LDAP: DN pointing to OATH/HOTP token object' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathToken )",
+               &ad_oathHOTPToken },
+       { "( oath-ldap-at:9.2 "
+               "NAME 'oathTOTPToken' "
+               "DESC 'OATH-LDAP: DN pointing to OATH/TOTP token object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "SUP oathToken )",
+               &ad_oathTOTPToken },
+
+       { "( oath-ldap-at:10 "
+               "NAME 'oathCounter' "
+               "DESC 'OATH-LDAP: Counter for OATH data (not directly used)' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "EQUALITY integerMatch "
+               "ORDERING integerOrderingMatch "
+               "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )" },
+       { "( oath-ldap-at:10.1 "
+        "NAME 'oathFailureCount' "
+        "DESC 'OATH-LDAP: OATH failure counter' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )" },
+       { "( oath-ldap-at:10.2 "
+        "NAME 'oathHOTPCounter' "
+              "DESC 'OATH-LDAP: Counter for HOTP' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )",
+               &ad_oathHOTPCounter },
+       { "( oath-ldap-at:10.3 "
+        "NAME 'oathHOTPLookAhead' "
+        "DESC 'OATH-LDAP: Look-ahead window for HOTP' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )",
+               &ad_oathHOTPLookahead },
+       { "( oath-ldap-at:10.5 "
+        "NAME 'oathThrottleLimit' "
+        "DESC 'OATH-LDAP: Failure throttle limit' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )" },
+       { "( oath-ldap-at:10.6 "
+               "NAME 'oathTOTPLastTimeStep' "
+               "DESC 'OATH-LDAP: Last time step seen for TOTP (time/period)' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "SINGLE-VALUE "
+               "SUP oathCounter )",
+               &ad_oathTOTPLastTimeStep },
+       { "( oath-ldap-at:10.7 "
+        "NAME 'oathMaxUsageCount' "
+        "DESC 'OATH-LDAP: Maximum number of times a token can be used' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )" },
+       { "( oath-ldap-at:10.8 "
+        "NAME 'oathTOTPTimeStepWindow' "
+        "DESC 'OATH-LDAP: Size of time step +/- tolerance window used for TOTP validation' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )",
+               &ad_oathTOTPTimeStepWindow },
+       { "( oath-ldap-at:10.9 "
+        "NAME 'oathTOTPTimeStepDrift' "
+        "DESC 'OATH-LDAP: Last observed time step shift seen for TOTP' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "SUP oathCounter )" },
+
+       { "( oath-ldap-at:11 "
+        "NAME 'oathSecretLength' "
+        "DESC 'OATH-LDAP: Length of plain-text shared secret (number of bytes)' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "EQUALITY integerMatch "
+        "ORDERING integerOrderingMatch "
+        "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )" },
+
+       { "( oath-ldap-at:12 "
+        "NAME 'oathEncKey' "
+        "DESC 'OATH-LDAP: public key to be used for encrypting new shared secrets' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "EQUALITY caseIgnoreMatch "
+        "SUBSTR caseIgnoreSubstringsMatch "
+        "SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )" },
+
+       { "( oath-ldap-at:13 "
+        "NAME 'oathResultCode' "
+        "DESC 'OATH-LDAP: LDAP resultCode to use in response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "EQUALITY integerMatch "
+        "ORDERING integerOrderingMatch "
+        "SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 )" },
+       { "( oath-ldap-at:13.1 "
+        "NAME 'oathSuccessResultCode' "
+        "DESC 'OATH-LDAP: success resultCode to use in bind/compare response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SUP oathResultCode )" },
+       { "( oath-ldap-at:13.2 "
+        "NAME 'oathFailureResultCode' "
+        "DESC 'OATH-LDAP: failure resultCode to use in bind/compare response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SUP oathResultCode )" },
+
+       { "( oath-ldap-at:14 "
+        "NAME 'oathTokenPIN' "
+        "DESC 'OATH-LDAP: Configuration PIN (possibly encrypted with oathEncKey)' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "EQUALITY caseIgnoreMatch "
+        "SUBSTR caseIgnoreSubstringsMatch "
+        "SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )" },
+
+       { "( oath-ldap-at:15 "
+        "NAME 'oathMessage' "
+        "DESC 'OATH-LDAP: success diagnosticMessage to use in bind/compare response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SINGLE-VALUE "
+        "EQUALITY caseIgnoreMatch "
+        "SUBSTR caseIgnoreSubstringsMatch "
+        "SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )" },
+       { "( oath-ldap-at:15.1 "
+        "NAME 'oathSuccessMessage' "
+        "DESC 'OATH-LDAP: success diagnosticMessage to use in bind/compare response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SUP oathMessage )" },
+       { "( oath-ldap-at:15.2 "
+        "NAME 'oathFailureMessage' "
+        "DESC 'OATH-LDAP: failure diagnosticMessage to use in bind/compare response' "
+        "X-ORIGIN 'OATH-LDAP' "
+        "SUP oathMessage )" },
+
+       { NULL }
+};
+
+ObjectClass *oc_oathOTPUser;
+ObjectClass *oc_oathHOTPToken;
+ObjectClass *oc_oathTOTPToken;
+ObjectClass *oc_oathHOTPParams;
+ObjectClass *oc_oathTOTPParams;
+
+static struct otp_oc {
+       char                    *schema;
+       ObjectClass             **ocp;
+} otp_oc[] = {
+       { "( oath-ldap-oc:1 "
+               "NAME 'oathUser' "
+               "DESC 'OATH-LDAP: User Object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "ABSTRACT )",
+               &oc_oathOTPUser },
+       { "( oath-ldap-oc:1.1 "
+               "NAME 'oathHOTPUser' "
+               "DESC 'OATH-LDAP: HOTP user object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathUser "
+               "MAY ( oathHOTPToken ) )" },
+       { "( oath-ldap-oc:1.2 "
+               "NAME 'oathTOTPUser' "
+               "DESC 'OATH-LDAP: TOTP user object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathUser "
+               "MUST ( oathTOTPToken ) )" },
+       { "( oath-ldap-oc:2 "
+               "NAME 'oathParams' "
+               "DESC 'OATH-LDAP: Parameter object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "ABSTRACT "
+               "MUST ( oathOTPLength $ oathHMACAlgorithm ) "
+               "MAY ( oathSecretMaxAge $ oathSecretLength $ "
+                       "oathMaxUsageCount $ oathThrottleLimit $ oathEncKey $ "
+                       "oathSuccessResultCode $ oathSuccessMessage $ "
+                       "oathFailureResultCode $ oathFailureMessage ) )" },
+       { "( oath-ldap-oc:2.1 "
+               "NAME 'oathHOTPParams' "
+               "DESC 'OATH-LDAP: HOTP parameter object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathParams "
+               "MUST ( oathHOTPLookAhead ) )",
+               &oc_oathHOTPParams },
+       { "( oath-ldap-oc:2.2 "
+               "NAME 'oathTOTPParams' "
+               "DESC 'OATH-LDAP: TOTP parameter object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathParams "
+               "MUST ( oathTOTPTimeStepPeriod ) "
+               "MAY ( oathTOTPTimeStepWindow $ oathTOTPTimeStepDrift ) )",
+               &oc_oathTOTPParams },
+       { "( oath-ldap-oc:3 "
+               "NAME 'oathToken' "
+               "DESC 'OATH-LDAP: User Object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "ABSTRACT "
+               "MAY ( oathSecret $ oathSecretTime $ "
+                       "oathLastLogin $ oathFailureCount $ oathLastFailure $ "
+                       "oathTokenSerialNumber $ oathTokenIdentifier $ oathTokenPIN ) )" },
+       { "( oath-ldap-oc:3.1 "
+               "NAME 'oathHOTPToken' "
+               "DESC 'OATH-LDAP: HOTP token object' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathToken "
+               "MAY ( oathHOTPParams $ oathHOTPCounter ) )",
+               &oc_oathHOTPToken },
+       { "( oath-ldap-oc:3.2 "
+               "NAME 'oathTOTPToken' "
+               "DESC 'OATH-LDAP: TOTP token' "
+               "X-ORIGIN 'OATH-LDAP' "
+               "AUXILIARY "
+               "SUP oathToken "
+               "MAY ( oathTOTPParams $ oathTOTPLastTimeStep ) )",
+               &oc_oathTOTPToken },
+       { NULL }
+};
+
+typedef struct myval {
+       ber_len_t mv_len;
+       void *mv_val;
+} myval;
+
+static void
+do_hmac( const void *hash, myval *key, myval *data, myval *out )
+{
+       TOTP_HMAC_CTX ctx;
+       unsigned int digestLen;
+
+       HMAC_setup( ctx, key->mv_val, key->mv_len, hash );
+       HMAC_crunch( ctx, data->mv_val, data->mv_len );
+       HMAC_finish( ctx, out->mv_val, digestLen );
+       out->mv_len = digestLen;
+}
+
+#define MAX_DIGITS 8
+static const int DIGITS_POWER[] = {
+       1, 10, 100, 1000, 10000, 100000, 1000000, 10000000, 100000000,
+};
+
+static const void *
+otp_choose_mech( struct berval *oid )
+{
+       /* RFC 8018 OIDs */
+       const struct berval oid_hmacwithSHA1 = BER_BVC("1.2.840.113549.2.7");
+       const struct berval oid_hmacwithSHA224 = BER_BVC("1.2.840.113549.2.8");
+       const struct berval oid_hmacwithSHA256 = BER_BVC("1.2.840.113549.2.9");
+       const struct berval oid_hmacwithSHA384 = BER_BVC("1.2.840.113549.2.10");
+       const struct berval oid_hmacwithSHA512 = BER_BVC("1.2.840.113549.2.11");
+
+       if ( !ber_bvcmp( &oid_hmacwithSHA1, oid ) ) {
+               return TOTP_SHA1;
+       } else if ( !ber_bvcmp( &oid_hmacwithSHA224, oid ) ) {
+               return TOTP_SHA224;
+       } else if ( !ber_bvcmp( &oid_hmacwithSHA256, oid ) ) {
+               return TOTP_SHA256;
+       } else if ( !ber_bvcmp( &oid_hmacwithSHA384, oid ) ) {
+               return TOTP_SHA384;
+       } else if ( !ber_bvcmp( &oid_hmacwithSHA512, oid ) ) {
+               return TOTP_SHA512;
+       }
+
+       Debug( LDAP_DEBUG_TRACE, "otp_choose_mech: "
+                       "hmac OID %s unsupported\n",
+                       oid->bv_val );
+       return NULL;
+}
+
+static void
+generate(
+               struct berval *bv,
+               uint64_t tval,
+               int digits,
+               struct berval *out,
+               const void *mech )
+{
+       unsigned char digest[TOTP_SHA512_DIGEST_LENGTH];
+       myval digval;
+       myval key, data;
+       unsigned char msg[8];
+       int i, offset, res, otp;
+
+#if WORDS_BIGENDIAN
+       *(uint64_t *)msg = tval;
+#else
+       for ( i = 7; i >= 0; i-- ) {
+               msg[i] = tval & 0xff;
+               tval >>= 8;
+       }
+#endif
+
+       key.mv_len = bv->bv_len;
+       key.mv_val = bv->bv_val;
+
+       data.mv_val = msg;
+       data.mv_len = sizeof(msg);
+
+       digval.mv_val = digest;
+       digval.mv_len = sizeof(digest);
+       do_hmac( mech, &key, &data, &digval );
+
+       offset = digest[digval.mv_len - 1] & 0xf;
+       res = ( (digest[offset] & 0x7f) << 24 ) |
+                       ( ( digest[offset + 1] & 0xff ) << 16 ) |
+                       ( ( digest[offset + 2] & 0xff ) << 8 ) |
+                       ( digest[offset + 3] & 0xff );
+
+       otp = res % DIGITS_POWER[digits];
+       out->bv_len = snprintf( out->bv_val, out->bv_len, "%0*d", digits, otp );
+}
+
+static int
+otp_bind_response( Operation *op, SlapReply *rs )
+{
+       if ( rs->sr_err == LDAP_SUCCESS ) {
+               /* If the bind succeeded, return our result */
+               rs->sr_err = LDAP_INVALID_CREDENTIALS;
+       }
+       return SLAP_CB_CONTINUE;
+}
+
+static long
+otp_hotp( Operation *op, Entry *token )
+{
+       char outbuf[MAX_DIGITS + 1];
+       Entry *params = NULL;
+       Attribute *a;
+       BerValue *secret, client_otp;
+       const void *mech;
+       long last_step = -1, found = -1;
+       int i, otp_len, window;
+
+       a = attr_find( token->e_attrs, ad_oathSecret );
+       secret = &a->a_vals[0];
+
+       a = attr_find( token->e_attrs, ad_oathHOTPCounter );
+       if ( a && lutil_atol( &last_step, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_hotp: "
+                               "could not parse oathHOTPCounter value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+
+       a = attr_find( token->e_attrs, ad_oathHOTPParams );
+       if ( !a ||
+                       be_entry_get_rw( op, &a->a_nvals[0], oc_oathHOTPParams, NULL, 0,
+                                       &params ) ) {
+               goto done;
+       }
+
+       a = attr_find( params->e_attrs, ad_oathOTPLength );
+       if ( lutil_atoi( &otp_len, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_hotp: "
+                               "could not parse oathOTPLength value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+       if ( otp_len > MAX_DIGITS || op->orb_cred.bv_len < otp_len ) {
+               /* Client didn't even send the token, fail immediately */
+               goto done;
+       }
+
+       a = attr_find( params->e_attrs, ad_oathHOTPLookahead );
+       if ( lutil_atoi( &window, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_hotp: "
+                               "could not parse oathHOTPLookAhead value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+       window++;
+
+       a = attr_find( params->e_attrs, ad_oathHMACAlgorithm );
+       if ( !(mech = otp_choose_mech( &a->a_vals[0] )) ) {
+               goto done;
+       }
+       be_entry_release_r( op, params );
+       params = NULL;
+
+       /* We are provided "password" + "OTP", split accordingly */
+       client_otp.bv_len = otp_len;
+       client_otp.bv_val = op->orb_cred.bv_val + op->orb_cred.bv_len - otp_len;
+
+       /* If check succeeds, advance the step counter accordingly */
+       for ( i = 1; i <= window; i++ ) {
+               BerValue out = { .bv_val = outbuf, .bv_len = sizeof(outbuf) };
+
+               generate( secret, last_step + i, otp_len, &out, mech );
+               if ( !ber_bvcmp( &out, &client_otp ) ) {
+                       found = last_step + i;
+                       /* Would we leak information if we stopped right now? */
+               }
+       }
+
+       if ( found >= 0 ) {
+               /* OTP check passed, trim the password */
+               op->orb_cred.bv_len -= otp_len;
+               Debug( LDAP_DEBUG_STATS, "%s HOTP token %s no. %ld redeemed\n",
+                               op->o_log_prefix, token->e_name.bv_val, found );
+       }
+
+done:
+       memset( outbuf, 0, sizeof(outbuf) );
+       if ( params ) {
+               be_entry_release_r( op, params );
+       }
+       return found;
+}
+
+static long
+otp_totp( Operation *op, Entry *token )
+{
+       Entry *params = NULL;
+       Attribute *a;
+       BerValue *secret, client_otp;
+       const void *mech;
+       long t, last_step = -1, found = -1, window = 0;
+       int i, otp_len, time_step;
+
+       a = attr_find( token->e_attrs, ad_oathSecret );
+       secret = &a->a_vals[0];
+
+       a = attr_find( token->e_attrs, ad_oathTOTPLastTimeStep );
+       if ( a && lutil_atol( &last_step, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_totp: "
+                               "could not parse oathTOTPLastTimeStep value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+
+       a = attr_find( token->e_attrs, ad_oathTOTPParams );
+       if ( !a ||
+                       be_entry_get_rw( op, &a->a_nvals[0], oc_oathTOTPParams, NULL, 0,
+                                       &params ) ) {
+               goto done;
+       }
+
+       a = attr_find( params->e_attrs, ad_oathTOTPTimeStepPeriod );
+       if ( lutil_atoi( &time_step, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_totp: "
+                               "could not parse oathTOTPTimeStepPeriod value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+       t = op->o_time / time_step;
+
+       a = attr_find( params->e_attrs, ad_oathTOTPTimeStepWindow );
+       if ( a && lutil_atol( &window, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_totp: "
+                               "could not parse oathTOTPTimeStepWindow value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+
+       a = attr_find( params->e_attrs, ad_oathOTPLength );
+       if ( lutil_atoi( &otp_len, a->a_vals[0].bv_val ) != 0 ) {
+               Debug( LDAP_DEBUG_ANY, "otp_totp: "
+                               "could not parse oathOTPLength value %s\n",
+                               a->a_vals[0].bv_val );
+               goto done;
+       }
+       if ( otp_len > MAX_DIGITS || op->orb_cred.bv_len < otp_len ) {
+               /* Client didn't even send the token, fail immediately */
+               goto done;
+       }
+
+       a = attr_find( params->e_attrs, ad_oathHMACAlgorithm );
+       if ( !(mech = otp_choose_mech( &a->a_vals[0] )) ) {
+               goto done;
+       }
+       be_entry_release_r( op, params );
+       params = NULL;
+
+       /* We are provided "password" + "OTP", split accordingly */
+       client_otp.bv_len = otp_len;
+       client_otp.bv_val = op->orb_cred.bv_val + op->orb_cred.bv_len - otp_len;
+
+       /* If check succeeds, advance the step counter accordingly */
+       for ( i = -window; i <= window; i++ ) {
+               char outbuf[MAX_DIGITS + 1];
+               BerValue out = { .bv_val = outbuf, .bv_len = sizeof(outbuf) };
+
+               if ( t + i < 0 ) continue;
+
+               generate( secret, t + i, otp_len, &out, mech );
+               if ( !ber_bvcmp( &out, &client_otp ) ) {
+                       found = t + i;
+               }
+       }
+
+       if ( found >= 0 ) {
+               int offset = found - t;
+
+               if ( found <= last_step ) {
+                       /* Token re-used, refuse */
+                       found = -1;
+                       Debug( LDAP_DEBUG_TRACE, "%s client tried to reuse old TOTP token %s, offset %d\n",
+                                       op->o_log_prefix, token->e_name.bv_val, offset );
+               } else {
+                       /* OTP check passed, trim the password */
+                       op->orb_cred.bv_len -= otp_len;
+                       Debug( LDAP_DEBUG_TRACE, "%s TOTP token %s redeemed with offset %d\n",
+                                       op->o_log_prefix, token->e_name.bv_val, offset );
+               }
+       } else {
+               Debug( LDAP_DEBUG_TRACE, "%s TOTP token was not valid\n",
+                               op->o_log_prefix );
+       }
+
+done:
+       if ( params ) {
+               be_entry_release_r( op, params );
+       }
+       return found;
+}
+
+static int
+otp_op_bind( Operation *op, SlapReply *rs )
+{
+       slap_overinst *on = (slap_overinst *)op->o_bd->bd_info;
+       BerValue totpdn = BER_BVNULL, hotpdn = BER_BVNULL, ndn;
+       Entry *user = NULL, *token = NULL;
+       AttributeDescription *ad = NULL;
+       Attribute *a;
+       long t = -1;
+       int rc = SLAP_CB_CONTINUE;
+
+       if ( op->oq_bind.rb_method != LDAP_AUTH_SIMPLE ) {
+               return rc;
+       }
+
+       op->o_bd->bd_info = (BackendInfo *)on->on_info;
+
+       if ( be_entry_get_rw( op, &op->o_req_ndn, NULL, NULL, 0, &user ) ) {
+               goto done;
+       }
+
+       if ( !is_entry_objectclass_or_sub( user, oc_oathOTPUser ) ) {
+               be_entry_release_r( op, user );
+               goto done;
+       }
+
+       if ( (a = attr_find( user->e_attrs, ad_oathTOTPToken )) ) {
+               ber_dupbv_x( &totpdn, &a->a_nvals[0], op->o_tmpmemctx );
+       }
+
+       if ( (a = attr_find( user->e_attrs, ad_oathHOTPToken )) ) {
+               ber_dupbv_x( &hotpdn, &a->a_nvals[0], op->o_tmpmemctx );
+       }
+       be_entry_release_r( op, user );
+
+       if ( !BER_BVISNULL( &totpdn ) &&
+                       be_entry_get_rw( op, &totpdn, oc_oathTOTPToken, ad_oathSecret, 0,
+                                       &token ) == LDAP_SUCCESS ) {
+               ndn = totpdn;
+               ad = ad_oathTOTPLastTimeStep;
+               t = otp_totp( op, token );
+               be_entry_release_r( op, token );
+               token = NULL;
+       }
+       if ( t < 0 && !BER_BVISNULL( &hotpdn ) &&
+                       be_entry_get_rw( op, &hotpdn, oc_oathHOTPToken, ad_oathSecret, 0,
+                                       &token ) == LDAP_SUCCESS ) {
+               ndn = hotpdn;
+               ad = ad_oathHOTPCounter;
+               t = otp_hotp( op, token );
+               be_entry_release_r( op, token );
+               token = NULL;
+       }
+
+       /* If check succeeds, advance the step counter accordingly */
+       if ( t >= 0 ) {
+               char outbuf[32];
+               Operation op2;
+               Opheader oh;
+               Modifications mod;
+               SlapReply rs2 = { REP_RESULT };
+               slap_callback cb = { .sc_response = &slap_null_cb };
+               BerValue bv[2];
+
+               bv[0].bv_val = outbuf;
+               bv[0].bv_len = snprintf( bv[0].bv_val, sizeof(outbuf), "%ld", t );
+               BER_BVZERO( &bv[1] );
+
+               mod.sml_numvals = 1;
+               mod.sml_values = bv;
+               mod.sml_nvalues = NULL;
+               mod.sml_desc = ad;
+               mod.sml_op = LDAP_MOD_REPLACE;
+               mod.sml_flags = SLAP_MOD_INTERNAL;
+               mod.sml_next = NULL;
+
+               op2 = *op;
+               oh = *op->o_hdr;
+               op2.o_hdr = &oh;
+
+               op2.o_callback = &cb;
+
+               op2.o_tag = LDAP_REQ_MODIFY;
+               op2.orm_modlist = &mod;
+               op2.o_dn = op->o_bd->be_rootdn;
+               op2.o_ndn = op->o_bd->be_rootndn;
+               op2.o_req_dn = ndn;
+               op2.o_req_ndn = ndn;
+               op2.o_opid = -1;
+
+               op2.o_bd->be_modify( &op2, &rs2 );
+               if ( rs2.sr_err != LDAP_SUCCESS ) {
+                       rc = LDAP_OTHER;
+                       goto done;
+               }
+       } else {
+               /* Client failed the bind, but we still have to pass it over to the
+                * backend and fail the Bind later */
+               slap_callback *cb;
+               cb = op->o_tmpcalloc( 1, sizeof(slap_callback), op->o_tmpmemctx );
+               cb->sc_response = otp_bind_response;
+               cb->sc_next = op->o_callback;
+               op->o_callback = cb;
+       }
+
+done:
+       if ( !BER_BVISNULL( &hotpdn ) ) {
+               ber_memfree_x( hotpdn.bv_val, op->o_tmpmemctx );
+       }
+       if ( !BER_BVISNULL( &totpdn ) ) {
+               ber_memfree_x( totpdn.bv_val, op->o_tmpmemctx );
+       }
+       op->o_bd->bd_info = (BackendInfo *)on;
+       return rc;
+}
+
+static slap_overinst otp;
+
+int
+otp_initialize( void )
+{
+       ConfigArgs ca;
+       char *argv[4];
+       int i;
+
+       otp.on_bi.bi_type = "otp_2fa";
+       otp.on_bi.bi_op_bind = otp_op_bind;
+
+       ca.argv = argv;
+       argv[0] = "otp_2fa";
+       ca.argv = argv;
+       ca.argc = 3;
+       ca.fname = argv[0];
+
+       argv[3] = NULL;
+       for ( i = 0; otp_oid[i].name; i++ ) {
+               argv[1] = otp_oid[i].name;
+               argv[2] = otp_oid[i].oid;
+               parse_oidm( &ca, 0, NULL );
+       }
+
+       /* schema integration */
+       for ( i = 0; otp_at[i].schema; i++ ) {
+               if ( register_at( otp_at[i].schema, otp_at[i].adp, 0 ) ) {
+                       Debug( LDAP_DEBUG_ANY, "otp_initialize: "
+                                       "register_at failed\n" );
+                       return -1;
+               }
+       }
+
+       for ( i = 0; otp_oc[i].schema; i++ ) {
+               if ( register_oc( otp_oc[i].schema, otp_oc[i].ocp, 0 ) ) {
+                       Debug( LDAP_DEBUG_ANY, "otp_initialize: "
+                                       "register_oc failed\n" );
+                       return -1;
+               }
+       }
+
+       return overlay_register( &otp );
+}
+
+#if SLAPD_OVER_OTP == SLAPD_MOD_DYNAMIC
+int
+init_module( int argc, char *argv[] )
+{
+       return otp_initialize();
+}
+#endif /* SLAPD_OVER_OTP == SLAPD_MOD_DYNAMIC */
+
+#endif /* defined(SLAPD_OVER_OTP) */
diff --git a/tests/data/otp_2fa/hotp.ldif b/tests/data/otp_2fa/hotp.ldif
new file mode 100644 (file)
index 0000000..dfd160e
--- /dev/null
@@ -0,0 +1,61 @@
+dn: dc=example, dc=com
+changetype: modify
+add: objectClass
+objectClass: oathHOTPParams
+-
+add: oathOTPLength
+oathOTPLength: 6
+-
+add: oathHOTPLookAhead
+oathHOTPLookAhead: 3
+-
+add: oathHMACAlgorithm
+# SHA-1
+oathHMACAlgorithm: 1.2.840.113549.2.7
+
+dn: ou=Information Technology Division,ou=People,dc=example,dc=com
+changetype: modify
+add: objectClass
+objectclass: oathHOTPToken
+-
+add: oathHOTPParams
+oathHOTPParams: dc=example, dc=com
+-
+add: oathSecret
+oathSecret:: PcbKpIJKbSiHZ7IzHiC0MWbLhdk=
+-
+add: oathHOTPCounter
+oathHOTPCounter: 3
+
+dn: ou=Alumni Association,ou=People,dc=example,dc=com
+changetype: modify
+add: objectClass
+objectClass: oathHOTPParams
+-
+add: oathOTPLength
+oathOTPLength: 8
+-
+add: oathHOTPLookAhead
+oathHOTPLookAhead: 0
+-
+add: oathHMACAlgorithm
+# SHA-512
+oathHMACAlgorithm: 1.2.840.113549.2.11
+
+dn: cn=Barbara Jensen,ou=Information Technology Division,ou=People,dc=example,
+ dc=com
+changetype: modify
+add: objectClass
+objectClass: oathHOTPUser
+-
+add: oathHOTPToken
+oathHOTPToken: ou=Information Technology Division,ou=People,dc=example,dc=com
+
+dn: cn=Bjorn Jensen,ou=Information Technology Division,ou=People,dc=example,
+ dc=com
+changetype: modify
+add: objectClass
+objectClass: oathHOTPUser
+-
+add: oathHOTPToken
+oathHOTPToken: ou=Information Technology Division,ou=People,dc=example,dc=com
diff --git a/tests/data/otp_2fa/test001-out.ldif b/tests/data/otp_2fa/test001-out.ldif
new file mode 100644 (file)
index 0000000..97fa931
--- /dev/null
@@ -0,0 +1,5 @@
+dn: ou=Information Technology Division,ou=People,dc=example,dc=com
+oathSecret:: PcbKpIJKbSiHZ7IzHiC0MWbLhdk=
+oathHOTPParams: ou=Alumni Association,ou=People,dc=example,dc=com
+oathHOTPCounter: 12
+
diff --git a/tests/data/otp_2fa/totp.ldif b/tests/data/otp_2fa/totp.ldif
new file mode 100644 (file)
index 0000000..1067dfd
--- /dev/null
@@ -0,0 +1,64 @@
+dn: dc=example, dc=com
+changetype: modify
+add: objectClass
+objectClass: oathTOTPParams
+-
+add: oathOTPLength
+oathOTPLength: 6
+-
+add: oathTOTPTimeStepPeriod
+oathTOTPTimeStepPeriod: 30
+-
+add: oathTOTPTimeStepWindow
+oathTOTPTimeStepWindow: 3
+-
+add: oathHMACAlgorithm
+# SHA-1
+oathHMACAlgorithm: 1.2.840.113549.2.7
+
+dn: ou=Information Technology Division,ou=People,dc=example,dc=com
+changetype: modify
+add: objectClass
+objectclass: oathTOTPToken
+-
+add: oathTOTPParams
+oathTOTPParams: dc=example, dc=com
+-
+add: oathSecret
+oathSecret:: PcbKpIJKbSiHZ7IzHiC0MWbLhdk=
+
+dn: ou=Alumni Association,ou=People,dc=example,dc=com
+changetype: modify
+add: objectClass
+objectClass: oathTOTPParams
+-
+add: oathOTPLength
+oathOTPLength: 8
+-
+add: oathTOTPTimeStepPeriod
+oathTOTPTimeStepPeriod: 30
+-
+add: oathTOTPTimeStepWindow
+oathTOTPTimeStepWindow: 0
+-
+add: oathHMACAlgorithm
+# SHA-512
+oathHMACAlgorithm: 1.2.840.113549.2.11
+
+dn: cn=Barbara Jensen,ou=Information Technology Division,ou=People,dc=example,
+ dc=com
+changetype: modify
+add: objectClass
+objectClass: oathTOTPUser
+-
+add: oathTOTPToken
+oathTOTPToken: ou=Information Technology Division,ou=People,dc=example,dc=com
+
+dn: cn=Bjorn Jensen,ou=Information Technology Division,ou=People,dc=example,
+ dc=com
+changetype: modify
+add: objectClass
+objectClass: oathTOTPUser
+-
+add: oathTOTPToken
+oathTOTPToken: ou=Information Technology Division,ou=People,dc=example,dc=com
index 3b58d0c23499dc752db3a85fc6b4385eae779a39..0afeca07b51238de0bfa2da885a39d26172c7ea7 100644 (file)
@@ -49,6 +49,7 @@ AC_deref=deref@BUILD_DEREF@
 AC_dynlist=dynlist@BUILD_DYNLIST@
 AC_homedir=homedir@BUILD_HOMEDIR@
 AC_memberof=memberof@BUILD_MEMBEROF@
+AC_otp=otp@BUILD_OTP@
 AC_pcache=pcache@BUILD_PROXYCACHE@
 AC_ppolicy=ppolicy@BUILD_PPOLICY@
 AC_refint=refint@BUILD_REFINT@
@@ -80,7 +81,7 @@ if test "${AC_asyncmeta}" = "asyncmetamod" && test "${AC_LIBS_DYNAMIC}" = "stati
 fi
 export AC_ldap AC_mdb AC_meta AC_asyncmeta AC_monitor AC_null AC_perl AC_relay AC_sql \
        AC_accesslog AC_argon2 AC_autoca AC_constraint AC_dds AC_deref AC_dynlist \
-       AC_homedir AC_memberof AC_pcache AC_ppolicy AC_refint AC_remoteauth \
+       AC_homedir AC_memberof AC_otp AC_pcache AC_ppolicy AC_refint AC_remoteauth \
        AC_retcode AC_rwm AC_unique AC_syncprov AC_translucent \
        AC_valsort \
        AC_lloadd \
index f12fa6a1cb67ed0f15716314a45d8e80a5dcc161..8581bb9d62d867ccb417a91eeafb471490955a9b 100755 (executable)
@@ -37,6 +37,7 @@ for CMD in $SRCDIR/scripts/test*; do
                *.bak)  continue;;
                *.orig) continue;;
                *.sav)  continue;;
+               *.py)   continue;;
                *)              test -f "$CMD" || continue;;
        esac
 
index 9928ad426182054e5329684e40f4ee210d3605c5..32e59fc7bf6a4c4a6dd7cb966de1f825ad93282b 100755 (executable)
@@ -37,6 +37,7 @@ DEREF=${AC_deref-derefno}
 DYNLIST=${AC_dynlist-dynlistno}
 HOMEDIR=${AC_homedir-homedirno}
 MEMBEROF=${AC_memberof-memberofno}
+OTP=${AC_otp-otpno}
 PROXYCACHE=${AC_pcache-pcacheno}
 PPOLICY=${AC_ppolicy-ppolicyno}
 REFINT=${AC_refint-refintno}
diff --git a/tests/scripts/test080-hotp b/tests/scripts/test080-hotp
new file mode 100755 (executable)
index 0000000..f4cc1aa
--- /dev/null
@@ -0,0 +1,295 @@
+#! /bin/sh
+# $OpenLDAP$
+## This work is part of OpenLDAP Software <http://www.openldap.org/>.
+##
+## Copyright 2016-2021 Ondřej Kuzník, Symas Corp.
+## Copyright 2021 The OpenLDAP Foundation.
+## All rights reserved.
+##
+## Redistribution and use in source and binary forms, with or without
+## modification, are permitted only as authorized by the OpenLDAP
+## Public License.
+##
+## A copy of this license is available in the file LICENSE in the
+## top-level directory of the distribution or, alternatively, at
+## <http://www.OpenLDAP.org/license.html>.
+
+echo "running defines.sh"
+. $SRCDIR/scripts/defines.sh
+
+if test $OTP = otpno; then
+    echo "OTP overlay not available, test skipped"
+    exit 0
+fi
+
+OTP_DATA=$DATADIR/otp_2fa/hotp.ldif
+
+# OTPs for this token
+TOKEN_0=818800
+TOKEN_1=320382
+TOKEN_2=404533
+TOKEN_3=127122
+TOKEN_4=892599
+TOKEN_5=407030
+TOKEN_6=880935
+TOKEN_7=920291
+TOKEN_8=145192
+TOKEN_9=316404
+TOKEN_10=409144
+
+# OTPs for the second set of parameters
+TOKEN_SHA512_11=17544155
+TOKEN_SHA512_12=48953477
+
+mkdir -p $TESTDIR $DBDIR1
+
+echo "Running slapadd to build slapd database..."
+. $CONFFILTER $BACKEND < $CONF > $ADDCONF
+$SLAPADD -f $ADDCONF -l $LDIFORDERED
+RC=$?
+if test $RC != 0 ; then
+    echo "slapadd failed ($RC)!"
+    exit $RC
+fi
+
+mkdir $TESTDIR/confdir
+. $CONFFILTER $BACKEND < $CONF > $CONF1
+
+$SLAPPASSWD -g -n >$CONFIGPWF
+echo "database config" >>$CONF1
+echo "rootpw `$SLAPPASSWD -T $CONFIGPWF`" >>$CONF1
+
+echo "Starting slapd on TCP/IP port $PORT1..."
+$SLAPD -f $CONF1 -F $TESTDIR/confdir -h $URI1 -d $LVL > $LOG1 2>&1 &
+PID=$!
+if test $WAIT != 0 ; then
+    echo PID $PID
+    read foo
+fi
+KILLPIDS="$PID"
+
+sleep $SLEEP0
+
+for i in 0 1 2 3 4 5; do
+    $LDAPSEARCH -s base -b "$MONITOR" -H $URI1 \
+        'objectclass=*' > /dev/null 2>&1
+    RC=$?
+    if test $RC = 0 ; then
+        break
+    fi
+    echo "Waiting ${SLEEP1} seconds for slapd to start..."
+    sleep ${SLEEP1}
+done
+
+if [ "$OTP" = otpmod ]; then
+$LDAPADD -D cn=config -H $URI1 -y $CONFIGPWF \
+    >> $TESTOUT 2>&1 <<EOMOD
+dn: cn=module,cn=config
+objectClass: olcModuleList
+cn: module
+olcModulePath: $TESTWD/../servers/slapd/overlays
+olcModuleLoad: otp_2fa.la
+EOMOD
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+fi
+
+echo "Loading test otp_2fa configuration..."
+$LDAPMODIFY -v -D cn=config -H $URI1 -y $CONFIGPWF \
+    >> $TESTOUT 2>&1 <<EOMOD
+dn: olcOverlay={0}otp_2fa,olcDatabase={1}$BACKEND,cn=config
+changetype: add
+objectClass: olcOverlayConfig
+EOMOD
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "Provisioning tokens and configuration..."
+$LDAPMODIFY -D "$MANAGERDN" -H $URI1 -w $PASSWD \
+    >> $TESTOUT 2>&1 < $OTP_DATA
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+
+echo "Authentication tests:"
+echo "\ttoken that's not valid yet..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_10" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\ta valid and expected token..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_4" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\ta valid token skipping some..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_6" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\treusing the same token..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_6" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\tanother account sharing the same token..."
+$LDAPWHOAMI -D "$BJORNSDN" -H $URI1 -w "bjorn$TOKEN_7" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\ttrying an old token..."
+$LDAPWHOAMI -D "$BJORNSDN" -H $URI1 -w "bjorn$TOKEN_5" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\tright token, wrong password..."
+$LDAPWHOAMI -D "$BJORNSDN" -H $URI1 -w "bjensen$TOKEN_8" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\tmaking sure previous token has been retired too..."
+$LDAPWHOAMI -D "$BJORNSDN" -H $URI1 -w "bjorn$TOKEN_8" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\tthe first token we tested that's just become valid..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_10" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "Reconfiguring token parameters..."
+$LDAPMODIFY -D "$MANAGERDN" -H $URI1 -w $PASSWD \
+       >/dev/null 2>&1 << EOMODS
+dn: ou=Information Technology Division,ou=People,dc=example,dc=com
+changetype: modify
+replace: oathHOTPParams
+oathHOTPParams: ou=Alumni Association,ou=People,dc=example,dc=com
+EOMODS
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "A new round of tests:"
+
+echo "\ta long token that's not valid yet..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_SHA512_12" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 49 ; then
+    echo "ldapwhoami should have failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\ta valid and expected token..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_SHA512_11" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "\tthe previous long token that's just become valid..."
+$LDAPWHOAMI -D "$BABSDN" -H $URI1 -w "bjensen$TOKEN_SHA512_12" \
+    >> $TESTOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapwhoami failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "Retrieving token status..."
+$LDAPSEARCH -b "ou=Information Technology Division,ou=People,dc=example,dc=com" \
+    -H $URI1 objectclass=oathHOTPToken '@oathHOTPToken' \
+    >> $SEARCHOUT 2>&1
+RC=$?
+if test $RC != 0 ; then
+       echo "ldapsearch failed ($RC)!"
+       test $KILLSERVERS != no && kill -HUP $KILLPIDS
+       exit $RC
+fi
+
+test $KILLSERVERS != no && kill -HUP $KILLPIDS
+
+LDIF=$DATADIR/otp_2fa/test001-out.ldif
+
+echo "Filtering ldapsearch results..."
+$LDIFFILTER < $SEARCHOUT > $SEARCHFLT
+echo "Filtering ldif with expected data..."
+$LDIFFILTER < $LDIF > $LDIFFLT
+echo "Comparing filter output..."
+$CMP $SEARCHFLT $LDIFFLT > $CMPOUT
+
+if test $? != 0 ; then
+       echo "Comparison failed"
+       exit 1
+fi
+
+echo ">>>>> Test succeeded"
+
+test $KILLSERVERS != no && wait
+
+exit 0
diff --git a/tests/scripts/test081-totp b/tests/scripts/test081-totp
new file mode 100755 (executable)
index 0000000..427ec40
--- /dev/null
@@ -0,0 +1,143 @@
+#!/bin/sh
+# $OpenLDAP$
+## This work is part of OpenLDAP Software <http://www.openldap.org/>.
+##
+## Copyright 2016-2021 Ondřej Kuzník, Symas Corp.
+## Copyright 2021 The OpenLDAP Foundation.
+## All rights reserved.
+##
+## Redistribution and use in source and binary forms, with or without
+## modification, are permitted only as authorized by the OpenLDAP
+## Public License.
+##
+## A copy of this license is available in the file LICENSE in the
+## top-level directory of the distribution or, alternatively, at
+## <http://www.OpenLDAP.org/license.html>.
+
+echo "running defines.sh"
+. $SRCDIR/scripts/defines.sh
+
+if test $OTP = otpno; then
+    echo "OTP overlay not available, test skipped"
+    exit 0
+fi
+
+for python in python3 python2 python2.7 python27 python ""; do
+    if test x"$python" = x; then
+        echo "Useable Python environment not found, skipping test"
+        exit 0
+    fi
+
+    "$python" "$0".py --check >>$TESTOUT 2>&1
+    RC=$?
+    case $RC in
+    0)
+        break;;
+    1)
+        echo "$python is missing some required modules, skipping"
+        python=""
+        continue;;
+    127)
+        ;;
+    esac
+done
+
+export URI1 MANAGERDN PASSWD BABSDN BJORNSDN
+
+OTP_DATA=$DATADIR/otp_2fa/totp.ldif
+
+mkdir -p $TESTDIR $DBDIR1
+
+echo "Running slapadd to build slapd database..."
+. $CONFFILTER $BACKEND < $CONF > $ADDCONF
+$SLAPADD -f $ADDCONF -l $LDIFORDERED
+RC=$?
+if test $RC != 0 ; then
+    echo "slapadd failed ($RC)!"
+    exit $RC
+fi
+
+mkdir $TESTDIR/confdir
+. $CONFFILTER $BACKEND < $CONF > $CONF1
+
+$SLAPPASSWD -g -n >$CONFIGPWF
+echo "database config" >>$CONF1
+echo "rootpw `$SLAPPASSWD -T $CONFIGPWF`" >>$CONF1
+
+echo "Starting slapd on TCP/IP port $PORT1..."
+$SLAPD -f $CONF1 -F $TESTDIR/confdir -h $URI1 -d $LVL > $LOG1 2>&1 &
+PID=$!
+if test $WAIT != 0 ; then
+    echo PID $PID
+    read foo
+fi
+KILLPIDS="$PID"
+
+sleep $SLEEP0
+
+for i in 0 1 2 3 4 5; do
+    $LDAPSEARCH -s base -b "$MONITOR" -H $URI1 \
+        'objectclass=*' > /dev/null 2>&1
+    RC=$?
+    if test $RC = 0 ; then
+        break
+    fi
+    echo "Waiting ${SLEEP1} seconds for slapd to start..."
+    sleep ${SLEEP1}
+done
+
+if [ "$OTP" = otpmod ]; then
+$LDAPADD -D cn=config -H $URI1 -y $CONFIGPWF \
+    >> $TESTOUT 2>&1 <<EOMOD
+dn: cn=module,cn=config
+objectClass: olcModuleList
+cn: module
+olcModulePath: $TESTWD/../servers/slapd/overlays
+olcModuleLoad: otp_2fa.la
+EOMOD
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+fi
+
+echo "Loading test otp_2fa configuration..."
+$LDAPMODIFY -v -D cn=config -H $URI1 -y $CONFIGPWF \
+    >> $TESTOUT 2>&1 <<EOMOD
+dn: olcOverlay={0}otp_2fa,olcDatabase={1}$BACKEND,cn=config
+changetype: add
+objectClass: olcOverlayConfig
+EOMOD
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+echo "Provisioning tokens and configuration..."
+$LDAPMODIFY -D "$MANAGERDN" -H $URI1 -w $PASSWD \
+    >> $TESTOUT 2>&1 < $OTP_DATA
+RC=$?
+if test $RC != 0 ; then
+    echo "ldapmodify failed ($RC)!"
+    test $KILLSERVERS != no && kill -HUP $KILLPIDS
+    exit $RC
+fi
+
+"$python" "$0".py
+RC=$?
+
+test $KILLSERVERS != no && kill -HUP $KILLPIDS
+
+if test $RC != 0 ; then
+    echo "Test failed ($RC)!"
+else
+    echo ">>>>> Test succeeded"
+fi
+
+test $KILLSERVERS != no && wait
+
+exit $RC
diff --git a/tests/scripts/test081-totp.py b/tests/scripts/test081-totp.py
new file mode 100755 (executable)
index 0000000..d5e3437
--- /dev/null
@@ -0,0 +1,182 @@
+# -*- coding: utf-8 -*-
+# $OpenLDAP$
+## This work is part of OpenLDAP Software <http://www.openldap.org/>.
+##
+## Copyright 2016-2021 Ondřej Kuzník, Symas Corp.
+## Copyright 2021 The OpenLDAP Foundation.
+## All rights reserved.
+##
+## Redistribution and use in source and binary forms, with or without
+## modification, are permitted only as authorized by the OpenLDAP
+## Public License.
+##
+## A copy of this license is available in the file LICENSE in the
+## top-level directory of the distribution or, alternatively, at
+## <http://www.OpenLDAP.org/license.html>.
+
+from __future__ import print_function
+
+import hashlib
+import hmac
+import os
+import struct
+import sys
+import time
+
+import ldap
+from ldap.cidict import cidict as CIDict
+from ldap.ldapobject import LDAPObject
+
+if len(sys.argv) > 1 and sys.argv[1] == "--check":
+    raise SystemExit(0)
+
+
+def get_digits(h, digits):
+    offset = h[19] & 15
+    number = struct.unpack(">I", h[offset:offset+4])[0] & 0x7fffffff
+    number %= (10 ** digits)
+    return ("%0*d" % (digits, number)).encode()
+
+
+def get_hotp_token(secret, interval_no):
+    msg = struct.pack(">Q", interval_no)
+    h = hmac.new(secret, msg, hashlib.sha1).digest()
+    return get_digits(bytearray(h), 6)
+
+
+def get_interval(period=30):
+    return int(time.time() // period)
+
+
+def get_token_for(connection, dn, typ="totp"):
+    result = connection.search_s(dn, ldap.SCOPE_BASE)
+    dn, attrs = result[0]
+    attrs = CIDict(attrs)
+
+    tokendn = attrs['oath'+typ+'token'][0].decode()
+
+    result = connection.search_s(tokendn, ldap.SCOPE_BASE)
+    dn, attrs = result[0]
+    attrs = CIDict(attrs)
+
+    return dn, attrs
+
+
+def main():
+    uri = os.environ["URI1"]
+
+    managerdn = os.environ['MANAGERDN']
+    passwd = os.environ['PASSWD']
+
+    babsdn = os.environ['BABSDN']
+    babspw = b"bjensen"
+
+    bjornsdn = os.environ['BJORNSDN']
+    bjornspw = b"bjorn"
+
+    connection = LDAPObject(uri)
+
+    start = time.time()
+    connection.bind_s(managerdn, passwd)
+    end = time.time()
+
+    if end - start > 1:
+        print("It takes more than a second to connect and bind, "
+              "skipping potentially unstable test", file=sys.stderr)
+        raise SystemExit(0)
+
+    dn, token_entry = get_token_for(connection, babsdn)
+
+    paramsdn = token_entry['oathTOTPParams'][0].decode()
+    result = connection.search_s(paramsdn, ldap.SCOPE_BASE)
+    _, attrs = result[0]
+    params = CIDict(attrs)
+
+    secret = token_entry['oathSecret'][0]
+    period = int(params['oathTOTPTimeStepPeriod'][0].decode())
+
+    bind_conn = LDAPObject(uri)
+
+    interval_no = get_interval(period)
+    token = get_hotp_token(secret, interval_no-3)
+
+    print("Testing old tokens are not useable")
+    bind_conn.bind_s(babsdn, babspw+token)
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        pass
+    else:
+        raise SystemExit("Bind with an old token should have failed")
+
+    interval_no = get_interval(period)
+    token = get_hotp_token(secret, interval_no)
+
+    print("Testing token can only be used once")
+    bind_conn.bind_s(babsdn, babspw+token)
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        pass
+    else:
+        raise SystemExit("Bind with a reused token should have failed")
+
+    token = get_hotp_token(secret, interval_no+1)
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        raise SystemExit("Bind should have succeeded")
+
+    dn, token_entry = get_token_for(connection, babsdn)
+    last = int(token_entry['oathTOTPLastTimeStep'][0].decode())
+    if last != interval_no+1:
+        SystemExit("Unexpected counter value %d (expected %d)" %
+                   (last, interval_no+1))
+
+    print("Resetting counter and testing secret sharing between accounts")
+    connection.modify_s(dn, [(ldap.MOD_REPLACE, 'oathTOTPLastTimeStep', [])])
+
+    interval_no = get_interval(period)
+    token = get_hotp_token(secret, interval_no)
+
+    try:
+        bind_conn.bind_s(bjornsdn, bjornspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        raise SystemExit("Bind should have succeeded")
+
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        pass
+    else:
+        raise SystemExit("Bind with a reused token should have failed")
+
+    print("Testing token is retired even with a wrong password")
+    connection.modify_s(dn, [(ldap.MOD_REPLACE, 'oathTOTPLastTimeStep', [])])
+
+    interval_no = get_interval(period)
+    token = get_hotp_token(secret, interval_no)
+
+    try:
+        bind_conn.bind_s(babsdn, b"not the password"+token)
+    except ldap.INVALID_CREDENTIALS:
+        pass
+    else:
+        raise SystemExit("Bind with an incorrect password should have failed")
+
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        pass
+    else:
+        raise SystemExit("Bind with a reused token should have failed")
+
+    token = get_hotp_token(secret, interval_no+1)
+    try:
+        bind_conn.bind_s(babsdn, babspw+token)
+    except ldap.INVALID_CREDENTIALS:
+        raise SystemExit("Bind should have succeeded")
+
+
+if __name__ == "__main__":
+    sys.exit(main())