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