はじめに
開発をしていると何らかの数字を利用しているが、フロントエンドではその数字をそのまま露出させたくない場面はままある。具体的にはprimary keyをオートインクリメントしている場合に、そのidを外に露出させない実装をしてみた?!で見たような、オートインクリメントしている数字を露出させたくないが、APIではそれを何らかの形で可逆のIDに変換し、それをキーにしたIFを設計したい、というような場面。
今回はそんな時に使えそうな実装をやってみたので、備忘録を残す。
ソースコード全体は以下(テストも含めて)。
※上記のコードのconfigにdestinationAlphabet
やseparetors
などセキュアな情報が載っているが、本来的にはこれは公開してはダメな情報になるが、今回は検証目的なので公開してしまっている。
※1点、免責として、筆者はセキュリティの専門家ではないため、今回の実装がプロダクトレベルで要求されるレベルを満たせているかは不明です。
実際にやってみる
考え方としては、可逆部+checksum
という構成になるように、数字を文字列(数字も含むが)に変換するという事をする。
まず、可逆部については、古典的な暗号であるシーザー暗号を生成できるライブラリany-baseを利用する(any-baseがシーザー暗号のためのライブラリか?と言われると微妙だが、やっている事としては本質的にはシーザー暗号と同じであると思う。any-baseは10進数の数字などある基数の数字を、任意の基数の数字に変換する、というライブラリ)。
any-baseを利用する事で、例えば、any-baseのdestinationAlphabet
にasdfg
を指定すると、10進数が以下のように変換される。
- 0 →
a
- 1 →
s
- 2 →
d
- 3 →
f
- 4 →
g
- 5 →
sa
- 6 →
ss
- ...
今回は基数が5(5つの文字が基準なので)だったが、これを20とか大きな基数にする事で大きな10進数を小さく表現できるようになる。そして、この変換の規則は秘密になるので、その意味ではシーザー暗号的な側面もあり、数字→IDの変換で用いれば、露出してもそれがどの数字を意味するのか?は分からないという状態を作れる。
ID→数字の変換に関しては、数字→IDの変換をした人であれば簡単にできる復号化できるので、APIのIFで利用する際にも問題ないだろう。
ただ、古典的な暗号のシーザー暗号だけでは心もとない。仮に規則がバレるとIDから数字が導き出せてしまうので。また、(今どきはトークンを利用するのでないと思うが)、別の人に成りすましてAPIを実行できてしまうリスクなども出てくる。
そこで、よりセキュアにするために、自分しか計算できない方法でchecksumを作り、それを可逆部にくっつけるという事も行う事にする。具体的にはsha256で10進数から変換した文字列のハッシュ値をsaltを付加した形で計算し、それをseparetorで分割できるように、文字列結合する。
上記のような考え方で実装したものが以下のソースコードになる。
import anyBase from 'any-base';
import config from 'config';
import crypto from 'crypto';
import { strict as assert } from 'assert';
const sha256hash = (buffer, salt) =>
crypto.createHash('sha256').update(`${salt}:${buffer}`).digest('hex');
export default class NumberConvertor {
checksumLength = 5;
salt = config.get('numberConvertor.salt') || 'salt';
constructor(options = {}) {
this.destinationAlphabet = options.destinationAlphabet || anyBase.HEX;
this.separetors = (
options.separetors || config.get('numberConvertor.separetors')
).split('');
this.encode = anyBase(anyBase.DEC, this.destinationAlphabet);
this.decode = anyBase(this.destinationAlphabet, anyBase.DEC);
this.getChecksum = (encoded) =>
sha256hash(encoded, this.salt).substring(0, this.checksumLength);
}
encrypting(number) {
assert.ok(typeof number === 'number', 'must be number');
const encoded = this.encode(number.toString());
const checksum = this.getChecksum(encoded);
const separetor = this.separetors[number % this.separetors.length];
return `${encoded}${separetor}${checksum}`;
}
decrypting(id) {
assert.ok(typeof id === 'string', 'must be string');
const values = id.split(new RegExp(`[${this.separetors.join('')}]`), 2);
const decoded = parseInt(this.decode(values[0]), 10);
const checksum = this.getChecksum(values[0]);
if (values[1] !== checksum) return null;
return decoded;
}
}
ポイントになる事
- separetorsに指定する文字列の中に、16進数で表現する時に使われる文字列を含めない
まず、今回のchecksumはsha256なので16進数である。という事は文字列としては0123456789abcdef
のいずれかで構成されることになる。
その前提で今回の実装を見てみると、id.split(new RegExp(`[${this.separetors.join('')}]`), 2)
と実装している部分で、separetorsの文字列によって受け取ったid
(文字列)を分割するが、その際に2つに分割される事を期待する。しかし、仮にchecksumの部分にseparetorsの文字列が入り込むとそこでも分割され、decryptingの際にchecksumとの一致チェックで意図しない動きになってしまう。
なので、checksumに入る文字をseparetorsに指定してはいけない。
※そもそもchecksumにseparetorsの文字が含まれないようにする方法もあるだろう。
※this.getChecksumの関数で、.replace(new RegExp(`[${this.separetors.join('')}]`, 'g'), '')
により、checksumにseparetorsの文字が含まれないようにする方法もあるが、分析すればseparetorsがバレそうなのであまり良くない気がしている。
- checksumが長くなりすぎないようにする
sha256のハッシュのままだと64桁もある(256ビット=32バイト=64桁(16進数は1バイト2文字))ので、そこを短くしないとUUIDよりも長いIDになり使えないと思われる。
まとめとして
今回は、以前の記事「primary keyをオートインクリメントしている場合に、そのidを外に露出させない実装をしてみた?!」では実際に使えるような代物ではなった(数字を変換して生成されるID(文字列)が、毎回異なるものになってしまうという致命的な欠陥があった)が、今回の実装であれば、同じ数字であれば必ず同じID(文字列)が生成でき、そのIDを数字に戻す際にchecksumによるチェックも行えるので、かなり前進したのではと思っている。
実際にプロダクトで利用する際には、checksumの計算方法や、separetorの文字数などで課題もありそうなので、今後そういった部分の理解も深められたらと思っている。