From: Stephan Bosch Date: Thu, 2 Oct 2025 00:33:58 +0000 (+0200) Subject: lib-sasl: Add OTP client support X-Git-Tag: 2.4.2~138 X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=7dc7a9a397a9ef3bb43402ae7125064750f08ce0;p=thirdparty%2Fdovecot%2Fcore.git lib-sasl: Add OTP client support --- diff --git a/src/lib-sasl/Makefile.am b/src/lib-sasl/Makefile.am index 286a040457..b338e341c9 100644 --- a/src/lib-sasl/Makefile.am +++ b/src/lib-sasl/Makefile.am @@ -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 index 0000000000..1fe1970b38 --- /dev/null +++ b/src/lib-sasl/dsasl-client-mech-otp.c @@ -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- + */ + + if (p >= pend) { + *error_r = "Server sent empty challenge"; + return DSASL_CLIENT_RESULT_ERR_PROTOCOL; + } + + /* otp- */ + 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); + + /* */ + 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); + + /* */ + 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 = * + */ + 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, +}; diff --git a/src/lib-sasl/dsasl-client-private.h b/src/lib-sasl/dsasl-client-private.h index ec9ac3e96d..016abff1bc 100644 --- a/src/lib-sasl/dsasl-client-private.h +++ b/src/lib-sasl/dsasl-client-private.h @@ -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; diff --git a/src/lib-sasl/dsasl-client.c b/src/lib-sasl/dsasl-client.c index 68156aaf3c..e514a8459e 100644 --- a/src/lib-sasl/dsasl-client.c +++ b/src/lib-sasl/dsasl-client.c @@ -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); diff --git a/src/lib-sasl/fuzz-sasl-authentication.c b/src/lib-sasl/fuzz-sasl-authentication.c index 6f0296b646..b21e82569b 100644 --- a/src/lib-sasl/fuzz-sasl-authentication.c +++ b/src/lib-sasl/fuzz-sasl-authentication.c @@ -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); diff --git a/src/lib-sasl/test-sasl-authentication.c b/src/lib-sasl/test-sasl-authentication.c index 5b46927338..56377835f8 100644 --- a/src/lib-sasl/test-sasl-authentication.c +++ b/src/lib-sasl/test-sasl-authentication.c @@ -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",