こんにちわ。
JavaScriptによる暗号アルゴリズムの実装は幾つかありますが、今回はWeb Crypto APIというブラウザのネイティブ実装による暗号化を試したいと思います。
Web Crypto API
ネイティブ実装は実行パフォーマンス面で有利ですが関数や入出力などの"お作法"が複雑、逆にソフトウェア実装はパフォーマンスこそ及びませんが、その"お作法"が上手い具合に取り回しやすくなっている傾向があります。
ゆえに、ネイティブ実装のコーディングは少しややこしく感じますが、紐解いていくと実はそんなに難しい話ではないので、順を追って解説していきます。
はじめに
Web Crypto APIは、各種暗号処理(鍵生成/鍵交換/鍵導出/暗号化/復号/署名/検証...)をJavaScriptで安全に実行するためのAPIです。
Web Crypto APIの全機能はwindow.crypto.subtle
オブジェクトに集約されており、いずれの関数もPromise
を返します。
ただMath.random()
より暗号強度の高い乱数を取得できるgetRandomValues()
だけは例外で、関数はwindow.crypto
オブジェクトに存在し、戻り値はTypedArray
となります。
window.crypto.getRandomValues() // => TypedArray
window.crypto.subtle.xxx() // => Promise
Web Crypto APIはいずれの機能も、使用するためのクラスは存在せず静的関数のみで構成され、都度パラメータを入力することで結果を得るかたちとなっています。
鍵オブジェクトは公開鍵/秘密鍵/共通鍵と種類を問わず、全てCryptoKey
というオブジェクト型で管理されます。
このCryptoKey
は、後述のインポート/エクスポート関数を使用してバイナリデータとして入出力が可能です。
Web Crypto APIはWebWorker内で使用可能です。
やること
今回は、楕円曲線暗号による鍵交換(ECDH)と、共通鍵暗号による暗号化(AES)を行います。
Web Crypto APIに実装されている楕円曲線は以下の通りです。
- P-256
- P-384
- P-521
2020年6月時点においては、いずれのブラウザもモンゴメリ曲線(Curve25519)やエドワーズ曲線(Ed25519)といった楕円曲線には対応していません。
Web Crypto APIに実装されている共通鍵暗号アルゴリズムは以下の通りです。
- AES-CTR
- AES-CBC
- AES-GCM
複数の暗号アルゴリズムを組み合わせる場合、暗号強度は一番低いものに依存します。
今回はECDHとAESを組み合わせるのですが、使用する暗号と鍵長の関係について、楕円曲線暗号の暗号強度は鍵長の半分、AES暗号の暗号強度は鍵長とイコールになります。
AESは256bitの鍵長で使用されるケースが大半を占めます。
つまり、組み合わせる楕円曲線暗号は512bit以上の鍵長をとる必要があります。
これらを踏まえて、ECDHはP-521を、AESは256bitのGCMを使用して暗号処理を実装していきます。
ちなみに、P-256(256bit)やP-384(384bit)ときて、なぜP-512(512bit)ではなくP-521(521bit)なのかというと、512bitより521bitのほうがキリが良く(素数:2^521-1)計算効率が上がるから、という理由だそうです。
手順としては、以下のような流れになります。
楕円曲線による公開鍵と秘密鍵の生成
function keyGen(){
const ec = {
name: "ECDH",
namedCurve: "P-521"
};
const usage = ["deriveKey"];
return crypto.subtle.generateKey(ec, true, usage);
}
generateKey()
で、公開鍵と秘密鍵の鍵ペアを生成します。
戻り値はpublicKey
プロパティとprivateKey
プロパティにそれぞれCryptoKey
が入ったCryptoKeyPair
というオブジェクトになります。
ec
で、使用する暗号アルゴリズムと楕円曲線を指定します。
uaage
で、鍵の用途を指定します。
この鍵は鍵交換にのみ使うのでderiveKey
を設定します。
第2引数のbooleanは、鍵のエクスポートを許可するかを指定します。
公開鍵は相手へ送るためにエクスポートしたいのでtrue
を設定します。
公開鍵/秘密鍵のエクスポート
async function keyExport(key, isPub){
const encode = isPub ? "spki" : "pkcs8";
return new Uint8Array(await crypto.subtle.exportKey(encode, key));
}
exportKey()
で、CryptoKey
を各フォーマットのバイナリデータとして出力できます。
戻り値はArrayBuffer
です。
楕円曲線暗号の場合、秘密鍵はPKCS#8、公開鍵はSPKIでの出力に対応しています。
公開鍵/秘密鍵のインポート
function keyImport(key, isPub){
const encode = isPub ? "spki" : "pkcs8";
const ec = {
name: "ECDH",
namedCurve: "P-521"
};
const usage = isPub ? [] : ["deriveKey"];
return crypto.subtle.importKey(encode, key, ec, false, usage);
}
importKey()
で、バイナリデータとして出力した鍵をCryptoKey
に戻せます。
戻り値はCryptoKey
です。
使用する暗号アルゴリズム/楕円曲線/用途とエクスポート時のフォーマットは、それぞれ鍵生成/エクスポート時と同一の必要があります。
第4引数のbooleanはエクスポート許可ですが、一度インポートした鍵を再エクスポートするケースは考えにくいのでfalse
とします。
ECDHによる共通鍵の導出
function keyDerive(pub, priv){
const aes = {
name: "AES-GCM",
length: 256
};
const ec = {
name: "ECDH",
public: pub
};
const usage = ["encrypt", "decrypt"];
return crypto.subtle.deriveKey(ec, priv, aes, false, usage);
}
deriveKey()
で、公開鍵と秘密鍵を使用して共通鍵を導出します。
aes
で、導出した鍵を使用する暗号アルゴリズムを指定します。
ここでのlength
プロパティはAESの鍵長(bit数)となります。
ec
で、鍵導出を行う暗号アルゴリズムと公開鍵を指定します。
usage
は、導出した鍵を暗号化と復号に使うのでencrypt
とdecrypt
を設定します。
第4引数のbooleanはエクスポート許可ですが、共通鍵がエクスポート出来てしまっては、せっかく安全性を保つために公開鍵で鍵交換した意味が台無しなのでfalse
とします。
AESによるデータの暗号化
async function aesEncrypt(key, data){
const aes = {
name: "AES-GCM",
iv: crypto.getRandomValues(new Uint8Array(16)),
tagLength: 128
};
const result = await crypto.subtle.encrypt(aes, key, data);
const buffer = new Uint8Array(aes.iv.byteLength + result.byteLength);
buffer.set(aes.iv, 0);
buffer.set(new Uint8Array(result), aes.iv.byteLength);
return buffer;
}
encrypt()
でデータを暗号化します。
入力データはTypedArray
となります。
戻り値(暗号データ)はArrayBuffer
となります。
初期ベクトル(IV)はgetRandomValues()
で16byteの乱数を取得します。
IVは公開しても問題ない代わりに、復号時も同じものが必要となるので、処理結果の暗号データの先頭に結合しています。
AESによるデータの復号
async function aesDecrypt(key, data){
const aes = {
name: "AES-GCM",
iv: data.subarray(0, 16),
tagLength: 128
};
return new Uint8Array(await crypto.subtle.decrypt(aes, key, data.subarray(16)));
}
decrypt()
で、暗号データを復号します。
入力データはTypedArray
となります。
戻り値はArrayBuffer
となります。
IVは暗号化時に暗号データの先頭に結合したので、今度は先頭16byteをsubarray()
で切り分けます。
17byte以降が暗号データ本体なので、これもsubarray()
で切り分けます。
試してみる
上記のラッパー関数を用いて実際に暗号化/復号を試してみます。
(async()=>{
const key1 = await keyGen();
const key2 = await keyGen();
const key1ExPub = await keyExport(key1.publicKey, true);
const key1ExPriv = await keyExport(key1.privateKey, false);
const key2ExPub = await keyExport(key2.publicKey, true);
const key2ExPriv = await keyExport(key2.privateKey, false);
console.log("--- Exported Keys ---");
for(const key of [key1ExPub, key1ExPriv, key2ExPub, key2ExPriv]){
console.log(key);
}
const key1ImPub = await keyImport(key1ExPub, true);
const key1ImPriv = await keyImport(key1ExPriv, false);
const key2ImPub = await keyImport(key2ExPub, true);
const key2ImPriv = await keyImport(key2ExPriv, false);
console.log("--- Imported Keys ---");
for(const key of [key1ImPub, key1ImPriv, key2ImPub, key2ImPriv]){
console.log(key);
}
const keyDe1Pub2Priv = await keyDerive(key1ImPub, key2ImPriv);
const keyDe2Pub1Priv = await keyDerive(key2ImPub, key1ImPriv);
console.log("--- Derived Keys ---");
for(const key of [keyDe1Pub2Priv, keyDe2Pub1Priv]){
console.log(key);
}
const raw = new TextEncoder().encode("hogehoge");
const enc = await aesEncrypt(keyDe1Pub2Priv, raw);
const dec = await aesDecrypt(keyDe2Pub1Priv, enc);
console.log("--- Results ---");
console.log(new TextDecoder().decode(raw));
console.log(enc);
console.log(new TextDecoder().decode(dec));
})();
無事に暗号化と復号が出来てると思います。
プレイグラウンド
(番外編)気になったこと
今回の記事とはあまり関係ないのですが、Web Crypto APIはCurve25519とEd25519には対応していないんだなぁと思って色々調べてるうちに疑問に思った事が...
楕円曲線 | 鍵交換(ECDH) | 署名(ECDSA) |
---|---|---|
モンゴメリ曲線(Curve25519) | X25519 | ??? |
エドワーズ曲線(Ed25519) | ??? | EdDSA |
モンゴメリ曲線を使用した鍵交換はX25519という仕様があり、エドワーズ曲線を使用した署名はEdDSAという仕様があります。
この2つの曲線は双有理同値という、超ざっくり解釈で"対"となる存在との事です。
(ここら辺は詳しくないので深追い言及は避けておきます)
そして、上記テーブルの "???" の部分が問題で、以前 elliptic という楕円曲線暗号ライブラリを用いて鍵交換を実装した時に、たまたま間違えてEd25519鍵をECDH関数に入力してしまったら、実際に共通鍵を導出できてしまいました。
これは、仕様化されていないが対となる曲線だから出来た必然なのか、それともライブラリのバグなのか...
また、同じ理論でCurve25519を使用した署名も可能なのかも気になるところです。
有識者の方がいらっしゃいましたら、ご教示お願い致します。