ブラウザ上で暗号化を使う

今日で年内の勤務は終わり。毎年こうですね。休暇の人も多いけど。

もうあさっては大晦日なのかと。


ちょっと前から気になっていたことがあって、暗号化されたファイルをブラウザのJavascriptで暗号化解除して利用するようなことはできるのかと。

これ、crypto-jsってライブラリがあるからできるみたいですね。

bowerでインストールするらしいが手間なのでcdnjsにあるのを使う。(cf. crypto-js (cdnjs))

これで共通鍵暗号としては強固らしく広く使われているAESとの組み合わせでやればよいだろうと。

AESについては以前紹介したことがあるが、AES256の場合、鍵が256bit=32byte、初期化ビクトル(IV)が128byte=16byte、ランダムなデータが必要である。

チェックを入れると暗号が解ける

IVはファイルごとに生成するので、暗号化されたファイルと一緒に伝達する必要がある。

ファイルの先頭にIVを付加するのが一般的みたいですね。

opensslには文字列のパスワードから鍵とIVを生成するアルゴリズムもあるけど。


まずは暗号化したファイルを作る。これはLinuxでシェルスクリプトで。

iv=`dd if=/dev/urandom bs=16 count=1 status=none | tee $file.enc | xxd -ps | tr -d "\r\n"`
/usr/bin/openssl aes-256-cbc -e -in $file -iv $iv -K $key >> $file.enc

ランダムな256bitの鍵を作るのは”openssl rand -hex 32”とかすればOK。

それでこれで作ったデータにJavascriptでアクセスして、暗号化を解除すればよい。

エラー処理など全て省略しているが、こんな感じ。

function decrypt(fname){
   var key = CryptoJS.enc.Hex.parse(document.getElementById("key").value);
   var reqenc = new XMLHttpRequest();
   reqenc.responseType = "arraybuffer";
   reqenc.onload=function(){
     if(reqenc.status == 200){
       var iv= CryptoJS.lib.WordArray.create(reqenc.response.slice(0,16));
       var enc= CryptoJS.lib.WordArray.create(reqenc.response.slice(16));
       var decrypted = CryptoJS.AES.decrypt({ciphertext:enc}, key, {iv:iv});
       if(decrypted.sigBytes != 0 && (decrypted.words[0] ^ 0xFFD8FFE0)==0 ){
         var imgelem = new Image();
         imgelem.src="data:image/jpeg;base64,"+decrypted.toString(CryptoJS.enc.Base64);
         document.getElementById("out").appendChild(imgelem);
       }
     }
   };
   reqenc.open("GET","./"+fname+".enc",true);
   reqenc.send(); }

XMLHttpRequestでバイナリを取得するのは初めてだったけど、responseTypeプロパティを設定すればできるらしい。

まず、IVと本体を分離して、CryptoJS.lib.WordArrayという独自のデータ型に変換する。

鍵データもCryptoJS.enc.Hex.parse()で16進数表記のデータをこの型に変換している。

で、CryptoJS.AES.decrypt関数で復号するが、引数の指定が独特ですね。

WordArray型については、16進数・Base64に変換するのは比較的容易で、

画像データをブラウザで表示する場合、DataURIスキームでBase64表記のデータをimg要素のsrcに設定するのが一番簡単。

数十MBぐらいまでならこれで全く問題ないらしい。(ChromeとFirefoxで確認)

AESは間違えた鍵を与えればインチキなデータができるだけである。

このため冒頭4byteを確認してJPEGのものと一致するか確認している。簡易的だがこれがよいだろう。


画像データはこれだが、バイナリデータはどうするか。これもDataURIスキームを使う。

var anchor = document.createElement("a");
anchor.download = fname;
anchor.href="data:application/octet-stream;base64,"+decrypted.toString(CryptoJS.enc.Base64);
document.body.appendChild(anchor);
anchor.click(); anchor.remove();

やってることはすさまじくて、Base64表記のファイルへのリンクを作って、

これをクリックしてダウンロードさせたら、リンクを消すというものである。

でも、けっこう使われている方法みたいね。これでファイルのダウンロードも容易。


