diff --git a/configure.ac b/configure.ac index 67b63fe2fc..458e0209ba 100644 --- a/configure.ac +++ b/configure.ac @@ -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 index 0000000000..205cd19a24 --- /dev/null +++ b/doc/man/man5/slapo-otp_2fa.5 @@ -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: +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: +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: +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: +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: +The shared secret is stored here as raw bytes. +.TP +.B oathOTPLength: +The password length, usually 6. +.TP +.B oathHMACAlgorithm: +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: +The number of successive HOTP tokens that can be skipped. +.TP +.B oathHOTPCounter: +The order of the last HOTP token successfully redeemed by the user. +.RE + +The TOTP attributes: + +.RS +.TP +.B oathTOTPTimeStepPeriod: +The length of the time-step period for TOTP calculation. +.TP +.B oathTOTPLastTimeStep: +The order of the last TOTP token successfully redeemed by the user. +.TP +.B oathTOTPGrace: +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. diff --git a/servers/slapd/overlays/Makefile.in b/servers/slapd/overlays/Makefile.in index 12ee746c88..d71f7c3d7a 100644 --- a/servers/slapd/overlays/Makefile.in +++ b/servers/slapd/overlays/Makefile.in @@ -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 index 0000000000..e5117800ff --- /dev/null +++ b/servers/slapd/overlays/otp_2fa.c @@ -0,0 +1,950 @@ +/* otp_2fa.c - OATH 2-factor authentication module */ +/* $OpenLDAP$ */ +/* This work is part of OpenLDAP Software . + * + * Copyright 2015-2021 The OpenLDAP Foundation. + * Portions Copyright 2015 by Howard Chu, Symas Corp. + * Portions Copyright 2016-2017 by Michael Ströder + * 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 + * . + */ +/* ACKNOWLEDGEMENTS: + * This work includes code from the lastbind overlay. + */ + +#include + +#ifdef SLAPD_OVER_OTP + +#if HAVE_STDINT_H +#include +#endif + +#include +#include +#include "lutil.h" +#include +#include +#include +/* include socket.h to get sys/types.h and/or winsock2.h */ +#include + +#if HAVE_OPENSSL +#include +#include + +#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 + +#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, + ¶ms ) ) { + 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, + ¶ms ) ) { + 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 index 0000000000..dfd160ea0a --- /dev/null +++ b/tests/data/otp_2fa/hotp.ldif @@ -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 index 0000000000..97fa9314ce --- /dev/null +++ b/tests/data/otp_2fa/test001-out.ldif @@ -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 index 0000000000..1067dfd352 --- /dev/null +++ b/tests/data/otp_2fa/totp.ldif @@ -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 diff --git a/tests/run.in b/tests/run.in index 3b58d0c234..0afeca07b5 100644 --- a/tests/run.in +++ b/tests/run.in @@ -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 \ diff --git a/tests/scripts/all b/tests/scripts/all index f12fa6a1cb..8581bb9d62 100755 --- a/tests/scripts/all +++ b/tests/scripts/all @@ -37,6 +37,7 @@ for CMD in $SRCDIR/scripts/test*; do *.bak) continue;; *.orig) continue;; *.sav) continue;; + *.py) continue;; *) test -f "$CMD" || continue;; esac diff --git a/tests/scripts/defines.sh b/tests/scripts/defines.sh index 9928ad4261..32e59fc7bf 100755 --- a/tests/scripts/defines.sh +++ b/tests/scripts/defines.sh @@ -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 index 0000000000..f4cc1aaed3 --- /dev/null +++ b/tests/scripts/test080-hotp @@ -0,0 +1,295 @@ +#! /bin/sh +# $OpenLDAP$ +## This work is part of OpenLDAP Software . +## +## 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 +## . + +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 <> $TESTOUT 2>&1 <> $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 index 0000000000..427ec40ec1 --- /dev/null +++ b/tests/scripts/test081-totp @@ -0,0 +1,143 @@ +#!/bin/sh +# $OpenLDAP$ +## This work is part of OpenLDAP Software . +## +## 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 +## . + +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 <> $TESTOUT 2>&1 <> $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 index 0000000000..d5e3437cdb --- /dev/null +++ b/tests/scripts/test081-totp.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# $OpenLDAP$ +## This work is part of OpenLDAP Software . +## +## 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 +## . + +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())