はじめに
データベースのprimary keyをauto incrementしている場合(サロゲートキーを使っている場合)に、もし何も気にせずREST APIでCRUDするようなアプリを作ろうとすると、一番簡単なのはそのPKを使ってデータを操作する事だと思う。
ただ、そのような実装した場合、PKが外に露出してしまうので、ToDoアプリ等であれば問題ないかもしれないが、もしPKがユーザーID等の個人を特定できる情報であれば、サービスの利用ユーザ数が知られてしまったり、何かと悪影響があるだろう。
今回は可逆暗号を利用して、オートインクリメントをしたユーザーIDなど外に露出してほしくない情報を隠す方法を試してみたので備忘録を残す。
※上記のprimary key露出に関しての懸念事項については、idをautoincrementして何が悪いの?などを参照。
※上記の問題を解決するのに、以下で見ていく方法が良いと言えるのか?実際にプロダクトで使えるのか?については、勉強中の身のため何とも言えません…。
やってみる
「暗号・復号(AES)」と「ハッシュ(SHA-2, BCrypt)」に書かれているようなAESを使う。
具体的には、
- サーバーからクライアントにデータを返す:PKを16進数32桁に変換後、any-baseで桁数を削りつつシーザー暗号化する、という方法でランダムな文字列に変換
- クライアントからサーバーにリクエストを送る:それを復号化し元のオートインクリメントのIDでDBへのCRUDを行う
という方法になる。
実装としては以下のようにやってみた。以下の実装はORMのSequelizeを利用している場合の実装(もしmyslq2などのライブラリでSQLを書いてやる場合には、都度SQLの実行結果に対し、encryptingなどを実行する実装になるだろう)。
import crypto from 'crypto';
import config from 'config';
import anyBase from 'any-base';
import pad from 'pad-left';
export default class NumberCryptoDecrypto {
algorithm = 'aes-128-cbc';
inputEncoding = 'utf8';
outputEncoding = 'hex';
constructor(options = {}) {
if (
'seed' in options &&
'separetors' in options &&
options.seed.match(new RegExp(`[${options.separetors}]`))
)
throw new Error('separetors string is not include in seed.');
this.seed = options.seed || config.get('crypto.seed');
this.key = crypto.scryptSync(
options.password || config.get('crypto.password'),
options.salt || config.get('crypto.salt'),
16
);
this.separetors = (
options.separetors || config.get('crypto.separetors')
).split('');
this.hexToShort = anyBase(anyBase.HEX, this.seed);
this.shortToHex = anyBase(this.seed, anyBase.HEX);
}
encrypting(number) {
if (typeof number !== 'number')
throw new Error(`arg must be number value.`);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(this.algorithm, this.key, iv);
let encrypted = cipher.update(
number.toString(),
this.inputEncoding,
this.outputEncoding
);
encrypted += cipher.final('hex');
const separetor = this.separetors[number % this.separetors.length];
const ivHex = iv.toString('hex');
return `${this.hexToShort(encrypted)}${separetor}${this.hexToShort(ivHex)}`;
}
decrypting(hexAndIv) {
try {
const values = hexAndIv.split(
new RegExp(`[${this.separetors.join('')}]`)
);
// 元の数値が16進数16Bytes(32桁)
const numberHex = pad(this.shortToHex(values[0]), 32, '0');
const ivHex = pad(this.shortToHex(values[1]), 32, '0');
const decipher = crypto.createDecipheriv(
this.algorithm,
this.key,
Buffer.from(ivHex, 'hex')
);
let decrypted = decipher.update(
numberHex,
this.outputEncoding,
this.inputEncoding
);
decrypted += decipher.final(this.inputEncoding);
return decrypted;
} catch (e) {
return null;
}
}
}
// 省略
import NumberCryptoDecrypto from '../../lib/number-crypto-decrypto';
// 省略
const numberCryptoDecrypto = new NumberCryptoDecrypto();
export default class Users extends Model {
static init(sequelize, DataTypes) {
return super.init(
{
id: {
allowNull: false,
autoIncrement: true,
primaryKey: true,
type: DataTypes.INTEGER.UNSIGNED
},
// 省略
userId: {
type: DataTypes.VIRTUAL,
get() {
return numberCryptoDecrypto.encrypting(this.getDataValue('id'));
},
set(v) {
this.setDataValue('id', numberCryptoDecrypto.decrypting(v));
}
}
},
{
sequelize,
// 省略
}
);
}
static findByUserId(userId, options = {}) {
if (!userId) throw new Error('userId must be required');
const id = numberCryptoDecrypto.decrypting(userId);
if (!id) return null;
return this.findByPk(id, options);
}
// 省略
}
// 省略
import initModels from './models/sequelize/init-models';
const app = express();
const router = Router();
// 省略
app.locals.models = initModels(new Sequelize(config.get('sequelize')));
app.use('/api/v1', router);
// 省略
router.post('/user', async (req, res) => {
const { models } = req.app.locals;
const { email, password } = req.body;
try {
const { id } = await models.user.create({ email, password });
const user = await models.user.findByPk(id);
res.status(201).json(user.toJSON({ exclude: ['password'] }));
} catch (error) {
res.status(500).error(error);
}
});
router.get('/users', async (req, res) => {
const { models } = req.app.locals;
const { offset, limit } = req.query;
try {
const result = await models.user.findAndCountAll({
offset: offset || 0,
limit: limit || 10
});
res.status(200).json({
total: result.count,
users: result.rows.map((user) => user.toJSON({ exclude: ['password'] }))
});
} catch (error) {
res.status(500).error(error);
}
});
router.get('/user/:userId', async (req, res) => {
const { models } = req.app.locals;
try {
const user = await models.user.findByUserId(req.params.userId, {
attributes: { exclude: [`password`] }
});
if (!user) throw new CustomError(404, 'Not Found');
res.status(200).json(user.toJSON());
} catch (error) {
res.status(500).error(error);
}
});
// 省略
ソースコード全体は以下。
上記の実装について補足をする。
src/lib/number-crypto-decrypto.js
これがnumber -> hex(16進数)への変換を行いany-baseで桁数を削りつつシーザー暗号化する、またその逆をして復号化するモジュール。秘密鍵を1つ共通のものをインスタンス生成時に作成し、それを毎回違う初期化ベクトル(暗号化した結果をばらつかせるためのもの)と合わせて暗号化するような実装をしている。
-
encrypting
ivが初期化ベクトルで今回はランダムな16Bytesを作成し、それと秘密鍵を使って暗号化のためのオブジェクト(cipher)を作成している。あとは、暗号化したい値とそのエンコードを指定して最終的にfinal('hex')を実行すると16進数16Bytes(32桁)が出来上がる。ただ、このままだと復号化時に必要なivと合わせて最小でも64桁になってしまうので、それをany-baseというオリジナルの基数を指定でき、記数法で数値を表現する事ができるライブラリを使い、桁の圧縮をしている(10進数は0~9までだが、16進数は0~9+abcdefなので10進数に比べ桁が少ないのと同じ事)。圧縮をしてPKを暗号化した部分と、ivの部分をセパレーターで繋げて最終的なIDを作っている。
ちなみに、IDの長さとしては、43桁(UUIDがハイフン含みで36桁なのでそれよりも長い…) -
decrypting
encryptingで暗号化して作成したIDをデコードするメソッドで、まずはany-baseで圧縮した状態の数値を元の16進数に戻す。ただし、この時JavaScriptだと数値として扱われ先頭の0が消えるので、それを補うために0埋めしている。その後は、秘密鍵とIDにくっついていたivを使い復号化のためのオブジェクト(decipher)を作成し、暗号化したものを復号化している。
この処理はユーザーからのリクエストを受けてIDからPKを解決する際に呼ばれるので、変なIDが渡ってくる可能性があるが、その際に出るエラーが外に露出するとヒントになるので全て握りつぶし、エラーが出ればnullを返すような実装してみた。 - const separetor = this.separetors[number % this.separetors.length];
セパレーターが複数ある場合に、PKの数字で使われるセパレーターにばらつきが出るようにするための工夫。この実装レベルでいいのか良く分かっていないが…。
上記のような実装をして、実際にAPIを実行してみると以下のようにPKが変換されてランダムな文字列になっている事が確認できる。また、そのIDを使ってユーザー情報が取得できている事も確認できる。
※実装してから気づいたが、今回の実装だと毎回IDが変わるので、これは利用側からすると非常に混乱を招くAPIと言えるのでNGかもしれない…。そしてIDの桁数が43桁と長いのもネックだろう…。
※もしIDをPKではない別のものに変換しつつ、必ず同じIDになるように実装するとしたら、パスワードを保存する時に最低限やるべき事 不可逆暗号(ハッシュ)化で取り上げたハッシュ関数等を使うのがいいのかもしれない。ただし、不可逆なのでハッシュ関数で生成した部分はチェックサム(checksum)として使い、渡ってきたIDの妥当性チェックを行うような実装になるだろう。
イメージとしては、{checksum}{separator}{可逆部}というようなIDで、可逆部を復号化して得られた数値をchecksumを生成する際に利用したハッシュ関数に渡してハッシュを作り、checksumと一致すればその数値をPKとして扱い、不一致ならnullにする、みたいな感じだろうか。この方法の場合、使うハッシュ関数で桁数が違うのでその桁数をいかに抑えるかがポイントになりそう。md5はこういう秘密性のあるものに用いるのはダメなようなので、sha256・sha512などを用いる事になりそうだが、sha256でも16進数64桁もあるので、any-baseのようなライブラリで圧縮してもかなり長くなる…。16進数である事を活かして生成されたハッシュをsubstringして16進数の長さを短くする方法もあるだろうが、この場合substringする桁数nが重要になりそう(例えば、3桁分をsubstringするという事は、16の3乗で約4096通りしかないのでchecksumとして心もとないだろう…)。
まとめとして
今回はオートインクリメントをしているPKが外に露出しないように隠す方法を試してみた(実際に使えるか?は微妙だが)。ただ、そもそも設計思想としてPKをオートインクリメントのIDにするのか?という議論はもちろんあると思う。
また、自分のアプリで認証を行っているのであれば、自分のユーザー情報等をCRUDするのに/api/v1/user/{userId}のようなパスにする必要はないだろう。ログインセッションがあればそのセッションでユーザーIDが特定できるように実装すると思われるので。
また、認証は自分のアプリではなく、認証(認可)サーバーを利用している場合でも、Authorizationヘッダーにトークンを設定してAPIをCallするような実装になるだろう。その場合、そのトークンを払い出す時点で「誰」という情報は持っているので、これまたユーザー情報のCRUD操作時にAPIのパスが/api/v1/user/{userId}のようにIDを指定するようなパスである必要はない。
という事で、作るアプリケーションによっては/api/v1/user/{userId}のような設計をそもそもする必要がない事もある。
※ちなみに、ユーザー登録できるようなアプリケーションでそのユーザーの個人ページが閲覧できるようなサービスではどのように実装されているのか?少し見てみたところ、以下のようなパスにして数値ではなく文字列のIDで一意になるような設計になっているようである(QiitaやTwitterなど)。
https://{domain}/{user_name|account_name}
https://{domain}/id/{user_name|account_name}
ユーチューブは上記とは違い、https://www.youtube.com/channel/{channel_id}のようにランダムなIDを使っているようである。
※IDをランダムな文字列に変換するには、hashidsというライブラリもあるらしい。