開發必懂的文件加解密

背景

最近團隊遇到一個小需求,存在兩個系統 A、B,系統 A 支持用戶在線製作皮膚包,製作後的皮膚包用戶可以下載後,導入到另外的系統 B 上。皮膚包本身的其實就是一個 zip 壓縮包,系統 B 接收到壓縮包後,解壓並做一些常規的校驗,比如版本、內容合法性校驗等,整體功能也比較簡單。

但沒想到啊,一幫測試人員對我們開發人員一頓輸出,首先繞過系統 A 搞了幾個視頻文件,把後綴改成 zip 就直接想上傳,系統 B 每次都是等到上傳完後才發現文件不合法,系統 B 在文件沒上傳完前又無法解壓,也不知道文件內容是不是合法的,就這麼消耗了大量帶寬、大量時間後才提示用戶皮膚包有問題。

這裏涉及了兩個問題,我們來捋一捋:

  1. 文件如何做加密,這樣用戶便無法去逆向,壓縮包內部的敏感信息不會泄露出去。

  2. 服務端在接收到信息流時,在未傳輸完時如何去判斷壓縮包的合法性,提前告知用戶。

AES VS RSA

說到加密,自己很多人會想到對稱算法 AES[2] 以及非對稱算法 RSA[3]。這兩種算法按字面意思也較好理解,對稱加密技術說白一點就是加密跟解密使用的是同一個密鑰,這種加密算法速度極快,安全級別高,加密前後的大小一致;非對稱加密技術則有公鑰PK私鑰SK,算法的原理在於尋找兩個素數,讓他們的乘積剛好等於一個約定的數字,非對稱算法的安全性是依賴於大數的分解,這個目前沒有理論支持可以快速破解,它的安全性完全依賴於這個密鑰的長度,一般用 1024 位已經足夠使用。但是它的速度相比對稱算法慢得多,一般僅用於少量數據的加密,待加密的數據長度不能超過密鑰的長度

使用 AES 對文件加密

結合這兩種加密方式的優缺點,我們採用 AES 對文件本身做加解密,使用 AES 的原因主要考慮如下:

  1. 加解密性能問題,AES 的速度極快,相比 RSA 有 1000 倍以上提升。

  2. RSA 對源文有長度的要求,最大長度僅有密鑰長度。

AES 的加密算法 Node.js 的 crypto[4] 模塊中已經有內置,具體的使用可以參考官方文檔。

AES 加密邏輯

const crypto = require('crypto');
const algorithm = 'aes-256-gcm';

/**
 * 對一個buffer進行AES加密
 * @param {Buffer} buffer   待加密的內容
 * @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();
    // 生成身份驗證標籤,用於驗證密文的來源
    const tag = cipher.getAuthTag();
    return {
        key,
        iv,
        tag,
        buffer: buffer.concat([encrypted, end]);
    };
}

AES 解密邏輯

解密整體跟加密一樣,只是接口換個名字即可:

const crypto = require('crypto');
const algorithm = 'aes-256-gcm';

/**
 * 對一個buffer進行AES解密
 * @param {{key: string, iv: string, tag: Buffer, buffer: Buffer}} ret   待解密的內容
 * @param {String} key      密鑰
 * @param {String} iv       初始向量
 * @return {Buffer}
 */
function aesDecrypt ({key, iv, tag, buffer}) {
    // 初始化解密算法
    const decipher = crypto.createDecipheriv(algorithm, key, iv);
    // 生成身份驗證標籤,用於驗證密文的來源
    decipher.setAuthTag(tag);
    let decrypted = decipher.update(buffer);
    let end = decipher.final();
    return Buffer.concat([decrypted, end]);
}

AES 具體使用

有了上述兩個接口後,我們便可實現一個簡單的對稱加密了:

const key = 'abcdefghijklmnopqrstuvwxyz123456'; // 32 共享密鑰,長度跟算法需要匹配上
const iv = 'abcdefghijklmnop';  // 16 初始向量,長度跟算法需要匹配上
let fileBuffer = Buffer.from('abc');

// 加密
let encrypted = aesEncrypt(fileBuffer, key, iv);

// 解密
let context = aesDecrypt(encrypted);
console.log(context.toString());

一般情況下,這個密鑰較爲重要,如果發生泄露則加密失去意義,所以keyiv會使用隨機數動態生成,比如:

const key = crypto.randomBytes(32);
const iv = crypto.randomBytes(16);

