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