NodeJS加解密之Crypto

前端LeBron發表於2021-12-22

如果覺得文章不錯,歡迎關注、點贊和分享!

持續分享技術博文,關注微信公眾號 ?? 前端LeBron

網際網路時代,網路上的資料量每天都在以驚人的速度增長。同時,各類網路安全問題層出不窮。在資訊保安重要性日益凸顯的今天,作為一名開發者,需要加強對安全的認識,並通過技術手段增強服務的安全性。crypto模組的目的是為了提供通用的加密和雜湊演算法。用純JavaScript程式碼實現這些功能不是不可能,但速度會非常慢。Nodejs用C/C++實現這些演算法後,通過cypto這個模組暴露為JavaScript介面,這樣用起來方便,執行速度也快。

編碼方式

為什麼資訊傳輸需要編碼?

在開發加密解密資料的時候碰到需要把加密好的位元組陣列轉換成 String 物件用於網路傳輸的需求,如果把位元組陣列直接轉換成 UTF-8 等編碼方式的話肯定會存在某些編碼沒有對應的字元(8bit只能表示128個字元),在編碼和解析過程中會出錯,不能正確地表達資訊。這時就可以通過常用的二進位制資料編碼方式 Base64 編碼或者 Hex 編碼來實現。

hex編碼

  • 編碼原理

將一個8位的位元組資料用兩個16進位制數表示出來

  1. 將8位二進位制碼重新分組成兩個4位的位元組
  2. 其中一個位元組的低4位是原位元組的高4位,另一個位元組的低4位是原資料的低4位
  3. 高4位都補0,然後輸出這兩個位元組對應的十六進位制數字作為編碼
  • 例子
ASCII碼:A(65)

二進位制碼:0100 0001

重新分組: 00000100  00000001

十六進位制: 4         1

Hex編碼:41

就算原檔案是純英文內容,編碼後內容也和原文完全不一樣,普通人難以閱讀但由於只有16個字元,聽說一些程式設計師大牛能夠記下他們的對映關係,從而達到讀hex編碼和讀原文一樣的效果。另外,資料在經過hex編碼後,空間佔用變成了原來的2倍。

base64編碼

  • 編碼原理

Base64編碼是通過64個字元來表示二進位制資料,64個字元表示二進位制資料只能表示6位,所以它可以通過4個 Base64字元來表示3個位元組,如下是Base64的字元編碼表

img

  • 舉個Base64編碼的例子,圖就很淺顯易懂了

img

  • 字串長度不是3的倍數時補0,也就是“=”

img

由64個字元組成,比hex編碼更難閱讀,但由於每3個位元組會被編碼為4個字元。

所以,空間佔用會是原來的4/3,比hex要節省空間。另外要注意的是,雖然Base64編碼後的資料難以閱讀,但不能將其作為加密演算法使用,因為它解碼都不需要你提供金鑰啊

urlencode編碼

  • 編碼原理

urlencode編碼,看名字就就知道是設計給url編碼的對於a-zA-Z0-9.-_ ,urlencode都不會做任何處理原樣輸出,而其它位元組會被編碼為%xx(16進位制)的形式,其中xx就是這個位元組對應的hex編碼。 由於英文字元原樣保留,對於以英文為主的內容,可讀性最好,空間佔用幾乎不變,而對於非英文內容,每個位元組會被編碼為%xx的3個字元,空間佔用是原來的3倍,所以urlencode是一個對英文友好的編碼方案。

Hash

摘要:將不固定長度的訊息作為輸入Hash函式,生成固定長度的輸出,這段輸出稱之為摘要

適用場景:敏感資訊的校驗和儲存、驗證訊息完整 & 未被篡改

特點

  1. 輸出長度固定:輸入長度不固定,輸出長度固定(因演算法而異,常見的有MD5、SHA系列)。
  2. 運算不可逆:已知運算結果的情況下,無法通過通過逆運算得到原始字串。
  3. 高度離散:輸入的微小變化,可導致運算結果差異巨大。
  4. 弱碰撞性:不同輸入的雜湊值可能相同。

以MD5為例

