前端開發(fā)者也需要了解的文件加解密知識(shí)

背景
最近團(tuán)隊(duì)遇到一個(gè)小需求,存在兩個(gè)系統(tǒng) A、B,系統(tǒng) A 支持用戶在線制作皮膚包,制作后的皮膚包用戶可以下載后,導(dǎo)入到另外的系統(tǒng) B 上。皮膚包本身的其實(shí)就是一個(gè) zip 壓縮包,系統(tǒng) B 接收到壓縮包后,解壓并做一些常規(guī)的校驗(yàn),比如版本、內(nèi)容合法性校驗(yàn)等,整體功能也比較簡(jiǎn)單。
但沒想到啊,一幫測(cè)試人員對(duì)我們開發(fā)人員一頓輸出,首先繞過系統(tǒng) A 搞了幾個(gè)視頻文件,把后綴改成?zip?就直接想上傳,系統(tǒng) B 每次都是等到上傳完后才發(fā)現(xiàn)文件不合法,系統(tǒng) B 在文件沒上傳完前又無(wú)法解壓,也不知道文件內(nèi)容是不是合法的,就這么消耗了大量帶寬、大量時(shí)間后才提示用戶皮膚包有問題。
這里涉及了兩個(gè)問題,我們來捋一捋:
文件如何做加密,這樣用戶便無(wú)法去逆向,壓縮包內(nèi)部的敏感信息不會(huì)泄露出去。 服務(wù)端在接收到信息流時(shí),在未傳輸完時(shí)如何去判斷壓縮包的合法性,提前告知用戶。
AES VS RSA
說到加密,自己很多人會(huì)想到對(duì)稱算法 AES[2]?以及非對(duì)稱算法 RSA[3]。這兩種算法按字面意思也較好理解,對(duì)稱加密技術(shù)說白一點(diǎn)就是加密跟解密使用的是同一個(gè)密鑰,這種加密算法速度極快,安全級(jí)別高,加密前后的大小一致;非對(duì)稱加密技術(shù)則有公鑰PK、私鑰SK,算法的原理在于尋找兩個(gè)素?cái)?shù),讓他們的乘積剛好等于一個(gè)約定的數(shù)字,非對(duì)稱算法的安全性是依賴于大數(shù)的分解,這個(gè)目前沒有理論支持可以快速破解,它的安全性完全依賴于這個(gè)密鑰的長(zhǎng)度,一般用 1024 位已經(jīng)足夠使用。但是它的速度相比對(duì)稱算法慢得多,一般僅用于少量數(shù)據(jù)的加密,待加密的數(shù)據(jù)長(zhǎng)度不能超過密鑰的長(zhǎng)度。
使用 AES 對(duì)文件加密
結(jié)合這兩種加密方式的優(yōu)缺點(diǎn),我們采用 AES 對(duì)文件本身做加解密,使用 AES 的原因主要考慮如下:
加解密性能問題,AES 的速度極快,相比 RSA 有 1000 倍以上提升。 RSA 對(duì)源文有長(zhǎng)度的要求,最大長(zhǎng)度僅有密鑰長(zhǎng)度。
AES 的加密算法 Node.js 的crypto[4]模塊中已經(jīng)有內(nèi)置,具體的使用可以參考官方文檔。
AES 加密邏輯
const?crypto?=?require('crypto');
const?algorithm?=?'aes-256-gcm';
/**
?*?對(duì)一個(gè)buffer進(jìn)行AES加密
?*?@param?{Buffer}?buffer???待加密的內(nèi)容
?*?@param?{String}?key??????密鑰
?*?@param?{String}?iv???????初始向量
?*?@return?{{key:?string,?iv:?string,?tag:?Buffer,?context:?Buffer}}
?*/
function?aesEncrypt?(buffer,?key,?iv)?{
????//?初始化加密算法
????const?cipher?=?crypto.createCipheriv(algorithm,?key,?iv);
????let?encrypted?=?cipher.update(buffer);
????let?end?=?cipher.final();
????//?生成身份驗(yàn)證標(biāo)簽,用于驗(yàn)證密文的來源
????const?tag?=?cipher.getAuthTag();
????return?{
????????key,
????????iv,
????????tag,
????????buffer:?buffer.concat([encrypted,?end]);
????};
}
AES 解密邏輯
解密整體跟加密一樣,只是接口換個(gè)名字即可:
const?crypto?=?require('crypto');
const?algorithm?=?'aes-256-gcm';
/**
?*?對(duì)一個(gè)buffer進(jìn)行AES解密
?*?@param?{{key:?string,?iv:?string,?tag:?Buffer,?buffer:?Buffer}}?ret???待解密的內(nèi)容
?*?@param?{String}?key??????密鑰
?*?@param?{String}?iv???????初始向量
?*?@return?{Buffer}
?*/
function?aesDecrypt?({key,?iv,?tag,?buffer})?{
????//?初始化解密算法
????const?decipher?=?crypto.createDecipheriv(algorithm,?key,?iv);
????//?生成身份驗(yàn)證標(biāo)簽,用于驗(yàn)證密文的來源
????decipher.setAuthTag(tag);
????let?decrypted?=?decipher.update(buffer);
????let?end?=?decipher.final();
????return?Buffer.concat([decrypted,?end]);
}
AES 具體使用
有了上述兩個(gè)接口后,我們便可實(shí)現(xiàn)一個(gè)簡(jiǎn)單的對(duì)稱加密了:
const?key?=?'abcdefghijklmnopqrstuvwxyz123456';?//?32?共享密鑰,長(zhǎng)度跟算法需要匹配上
const?iv?=?'abcdefghijklmnop';??//?16?初始向量,長(zhǎng)度跟算法需要匹配上
let?fileBuffer?=?Buffer.from('abc');
//?加密
let?encrypted?=?aesEncrypt(fileBuffer,?key,?iv);
//?解密
let?context?=?aesDecrypt(encrypted);
console.log(context.toString());
一般情況下,這個(gè)密鑰較為重要,如果發(fā)生泄露則加密失去意義,所以key、iv會(huì)使用隨機(jī)數(shù)動(dòng)態(tài)生成,比如:
const?key?=?crypto.randomBytes(32);
const?iv?=?crypto.randomBytes(16);
通過上述的調(diào)整后,加解密文件是比較容易的,回到我們的業(yè)務(wù)系統(tǒng)上面,系統(tǒng) A 生成的壓縮包,最終是需要給系統(tǒng) B 使用,兩個(gè)系統(tǒng)是隔離的,那這樣?key、iv?如何傳輸?shù)较到y(tǒng) B 上面呢,況且還是動(dòng)態(tài)生成的,生成出來?key?系統(tǒng) B 是不知道的。
讀到這聰明的你可能會(huì)想到,在把壓縮包給到 B 的時(shí)候,順便把?key、iv?一同提交過去不就可以了,但細(xì)想了下,這個(gè)肯定不能明文把這個(gè)密鑰發(fā)送過去,要不這個(gè)加密意義何在。
這時(shí)便需要用上?RSA 非對(duì)稱加密技術(shù)了。
使用 RSA 算法對(duì)密鑰再次進(jìn)行非對(duì)稱加密
RSA 的加密算法 Node.js 的?crypto 模塊[5]?中已經(jīng)有內(nèi)置,具體的使用可以參考官方文檔。
生成 RSA 的公鑰與私鑰
使用?openssl[6]?組件可以直接生成?RSA?的公鑰私鑰對(duì),具體的命令可以參考:www.scottbrady91.com/OpenSSL/Cre…[7]。
#?生成私鑰
openssl?genrsa?-out?private.pem?1024
#?提取公鑰
openssl?rsa?-in?private.pem?-pubout?-out?public.pem
這樣生成出來的兩個(gè)文件?private.pem、public.pem?就可以使用了,下面我們使用 Node.js 實(shí)現(xiàn)具體的加解密邏輯。
RSA 加密邏輯
const?fs?=?require('fs');
const?crypto?=?require('crypto');
const?PK?=?fs.readFileSync('./public.pem',?'utf-8');
/**
?*?對(duì)一個(gè)buffer進(jìn)行RSA加密
?*?@param?{Buffer}?待加密的內(nèi)容
?*?@return?{Buffer}
?*/
function?rsaEncrypt?(buffer)?{
????return?crypto.publicEncrypt(PK,?buffer);
}
RSA 解密邏輯
const?fs?=?require('fs');
const?crypto?=?require('crypto');
const?SK?=?fs.readFileSync('./private.pem',?'utf-8');
/**
?*?對(duì)一個(gè)buffer進(jìn)行RSA解密
?*?@param?{Buffer}?待解密的內(nèi)容
?*?@return?{Buffer}
?*/
function?rsaDecrypt?(buffer)?{
????return?crypto.privateDecrypt(SK,?buffer);
}
RSA 具體使用
有了上述接口后,便可對(duì) AES 的密鑰進(jìn)行加密后再傳輸,服務(wù)器 B 保存好?RSA 私鑰?,服務(wù)器 A 則可以直接用?RSA 公鑰?對(duì)數(shù)據(jù)加密后再發(fā)送,結(jié)合剛?AES?的邏輯后,如下:
/**
?*?加密文件
?*?@param?{Buffer}?fileBuffer
?*?@return?{{file:?Buffer,?key:?Buffer}}
?*/
function?encrypt?(fileBuffer)?{
????const?key?=?crypto.randomBytes(32);
????const?iv?=?crypto.randomBytes(16);
????const?{?tag,?file?}?=?aesEncrypt(fileBuffer,?key,?iv);
????return?{
????????file,
????????key:?rsaEncrypt(Buffer.concat([key,?iv,?tag]));?????//?由于長(zhǎng)度是固定的,直接連在一起即可
????};
}
/**
?*?解密文件
?*?@param?{{file:?Buffer,?key:?Buffer}}
?*?@return?{Buffer}
?*/
function?decrypt?({file,?key})?{
????const?source?=?rsaDecrypt(key).toString();
????const?k?=?source.slice(0,?32);
????const?iv?=?source.slice(32,?48);
????const?tag?=?source.slice(48);
????return?aesDecrypt({
????????key:?k,
????????iv,
????????tag,
????????buffer:?file
????})
}
這樣結(jié)合在一起后,服務(wù)器 A 生成的壓縮包,只要包含好?{file, key}?這兩塊內(nèi)容,服務(wù)器 B 便可把文件解密出來了,這樣基本上實(shí)現(xiàn)了我們第一點(diǎn)的目標(biāo):1. 文件如何做加密,這樣用戶便無(wú)法去逆向,壓縮包內(nèi)部的敏感信息不會(huì)泄露出去
但還遺留了另外一個(gè)問題需要解決:2. 服務(wù)端在接收到信息流時(shí),在未傳輸完時(shí)如何去判斷壓縮包的合法性,提前告知用戶
關(guān)于解密的還可以看看:記一次破解前端加密詳細(xì)過程
優(yōu)化加密文件
按上面的加密方式,輸出的結(jié)果是一個(gè)?buffer文件?內(nèi)容,以及一個(gè)?加密過的key,除了這些信息外,一般這個(gè)?buffer文件?壓縮包還會(huì)有一些額外的信息,比如:版本號(hào)、壓縮包生成時(shí)間,描述信息等。這些信息按常規(guī)的方式,可能是分成幾個(gè)文件,然后再打一個(gè)壓縮包把文件放在一起,比如:
//?zip?file
-?pkg
????manifest.json???????//?額外的信息
????key.json????????????//?保存了加密過的密鑰
????file.json???????????//?加密過的文件
但如果用這種方式保存,一般情況下還要對(duì)這個(gè)?zip文件?做下加密,然后改下后綴名,但是服務(wù)器 B 在讀取這個(gè)文件后仍然是需要全部接收,再解壓到臨時(shí)目錄,讀取內(nèi)容后才可以做校驗(yàn),這樣問題仍然解決不了。
除此之外,還有另外一個(gè)常見的需求,產(chǎn)品一般希望在瀏覽器側(cè)在文件上傳時(shí)就先做初步的解析,把明顯不合法的文件提示到用戶,這樣用戶體驗(yàn)更好。
這個(gè)問題的解決方案也不難,這些所有額外的信息都是可以把它當(dāng)成二進(jìn)制插入到文件的頭部上的,比如:
包字段描述:|----插入的額外信息----|----后面才是真正的文件內(nèi)容----|??
二進(jìn)制文件:010101010101010101010xxxxxxxxxxxxxxxxxxxxxxxxxxxx
文件頭字段設(shè)計(jì)
我們把這些所有信息,按一定的格式,使用二進(jìn)制的方式全部串連在一起,最終交付的只有一個(gè)組合過的文件,比如:
//?theme?pkg.
0????????????????8????????????????16?????????????????
|------flag------|--extra?length--|
|----------extra?data...----------|
|-------------data...-------------|
flag:
固定標(biāo)識(shí)?THEME,長(zhǎng)度:8 byte,說明該壓縮包為一個(gè)皮膚包,這樣可以快速對(duì)壓縮包進(jìn)行識(shí)別
extra length:
extra data?的真實(shí)長(zhǎng)度,這是一個(gè) 16 進(jìn)制的數(shù)據(jù),長(zhǎng)度:8 byte,說明插入的數(shù)據(jù)長(zhǎng)度。比如:長(zhǎng)度?35?的數(shù)據(jù),轉(zhuǎn)化為 16 進(jìn)制后為?0x23,那這字段為?00000023
extra data:
使用?RSA?加密過的數(shù)據(jù),我們可以把上述需要用?RSA?加密的信息全部放在這里,比如?key?字段、版本號(hào)、描述信息等
data:
使用?AES?加密過的數(shù)據(jù),可以通過?extra data?里面保存的?key?把真實(shí)的數(shù)據(jù)全部解密出來
生成的新的加密文件
有了上面的理論基礎(chǔ)后,馬上可以實(shí)踐起來,代碼如下:
/**
?*?加密文件
?*?@param?{Buffer}?fileBuffer
?*?@return?{Buffer}
?*/
function?encrypt?(fileBuffer)?{
????const?key?=?crypto.randomBytes(32);
????const?iv?=?crypto.randomBytes(16);
????const?version?=?'v1.1';
????//?記錄上所有額外的壓縮外信息,比如版本號(hào)、原始的密鑰
????const?extraJSON?=?{
????????version,
????????key,
????????iv
????}
????//?完成文件的AES加密,并輸出身份驗(yàn)證標(biāo)簽
????const?{?tag,?file?}?=?aesEncrypt(fileBuffer,?key,?iv);
????extraJSON.tag?=?tag;
????//?對(duì)?extraJSON?整個(gè)進(jìn)行RSA加密
????const?extraData?=?rsaEncrypt(Buffer.from(JSON.stringify(extraJSON)));
????const?extraLength?=?extraData.length;
????//?最終把所有數(shù)據(jù)合并在一起
????return?Buffer.concat([
????????Buffer.from('THEME'),
????????Buffer.from(Buffer.from(extraLength.toString(16).padStart(8,?'0'))),
????????extraData,
????????file
????]);
}
通過這種加密方式后,相關(guān)的信息都放在文件的頭部上,我們可以不用對(duì)整個(gè)文件進(jìn)行操作的時(shí)候,便可以輕松讀取出來,對(duì)于解密其實(shí)就是一個(gè)反向的操作。
對(duì)新生成的文件進(jìn)行解密
/**
?*?解密文件
?*?@param?{Buffer}?fileBuffer
?*?@return?{Buffer}
?*/
function?decrypt?(fileBuffer)?{
????const?type?=?fileBuffer.slice(0,?8);????//?THEME
????const?extraLength?=?+('0x'?+?fileBuffer.slice(8,?16).toString());
????const?extraDataEndIndex?=?16?+?extraLength;
????//?對(duì)已經(jīng)被RSA加密過的數(shù)據(jù)進(jìn)行解密操作
????const?extraData?=?rsaDecrypt(fileBuffer.slice(16,?extraDataEndIndex));
????const?extraJSON?=?JSON.parse(extraData);
????//?最終使用AES再對(duì)剩下文件進(jìn)行解密操作,即為最終的文件
????return?aesDecrypt({
????????key:?extraJSON.key,
????????iv:?extraJSON.iv,
????????tag:?extraJSON.tag,
????????buffer:?Buffer.slice(extraDataEndIndex)
????});
}
使用這種方式處理后,在?RSA?解密出?extraData?的時(shí)候,就可以對(duì)整個(gè)文件進(jìn)行各種校驗(yàn),整個(gè)過程只要有異常說明文件已經(jīng)被篡改,用這種方式比用壓縮包會(huì)好很多,特別是文件體積龐大的時(shí)候,可以流式處理,發(fā)現(xiàn)不合理時(shí)即可馬上阻止。
瀏覽器端如何解析該文件
由于現(xiàn)在整個(gè)文件格式都是二進(jìn)制流,現(xiàn)代的瀏覽器都是有相應(yīng)的能力去讀取并做處理的,這樣也可以在用戶上傳文件時(shí)先做一定的初步處理,體驗(yàn)會(huì)有比較大的提升
可以使用?DataView?的方式把二進(jìn)制數(shù)據(jù)讀取出來,詳情可以參考:DataView[8],初步的實(shí)現(xiàn)如下:
/**
?*?把二進(jìn)制流轉(zhuǎn)成對(duì)應(yīng)ascii字符
?*?@param?{DataView}?dv?????????二進(jìn)制數(shù)據(jù)庫(kù)
?*?@param?{Number}???start??????起始位置
?*?@param?{Number}???end????????結(jié)束位置
?*?@return?{String}
?*/
function?buffer2Char?(dv,?start,?end)?{
????let?ret?=?[];
????for?(let?i?=?start;?i?????????let?charCode?=?dv.getUint8(i);
????????let?code?=?String.fromCharCode(charCode);
????????ret.push(code);
????}
????return?ret.join('');
}
function?test?()?{
????let?fileDom?=?document.getElementById('file');
????let?file?=?fileDom.files[0];
????let?reader?=?new?FileReader();
????reader.readAsArrayBuffer(file);
????reader.addEventListener("load",?function(e)?{
????????let?dv?=?new?DataView(buffer);
????????let?flag?=?buffer2Char(dv,?0,?8);???//?THEME
????????var?extraLength?=?+('0x'?+?buffer2Char(dv,?8,?16));
????????var?extraData?=?buffer2Char(dv,?16,?extraLength);
????????console.log(flag,?extraLength,?extraData);
????});
}
當(dāng)然用這種方式有一個(gè)前提是需要把一部分非敏感的信息放出來,不要加密,這樣便可以實(shí)現(xiàn)在瀏覽器端也對(duì)文件進(jìn)行讀取。只需要前后端的格式約定做好,都可以采用這種方式對(duì)壓縮包進(jìn)行一定的初步校驗(yàn),當(dāng)然后端的校驗(yàn)仍然是需要做好的。
至此,我們完成了對(duì)文件的加密、解密以及瀏覽器解析等操作,希望對(duì)你們有幫助
結(jié)語(yǔ)
文件的加密、解密在后端其實(shí)是一個(gè)很常規(guī)的操作,除了上面聊到的?AES、RSA,其實(shí)還有其它很多加密方案,具體可以看看?Node.js crypto 模塊[9],已經(jīng)有內(nèi)置比較多的方案可以直接使用。
當(dāng)然文件的加解密,也可以直接用?zip、7z?等這些壓縮工具,再配合密碼的方案,一般情況也是夠用的,但是免不了有定制化的需求,一般也都是結(jié)合使用,比如上面的?fileBuffer?實(shí)際內(nèi)部就是先用這些工具對(duì)文件進(jìn)行了壓縮并加密。還是以場(chǎng)景為重,多種方案結(jié)合效果更好。
文件加解密的就講到這里吧,還有什么其它問題的可以在評(píng)論區(qū)討論,謝謝。
關(guān)于本文
作者:IDuxFE
https://juejin.cn/post/6997565255463206925
參考資料
https://juejin.cn/user/1047150053304157
[2]https://en.wikipedia.org/wiki/Advanced_Encryption_Standard
[3]https://en.wikipedia.org/wiki/RSA_(cryptosystem)
[4]http://nodejs.cn/api/crypto.html
[6]
https://www.openssl.org/
[7]https://www.scottbrady91.com/OpenSSL/Creating-RSA-Keys-using-OpenSSL
[8]https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/DataView
[9]http://nodejs.cn/api/crypto.html