通過上述的調整後,加解密文件是比較容易的,回到我們的業務系統上面,系統 A 生成的壓縮包,最終是需要給系統 B 使用,兩個系統是隔離的,那這樣 keyiv 如何傳輸到系統 B 上面呢,況且還是動態生成的,生成出來 key 系統 B 是不知道的。

讀到這聰明的你可能會想到,在把壓縮包給到 B 的時候,順便把 keyiv 一同提交過去不就可以了,但細想了下,這個肯定不能明文把這個密鑰發送過去,要不這個加密意義何在。

這時便需要用上 RSA 非對稱加密技術了。

使用 RSA 算法對密鑰再次進行非對稱加密

RSA 的加密算法 Node.js 的 crypto 模塊 [5] 中已經有內置,具體的使用可以參考官方文檔。

生成 RSA 的公鑰與私鑰

使用 openssl[6] 組件可以直接生成 RSA 的公鑰私鑰對,具體的命令可以參考:www.scottbrady91.com/OpenSSL/Cre…[7]。

# 生成私鑰
openssl genrsa -out private.pem 1024

# 提取公鑰
openssl rsa -in private.pem -pubout -out public.pem

這樣生成出來的兩個文件 private.pempublic.pem 就可以使用了,下面我們使用 Node.js 實現具體的加解密邏輯。

RSA 加密邏輯

const fs = require('fs');
const crypto = require('crypto');
const PK = fs.readFileSync('./public.pem''utf-8');

/**
 * 對一個buffer進行RSA加密
 * @param {Buffer} 待加密的內容
 * @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');

/**
 * 對一個buffer進行RSA解密
 * @param {Buffer} 待解密的內容
 * @return {Buffer}
 */
function rsaDecrypt (buffer) {
    return crypto.privateDecrypt(SK, buffer);
}

RSA 具體使用

有了上述接口後,便可對 AES 的密鑰進行加密後再傳輸,服務器 B 保存好 RSA 私鑰 ,服務器 A 則可以直接用 RSA 公鑰 對數據加密後再發送,結合剛 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]));     // 由於長度是固定的,直接連在一起即可
    };
}

/**
 * 解密文件
 * @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
    })
}

這樣結合在一起後,服務器 A 生成的壓縮包,只要包含好 {file, key} 這兩塊內容,服務器 B 便可把文件解密出來了,這樣基本上實現了我們第一點的目標:1. 文件如何做加密,這樣用戶便無法去逆向,壓縮包內部的敏感信息不會泄露出去

但還遺留了另外一個問題需要解決:2. 服務端在接收到信息流時,在未傳輸完時如何去判斷壓縮包的合法性,提前告知用戶

關於解密的還可以看看:記一次破解前端加密詳細過程

優化加密文件

按上面的加密方式,輸出的結果是一個 buffer文件 內容,以及一個 加密過的key,除了這些信息外,一般這個 buffer文件 壓縮包還會有一些額外的信息,比如:版本號、壓縮包生成時間,描述信息等。這些信息按常規的方式,可能是分成幾個文件,然後再打一個壓縮包把文件放在一起,比如:

// zip file
- pkg
    manifest.json       // 額外的信息
    key.json            // 保存了加密過的密鑰
    file.json           // 加密過的文件

但如果用這種方式保存,一般情況下還要對這個 zip文件 做下加密,然後改下後綴名,但是服務器 B 在讀取這個文件後仍然是需要全部接收,再解壓到臨時目錄,讀取內容後纔可以做校驗,這樣問題仍然解決不了。

除此之外,還有另外一個常見的需求,產品一般希望在瀏覽器側在文件上傳時就先做初步的解析,把明顯不合法的文件提示到用戶,這樣用戶體驗更好。

這個問題的解決方案也不難,這些所有額外的信息都是可以把它當成二進制插入到文件的頭部上的,比如:

包字段描述:|----插入的額外信息----|----後面纔是真正的文件內容----|  
二進制文件:010101010101010101010xxxxxxxxxxxxxxxxxxxxxxxxxxxx

文件頭字段設計

我們把這些所有信息,按一定的格式,使用二進制的方式全部串連在一起,最終交付的只有一個組合過的文件,比如:

// theme pkg.

0                8                16                 
|------flag------|--extra length--|
|----------extra data...----------|
|-------------data...-------------|

固定標識 THEME,長度:8 byte,說明該壓縮包爲一個皮膚包,這樣可以快速對壓縮包進行識別

extra data 的真實長度,這是一個 16 進制的數據,長度:8 byte,說明插入的數據長度。比如:長度 35 的數據,轉化爲 16 進制後爲 0x23,那這字段爲 00000023

使用 RSA 加密過的數據,我們可以把上述需要用 RSA 加密的信息全部放在這裏,比如 key 字段、版本號、描述信息等

使用 AES 加密過的數據,可以通過 extra data 裏面保存的 key 把真實的數據全部解密出來

生成的新的加密文件

有了上面的理論基礎後,馬上可以實踐起來,代碼如下:

/**
 * 加密文件
 * @param {Buffer} fileBuffer
 * @return {Buffer}
 */
