]> git.ipfire.org Git - thirdparty/dovecot/core.git/commitdiff
lib-sasl: Add OTP client support
authorStephan Bosch <stephan.bosch@open-xchange.com>
Thu, 2 Oct 2025 00:33:58 +0000 (02:33 +0200)
committertimo.sirainen <timo.sirainen@open-xchange.com>
Thu, 9 Oct 2025 08:41:22 +0000 (08:41 +0000)
src/lib-sasl/Makefile.am
src/lib-sasl/dsasl-client-mech-otp.c [new file with mode: 0644]
src/lib-sasl/dsasl-client-private.h
src/lib-sasl/dsasl-client.c
src/lib-sasl/fuzz-sasl-authentication.c
src/lib-sasl/test-sasl-authentication.c

index 286a040457d7116f090428070d429a94d75c563d..b338e341c95e7cd353a5571ad689723b84e16373 100644 (file)
@@ -22,6 +22,7 @@ client_mechanisms = \
        dsasl-client-mech-external.c \
        dsasl-client-mech-login.c \
        dsasl-client-mech-oauthbearer.c \
+       dsasl-client-mech-otp.c \
        dsasl-client-mech-plain.c \
        dsasl-client-mech-scram.c
 
diff --git a/src/lib-sasl/dsasl-client-mech-otp.c b/src/lib-sasl/dsasl-client-mech-otp.c
new file mode 100644 (file)
index 0000000..1fe1970
--- /dev/null
@@ -0,0 +1,271 @@
+/* Copyright (c) 2025 Dovecot authors, see the included COPYING file */
+
+#include "lib.h"
+#include "str.h"
+#include "strnum.h"
+#include "str-sanitize.h"
+#include "hex-binary.h"
+#include "randgen.h"
+#include "otp.h"
+#include "otp-hash.h"
+
+#include "dsasl-client-private.h"
+
+#define OTP_MAX_SEQUENCE (64 * 4096)
+
+/* Sequence count below which we trigger seed reinitialization and
+   sequence reset. */
+#define MECH_OTP_MIN_SEQ 8
+
+#define IS_LWSP(c) ((c) == ' ' || (c) == '\t')
+
+struct otp_dsasl_client {
+       struct dsasl_client client;
+
+       struct otp_state state;
+};
+
+static bool
+parse_prefix(const unsigned char **_p, const unsigned char *pend,
+            const char *prefix)
+{
+       const unsigned char *p = *_p;
+       size_t prlen = strlen(prefix);
+
+       if (prlen > (size_t)(pend - p))
+               return FALSE;
+       if (memcmp(p, prefix, prlen) != 0)
+               return FALSE;
+
+       *_p = p + prlen;
+       return TRUE;
+}
+
+static void
+skip_lwsp(const unsigned char **_p, const unsigned char *pend)
+{
+       const unsigned char *p = *_p;
+
+       while (p < pend && IS_LWSP(*p))
+               p++;
+       *_p = p;
+}
+
+static void
+parse_field(const unsigned char **_p, const unsigned char *pend,
+           char **field_r)
+{
+       const unsigned char *p = *_p, *poffset;
+
+       poffset = p;
+       while (p < pend && !IS_LWSP(*p))
+               p++;
+
+       i_assert(p > poffset);
+       *field_r = t_strdup_until_noconst(poffset, p);
+       *_p = p;
+}
+
+static enum dsasl_client_result
+mech_otp_input(struct dsasl_client *client,
+              const unsigned char *input, size_t input_len,
+              const char **error_r)
+{
+       struct otp_dsasl_client *oclient =
+               container_of(client, struct otp_dsasl_client, client);
+       const unsigned char *p = input, *pend = input + input_len;
+       char *algorithm, *seed;
+       uintmax_t sequence;
+       int ret;
+
+       /* otp-<algorithm identifier> <sequence integer> <seed>
+        */
+
+       if (p >= pend) {
+               *error_r = "Server sent empty challenge";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+
+       /* otp-<algorithm identifier> */
+       if (!parse_prefix(&p, pend, "otp-")) {
+               *error_r = "Server sent invalid challenge: "
+                       "Missing 'otp-' prefix";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       if (p >= pend || IS_LWSP(*p)) {
+               *error_r = "Server sent invalid challenge: "
+                       "Missing algorithm name";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       parse_field(&p, pend, &algorithm);
+       skip_lwsp(&p, pend);
+
+       /* <sequence integer> */
+       if (p >= pend) {
+               *error_r = "Server sent incomplete challenge: "
+                       "Sequence field missing";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       if (str_parse_data_uintmax(p, pend - p, &sequence, &p) < 0 ||
+           (p < pend && !IS_LWSP(*p)) ||
+           sequence == 0 || sequence > INT_MAX) {
+               *error_r = "Server sent invalid challenge: "
+                       "Invalid sequence field";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       skip_lwsp(&p, pend);
+
+       /* <seed> */
+       if (p >= pend) {
+               *error_r = "Server sent incomplete challenge: "
+                       "Seed field missing";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       parse_field(&p, pend, &seed);
+       seed = str_lcase(seed);
+       skip_lwsp(&p, pend);
+
+       /* extended-challenge = otp-challenge 1*LWSP-char capability-list
+                        (NL / *LWSP-char)
+          capability-list   = "ext" *("," extension-set-id)
+          extension-set-id  = *<any CHAR except LWSP, CTLs, or ",">
+        */
+       if (p >= pend) {
+               *error_r = "Server sent incomplete challenge: "
+                       "Capability list missing";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       if (!parse_prefix(&p, pend, "ext")) {
+               *error_r = "Server sent invalid challenge: "
+                       "Invalid capability list";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+       if (p < pend && *p == ',') {
+               /* skip rest of capability list; we support none */
+               while (p < pend && *p > 0x20 && *p < 0x7F)
+                       p++;
+       }
+       if (p < pend && *p == '\n')
+               p++;
+       else
+               skip_lwsp(&p, pend);
+       if (p < pend) {
+               *error_r = "Server sent invalid challenge: "
+                       "Unrecognized trailing data";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+
+       /* Check limits */
+       if (sequence > OTP_MAX_SEQUENCE) {
+               *error_r = t_strdup_printf(
+                       "Server sent unacceptable challenge: "
+                       "Sequence out of acceptable range (%"PRIuMAX" > %d)",
+                       sequence, OTP_MAX_SEQUENCE);
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+
+       /* Find hash algorithm */
+       ret = digest_find(algorithm);
+       if (ret < 0) {
+               *error_r = t_strdup_printf(
+                       "Server sent unacceptable challenge: "
+                       "Unsupported hash algorithm: %s",
+                       str_sanitize(algorithm, 64));
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+
+       /* RFC 2289, Section 6.0:
+          The seed MUST consist of purely alphanumeric characters and MUST be
+          of one to 16 characters in length.
+        */
+       size_t si;
+       for (si = 0; seed[si] != '\0' && si < 16; si++) {
+               if (seed[si] >= '0' && seed[si] <= '9')
+                       continue;
+               else if (seed[si] >= 'a' && seed[si] <= 'z')
+                       continue;
+               break;
+       }
+       if (seed[si] != '\0') {
+               *error_r = "Server sent unacceptable challenge: "
+                       "Invalid seed string";
+               return DSASL_CLIENT_RESULT_ERR_PROTOCOL;
+       }
+
+       oclient->state.algo = ret;
+       oclient->state.seq = sequence;
+       memcpy(oclient->state.seed, seed, si);
+       return DSASL_CLIENT_RESULT_OK;
+}
+
+static enum dsasl_client_result
+mech_otp_output(struct dsasl_client *client,
+               const unsigned char **output_r, size_t *output_len_r,
+               const char **error_r)
+{
+       struct otp_dsasl_client *oclient =
+               container_of(client, struct otp_dsasl_client, client);
+       string_t *str;
+
+       if (client->set.authid == NULL) {
+               *error_r = "authid not set";
+               return DSASL_CLIENT_RESULT_ERR_INTERNAL;
+       }
+       if (client->password == NULL) {
+               *error_r = "password not set";
+               return DSASL_CLIENT_RESULT_ERR_INTERNAL;
+       }
+
+       if (oclient->state.seq == 0) {
+               str = str_new(client->pool, 128);
+               if (client->set.authzid != NULL)
+                       str_append(str, client->set.authzid);
+               str_append_c(str, '\0');
+               str_append(str, client->set.authid);
+
+               *output_r = str_data(str);
+               *output_len_r = str_len(str);
+               return DSASL_CLIENT_RESULT_OK;
+       }
+
+       struct otp_state *state = &oclient->state;
+       unsigned char hash[OTP_HASH_SIZE];
+
+       otp_hash(state->algo, state->seed, client->password, state->seq, hash);
+       if (oclient->state.seq > MECH_OTP_MIN_SEQ) {
+               str = str_new(client->pool, 16);
+               str_append(str, "hex:");
+               binary_to_hex_append(str, hash, sizeof(hash));
+       } else {
+               unsigned char new_hash[OTP_HASH_SIZE];
+               unsigned char random_data[OTP_MAX_SEED_LEN / 2];
+               const char *random_hex;
+
+               random_fill(random_data, sizeof(random_data));
+               random_hex = binary_to_hex(random_data, sizeof(random_data));
+               memcpy(state->seed, random_hex, sizeof(state->seed));
+               state->seq = 1024;
+
+               otp_hash(state->algo, state->seed, client->password, state->seq,
+                        new_hash);
+
+               str = str_new(client->pool, 128);
+               str_append(str, "init-hex:");
+               binary_to_hex_append(str, hash, sizeof(hash));
+               str_printfa(str, ":%s %d %s:",
+                           digest_name(state->algo), state->seq, state->seed);
+               binary_to_hex_append(str, new_hash, sizeof(new_hash));
+       }
+
+       *output_r = str_data(str);
+       *output_len_r = str_len(str);
+       return DSASL_CLIENT_RESULT_OK;
+}
+
+const struct dsasl_client_mech dsasl_client_mech_otp = {
+       .name = SASL_MECH_NAME_OTP,
+       .struct_size = sizeof(struct otp_dsasl_client),
+
+       .input = mech_otp_input,
+       .output = mech_otp_output,
+};
index ec9ac3e96dac990367201e12c278fb29b6cafd59..016abff1bcebd75d20411ae6bb3271071f45fe48 100644 (file)
@@ -48,6 +48,7 @@ extern const struct dsasl_client_mech dsasl_client_mech_digest_md5;
 extern const struct dsasl_client_mech dsasl_client_mech_external;
 extern const struct dsasl_client_mech dsasl_client_mech_login;
 extern const struct dsasl_client_mech dsasl_client_mech_oauthbearer;
+extern const struct dsasl_client_mech dsasl_client_mech_otp;
 extern const struct dsasl_client_mech dsasl_client_mech_xoauth2;
 extern const struct dsasl_client_mech dsasl_client_mech_scram_sha_1;
 extern const struct dsasl_client_mech dsasl_client_mech_scram_sha_1_plus;
index 68156aaf3ce9c5c9433586cb21cedd2c564045a1..e514a8459e4d8e55e2adc9fce73c1c6dbca735f5 100644 (file)
@@ -164,6 +164,7 @@ void dsasl_clients_init(void)
        dsasl_client_mech_register(&dsasl_client_mech_digest_md5);
        dsasl_client_mech_register(&dsasl_client_mech_cram_md5);
        dsasl_client_mech_register(&dsasl_client_mech_oauthbearer);
+       dsasl_client_mech_register(&dsasl_client_mech_otp);
        dsasl_client_mech_register(&dsasl_client_mech_xoauth2);
        dsasl_client_mech_register(&dsasl_client_mech_scram_sha_1);
        dsasl_client_mech_register(&dsasl_client_mech_scram_sha_1_plus);
index 6f0296b646bd58c24d56bbb2cd8d877ae2951be8..b21e82569b6297e25a73a1409d08870374b22b42 100644 (file)
@@ -207,6 +207,23 @@ fuzz_server_request_lookup_credentials(
        callback(&fctx->ssrctx, &result);
 }
 
+static void
+fuzz_server_request_set_credentials(
+       struct sasl_server_req_ctx *rctx,
+       const char *scheme ATTR_UNUSED, const char *data ATTR_UNUSED,
+       sasl_server_passdb_callback_t *callback)
+{
+       struct fuzz_sasl_context *fctx =
+               container_of(rctx, struct fuzz_sasl_context, ssrctx);
+       struct sasl_passdb_result result;
+
+       /* Credentials are currently not actually stored */
+
+       i_zero(&result);
+       result.status = SASL_PASSDB_RESULT_OK;
+       callback(&fctx->ssrctx, &result);
+}
+
 static void
 fuzz_sasl_amend_data(struct fuzz_sasl_context *fctx,
                     const unsigned char **_data, size_t *_size)
@@ -449,6 +466,7 @@ struct sasl_server_request_funcs server_funcs = {
 
        .request_verify_plain = fuzz_server_request_verify_plain,
        .request_lookup_credentials = fuzz_server_request_lookup_credentials,
+       .request_set_credentials = fuzz_server_request_set_credentials,
 
        .request_output = fuzz_server_request_output,
 };
@@ -583,6 +601,7 @@ static void fuzz_sasl_run(struct istream *input)
        sasl_server_mech_register_cram_md5(server_inst);
        sasl_server_mech_register_digest_md5(server_inst);
        sasl_server_mech_register_login(server_inst);
+       sasl_server_mech_register_otp(server_inst);
        sasl_server_mech_register_plain(server_inst);
        sasl_server_mech_register_scram_sha1(server_inst);
        sasl_server_mech_register_scram_sha1_plus(server_inst);
index 5b4692733882cbed3d8160caadcab27cd24c3383..56377835f82cc506a1695ee603d5c27b1245fcd0 100644 (file)
@@ -25,12 +25,23 @@ struct test_sasl {
                const char *password;
        } client, server;
 
+       unsigned int repeat;
+
        bool failure:1;
 };
 
+struct test_sasl_passdb {
+       pool_t pool;
+
+       const char *credentials_stored;
+       const char *credentials_scheme;
+};
+
 struct test_sasl_context {
        pool_t pool;
 
+       struct test_sasl_passdb *passdb;
+
        struct sasl_server_req_ctx ssrctx;
        const struct test_sasl *test;
 
@@ -190,6 +201,7 @@ test_server_request_lookup_credentials(
        struct test_sasl_context *tctx =
                container_of(rctx, struct test_sasl_context, ssrctx);
        const struct test_sasl *test = tctx->test;
+       struct test_sasl_passdb *passdb = tctx->passdb;
        struct sasl_passdb_result result;
 
        i_zero(&result);
@@ -202,6 +214,16 @@ test_server_request_lookup_credentials(
                return;
        }
 
+       if (passdb->credentials_stored != NULL) {
+               i_assert(strcasecmp(scheme, passdb->credentials_scheme) == 0);
+               result.status = SASL_PASSDB_RESULT_OK;
+               result.credentials.data =
+                       (const unsigned char *)passdb->credentials_stored;
+               result.credentials.size = strlen(passdb->credentials_stored);
+               callback(&tctx->ssrctx, &result);
+               return;
+       }
+
        const struct password_generate_params params = {
                .user = (test->server.realm == NULL ? test->server.authid :
                         t_strconcat(test->server.authid, "@",
@@ -220,6 +242,23 @@ test_server_request_lookup_credentials(
        callback(&tctx->ssrctx, &result);
 }
 
+static void
+test_server_request_set_credentials(
+       struct sasl_server_req_ctx *rctx, const char *scheme, const char *data,
+       sasl_server_passdb_callback_t *callback)
+{
+       struct test_sasl_context *tctx =
+               container_of(rctx, struct test_sasl_context, ssrctx);
+       struct test_sasl_passdb *passdb = tctx->passdb;
+       struct sasl_passdb_result result;
+
+       passdb->credentials_stored = p_strdup(passdb->pool, data);
+       passdb->credentials_scheme = p_strdup(passdb->pool, scheme);
+
+       result.status = SASL_PASSDB_RESULT_OK;
+       callback(&tctx->ssrctx, &result);
+}
+
 static void
 test_server_request_output(struct sasl_server_req_ctx *rctx,
                           const struct sasl_server_output *output)
@@ -314,6 +353,7 @@ struct sasl_server_request_funcs server_funcs = {
 
        .request_verify_plain = test_server_request_verify_plain,
        .request_lookup_credentials = test_server_request_lookup_credentials,
+       .request_set_credentials = test_server_request_set_credentials,
 
        .request_output = test_server_request_output,
 };
@@ -371,6 +411,7 @@ static void test_sasl_interact(struct test_sasl_context *tctx)
 
 static void
 test_sasl_run_once(const struct test_sasl *test,
+                  struct test_sasl_passdb *passdb,
                   const struct sasl_server_mech *server_mech,
                   bool auth_initial)
 {
@@ -379,6 +420,7 @@ test_sasl_run_once(const struct test_sasl *test,
 
        i_zero(&tctx);
        tctx.pool = pool_alloconly_create(MEMPOOL_GROWING"test_sasl", 2048);
+       tctx.passdb = passdb;
        tctx.test = test;
        tctx.auth_initial = auth_initial;
 
@@ -452,6 +494,7 @@ test_sasl_run(const struct test_sasl *test, const char *label,
        sasl_server_mech_register_digest_md5(server_inst);
        sasl_server_mech_register_external(server_inst);
        sasl_server_mech_register_login(server_inst);
+       sasl_server_mech_register_otp(server_inst);
        sasl_server_mech_register_plain(server_inst);
        sasl_server_mech_register_scram_sha1(server_inst);
        sasl_server_mech_register_scram_sha1_plus(server_inst);
@@ -473,7 +516,16 @@ test_sasl_run(const struct test_sasl *test, const char *label,
        server_mech = sasl_server_mech_find(server_inst, test->mech);
        i_assert(server_mech != NULL);
 
-       test_sasl_run_once(test, server_mech, auth_initial);
+       struct test_sasl_passdb passdb;
+       unsigned int repeat = (test->repeat > 0 ? test->repeat : 1);
+
+       i_zero(&passdb);
+       passdb.pool = pool_alloconly_create(MEMPOOL_GROWING"test passdb", 2048);
+
+       for (i = 0; i < repeat && !test_has_failed(); i++)
+               test_sasl_run_once(test, &passdb, server_mech, auth_initial);
+
+       pool_unref(&passdb.pool);
 
        sasl_server_instance_unref(&server_inst);
        sasl_server_deinit(&server);
@@ -639,6 +691,16 @@ static const struct test_sasl success_tests[] = {
                        .password = "tokentokentoken",
                },
        },
+       /* OTP */
+       {
+               .mech = "OTP",
+               .authid_type = SASL_SERVER_AUTHID_TYPE_USERNAME,
+               .server = {
+                       .authid = "user",
+                       .password = "pass",
+               },
+               .repeat = 1050,
+       },
        /* EXTERNAL */
        {
                .mech = "EXTERNAL",
@@ -1345,6 +1407,31 @@ static const struct test_sasl bad_creds_tests[] = {
                },
                .failure = TRUE,
        },
+       /* OTP */
+       {
+               .mech = "OTP",
+               .authid_type = SASL_SERVER_AUTHID_TYPE_USERNAME,
+               .server = {
+                       .authid = "user",
+                       .password = "pass",
+               },
+               .client = {
+                       .authid = "userb",
+               },
+               .failure = TRUE,
+       },
+       {
+               .mech = "OTP",
+               .authid_type = SASL_SERVER_AUTHID_TYPE_USERNAME,
+               .server = {
+                       .authid = "user",
+                       .password = "pass",
+               },
+               .client = {
+                       .password = "florp",
+               },
+               .failure = TRUE,
+       },
        /* EXTERNAL */
        {
                .mech = "EXTERNAL",