「添付ファイルのパスワードは別途送付します」
皆さんも、添付されたZIPファイルと共に、どこかで一度はこの文面を見たことがあると思います。
そして、この行為が無意味であるという事もまた、ご存じかと思います。
しかし、日本の特に大手企業では古い習慣や責任逃れとして、未だに使われ続けているのが現状です。
そして、そのような事を続けている企業ではもれなく、クラウドストレージサービスの使用も制限されているのがお約束です。
今回は、そんな脳死古い思考を引き摺った環境でも、安全でなるべく簡単にファイル転送を行えるアプリケーションを作ってみました。
なぜ無意味なのか
まず初めに、そもそも何故「パスワードを別のメールで送付」が無意味なのかをまとめてみます。
セキュリティ強度
6.0 Traditional PKWARE Encryption
6.0.1 The following information discusses the decryption steps required to support traditional PKWARE encryption.
This form of encryption is considered weak by today's standards and its use is recommended only for situations with low security needs or for compatibility with older .ZIP applications.(意訳) 次の情報は、従来のPKWARE暗号化をサポートするために必要な復号化手順を説明しています。
この形式の暗号化は、今日の標準では弱いと見なされており、セキュリティのニーズが低い状況や、古い.ZIPアプリケーションとの互換性がある場合にのみ使用することをお勧めします。
もうこれが全ての答えのようなものですが、堂々とZIP標準仕様書に「セキュリティ強度が低い」と書かれてあります。
7.1.4 The algorithms introduced in
Version 5.0 of this specification include:
RC2 40 bit, 64 bit, and 128 bit
RC4 40 bit, 64 bit, and 128 bit
DES
3DES 112 bit and 168 bit
Version 5.1 adds support for the following:
AES 128 bit, 192 bit, and 256 bit
えこひいきは良くないので載せますが、一応ZIPでも最近のバージョンでは標準でAESが使えるようになっています。
が、ZIPのバージョンに気を配れるような企業はそもそもこんなことはしてないと思いますし、裏を返せば、未だにしている企業はZIPのバージョンなんて気にもしてないのではないでしょうか。
メール伝送プロトコル
メールデータをHTTPSやSMTPSといったセキュアプロトコルで伝送するのであればまだマシですが、HTTPやSMTPといった非セキュアプロトコルで伝送するとなると、全てが駄々洩れになってしまいます。
令和にもなって、未だHTTPやSMTPを使い続けている企業なんて ま さ か 居ないとは思いますが、機密データのセキュリティが下層プロトコルに左右されるというのは、どの道よろしくありません。
対策
まず何が良くないのかを洗い出してみます。
- 暗号アルゴリズム自体のセキュリティ強度が低い(場合が多い)
- 暗号化と復号を同じ鍵で行うため鍵を相手と共有する必要がある
この2点が挙げられます。
おや...?
これらの課題をキレイサッパリ解決できる方法を僕らは知っているではありませんか。
公開鍵暗号
皆さんご存じ、暗号化と復号で別々の鍵を用いる暗号方式です。
今更だとは思いますが、公開鍵暗号について軽くおさらいです。
公開鍵暗号の仕組は、素因数分解のように、元となる数を知らなければ計算コストが途方も無く莫大なことを安全の根拠にした暗号方式です。
まず、秘匿しなければならない"秘密鍵"を生成し、生成された秘密鍵を元に、公開しても問題ない"公開鍵"を生成します。
暗号化は公開鍵で行い、復号は秘密鍵で行いますが、暗号化に用いた公開鍵の元になった秘密鍵でないと復号できません。
公開鍵暗号はその性質上、安全でない経路を使用した通信で安全を確保するために用いられますが、大容量データの暗号化や復号には向きません。
共通鍵暗号
暗号化と復号に同じ鍵を用いる暗号方式です。
暗号化ZIPも共通鍵暗号方式といえます。
共通鍵暗号でいうところの"鍵"は、世間一般での所謂"パスワード"と同義となります。
鍵を相手と共有しなければならないため、この記事の命題でもある「別途送付します」のような欠点もありますが、大容量データの暗号化や復号も問題なく行えるといった利点もあります。
鍵交換
公開鍵暗号と共通鍵暗号にはそれぞれ利点と欠点があるので、一般的には両方の利点を生かした方法が主流となっています。
まず、事前に相手から公開鍵を入手しておきます。
そして、データ自体は共通鍵で暗号化し、使用したパスワードだけを公開鍵で暗号化します。
共通鍵で暗号化されたデータと公開鍵で暗号化されたパスワードを伝送した後、相手側の秘密鍵でパスワードを復号し、データをパスワードで復号して中身を取り出します。
これを"鍵交換"によるデータ伝送といいます。
仕様
できること
- ファイルの暗号化
- 暗号化データの復号
- 公開鍵/秘密鍵の鍵ペア生成
この3つを、なるべく簡単に行える事が大前提となります。
いくら完璧なアルゴリズムを用いても、見た目がごちゃごちゃしていたり操作感が小難しいと使ってくれませんからね。
プラットフォーム
誰もがすぐ使えるようWebアプリケーションとし、全ての処理をクライアント側で完結させます。
スタンドアロンアプリケーションはアップデートが大変ですし、ダウンロードやインストールとなると面倒臭がるか怖がって使ってもらえないのがオチです。
知らないことは怖いし、怖いことは知りたくないのです。
そして、それが本当の意味で安全ではなくても、周りも同じことをしているから、という安心感を根拠に、それを信じ込んでしまうのです。
暗号アルゴリズム
- 公開鍵暗号:
RSA 4096bit OAEP-SHA256
- 共通鍵暗号:
AES 256bit CBC
公開鍵暗号については、今SSH界隈でアツいと噂の"ED25519/X25519(Curve25519)"を使おうかと思ったのですが、対応しているライブラリが少ないのと、そもそも自分の理解が浅い状態で使うのは良くないので、無難にRSAを使用します。
どうやらCurve25519はRSA/3072bitと同程度の強度らしいので、使用できる鍵長は4096bitに限定します。
共通鍵暗号についても、他の候補として"GCM Mode"の使用など思い浮かびましたが、本当は怖いGCM を読み切れていないのと、タグの概念がまだイマイチ掴めてないので、無難にCBC Modeを使用します。
この辺は、いずれ理解したときに改良できたら良いなと考えています。
暗号化ファイル
AES暗号化データとRSA暗号化パスワードとAES初期ベクトルをそれぞれBase64エンコードし、","(カンマ)で結合したテキストファイルとします。
[2019/11/19 追記]
Base64のままだと容量が大きくなってしまうため、それぞれのデータをバイナリ化してZIP圧縮する方法に変更しました。
実装
使用ライブラリ
node-forge
JavaScriptによるTLS実装です。
TLSに用いられる各種暗号アルゴリズムとユーティリティがオールインワンとなっている、とても優秀な多機能暗号ライブラリです。
名前にnodeと付いてますが、ブラウザでも普通に使えます。
jszip
JavaScriptでZIPを扱えるライブラリです。
暗号化時に複数ファイルを束ねたり、復号時に元に戻すため使用します。
アプリケーション
Vue.jsとVuetify.jsを使います。
簡単にマテリアルUIを組めるのは良いですね。
データ処理
いずれの暗号処理も高い計算負荷が掛かるため、メインスレッドで実行するとUI描画をブロッキングしてしまいます。
データ処理は全てWebWorkerで行い、メインスレッドはUI描画に専念させます。
HTML単独でWebWorkerコードを書けるWorkerInline
というヘルパークラスを作りました。
class WorkerInline extends Worker{
constructor(sources){
super(URL.createObjectURL(new Blob([sources.reduce((a, c) => `${a}\n${String(c).replace(/^.+?\{|\}$/g, "")}`, "")])));
}
}
function wThread(){
console.log("Hello!");
}
const worker = new WorkerInline([
wThread,
()=>{
console.log("World!");
},
function(){console.log("Hoge")}
]);
暗号化
- 暗号化したいファイル
- PEM形式の公開鍵ファイル
を指定し暗号化します。
// message.data -> [[[file:Blob, name:string], ...], pubkey:Blob]
self.addEventListener("message", ({data})=>{
// 複数の入力ファイルを束ねる
data[0].reduce((zip, [name, bin])=>{
return zip.file(name, bin, {
binary: true
});
}, new JSZip())
.generateAsync({
type: "base64",
compression: "DEFLATE",
compressionOptions: {
level: 9
}
})
.then((base)=>{
// PEMファイルから公開鍵オブジェクトを生成
const key = forge.pki.publicKeyFromPem(new FileReaderSync().readAsText(data[1]));
const pw = forge.random.getBytesSync(32);
const iv = forge.random.getBytesSync(16);
// ZIPデータはBase64からBinaryに変換して容量削減
// AES暗号化
const aes = forge.aes.startEncrypting(pw, iv, null, "CBC");
aes.update(forge.util.createBuffer(forge.util.decode64(base)));
aes.finish();
// RSA暗号化
const encPw = key.encrypt(pw, "RSA-OAEP", {
md: forge.md.sha256.create()
});
// ZIPオブジェクトにBibary化したAES暗号化データ・RSA暗号化キー・AES初期ベクトルを追加して圧縮
return [["enc.bin", aes.output.data], ["key.bin", encPw], ["iv.bin", iv]].reduce((zip, [name, bin])=>{
return zip.file(name, forge.util.encode64(bin), {
base64: true
});
}, new JSZip())
.generateAsync({
type: "blob",
compression: "DEFLATE",
compressionOptions: {
level: 9
}
});
})
.then((blob)=>{
self.postMessage(blob);
});
});
復号
- 暗号化ファイル
- PEM形式の秘密鍵ファイル
を指定し復号します。
// message.data -> [file:Blob, privkey:Blob]
self.addEventListener("message", ({data})=>{
// ZIPファイルを解凍
new JSZip().loadAsync(data[0], {
base64: false
})
.then((zip)=>{
// 3つのファイルをPromise:Base64形式で取得
// 全てResolvedになったら次へ
return Promise.all(["enc.bin", "key.bin", "iv.bin"].map(name => zip.file(name).async("base64")));
})
.then((base)=>{
// PEMファイルから秘密鍵オブジェクトを生成
const key = forge.pki.privateKeyFromPem(new FileReaderSync().readAsText(data[1]));
if(4096 > key.n.bitLength()){
throw new Error("key length is too short.");
}
// [AES暗号化データ, RSA暗号化データ, AES初期ベクトル] に分割
const [encData, encPw, iv] = base.map(v => forge.util.decode64(v));
// RSA復号
const decPw = key.decrypt(encPw, "RSA-OAEP", {
md: forge.md.sha256.create()
});
// AES復号
const aes = forge.aes.startDecrypting(decPw, iv, null, "CBC");
aes.update(forge.util.createBuffer(encData));
aes.finish();
// 復号されたファイルは束ねてあるので展開
return new JSZip().loadAsync(forge.util.encode64(aes.output.data), {
base64: true
});
})
.then((zip)=>{
// 各ファイルをPromise:Blob形式で取得
// 全てResolvedになったら次へ
const promises = [];
zip.forEach((name, file) => promises.push(file.async("blob").then(blob => [name, blob])));
return Promise.all(promises);
})
.then((files)=>{
self.postMessage(files);
});
});
鍵生成
RSA 4096bitでPEM形式の鍵ペアを生成します。
OpenSSLをインストールしなくてもブラウザからワンクリックで鍵ペアを生成できるのは、公開鍵暗号という手段への敷居を大きく下げてくれるのではないかと思いました。
self.addEventListener("message", ()=>{
const {publicKey, privateKey} = forge.pki.rsa.generateKeyPair({
bits: 4096,
e: 65537
});
// 余白を削ってPEMファイル化
self.postMessage([forge.pki.publicKeyToPem(publicKey), forge.pki.privateKeyToPem(privateKey)].map(key => new Blob([key.trim().replace(/\r/g, "")])));
});
作ったもの
まとめ
信頼できない&制限された環境で如何に安全なファイル転送を行うか、という問題を自分なりに考え、その対策手段を作ってみました。
仕組自体は簡単な鍵交換ですが、良い経験になりました。