というわけで暗号化して保存しているファイルをダウンロードして表示できる。

では、逆に暗号化してアップロードする場合はどうするか。こちらはテキストで。

function encupload(){
   var encdata = "    "+document.getElementById("textdata").value;
   var key = CryptoJS.enc.Hex.parse(document.getElementById("key").value);
   var iv = CryptoJS.lib.WordArray.random(16);
   var enc = CryptoJS.AES.encrypt(encdata, key, {iv:iv});
   var reqenc = new XMLHttpRequest();
   var upload = new FormData();
   var dv32=new DataView(new ArrayBuffer(enc.ciphertext.sigBytes+iv.sigBytes));
   var idx=0;
   for(var val of iv.words){
     dv32.setUint32(idx,val,false); idx+=4;
   }
   for(var val of enc.ciphertext.words){
     dv32.setUint32(idx,val,false); idx+=4;
   }
   upload.append("encfile",new Blob([dv32.buffer], {type:"application/octet-stream;base64"}),'upload.enc');
   reqenc.onload=function(){
     if(reqenc.status == 200){
       document.getElementById("status").innerText="Upload:"+reqenc.response;
     }
   };
   reqenc.open("POST","./uploaddec.php",true);
   reqenc.send(upload); }

暗号化自体はCryptoJS.AEC.encryptでできるし、IVもランダム生成する関数がある。

問題はこのIV+暗号化データをBlog型に変換することである。

バイナリデータをBlob型にするためにはデータをArrayBuffer型にする必要がある。

CryptoJSのWordArray型は32bitのデータ配列なので、UInt32Array型を介してArrayBufferにすればいいと見せかけて、これだとバイト順が処理系依存になる。

このため、DataViewを使ってArrayBuffer化するが、なんとデータ1個ずつ格納しなければならない。

特にファイルアップロードの形式にこだわらないなら、Base64のままサーバーに渡した方が楽だし、そっちの方がいいと思った。

PHPで受け取ったら、アップロードされたファイルを所定のところに保存して、

これを読んで復号すれば、暗号化鍵を知るユーザーだけが中身がわかるってわけ。

暗号化時にスペース4つをデータ先頭に付加して、復号時に0x20202020から始まっていることを確認して正常な復号を確認するようにしてある。


というわけで技術的には可能であるものの、果たしてこれは何に使えるのかという話である。

HTTPSを利用すれば、サーバー~クライアント間の暗号化はできる。

ゆえにサーバー上に平文で置いておくのは心配なファイルに使うのだと思うが。

あるいはユーザー毎に秘密を保持したいセンシティブなデータの保存に使うか。

サーバーには複数人のデータが平文で保管されているのはどうなんだという話はあるかもしれないし。

けっこう用途は限られるだろうなとは思いますけど。


課題の1つは鍵データの保管・転送といったところだが、

WebStorageという機能を使えば、キャッシュの削除などしない限りデータを保存できる。

function loadkey(){
   var key=localStorage.getItem("aes2key");
   if(key !== null){
     document.getElementById("key").value=key;
     document.getElementById("key").disabled=true;
   } } function storekey(key){
   localStorage.setItem("aes2key",key);
   document.getElementById("key").disabled=true; } function removekey(){
   localStorage.removeItem("aes2key");
   document.getElementById("key").value="";
   document.getElementById("key").disabled=false; }

ブラウザである程度永続的に保持できればだいぶ楽ではある。

あとはこれをQRコードで保管・転送するとか。クラウドストレージでの保管はちょっと違うかなぁ。


果たしてそこまでするべきデータがあるのかという話はあるのだが。

別の観点では、PDFやWord・Excelの暗号化機能というのもあって、

勤務先でも機密性の高い文章にセキュアPDFとかいうのを使ってたりするけど。

(これは読めなくするというよりは、印刷・保存されることを防ぐ目的だが)

HTTPSでの暗号化とも、表示ソフトでの暗号化とも違うレイヤーということ役立つケースもありそうだが。

処理内容を見てもわかるが、ブラウザでそこまで暗号化を意識せず使えるのはよいかもしれない。