diff --git a/backend/Makefile b/backend/Makefile index 6a3ef39..e664d83 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -7,6 +7,6 @@ build: cd $(BUILD_DIR) && cmake --build . check: build - cd $(BUILD_DIR) && ./test/pdl_test + cd $(BUILD_DIR) && ./tests/pdl_test .PHONY: all build run check \ No newline at end of file diff --git a/backend/src/cryptography.cpp b/backend/src/cryptography.cpp index b8551c0..48cf9d3 100644 --- a/backend/src/cryptography.cpp +++ b/backend/src/cryptography.cpp @@ -3,7 +3,7 @@ namespace cryptography { - std::string hashAndEncryptPassword(const std::string &password, unsigned char *b) + std::string hashAndEncryptPassword(const std::string &password, unsigned char *b, size_t offset) { // hash password to point unsigned char hash[crypto_core_ristretto255_HASHBYTES]; @@ -12,28 +12,30 @@ namespace cryptography crypto_core_ristretto255_from_hash(point, hash); // encrypt password - unsigned char encryptedPassword[crypto_core_ristretto255_BYTES]; - crypto_scalarmult_ristretto255(encryptedPassword, b, point); - return std::string(encryptedPassword, encryptedPassword + crypto_core_ristretto255_BYTES); + unsigned char encrypted_password[crypto_core_ristretto255_BYTES + offset]; + crypto_scalarmult_ristretto255(encrypted_password + offset, b, point); + memcpy(encrypted_password, point, offset); + + return std::string(encrypted_password, encrypted_password + crypto_core_ristretto255_BYTES + offset); } - std::string encryptPassword(const std::string &password, unsigned char *b) + std::string encryptPassword(const std::string &password, unsigned char *b, size_t offset) { - // encrypt password - const unsigned char *data = (const unsigned char *)password.data(); - unsigned char encryptedPassword[crypto_core_ristretto255_BYTES]; - crypto_scalarmult_ristretto255(encryptedPassword, b, data); - return std::string(encryptedPassword, encryptedPassword + crypto_core_ristretto255_BYTES); + std::string raw_password = password.substr(offset, crypto_core_ristretto255_BYTES); + const unsigned char *data = (const unsigned char *)raw_password.data(); + unsigned char encrypted_password[crypto_core_ristretto255_BYTES]; + crypto_scalarmult_ristretto255(encrypted_password, b, data); + return std::string(encrypted_password, encrypted_password + crypto_core_ristretto255_BYTES); } - std::vector encrypt(const std::unordered_set &passwords, unsigned char *b) + std::vector encrypt(const std::unordered_set &passwords, unsigned char *b, size_t offset) { std::vector result; result.reserve(passwords.size()); for (const auto &password : passwords) { - result.push_back(hashAndEncryptPassword(password, b)); + result.push_back(hashAndEncryptPassword(password, b, offset)); } return result; diff --git a/backend/src/cryptography.hpp b/backend/src/cryptography.hpp index 06933cc..32a6f1d 100644 --- a/backend/src/cryptography.hpp +++ b/backend/src/cryptography.hpp @@ -1,7 +1,11 @@ #include #include +#include #include +#ifndef CRYPTOGRAPHY_H +#define CRYPTOGRAPHY_H + namespace cryptography { @@ -12,9 +16,10 @@ namespace cryptography * * @param password the password to encrypt * @param b the secret key + * @param offset the number of bytes to leak * @return std::string the encrypted password */ - std::string hashAndEncryptPassword(const std::string &password, unsigned char *b); + std::string hashAndEncryptPassword(const std::string &password, unsigned char *b, size_t offset = 0); /** * @brief Encrypt the provided password using secret key b. @@ -23,17 +28,21 @@ namespace cryptography * * @param password the password to encrypt * @param b the secret key + * @param offset the number of bytes to leak * @return std::string the encrypted password */ - std::string encryptPassword(const std::string &password, unsigned char *b); + std::string encryptPassword(const std::string &password, unsigned char *b, size_t offset = 0); /** * @brief Encrypt the provided set of passwords using secret key b. * * @param passwords the set of passwords to encrypt * @param b the secret key + * @param offset the number of bytes to leak * @return std::vector the encrypted passwords */ std::vector encrypt(const std::unordered_set &passwords, - unsigned char *b); -} \ No newline at end of file + unsigned char *b, size_t offset = 0); +} + +#endif \ No newline at end of file diff --git a/backend/src/main.cpp b/backend/src/main.cpp index a59d8d8..21d639e 100644 --- a/backend/src/main.cpp +++ b/backend/src/main.cpp @@ -43,6 +43,10 @@ int main(int argc, char *argv[]) } database::Database db = database::Database(argv[1], build); + // TODO: store this data in server because same file with different offset will have different passwords + const size_t offset = 1; + spdlog::info("Password offset: {}", offset); + if (build) { // create password table @@ -58,7 +62,7 @@ int main(int argc, char *argv[]) crypto_core_ristretto255_scalar_random(b); // 2. encrypt each password with b (and hash to point) - std::vector encrypted_passwords = cryptography::encrypt(passwords, b); + std::vector encrypted_passwords = cryptography::encrypt(passwords, b, offset); // 3. insert into database for (const auto &password : encrypted_passwords) @@ -90,7 +94,6 @@ int main(int argc, char *argv[]) throw std::invalid_argument("Passwords and/or secret key table does not exist. Use --build to create a new database"); } } - // Enable CORS crow::App app; @@ -101,7 +104,7 @@ int main(int argc, char *argv[]) // initialize endpoints server::root(app); - server::breachedPasswords(app, db); + server::breachedPasswords(app, db, offset); // set the port, set the app to run on multiple threads, and run the app app.port(18080).multithreaded().run(); diff --git a/backend/src/password.cpp b/backend/src/password.cpp index 19208d0..4a5c01f 100644 --- a/backend/src/password.cpp +++ b/backend/src/password.cpp @@ -1,4 +1,3 @@ -#include #include "password.hpp" namespace password diff --git a/backend/src/password.hpp b/backend/src/password.hpp index ea77bc7..0e6bc0b 100644 --- a/backend/src/password.hpp +++ b/backend/src/password.hpp @@ -1,4 +1,5 @@ #include +#include #include #ifndef PASSWORDS_H diff --git a/backend/src/server.cpp b/backend/src/server.cpp index 3c561d3..e07befc 100644 --- a/backend/src/server.cpp +++ b/backend/src/server.cpp @@ -1,5 +1,6 @@ #include "server.hpp" #include "sqlite3.h" +#include "spdlog/spdlog.h" namespace server { void root(crow::App &app) @@ -12,15 +13,16 @@ namespace server return response; }); } - void breachedPasswords(crow::App &app, database::Database &db) + void breachedPasswords(crow::App &app, database::Database &db, size_t offset) { CROW_ROUTE(app, "/breachedPasswords") - .methods("POST"_method)([&db](const crow::request &req) + .methods("POST"_method)([&db, offset](const crow::request &req) { crow::json::wvalue response; std::string user_password = req.body.data(); if (user_password.empty()) { + spdlog::error("Empty user password"); response["status"] = "error"; return response; } @@ -40,7 +42,7 @@ namespace server unsigned char *b = (unsigned char *)decoded_b.data(); // encrypt user password - std::string encrypted_password = cryptography::encryptPassword(crow::utility::base64decode(user_password, user_password.size()), b); + std::string encrypted_password = cryptography::encryptPassword(crow::utility::base64decode(user_password, user_password.size()), b, offset); response["status"] = "success"; response["userPassword"] = crow::utility::base64encode(encrypted_password, encrypted_password.size()); response["breachedPasswords"] = breached_passwords; diff --git a/backend/src/server.hpp b/backend/src/server.hpp index 6cf1ffc..1e6fad8 100644 --- a/backend/src/server.hpp +++ b/backend/src/server.hpp @@ -23,7 +23,8 @@ namespace server * * @param app The crow server. * @param db The database of all breached passwords. + * @param offset The number of bytes to leak. */ - void breachedPasswords(crow::App &app, database::Database &db); + void breachedPasswords(crow::App &app, database::Database &db, size_t offset = 0); } #endif // SERVER_H \ No newline at end of file diff --git a/backend/tests/CMakeLists.txt b/backend/tests/CMakeLists.txt index ab9f27b..33095c0 100644 --- a/backend/tests/CMakeLists.txt +++ b/backend/tests/CMakeLists.txt @@ -1,6 +1,7 @@ find_package(Catch2 REQUIRED) find_package(nlohmann_json 3.11.3 REQUIRED) find_package(SQLite3 REQUIRED) +find_package(spdlog REQUIRED) set (TEST_SOURCE password.cpp @@ -11,6 +12,6 @@ set (TEST_SOURCE add_executable(pdl_test ${TEST_SOURCE}) -target_link_libraries(pdl_test src Catch2::Catch2WithMain nlohmann_json::nlohmann_json SQLite::SQLite3) +target_link_libraries(pdl_test src Catch2::Catch2WithMain nlohmann_json::nlohmann_json SQLite::SQLite3 spdlog::spdlog) target_include_directories(pdl_test PUBLIC ${CMAKE_SOURCE_DIR}/src) \ No newline at end of file diff --git a/backend/tests/cryptography.cpp b/backend/tests/cryptography.cpp index f2f6d9c..c62d5e3 100644 --- a/backend/tests/cryptography.cpp +++ b/backend/tests/cryptography.cpp @@ -5,30 +5,55 @@ TEST_CASE("Test hashAndEncryptPassword") { - for (int i = 0; i < 30; ++i) + // generate constants + const std::string password = "TestPass1&"; + unsigned char b[crypto_core_ristretto255_SCALARBYTES]; + crypto_core_ristretto255_scalar_random(b); + unsigned char inverse[crypto_core_ristretto255_SCALARBYTES]; + crypto_core_ristretto255_scalar_invert(inverse, b); + + // compute expected value for password after computation + unsigned char expected_hash[crypto_core_ristretto255_HASHBYTES]; + crypto_generichash(expected_hash, sizeof expected_hash, (const unsigned char *)password.data(), password.length(), NULL, 0); + unsigned char expected_point[crypto_core_ristretto255_BYTES]; + crypto_core_ristretto255_from_hash(expected_point, expected_hash); + + SECTION("without leaked byte") { - // generate constants - unsigned char b[crypto_core_ristretto255_SCALARBYTES]; - crypto_core_ristretto255_scalar_random(b); - unsigned char inverse[crypto_core_ristretto255_SCALARBYTES]; - crypto_core_ristretto255_scalar_invert(inverse, b); - const std::string password = "TestPass1&"; - - // encrypt password - std::string encryptedPasswordStr = cryptography::hashAndEncryptPassword(password, b); - unsigned char encryptedPassword[crypto_core_ristretto255_BYTES]; - memcpy(encryptedPassword, encryptedPasswordStr.data(), crypto_core_ristretto255_BYTES); - - // unencrypt the password with the inverse of b - unsigned char decryptedPassword[crypto_core_ristretto255_BYTES]; - crypto_scalarmult_ristretto255(decryptedPassword, inverse, encryptedPassword); - - // compute expected value - unsigned char expectedHash[crypto_core_ristretto255_HASHBYTES]; - crypto_generichash(expectedHash, sizeof expectedHash, (const unsigned char *)password.data(), password.length(), NULL, 0); - unsigned char expectedPoint[crypto_core_ristretto255_BYTES]; - crypto_core_ristretto255_from_hash(expectedPoint, expectedHash); - - CHECK(std::memcmp(expectedPoint, decryptedPassword, crypto_core_ristretto255_BYTES) == 0); + for (int i = 0; i < 30; ++i) + { + // encrypt password + std::string expected_password_str = cryptography::hashAndEncryptPassword(password, b); + unsigned char encrypted_password[crypto_core_ristretto255_BYTES]; + memcpy(encrypted_password, expected_password_str.data(), crypto_core_ristretto255_BYTES); + + // unencrypt the password with the inverse of b + unsigned char decrypted_password[crypto_core_ristretto255_BYTES]; + int result = crypto_scalarmult_ristretto255(decrypted_password, inverse, encrypted_password); + REQUIRE(result == 0); + + CHECK(std::memcmp(expected_point, decrypted_password, crypto_core_ristretto255_BYTES) == 0); + } + } + + SECTION("with 1 leaked byte") + { + for (int i = 0; i < 30; ++i) + { + const size_t offset = 1; + // encrypt password + std::string encrypted_password_str = cryptography::hashAndEncryptPassword(password, b, offset); + unsigned char encrypted_password[crypto_core_ristretto255_BYTES + offset]; + memcpy(encrypted_password, encrypted_password_str.data(), crypto_core_ristretto255_BYTES + offset); + + // decrypt password + unsigned char decrypted_password[crypto_core_ristretto255_BYTES + offset]; + int result = crypto_scalarmult_ristretto255(decrypted_password + offset, inverse, encrypted_password + offset); + REQUIRE(result == 0); + memcpy(decrypted_password, encrypted_password, offset); + + CHECK(std::memcmp(expected_point, decrypted_password + offset, crypto_core_ristretto255_BYTES) == 0); + CHECK(expected_point[0] == decrypted_password[0]); + } } } \ No newline at end of file diff --git a/backend/tests/server.cpp b/backend/tests/server.cpp index 313adea..d863295 100644 --- a/backend/tests/server.cpp +++ b/backend/tests/server.cpp @@ -5,6 +5,7 @@ #include "cryptography.hpp" #include "sodium.h" #include +#include TEST_CASE("Test endpoints using handler") { @@ -25,7 +26,7 @@ TEST_CASE("Test endpoints using handler") REQUIRE(file.is_open()); // create the database, with tables passwords and secret - database::Database db = database::Database(path); + database::Database db = database::Database(path, true); REQUIRE_NOTHROW(db.execute("CREATE TABLE passwords (password TEXT);")); REQUIRE_NOTHROW(db.execute("CREATE TABLE secret (key TEXT);")); @@ -37,7 +38,8 @@ TEST_CASE("Test endpoints using handler") crypto_core_ristretto255_scalar_random(b); // 2. encrypt each password with b (and hash to point) - std::vector encrypted_passwords = cryptography::encrypt(passwords, b); + const size_t offset = 1; + std::vector encrypted_passwords = cryptography::encrypt(passwords, b, offset); // 3. insert into database for (const auto &password : encrypted_passwords) @@ -49,7 +51,7 @@ TEST_CASE("Test endpoints using handler") // encode key b and insert into database db.execute("INSERT INTO secret (key) VALUES ('" + crow::utility::base64encode(std::string(reinterpret_cast(b), crypto_core_ristretto255_SCALARBYTES), crypto_core_ristretto255_SCALARBYTES) + "');"); - server::breachedPasswords(app, db); + server::breachedPasswords(app, db, offset); // check that all the route handlers were created app.validate(); @@ -83,14 +85,31 @@ TEST_CASE("Test endpoints using handler") CHECK(breached_passwords.size() == 3); for (const auto &breached_password : breached_passwords) { - // encoded password should end with '=' - CHECK(breached_password.back() == '='); + // offset determines the amount of padding appended to the end of the password + switch (offset % 3) + { + case 0: + // password is padded with single '=' + CHECK(breached_password[breached_password.size() - 1] == '='); + CHECK(breached_password[breached_password.size() - 2] != '='); + break; + case 1: + // password is not padded + CHECK(breached_password[breached_password.size() - 1] != '='); + CHECK(breached_password[breached_password.size() - 2] != '='); + break; + case 2: + // password is padded with two '=' + CHECK(breached_password[breached_password.size() - 1] == '='); + CHECK(breached_password[breached_password.size() - 2] == '='); + break; + } } - std::string user_password = body["userPassword"]; CHECK(!user_password.empty()); CHECK(user_password.back() == '='); CHECK(res.code == 200); } -} + REQUIRE(std::remove(path.c_str()) == 0); +} \ No newline at end of file diff --git a/frontend/app/page.tsx b/frontend/app/page.tsx index 620b15d..f537cee 100644 --- a/frontend/app/page.tsx +++ b/frontend/app/page.tsx @@ -69,7 +69,7 @@ export default function SignUp() { ) => { event.preventDefault(); setIsLoading(true); - const response = await checkSecurity(password); // makes an API call with the user's password + const response = await checkSecurity(password, 1); // makes an API call with the user's password setIsLoading(false); if (response.status == "success") { diff --git a/frontend/app/psi.ts b/frontend/app/psi.ts index 2a3a80f..118924d 100644 --- a/frontend/app/psi.ts +++ b/frontend/app/psi.ts @@ -8,6 +8,11 @@ type ServerResponse = { breachedPasswords: string[]; }; +/** + * Hash a password to a point on an elliptic curve + * @param input the password to be hashed to a point on an elliptic curve + * @returns the point on an elliptic curve + */ export function hashToPoint(input: string): Uint8Array { const hash = sodium.crypto_generichash( sodium.crypto_core_ristretto255_HASHBYTES, @@ -17,11 +22,12 @@ export function hashToPoint(input: string): Uint8Array { } /** - * - * @param input the string to be encrypted - * @returns input with a secret key applied and the key's inverse + * Apply a random seed to the password and returns the encrypted password and the inverse of the seed + * @param input the password to be encrypted + * @param offset the number of bytes to be leaked + * @returns the encrypted password and the inverse of the seed */ -export function applySeed(input: string): [Uint8Array, Uint8Array] { +export function applySeed(input: string, offset = 0): [Uint8Array, Uint8Array] { // generate random seed const seed = sodium.crypto_core_ristretto255_scalar_random(); // get seed inverse @@ -29,19 +35,24 @@ export function applySeed(input: string): [Uint8Array, Uint8Array] { sodium.crypto_core_ristretto255_scalar_invert(seed); const point = hashToPoint(input); // apply seed + const leakedBytes = point.subarray(0, offset); const seededPassword = sodium.crypto_scalarmult_ristretto255( seed, point ); - return [seededPassword, seedInverse]; + const leakedSeededPassword = new Uint8Array(offset + seededPassword.length); + leakedSeededPassword.set(leakedBytes, 0); + leakedSeededPassword.set(seededPassword, offset); + return [leakedSeededPassword, seedInverse]; } function computeIntersection( data: ServerResponse, - aInverse: Uint8Array + aInverse: Uint8Array, + offset = 0 ): boolean { const userPassword = base64.parse(data.userPassword); - const breachedPasswords = new Set((data.breachedPasswords).map(function (element) { return base64.parse(element).join(""); })); + const breachedPasswords = new Set((data.breachedPasswords).map(function (element) { return base64.parse(element).subarray(offset).join(""); })); // Client phase 2 - applies inverse seed A to (user password)^ab // so now ((user password)^ab)^-a = (user password)^b @@ -55,9 +66,9 @@ function computeIntersection( } // Make API call to server to check if password was found in breached dataset -export async function checkSecurity(password: string) { +export async function checkSecurity(password: string, offset = 0) { try { - const [seededPassword, keyInverse] = applySeed(password); + const [seededPassword, keyInverse] = applySeed(password, offset); const response = await fetch( "http://localhost:18080/breachedPasswords", @@ -72,7 +83,7 @@ export async function checkSecurity(password: string) { } ); const data = await response.json(); - if (computeIntersection(data, keyInverse)) { + if (computeIntersection(data, keyInverse, offset)) { return { status: "fail" }; } else { return { status: "success" }; diff --git a/frontend/package.json b/frontend/package.json index 9bab936..e08b82b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -15,6 +15,7 @@ "@emotion/styled": "^11.11.0", "@mui/icons-material": "^5.14.12", "@mui/material": "^5.14.12", + "encoding": "^0.1.13", "libsodium-wrappers-sumo": "^0.7.13", "next": "^13.5.4", "node-fetch": "2", diff --git a/frontend/tests/psi.test.ts b/frontend/tests/psi.test.ts index 0afca06..256a22d 100644 --- a/frontend/tests/psi.test.ts +++ b/frontend/tests/psi.test.ts @@ -2,40 +2,40 @@ import { applySeed, hashToPoint, checkSecurity } from "../app/psi"; const sodium = require("libsodium-wrappers-sumo"); beforeAll(async () => { - await sodium.ready; + await sodium.ready; }); describe("testing applySeed", () => { - test("applying secret key should be same as original value", () => { - const password = "TestPass1&"; + test("applying secret key should be same as original value", () => { + const password = "TestPass1&"; - // hash the password - const hashedPassword = hashToPoint(password); - // apply the seed - const [seededPassword, seedInverse] = applySeed(password); - // apply the inverse to the seeded password - const inversedSeededPassword = - sodium.crypto_scalarmult_ristretto255( - seedInverse, - seededPassword - ); + // hash the password + const hashedPassword = hashToPoint(password); + // apply the seed + const [seededPassword, seedInverse] = applySeed(password); + // apply the inverse to the seeded password + const inversedSeededPassword = + sodium.crypto_scalarmult_ristretto255( + seedInverse, + seededPassword + ); - // check that the seeded+inversed password is the same as the hashed password - expect( - Buffer.compare(inversedSeededPassword, hashedPassword) - ).toBe(0); - }); + // check that the seeded+inversed password is the same as the hashed password + expect( + Buffer.compare(inversedSeededPassword, hashedPassword) + ).toBe(0); + }); }); describe("testing expected server response", () => { - test("sending breached password should return fail status", async () => { - const password = "TestPass1&"; - const response = await checkSecurity(password); - expect(response.status).toBe("fail"); - }); - test("sending non-breach password should return success status", async () => { - const password = "NiniIsTheBest!4"; - const response = await checkSecurity(password); - expect(response.status).toBe("success"); - }); + test("sending breached password should return fail status", async () => { + const password = "TestPass1&"; + const response = await checkSecurity(password, 1); + expect(response.status).toBe("fail"); + }); + test("sending non-breach password should return success status", async () => { + const password = "NiniIsTheBest!4"; + const response = await checkSecurity(password, 1); + expect(response.status).toBe("success"); + }); }); \ No newline at end of file diff --git a/frontend/yarn.lock b/frontend/yarn.lock index 126aa27..c40bddf 100644 --- a/frontend/yarn.lock +++ b/frontend/yarn.lock @@ -1846,6 +1846,13 @@ emoji-regex@^9.2.2: resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz" integrity sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg== +encoding@^0.1.13: + version "0.1.13" + resolved "https://registry.yarnpkg.com/encoding/-/encoding-0.1.13.tgz#56574afdd791f54a8e9b2785c0582a2d26210fa9" + integrity sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A== + dependencies: + iconv-lite "^0.6.2" + enhanced-resolve@^5.12.0: version "5.15.0" resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.15.0.tgz" @@ -2561,6 +2568,13 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== +iconv-lite@^0.6.2: + version "0.6.3" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + ignore@^5.2.0: version "5.2.4" resolved "https://registry.npmjs.org/ignore/-/ignore-5.2.4.tgz" @@ -4049,6 +4063,11 @@ safe-regex-test@^1.0.0: get-intrinsic "^1.1.3" is-regex "^1.1.4" +"safer-buffer@>= 2.1.2 < 3.0.0": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== + scheduler@^0.23.0: version "0.23.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.23.0.tgz"