MD5(Message-Digest Algorithm)是電腦保安領域廣泛使用的雜湊函式(又稱雜湊演算法、摘要演算法),主要用來確保訊息的完整和一致性。

常見的應用場景:密碼保護、下載檔案校驗等。

應用場景

  1. 檔案完整性校驗:比如從網上下載一個軟體,一般網站都會將軟體的md5值附在網頁上,使用者下載完軟體後,可對下載到本地的軟體進行md5運算,然後跟網站上的md5值進行對比,確保軟體的完整性
  2. 密碼保護:將md5後的密碼儲存到資料庫,而不是儲存明文密碼,避免拖庫等事件發生後,明文密碼洩漏。
  3. 防篡改:比如數字證照的防篡改,就用到了摘要演算法。(當然還要結合數字簽名等手段)

簡單的md5運算

  • hash.digest([encoding])

計算摘要。encoding可以是hexbase64或其他。如果宣告瞭encoding,那麼返回字串。否則,返回Buffer例項。注意,呼叫hash.digest()後,hash物件就作廢了,再次呼叫就會報錯。

  • hash.update(data[, input_encoding])

input_encoding可以是utf8ascii或者其他。如果data是字串,且沒有指定 input_encoding,則預設是utf8。注意,hash.update()方法可以呼叫多次。

const crypto = require('crypto');
const fs = require('fs');

const FILE_PATH = './index.txt'
const ENCODING = 'hex';

const md5 = crypto.createHash('md5');
const content = fs.readFileSync(FILE_PATH);
const result = md5.update(content).digest(ENCODING);
console.log(result);

// f62091d58876a322864f5a522eb05052

密碼保護

前面提到,將明文密碼儲存到資料庫是很不安全的

最不濟也要進行md5後進行儲存

比如使用者密碼是123456,md5執行後,得到輸出:e10adc3949ba59abbe56e057f20f883e

這樣至少有兩個好處:

  1. 防內部攻擊:網站開發者也不知道使用者的明文密碼,避免開發者拿著使用者明文密碼幹壞事,以這種形式來保護使用者的隱私
  2. 防外部攻擊:如網站被黑客入侵,黑客也只能拿到md5後的密碼,而不是使用者的明文密碼,保證了密碼的安全性
const crypto = require('crypto');

const cryptPwd = (password) => {
    const md5 = crypto.createHash('md5');
    return md5.update(password).digest('hex');
}

const password = '123456';
const cryptPassword = cryptPwd(password);
console.log(cryptPassword);

// e10adc3949ba59abbe56e057f20f883e
  • 前面提到,通過對使用者密碼進行md5運算來提高安全性。

    • 但實際上,這樣的安全性是很差的,為什麼呢?
    • 稍微修改下上面的例子,可能你就明白了。相同的明文密碼,md5值也是相同的。
  • 也就是說當攻擊者知道演算法是md5,且資料庫裡儲存的密碼值為e10adc3949ba59abbe56e057f20f883e時,理論上可以可以猜到,使用者的明文密碼就是123456
  • 事實上,彩虹表就是這麼進行暴力破解的:事先將常見明文密碼的md5值運算好存起來,然後跟網站資料庫裡儲存的密碼進行匹配,就能夠快速找到使用者的明文密碼。

那麼有什麼辦法可以進一步提升安全性呢?

答案是:密碼加鹽。

密碼加鹽

“加鹽”這個詞看上去很玄乎,其實原理很簡單

就是在密碼特定位置插入特定字串後,再對修改後的字串進行md5運算。

同樣的密碼,當“鹽”值不一樣時,md5值的差異非常大

通過密碼加鹽,可以防止最初級的暴力破解,如果攻擊者事先不知道”鹽“值,破解的難度就會非常大

const crypto = require('crypto');

const cryptPwd = (password, salt) => {
    const saltPassword = `${password}:${salt}`;
    console.log(`原始密碼:${password}`);
    console.log(`加鹽密碼:${saltPassword}`);

    const md5 = crypto.createHash('md5');
    const result = md5.update(password).digest('hex');
    console.log(`加鹽密碼的MD5值:${result}`)
}



