scrypt-js vs scryptsy
前端加密场景下的 Scrypt 算法实现选型
scrypt-jsscryptsy

前端加密场景下的 Scrypt 算法实现选型

scrypt-js 和 scryptsy 都是 Scrypt 密码哈希函数的纯 JavaScript 实现,旨在解决浏览器或 Node.js 环境中无法使用原生 C++ 绑定时的加密需求。scrypt-js 由 ethers.js 的作者维护,专注于以太坊生态及现代异步 API 支持,提供 Promise 接口。scryptsy 是一个历史较悠久的实现,曾被广泛使用,但近年来维护频率较低,主要提供同步和回调风格的异步接口。两者都允许在前端安全地执行密钥派生,但实现细节和长期维护状态存在差异。

npm下载趋势

3 年

GitHub Stars 排名

统计详情

npm包名称
下载量
Stars
大小
Issues
发布时间
License
scrypt-js845,114145-136 年前MIT
scryptsy045-67 年前MIT

scrypt-js vs scryptsy:前端 Scrypt 实现深度对比

在前端或 Node.js 环境中执行 Scrypt 密钥派生函数(KDF)时,我们通常无法使用原生的 C++ 绑定(如 scrypt 包),因此必须依赖纯 JavaScript 实现。scrypt-js 和 scryptsy 是这一领域最常见的两个选择。虽然它们的核心目标一致,但在 API 设计、维护状态和运行行为上有显著区别。让我们从架构师的角度深入分析。

⚙️ 核心 API 与调用方式

scrypt-js 采用现代 Promise 风格,支持异步操作,避免阻塞主线程。它允许开发者传入进度回调,便于在 UI 上显示加密进度。

// scrypt-js: Promise 风格 + 进度回调
const scrypt = require('scrypt-js');

const password = Buffer.from('secret');
const salt = Buffer.from('salt');

// 异步执行,不阻塞 UI
scrypt.scrypt(password, salt, 32, {
    N: 1024,
    r: 8,
    p: 1
}, (progress) => {
    console.log(`进度:${progress * 100}%`);
}).then((key) => {
    console.log('派生密钥:', key.toString('hex'));
});

scryptsy 提供同步和异步两种模式,但异步模式基于传统的 Node.js 回调风格(callback),在现代 async/await 代码中需要封装。

// scryptsy: 回调风格
const scryptsy = require('scryptsy');

const password = Buffer.from('secret');
const salt = Buffer.from('salt');

// 异步执行,需嵌套回调
scryptsy(password, salt, 1024, 8, 1, 32, (err, key) => {
    if (err) throw err;
    console.log('派生密钥:', key.toString('hex'));
});

// 也支持同步(不推荐在主线程使用)
// const key = scryptsy.sync(password, salt, 1024, 8, 1, 32);

🛠️ 维护状态与生态依赖

scrypt-js 与以太坊生态(ethers.js)紧密绑定,由知名开发者维护。由于区块链钱包生成在前端的需求稳定,该库的更新和安全性审查较为持续。它对 TypeScript 的支持也更好,类型定义更完善。

// scrypt-js: 类型定义清晰
import { scrypt } from 'scrypt-js';
// TypeScript 能正确推断 scrypt 函数的参数和返回值

scryptsy 是一个较老的库,虽然稳定,但更新频率极低。在 npm 上其版本迭代缓慢,社区活跃度不如前者。对于需要长期维护的企业级项目,这增加了潜在的技术债务风险。

// scryptsy: 类型支持较弱
import scryptsy from 'scryptsy';
// 可能需要手动编写 .d.ts 文件或依赖社区类型包

🚀 性能与阻塞行为

scrypt-js 内部实现了时间分片(time-slicing)逻辑。当计算量过大时,它会主动让出主线程控制权,防止界面冻结。这对于用户体验至关重要。

// scrypt-js: 内置防阻塞机制
// 通过进度回调自动分片执行,UI 保持响应
scrypt.scrypt(password, salt, 32, params, progressCallback)

scryptsy 的异步版本虽然使用 setImmediate 或 process.nextTick 进行分片,但在某些高负载场景下,其分片策略不如 scrypt-js 细腻。同步版本则会完全阻塞事件循环,严禁在浏览器主线程使用。

// scryptsy: 手动控制风险
// 如果不小心调用了 sync 方法,整个页面会卡死
// const key = scryptsy.sync(...); // 危险操作

🔒 安全性与参数校验

scrypt-js 对输入参数有严格的校验,防止因参数错误导致的安全弱点(如 N 值必须是 2 的幂)。它还会清理内存中的敏感数据,减少密钥泄露风险。

// scrypt-js: 严格参数校验
// 如果 N 不是 2 的幂,会直接抛出错误
scrypt.scrypt(password, salt, 32, { N: 1000, r: 8, p: 1 }) 
// 报错:N must be a power of 2

scryptsy 也进行基本校验,但在内存清理和边缘情况处理上不如 scrypt-js 激进。在安全敏感型应用中,更严格的实现通常更受青睐。

