Skip to content

Commit 8b691ab

Browse files
authored
feat: implement scrypt key derivation function (#849)
1 parent fd42caa commit 8b691ab

File tree

17 files changed

+561
-5
lines changed

17 files changed

+561
-5
lines changed

docs/implementation-coverage.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -151,8 +151,8 @@ These algorithms provide quantum-resistant cryptography.
151151
*`crypto.randomFillSync(buffer[, offset][, size])`
152152
*`crypto.randomInt([min, ]max[, callback])`
153153
*`crypto.randomUUID([options])`
154-
* `crypto.scrypt(password, salt, keylen[, options], callback)`
155-
* `crypto.scryptSync(password, salt, keylen[, options])`
154+
* `crypto.scrypt(password, salt, keylen[, options], callback)`
155+
* `crypto.scryptSync(password, salt, keylen[, options])`
156156
*`crypto.secureHeapUsed()`
157157
*`crypto.setEngine(engine[, flags])`
158158
*`crypto.setFips(bool)`
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import rnqc from 'react-native-quick-crypto';
2+
import * as noble from '@noble/hashes/scrypt';
3+
// @ts-expect-error - crypto-browserify is not typed
4+
import browserify from 'crypto-browserify';
5+
import type { BenchFn } from '../../types/benchmarks';
6+
import { Bench } from 'tinybench';
7+
8+
const TIME_MS = 1000;
9+
10+
// N=256, r=8, p=1 is light and fast enough for mobile benchmarking
11+
// Higher values like 1024 can cause timeouts on slower devices
12+
const N = 256;
13+
const r = 8;
14+
const p = 1;
15+
const keylen = 64;
16+
17+
const scrypt_async: BenchFn = () => {
18+
const bench = new Bench({
19+
name: `scrypt N=${N} r=${r} p=${p} (async)`,
20+
time: TIME_MS,
21+
});
22+
23+
bench
24+
.add('rnqc', async () => {
25+
try {
26+
await new Promise<void>((resolve, reject) => {
27+
rnqc.scrypt(
28+
'password',
29+
'salt',
30+
keylen,
31+
{ N, r, p, maxmem: 32 * 1024 * 1024 },
32+
(err: unknown) => {
33+
if (err) reject(err);
34+
else resolve();
35+
},
36+
);
37+
});
38+
} catch (error) {
39+
console.error('RNQC scrypt error:', error);
40+
throw error;
41+
}
42+
})
43+
.add('@noble/hashes/scrypt', async () => {
44+
await noble.scryptAsync('password', 'salt', { N, r, p, dkLen: keylen });
45+
})
46+
.add('browserify/scrypt', async () => {
47+
await new Promise<void>((resolve, reject) => {
48+
browserify.scrypt(
49+
'password',
50+
'salt',
51+
keylen,
52+
{ N, r, p },
53+
(err: unknown) => {
54+
if (err) reject(err);
55+
else resolve();
56+
},
57+
);
58+
});
59+
});
60+
61+
bench.warmupTime = 100;
62+
return bench;
63+
};
64+
65+
const scrypt_sync: BenchFn = () => {
66+
const bench = new Bench({
67+
name: `scrypt N=${N} r=${r} p=${p} (sync)`,
68+
time: TIME_MS,
69+
});
70+
71+
bench
72+
.add('rnqc', () => {
73+
try {
74+
rnqc.scryptSync('password', 'salt', keylen, {
75+
N,
76+
r,
77+
p,
78+
maxmem: 32 * 1024 * 1024,
79+
});
80+
} catch (error) {
81+
console.error('RNQC scryptSync error:', error);
82+
throw error;
83+
}
84+
})
85+
.add('@noble/hashes/scrypt', () => {
86+
noble.scrypt('password', 'salt', { N, r, p, dkLen: keylen });
87+
})
88+
.add('browserify/scrypt', () => {
89+
browserify.scryptSync('password', 'salt', keylen, { N, r, p });
90+
});
91+
92+
bench.warmupTime = 100;
93+
return bench;
94+
};
95+
96+
export default [scrypt_async, scrypt_sync];

example/src/components/BenchmarkItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,10 @@ export const BenchmarkItem: React.FC<BenchmarkItemProps> = ({
4444

4545
// results handling
4646
const usTput = suite.results.reduce((acc, result) => {
47-
return acc + (result.us?.throughput.mean || 0);
47+
return acc + (result.us?.throughput?.mean || 0);
4848
}, 0);
4949
const themTput = suite.results.reduce((acc, result) => {
50-
return acc + (result.them?.throughput.mean || 0);
50+
return acc + (result.them?.throughput?.mean || 0);
5151
}, 0);
5252
const times = calculateTimes(usTput, themTput);
5353
const timesStyle = usTput > themTput ? styles.faster : styles.slower;

example/src/hooks/useTestsList.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import '../tests/keys/generate_keypair';
1919
import '../tests/keys/public_cipher';
2020
import '../tests/keys/sign_verify_streaming';
2121
import '../tests/pbkdf2/pbkdf2_tests';
22+
import '../tests/scrypt/scrypt_tests';
2223
import '../tests/random/random_tests';
2324
import '../tests/subtle/x25519_x448';
2425
import '../tests/subtle/deriveBits';
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
/* eslint-disable @typescript-eslint/no-unused-expressions */
2+
import { Buffer } from 'safe-buffer';
3+
import { expect } from 'chai';
4+
import { test } from '../util';
5+
6+
import crypto from 'react-native-quick-crypto';
7+
8+
const SUITE = 'scrypt';
9+
10+
// RFC 7914 Test Vectors
11+
// https://tools.ietf.org/html/rfc7914#section-2
12+
const kTests = [
13+
{
14+
password: '',
15+
salt: '',
16+
N: 16,
17+
r: 1,
18+
p: 1,
19+
keylen: 64,
20+
expected:
21+
'77d6576238657b203b19ca42c18a0497f16b4844e3074ae8dfdffa3fede21442fcd0069ded0948f8326a753a0fc81f17e8d3e0fb2e0d3628cf35e20c38d18906',
22+
},
23+
{
24+
password: 'password',
25+
salt: 'NaCl',
26+
N: 1024,
27+
r: 8,
28+
p: 16,
29+
keylen: 64,
30+
expected:
31+
'fdbabe1c9d3472007856e7190d01e9fe7c6ad7cbc8237830e77376634b3731622eaf30d92e22a3886ff109279d9830dac727afb94a83ee6d8360cbdfa2cc0640',
32+
},
33+
{
34+
password: 'pleaseletmein',
35+
salt: 'SodiumChloride',
36+
N: 16384,
37+
r: 8,
38+
p: 1,
39+
keylen: 64,
40+
expected:
41+
'7023bdcb3afd7348461c06cd81fd38ebfda8fbba904f8e3ea9b543f6545da1f2d5432955613f0fcf62d49705242a9af9e61e85dc0d651e40dfcf017b45575887',
42+
},
43+
];
44+
45+
kTests.forEach(({ password, salt, N, r, p, keylen, expected }, index) => {
46+
const description = `RFC 7914 Test Case ${index + 1}`;
47+
48+
test(SUITE, `${description} (async)`, () => {
49+
crypto.scrypt(
50+
password,
51+
salt,
52+
keylen,
53+
{ N, r, p, maxmem: 32 * 1024 * 1024 }, // 32MB - generous headroom for all test cases
54+
(err, derivedKey) => {
55+
expect(err).to.be.null;
56+
expect(derivedKey).not.to.be.undefined;
57+
expect(derivedKey!.toString('hex')).to.equal(expected);
58+
},
59+
);
60+
});
61+
62+
test(SUITE, `${description} (sync)`, () => {
63+
const derivedKey = crypto.scryptSync(password, salt, keylen, {
64+
N,
65+
r,
66+
p,
67+
maxmem: 32 * 1024 * 1024, // 32MB - generous headroom for all test cases
68+
});
69+
expect(derivedKey).not.to.be.undefined;
70+
expect(derivedKey.toString('hex')).to.equal(expected);
71+
});
72+
});
73+
74+
test(SUITE, 'should throw if no callback provided (async)', () => {
75+
expect(() => {
76+
crypto.scrypt('password', 'salt', 64);
77+
}).to.throw(/No callback provided/);
78+
});
79+
80+
test(SUITE, 'should handle default options (async)', () => {
81+
// This just tests it doesn't crash and returns a buffer
82+
crypto.scrypt('password', 'salt', 32, (err, key) => {
83+
expect(err).to.be.null;
84+
expect(key).to.be.instanceOf(Buffer);
85+
expect(key!.length).to.equal(32);
86+
});
87+
});
88+
89+
test(SUITE, 'should handle aliases cost/blockSize/parallelization', () => {
90+
// Same as Test Case 1 but with named aliases
91+
const t = kTests[0]!;
92+
const derivedKey = crypto.scryptSync(t.password, t.salt, t.keylen, {
93+
cost: t.N,
94+
blockSize: t.r,
95+
parallelization: t.p,
96+
});
97+
expect(derivedKey.toString('hex')).to.equal(t.expected);
98+
});

packages/react-native-quick-crypto/android/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ add_library(
4545
../cpp/pbkdf2/HybridPbkdf2.cpp
4646
../cpp/random/HybridRandom.cpp
4747
../cpp/rsa/HybridRsaKeyPair.cpp
48+
../cpp/scrypt/HybridScrypt.cpp
4849
../cpp/sign/HybridSignHandle.cpp
4950
../cpp/sign/HybridVerifyHandle.cpp
5051
${BLAKE3_SOURCES}
@@ -71,6 +72,7 @@ include_directories(
7172
"../cpp/random"
7273
"../cpp/rsa"
7374
"../cpp/sign"
75+
"../cpp/scrypt"
7476
"../cpp/utils"
7577
"../deps/blake3/c"
7678
"../deps/fastpbkdf2"
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
#include <NitroModules/ArrayBuffer.hpp>
2+
#include <memory>
3+
#include <openssl/err.h>
4+
#include <openssl/evp.h>
5+
#include <string>
6+
#include <vector>
7+
8+
#include "HybridScrypt.hpp"
9+
#include "Utils.hpp"
10+
11+
namespace margelo::nitro::crypto {
12+
13+
std::shared_ptr<Promise<std::shared_ptr<ArrayBuffer>>> HybridScrypt::deriveKey(const std::shared_ptr<ArrayBuffer>& password,
14+
const std::shared_ptr<ArrayBuffer>& salt, double N, double r,
15+
double p, double maxmem, double keylen) {
16+
// get owned NativeArrayBuffers before passing to sync function
17+
auto nativePassword = ToNativeArrayBuffer(password);
18+
auto nativeSalt = ToNativeArrayBuffer(salt);
19+
20+
return Promise<std::shared_ptr<ArrayBuffer>>::async([this, nativePassword, nativeSalt, N, r, p, maxmem, keylen]() {
21+
return this->deriveKeySync(nativePassword, nativeSalt, N, r, p, maxmem, keylen);
22+
});
23+
}
24+
25+
std::shared_ptr<ArrayBuffer> HybridScrypt::deriveKeySync(const std::shared_ptr<ArrayBuffer>& password,
26+
const std::shared_ptr<ArrayBuffer>& salt, double N, double r, double p,
27+
double maxmem, double keylen) {
28+
// Use EVP_PBE_scrypt to match Node.js implementation exactly
29+
// All parameters are uint64_t for this API (unlike EVP_KDF which uses uint32_t for r/p)
30+
uint64_t n_val = static_cast<uint64_t>(N);
31+
uint64_t r_val = static_cast<uint64_t>(r);
32+
uint64_t p_val = static_cast<uint64_t>(p);
33+
uint64_t maxmem_val = static_cast<uint64_t>(maxmem);
34+
size_t outLen = static_cast<size_t>(keylen);
35+
36+
if (outLen == 0) {
37+
throw std::runtime_error("SCRYPT length cannot be zero");
38+
}
39+
40+
// Prepare password and salt pointers
41+
const char* pass_data = password && password->size() > 0 ? reinterpret_cast<const char*>(password->data()) : "";
42+
size_t pass_len = password ? password->size() : 0;
43+
44+
const unsigned char* salt_data =
45+
salt && salt->size() > 0 ? reinterpret_cast<const unsigned char*>(salt->data()) : reinterpret_cast<const unsigned char*>("");
46+
size_t salt_len = salt ? salt->size() : 0;
47+
48+
// Allocate output buffer
49+
uint8_t* outBuf = new uint8_t[outLen];
50+
51+
// Use EVP_PBE_scrypt - the same API Node.js uses
52+
int result = EVP_PBE_scrypt(pass_data, pass_len, salt_data, salt_len, n_val, r_val, p_val, maxmem_val, outBuf, outLen);
53+
54+
if (result != 1) {
55+
delete[] outBuf;
56+
throw std::runtime_error("SCRYPT derivation failed: " + getOpenSSLError());
57+
}
58+
59+
return std::make_shared<NativeArrayBuffer>(outBuf, outLen, [=]() { delete[] outBuf; });
60+
}
61+
62+
} // namespace margelo::nitro::crypto
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#pragma once
2+
3+
#include <NitroModules/ArrayBuffer.hpp>
4+
#include <NitroModules/Promise.hpp>
5+
#include <memory>
6+
#include <openssl/evp.h>
7+
#include <string>
8+
9+
#include "HybridScryptSpec.hpp"
10+
11+
namespace margelo::nitro::crypto {
12+
13+
using namespace facebook;
14+
15+
class HybridScrypt : public HybridScryptSpec {
16+
public:
17+
HybridScrypt() : HybridObject(TAG) {}
18+
19+
public:
20+
// Methods
21+
std::shared_ptr<ArrayBuffer> deriveKeySync(const std::shared_ptr<ArrayBuffer>& password, const std::shared_ptr<ArrayBuffer>& salt,
22+
double N, double r, double p, double maxmem, double keylen) override;
23+
std::shared_ptr<Promise<std::shared_ptr<ArrayBuffer>>> deriveKey(const std::shared_ptr<ArrayBuffer>& password,
24+
const std::shared_ptr<ArrayBuffer>& salt, double N, double r, double p,
25+
double maxmem, double keylen) override;
26+
};
27+
28+
} // namespace margelo::nitro::crypto

packages/react-native-quick-crypto/nitro.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
"RsaKeyPair": { "cpp": "HybridRsaKeyPair" },
2424
"SignHandle": { "cpp": "HybridSignHandle" },
2525
"VerifyHandle": { "cpp": "HybridVerifyHandle" },
26-
"MlDsaKeyPair": { "cpp": "HybridMlDsaKeyPair" }
26+
"MlDsaKeyPair": { "cpp": "HybridMlDsaKeyPair" },
27+
"Scrypt": { "cpp": "HybridScrypt" }
2728
},
2829
"ignorePaths": ["node_modules", "lib"]
2930
}

packages/react-native-quick-crypto/nitrogen/generated/android/QuickCrypto+autolinking.cmake

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)