const password = '123456';
const salt = 'abc'
cryptPwd(password, salt);
/*
原始密碼:123456
加鹽密碼:123456:abc
加鹽密碼的MD5值:e10adc3949ba59abbe56e057f20f883e
*/

密碼加鹽:隨機鹽值

通過密碼加鹽,密碼的安全性已經提高了不少

但其實上面的例子存在不少問題

  • 假設字串拼接演算法、鹽值已外洩,上面的程式碼至少存在下面問題:
  1. 短鹽值:需要窮舉的可能性較少,容易暴力破解,一般採用長鹽值來解決。
  2. 鹽值固定:類似的,攻擊者只需要把常用密碼+鹽值的hash值表算出來。
  • 短鹽值自不必說,應該避免

    • 對於為什麼不應該使用固定鹽值,這裡需要多解釋一下。很多時候,我們的鹽值是硬編碼到我們的程式碼裡的(比如配置檔案),一旦攻擊者通過某種手段獲知了鹽值,那麼,只需要針對這串固定的鹽值進行暴力窮舉就行了
  • 比如上面的程式碼,當你知道鹽值是abc時,立刻就能猜到51011af1892f59e74baf61f3d4389092對應的明文密碼是123456
那麼,該怎麼優化呢?答案是:隨機鹽值。

可以看到,密碼同樣是123456,由於採用了隨機鹽值,前後運算得出的結果是不同的

這樣帶來的好處是,多個使用者,同樣的密碼,攻擊者需要進行多次運算才能夠完全破解

同樣是純數字3位短鹽值,隨機鹽值破解所需的運算量 >> 固定鹽值

示例程式碼如下
const crypto = require('crypto');

const getRandomSalt = () => {
    return Math.random().toString().slice(2,5);
}

const cryptPwd = (password, salt) => {
    const saltPassword = `${password}:${salt}`;
    console.log(`原始密碼:${password}`);
    console.log(`加鹽密碼:${saltPassword}`);

    const md5 = crypto.createHash('md5');
    const result = md5.update(saltPassword).digest('hex');
    console.log(`加鹽密碼的MD5值:${result}`)
}

const password = '123456';

cryptPwd(password, getRandomSalt());

/*
原始密碼:123456
加鹽密碼:123456:126
加鹽密碼的MD5值:3aeb1848ff63aa32b262bc3f8dd5bd82
*/

cryptPwd(password, getRandomSalt());

/*
原始密碼:123456
加鹽密碼:123456:232
加鹽密碼的MD5值:21a427268a5094322146e18e47b135fb
*/

HMAC功能

HMAC的全稱是Hash-based Message Authentication Code,也即在hash的加鹽運算。

具體到使用的話,跟hash模組差不多,選定hash演算法,指定“鹽”即可。

和上面的例子的區別是一個是手動拼鹽值,一個是利用HMAC模組

const crypto = require("crypto")
const fs = require("fs")

const FILE_PATH = "./index.txt"
const SECRET = 'secret'
const content = fs.readFileSync(FILE_PATH,{encoding:'utf8'})
const hmac = crypto.createHmac('sha256', SECRET);

hmac.update(content)
const output = hmac.digest('hex')
console.log(`Hmac: ${output}`)

// Hmac: 6f438ef66d3806ae14d6692d9610e55c41ebb4eb3ee73911a4d512bd1cade976
注:大檔案可流式處理

加密 / 解密

加解密主要用到下面兩組方法:
  • 加密:

    • crypto.createCipher(algorithm, password)
    • crypto.createCipheriv(algorithm, key, iv)
  • 解密:

    • crypto.createDecipher(algorithm, password)
    • crypto.createDecipheriv(algorithm, key, iv)

crypto.createCipher / crypto.createDecipher

先來看下 crypto.createCipher(algorithm, password),兩個引數分別是加密演算法、密碼
  • algorithm:加密演算法,比如aes192

    • 具體有哪些可選的演算法,依賴於本地openssl的版本
    • 可以通過openssl list-cipher-algorithms命令檢視支援哪些演算法
  • password:用來生成金鑰(key)、初始化向量(IV)