// scryptsy: 基础校验
// 可能允许某些非标准参数通过,依赖开发者自行确保安全性
scryptsy(password, salt, 1000, 8, 1, 32, callback)

🌐 环境兼容性

scrypt-js 专为浏览器和 Node.js 双环境设计,不依赖 Node.js 特有的 crypto 模块(尽管可以使用),因此在 Webpack 或 Vite 打包时配置更少。

// scrypt-js: 零配置浏览器支持
// 直接在浏览器环境中运行,无需 polyfill
import { scrypt } from 'scrypt-js';

scryptsy 同样支持双环境,但在某些现代构建工具中,可能需要处理 Buffer 的 polyfill,因为它更依赖 Node.js 的 Buffer 实现。

// scryptsy: 可能需要 Buffer polyfill
// 在纯浏览器环境中,有时需引入 buffer 包
import { Buffer } from 'buffer';

🤝 共同点:纯 JavaScript 实现

尽管有上述差异,两者在核心原理上是一致的。它们都是纯 JS 实现,不依赖原生模块,这意味着它们可以在任何支持 ES5/ES6 的环境中运行。

1. 🔐 相同的算法标准

  • 都实现了标准的 Scrypt 算法(RFC 7914)。
  • 输出结果在相同参数下是一致的(可互操作)。
// 两者在相同参数下生成相同的密钥
// scrypt-js 结果
const key1 = await scrypt.scrypt(...);
// scryptsy 结果
const key2 = await scryptsy(...);
// key1.toString('hex') === key2.toString('hex')

2. 📦 无原生依赖

  • 不需要编译 C++ 扩展。
  • 安装简单,跨平台兼容性好。
// 安装方式相同
npm install scrypt-js
npm install scryptsy

3. ⚙️ 可配置参数

  • 都允许调整 N, r, p 参数以平衡安全性和性能。
  • 支持自定义导出密钥长度。
// 参数配置结构类似
const params = { N: 1024, r: 8, p: 1 };
// 两者都接受这些核心参数

📊 总结对比表

特性scrypt-jsscryptsy
API 风格✅ Promise / async-await⚠️ Callback / Sync
维护状态🟢 活跃 (ethers 生态)🟡 低频维护
类型支持✅ 内置 TypeScript⚠️ 需社区类型
主线程保护✅ 自动时间分片⚠️ 需手动注意
浏览器兼容✅ 开箱即用✅ 需 Buffer polyfill
推荐场景新项目 / 钱包应用旧项目维护

💡 架构师建议

scrypt-js 像是为现代 Web 应用打造的工具 🔧——它理解前端主线程的脆弱性,提供了友好的异步接口和进度反馈。如果你的项目涉及用户密码加密、钱包生成或任何需要 KDF 的场景,它是更稳妥的选择。

scryptsy 像是一个经典的老式引擎 🚂——它曾经非常可靠,但现在显得有些过时。除非你正在维护一个依赖它的遗留系统,否则在新架构中引入它会增加未来的迁移成本。

最终建议:在 2024 年及以后的前端架构中,优先选择 scrypt-js。它不仅 API 更现代,而且背后的维护团队在安全领域的投入更值得信赖。对于加密库而言,维护活跃度本身就是安全性的重要指标。

如何选择: scrypt-js vs scryptsy

  • scrypt-js:

    如果你的项目依赖 ethers.js 或需要标准的 Promise 接口,选择 scrypt-js。它在现代前端工程化中集成更顺畅,且维护状态相对活跃,适合需要长期支持的新项目。特别是当你需要精确控制进度回调或在中端性能设备上运行时,它的优化更为友好。

  • scryptsy:

    仅在与旧系统兼容或必须依赖特定历史版本时使用 scryptsy。由于该库维护频率较低,且 API 风格偏向传统的回调模式,不建议在新架构中作为首选。除非你有明确的迁移成本顾虑,否则应优先考虑更现代的实现方案。

scrypt-js的README

scrypt

The scrypt password-base key derivation function (pbkdf) is an algorithm designed to be brute-force resistant that converts human readable passwords into fixed length arrays of bytes, which can then be used as a key for symmetric block ciphers, private keys, et cetera.

Features:

  • Non-blocking - Gives other events in the event loop opportunities to run (asynchronous)
  • Cancellable - If the key is no longer required, the computation can be cancelled
  • Progress Callback - Provides the current progress of key derivation as a percentage complete

Tuning

The scrypt algorithm is, by design, expensive to execute, which increases the amount of time an attacker requires in order to brute force guess a password, adjustable by several parameters which can be tuned:

  • N - The CPU/memory cost; increasing this increases the overall difficulty
  • r - The block size; increasing this increases the dependency on memory latency and bandwidth
  • p - The parallelization cost; increasing this increases the dependency on multi-processing

Installing

node.js

If you do not require the progress callback or cancellable features, and your application is specific to node.js, you should likely use the built-in crypto package.

Otherwise, to install in node.js, use:

npm install scrypt-js

browser

<script src="https://raw.githubusercontent.com/ricmoo/scrypt-js/master/scrypt.js" type="text/javascript"></script>

