taler-merchant-httpd_mfa.c (22855B)
1 /* 2 This file is part of TALER 3 (C) 2025 Taler Systems SA 4 5 TALER is free software; you can redistribute it and/or modify 6 it under the terms of the GNU Affero General Public License as 7 published by the Free Software Foundation; either version 3, 8 or (at your option) any later version. 9 10 TALER is distributed in the hope that it will be useful, but 11 WITHOUT ANY WARRANTY; without even the implied warranty of 12 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 GNU General Public License for more details. 14 15 You should have received a copy of the GNU General Public 16 License along with TALER; see the file COPYING. If not, 17 see <http://www.gnu.org/licenses/> 18 */ 19 20 /** 21 * @file src/backend/taler-merchant-httpd_mfa.c 22 * @brief internal APIs for multi-factor authentication (MFA) 23 * @author Christian Grothoff 24 */ 25 #include "platform.h" 26 #include "taler-merchant-httpd.h" 27 #include "taler-merchant-httpd_mfa.h" 28 #include "merchant-database/create_mfa_challenge.h" 29 #include "merchant-database/lookup_mfa_challenge.h" 30 31 32 /** 33 * How many challenges do we allow at most per request? 34 */ 35 #define MAX_CHALLENGES 9 36 37 /** 38 * How long are challenges valid? 39 */ 40 #define CHALLENGE_LIFETIME GNUNET_TIME_UNIT_DAYS 41 42 43 enum GNUNET_GenericReturnValue 44 TMH_mfa_parse_challenge_id (struct TMH_HandlerContext *hc, 45 const char *challenge_id, 46 uint64_t *challenge_serial, 47 struct TALER_MERCHANT_MFA_BodyHash *h_body) 48 { 49 const char *dash = strchr (challenge_id, 50 '-'); 51 unsigned long long ser; 52 char min; 53 54 if (NULL == dash) 55 { 56 GNUNET_break_op (0); 57 return (MHD_NO == 58 TALER_MHD_reply_with_error (hc->connection, 59 MHD_HTTP_BAD_REQUEST, 60 TALER_EC_GENERIC_PARAMETER_MALFORMED, 61 "'-' missing in challenge ID")) 62 ? GNUNET_SYSERR 63 : GNUNET_NO; 64 } 65 if ( (2 != 66 sscanf (challenge_id, 67 "%llu%c%*s", 68 &ser, 69 &min)) || 70 ('-' != min) ) 71 { 72 GNUNET_break_op (0); 73 return (MHD_NO == 74 TALER_MHD_reply_with_error (hc->connection, 75 MHD_HTTP_BAD_REQUEST, 76 TALER_EC_GENERIC_PARAMETER_MALFORMED, 77 "Invalid number for challenge ID")) 78 ? GNUNET_SYSERR 79 : GNUNET_NO; 80 } 81 if (GNUNET_OK != 82 GNUNET_STRINGS_string_to_data (dash + 1, 83 strlen (dash + 1), 84 h_body, 85 sizeof (*h_body))) 86 { 87 GNUNET_break_op (0); 88 return (MHD_NO == 89 TALER_MHD_reply_with_error (hc->connection, 90 MHD_HTTP_BAD_REQUEST, 91 TALER_EC_GENERIC_PARAMETER_MALFORMED, 92 "Malformed challenge ID")) 93 ? GNUNET_SYSERR 94 : GNUNET_NO; 95 } 96 *challenge_serial = (uint64_t) ser; 97 return GNUNET_OK; 98 } 99 100 101 /** 102 * Check if the given authentication check was already completed. 103 * 104 * @param[in,out] hc handler context of the connection to authorize 105 * @param op operation for which we are requiring authorization 106 * @param challenge_id ID of the challenge to check if it is done 107 * @param[out] solved set to true if the challenge was solved, 108 * set to false if @a challenge_id was not found 109 * @param[out] channel TAN channel that was used, 110 * set to #TALER_MERCHANT_MFA_CHANNEL_NONE if @a challenge_id 111 * was not found 112 * @param[out] target_address address which was validated, 113 * set to NULL if @a challenge_id was not found 114 * @param[out] retry_counter how many attempts are left on the challenge 115 * @return #GNUNET_OK on success (challenge found) 116 * #GNUNET_NO if an error message was returned to the client 117 * #GNUNET_SYSERR to just close the connection 118 */ 119 static enum GNUNET_GenericReturnValue 120 mfa_challenge_check ( 121 struct TMH_HandlerContext *hc, 122 enum TALER_MERCHANT_MFA_CriticalOperation op, 123 const char *challenge_id, 124 bool *solved, 125 enum TALER_MERCHANT_MFA_Channel *channel, 126 char **target_address, 127 uint32_t *retry_counter) 128 { 129 uint64_t challenge_serial; 130 struct TALER_MERCHANT_MFA_BodyHash h_body; 131 struct TALER_MERCHANT_MFA_BodyHash x_h_body; 132 struct TALER_MERCHANT_MFA_BodySalt salt; 133 struct GNUNET_TIME_Absolute retransmission_date; 134 enum TALER_MERCHANT_MFA_CriticalOperation xop; 135 enum GNUNET_DB_QueryStatus qs; 136 struct GNUNET_TIME_Absolute confirmation_date; 137 enum GNUNET_GenericReturnValue ret; 138 char *instance_id = NULL; 139 140 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 141 "Checking status of challenge %s\n", 142 challenge_id); 143 ret = TMH_mfa_parse_challenge_id (hc, 144 challenge_id, 145 &challenge_serial, 146 &x_h_body); 147 if (GNUNET_OK != ret) 148 return ret; 149 *target_address = NULL; 150 *solved = false; 151 *channel = TALER_MERCHANT_MFA_CHANNEL_NONE; 152 *retry_counter = UINT_MAX; 153 qs = TALER_MERCHANTDB_lookup_mfa_challenge (TMH_db, 154 challenge_serial, 155 &x_h_body, 156 &salt, 157 target_address, 158 &xop, 159 &confirmation_date, 160 &retransmission_date, 161 retry_counter, 162 channel, 163 &instance_id); 164 switch (qs) 165 { 166 case GNUNET_DB_STATUS_HARD_ERROR: 167 GNUNET_break (0); 168 return (MHD_NO == 169 TALER_MHD_reply_with_error (hc->connection, 170 MHD_HTTP_INTERNAL_SERVER_ERROR, 171 TALER_EC_GENERIC_DB_COMMIT_FAILED, 172 NULL)) 173 ? GNUNET_SYSERR 174 : GNUNET_NO; 175 case GNUNET_DB_STATUS_SOFT_ERROR: 176 GNUNET_break (0); 177 return (MHD_NO == 178 TALER_MHD_reply_with_error (hc->connection, 179 MHD_HTTP_INTERNAL_SERVER_ERROR, 180 TALER_EC_GENERIC_DB_SOFT_FAILURE, 181 NULL)) 182 ? GNUNET_SYSERR 183 : GNUNET_NO; 184 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 185 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 186 "Challenge %s not found\n", 187 challenge_id); 188 return GNUNET_OK; 189 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 190 break; 191 } 192 GNUNET_free (instance_id); 193 194 if (xop != op) 195 { 196 GNUNET_log (GNUNET_ERROR_TYPE_ERROR, 197 "Challenge was for a different operation (%d!=%d)!\n", 198 (int) op, 199 (int) xop); 200 *solved = false; 201 return GNUNET_OK; 202 } 203 TALER_MERCHANT_mfa_body_hash (hc->request_body, 204 &salt, 205 &h_body); 206 if (0 != 207 GNUNET_memcmp (&h_body, 208 &x_h_body)) 209 { 210 GNUNET_log (GNUNET_ERROR_TYPE_ERROR, 211 "Challenge was for a different request body!\n"); 212 *solved = false; 213 return GNUNET_OK; 214 } 215 *solved = (! GNUNET_TIME_absolute_is_future (confirmation_date)); 216 return GNUNET_OK; 217 } 218 219 220 /** 221 * Multi-factor authentication check to see if for the given @a instance_id 222 * and the @a op operation all the TAN channels given in @a required_tans have 223 * been satisfied. Note that we always satisfy @a required_tans in the order 224 * given in the array, so if the last one is satisfied, all previous ones must 225 * have been satisfied before. 226 * 227 * If the challenges has not been satisfied, an appropriate response 228 * is returned to the client of @a hc. 229 * 230 * @param[in,out] hc handler context of the connection to authorize 231 * @param op operation for which we are performing 232 * @param channel TAN channel to try 233 * @param expiration_date when should the challenge expire 234 * @param required_address addresses to use for 235 * the respective challenge 236 * @param[out] challenge_id set to the challenge ID, to be freed by 237 * the caller 238 * @return #GNUNET_OK on success, 239 * #GNUNET_NO if an error message was returned to the client 240 * #GNUNET_SYSERR to just close the connection 241 */ 242 static enum GNUNET_GenericReturnValue 243 mfa_challenge_start ( 244 struct TMH_HandlerContext *hc, 245 enum TALER_MERCHANT_MFA_CriticalOperation op, 246 enum TALER_MERCHANT_MFA_Channel channel, 247 struct GNUNET_TIME_Absolute expiration_date, 248 const char *required_address, 249 char **challenge_id) 250 { 251 enum GNUNET_DB_QueryStatus qs; 252 struct TALER_MERCHANT_MFA_BodySalt salt; 253 struct TALER_MERCHANT_MFA_BodyHash h_body; 254 uint64_t challenge_serial; 255 unsigned long long challenge_num; 256 char *code; 257 258 GNUNET_CRYPTO_random_block (GNUNET_CRYPTO_QUALITY_NONCE, 259 &salt, 260 sizeof (salt)); 261 TALER_MERCHANT_mfa_body_hash (hc->request_body, 262 &salt, 263 &h_body); 264 challenge_num = (unsigned long long) 265 GNUNET_CRYPTO_random_u64 (GNUNET_CRYPTO_QUALITY_NONCE, 266 1000 * 1000 * 100); 267 /* Note: if this is changed, the code in 268 taler-merchant-httpd_post-challenge-ID.c and 269 taler-merchant-httpd_post-challenge-ID-confirm.c must 270 possibly also be updated! */ 271 GNUNET_asprintf (&code, 272 "%04llu-%04llu", 273 challenge_num / 10000, 274 challenge_num % 10000); 275 qs = TALER_MERCHANTDB_create_mfa_challenge (TMH_db, 276 op, 277 &h_body, 278 &salt, 279 code, 280 expiration_date, 281 GNUNET_TIME_UNIT_ZERO_ABS, 282 channel, 283 required_address, 284 hc->instance->settings.id, 285 &challenge_serial); 286 GNUNET_free (code); 287 switch (qs) 288 { 289 case GNUNET_DB_STATUS_HARD_ERROR: 290 GNUNET_break (0); 291 return (MHD_NO == 292 TALER_MHD_reply_with_error (hc->connection, 293 MHD_HTTP_INTERNAL_SERVER_ERROR, 294 TALER_EC_GENERIC_DB_COMMIT_FAILED, 295 NULL)) 296 ? GNUNET_SYSERR 297 : GNUNET_NO; 298 case GNUNET_DB_STATUS_SOFT_ERROR: 299 GNUNET_break (0); 300 return (MHD_NO == 301 TALER_MHD_reply_with_error (hc->connection, 302 MHD_HTTP_INTERNAL_SERVER_ERROR, 303 TALER_EC_GENERIC_DB_SOFT_FAILURE, 304 NULL)) 305 ? GNUNET_SYSERR 306 : GNUNET_NO; 307 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 308 GNUNET_assert (0); 309 break; 310 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 311 break; 312 } 313 { 314 char *h_body_s; 315 316 h_body_s = GNUNET_STRINGS_data_to_string_alloc (&h_body, 317 sizeof (h_body)); 318 GNUNET_asprintf (challenge_id, 319 "%llu-%s", 320 (unsigned long long) challenge_serial, 321 h_body_s); 322 GNUNET_free (h_body_s); 323 } 324 return GNUNET_OK; 325 } 326 327 328 /** 329 * Internal book-keeping for #TMH_mfa_challenges_do(). 330 */ 331 struct Challenge 332 { 333 /** 334 * Channel on which the challenge is transmitted. 335 */ 336 enum TALER_MERCHANT_MFA_Channel channel; 337 338 /** 339 * Address to send the challenge to. 340 */ 341 const char *required_address; 342 343 /** 344 * Internal challenge ID. 345 */ 346 char *challenge_id; 347 348 /** 349 * True if the challenge was solved. 350 */ 351 bool solved; 352 353 /** 354 * True if the challenge could still be solved. 355 */ 356 bool solvable; 357 358 }; 359 360 361 /** 362 * Obtain hint about the @a target_address of type @a channel to 363 * return to the client. 364 * 365 * @param channel type of challenge 366 * @param target_address address we will sent the challenge to 367 * @return hint for the user about the address 368 */ 369 static char * 370 get_hint (enum TALER_MERCHANT_MFA_Channel channel, 371 const char *target_address) 372 { 373 switch (channel) 374 { 375 case TALER_MERCHANT_MFA_CHANNEL_NONE: 376 GNUNET_assert (0); 377 return NULL; 378 case TALER_MERCHANT_MFA_CHANNEL_SMS: 379 { 380 size_t slen = strlen (target_address); 381 const char *end; 382 383 if (slen > 4) 384 end = &target_address[slen - 4]; 385 else 386 end = &target_address[slen / 2]; 387 return GNUNET_strdup (end); 388 } 389 case TALER_MERCHANT_MFA_CHANNEL_EMAIL: 390 { 391 const char *at; 392 size_t len; 393 394 at = strchr (target_address, 395 '@'); 396 if (NULL == at) 397 len = 0; 398 else 399 len = at - target_address; 400 return GNUNET_strndup (target_address, 401 len); 402 } 403 case TALER_MERCHANT_MFA_CHANNEL_TOTP: 404 GNUNET_break (0); 405 return GNUNET_strdup ("TOTP is not implemented: #10327"); 406 } 407 GNUNET_break (0); 408 return NULL; 409 } 410 411 412 /** 413 * Check that a set of MFA challenges has been satisfied by the 414 * client for the request in @a hc. 415 * 416 * @param[in,out] hc handler context with the connection to the client 417 * @param op operation for which we should check challenges for 418 * @param combi_and true to tell the client to solve all challenges (AND), 419 * false means that any of the challenges will do (OR) 420 * @param ... pairs of channel and address, terminated by 421 * #TALER_MERCHANT_MFA_CHANNEL_NONE 422 * @return #GNUNET_OK on success (challenges satisfied) 423 * #GNUNET_NO if an error message was returned to the client 424 * #GNUNET_SYSERR to just close the connection 425 */ 426 enum GNUNET_GenericReturnValue 427 TMH_mfa_challenges_do ( 428 struct TMH_HandlerContext *hc, 429 enum TALER_MERCHANT_MFA_CriticalOperation op, 430 bool combi_and, 431 ...) 432 { 433 struct Challenge challenges[MAX_CHALLENGES]; 434 const char *challenge_ids[MAX_CHALLENGES]; 435 size_t num_challenges; 436 char *challenge_ids_copy = NULL; 437 size_t num_provided_challenges; 438 enum GNUNET_GenericReturnValue ret; 439 440 { 441 va_list ap; 442 443 va_start (ap, 444 combi_and); 445 num_challenges = 0; 446 while (num_challenges < MAX_CHALLENGES) 447 { 448 enum TALER_MERCHANT_MFA_Channel channel; 449 const char *address; 450 451 channel = va_arg (ap, 452 enum TALER_MERCHANT_MFA_Channel); 453 if (TALER_MERCHANT_MFA_CHANNEL_NONE == channel) 454 break; 455 address = va_arg (ap, 456 const char *); 457 if (NULL == address) 458 continue; 459 challenges[num_challenges].channel = channel; 460 challenges[num_challenges].required_address = address; 461 challenges[num_challenges].challenge_id = NULL; 462 challenges[num_challenges].solved = false; 463 challenges[num_challenges].solvable = true; 464 num_challenges++; 465 } 466 va_end (ap); 467 } 468 469 if (0 == num_challenges) 470 { 471 /* No challenges required. Strange... */ 472 return GNUNET_OK; 473 } 474 475 { 476 const char *challenge_ids_header; 477 478 challenge_ids_header 479 = MHD_lookup_connection_value (hc->connection, 480 MHD_HEADER_KIND, 481 "Taler-Challenge-Ids"); 482 num_provided_challenges = 0; 483 if (NULL != challenge_ids_header) 484 { 485 challenge_ids_copy = GNUNET_strdup (challenge_ids_header); 486 487 for (char *token = strtok (challenge_ids_copy, 488 ","); 489 NULL != token; 490 token = strtok (NULL, 491 ",")) 492 { 493 if (num_provided_challenges >= MAX_CHALLENGES) 494 { 495 GNUNET_break_op (0); 496 GNUNET_free (challenge_ids_copy); 497 return (MHD_NO == 498 TALER_MHD_reply_with_error ( 499 hc->connection, 500 MHD_HTTP_BAD_REQUEST, 501 TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED, 502 "Taler-Challenge-Ids")) 503 ? GNUNET_SYSERR 504 : GNUNET_NO; 505 } 506 challenge_ids[num_provided_challenges] = token; 507 num_provided_challenges++; 508 } 509 } 510 } 511 512 /* Check provided challenges against requirements */ 513 for (size_t i = 0; i < num_provided_challenges; i++) 514 { 515 bool solved; 516 enum TALER_MERCHANT_MFA_Channel channel; 517 char *target_address; 518 uint32_t retry_counter; 519 520 ret = mfa_challenge_check (hc, 521 op, 522 challenge_ids[i], 523 &solved, 524 &channel, 525 &target_address, 526 &retry_counter); 527 if (GNUNET_OK != ret) 528 goto cleanup; 529 for (size_t j = 0; j < num_challenges; j++) 530 { 531 if ( (challenges[j].channel == channel) && 532 (NULL == challenges[j].challenge_id) && 533 (NULL != target_address /* just to be sure */) && 534 (0 == strcmp (target_address, 535 challenges[j].required_address) ) ) 536 { 537 challenges[j].solved 538 = solved; 539 challenges[j].challenge_id 540 = GNUNET_strdup (challenge_ids[i]); 541 if ( (! solved) && 542 (0 == retry_counter) ) 543 { 544 /* can't be solved anymore! */ 545 challenges[i].solvable = false; 546 } 547 break; 548 } 549 } 550 GNUNET_free (target_address); 551 } 552 553 { 554 struct GNUNET_TIME_Absolute expiration_date 555 = GNUNET_TIME_relative_to_absolute (CHALLENGE_LIFETIME); 556 557 /* Start new challenges for unsolved requirements */ 558 for (size_t i = 0; i < num_challenges; i++) 559 { 560 if (NULL == challenges[i].challenge_id) 561 { 562 GNUNET_assert (! challenges[i].solved); 563 GNUNET_assert (challenges[i].solvable); 564 ret = mfa_challenge_start (hc, 565 op, 566 challenges[i].channel, 567 expiration_date, 568 challenges[i].required_address, 569 &challenges[i].challenge_id); 570 if (GNUNET_OK != ret) 571 goto cleanup; 572 } 573 } 574 } 575 576 { 577 bool all_solved = true; 578 bool any_solved = false; 579 bool solvable = true; 580 581 for (size_t i = 0; i < num_challenges; i++) 582 { 583 if (challenges[i].solved) 584 { 585 any_solved = true; 586 } 587 else 588 { 589 all_solved = false; 590 if (combi_and && 591 (! challenges[i].solvable) ) 592 solvable = false; 593 } 594 } 595 596 if ( (combi_and && all_solved) || 597 (! combi_and && any_solved) ) 598 { 599 /* Authorization successful */ 600 ret = GNUNET_OK; 601 goto cleanup; 602 } 603 if (! solvable) 604 { 605 ret = (MHD_NO == 606 TALER_MHD_reply_with_error ( 607 hc->connection, 608 MHD_HTTP_FORBIDDEN, 609 TALER_EC_MERCHANT_MFA_FORBIDDEN, 610 GNUNET_TIME_relative2s (CHALLENGE_LIFETIME, 611 false))) 612 ? GNUNET_SYSERR 613 : GNUNET_NO; 614 goto cleanup; 615 } 616 } 617 618 /* Return challenges to client */ 619 { 620 json_t *jchallenges; 621 622 jchallenges = json_array (); 623 GNUNET_assert (NULL != jchallenges); 624 for (size_t i = 0; i<num_challenges; i++) 625 { 626 const struct Challenge *c = &challenges[i]; 627 json_t *jc; 628 char *hint; 629 630 hint = get_hint (c->channel, 631 c->required_address); 632 633 jc = GNUNET_JSON_PACK ( 634 GNUNET_JSON_pack_string ("tan_info", 635 hint), 636 GNUNET_JSON_pack_string ("tan_channel", 637 TALER_MERCHANT_MFA_channel_to_string ( 638 c->channel)), 639 GNUNET_JSON_pack_string ("challenge_id", 640 c->challenge_id)); 641 GNUNET_free (hint); 642 GNUNET_assert (0 == 643 json_array_append_new ( 644 jchallenges, 645 jc)); 646 } 647 ret = (MHD_NO == 648 TALER_MHD_REPLY_JSON_PACK ( 649 hc->connection, 650 MHD_HTTP_ACCEPTED, 651 GNUNET_JSON_pack_bool ("combi_and", 652 combi_and), 653 GNUNET_JSON_pack_array_steal ("challenges", 654 jchallenges))) 655 ? GNUNET_SYSERR 656 : GNUNET_NO; 657 } 658 659 cleanup: 660 for (size_t i = 0; i < num_challenges; i++) 661 GNUNET_free (challenges[i].challenge_id); 662 GNUNET_free (challenge_ids_copy); 663 return ret; 664 } 665 666 667 enum GNUNET_GenericReturnValue 668 TMH_mfa_check_simple ( 669 struct TMH_HandlerContext *hc, 670 enum TALER_MERCHANT_MFA_CriticalOperation op, 671 struct TMH_MerchantInstance *mi) 672 { 673 enum GNUNET_GenericReturnValue ret; 674 bool have_sms = (NULL != mi->settings.phone) && 675 (NULL != TMH_helper_sms) && 676 (mi->settings.phone_validated); 677 bool have_email = (NULL != mi->settings.email) && 678 (NULL != TMH_helper_email) && 679 (mi->settings.email_validated); 680 681 /* Note: we check for 'validated' above, but in theory 682 we could also use unvalidated for this operation. 683 That's a policy-decision we may want to revise, 684 but probably need to look at the global threat model to 685 make sure alternative configurations are still sane. */ 686 if (have_email) 687 { 688 ret = TMH_mfa_challenges_do (hc, 689 op, 690 false, 691 TALER_MERCHANT_MFA_CHANNEL_EMAIL, 692 mi->settings.email, 693 have_sms 694 ? TALER_MERCHANT_MFA_CHANNEL_SMS 695 : TALER_MERCHANT_MFA_CHANNEL_NONE, 696 mi->settings.phone, 697 TALER_MERCHANT_MFA_CHANNEL_NONE); 698 } 699 else if (have_sms) 700 { 701 ret = TMH_mfa_challenges_do (hc, 702 op, 703 false, 704 TALER_MERCHANT_MFA_CHANNEL_SMS, 705 mi->settings.phone, 706 TALER_MERCHANT_MFA_CHANNEL_NONE); 707 } 708 else 709 { 710 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 711 "No MFA possible, skipping 2-FA\n"); 712 ret = GNUNET_OK; 713 } 714 return ret; 715 }