crypto.createDecipher(algorithm, password)可以看作 crypto.createCipher(algorithm, password) 逆向操作
const crypto = require("crypto")

const SECRET = 'secret'
const ALGORITHM = 'aes192'
const content = 'Hello Node.js'
const encoding = 'hex'

// 加密
const cipher = crypto.createCipher(ALGORITHM, SECRET)
cipher.update(content)
const output = cipher.final(encoding)
console.log(output)
// 944e6e3c21d6eb8568bd6a9716631e、e

// 解密
const decipher = crypto.createDecipher(ALGORITHM, SECRET)
decipher.update(output, encoding)
const input = decipher.final('utf8')
console.log(input)

// Hello Node.js

crypto.createCipheriv / crypto.createDecipheriv

相對於 crypto.createCipher() 來說,crypto.createCipheriv() 需要提供keyiv,而 crypto.createCipher() 是根據使用者提供的 password 算出來的

key、iv 可以是Buffer,也可以是utf8編碼的字串,這裡需要關注的是它們的長度:

  • key:根據選擇的演算法有關

    • 比如 aes128、aes192、aes256,長度分別是128、192、256位(16、24、32位元組)
  • iv:初始化向量,都是128位(16位元組),也可以理解為密碼鹽的一種
const crypto = require("crypto")

const key = crypto.randomBytes(192 / 8)
const iv = crypto.randomBytes(128 / 8)
const algorithm = 'aes192'
const encoding = 'hex'

const encrypt = (text) => {
    const cipher = crypto.createCipheriv(algorithm, key, iv)
    cipher.update(text)
    return cipher.final(encoding)
}

const decrypt = (encrypted) => {
    const decipher = crypto.createDecipheriv(algorithm, key, iv)
    decipher.update(encrypted, encoding)
    return decipher.final('utf8')
}

const content = 'Hello Node.js'
const crypted = encrypt(content)
console.log(crypted)

// db75f3e9e78fba0401ca82527a0bbd62

const decrypted = decrypt(crypted)
console.log(decrypted)

// Hello Node.js

數字簽名 / 簽名校驗

  • 假設:

    • 服務端原始資訊為M,摘要演算法為Hash,Hash(M)得出的摘要是H
    • 公鑰為Pub,私鑰為Piv,非對稱加密演算法為Encrypt,非對稱解密演算法為Decrypt
    • Encrypt(H)得到的結果是S
    • 客戶端拿到的資訊為M1,利用Hash(M1)得出的結果是H1
  • 數字簽名的產生、校驗步驟分別如下:

    • 數字簽名的產生步驟:

      • 利用摘要演算法Hash算出M的摘要,即Hash(M) == H
      • 利用非對稱加密演算法對摘要進行加密Encrypt( H, Piv ),得到數字簽名S
    • 數字簽名的校驗步驟:

      • 利用解密演算法D對數字簽名進行解密,即Decrypt(S) == H
      • 計算M1的摘要 Hash(M1) == H1,對比 H、H1,如果兩者相同,則通過校驗

私鑰如何生成不是這裡的重點,這裡採用網上的服務來生成。

瞭解了數字簽名產生、校驗的原理後,相信下面的程式碼很容易理解:

const crypto = require('crypto');
const fs = require('fs');
const privateKey = fs.readFileSync('./private-key.pem');  // 私鑰
const publicKey = fs.readFileSync('./public-key.pem');  // 公鑰
const algorithm = 'RSA-SHA256';  // 加密演算法 vs 摘要演算法
const encoding = 'hex'

// 數字簽名
function sign(text){
    const sign = crypto.createSign(algorithm);
    sign.update(text);
    return sign.sign(privateKey, encoding);
}

// 校驗簽名
function verify(oriContent, signature){
    const verifier = crypto.createVerify(algorithm);
    verifier.update(oriContent);
    return verifier.verify(publicKey, signature, encoding);
}

// 對內容進行簽名
const content = 'hello world';
const signature = sign(content);
console.log(signature);

// 校驗簽名,如果通過,返回true
const verified = verify(content, signature);
console.log(verified);