API

scrypt . scrypt ( password , salt , N , r , p , dkLen [ , progressCallback ] ) => Promise

Compute the scrypt PBKDF asynchronously using a Promise. If progressCallback is provided, it is periodically called with a single parameter, a number between 0 and 1 (inclusive) indicating the completion progress; it will always emit 0 at the beginning and 1 at the end, and numbers between may repeat.

scrypt . syncScrypt ( password , salt , N , r , p , dkLen ) => Uint8Array

Compute the scrypt PBKDF synchronously. Keep in mind this may stall UI and other tasks and the asynchronous version is highly preferred.

Example

<html>
  <body>
    <div><span id="progress"></span>% complete...</div>
    <!-- These two libraries are highly recommended for encoding password/salt -->
    <script src="libs/buffer.js" type="text/javascript"></script>

    <!-- This shim library greatly improves performance of the scrypt algorithm -->
    <script src="libs/setImmediate.js" type="text/javascript"></script>

    <script src="index.js" type="text/javascript"></script>
    <script type="text/javascript">

      // See the section below: "Encoding Notes"
      const password = new buffer.SlowBuffer("anyPassword".normalize('NFKC'));
      const salt = new buffer.SlowBuffer("someSalt".normalize('NFKC'));

      const N = 1024, r = 8, p = 1;
      const dkLen = 32;

      function updateInterface(progress) {
          document.getElementById("progress").textContent = Math.trunc(100 * progress);
      }

      // Async
      const keyPromise = scrypt.scrypt(password, salt, N, r, p, dkLen, updateInterface);

      keyPromise.then(function(key) {
          console.log("Derived Key (async): ", key);
      });

      // Sync
      const key = scrypt.syncScrypt(password, salt, N, r, p, dkLen);
      console.log("Derived Key (sync): ", key);
    </script>
  </body>
</html>

Encoding Notes

TL;DR - either only allow ASCII characters in passwords, or use
        String.prototype.normalize('NFKC') on any password

It is HIGHLY recommended that you do NOT pass strings into this (or any password-base key derivation function) library without careful consideration; you should convert your strings to a canonical format that you will use consistently across all platforms.

When encoding passwords with UTF-8, it is important to realize that there may be multiple UTF-8 representations of a given string. Since the key generated by a password-base key derivation function is dependent on the specific bytes, this matters a great deal.

Composed vs. Decomposed

Certain UTF-8 code points can be combined with other characters to create composed characters. For example, the letter a with the umlaut diacritic mark (two dots over it) can be expressed two ways; as its composed form, U+00FC; or its decomposed form, which is the letter "u" followed by U+0308 (which basically means modify the previous character by adding an umlaut to it).

// In the following two cases, a "u" with an umlaut would be seen
> '\u00fc'
> 'u\u0308'


// In its composed form, it is 2 bytes long
> new Buffer('u\u0308'.normalize('NFKC'))
<Buffer c3 bc>
> new Buffer('\u00fc')
<Buffer c3 bc>

// Whereas the decomposed form is 3 bytes, the letter u followed by U+0308
> new Buffer('\u00fc'.normalize('NFKD'))
<Buffer 75 cc 88>
> new Buffer('u\u0308')
<Buffer 75 cc 88>

Compatibility equivalence mode

Certain strings are often displayed the same, even though they may have different semantic means. For example, UTF-8 provides a code point for the roman number for one, which appears as the letter I, in most fonts identically. Compatibility equivalence will fold these two cases into simply the capital letter I.

> '\u2160'
'I'
> 'I'
'I'
> '\u2160' === 'I'
false
> '\u2160'.normalize('NFKC') === 'I'
true

Normalizing

The normalize() method of a string can be used to convert a string to a specific form. Without going into too much detail, I generally recommend NFKC, however if you wish to dive deeper into this, a nice short summary can be found in Pythons unicodedata module's documentation.

For browsers without normalize() support, the npm unorm module can be used to polyfill strings.

Another example of encoding woes

One quick story I will share is a project which used the SHA256(encodeURI(password)) as a key, which (ignoring rainbow table attacks) had an unfortunate consequence of old web browsers replacing spaces with + while on new web browsers, replacing it with a %20, causing issues for anyone who used spaces in their password.

Suggestions

  • While it may be inconvenient to many international users, one option is to restrict passwords to a safe subset of ASCII, for example: /^[A-Za-z0-9!@#$%^&*()]+$/.
  • My personal recommendation is to normalize to the NFKC form, however, one could imagine setting their password to a Chinese phrase on one computer, and then one day using a computer that does not have Chinese input capabilities and therefore be unable to log in.

See: Unicode Equivalence

Tests

The test cases from the scrypt whitepaper are included in test/test-vectors.json and can be run using:

npm test

Special Thanks

I would like to thank @dchest for his scrypt-async library and for his assistance providing feedback and optimization suggestions.

License

MIT license.

References

Donations

Obviously, it's all licensed under the MIT license, so use it as you wish; but if you'd like to buy me a coffee, I won't complain. =)

  • Ethereum - ricmoo.eth