前言
前陣子用 Node.js 開發時需要用一組加解密的 key 來將某個 url 的 query parameters 加密,就重新回頭研究一下對稱加密與非對稱加密的差異和手段,最後統一用 Node.js 的套件來簡單實作,留下此篇文章以供未來參考
密碼學在學什麼?
密碼學的目標可以簡單列出底下三個:
- 資訊保密: 確保只有授權者才能取得訊息
- 完整性驗證: 確保資訊沒有遭到算改
- 身分驗證: 驗證傳送方與接受方的身分
密碼學主要分為三種,先講結論:
- Encode(編碼)
- Two way 的轉換,只是編碼格式的轉換,不需要密碼,所以不算加密,沒有任何資安防護能力
- Encrypt(加密)
- Two way 的轉換,需要密碼介入,可分成對稱加密與非對稱加密,前者是同一把金鑰,後者是有一組公私鑰,有防護資安的能力
- Hash(雜湊)
- One way 的轉換(不可逆),應用 Hashing 的演算法將待加密的字串轉換成一個獨一無二的 hash digest
Encode(編碼)
- 只是換個方式來呈現資料
- 沒有加密,所以沒有安全性
- 只是各種平台使用數據的載體,ex: QRCode、UTF-8
- 為了配合網路傳輸的標準規範(RFC,Requests For Comment),有時就必須透過編碼後才能傳送
Base64
是一種基於 64 個字符來表示的方法
Base64 中的 64 其實是有含意的,他會把資料轉成:
a~z (26)
A~Z (26)
0~9 (10)
+ (1)
/ (1)
base64 的 64 就是上面五種字元的所有可能(26+26+10+1+1=64)
打開瀏覽器的 console 介面,用 Web API 就可以開箱及用
Huffman Coding(霍夫曼編碼)
是一種無損數據壓縮的編碼方式
- 根據整組資料中符號出現的頻率高低,決定如何給符號編碼。如果符號出現的頻率越高,則給符號的碼越短,相反符號的號碼越長
- 可以透過這個網站 –> Huffman Tree Generator來了解 Huffman 的編碼 tree
Encrypt(加密)
將明文資訊轉變成難以讀取的密文
Caesar Cipher(凱薩加密法)
- 凱撒密碼是一種替換加密技術
- 明文中的所有字母都在字母表上向後(或向前)按照一個固定數目進行偏移後被替換成密文。
- 已經不再使用,因為英文字母只有 26 個,全部破解然後列出一個彩虹表(Rainbow Table),看哪一個看得懂就破解了
AES(Advanced Encryption Standard)
- 它是一個區塊加密的演算法
- 所謂的「區塊加密」就是把明文拆成多個區段,然後分別加密再組合起來
- 加密的方式是連續對每個 128 bit 的數據應用一系列的數學變換
- 也是一種 對稱加密演算法(加密解密都是同個 key)
- AES 有分成幾種模式,像是 ECB、CBC、CFB,其中CBC 是最安全的(記住這個就好)
- 是業界常用的加密方法,速度快
- 忘記密碼基本上就沒救了,因為排列組合太多,AES128 加密需要 10^18 年才能解開
- 因為加密的數學計算量較低,所以小型裝置如筆電、手機都可以完成加密運算
RSA
- 縮寫來自三位 MIT 科學家的名字(Rivest, Shamir, and Adleman)
- 是一種 非對稱加密演算法(加密解密是不同 key,一個公鑰一個私鑰)
- 概念很簡單,應用 兩個很大的質數 p 與 q 相乘出 N 很容易,但從 N 要找回原本 p 與 q 是極度困難的任務 的概念來做加密
- 數學計算量較高,所以只適用於小量數據
Hash(雜湊)
- 將資料內容打亂、混和
- 無法從 hash 回推解密
- 舉例來說,身分證號碼最後一碼是驗證碼,但因為只有 0-9,所以極有可能會雜湊碰撞(collection)
MD5 (Message-Digest Algorithm)
- 可以產生出一個128 bit(16 byte)的雜湊值,用於確保資料傳輸是完整一致的
- 兩個不同的明文不會得到相同的輸出值
- 輸出值,不能得到原始的明文,即其過程不可逆
- 可以判斷檔案內容是否一樣,看雜湊值是不是一樣
- 也可以確認網站上下載下來的檔案是不是雜湊值一樣,例如下載任何套件的情境,這樣就能確保不會載到病毒
- 即使僅改變一個字,也會導致結果差很多
- 業界已經較禁止使用了, 因為已經證明容易破解! 因為 MD5 無法防止碰撞,駭客可以上傳一個內容不同但最終雜湊值相同的檔案
SHA384 (Secure Hash Algorithm)
- 可以理解成更複雜的 MD5
- 會 output 出 384 個字元,複雜度較高
- 可以驗證檔案的完整性
- 保存使用者密碼
- 後端資料庫應該要存的是 Hash 過後的密碼
- 這樣即使網站被攻擊後也無法被竊取解密
- 如果某線上服務忘記密碼回傳的是明文,那就代表該網站後端存的是明文密碼(怕),因為 Hash 理論上無法被回推,所以代表資料庫一定也是存明碼,否則無法將當初設定的密碼明文寄回
- 理論上應該要導去重新設定密碼的頁面,重新讓使用者輸入密碼
bcrypt
- bcrypt 能夠將一個字串做雜湊加密
- 專門為密碼而設計的雜湊函式,為避免每次雜湊出來的值都是一樣的,有加鹽機制。
- 其中有個參數叫
saltRounds
是在密碼學中的加鹽(salt) - 加鹽的意思是在要加密的字串中加特定的字符,打亂原始的字符串,使其生成的 Hash value 產生變化
- 參數越高,代表加鹽次數愈多愈安全,相對的加密時間就越長
- 其中有個參數叫
- 比 SHA 更安全,但耗時也較久,所以要用 bcrypt 還是 SHA 是看情況而定
Node.js 的各種加密方式
Crypto
Encrypt & Decrypt
Output Encoding 設定成
hex
,所以只會有 16 種符號(a-f, 0-9)一旦執行
cipher.final()
,cipher 物件就不能再加密文字
const crypto = require("crypto");
// settings
const initKey = crypto.randomBytes(16);
const securityKey = crypto.randomBytes(32);
const algorithm = "aes-256-cbc";
const message = "Hello I am Madi";
// Cipher for encrypt data
const cipher = crypto.createCipheriv(algorithm, securityKey, initKey);
let encryptedMsg = cipher.update(message, "utf-8", "hex"); // input-encoding: utf-8, output encoding: hex
encryptedMsg += cipher.final("hex");
console.log("Encrypted: ", encryptedMsg); // Encrypted: c64dc767eedbf6f18696b548537d01d2
// Decipher for decrypted data
const decipher = crypto.createDecipheriv(algorithm, securityKey, initKey);
let decryptedMsg = decipher.update(encryptedMsg, "hex", "utf-8"); // input-encoding: hex, output encoding: utf-8
decryptedMsg += decipher.final("utf-8");
console.log("Decrypted: ", decryptedMsg); // Decrypted: Hello I am Madi
Encrypt & Decrypt with salt
const crypto = require("crypto");
const salt = crypto.randomBytes(3); // Create salt
const message = "Hello, I am Madi";
// USe PBKDF2 function to derive secret
crypto.pbkdf2(message, salt, 10000, 32, "sha256", (err, derivedKey) => {
if (err) throw err;
const algorithm = "aes-256-cbc";
console.log(`DerivedKey: ${derivedKey.toString("hex")}`); // DerivedKey: 2f41bbe3a59652f5c5c8a727366db3cc7162a0846fae720687a9ab880928f70e
console.log(`Salt: ${salt.toString("hex")}`); // Salt: cdfbf3
// Encrypt
const initKey = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, derivedKey, initKey);
let encrypted = cipher.update(message, "utf-8", "hex");
encrypted += cipher.final("hex");
console.log(`Message: ${message}`); // Message: Hello, I am Madi
console.log(`Encrypted: ${encrypted}`); // Encrypted: db846c67a25645b51d7fe6d2b00b61c9e7084d6256a20556cbef60d0590ca6fa
// Decrypt
const decipher = crypto.createDecipheriv(algorithm, derivedKey, initKey);
let decrypted = decipher.update(encrypted, "hex", "utf-8");
decrypted += decipher.final("utf-8");
console.log(`Decrypted: ${decrypted}`); // Decrypted: Hello, I am Madi
});
Hash
Output Encoding 設定成
hex
,所以只會有 16 種符號(a-f, 0-9)
const crypto = require("crypto");
const hash = crypto.createHash("sha256"); // 256 bits
const message = "Hello, I am Madi";
const hashedMsg = hash.update(message).digest("hex");
console.log(hashedMsg); // 689e9ce3a07a10a090b9c387c4b08cfac7b9a5edd55b416c81f44248983172ab
Hash 轉 hex 長度會增為 2 倍,因為每個 byte(8 bits)會被表示成兩個十六進制的字元(4 bits each)
Hash 轉 Base64 長度會增為 4/3 倍,因為每 3 個 byte 會被表示成 4 個 base64 字元,如果未滿 3 的倍數,則會用
=
做 padding,這部分在使用 JWT 時常會看到