DH(DiffieHellman)

DiffieHellman:Diffie–Hellman key exchange,縮寫為D-H,是一種安全協議,常用於金鑰交換,讓通訊雙方在預先沒有對方資訊的情況下,通過不安全通訊通道,建立一個金鑰。這個金鑰可以在後續的通訊中,作為對稱加密的金鑰加密傳遞的資訊。
  • 原理解析

假設客戶端、服務端挑選兩個素數a、p(都公開),然後

  • 客戶端:選擇自然數Xa,Ya = a^Xa mod p,並將Ya傳送給服務端;
  • 服務端:選擇自然數Xb,Yb = a^Xb mod p,並將Yb傳送給客戶端;
  • 客戶端:計算 Ka = Yb^Xa mod p
  • 服務端:計算 Kb = Ya^Xb mod p

Ka = Yb^Xa mod p

= (a^Xb mod p)^Xa mod p

= a^(Xb * Xa) mod p

= (a^Xa mod p)^Xb mod p

= Ya^Xb mod p

= Kb

可以看到,儘管客戶端、服務端彼此不知道對方的Xa、Xb,但算出了相等的secret

const crypto = require('crypto');

const primeLength = 1024;  // 素數p的長度
const generator = 5;  // 素數a

// 建立客戶端的DH例項
const client = crypto.createDiffieHellman(primeLength, generator);
// 產生公、私鑰對,Ya = a^Xa mod p
const clientKey = client.generateKeys();

// 建立服務端的DH例項,採用跟客戶端相同的素數a、p
const server = crypto.createDiffieHellman(client.getPrime(), client.getGenerator());
// 產生公、私鑰對,Yb = a^Xb mod p
const serverKey = server.generateKeys();

// 計算 Ka = Yb^Xa mod p
const clientSecret = client.computeSecret(server.getPublicKey());
// 計算 Kb = Ya^Xb mod p
const serverSecret = server.computeSecret(client.getPublicKey());

// 由於素數p是動態生成的,所以每次列印都不一樣
// 但是 clientSecret === serverSecret
console.log(clientSecret.toString('hex'));
console.log(serverSecret.toString('hex'));
// 39edfedad4f1be731977436936ca844e50ebc90953ad208c71d7f2dc1772409962ec3eb90eaf99db5948f089e1d4951f148bd7ff76c18b53ff6be32f267fc54535928ce4acf15d923cfd0caec45db95b206e7636128210ea6813a20fb09cbfb06214b2f488716fea32788023d98cb4cb7fe39b68bd3563b3b34257e37f6b7fb7

// 39edfedad4f1be731977436936ca844e50ebc90953ad208c71d7f2dc1772409962ec3eb90eaf99db5948f089e1d4951f148bd7ff76c18b53ff6be32f267fc54535928ce4acf15d923cfd0caec45db95b206e7636128210ea6813a20fb09cbfb06214b2f488716fea32788023d98cb4cb7fe39b68bd3563b3b34257e37f6b7fb7

ECDH(Elliptic Curve Diffie-Hellma)

ECDH和DH原理類似,都是安全金鑰協商協議。

相對於DH協議,結合橢圓曲線密碼學ECC加速,運算更節省CPU資源

  • ECDH(Elliptic Curve Diffie-Hellman )原理如下

img

const crypto = require('crypto');

const G = 'secp521r1';
const encoding = 'hex'

const server = crypto.createECDH(G);
const serverKey = server.generateKeys();

const client = crypto.createECDH(G);
const clientKey = client.generateKeys();

const serverSecret = server.computeSecret(clientKey);
const clientSecret = client.computeSecret(serverKey);

console.log(serverSecret.toString(encoding));
console.log(clientSecret.toString(encoding));
// 01c418be1b479f936397d4c1653ad77fa28fade67ff058dc18264a72bd1fc208ea6cac4dad996fda55bf271e84f0faef085173257b67bf21f95b09acee4d0a204517

// 01c418be1b479f936397d4c1653ad77fa28fade67ff058dc18264a72bd1fc208ea6cac4dad996fda55bf271e84f0faef085173257b67bf21f95b09acee4d0a204517

