きっかけ
会社メンバーから、「ブラウザ上にデータをcookieやlocalStorageなどに保存したいんですが、そのまま(平文)として保存するのは流石に…なので何か簡易的に暗号化する方法って思いつきますか?」という話がありました。
結論として「セキュリティを強固にしたいのであればブラウザだけじゃなくサーバーサイドと一緒にやらないとダメだよね」という当たり前の話になったのですが、それでは面白くないのでお遊び的に考えてみました。
※注意
あくまで表題にある通り『適当に』です。
結論に記載している通りブラウザサイドのみで色々やっても検証ツールが普通に使えて簡単に色々出来ちゃうので基本的にはサーバーサイドと組み合わせてやるべきです。
めっちゃ意訳しますが、現在の主流となっている暗号化の仕組みはアルゴリズムが複雑で解読に途方もない時間がかかるので使われているだけで未来永劫解読できないわけではありませんし数学の進歩によっては短時間で解読できてしまう可能性もあります。
また、「crypto.jsやWeb Crypto APIなどを使って〜」みたいな話でもないのであくまで「ネタ」としてということも踏まえてということもご了承ください。
真似る方はいないと思いますが、Math.random()なども偏りがあるので本来であればMT(MTも古いけど)などを使った方がよいです。
そもそも暗号化とは?
当たり前ですが「こんにちわ」という文字(平文)を分からない別の文字列にすることです。
例えば一旦ローマ字の「konnichiwa」という文字列にして、アルファベットを1文字ずらします。(zが来た場合はaに変換)
そうすると「lpoojdijxb」という文字列になります。
脳死でコードにすると
const alphabets = "abcdefghijklmnopqrstuvwxyz".split("")
const text = "konnichiwa"
const max = alphabets.length + 1
const cipher_array = []
text.split("").map((t) =>{
const index = alphabets.indexOf(t) == max ? 0 : alphabets.indexOf(t) + 1
cipher_array.push(alphabets[index])
})
console.log(cipher_array.join(""))
これも立派な暗号化ですが…なんか法則性がすぐ分かっちゃいそうな気がしますよね。
この「アルファベットを1文字ずらし」というアルゴリズムは正直、TVの謎解き番組なんかでも放送されそうレベルな気がします。
今回この「konnichiwa」を別の方法で暗号化して元に戻す方法をもうちょっと掘り下げたいと思います。
暗号化のアルゴリズムを標準関数で変更してみる
パッと簡単に思いつきそうなのは、base64を使う方法でしょうか…
const text = "konnichiwa"
const cipher_text = btoa(text)
console.log(cipher_text)
こうすると「a29ubmljaGl3YQ==」という文字が返ってきます。
復号化するには
const cipher_text = "a29ubmljaGl3YQ=="
const text = atob(cipher_text)
console.log(text)
ただ、これも問題があります。末尾に「=」があることで一定のエンジニアであれば『あ、これbase64してるだけじゃね?』と簡単に推測できることです。 そうじゃなくても、Javascriptでこういう復号化できそうな任意の文字列って『base64してんじゃないの?』と思われる方も多いと思います。
1文字ズラし + base64のトラップを発動してみる
では、このエンジニアの嫌な習性を利用して最初にやった1文字ズラしてでてきた「lpoojdijxb」という文字列が10文字をbase64っぽくしてみましょう。
具体的には簡単で末尾に「=」を足して「lpoojdijxb==」という文字列にします。
こうすると、この「lpoojdijxb==」文字列をatobしたくなると思います。
その結果のtextは
(Ø£Å
という「konnichiwa」とかけ離れた文字列が返ってきます。
少しだけJavascriptを齧った方には十分な「トラップ発動」になる気がします。
元の文字列に戻すのであれば末尾の「=」を削除して、atobせずに1文字前にズラせばいいですね。
ただ、これには以下のような問題があります。(もっとクリティカルなのがあるけど)
- 全部小文字の英字のみ
- base64はあくまで4の倍数に満たなければ「=」で埋めちゃうので『こんばんは(konbanwa)』という文字列だと「=」で埋められない
1の問題は今回はローマ字なので大文字小文字は適当に一旦ランダムで大文字小文字にしてもみましょう。
const alphabets = "abcdefghijklmnopqrstuvwxyz".split("")
const text = "konnichiwa"
const max = alphabets.length
const cipher_array = []
text.split("").map((t) =>{
const index = alphabets.indexOf(t) == max ? 0 : alphabets.indexOf(t) + 1
//50%の確率で大文字か小文字を返す
const fake_text = Math.random() * 100 < 50 ? alphabets[index].toUpperCase() : alphabets[index].toLowerCase()
cipher_array.push(fake_text)
})
console.log(cipher_array.join(""))
何度もやっているので似通った文字に見えますが、一回だけだと大文字小文字が混ざっています。
で、2番目の問題について解決方法を考えます。
4*nの文字列であっても強制的に4文字足す。(しかもランダムにする)
いわゆる擬似的な「ソルト」という技術ですね。
const salt_array = "====".split("")
const m = max%4 == 0 ? 1 + Math.floor(Math.random() * 3 ) : max%4
for(let i = 0; i < m; i++){
salt_array[i] = alphabets[Math.floor( Math.random() * alphabets.length )]
}
const cipher_text = cipher_array.concat(salt_array).join("")
console.log(cipher_text)
復号化するときは強制的に末尾4文字を取り除いてあげればいいわけなので
const trim_text = cipher_text.slice(0,-4)
で取れたものの、アルファベットを1つ前の値にしてあげれば復号化できます…が!
そうですよね分かりますよ数値がないことよりも。そもそも
出てくる文字列(の一部)が「lpoojdijxb」の大文字、小文字の組み合わせで毎回一緒
この方が問題な気がします。
毎回ランダムな文字列を生成するようにする
単純に考えると元々の文字列「lpoojdijxb」をどうにかすればいいわけですね。
一旦、実験として前後にランダムな4文字計8文字を追加してそれをatobしてみましょう。
const prefix_salt_array = []
const suffix_salt_array = []
for(let i = 0; i < 4; i++){
prefix_salt_array[i] = alphabets[Math.floor( Math.random() * alphabets.length )]
suffix_salt_array[i] = alphabets[Math.floor( Math.random() * alphabets.length )]
}
const text_with_salt = prefix_salt_array + text + suffix_salt_array
const cipher_text = btoa(text_with_salt)
console.log(cipher_text)
はい、一見変わったように真ん中の文字列「(tvbm〜pd2という部分)は変わってないですね。
当たり前です、だって前後4文字の真ん中の部分の文字列は一緒なので!
ということで、元のテキストと同じ長さのランダムな文字列を1文字ずつ挟んでそれをatobしてみます。
const text = "konnichiwa"
const l = text.length
//元の文字列と同じ文字列長のランダムな文字列を求めて配列にする
let randomStringArray = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(l)))).substring(0,l).split("")
//ランダムな文字列の配列と元の文字列を配列したものを1文字ごとに合成する
//つまり毎回tempが違う文字列になる (でも実際の1文字飛ばしはtextになる)
const temp = text.split("").map((e,i) => randomStringArray[i] + e ).join("")
// base64化
const cipher_text = btoa(temp)
console.log(cipher_text)
実際は文字列によって末尾や一部の文字列が変わらない可能性があるのでsaltをつけるといいでしょう…
復号化するときは
const cipher_text = "eGtHb2ZuNm56aVdjUmhEaXl3TGE="
// base64をdecode
const decode_text_array = atob(cipher_text).split("")
// 奇数だけの文字列を取得する
const base_text = decode_text_array.filter((e, i) => { return i%2 !== 0 }).join("")
console.log(base_text)
こんな感じでbtoaでも毎回別の文字列を生成しつつも同じ文字列を復号化できることに成功しました。
文字列が短ければ、よくある暗号化、復号化の鍵に使えそうな気がしますよね…
暗号強度とか考えるとcrypto.subtle.generateKeyを使うべき??
現在の主流なブラウザではcryptoが使えるので鍵の生成にはcrypto.subtle.generateKeyを使うのが一般的な気がします。
async function makeKey(){
let secret_key = await crypto.subtle.generateKey({name:'AES-GCM', length:256}, true, ['encrypt','decrypt'])
let export_secret_key = await crypto.subtle.exportKey('jwk', secret_key)
//console.log(export_secret_key)
return export_secret_key
}
makeKey()
これでいい感じにいけそうな気がしますが…
ソースコードをみれてしまう&セキュアな鍵の置き場所がない
色々、暗号化のおさらい?と頭の体操として実験してきましたが、そもそも末尾に「=」を付けた偽物のbase64のアルゴリズムを使おうとしてもブラウザではソースコードが丸見えになります。(難読化はできるけど)
何より、結局このランダムな文字列を鍵にしたところでブラウザだとセキュアなところに保存することができません。
最初にも記載していますが「適当に」と記載したのはこのためです。
繰り返しになりますが、暗号化などはブラウザだけで無理せずサーバーサイドと連携することを前提とした設計にすべきだと思います。