サーバ関連系でハマったお話です。

通信時にリクエストとアプリケーション用のキーからシグネチャを生成して認証を行うシステムの開発で、「PHP と node.js でマルチバイト文字が含まれる場合に SHA-256 のハッシュ結果が異なる」ことがありました。
node.js 側のシステムは稼働中であり、こちらを修正することが難しかったため、PHP 側で対処することとなりました。

コード

前提として、文字コードはすべてutf8です。

 1// node.js
 2var crypto = require('crypto');
 3
 4var ascii_string = '1';
 5console.log(hashHmac(ascii_string));
 6// 6ff6ceedb1c100266849d7e4e13449c98d1c98e569994f8ddbfe7b52e780dead
 7
 8var utf8_string = 'あ';
 9console.log(hashHmac(utf8_string));
10// 45709a1cb960f7f15115206d921776da69be6197f486df8b78a04e2790d79c9c
11
12function hashHmac(data) {
13    var hmac = crypto.createHmac('sha256', data);
14    hmac = hmac.update(data);
15    return hmac.digest('hex');
16}
17
1<?php
2$ascii_string = '1';
3echo hash_hmac('sha256', $ascii_string, $ascii_string, false);
4// 6ff6ceedb1c100266849d7e4e13449c98d1c98e569994f8ddbfe7b52e780dead
5
6$utf8_string = 'あ';
7echo hash_hmac('sha256', $ascii_string, $ascii_string, false);
8// 812a1c58990d93aaabb35a0d3cc9cc5b6c6cfcab965d2a5e36b253d80aeca33b
9

ASCII文字の場合は一致しますが、アルゴリズムが同じですので、言語に関わらず結果が等しくならなくてはなりません。 この違いはどこから来るのでしょうか

node.jsの暗黙的な変換

node.jsのドキュメントを見てみますが、こちらにはヒントになる情報はありませんでした。

https://nodejs.org/api/crypto.html#crypto_crypto_createhmac_algorithm_key

node.js側の実行ファイル文字コードををS-JISなどに変更すると、同じ「あ」でも結果が変わることに気づき「 文字コードが何か変換されているのではないか 」とあたりをつけます。

結論として、node.jsのcryptoでは文字を暗黙(?)にASCIIのBufferオブジェクトに変換していることがわかりました。
Bufferはnode.js内でバイナリデータを扱うためのクラスです。

上で示したコードは、次のコードと等価であることがわかりました。

 1// node.js
 2var crypto = require('crypto');
 3
 4var utf8_string = 'あ';
 5console.log(hashHmac(utf8_string));
 6// 45709a1cb960f7f15115206d921776da69be6197f486df8b78a04e2790d79c9c
 7
 8function hashHmac(data) {
 9    var buf  = new Buffer(data, 'ascii');
10    var hmac = crypto.createHmac('sha256', buf);
11    hmac = hmac.update(buf, 'binary');
12    return hmac.digest('hex');
13}
14

Bufferクラスの挙動について

以下参考にしたSourceForgeさんの記事に次の一文がありました。

非ASCII文字が含まれる文字列に対しasciiエンコーディングを指定された場合、各文字の下位7ビット部分のみが使われる。

同じ記事の抜粋ですが、utf8のつもりが、asciiとして扱われておりバイナリコードが書き換えらてていました。

1new Buffer('あ', 'utf8');
2> Buffer <e3 81 82>
3new Buffer('あ', 'ascii')
4> Buffer <42>

「e3 81 82」=「42」???

「e3 81 82」を2進数変換して下位7ビットを抜き出しても、42にはならなずこの点だけが最後までわかりませんでした。 しかし「あ」をunicodeに変換すると u3042 となりますが、この下位バイトが42と、一致するため解決に至りました。

最終的なPHPコード

 1<?php
 2$data = toAscii('あ');
 3echo hash_hmac('sha256', $data, $data, false);
 4// 45709a1cb960f7f15115206d921776da69be6197f486df8b78a04e2790d79c9c
 5
 6function toAscii($string) {
 7    return preg_replace_callback("/((?:[^x09x0Ax0Dx20-x7E]{3})+)/", "callbackToAscii", $string);
 8}
 9function callbackToAscii($matches) {
10    $char  = mb_convert_encoding($matches[1], "UTF-16", "UTF-8");
11    $ascii = "";
12    for ($i = 0, $l = strlen($char); $i < $l; $i += 2) {
13      $ascii .= hex2bin( sprintf("%02x", ord($char[$i+1])) );
14    }
15    return $ascii;
16}
17

まとめ

  • node.jsの文字列にはStringとBufferがある
  • node.jsでBufferにASCIIを指定すると、マルチバイトでも無理やりASCIIに変換してしまう
  • 型を意識する

参考サイト

はじめてのNode.js:Node.js内でバイナリデータを扱うための「Buffer」クラス
http://sourceforge.jp/magazine/13/04/09/193000

はて日記 PHPでユニコードエスケープ(unicode_encode, unicode_decode代替)
http://d.hatena.ne.jp/iizukaw/20090422