ECDHE(Elliptic Curve Diffie-Hellma Ephemeral)

普通的ECDH演算法也存在一定缺陷,比如金鑰協商的時候有一方的私鑰總是一樣的,一般都是Server方固定,Client方私鑰隨機生成。隨著時間的延長,黑客可以截獲到海量的金鑰協商過程(有些資料是公開的),黑客就可以依據這些資料暴力破解出Server的私鑰,然後就可以計算出會話金鑰了,加密的資料也會隨之被破解。固定一方的私鑰會有被破解的風險,那麼就讓雙方的私鑰在每次金鑰交換通訊時,都是隨機生成的、臨時的,這個演算法就是ECDH的增強版:ECDHE, E 全稱是 Ephemeral(臨時性的)。

擴充套件

學習這塊兒知識的同時也學習了很多密碼學相關知識,發現越挖越深快陷進去了?,感興趣的同學可以繼續展開看看相關加密演算法和他們之間的區別以及應用場景,例如:
  • 非對稱加密DSA、RSA、DH、DHE、ECDHE
  • 對稱加密AES、DES

RSA演算法原理(二) - 阮一峰的網路日誌

圖解 ECDHE 金鑰交換演算法 - 小林coding

資料加密標準(DES) - 維基百科](https://zh.wikipedia.org/wiki/資料加密標準)

高階加密標準(AES) - 維基百科

相關術語

SPKAC:Signed Public Key and Challenge

MD5:Message-Digest Algorithm 5,資訊-摘要演算法。

SHA:Secure Hash Algorithm,安全雜湊演算法。

HMAC:Hash-based Message Authentication Code,金鑰相關的雜湊運算訊息認證碼。

對稱加密:比如AES、DES

非對稱加密:比如RSA、DSA

AES:Advanced Encryption Standard(高階加密標準),金鑰長度可以是128、192和256位。

DES:Data Encryption Standard,資料加密標準,對稱金鑰加密演算法(現在認為不安全)。

DiffieHellman:Diffie–Hellman key exchange,縮寫為D-H,是一種安全協議,讓通訊雙方在預先沒有對方資訊的情況下,通過不安全通訊通道,建立一個金鑰。這個金鑰可以在後續的通訊中,作為對稱加密的金鑰加密傳遞的資訊。(備註,是使用協議的發明者命名)

金鑰交換演算法

常見的金鑰交換演算法有 RSA,ECDHE,DH,DHE 等演算法。它們的特性如下:

  • RSA:演算法實現簡單,誕生於 1977 年,歷史悠久,經過了長時間的破解測試,安全性高。缺點就是需要比較大的素數(目前常用的是 2048 位)來保證安全強度,很消耗 CPU 運算資源。RSA 是目前唯一一個既能用於金鑰交換又能用於證照籤名的演算法。
  • DH:diffie-hellman 金鑰交換演算法,誕生時間比較早(1977 年),但是 1999 年才公開。缺點是比較消耗 CPU 效能。
  • ECDHE:使用橢圓曲線(ECC)的 DH 演算法,優點是能用較小的素數(256 位)實現 RSA 相同的安全等級。缺點是演算法實現複雜,用於金鑰交換的歷史不長,沒有經過長時間的安全攻擊測試。
  • ECDH:不支援 PFS,安全性低,同時無法實現 false start。
  • DHE:不支援 ECC。非常消耗 CPU 資源 。

建議優先支援 RSA 和 ECDH_RSA 金鑰交換演算法。原因是:

  • ECDHE 支援 ECC 加速,計算速度更快。支援 PFS,更加安全。支援 false start,使用者訪問速度更快。
  • 目前還有至少 20% 以上的客戶端不支援 ECDHE,我們推薦使用 RSA 而不是 DH 或者 DHE,因為 DH 系列演算法非常消耗 CPU(相當於要做兩次 RSA 計算)。

    持續分享技術博文,歡迎關注!
  • 掘金:前端LeBron
  • 知乎:前端LeBron
    持續分享技術博文,關注微信公眾號??

image.png

相關文章