最終回となる今回は、Mega というサービスを題材に、クライアントサイドでの暗号化とバイナリデータの扱いについて取り上げます。
Megauploadの閉鎖と復活
2012年1月19日、Megaの前身となるMegauploadが、FBIの強制捜査を受け閉鎖しました[1] 。Megauploadはオンラインストレージサービスですが、閉鎖時には登録ユーザ数が1億8,000万人、アップロードされたファイルが合計25PB(ペタバイト)あったといいます 。
Megauploadのようなオンラインストレージサービスを、「 サイバーロッカー」と呼びます。同種のサービスには、おおむね次のような特徴があります。
大容量のファイルをアップロードすることが可能である
同時ダウンロード数や速度が制限されていて、有料会員登録することでダウンロード速度が速くなる
ファイルをアップロードすることで報酬を得られるプログラムがある
実態として、多くの音楽や映画などがアップロードされ、著作権侵害を助長し利益を上げていたとして、コンテンツ産業から問題視されてきました。Megauploadの捜査とサービス閉鎖後、同様のオンラインストレージサービスの多くが、アフィリエイトプログラムの終了や、違法アップロードの自主的な排除の強化など、方針転換を行っています。
知名度が高く、登録ユーザの多いサービスでしたので、「 著作権を侵害していない」ファイルも数多くアップロードされていました。サービス閉鎖に伴い、Megauploadにアップロードされていたファイルのすべてが参照不可能になり、プライベートなファイル保存目的で使っていたユーザも巻き添えを食らってしまったわけです 。
1年後の2013年1月19日、Megaとして復活しました。サービスの内容は、「 ローカルでファイルの内容を暗号化し、運営者からも何がアップロードされているのかわからなくする」というものでした。
サービス運営者にも中身がわからない
サービス運営者にも中身がわからない、といったことを売りにするサービスは今までもありました。LastPass のようなパスワード管理サービス、あるいは専用ソフトウェアを使ったストレージサービスのいくつかは「運営者にもわからない暗号化」を行っています。
Megaは、ブラウザだけで大容量ファイルの暗号化/復号を行っているのがポイントです。これだけ大規模なストレージサービスでの暗号化を、ここまで前面に押し出しているのは初めてのことではないかと思われます。Megaを動かすしくみには、つい最近できるようになったフロントエンドの新しい技術が数多く含まれています。JavaScriptに関心のあるエンジニアであれば、興味深いテクノロジがいくつも使われているのです。
Megaに学ぶクライアントサイド技術
というわけでMegaのようなクライアントサイドでの暗号化/復号を行うアップローダのサンプルを作ってみましたので、ソースコード解説をしていきます。サンプルコードは本誌サポートサイト からダウンロードしてください。動作するサンプルは次のURLからアクセスできます[2] 。
このサンプルには、次の大きな特徴があります。
ファイルはブラウザ上で暗号化/復号化を行う
復号にはパスワードが必要だが、パスワードをlocation.hash[3] を使って受け渡すことで負担なくダウンロードできる
それではアップロードとダウンロードに分けて、新しいテクノロジの数々を紐解いていきましょう。
暗号化してアップロード
ファイルを暗号化してアップロードするには
ローカルファイルを読み取る
暗号化する
アップロードする
という3つの処理が必要です。最近のPCとブラウザであれば、数MBのファイルは難なくこなすことができるでしょうが、ここではメモリ消費を抑えるため、すべての処理をストリーミングで行います。
ファイルを受け取る
ブラウザからのファイルアップロードと言えば、伝統的にはinput type = file
を使うことで行えます。
今までは、この方法でJavaScriptから参照可能なファイルの情報は限られていました。ファイル名を取得することはできても、ファイルの中身を読むことまではできなかったのです。
HTML5にてFile APIが整備されたことで、input type = file
によって選択されたファイル、またはドラッグ&ドロップによってブラウザのウィンドウにドロップされたファイルの中身を、JavaScript自身が参照できるようになりました。サンプルではドラッグ&ドロップによってファイルを受け取っています。
分割して読み取る
File APIにより、ブラウザ上のJavaScriptからローカルファイルを参照可能になりましたが、ローカルでこのファイルを暗号化する際、バイナリデータとしてこのファイルを読み取っていく必要があります。ファイル選択ダイアログやドラッグ&ドロップで渡されたファイルは、Fileオブジェクトになっています。Fileオブジェクトには、name(名前) 、size(ファイルサイズ) 、type(MIMEタイプ)( 注4 )といったプロパティが設定されていますが、ファイルの中身を直接読み取るインタフェースは備わっていません。Fileの中身を読み取るためには次のように、FileReaderを使います。
reader = new FileReader;
reader.onload = function(){
reader.result;
};
reader.readAsBinaryString(file);
Fileオブジェクトが作られた段階では、Fileの中身は、まだアップロードしているユーザのブラウザのメモリ上に読み込まれていません。この段階では、ディスク上にあるファイルを参照しているだけのオブジェクトです。FileReaderを使うことで、ディスクからファイルの中身を非同期で読み取り、完了次第、onloadイベントが発生します。ただし、こういった普通のやり方でファイルを読み込むと、巨大なファイルを読み込んだ場合には大量のメモリを消費してしまいます。ここでは省メモリで巨大なファイルを取り扱うノウハウが必要です。
単純にFileオブジェクトをFileReaderを使って読み込む場合、巨大なファイルを読み込むとメモリを大量に消費してしまいます。この問題は、Fileオブジェクトを小さく分割しながら、順次読み込んでいくことで回避できます。一度に読み込むファイルのバイト数を設定し、offsetを移動しながらFileオブジェクトの一部分のみを参照するBlobオブジェクト[5] を作っていきます。表1 に、指定できるクラスの役割を示します。
表1 File APIに関する各クラス
クラス名 役割
Blob サイズ固定、不変のバイナリオブジェクト
File Blobにファイル用のプロパティが備わったもの
FileReader Blob、Fileを実際に読み込む
分割して暗号化する
それでは、読み取ったデータを暗号化していきましょう。ここでは、crypto-js というライブラリを使います(リスト1 ) 。ハッシュ関数や暗号化ライブラリは多数の実装が存在しますが、ストリームの入力に対応していることは重要です[6] 。
リスト1 crypto-jsを使ったサンプルコード
<script src="http://crypto-js.googlecode.com/svn/tags/3.1.2/build/rollups/aes.js"></script>
<script>
var key = CryptoJS.enc.Hex.parse('000102030405060708090a0b0c0d0e0f');
var iv = CryptoJS.enc.Hex.parse('101112131415161718191a1b1c1d1e1f');
var aesEncryptor = CryptoJS.algo.AES.createEncryptor(key, { iv: iv });
var ciphertextPart1 = aesEncryptor.process("Message Part 1");
var ciphertextPart2 = aesEncryptor.process("Message Part 2");
var ciphertextPart3 = aesEncryptor.process("Message Part 3");
var ciphertextPart4 = aesEncryptor.finalize();
var aesDecryptor = CryptoJS.algo.AES.createDecryptor(key, { iv: iv });
var plaintextPart1 = aesDecryptor.process(ciphertextPart1);
var plaintextPart2 = aesDecryptor.process(ciphertextPart2);
var plaintextPart3 = aesDecryptor.process(ciphertextPart3);
var plaintextPart4 = aesDecryptor.process(ciphertextPart4);
var plaintextPart5 = aesDecryptor.finalize();
</script>
通常の暗号化が文字列とパスワードを一度に渡し、暗号化された文字列を得るのに対し、分割して暗号化するには、process
(リスト1 (1) )とfinalize
(リスト1 (2) )を使います。リスト1 (1) が、分割して順次暗号化している個所です。分割して入力された文字列をバッファに格納し、ブロック単位で暗号化していきます。入力文字列をブロックの長さで分割していきますので、割り切れなかった部分はバッファに残ってしまっています。リスト1 (2) でfinalizeを呼ぶことで、ブロックから溢れたバッファに残っている部分を暗号化し、出力することができるのです[7] 。
暗号化したそばからアップロード
大容量のファイルをサーバに直接アップロードする場合、サーバ側でも受け取ったコンテンツをメモリ上に展開せず、ファイルに直接保存するなどの工夫が必要です。ファイルをブラウザ側でメモリに溜め込まず、分割して逐次処理をしていますので、サーバにも分割したまま次々とアップロードしていきましょう。ファイル名にはランダムに生成した文字列のハッシュ値を使用しています。元の文字列がわからない限り上書きも削除もできません。サンプルはリスト2 のとおりです。
リスト2 サーバ側の実装例
# app.psgi
# plackup --port=5000 app.psgi
use strict;
use Plack::Builder;
use Plack::Request;
use Plack::App::Directory;
use Path::Class;
use Digest::MD5 qw(md5_hex);
my $upload_dir = "./files/";
builder {
mount "/files" => Plack::App::File->new(
root => $upload_dir);
mount "/upload" => sub {
my $env = shift;
my $req = Plack::Request->new($env);
if ($req->method eq "POST") {
my $file = file(
$upload_dir . md5_hex($req->param("key")));
my $appender = $file->open('a') or die $!;
$appender->print($req->raw_body);
$appender->close;
}
[200, [], ["OK"]];
};
mount "/delete" => sub {
my $env = shift;
my $req = Plack::Request->new($env);
my $success;
if ($req->method eq "POST") {
my $file = file(
$upload_dir . md5_hex($req->param("key")));
$success = $file->remove;
}
[200, [], [ $success ? "OK" : "NG" ]];
};
mount "/" => sub {
my $env = shift;
if ($env->{PATH_INFO} eq "/") {
return Plack::App::File->new(
file => './static/index.html')->call($env);
}
Plack::App::File->new(root => './static/')->call($env);
};
};
サーバ側での実装に合わせて形式は自由に決めてもよいのですが、ここではArrayBufferを使ってバイナリデータをそのまま送信します。ファイル名を決定するために必要なパラメータは、URLへのクエリパラメータで指定するようにしました。アップロードクライアント側の処理は、リスト3 のとおりです。
以上が暗号化してアップロードするまでの流れです。次は、アップロードされたファイルをダウンロードする際の復号する処理について解説します。
リスト3 アップロードクライアント側の実装例
function StreamUploader(api){
this.uniq_key = random_str(20);
this.num = 0;
this.api = api;
this.busy = false;
this.queue = [];
}
StreamUploader.prototype = {
// TODO: retry
upload_binary: function(binary){ // typed array
var self = this;
var task = function() {
self.busy = true;
var xhr = new XMLHttpRequest;
console.log("upload: " + binary.length + " bytes");
var param = "num=" + self.num + "&" + "key=" + self.uniq_key;
self.num++;
xhr.onload = function(){
setTimeout(function(){ self.busy = false }, 1000) };
xhr.open("POST", self.api + "?" + param, true);
xhr.send(binary.buffer);
};
this.queue.push(task);
this.dequeue();
},
dequeue: function(){
var self = this;
if (!this.queue.length) return;
if (this.busy) {
setTimeout(function(){
self.dequeue();
}, 1000);
} else {
var task = this.queue.shift();
task();
}
}
};