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