フロントエンドWeb戦略室

最終回 クライアントサイドでの暗号化とバイナリデータの扱い(2)

<(1)はこちらから。>

ダウンロードして復号までの流れ

ファイルのダウンロード時にはlocation.hashからパラメータを受け取ります。ダウンロード用リンクのlocation.hashは、暗号化済みのデータのパス、復号のためのパスワード、オリジナルのファイル名をカンマで連結したものです。

location.hashの値は、JavaScriptで明示的に取得し送信を行わない限り、サーバに送られることはありません。暗号化済みデータのパスはダウンロードの際、サーバに送られることになりますが、パスワードとオリジナルのファイル名はサーバに送られずに、ブラウザ内でのみ使用します。つまりサービスの運営者からわかるのは「どのファイルがリクエストされたのか」という情報だけで、そのファイルの内容やファイル名はわからないことになります。

ファイルをダウンロードする

XMLHttpRequestを使って暗号化済みのファイルをダウンロードします。responseTypeを指定することで、ファイルをバイナリデータとして受け取ることが可能です。

XMLHttpRequestで従来よりよく使われるresponseTextは、標準では受信したデータを UTF-8のテキストとして解釈しようとしますリスト4⁠。たとえば、

  • あいうえお

とUTF-8で書かれたファイルを受信した場合、ファイルは15bytesになりますリスト5⁠。

リスト4 textとして受信する場合
req = new XMLHttpRequest;
req.open("GET", "test.txt", false);
req.send();
req.responseText.length; // 5 文字
リスト5 バイナリデータとして受信する場合
req = new XMLHttpRequest;
req.responseType = "arraybuffer";
req.open("GET", "test.txt", false);
req.send();
req.response.byteLength; // 15bytes

それに対して、req.responseText.lengthはリスト4のように5文字を示します。文字数とバイト数が一致しないと、crypto-jsなどのライブラリにて、1byte単位で取り出すことができません。一般的に、暗号化されると8bytes単位で処理されますが、先の例だと「う」の途中で切れてしまう、という問題が発生してしまうのです。

今までXMLHttpRequestを使ってバイナリファイルを扱うには、overrideMimeTypeを使ってcharset = x-user-definedという文字コードを指定する方法がよく知られていました[8]⁠。英数字のみを扱うならば文字数=バイト数になりますが、リスト4、リスト5のようにマルチバイト文字を含むデータは、テキストとしてデコードされると、文字数とバイト数が一致しなくなります。

特殊な文字コード指定を使うことで、受信したデータが1byte単位でJavaScriptのstringに格納されるのです[9]⁠。古いブラウザを動作対象とするのであれば覚えておいても損はないですが、最新のブラウザを動作対象にするのであれば、何はともあれArray Bufferを使うのがよいでしょう。

ファイルのダウンロードと復号

単純にXMLHttpRequestで巨大なファイルをダウンロードする場合、やはりメモリに貯めこんでしまいますので、数MBであればともかくとして、GBを超えるファイルはとても現実的には扱えないという状況になります。

この問題はサーバ側でファイルを分割してダウンロードすることで解決できます。問題は、分割して受信したファイルをその都度復号し、復号した結果を結合しなければならないことです。

crypto-jsでは受信したデータを順次復号していくことが可能ですが、その結果をJavaScriptの変数に格納するのであれば、分割して受信したところで最終的なファイルのサイズ分だけメモリを消費することになってしまいます。ローカルで巨大なファイルを生成するには、⁠サーバからデータを受信して、ファイルに追記し破棄する」という繰り返し処理が必要です。この問題を解決するためには、JavaScriptから「ファイルに追記していく」操作が必要なのですが、原稿執筆時点(2013年1月)でこれが可能なのはGoogle Chromeだけです。MegaがGoogle Chromeを推奨しているのは、こういった事情のためでしょう。

ほかのブラウザでは動かないことになってしまうので、分割ダウンロードと結合部分についてはサンプルでは作りませんでしたが、FileSystem APIがサポートされている場合はFileSystem APIを使うようにしてもよいでしょうリスト6⁠。次項ではFileSystem APIを詳しく見ていきます。

リスト6 ダウンロード処理
function download(path, password, filename){
  req = new XMLHttpRequest;
  req.open("GET", "./files/" + path, true);
  req.responseType = "arraybuffer";
  req.onload = function(){
    var buffer = req.response;
    decode_arraybuffer(buffer, password, filename);
  };
  req.send("");
}