function encrypt (fileBuffer) {
    const key = crypto.randomBytes(32);
    const iv = crypto.randomBytes(16);
    const version = 'v1.1';

    // 記錄上所有額外的壓縮外信息,比如版本號、原始的密鑰
    const extraJSON = {
        version,
        key,
        iv
    }
    // 完成文件的AES加密,並輸出身份驗證標籤
    const { tag, file } = aesEncrypt(fileBuffer, key, iv);
    extraJSON.tag = tag;

    // 對 extraJSON 整個進行RSA加密
    const extraData = rsaEncrypt(Buffer.from(JSON.stringify(extraJSON)));
    const extraLength = extraData.length;

    // 最終把所有數據合併在一起
    return Buffer.concat([
        Buffer.from('THEME'),
        Buffer.from(Buffer.from(extraLength.toString(16).padStart(8, '0'))),
        extraData,
        file
    ]);
}

通過這種加密方式後,相關的信息都放在文件的頭部上,我們可以不用對整個文件進行操作的時候,便可以輕鬆讀取出來,對於解密其實就是一個反向的操作。

對新生成的文件進行解密

/**
 * 解密文件
 * @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;

    // 對已經被RSA加密過的數據進行解密操作
    const extraData = rsaDecrypt(fileBuffer.slice(16, extraDataEndIndex));
    const extraJSON = JSON.parse(extraData);
    // 最終使用AES再對剩下文件進行解密操作,即爲最終的文件
    return aesDecrypt({
        key: extraJSON.key,
        iv: extraJSON.iv,
        tag: extraJSON.tag,
        buffer: Buffer.slice(extraDataEndIndex)
    });
}

使用這種方式處理後,在 RSA 解密出 extraData 的時候,就可以對整個文件進行各種校驗,整個過程只要有異常說明文件已經被篡改,用這種方式比用壓縮包會好很多,特別是文件體積龐大的時候,可以流式處理,發現不合理時即可馬上阻止。

瀏覽器端如何解析該文件

由於現在整個文件格式都是二進制流,現代的瀏覽器都是有相應的能力去讀取並做處理的,這樣也可以在用戶上傳文件時先做一定的初步處理,體驗會有比較大的提升

可以使用 DataView 的方式把二進制數據讀取出來,詳情可以參考:DataView[8],初步的實現如下:

/**
 * 把二進制流轉成對應ascii字符
 * @param {DataView} dv         二進制數據庫
 * @param {Number}   start      起始位置
 * @param {Number}   end        結束位置
 * @return {String}
 */
function buffer2Char (dv, start, end) {
    let ret = [];
    for (let i = start; i < end; 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);
    });
}

當然用這種方式有一個前提是需要把一部分非敏感的信息放出來,不要加密,這樣便可以實現在瀏覽器端也對文件進行讀取。只需要前後端的格式約定做好,都可以採用這種方式對壓縮包進行一定的初步校驗,當然後端的校驗仍然是需要做好的。

至此,我們完成了對文件的加密、解密以及瀏覽器解析等操作,希望對你們有幫助

結語

文件的加密、解密在後端其實是一個很常規的操作,除了上面聊到的 AESRSA,其實還有其它很多加密方案,具體可以看看 Node.js crypto 模塊 [9],已經有內置比較多的方案可以直接使用。

當然文件的加解密,也可以直接用 zip7z 等這些壓縮工具,再配合密碼的方案,一般情況也是夠用的,但是免不了有定製化的需求,一般也都是結合使用,比如上面的 fileBuffer 實際內部就是先用這些工具對文件進行了壓縮並加密。還是以場景爲重,多種方案結合效果更好。

文件加解密的就講到這裏吧,還有什麼其它問題的可以在評論區討論,謝謝。

關於本文

作者:IDuxFE
https://juejin.cn/post/6997565255463206925

參考資料

[1]

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

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/clq_2UcZOzuT3xuGcud7Lw