taler-merchant-httpd_mfa.c (22067B)
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 "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 char *code; 251 252 GNUNET_CRYPTO_random_block (GNUNET_CRYPTO_QUALITY_NONCE, 253 &salt, 254 sizeof (salt)); 255 TALER_MERCHANT_mfa_body_hash (hc->request_body, 256 &salt, 257 &h_body); 258 GNUNET_asprintf (&code, 259 "%llu", 260 (unsigned long long) 261 GNUNET_CRYPTO_random_u64 (GNUNET_CRYPTO_QUALITY_NONCE, 262 1000 * 1000 * 100)); 263 qs = TMH_db->create_mfa_challenge (TMH_db->cls, 264 op, 265 &h_body, 266 &salt, 267 code, 268 expiration_date, 269 GNUNET_TIME_UNIT_ZERO_ABS, 270 channel, 271 required_address, 272 &challenge_serial); 273 GNUNET_free (code); 274 switch (qs) 275 { 276 case GNUNET_DB_STATUS_HARD_ERROR: 277 GNUNET_break (0); 278 return (MHD_NO == 279 TALER_MHD_reply_with_error (hc->connection, 280 MHD_HTTP_INTERNAL_SERVER_ERROR, 281 TALER_EC_GENERIC_DB_COMMIT_FAILED, 282 NULL)) 283 ? GNUNET_SYSERR 284 : GNUNET_NO; 285 case GNUNET_DB_STATUS_SOFT_ERROR: 286 GNUNET_break (0); 287 return (MHD_NO == 288 TALER_MHD_reply_with_error (hc->connection, 289 MHD_HTTP_INTERNAL_SERVER_ERROR, 290 TALER_EC_GENERIC_DB_SOFT_FAILURE, 291 NULL)) 292 ? GNUNET_SYSERR 293 : GNUNET_NO; 294 case GNUNET_DB_STATUS_SUCCESS_NO_RESULTS: 295 GNUNET_assert (0); 296 break; 297 case GNUNET_DB_STATUS_SUCCESS_ONE_RESULT: 298 break; 299 } 300 { 301 char *h_body_s; 302 303 h_body_s = GNUNET_STRINGS_data_to_string_alloc (&h_body, 304 sizeof (h_body)); 305 GNUNET_asprintf (challenge_id, 306 "%llu-%s", 307 (unsigned long long) challenge_serial, 308 h_body_s); 309 GNUNET_free (h_body_s); 310 } 311 return GNUNET_OK; 312 } 313 314 315 /** 316 * Internal book-keeping for #TMH_mfa_challenges_do(). 317 */ 318 struct Challenge 319 { 320 /** 321 * Channel on which the challenge is transmitted. 322 */ 323 enum TALER_MERCHANT_MFA_Channel channel; 324 325 /** 326 * Address to send the challenge to. 327 */ 328 const char *required_address; 329 330 /** 331 * Internal challenge ID. 332 */ 333 char *challenge_id; 334 335 /** 336 * True if the challenge was solved. 337 */ 338 bool solved; 339 340 /** 341 * True if the challenge could still be solved. 342 */ 343 bool solvable; 344 345 }; 346 347 348 /** 349 * Obtain hint about the @a target_address of type @a channel to 350 * return to the client. 351 * 352 * @param channel type of challenge 353 * @param target_address address we will sent the challenge to 354 * @return hint for the user about the address 355 */ 356 static char * 357 get_hint (enum TALER_MERCHANT_MFA_Channel channel, 358 const char *target_address) 359 { 360 switch (channel) 361 { 362 case TALER_MERCHANT_MFA_CHANNEL_NONE: 363 GNUNET_assert (0); 364 return NULL; 365 case TALER_MERCHANT_MFA_CHANNEL_SMS: 366 { 367 size_t slen = strlen (target_address); 368 const char *end; 369 370 if (slen > 4) 371 end = &target_address[slen - 4]; 372 else 373 end = &target_address[slen / 2]; 374 return GNUNET_strdup (end); 375 } 376 case TALER_MERCHANT_MFA_CHANNEL_EMAIL: 377 { 378 const char *at; 379 size_t len; 380 381 at = strchr (target_address, 382 '@'); 383 if (NULL == at) 384 len = 0; 385 else 386 len = at - target_address; 387 return GNUNET_strndup (target_address, 388 len); 389 } 390 case TALER_MERCHANT_MFA_CHANNEL_TOTP: 391 GNUNET_break (0); 392 return GNUNET_strdup ("TOTP is not implemented: #10327"); 393 } 394 GNUNET_break (0); 395 return NULL; 396 } 397 398 399 /** 400 * Check that a set of MFA challenges has been satisfied by the 401 * client for the request in @a hc. 402 * 403 * @param[in,out] hc handler context with the connection to the client 404 * @param op operation for which we should check challenges for 405 * @param combi_and true to tell the client to solve all challenges (AND), 406 * false means that any of the challenges will do (OR) 407 * @param ... pairs of channel and address, terminated by 408 * #TALER_MERCHANT_MFA_CHANNEL_NONE 409 * @return #GNUNET_OK on success (challenges satisfied) 410 * #GNUNET_NO if an error message was returned to the client 411 * #GNUNET_SYSERR to just close the connection 412 */ 413 enum GNUNET_GenericReturnValue 414 TMH_mfa_challenges_do ( 415 struct TMH_HandlerContext *hc, 416 enum TALER_MERCHANT_MFA_CriticalOperation op, 417 bool combi_and, 418 ...) 419 { 420 struct Challenge challenges[MAX_CHALLENGES]; 421 const char *challenge_ids[MAX_CHALLENGES]; 422 size_t num_challenges; 423 char *challenge_ids_copy = NULL; 424 size_t num_provided_challenges; 425 enum GNUNET_GenericReturnValue ret; 426 427 { 428 va_list ap; 429 430 va_start (ap, 431 combi_and); 432 for (num_challenges = 0; 433 num_challenges < MAX_CHALLENGES; 434 num_challenges++) 435 { 436 enum TALER_MERCHANT_MFA_Channel channel; 437 const char *address; 438 439 channel = va_arg (ap, 440 enum TALER_MERCHANT_MFA_Channel); 441 if (TALER_MERCHANT_MFA_CHANNEL_NONE == channel) 442 break; 443 address = va_arg (ap, 444 const char *); 445 GNUNET_assert (NULL != address); 446 challenges[num_challenges].channel = channel; 447 challenges[num_challenges].required_address = address; 448 challenges[num_challenges].challenge_id = NULL; 449 challenges[num_challenges].solved = false; 450 challenges[num_challenges].solvable = true; 451 } 452 va_end (ap); 453 } 454 455 if (0 == num_challenges) 456 { 457 /* No challenges required. Strange... */ 458 return GNUNET_OK; 459 } 460 461 { 462 const char *challenge_ids_header; 463 464 challenge_ids_header 465 = MHD_lookup_connection_value (hc->connection, 466 MHD_HEADER_KIND, 467 "Taler-Challenge-Ids"); 468 num_provided_challenges = 0; 469 if (NULL != challenge_ids_header) 470 { 471 challenge_ids_copy = GNUNET_strdup (challenge_ids_header); 472 473 for (char *token = strtok (challenge_ids_copy, 474 ","); 475 NULL != token; 476 token = strtok (NULL, 477 ",")) 478 { 479 if (num_provided_challenges >= MAX_CHALLENGES) 480 { 481 GNUNET_break_op (0); 482 GNUNET_free (challenge_ids_copy); 483 return (MHD_NO == 484 TALER_MHD_reply_with_error ( 485 hc->connection, 486 MHD_HTTP_BAD_REQUEST, 487 TALER_EC_GENERIC_HTTP_HEADERS_MALFORMED, 488 "Taler-Challenge-Ids")) 489 ? GNUNET_SYSERR 490 : GNUNET_NO; 491 } 492 challenge_ids[num_provided_challenges] = token; 493 num_provided_challenges++; 494 } 495 } 496 } 497 498 /* Check provided challenges against requirements */ 499 for (size_t i = 0; i < num_provided_challenges; i++) 500 { 501 bool solved; 502 enum TALER_MERCHANT_MFA_Channel channel; 503 char *target_address; 504 uint32_t retry_counter; 505 506 ret = mfa_challenge_check (hc, 507 op, 508 challenge_ids[i], 509 &solved, 510 &channel, 511 &target_address, 512 &retry_counter); 513 if (GNUNET_OK != ret) 514 goto cleanup; 515 for (size_t j = 0; j < num_challenges; j++) 516 { 517 if ( (challenges[j].channel == channel) && 518 (NULL == challenges[j].challenge_id) && 519 (NULL != target_address /* just to be sure */) && 520 (0 == strcmp (target_address, 521 challenges[j].required_address) ) ) 522 { 523 challenges[j].solved 524 = solved; 525 challenges[j].challenge_id 526 = GNUNET_strdup (challenge_ids[i]); 527 if ( (! solved) && 528 (0 == retry_counter) ) 529 { 530 /* can't be solved anymore! */ 531 challenges[i].solvable = false; 532 } 533 break; 534 } 535 } 536 GNUNET_free (target_address); 537 } 538 539 { 540 struct GNUNET_TIME_Absolute expiration_date 541 = GNUNET_TIME_relative_to_absolute (CHALLENGE_LIFETIME); 542 543 /* Start new challenges for unsolved requirements */ 544 for (size_t i = 0; i < num_challenges; i++) 545 { 546 if (NULL == challenges[i].challenge_id) 547 { 548 GNUNET_assert (! challenges[i].solved); 549 GNUNET_assert (challenges[i].solvable); 550 ret = mfa_challenge_start (hc, 551 op, 552 challenges[i].channel, 553 expiration_date, 554 challenges[i].required_address, 555 &challenges[i].challenge_id); 556 if (GNUNET_OK != ret) 557 goto cleanup; 558 } 559 } 560 } 561 562 { 563 bool all_solved = true; 564 bool any_solved = false; 565 bool solvable = true; 566 567 for (size_t i = 0; i < num_challenges; i++) 568 { 569 if (challenges[i].solved) 570 { 571 any_solved = true; 572 } 573 else 574 { 575 all_solved = false; 576 if (combi_and && 577 (! challenges[i].solvable) ) 578 solvable = false; 579 } 580 } 581 582 if ( (combi_and && all_solved) || 583 (! combi_and && any_solved) ) 584 { 585 /* Authorization successful */ 586 ret = GNUNET_OK; 587 goto cleanup; 588 } 589 if (! solvable) 590 { 591 ret = (MHD_NO == 592 TALER_MHD_reply_with_error ( 593 hc->connection, 594 MHD_HTTP_FORBIDDEN, 595 TALER_EC_MERCHANT_MFA_FORBIDDEN, 596 GNUNET_TIME_relative2s (CHALLENGE_LIFETIME, 597 false))) 598 ? GNUNET_SYSERR 599 : GNUNET_NO; 600 goto cleanup; 601 } 602 } 603 604 /* Return challenges to client */ 605 { 606 json_t *jchallenges; 607 608 jchallenges = json_array (); 609 GNUNET_assert (NULL != jchallenges); 610 for (size_t i = 0; i<num_challenges; i++) 611 { 612 const struct Challenge *c = &challenges[i]; 613 json_t *jc; 614 char *hint; 615 616 hint = get_hint (c->channel, 617 c->required_address); 618 619 jc = GNUNET_JSON_PACK ( 620 GNUNET_JSON_pack_string ("tan_info", 621 hint), 622 GNUNET_JSON_pack_string ("tan_channel", 623 TALER_MERCHANT_MFA_channel_to_string ( 624 c->channel)), 625 GNUNET_JSON_pack_string ("challenge_id", 626 c->challenge_id)); 627 GNUNET_free (hint); 628 GNUNET_assert (0 == 629 json_array_append_new ( 630 jchallenges, 631 jc)); 632 } 633 ret = (MHD_NO == 634 TALER_MHD_REPLY_JSON_PACK ( 635 hc->connection, 636 MHD_HTTP_ACCEPTED, 637 GNUNET_JSON_pack_bool ("combi_and", 638 combi_and), 639 GNUNET_JSON_pack_array_steal ("challenges", 640 jchallenges))) 641 ? GNUNET_SYSERR 642 : GNUNET_NO; 643 } 644 645 cleanup: 646 for (size_t i = 0; i < num_challenges; i++) 647 GNUNET_free (challenges[i].challenge_id); 648 GNUNET_free (challenge_ids_copy); 649 return ret; 650 } 651 652 653 enum GNUNET_GenericReturnValue 654 TMH_mfa_check_simple ( 655 struct TMH_HandlerContext *hc, 656 enum TALER_MERCHANT_MFA_CriticalOperation op, 657 struct TMH_MerchantInstance *mi) 658 { 659 enum GNUNET_GenericReturnValue ret; 660 bool have_sms = (NULL != mi->settings.phone) && 661 (NULL != TMH_helper_sms) && 662 (mi->settings.phone_validated); 663 bool have_email = (NULL != mi->settings.email) && 664 (NULL != TMH_helper_email) && 665 (mi->settings.email_validated); 666 667 /* Note: we check for 'validated' above, but in theory 668 we could also use unvalidated for this operation. 669 That's a policy-decision we may want to revise, 670 but probably need to look at the global threat model to 671 make sure alternative configurations are still sane. */ 672 if (have_email) 673 { 674 ret = TMH_mfa_challenges_do (hc, 675 op, 676 false, 677 TALER_MERCHANT_MFA_CHANNEL_EMAIL, 678 mi->settings.email, 679 have_sms 680 ? TALER_MERCHANT_MFA_CHANNEL_SMS 681 : TALER_MERCHANT_MFA_CHANNEL_NONE, 682 mi->settings.phone, 683 TALER_MERCHANT_MFA_CHANNEL_NONE); 684 } 685 else if (have_sms) 686 { 687 ret = TMH_mfa_challenges_do (hc, 688 op, 689 false, 690 TALER_MERCHANT_MFA_CHANNEL_SMS, 691 mi->settings.phone, 692 TALER_MERCHANT_MFA_CHANNEL_NONE); 693 } 694 else 695 { 696 GNUNET_log (GNUNET_ERROR_TYPE_INFO, 697 "No MFA possible, skipping 2-FA\n"); 698 ret = GNUNET_OK; 699 } 700 return ret; 701 }