function objecturlready(url, filename){
  var el;
  if (/(gif|jpg|png)$/i.test(filename)) {
    el = TB("img", {title: filename, alt: filename, src: url});
  } else {
    el = TB("a", {href: url, download: filename}, "Click here to save file");
  }
  document.getElementById("result").appendChild(el);
}

function decode_arraybuffer(ab, password, filename) {
  var dec = new StreamDecryptor(password);
  dec.decorder = null;
  var wordarray = CryptoJS.lib.WordArray.create( ab );
  dec.process(wordarray);
  dec.process();

  var isImage = /(gif|jpg|png)$/i.test(filename);
  var suffix = filename.match(/([^.]*?)$/);
  suffix = suffix ? suffix[1].toLowerCase() : "";
  var mimetype = isImage ? { type: "image/"+suffix}
  : { type: "application/octet-stream"};
  if (window.requestFileSystem) {
    var file = new TempFile();
    file.onready = function(){
      file.write( new Blob(dec.result) );
      setTimeout(function(){
        objecturlready(file.toURL(), filename);
      }, 100);
    };
  } else {
    // Safari does not support create blob from typed array
    var isBuggyBrowser = (new Blob([new Uint8Array()]).size > 0 ) ? true : false;
    var blob = isBuggyBrowser
        ? new Blob(dec.result.map(function(v){ return v.buffer }), mimetype)
        : new Blob(dec.result, mimetype);
    var objectURL = (
        window.URL || window.webkitURL || dataURLsim
      ).createObjectURL(blob);
    setTimeout(function(){
      objecturlready(objectURL, filename);
    }, 0);
  }
}

FileSystem APIを使って一時ファイルを作る

次に、FileSystem APIを使って一時ファイルを作る方法を説明します。リスト7のようにすれば、一時ファイルを作ることができます。FileSystem APIとはいっても、ユーザのHDDに直接アクセスできるようなものではなく、PC上のファイルシステムとは隔離された、抽象化された独自のストレージを提供するAPIです[10]⁠。開発者は、ファイルの実体がどこに保存されているのかなどを意識する必要はありません。

リスト7 FileSystem APIを使って一時ファイルを作る(Google Chrome限定)
window.requestFileSystem = window.requestFileSystem ||
window.webkitRequestFileSystem;
// for chrome
if (window.requestFileSystem) {
// var file = new TempFile; file.write(); file.
downloadLink;
  var TempFile = function(){
    var self = {
      ready: false
    };
    var errorHandler = function(){ console.log(arguments)
};
    var onInitFs = function(fs) {
      var fileHandler = function(fileEntry) {
        self.fileEntry = fileEntry;
        // Create a FileWriter object for our FileEntry
        fileEntry.createWriter(function(fileWriter) {
        fileWriter.onwriteend = function(e) {
          console.log('Write completed.') };
        fileWriter.onerror = function(e) {
          console.log('Write failed: ' + e.toString())
};
        // Create a new Blob and write it to log.txt.
        self.ready = true;
        self.writer = fileWriter;
          self.seek = function(pos) { fileWriter.seek(pos) };
          self.write = function(blob) {
            fileWriter.write(blob) };
          self.append = function(blob) {
            self.seek(fileWriter.length);
            self.write(blob);
          };
          self.toURL = function(){
            return self.fileEntry.toURL() };
          if (self.onready) {
            self.onready();
          }
        }, errorHandler);
      }
      fs.root.getFile(
       'data', // ファイル名
       {create: true}, fileHandler , errorHandler);
    };
    window.requestFileSystem(
      window.TEMPORARY, // 一時領域
      1024*1024, onInitFs, errorHandler);
    return self;
  }
}

サイズの小さいデータならば、localStorageを使うことができますし、Indexed DatabaseにもBlobを保存することが可能です。しかしIndexed Databaseを使う場合、結局のところ、JavaScriptによってメモリ内に生成されたBlobを保存することになるため、巨大なBlobを保存するときには、サイズに応じてメモリを消費することになってしまいます。FileSystem APIが必要とされるのは、ファイルやディレクトリといったメタファでデータを扱いたい場合や、あるいは、メモリに収まらないようなサイズの巨大なファイルを扱い、追記したり部分的に書き換えるような処理が必要である場合です。

ファイルをURLに変換する

createObjectURLを使うことで、FileやBlobを参照するURLを生成できます。生成したURLは、画像やビデオやオーディオのsrcとして使うこともできますし、ブラウザで表示することが可能なファイルタイプであればiframeを使ってその場で表示することもできます。

名前を付けてファイルを保存

