JSON 正規化:同じオブジェクトが二つのハッシュになる理由
キーの順序、数値の表記、エスケープの仕方でバイト列は変わります。ハッシュを安定させるには、JSON.stringify に期待せずシリアライズ層で規則を固定します。
同じデータから二つのハッシュが出るとき、原因はまずハッシュ関数ではありません。規則を固定していないシリアライズです。JSON.stringify の出力はキーの挿入順に依存し、複数箇所でオブジェクトを組み立てる場合それは保証されません。
JSON.stringify({ a: 1, b: 2 }) // '{"a":1,"b":2}'
JSON.stringify({ b: 2, a: 1 }) // '{"b":2,"a":1}' ← 同じデータ
固定すべき三つ
キーの順序。 Unicode コードポイントの昇順で並べ、再帰的に適用します。
数値の表記。 1、1.0、1e0 は JSON では同じ値ですがバイト列は違います。最短の十進表記に統一し、指数表記を禁じるのが一般的です。
文字列のエスケープ。 \/ と /、\u00e9 と é は等価です。どの文字をエスケープし、どれを素通しするかを決める必要があります。
実用的な実装
function canonicalize(value: unknown): string {
if (value === null || typeof value !== 'object') {
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new Error('non-finite');
return JSON.stringify(value); // JS の最短表記を採用
}
return JSON.stringify(value);
}
if (Array.isArray(value)) {
return '[' + value.map(canonicalize).join(',') + ']';
}
const keys = Object.keys(value as object).sort();
const parts = keys.map(
(k) => JSON.stringify(k) + ':' + canonicalize((value as Record<string, unknown>)[k]),
);
return '{' + parts.join(',') + '}';
}
キーの順序は解決しますが、数値は解決しません。1.0 は JS では 1 なので言語が吸収します。他人の JSON を解析して再シリアライズする場合、1e2 は 100 になり、これは望ましい正規化です。
本当に必要な場面
| 場面 | 必要か | 理由 |
|---|---|---|
| 内容アドレス型ストレージ | 必要 | 同じ内容は同じアドレス |
| リクエスト署名 | 必要 | クライアントとサーバが一致すべき |
| キャッシュキー | 必要 | でないと等価な要求が別枠になる |
| 冪等キー | 必要 | でないと再送が新規扱いになる |
| 通常のログ | 不要 | キー順を読む人はいない |
署名での追加条件
リクエスト署名では、正規化後のバイト列について双方が同一の規範を共有する必要があり、その規範は API ドキュメントに書くべきです。JCS(RFC 8785)はまさにそのために定義されました。数値を ECMAScript の Number::toString 出力に、順序を UTF-16 コード単位に限定します。
自前で作らないでください。両側で別々に正規化関数を書くのは、いつか境界値で食い違うという約束です。
ハッシュが不安定なときは、ハッシュより先にシリアライズを疑ってください。「ハッシュ不一致」のほぼすべてはキー順に行き着きます。

コメント
…