diff --git a/.docs/implementation-coverage.md b/.docs/implementation-coverage.md index 5d0cc4d9..a83fc3eb 100644 --- a/.docs/implementation-coverage.md +++ b/.docs/implementation-coverage.md @@ -7,6 +7,7 @@ This document attempts to describe the implementation status of Crypto APIs/Inte - ` ` - not implemented in Node - ❌ - implemented in Node, not RNQC - ✅ - implemented in Node and RNQC +- ✨ - implemented in RNQC, not in Node (RNQC extension) - 🚧 - work in progress - `-` - not applicable to React Native @@ -145,6 +146,10 @@ These algorithms provide quantum-resistant cryptography. - ✅ `crypto.hash(algorithm, data[, outputEncoding])` - ✅ `crypto.hkdf(digest, ikm, salt, info, keylen, callback)` - ✅ `crypto.hkdfSync(digest, ikm, salt, info, keylen)` + - ✨ `crypto.hkdfExtract(digest, ikm, salt, callback)` (RFC 5869 Extract-only) + - ✨ `crypto.hkdfExtractSync(digest, ikm[, salt])` (RFC 5869 Extract-only) + - ✨ `crypto.hkdfExpand(digest, prk, info, keylen, callback)` (RFC 5869 Expand-only) + - ✨ `crypto.hkdfExpandSync(digest, prk, info, keylen)` (RFC 5869 Expand-only) - ✅ `crypto.pbkdf2(password, salt, iterations, keylen, digest, callback)` - ✅ `crypto.pbkdf2Sync(password, salt, iterations, keylen, digest)` - ✅ `crypto.privateDecrypt(privateKey, buffer)` diff --git a/.docs/troubleshooting.md b/.docs/troubleshooting.md index 7d642800..53b27c5a 100644 --- a/.docs/troubleshooting.md +++ b/.docs/troubleshooting.md @@ -10,6 +10,25 @@ Cannot read property 'install' of undefined Then you need to install `react-native-quick-crypto` as a dependency in your `package.json` file. Make sure to install pods (ios). +## `QuickBase64` could not be found + +If your app crashes on launch with: + +``` +Invariant Violation: TurboModuleRegistry.getEnforcing(...): 'QuickBase64' could not be found. Verify that a module by this name is registered in the native binary. +``` + +This comes from `react-native-quick-base64`, which is a required dependency of `react-native-quick-crypto`. Since `1.x` of `react-native-quick-crypto` targets the New Architecture, `react-native-quick-base64` `3.0.0`+ is a pure C++ TurboModule that only registers when your app runs with the New Architecture enabled. On the Old Architecture there is no registration for `QuickBase64`, so the lookup fails and takes `react-native-quick-crypto` down with it at startup. + +Fix it one of these ways: + +- **Enable the New Architecture, then rebuild.** + - Bare React Native: set `newArchEnabled=true` in `android/gradle.properties`, then `cd android && ./gradlew clean` and rebuild. (New Architecture is the default on RN `0.76`+ and required on `0.85`+.) + - Expo: use SDK 54+, where the New Architecture is on by default, or enable it explicitly on SDK 53. +- **Stay on the Old Architecture** by pinning `react-native-quick-base64@2.2.2`, whose `2.x` line still ships the legacy Old Architecture module. + +> Note: the `ndkVersion` patch that circulates for this error targets the old `react-native-quick-base64` `2.2.2` `android/build.gradle`. Version `3.0.0`+ has no `build.gradle` (it is CMake only), so that patch does not apply. + ## Android build errors If you get an error similar to this: diff --git a/docs/content/docs/api/hkdf.mdx b/docs/content/docs/api/hkdf.mdx index c789ce6f..657f549c 100644 --- a/docs/content/docs/api/hkdf.mdx +++ b/docs/content/docs/api/hkdf.mdx @@ -73,3 +73,37 @@ const derived = QuickCrypto.hkdfSync( } }} /> + +### hkdfExtractSync(digest, ikm, salt?) / hkdfExtract(digest, ikm, salt, callback) + +Runs only the **Extract** step of RFC 5869, returning the pseudorandom key +($PRK$) as a `Buffer` of `HashLen` bytes. Use this when a protocol performs +Extract and Expand at different stages (TLS 1.3, Noise, Signal, MLS). `salt` +defaults to a string of `HashLen` zero bytes when omitted from the sync form; +the async form requires `salt` (the callback takes the trailing argument). + +```ts +import QuickCrypto from 'react-native-quick-crypto'; + +const prk = QuickCrypto.hkdfExtractSync('sha256', 'ikm-secret', 'salt'); + +QuickCrypto.hkdfExtract('sha256', 'ikm-secret', 'salt', (err, prk) => { + // ... +}); +``` + +### hkdfExpandSync(digest, prk, info, keylen) / hkdfExpand(digest, prk, info, keylen, callback) + +Runs only the **Expand** step, deriving `keylen` bytes of output keying +material from an already-derived `prk`. `prk` must be at least `HashLen` bytes, +and `keylen` must not exceed `255 * HashLen` (RFC 5869 §2.3). + +```ts +import QuickCrypto from 'react-native-quick-crypto'; + +const okm = QuickCrypto.hkdfExpandSync('sha256', prk, 'context-info', 64); + +QuickCrypto.hkdfExpand('sha256', prk, 'context-info', 64, (err, okm) => { + // ... +}); +``` diff --git a/docs/data/coverage.ts b/docs/data/coverage.ts index 376be4da..dd441159 100644 --- a/docs/data/coverage.ts +++ b/docs/data/coverage.ts @@ -275,6 +275,16 @@ export const COVERAGE_DATA: CoverageCategory[] = [ { name: 'getRandomValues', status: 'implemented' }, { name: 'hash', status: 'implemented' }, { name: 'hkdf', status: 'implemented' }, + { + name: 'hkdfExpand / hkdfExpandSync', + status: 'not-in-node', + note: 'RNQC extension: RFC 5869 Expand-only', + }, + { + name: 'hkdfExtract / hkdfExtractSync', + status: 'not-in-node', + note: 'RNQC extension: RFC 5869 Extract-only', + }, { name: 'pbkdf2', status: 'implemented' }, { name: 'privateDecrypt / privateEncrypt', status: 'implemented' }, { name: 'publicDecrypt / publicEncrypt', status: 'implemented' }, diff --git a/example/ios/Podfile.lock b/example/ios/Podfile.lock index cadd36e0..40d396d4 100644 --- a/example/ios/Podfile.lock +++ b/example/ios/Podfile.lock @@ -2841,7 +2841,7 @@ SPEC CHECKSUMS: NitroMmkv: afbc5b2fbf963be567c6c545aa1efcf6a9cec68e NitroModules: 11bba9d065af151eae51e38a6425e04c3b223ff3 OpenSSL-Universal: ecee7b138fa75a74ecf00d7ffd248fb584739b9e - QuickCrypto: fd9ac356a20fa61425a09f5a6f7eff8d50ca3172 + QuickCrypto: 21eda4d230c62635279e316ea06abead4df1c662 RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: c4b9e2fd0ab200e3af72b013ed6113187c607077 RCTRequired: e97dd5dafc1db8094e63bc5031e0371f092ae92a diff --git a/example/src/tests/hkdf/hkdf_tests.ts b/example/src/tests/hkdf/hkdf_tests.ts index f757ec16..dfcbf151 100644 --- a/example/src/tests/hkdf/hkdf_tests.ts +++ b/example/src/tests/hkdf/hkdf_tests.ts @@ -1,4 +1,12 @@ -import crypto, { Buffer, hkdf, hkdfSync } from 'react-native-quick-crypto'; +import crypto, { + Buffer, + hkdf, + hkdfSync, + hkdfExtract, + hkdfExtractSync, + hkdfExpand, + hkdfExpandSync, +} from 'react-native-quick-crypto'; import { expect } from 'chai'; import { test } from '../util'; @@ -17,6 +25,7 @@ const testVectors = [ info: 'f0f1f2f3f4f5f6f7f8f9', len: 42, okm: '3cb25f25faacd57a90434f64d0362f2a2d2d0a90cf1a5a4c5db02d56ecc4c5bf34007208d5b887185865', + prk: '077709362c2e32df0ddc3f0dc47bba6390b6c73bb50f9c3122ec844ad7c2b3e5', algo: 'sha256', }, // A.2 — Test Case 2: Test with SHA-256 and longer inputs/outputs @@ -26,6 +35,7 @@ const testVectors = [ info: 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', len: 82, okm: 'b11e398dc80327a1c8e7f78c596a49344f012eda2d4efad8a050cc4c19afa97c59045a99cac7827271cb41c65e590e09da3275600c2f09b8367793a9aca3db71cc30c58179ec3e87c14c01d5c1f3434f1d87', + prk: '06a6b88c5853361a06104c9ceb35b45cef760014904671014a193f40c15fc244', algo: 'sha256', }, // A.3 — Test Case 3: SHA-256 with zero-length salt and zero-length info @@ -35,6 +45,7 @@ const testVectors = [ info: '', len: 42, okm: '8da4e775a563c18f715f802a063c5a31b8a11f5c5ee1879ec3454e5f3c738d2d9d201395faa4b61a96c8', + prk: '19ef24a32c717b167f33a91d6f648bdf96596776afdb6377ac434c1c293ccb04', algo: 'sha256', }, // A.4 — Test Case 4: Basic test case with SHA-1 @@ -44,6 +55,7 @@ const testVectors = [ info: 'f0f1f2f3f4f5f6f7f8f9', len: 42, okm: '085a01ea1b10f36933068b56efa5ad81a4f14b822f5b091568a9cdd4f155fda2c22e422478d305f3f896', + prk: '9b6c18c432a7bf8f0e71c8eb88f4b30baa2ba243', algo: 'sha1', }, // A.5 — Test Case 5: SHA-1 with longer inputs/outputs @@ -53,6 +65,7 @@ const testVectors = [ info: 'b0b1b2b3b4b5b6b7b8b9babbbcbdbebfc0c1c2c3c4c5c6c7c8c9cacbcccdcecfd0d1d2d3d4d5d6d7d8d9dadbdcdddedfe0e1e2e3e4e5e6e7e8e9eaebecedeeeff0f1f2f3f4f5f6f7f8f9fafbfcfdfeff', len: 82, okm: '0bd770a74d1160f7c9f12cd5912a06ebff6adcae899d92191fe4305673ba2ffe8fa3f1a4e5ad79f3f334b3b202b2173c486ea37ce3d397ed034c7f9dfeb15c5e927336d0441f4c4300e2cff0d0900b52d3b4', + prk: '8adae09a2a307059478d309b26c4115a224cfaf6', algo: 'sha1', }, // A.6 — Test Case 6: SHA-1 with zero-length salt and zero-length info @@ -62,6 +75,7 @@ const testVectors = [ info: '', len: 42, okm: '0ac1af7002b3d761d1e55298da9d0506b9ae52057220a306e07b6b87e8df21d0ea00033de03984d34918', + prk: 'da8c8a73c7fa77288ec6f5e7c297786aa0d32d01', algo: 'sha1', }, // A.7 — Test Case 7: SHA-1, salt == NULL (treated as HashLen zeros), zero info @@ -71,6 +85,7 @@ const testVectors = [ info: '', len: 42, okm: '2c91117204d745f3500d636a62f64f0ab3bae548aa53d423b0d1f27ebba6f5e5673a081d70cce7acfc48', + prk: '2adccada18779e7c2077ad2eb19d3f3e731385dd', algo: 'sha1', }, ]; @@ -306,3 +321,95 @@ test(SUITE, 'WebCrypto HKDF deriveKey (AES-GCM)', async () => { const expected = vec.okm.slice(0, 64); // 32 bytes * 2 hex chars expect(Buffer.from(rawDerived).toString('hex')).to.equal(expected); }); + +// --- HKDF-Extract / HKDF-Expand split (RFC 5869 §2.2–2.3, issue #1060) --- +// +// Validates the two steps independently against the published intermediate +// PRK, and that Extract→Expand recomposes the full-HKDF OKM. +for (let i = 0; i < testVectors.length; i++) { + const vec = testVectors[i]!; + + test(SUITE, `hkdfExtractSync (RFC 5869 Case ${i + 1})`, () => { + const ikm = Buffer.from(vec.ikm, 'hex'); + const salt = Buffer.from(vec.salt, 'hex'); + const prk = hkdfExtractSync(vec.algo, ikm, salt); + expect(prk.toString('hex')).to.equal(vec.prk); + }); + + test(SUITE, `hkdfExpandSync (RFC 5869 Case ${i + 1})`, () => { + const prk = Buffer.from(vec.prk, 'hex'); + const info = Buffer.from(vec.info, 'hex'); + const okm = hkdfExpandSync(vec.algo, prk, info, vec.len); + expect(okm.toString('hex')).to.equal(vec.okm); + }); + + test( + SUITE, + `hkdfExtractSync→hkdfExpandSync recomposes OKM (Case ${i + 1})`, + () => { + const ikm = Buffer.from(vec.ikm, 'hex'); + const salt = Buffer.from(vec.salt, 'hex'); + const info = Buffer.from(vec.info, 'hex'); + const prk = hkdfExtractSync(vec.algo, ikm, salt); + const okm = hkdfExpandSync(vec.algo, prk, info, vec.len); + expect(okm.toString('hex')).to.equal(vec.okm); + }, + ); + + test(SUITE, `hkdfExtract async (RFC 5869 Case ${i + 1})`, async () => { + const ikm = Buffer.from(vec.ikm, 'hex'); + const salt = Buffer.from(vec.salt, 'hex'); + return new Promise((resolve, reject) => { + hkdfExtract(vec.algo, ikm, salt, (err, prk) => { + try { + expect(err).to.equal(null); + expect(prk?.toString('hex')).to.equal(vec.prk); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); + + test(SUITE, `hkdfExpand async (RFC 5869 Case ${i + 1})`, async () => { + const prk = Buffer.from(vec.prk, 'hex'); + const info = Buffer.from(vec.info, 'hex'); + return new Promise((resolve, reject) => { + hkdfExpand(vec.algo, prk, info, vec.len, (err, okm) => { + try { + expect(err).to.equal(null); + expect(okm?.toString('hex')).to.equal(vec.okm); + resolve(); + } catch (e) { + reject(e); + } + }); + }); + }); +} + +// Extract with omitted salt defaults to HashLen zero bytes (RFC 5869 §2.2). +// Case 3 uses an all-zero salt path, so an empty explicit salt must match. +test(SUITE, 'hkdfExtractSync: omitted salt defaults to HashLen zeros', () => { + const vec = testVectors[2]!; // Case 3, sha256, empty salt + const ikm = Buffer.from(vec.ikm, 'hex'); + const prk = hkdfExtractSync(vec.algo, ikm); + expect(prk.toString('hex')).to.equal(vec.prk); +}); + +// Expand inherits the RFC 5869 keylen ceiling (255 * HashLen). +test(SUITE, 'hkdfExpandSync: rejects keylen > 255 * HashLen', () => { + const prk = Buffer.from('00'.repeat(32), 'hex'); + expect(() => { + hkdfExpandSync('sha256', prk, Buffer.alloc(0), 8161); + }).to.throw(RangeError, /exceeds RFC 5869 ceiling/); +}); + +// Expand requires PRK >= HashLen (RFC 5869 §2.3). +test(SUITE, 'hkdfExpandSync: rejects PRK shorter than HashLen', () => { + const shortPrk = Buffer.from('00'.repeat(16), 'hex'); // 16 < sha256 HashLen 32 + expect(() => { + hkdfExpandSync('sha256', shortPrk, Buffer.alloc(0), 32); + }).to.throw(RangeError, /at least HashLen/); +}); diff --git a/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.cpp b/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.cpp index 3b1b32ce..f74d23fa 100644 --- a/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.cpp +++ b/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.cpp @@ -12,23 +12,22 @@ namespace margelo::nitro::crypto { -std::shared_ptr>> HybridHkdf::deriveKey(const std::string& algorithm, - const std::shared_ptr& key, - const std::shared_ptr& salt, - const std::shared_ptr& info, double length) { +std::shared_ptr>> +HybridHkdf::deriveKey(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, + const std::shared_ptr& info, double length, HkdfMode mode) { // get owned NativeArrayBuffers before passing to sync function auto nativeKey = ToNativeArrayBuffer(key); auto nativeSalt = ToNativeArrayBuffer(salt); auto nativeInfo = ToNativeArrayBuffer(info); - return Promise>::async([this, algorithm, nativeKey, nativeSalt, nativeInfo, length]() { - return this->deriveKeySync(algorithm, nativeKey, nativeSalt, nativeInfo, length); + return Promise>::async([this, algorithm, nativeKey, nativeSalt, nativeInfo, length, mode]() { + return this->deriveKeySync(algorithm, nativeKey, nativeSalt, nativeInfo, length, mode); }); } std::shared_ptr HybridHkdf::deriveKeySync(const std::string& algorithm, const std::shared_ptr& baseKey, const std::shared_ptr& salt, const std::shared_ptr& info, - double length) { + double length, HkdfMode mode) { EVP_KDF* kdf = EVP_KDF_fetch(nullptr, "HKDF", nullptr); if (kdf == nullptr) { throw std::runtime_error("Failed to fetch HKDF implementation: " + std::to_string(ERR_get_error())); @@ -41,9 +40,27 @@ std::shared_ptr HybridHkdf::deriveKeySync(const std::string& algori } // Set up parameters - OSSL_PARAM params[5]; + OSSL_PARAM params[6]; size_t paramIndex = 0; + // HKDF mode (RFC 5869): full extract+expand, extract-only (PRK), or expand-only. + int hkdfMode; + switch (mode) { + case HkdfMode::EXTRACT: + hkdfMode = EVP_KDF_HKDF_MODE_EXTRACT_ONLY; + break; + case HkdfMode::EXPAND: + hkdfMode = EVP_KDF_HKDF_MODE_EXPAND_ONLY; + break; + case HkdfMode::FULL: + hkdfMode = EVP_KDF_HKDF_MODE_EXTRACT_AND_EXPAND; + break; + default: + EVP_KDF_CTX_free(ctx); + throw std::runtime_error("Unknown HKDF mode"); + } + params[paramIndex++] = OSSL_PARAM_construct_int(OSSL_KDF_PARAM_MODE, &hkdfMode); + params[paramIndex++] = OSSL_PARAM_construct_utf8_string(OSSL_KDF_PARAM_DIGEST, const_cast(algorithm.c_str()), 0); // Key (Input Keying Material) diff --git a/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.hpp b/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.hpp index 6e47ac70..89079a55 100644 --- a/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.hpp +++ b/packages/react-native-quick-crypto/cpp/hkdf/HybridHkdf.hpp @@ -19,10 +19,11 @@ class HybridHkdf : public HybridHkdfSpec { // Methods std::shared_ptr deriveKeySync(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, const std::shared_ptr& info, - double length) override; + double length, HkdfMode mode) override; std::shared_ptr>> deriveKey(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, - const std::shared_ptr& info, double length) override; + const std::shared_ptr& info, double length, + HkdfMode mode) override; }; } // namespace margelo::nitro::crypto diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HkdfMode.hpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HkdfMode.hpp new file mode 100644 index 00000000..e159ca26 --- /dev/null +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HkdfMode.hpp @@ -0,0 +1,80 @@ +/// +/// HkdfMode.hpp +/// This file was generated by nitrogen. DO NOT MODIFY THIS FILE. +/// https://github.com/mrousavy/nitro +/// Copyright © Marc Rousavy @ Margelo +/// + +#pragma once + +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif +#if __has_include() +#include +#else +#error NitroModules cannot be found! Are you sure you installed NitroModules properly? +#endif + +namespace margelo::nitro::crypto { + + /** + * An enum which can be represented as a JavaScript union (HkdfMode). + */ + enum class HkdfMode { + FULL SWIFT_NAME(full) = 0, + EXTRACT SWIFT_NAME(extract) = 1, + EXPAND SWIFT_NAME(expand) = 2, + } CLOSED_ENUM; + +} // namespace margelo::nitro::crypto + +namespace margelo::nitro { + + // C++ HkdfMode <> JS HkdfMode (union) + template <> + struct JSIConverter final { + static inline margelo::nitro::crypto::HkdfMode fromJSI(jsi::Runtime& runtime, const jsi::Value& arg) { + std::string unionValue = JSIConverter::fromJSI(runtime, arg); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("full"): return margelo::nitro::crypto::HkdfMode::FULL; + case hashString("extract"): return margelo::nitro::crypto::HkdfMode::EXTRACT; + case hashString("expand"): return margelo::nitro::crypto::HkdfMode::EXPAND; + default: [[unlikely]] + throw std::invalid_argument("Cannot convert \"" + unionValue + "\" to enum HkdfMode - invalid value!"); + } + } + static inline jsi::Value toJSI(jsi::Runtime& runtime, margelo::nitro::crypto::HkdfMode arg) { + switch (arg) { + case margelo::nitro::crypto::HkdfMode::FULL: return JSIConverter::toJSI(runtime, "full"); + case margelo::nitro::crypto::HkdfMode::EXTRACT: return JSIConverter::toJSI(runtime, "extract"); + case margelo::nitro::crypto::HkdfMode::EXPAND: return JSIConverter::toJSI(runtime, "expand"); + default: [[unlikely]] + throw std::invalid_argument("Cannot convert HkdfMode to JS - invalid value: " + + std::to_string(static_cast(arg)) + "!"); + } + } + static inline bool canConvert(jsi::Runtime& runtime, const jsi::Value& value) { + if (!value.isString()) { + return false; + } + std::string unionValue = JSIConverter::fromJSI(runtime, value); + switch (hashString(unionValue.c_str(), unionValue.size())) { + case hashString("full"): + case hashString("extract"): + case hashString("expand"): + return true; + default: + return false; + } + } + }; + +} // namespace margelo::nitro diff --git a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHkdfSpec.hpp b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHkdfSpec.hpp index c60f19bc..4cd9ea9f 100644 --- a/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHkdfSpec.hpp +++ b/packages/react-native-quick-crypto/nitrogen/generated/shared/c++/HybridHkdfSpec.hpp @@ -13,10 +13,12 @@ #error NitroModules cannot be found! Are you sure you installed NitroModules properly? #endif - +// Forward declaration of `HkdfMode` to properly resolve imports. +namespace margelo::nitro::crypto { enum class HkdfMode; } #include #include +#include "HkdfMode.hpp" #include namespace margelo::nitro::crypto { @@ -50,8 +52,8 @@ namespace margelo::nitro::crypto { public: // Methods - virtual std::shared_ptr deriveKeySync(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, const std::shared_ptr& info, double length) = 0; - virtual std::shared_ptr>> deriveKey(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, const std::shared_ptr& info, double length) = 0; + virtual std::shared_ptr deriveKeySync(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, const std::shared_ptr& info, double length, HkdfMode mode) = 0; + virtual std::shared_ptr>> deriveKey(const std::string& algorithm, const std::shared_ptr& key, const std::shared_ptr& salt, const std::shared_ptr& info, double length, HkdfMode mode) = 0; protected: // Hybrid Setup diff --git a/packages/react-native-quick-crypto/src/hkdf.ts b/packages/react-native-quick-crypto/src/hkdf.ts index 5f12ae9b..f20b0fe6 100644 --- a/packages/react-native-quick-crypto/src/hkdf.ts +++ b/packages/react-native-quick-crypto/src/hkdf.ts @@ -62,6 +62,14 @@ const HKDF_HASH_BYTES: Readonly> = { ripemd160: 20, }; +function hkdfHashLen(digest: string): number { + const hashLen = HKDF_HASH_BYTES[digest.toLowerCase()]; + if (hashLen === undefined) { + throw new TypeError(`Unsupported HKDF digest: ${digest}`); + } + return hashLen; +} + function validateHkdfKeylen(digest: string, keylen: number): void { if ( typeof keylen !== 'number' || @@ -111,6 +119,7 @@ export function hkdf( sanitizedSalt, sanitizedInfo, keylen, + 'full', ) .then( res => { @@ -146,11 +155,143 @@ export function hkdfSync( sanitizedSalt, sanitizedInfo, keylen, + 'full', + ); + + return Buffer.from(result); +} + +// RFC 5869 §2.2 HKDF-Extract (sync): PRK = HMAC(salt, IKM). Salt defaults to +// a string of HashLen zeros when omitted. Returns a PRK of HashLen bytes. +export function hkdfExtractSync( + digest: string, + ikm: KeyMaterial, + salt: Salt = new Uint8Array(0), +): Buffer { + const normalizedDigest = normalizeHashName(digest); + const hashLen = hkdfHashLen(normalizedDigest); + const sanitizedIkm = sanitizeInput(ikm, 'IKM'); + const sanitizedSalt = sanitizeInput(salt, 'Salt'); + + const result = getNative().deriveKeySync( + normalizedDigest, + sanitizedIkm, + sanitizedSalt, + new ArrayBuffer(0), + hashLen, + 'extract', ); return Buffer.from(result); } +// Async HKDF-Extract, mirroring `hkdf`. Unlike the sync form, `salt` is +// required here because the callback occupies the trailing argument. +export function hkdfExtract( + digest: string, + ikm: KeyMaterial, + salt: Salt, + callback: HkdfCallback, +): void { + validateCallback(callback); + + try { + const normalizedDigest = normalizeHashName(digest); + const hashLen = hkdfHashLen(normalizedDigest); + const sanitizedIkm = sanitizeInput(ikm, 'IKM'); + const sanitizedSalt = sanitizeInput(salt, 'Salt'); + + getNative() + .deriveKey( + normalizedDigest, + sanitizedIkm, + sanitizedSalt, + new ArrayBuffer(0), + hashLen, + 'extract', + ) + .then( + res => callback(null, Buffer.from(res)), + err => callback(err), + ); + } catch (err) { + callback(err as Error); + } +} + +// RFC 5869 §2.3 HKDF-Expand (sync): OKM = expand(PRK, info, L). `prk` must be +// at least HashLen bytes (a pseudorandom key, e.g. from hkdfExtract). +export function hkdfExpandSync( + digest: string, + prk: KeyMaterial, + info: Info, + keylen: number, +): Buffer { + const normalizedDigest = normalizeHashName(digest); + const hashLen = hkdfHashLen(normalizedDigest); + const sanitizedPrk = sanitizeInput(prk, 'PRK'); + if (sanitizedPrk.byteLength < hashLen) { + throw new RangeError( + `HKDF-Expand PRK must be at least HashLen (${hashLen}) bytes for ${digest}`, + ); + } + const sanitizedInfo = sanitizeInput(info, 'Info'); + + validateHkdfKeylen(normalizedDigest, keylen); + + const result = getNative().deriveKeySync( + normalizedDigest, + sanitizedPrk, + new ArrayBuffer(0), + sanitizedInfo, + keylen, + 'expand', + ); + + return Buffer.from(result); +} + +// Async HKDF-Expand, mirroring `hkdf`. +export function hkdfExpand( + digest: string, + prk: KeyMaterial, + info: Info, + keylen: number, + callback: HkdfCallback, +): void { + validateCallback(callback); + + try { + const normalizedDigest = normalizeHashName(digest); + const hashLen = hkdfHashLen(normalizedDigest); + const sanitizedPrk = sanitizeInput(prk, 'PRK'); + if (sanitizedPrk.byteLength < hashLen) { + throw new RangeError( + `HKDF-Expand PRK must be at least HashLen (${hashLen}) bytes for ${digest}`, + ); + } + const sanitizedInfo = sanitizeInput(info, 'Info'); + + validateHkdfKeylen(normalizedDigest, keylen); + + getNative() + .deriveKey( + normalizedDigest, + sanitizedPrk, + new ArrayBuffer(0), + sanitizedInfo, + keylen, + 'expand', + ) + .then( + res => callback(null, Buffer.from(res)), + err => callback(err), + ); + } catch (err) { + callback(err as Error); + } +} + export function hkdfDeriveBits( algorithm: HkdfAlgorithm, baseKey: CryptoKey, @@ -179,6 +320,7 @@ export function hkdfDeriveBits( binaryLikeToArrayBuffer(salt), binaryLikeToArrayBuffer(info), keylen, + 'full', ); return result; diff --git a/packages/react-native-quick-crypto/src/specs/hkdf.nitro.ts b/packages/react-native-quick-crypto/src/specs/hkdf.nitro.ts index ea6d3260..c98ebe5f 100644 --- a/packages/react-native-quick-crypto/src/specs/hkdf.nitro.ts +++ b/packages/react-native-quick-crypto/src/specs/hkdf.nitro.ts @@ -1,5 +1,9 @@ import type { HybridObject } from 'react-native-nitro-modules'; +// RFC 5869 stage: 'full' (extract+expand), 'extract' (PRK only), or +// 'expand' (from an existing PRK). +export type HkdfMode = 'full' | 'extract' | 'expand'; + export interface Hkdf extends HybridObject<{ ios: 'c++'; android: 'c++' }> { deriveKeySync( algorithm: string, @@ -7,6 +11,7 @@ export interface Hkdf extends HybridObject<{ ios: 'c++'; android: 'c++' }> { salt: ArrayBuffer, info: ArrayBuffer, length: number, + mode: HkdfMode, ): ArrayBuffer; deriveKey( @@ -15,5 +20,6 @@ export interface Hkdf extends HybridObject<{ ios: 'c++'; android: 'c++' }> { salt: ArrayBuffer, info: ArrayBuffer, length: number, + mode: HkdfMode, ): Promise; }