scrypt-js と scryptsy は、どちらもパスワードベースの鍵導出関数(KDF)である Scrypt を JavaScript で実装したパッケージです。Scrypt は、ブルートフォース攻撃に対して耐性を持つように設計されており、パスワードのハッシュ化や暗号鍵の生成に使用されます。scrypt-js はブラウザと Node.js の両方で動作する純粋な JavaScript 実装を目指しており、Web Worker を利用した非同期処理に重点を置いています。一方、scryptsy はより古くから存在する実装で、Node.js のネイティブ crypto モジュールとの互換性や、特定の環境での最適化を特徴としていますが、メンテナンス状況に注意が必要です。
パスワードベースの鍵導出関数(KDF)である Scrypt を JavaScript で実装する際、scrypt-js と scryptsy は長年候補として挙がってきました。しかし、フロントエンドアーキテクチャの観点から見ると、両者の設計思想、メンテナンス状況、そしてセキュリティへのアプローチには明確な違いがあります。特に、メインスレッドをブロックしない処理と、進行状況の可視化は、ユーザー体験に直結する重要な要素です。
scrypt-js は、セキュリティとブラウザでの動作を第一に設計されています。
// scrypt-js: 進行状況の監視が可能
const scrypt = require('scrypt-js');
scrypt.scrypt(
Buffer.from(password),
Buffer.from(salt),
N, r, p, dkLen,
(error, progress, key) => {
if (error) { throw error; }
if (key) { return key; }
// progress: 0.0 ~ 1.0
updateProgressBar(progress);
}
);
scryptsy は歴史のあるライブラリですが、メンテナンス頻度は低いです。
// scryptsy: 基本的な同期/非同期呼び出し
const scryptsy = require('scryptsy');
// 同期版(メインスレッドをブロックするためブラウザでは非推奨)
const key = scryptsy(password, salt, N, r, p, dkLen);
// 非同期版
scryptsy.async(password, salt, N, r, p, dkLen, (err, hash) => {
if (err) { throw err; }
// 進行状況のコールバックは標準では提供されない
});
Scrypt 計算は意図的に計算コストが高く、CPU を消費します。フロントエンドでは、これが UI のフリーズ(メインスレッドのブロック)を引き起こす主要な原因となります。
scrypt-js は、処理を細かく分割して実行する仕組みを持っています。
setTimeout などを内部で使い、イベントループを占有しすぎないように制御しています。// scrypt-js: 非同期処理で UI ブロックを回避
scrypt.scrypt(
Buffer.from(password),
Buffer.from(salt),
1024, 1, 1, 32,
(error, progress, key) => {
// UI スレッドをブロックせずに進行状況を更新可能
console.log(`Progress: ${progress * 100}%`);
}
);
scryptsy は、単純な非同期ラッパーを提供していますが、内部処理の細分化までは保証されていません。
// scryptsy: 自前で Worker 管理が必要な場合が多い
// main.js
const worker = new Worker('scrypt-worker.js');
worker.postMessage({ password, salt });
// scrypt-worker.js
const scryptsy = require('scryptsy');
self.onmessage = (e) => {
// Worker 内でも長時間処理になるリスクがある
const hash = scryptsy(e.data.password, e.data.salt, 1024, 1, 1, 32);
self.postMessage(hash);
};
API の設計は、開発者の生産性とミスの起こりにくさに影響します。
scrypt-js は、コールバックベースの API を採用しています。
util.promisify を使うと現代的な async/await 構文で扱えます。// scrypt-js: Promise ラッパー例
const scryptPromise = (password, salt) => {
return new Promise((resolve, reject) => {
scrypt.scrypt(password, salt, 1024, 1, 1, 32, (err, progress, key) => {
if (err) reject(err);
if (key) resolve(key);
});
});
};
const key = await scryptPromise(Buffer.from(pass), Buffer.from(salt));
scryptsy は、同期的な関数と非同期な関数が別れています。
// scryptsy: 同期と非同期の使い分け
const hashSync = scryptsy(password, salt, 1024, 1, 1, 32); // ブロック注意
scryptsy.async(password, salt, 1024, 1, 1, 32, (err, hash) => {
// 非同期処理
});
パッケージがどの環境で動作するかは、アーキテクチャ決定の重要な要素です。
scrypt-js は、環境依存を最小限に抑えています。
crypto モジュールや Buffer に依存しますが、ブラウザ向けに polyfill や変換処理を内包しています。// scrypt-js: バンドラーとの互換性
// webpack.config.js などで特別な設定が不要な場合が多い
import scrypt from 'scrypt-js';
// ブラウザでも Node でも同じ API で動作
scryptsy は、Node.js 寄りの設計です。
Buffer の polyfill や、プロセス関連のグローバル変数の設定が必要になることがあります。// scryptsy: ブラウザでの設定例
// Buffer の polyfill が必要な場合がある
import { Buffer } from 'buffer';
window.Buffer = Buffer;
import scryptsy from 'scryptsy';
両パッケージとも、同じ Scrypt 算法に基づいており、適切なパラメータを与えれば同じ出力を生成します。
// 両者共通のパラメータ概念
// N: CPU コスト (2 のべき乗)
// r: メモリコスト
// p: 並列化パラメータ
const N = 1024, r = 1, p = 1;
// 両者共通の入力イメージ
const password = 'my-secret-password';
const salt = crypto.randomBytes(16); // または保存済みのソルト
// 両者共通の用途:AES 鍵の生成
const key = await deriveKey(password, salt);
const cipher = aes.encrypt(data, key);
| 特徴 | scrypt-js | scryptsy |
|---|---|---|
| メンテナンス | ✅ 比較的活発 | ⚠️ 停滞気味 |
| UI ブロック対策 | ✅ 内部で細分化処理 | ❌ 自前で Worker 管理が必要 |
| 進行状況監視 | ✅ コールバックで提供 | ❌ 標準では提供されない |
| ブラウザ対応 | ✅ 容易 | ⚠️ Polyfill 設定が必要な場合あり |
| API 形態 | コールバック(Progress 付き) | 同期 / 非同期(別関数) |
| 推奨度 | 🟢 新しいプロジェクト | 🟡 レガシー維持の場合のみ |
scrypt-js は、現代のフロントエンド開発において、セキュリティとユーザー体験のバランスが取れた選択肢です。特に、進行状況の通知機能と、メインスレッドをブロックしない設計は、パスワード入力などのインタラクティブな場面において不可欠な機能です。新しいプロジェクトでは、これを第一候補とするべきです。
scryptsy は、過去の資産や、特定の Node.js 環境との厳密な互換性が必要な場合にのみ検討します。しかし、メンテナンスのリスクを考慮すると、長期的には scrypt-js への移行、あるいは Web Crypto API を活用したより現代的なアプローチ(PBKDF2 など、ブラウザネイティブサポートがあるもの)への切り替えを検討することが賢明です。
最終的なアドバイス:暗号化実装では「動くこと」以上に「安全であること」と「維持できること」が重要です。パッケージの更新履歴とセキュリティ報告を定期的にチェックし、必要に応じて専門家の監査を受けることを忘れないでください。
ブラウザ環境でのセキュリティと UX を最優先する場合に scrypt-js を選択します。このパッケージは Web Worker を活用してメインスレッドをブロックせず、進行状況(progress)のコールバックを提供するため、ユーザーに処理中の状態を表示しやすいです。純粋な JavaScript 実装であるため、Node.js の crypto モジュールに依存せず、環境を選ばず動作します。
既存のレガシーシステムとの互換性が必須である場合、または Node.js サーバー環境でネイティブモジュールとの挙動を厳密に合わせる必要がある場合に scryptsy を検討します。ただし、メンテナンスが停滞している可能性が高く、新しいプロジェクトではセキュリティと長期サポートの観点から scrypt-js や他の現代的な代替案(例:Web Crypto API を利用した実装)の評価を強く推奨します。
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.
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:
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>
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.
<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>
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.
/^[A-Za-z0-9!@#$%^&*()]+$/.See: Unicode Equivalence
The test cases from the scrypt whitepaper are included in test/test-vectors.json and can be run using:
npm test
I would like to thank @dchest for his scrypt-async library and for his assistance providing feedback and optimization suggestions.
MIT license.
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. =)
ricmoo.eth