aタグのdownload attributeというものがWHATWGで提案されており、Google Chromeでサポートされています。これは、download="ファイル名"とすることで、リンク先に遷移することなく指定したファイル名でリンク先を保存できるものです。これが普及すれば、BlobオブジェクトからcreateObjectURLを使って生成したリンクをローカルに保存することが可能になります。

Data URIで代用する

比較的小さなファイルや、ブラウザ上で表示可能な画像などであれば、Data Uriを使って代用することも可能ですリスト8⁠。FileReaderのreadAsDataURLを使います。

リスト8 createObjectURLとData URIを使って取得する
var dataURLsim = {
  createObjectURL: function(blob){
    var fr = new FileReader;
    fr.readAsDataURL(blob);
    return {
      toString: function(){ return fr.result }
    };
  }
};
var objectURL = (
  window.URL || window.webkitURL || dataURLsim
).createObjectURL(blob);
setTimeout(function(){ img.src = objectURL }, 0);

本来使うべきcreateObjectURLと違ってFileReaderを使うため、必然的に非同期インタフェースとなってしまいます。すでにメモリ上に生成されているBlobオブジェクトですので、setTimeoutで0msのwaitを入れることで利用可能です。完全に互換というわけではありませんが、実際にobjectURLを利用するまでにタイムラグがあるのであれば、シームレスに使うことができると思います。

ダウンロード復号の際、特に気を付けたいポイント

次に、ダウンロードを復号する際、特に気を付けたいポイントを解説します。

Blobの組み立て

Blobを生成するためには、以前はBlobBuilderという、Blobを組み立てるための、専用のインタフェースが存在していました[11]⁠。Blobコンストラクタの第1引数には、配列に格納された、ArrayBuffer、Typed Array、Blob、DOMStringを受け取ることができます。DOM Stringが渡された場合は、自動的にUTF-8として解釈されます。

// 文字列からBlob を作る
// 長さ1 の内部表現文字列がUTF-8 にエンコードされて
// 3bytes のBlob になった
new Blob([" あ"]).size // 3bytes

// 長さ1 の内部表現文字列をUTF-8 のバイト列に変換する
inta = new Uint8Array( encodeURI(" あ").split("%").
slice(1).map(function(v){ return parseInt(v,16)}) )
// 長さ3 のUint8Array がそのままBlob に変換された
new Blob([inta]).size // 3bytes
// " あ" を3 文字に分解する
bytes = encodeURI(" あ").split("%").slice(1).
map(function(v){ return String.fromCharCode(
parseInt(v,16) ) }).join("");
bytes.length // 3 文字
// 長さ3 の内部表現文字列をUTF-8 にエンコードした結果
// 2×3 で6bytes になった
new Blob([bytes]).size // 6bytes

従来の「Stringをバイナリ文字列として使うテクニック」を使っている場合、Blobの挙動はしばしば混乱を引き起こします。Stringが渡されている以上、Blobコンストラクタは文字列として解釈しようとするからです。Stringを使ってバイナリを扱うことは依然として可能ですが、トラブルのもとになるため、なるべく避けたほうがよいでしょう。

Typed ArrayからのBlob生成

Blobコンストラクタに直接渡してBlobオブジェクトを生成できます。しかし、Typed ArrayからのBlob作成をサポートしていない場合があります。

// "\x01\x02\x03\x04\x05" 相当のバイナリデータを作る
var array = new Uint8Array([1,2,3,4,5]);
new Blob([array]) // 現在推奨されている方法 [12]
new Blob([array.buffer]) // 古い方法 [13]

SafariでBlobコンストラクタに空のTyped Arrayを渡した場合、次のようなBlobが生成されます。

b = new Blob([new Int8Array()]);
f = new FileReader;f.readAsText(b);
f.result; // "[object Int8Array]"

このように、new Int8Array( ).toString( ) の実行結果が入っていることがわかります。Blobへの変換に対応していないオブジェクトだったため、文字列化した状態でBlobが生成されてしまったわけです。いずれこの問題は修正されるでしょうから、UserAgentやバージョンで判別するのは避けましょう。

Typed ArrayからBlobの生成に対応しているかどうか見極めるためには、次のように、サイズが0のTyped Arrayを使ってBlobを生成し、生成されたBlobのサイズをチェックすることで対応しているかどうかを判定するのがよいでしょう。

var isOldBlobConstructor = (
  new Blob([new Int8Array()]).size > 0 ) ? true : false;
<続きの(3)はこちら。>

おすすめ記事

記事・ニュース一覧