diff --git a/.gitignore b/.gitignore index 5f68e322..058e4764 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ # VSCode jsconfig.json +.vscode/ # Xcode # diff --git a/.vscode/launch.json b/.vscode/launch.json deleted file mode 100644 index 6f8c45f8..00000000 --- a/.vscode/launch.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - // Use IntelliSense to learn about possible attributes. - // Hover to view descriptions of existing attributes. - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 - "version": "0.2.0", - "configurations": [ - { - "name": "example app", - "type": "radon-ide", - "request": "launch", - "appRoot": "./example", - "ios": { - "configuration": "Debug" - }, - "android": { - "buildType": "debug" - } - } - ] -} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb index 3cdd8307..1623ecaa 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/docs/implementation-coverage.md b/docs/implementation-coverage.md index 4e89d93d..9a187544 100644 --- a/docs/implementation-coverage.md +++ b/docs/implementation-coverage.md @@ -43,10 +43,10 @@ This document attempts to describe the implementation status of Crypto APIs/Inte * ❌ `ecdh.getPublicKey([encoding][, format])` * ❌ `ecdh.setPrivateKey(privateKey[, encoding])` * ❌ `ecdh.setPublicKey(publicKey[, encoding])` -* ❌ Class: `Hash` - * ❌ `hash.copy([options])` - * ❌ `hash.digest([encoding])` - * ❌ `hash.update(data[, inputEncoding])` +* ✅ Class: `Hash` + * ✅ `hash.copy([options])` + * ✅ `hash.digest([encoding])` + * ✅ `hash.update(data[, inputEncoding])` * ❌ Class: `Hmac` * ❌ `hmac.digest([encoding])` * ❌ `hmac.update(data[, inputEncoding])` @@ -101,7 +101,7 @@ This document attempts to describe the implementation status of Crypto APIs/Inte * ❌ `crypto.createDiffieHellman(primeLength[, generator])` * ❌ `crypto.createDiffieHellmanGroup(name)` * ❌ `crypto.createECDH(curveName)` - * ❌ `crypto.createHash(algorithm[, options])` + * ✅ `crypto.createHash(algorithm[, options])` * ❌ `crypto.createHmac(algorithm, key[, options])` * ❌ `crypto.createPrivateKey(key)` * ❌ `crypto.createPublicKey(key)` @@ -121,7 +121,7 @@ This document attempts to describe the implementation status of Crypto APIs/Inte * ❌ `crypto.getCurves()` * ❌ `crypto.getDiffieHellman(groupName)` * ❌ `crypto.getFips()` - * ❌ `crypto.getHashes()` + * ✅ `crypto.getHashes()` * ❌ `crypto.getRandomValues(typedArray)` * ❌ `crypto.hkdf(digest, ikm, salt, info, keylen, callback)` * ❌ `crypto.hkdfSync(digest, ikm, salt, info, keylen)` diff --git a/docs/test_suite_results.png b/docs/test_suite_results.png index a32659ed..6cf8b0e0 100644 Binary files a/docs/test_suite_results.png and b/docs/test_suite_results.png differ diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index a96e251e..932796d9 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -1940,7 +1940,7 @@ SPEC CHECKSUMS: fmt: 10c6e61f4be25dc963c36bd73fc7b1705fe975be glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a hermes-engine: 46f1ffbf0297f4298862068dd4c274d4ac17a1fd - NitroModules: 3a58d9bc70815a0d5de4476ed6a36eff05a6a0ae + NitroModules: c36d6f656038a56beb1b1bcab2d0252d71744013 OpenSSL-Universal: b60a3702c9fea8b3145549d421fdb018e53ab7b4 QuickCrypto: 11878b44cfc77fad2ea8f387a16e315841651305 RCT-Folly: 84578c8756030547307e4572ab1947de1685c599 diff --git a/example/src/hooks/useTestsList.ts b/example/src/hooks/useTestsList.ts index bbe542fa..73b09c77 100644 --- a/example/src/hooks/useTestsList.ts +++ b/example/src/hooks/useTestsList.ts @@ -2,6 +2,7 @@ import { useState, useCallback } from 'react'; import type { TestSuites } from '../types/tests'; import { TestsContext } from '../tests/util'; +import '../tests/hash/hash_tests'; import '../tests/ed25519/ed25519_tests'; import '../tests/pbkdf2/pbkdf2_tests'; import '../tests/random/random_tests'; diff --git a/example/src/tests/hash/hash_tests.ts b/example/src/tests/hash/hash_tests.ts new file mode 100644 index 00000000..60ead5fe --- /dev/null +++ b/example/src/tests/hash/hash_tests.ts @@ -0,0 +1,225 @@ +/** + * Tests are based on Node.js tests + * https://github.com/nodejs/node/blob/master/test/parallel/test-crypto-hash.js + */ + +import { Buffer } from '@craftzdog/react-native-buffer'; +import { + createHash, + getHashes, + type Encoding, +} from 'react-native-quick-crypto'; +import { expect } from 'chai'; +import { test } from '../util'; + +const SUITE = 'hash'; + +test(SUITE, 'createHash with valid algorithm', () => { + expect(() => { + createHash('sha256'); + }).to.not.throw(); +}); + +test(SUITE, 'createHash with invalid algorithm', () => { + expect(() => { + createHash('sha123'); + }).to.throw(/Unknown hash algorithm: sha123/); +}); + +// test hashing +const a0 = createHash('md5').update('Test123').digest('latin1'); +const a1 = createHash('sha1').update('Test123').digest('hex'); +const a2 = createHash('sha256').update('Test123').digest('base64'); +const a3 = createHash('sha512').update('Test123').digest(); // buffer +const a4 = createHash('sha1').update('Test123').digest('buffer'); + +test(SUITE, 'non stream - digest with latin1 argument', () => { + expect(a0).to.deep.equal( + 'h\u00ea\u00cb\u0097\u00d8o\fF!\u00fa+\u000e\u0017\u00ca\u00bd\u008c', + ); +}); +test(SUITE, 'non stream - digest with hex argument', () => { + expect(a1).to.deep.equal('8308651804facb7b9af8ffc53a33a22d6a1c8ac2'); +}); +test(SUITE, 'non stream - digest with base64 argument', () => { + expect(a2).to.deep.equal('2bX1jws4GYKTlxhloUB09Z66PoJZW+y+hq5R8dnx9l4='); +}); +test(SUITE, 'non stream - digest with buffer argument', () => { + expect(a4).to.deep.equal( + Buffer.from('8308651804facb7b9af8ffc53a33a22d6a1c8ac2', 'hex'), + ); +}); +test(SUITE, 'non stream - digest without argument defaults to buffer', () => { + expect(a3).to.deep.equal( + Buffer.from( + "\u00c1(4\u00f1\u0003\u001fd\u0097!O'\u00d4C/&Qz\u00d4" + + '\u0094\u0015l\u00b8\u008dQ+\u00db\u001d\u00c4\u00b5}\u00b2' + + '\u00d6\u0092\u00a3\u00df\u00a2i\u00a1\u009b\n\n*\u000f' + + '\u00d7\u00d6\u00a2\u00a8\u0085\u00e3<\u0083\u009c\u0093' + + "\u00c2\u0006\u00da0\u00a1\u00879(G\u00ed'", + 'latin1', + ), + ); +}); + +test(SUITE, 'non stream - multiple updates to same hash', () => { + const h1 = createHash('sha1').update('Test').update('123').digest('hex'); + expect(h1).to.deep.equal(a1); +}); + +// stream interface +let a5 = createHash('sha512'); +a5.end('Test123'); +a5 = a5.read(); +let a6 = createHash('sha512'); +a6.write('Te'); +a6.write('st'); +a6.write('123'); +a6.end(); +a6 = a6.read(); +let a7 = createHash('sha512'); +a7.end(); +a7 = a7.read(); +let a8 = createHash('sha512'); +a8.write(''); +a8.end(); +a8 = a8.read(); + +test(SUITE, 'stream - should produce the same output as non-stream', () => { + expect(a5).to.deep.equal(a3); + expect(a6).to.deep.equal(a3); +}); +test(SUITE, 'stream - empty', () => { + expect(a7).to.deep.equal(a8); + expect(a7).not.to.deep.equal(undefined); + expect(a8).not.to.deep.equal(undefined); +}); + +test(SUITE, 'copy - should create identical hash state', () => { + const hash1 = createHash('sha256').update('Test123'); + const hash2 = hash1.copy(); + expect(hash1.digest('hex')).to.deep.equal(hash2.digest('hex')); +}); + +test(SUITE, 'copy - calculate a rolling hash', () => { + const hash = createHash('sha256'); + hash.update('one'); + expect(hash.copy().digest('hex')).to.deep.equal( + '7692c3ad3540bb803c020b3aee66cd8887123234ea0c6e7143c0add73ff431ed', + ); + hash.update('two'); + expect(hash.copy().digest('hex')).to.deep.equal( + '25b6746d5172ed6352966a013d93ac846e1110d5a25e8f183b5931f4688842a1', + ); + hash.update('three'); + expect(hash.copy().digest('hex')).to.deep.equal( + '4592092e1061c7ea85af2aed194621cc17a2762bae33a79bf8ce33fd0168b801', + ); +}); + +test(SUITE, 'getHashes - should return array of supported algorithms', () => { + const algorithms = getHashes(); + const expectedAlgorithms = [ + 'BLAKE2B-512', + 'BLAKE2S-256', + 'KECCAK-224', + 'KECCAK-256', + 'KECCAK-384', + 'KECCAK-512', + 'KECCAK-KMAC-128', + 'KECCAK-KMAC-256', + 'MD5', + 'MD5-SHA1', + 'NULL', + 'RIPEMD-160', + 'SHA1', + 'SHA2-224', + 'SHA2-256', + 'SHA2-256/192', + 'SHA2-384', + 'SHA2-512', + 'SHA2-512/224', + 'SHA2-512/256', + 'SHA3-224', + 'SHA3-256', + 'SHA3-384', + 'SHA3-512', + 'SHAKE-128', + 'SHAKE-256', + 'SM3', + ]; + expect(algorithms).to.be.an('array'); + expect(algorithms.sort()).to.deep.equal(expectedAlgorithms.sort()); +}); + +// errors +test(SUITE, 'digest - segfault', () => { + const hash = createHash('sha256'); + expect(() => { + hash.digest({ + toString: () => { + throw new Error('segfault'); + }, + } as unknown as Encoding); + }).to.throw(/Value is an object/); +}); +test(SUITE, 'update - calling update without argument', () => { + const hash = createHash('sha256'); + expect(() => { + // @ts-expect-error calling update without argument + hash.update(); + }).to.throw(/input could not be converted/); +}); +test(SUITE, 'digest - calling update after digest', () => { + const hash = createHash('sha256'); + hash.digest(); + expect(() => hash.update('test')).to.throw(/Failed to update/); +}); + +// outputLength option +test(SUITE, 'output length = 0', () => { + const hash = createHash('SHAKE-256', { outputLength: 0 }); + expect(hash.digest('hex')).to.deep.equal(''); +}); +test(SUITE, 'output length = 5', () => { + expect( + createHash('shake128', { outputLength: 5 }).digest('hex'), + ).to.deep.equal('7f9c2ba4e8'); +}); +test(SUITE, 'output length with copy', () => { + const hash = createHash('shake128', { outputLength: 5 }); + const copy = hash.copy({ outputLength: 0 }); + expect(copy.digest('hex')).to.deep.equal(''); + expect(hash.digest('hex')).to.deep.equal('7f9c2ba4e8'); +}); +test(SUITE, 'large output length', () => { + const largeHash = createHash('shake128', { outputLength: 128 }).digest('hex'); + expect(largeHash.length).to.equal(2 * 128); + expect(largeHash.slice(0, 32)).to.deep.equal( + '7f9c2ba4e88f827d616045507605853e', + ); + expect(largeHash.slice(2 * 128 - 32, 2 * 128)).to.deep.equal( + 'df9a04302e10c8bc1cbf1a0b3a5120ea', + ); +}); +test(SUITE, 'super long hash', () => { + const superLongHash = createHash('shake256', { + outputLength: 1024 * 1024, + }) + .update('The message is shorter than the hash!') + .digest('hex'); + expect(superLongHash.length).to.equal(2 * 1024 * 1024); + expect(superLongHash.slice(0, 32)).to.deep.equal( + 'a2a28dbc49cfd6e5d6ceea3d03e77748', + ); + expect( + superLongHash.slice(2 * 1024 * 1024 - 32, 2 * 1024 * 1024), + ).to.deep.equal('193414035ddba77bf7bba97981e656ec'); +}); +test(SUITE, 'unreasonable output length', () => { + expect(() => { + createHash('shake128', { outputLength: 1024 * 1024 * 1024 }).digest('hex'); + }).to.throw( + /Output length 1073741824 exceeds maximum allowed size of 16777216/, + ); +}); diff --git a/package.json b/package.json index aa408983..077bd008 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "@release-it/conventional-changelog": "^9.0.3", "release-it": "^17.10.0" }, - "packageManager": "bun@1.1.26", + "packageManager": "bun@1.2.0", "release-it": { "npm": { "publish": false diff --git a/packages/react-native-quick-crypto/android/CMakeLists.txt b/packages/react-native-quick-crypto/android/CMakeLists.txt index ac8da346..489c1d58 100644 --- a/packages/react-native-quick-crypto/android/CMakeLists.txt +++ b/packages/react-native-quick-crypto/android/CMakeLists.txt @@ -9,6 +9,7 @@ set(CMAKE_CXX_STANDARD 20) add_library( ${PACKAGE_NAME} SHARED src/main/cpp/cpp-adapter.cpp + ../cpp/hash/HybridHash.cpp ../cpp/ed25519/HybridEdKeyPair.cpp ../cpp/pbkdf2/HybridPbkdf2.cpp ../cpp/random/HybridRandom.cpp @@ -21,6 +22,7 @@ include(${CMAKE_SOURCE_DIR}/../nitrogen/generated/android/QuickCrypto+autolinkin # local includes include_directories( "src/main/cpp" + "../cpp/hash" "../cpp/ed25519" "../cpp/pbkdf2" "../cpp/random" diff --git a/packages/react-native-quick-crypto/cpp/hash/HybridHash.cpp b/packages/react-native-quick-crypto/cpp/hash/HybridHash.cpp new file mode 100644 index 00000000..4a5b386d --- /dev/null +++ b/packages/react-native-quick-crypto/cpp/hash/HybridHash.cpp @@ -0,0 +1,181 @@ +#include +#include +#include +#include +#include +#include +#include + +#include "HybridHash.hpp" + +namespace margelo::nitro::crypto { + +HybridHash::~HybridHash() +{ + if (ctx) { + EVP_MD_CTX_free(ctx); + ctx = nullptr; + } +} + +void +HybridHash::createHash(const std::string& hashAlgorithmArg, + const std::optional outputLengthArg) +{ + algorithm = hashAlgorithmArg; + outputLength = outputLengthArg; + + // Create hash context + ctx = EVP_MD_CTX_new(); + if (!ctx) { + throw std::runtime_error("Failed to create hash context: " + + std::to_string(ERR_get_error())); + } + + // Get the message digest by name + md = EVP_get_digestbyname(algorithm.c_str()); + if (!md) { + EVP_MD_CTX_free(ctx); + ctx = nullptr; + throw std::runtime_error("Unknown hash algorithm: " + algorithm); + } + + // Initialize the digest + if (EVP_DigestInit_ex(ctx, md, nullptr) != 1) { + EVP_MD_CTX_free(ctx); + ctx = nullptr; + throw std::runtime_error("Failed to initialize hash digest: " + + std::to_string(ERR_get_error())); + } +} + +void +HybridHash::update(const std::shared_ptr& data) +{ + if (!ctx) { + throw std::runtime_error("Hash context not initialized"); + } + + // Update the digest with the data + if (EVP_DigestUpdate(ctx, + reinterpret_cast(data->data()), + data->size()) != 1) { + throw std::runtime_error("Failed to update hash digest: " + + std::to_string(ERR_get_error())); + } +} + +std::shared_ptr +HybridHash::digest(const std::optional& encoding) +{ + if (!ctx) { + throw std::runtime_error("Hash context not initialized"); + } + + setParams(); + + // Get the default digest size + const size_t defaultLen = EVP_MD_CTX_size(ctx); + const size_t digestSize = + (outputLength.has_value()) ? static_cast(*outputLength) : defaultLen; + + if (digestSize < 0) { + throw std::runtime_error("Invalid digest size: " + + std::to_string(digestSize)); + } + + // Create a buffer for the hash output + uint8_t* hashBuffer = new uint8_t[digestSize]; + size_t hashLength = digestSize; + + // Finalize the digest + int ret; + if (digestSize == defaultLen) { + ret = EVP_DigestFinal_ex( + ctx, hashBuffer, reinterpret_cast(&hashLength)); + } else { + ret = EVP_DigestFinalXOF(ctx, hashBuffer, hashLength); + } + + if (ret != 1) { + delete[] hashBuffer; + throw std::runtime_error("Failed to finalize hash digest: " + + std::to_string(ERR_get_error())); + } + + return std::make_shared( + hashBuffer, hashLength, [=]() { delete[] hashBuffer; }); +} + +std::shared_ptr +HybridHash::copy(const std::optional outputLengthArg) +{ + if (!ctx) { + throw std::runtime_error("Hash context not initialized"); + } + + // Create a new context + EVP_MD_CTX* newCtx = EVP_MD_CTX_new(); + if (!newCtx) { + throw std::runtime_error("Failed to create new hash context: " + + std::to_string(ERR_get_error())); + } + + // Copy the existing context to the new one + if (EVP_MD_CTX_copy(newCtx, ctx) != 1) { + EVP_MD_CTX_free(newCtx); + throw std::runtime_error("Failed to copy hash context: " + + std::to_string(ERR_get_error())); + } + + return std::make_shared(newCtx, md, algorithm, outputLengthArg); +} + +std::vector +HybridHash::getSupportedHashAlgorithms() +{ + std::vector hashAlgorithms; + + EVP_MD_do_all_provided( + nullptr, + [](EVP_MD* md, void* arg) { + auto* algorithms = static_cast*>(arg); + const char* name = EVP_MD_get0_name(md); + if (name) { + algorithms->push_back(name); + } + }, + &hashAlgorithms); + + return hashAlgorithms; +} + +void +HybridHash::setParams() +{ + // Handle algorithm parameters (like XOF length for SHAKE) + if (outputLength.has_value()) { + uint32_t xoflen = outputLength.value(); + + // Add a reasonable maximum output length + const int MAX_OUTPUT_LENGTH = 16 * 1024 * 1024; // 16MB + if (xoflen > MAX_OUTPUT_LENGTH) { + throw std::runtime_error("Output length " + std::to_string(xoflen) + + " exceeds maximum allowed size of " + + std::to_string(MAX_OUTPUT_LENGTH)); + } + + OSSL_PARAM params[] = { OSSL_PARAM_construct_uint("xoflen", &xoflen), + OSSL_PARAM_END }; + + if (EVP_MD_CTX_set_params(ctx, params) != 1) { + EVP_MD_CTX_free(ctx); + ctx = nullptr; + throw std::runtime_error( + "Failed to set XOF length (outputLength) parameter: " + + std::to_string(ERR_get_error())); + } + } +} + +} // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/cpp/hash/HybridHash.hpp b/packages/react-native-quick-crypto/cpp/hash/HybridHash.hpp new file mode 100644 index 00000000..a2faa71b --- /dev/null +++ b/packages/react-native-quick-crypto/cpp/hash/HybridHash.hpp @@ -0,0 +1,57 @@ +#include +#include +#include +#include +#include +#include + +#include "HybridHashSpec.hpp" + +namespace margelo::nitro::crypto { + +using namespace facebook; + +class HybridHash : public HybridHashSpec +{ +public: + HybridHash() + : HybridObject(TAG) + { + } + HybridHash(EVP_MD_CTX* ctx, + const EVP_MD* md, + const std::string& algorithm, + const std::optional outputLength) + : HybridObject(TAG) + , ctx(ctx) + , md(md) + , algorithm(algorithm) + , outputLength(outputLength) + { + } + ~HybridHash(); + +public: + // Methods + void createHash(const std::string& algorithm, + const std::optional outputLength) override; + void update(const std::shared_ptr& data) override; + std::shared_ptr digest( + const std::optional& encoding = std::nullopt) override; + std::shared_ptr copy( + const std::optional outputLength) override; + std::vector getSupportedHashAlgorithms() override; + +private: + // Methods + void setParams(); + +private: + // Properties + EVP_MD_CTX* ctx = nullptr; + const EVP_MD* md = nullptr; + std::string algorithm = ""; + std::optional outputLength = std::nullopt; +}; + +} // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/nitro.json b/packages/react-native-quick-crypto/nitro.json index dfd36c51..a205a7e0 100644 --- a/packages/react-native-quick-crypto/nitro.json +++ b/packages/react-native-quick-crypto/nitro.json @@ -8,6 +8,7 @@ "androidCxxLibName": "QuickCrypto" }, "autolinking": { + "Hash": { "cpp": "HybridHash" }, "EdKeyPair": { "cpp": "HybridEdKeyPair" }, "Pbkdf2": { "cpp": "HybridPbkdf2" }, "Random": { "cpp": "HybridRandom" } diff --git a/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCrypto+autolinking.cmake b/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCrypto+autolinking.cmake index 96b2a4d7..a779838e 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCrypto+autolinking.cmake +++ b/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCrypto+autolinking.cmake @@ -28,6 +28,7 @@ target_sources( ../nitrogen/generated/android/QuickCryptoOnLoad.cpp # Shared Nitrogen C++ sources ../nitrogen/generated/shared/c++/HybridEdKeyPairSpec.cpp + ../nitrogen/generated/shared/c++/HybridHashSpec.cpp ../nitrogen/generated/shared/c++/HybridKeyObjectHandleSpec.cpp ../nitrogen/generated/shared/c++/HybridPbkdf2Spec.cpp ../nitrogen/generated/shared/c++/HybridRandomSpec.cpp diff --git a/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCryptoOnLoad.cpp b/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCryptoOnLoad.cpp index 75b895f5..e163fb62 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCryptoOnLoad.cpp +++ b/packages/react-native-quick-crypto/nitrogen/generated/android/QuickCryptoOnLoad.cpp @@ -15,6 +15,7 @@ #include #include +#include "HybridHash.hpp" #include "HybridEdKeyPair.hpp" #include "HybridPbkdf2.hpp" #include "HybridRandom.hpp" @@ -31,6 +32,15 @@ int initialize(JavaVM* vm) { // Register Nitro Hybrid Objects + HybridObjectRegistry::registerHybridObjectConstructor( + "Hash", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridHash\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); HybridObjectRegistry::registerHybridObjectConstructor( "EdKeyPair", []() -> std::shared_ptr { diff --git a/packages/react-native-quick-crypto/nitrogen/generated/ios/QuickCryptoAutolinking.mm b/packages/react-native-quick-crypto/nitrogen/generated/ios/QuickCryptoAutolinking.mm index 42e907fd..4bb042fe 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/ios/QuickCryptoAutolinking.mm +++ b/packages/react-native-quick-crypto/nitrogen/generated/ios/QuickCryptoAutolinking.mm @@ -10,6 +10,7 @@ #import +#include "HybridHash.hpp" #include "HybridEdKeyPair.hpp" #include "HybridPbkdf2.hpp" #include "HybridRandom.hpp" @@ -23,6 +24,15 @@ + (void) load { using namespace margelo::nitro; using namespace margelo::nitro::crypto; + HybridObjectRegistry::registerHybridObjectConstructor( + "Hash", + []() -> std::shared_ptr { + static_assert(std::is_default_constructible_v, + "The HybridObject \"HybridHash\" is not default-constructible! " + "Create a public constructor that takes zero arguments to be able to autolink this HybridObject."); + return std::make_shared(); + } + ); HybridObjectRegistry::registerHybridObjectConstructor( "EdKeyPair", []() -> std::shared_ptr { diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.cpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.cpp new file mode 100644 index 00000000..0a8c7ab1 --- /dev/null +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.cpp @@ -0,0 +1,25 @@ +/// +/// HybridHashSpec.cpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#include "HybridHashSpec.hpp" + +namespace margelo::nitro::crypto { + + void HybridHashSpec::loadHybridMethods() { + // load base methods/properties + HybridObject::loadHybridMethods(); + // load custom methods/properties + registerHybrids(this, [](Prototype& prototype) { + prototype.registerHybridMethod("createHash", &HybridHashSpec::createHash); + prototype.registerHybridMethod("update", &HybridHashSpec::update); + prototype.registerHybridMethod("digest", &HybridHashSpec::digest); + prototype.registerHybridMethod("copy", &HybridHashSpec::copy); + prototype.registerHybridMethod("getSupportedHashAlgorithms", &HybridHashSpec::getSupportedHashAlgorithms); + }); + } + +} // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.hpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.hpp new file mode 100644 index 00000000..cc4559ca --- /dev/null +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHashSpec.hpp @@ -0,0 +1,74 @@ +/// +/// HybridHashSpec.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © 2025 Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +// Forward declaration of `ArrayBuffer` to properly resolve imports. +namespace NitroModules { class ArrayBuffer; } +// Forward declaration of `HybridHashSpec` to properly resolve imports. +namespace margelo::nitro::crypto { class HybridHashSpec; } + +#include +#include +#include +#include +#include "HybridHashSpec.hpp" +#include + +namespace margelo::nitro::crypto { + + using namespace margelo::nitro; + + /** + * An abstract base class for `Hash` + * Inherit this class to create instances of `HybridHashSpec` in C++. + * You must explicitly call `HybridObject`'s constructor yourself, because it is virtual. + * @example + * ```cpp + * class HybridHash: public HybridHashSpec { + * public: + * HybridHash(...): HybridObject(TAG) { ... } + * // ... + * }; + * ``` + */ + class HybridHashSpec: public virtual HybridObject { + public: + // Constructor + explicit HybridHashSpec(): HybridObject(TAG) { } + + // Destructor + virtual ~HybridHashSpec() { } + + public: + // Properties + + + public: + // Methods + virtual void createHash(const std::string& algorithm, std::optional outputLength) = 0; + virtual void update(const std::shared_ptr& data) = 0; + virtual std::shared_ptr digest(const std::optional& encoding) = 0; + virtual std::shared_ptr copy(std::optional outputLength) = 0; + virtual std::vector getSupportedHashAlgorithms() = 0; + + protected: + // Hybrid Setup + void loadHybridMethods() override; + + protected: + // Tag for logging + static constexpr auto TAG = "Hash"; + }; + +} // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/src/hash.ts b/packages/react-native-quick-crypto/src/hash.ts new file mode 100644 index 00000000..e2efdad2 --- /dev/null +++ b/packages/react-native-quick-crypto/src/hash.ts @@ -0,0 +1,172 @@ +import { Stream } from 'readable-stream'; +import { NitroModules } from 'react-native-nitro-modules'; +import type { TransformOptions } from 'readable-stream'; +import type { Hash as NativeHash } from './specs/hash.nitro'; +import type { BinaryLike, Encoding } from './utils'; +import { ab2str, binaryLikeToArrayBuffer } from './utils'; + +class HashUtils { + private static native = NitroModules.createHybridObject('Hash'); + public static getSupportedHashAlgorithms(): string[] { + return this.native.getSupportedHashAlgorithms(); + } +} + +export function getHashes() { + return HashUtils.getSupportedHashAlgorithms(); +} + +interface HashOptions extends TransformOptions { + /** + * For XOF hash functions such as `shake256`, the + * outputLength option can be used to specify the desired output length in bytes. + */ + outputLength?: number | undefined; +} + +interface HashArgs { + algorithm: string; + options?: HashOptions; + native?: NativeHash; +} + +class Hash extends Stream.Transform { + private algorithm: string; + private options: HashOptions; + private native: NativeHash; + + /** + * @internal use `createHash()` instead + */ + private constructor({ algorithm, options, native }: HashArgs) { + super(options); + + this.algorithm = algorithm; + this.options = options ?? {}; + + if (native) { + this.native = native; + } else { + this.native = NitroModules.createHybridObject('Hash'); + this.native.createHash(algorithm, this.options.outputLength); + } + } + + /** + * Updates the hash content with the given `data`, the encoding of which + * is given in `inputEncoding`. + * If `encoding` is not provided, and the `data` is a string, an + * encoding of `'utf8'` is enforced. If `data` is a `Buffer`, `TypedArray`, or`DataView`, then `inputEncoding` is ignored. + * + * This can be called many times with new data as it is streamed. + * @since v1.0.0 + * @param inputEncoding The `encoding` of the `data` string. + */ + update(data: BinaryLike): Hash; + update(data: BinaryLike, inputEncoding: Encoding): Buffer; + update(data: BinaryLike, inputEncoding?: Encoding): Hash | Buffer { + const defaultEncoding: Encoding = 'utf8'; + inputEncoding = inputEncoding ?? defaultEncoding; + + this.native.update(binaryLikeToArrayBuffer(data, inputEncoding)); + + if (typeof data === 'string' && inputEncoding !== 'buffer') { + return this; // to support chaining syntax createHash().update().digest() + } + + return Buffer.from([]); // returning empty buffer as _flush calls digest + } + + /** + * Calculates the digest of all of the data passed to be hashed (using the `hash.update()` method). + * If `encoding` is provided a string will be returned; otherwise + * a `Buffer` is returned. + * + * The `Hash` object can not be used again after `hash.digest()` method has been + * called. Multiple calls will cause an error to be thrown. + * @since v1.0.0 + * @param encoding The `encoding` of the return value. + */ + digest(): Buffer; + digest(encoding: Encoding): Buffer; + digest(encoding?: Encoding): Buffer | string { + const nativeDigest = this.native.digest(encoding); + + if (encoding && encoding !== 'buffer') { + return ab2str(nativeDigest, encoding); + } + + return Buffer.from(nativeDigest); + } + + /** + * Creates a new `Hash` object that contains a deep copy of the internal state + * of the current `Hash` object. + * + * The optional `options` argument controls stream behavior. For XOF hash + * functions such as `'shake256'`, the `outputLength` option can be used to + * specify the desired output length in bytes. + * + * An error is thrown when an attempt is made to copy the `Hash` object after + * its `hash.digest()` method has been called. + * + * ```js + * // Calculate a rolling hash. + * import { createHash } from 'react-native-quick-crypto'; + * + * const hash = createHash('sha256'); + * + * hash.update('one'); + * console.log(hash.copy().digest('hex')); + * + * hash.update('two'); + * console.log(hash.copy().digest('hex')); + * + * hash.update('three'); + * console.log(hash.copy().digest('hex')); + * + * // Etc. + * ``` + * @since v1.0.0 + * @param options `stream.transform` options + */ + copy(): Hash; + copy(options: HashOptions): Hash; + copy(options?: HashOptions): Hash { + const newOptions = options ?? this.options; + const newNativeHash = this.native.copy(newOptions.outputLength); + const hash = new Hash({ + algorithm: this.algorithm, + options: newOptions, + native: newNativeHash, + }); + return hash; + } + + // stream interface + _transform( + chunk: BinaryLike, + encoding: BufferEncoding, + callback: () => void, + ) { + this.push(this.update(chunk, encoding as Encoding)); + callback(); + } + _flush(callback: () => void) { + this.push(this.digest()); + callback(); + } +} + +export function createHash(algorithm: string, options?: HashOptions): Hash { + // @ts-expect-error private constructor + return new Hash({ + algorithm, + options, + }); +} + +export const hashExports = { + createHash, + getHashes, +}; diff --git a/packages/react-native-quick-crypto/src/index.ts b/packages/react-native-quick-crypto/src/index.ts index 3151ea98..ac99438b 100644 --- a/packages/react-native-quick-crypto/src/index.ts +++ b/packages/react-native-quick-crypto/src/index.ts @@ -3,6 +3,7 @@ import { Buffer } from '@craftzdog/react-native-buffer'; // API imports import * as keys from './keys'; +import { hashExports as hash } from './hash'; import * as ed from './ed'; import * as pbkdf2 from './pbkdf2'; import * as random from './random'; @@ -33,6 +34,7 @@ const QuickCrypto = { // subtle, // constants, ...keys, + ...hash, ...ed, ...pbkdf2, ...random, @@ -59,6 +61,7 @@ global.process.nextTick = setImmediate; // exports export default QuickCrypto; +export * from './hash'; export * from './ed'; export * from './pbkdf2'; export * from './random'; diff --git a/packages/react-native-quick-crypto/src/specs/hash.nitro.ts b/packages/react-native-quick-crypto/src/specs/hash.nitro.ts new file mode 100644 index 00000000..eaaa2bd0 --- /dev/null +++ b/packages/react-native-quick-crypto/src/specs/hash.nitro.ts @@ -0,0 +1,9 @@ +import type { HybridObject } from 'react-native-nitro-modules'; + +export interface Hash extends HybridObject<{ ios: 'c++'; android: 'c++' }> { + createHash(algorithm: string, outputLength?: number): void; + update(data: ArrayBuffer): void; + digest(encoding?: string): ArrayBuffer; + copy(outputLength?: number): Hash; + getSupportedHashAlgorithms(): string[]; +} diff --git a/packages/react-native-quick-crypto/src/utils/types.ts b/packages/react-native-quick-crypto/src/utils/types.ts index fba396b2..7b4208b4 100644 --- a/packages/react-native-quick-crypto/src/utils/types.ts +++ b/packages/react-native-quick-crypto/src/utils/types.ts @@ -273,3 +273,12 @@ export enum KeyVariant { export type SignCallback = (err: Error | null, signature?: ArrayBuffer) => void; export type VerifyCallback = (err: Error | null, valid?: boolean) => void; + +export type BinaryToTextEncoding = 'base64' | 'base64url' | 'hex' | 'binary'; +export type CharacterEncoding = 'utf8' | 'utf-8' | 'utf16le' | 'latin1'; +export type LegacyCharacterEncoding = 'ascii' | 'binary' | 'ucs2' | 'ucs-2'; +export type Encoding = + | BinaryToTextEncoding + | CharacterEncoding + | LegacyCharacterEncoding + | 'buffer';