[安全程式設計] 003 密碼雜湊與機敏資料處理實戰指南:bcrypt vs Argon2、AES-256 加密與金鑰管理
「密碼存明碼,就像把金庫密碼寫在便利貼上貼在螢幕旁邊——
你不是沒鎖門,你是把鑰匙插在門上。」
— SSDLC by 飛飛
一、加密與雜湊是什麼?為什麼搞混了會出大事?
在 SSDLC 的「蓋房子」旅程中,我們已經走過了安全需求定義(確認房子要防什麼)、安全設計(畫好建築藍圖)、安全編碼基礎(學會用防火建材和安全電線)、OWASP Top 10 防禦(認識十大常見工安事故)。
現在,我們要處理蓋房子時最珍貴的東西——保險箱裡的財物。
在軟體系統中,「保險箱裡的財物」就是密碼、信用卡號、個人身分證字號、API 金鑰這些機敏資料(Sensitive Data)。這些資料如果被偷走或外洩,後果不堪設想:使用者帳號被盜、信用卡被盜刷、公司面臨個資法罰款(最高 NT$1,500 萬)。
而保護這些財物的兩個核心工具,就是加密(Encryption)和雜湊(Hashing)。
很多開發者會搞混這兩個概念,甚至互相混用——這就像把保險箱當碎紙機用,或拿碎紙機當保險箱。先用一個生活比喻釐清:
| 概念 | 英文 | 生活比喻 | 核心特性 | 適用場景 |
|---|---|---|---|---|
| 加密 | Encryption | 把信件放進上鎖的保險箱,有鑰匙的人可以打開 | 可逆——加密後可以解密還原 | 傳輸中的資料、儲存需要讀取的機敏資料 |
| 雜湊 | Hashing | 把文件丟進碎紙機,變成固定大小的碎片 | 不可逆——無法從碎片還原文件 | 密碼儲存、資料完整性驗證 |
一句話記住:需要還原的用加密,不需要還原的用雜湊。
密碼為什麼要用雜湊而不是加密?因為你永遠不需要知道使用者的原始密碼。登入時,你只需要把使用者輸入的密碼做雜湊,跟資料庫裡存的雜湊值比對就好。如果你用加密存密碼,代表你的系統「有能力解密看到所有人的密碼」——萬一這把鑰匙被偷了,全部密碼一次外洩。
飛飛觀點:
我見過太多台灣新創把密碼用 AES 加密存在資料庫裡,覺得「有加密就安全了」。問題是:加密的金鑰放哪裡?如果金鑰跟密碼存在同一台伺服器上,那攻擊者只要入侵一次就全拿走了。密碼用雜湊,就是讓攻擊者就算拿到資料庫,也拿不到原始密碼。
二、密碼雜湊:bcrypt vs Argon2——別再用 MD5 了
2.1 為什麼 MD5 和 SHA-256 不能用來雜湊密碼?
很多教科書的範例還在用 <code>md5(password)</code> 或 <code>sha256(password)</code>,但這些通用雜湊函數根本不是設計來保護密碼的。原因有三:
| 問題 | 說明 | 後果 |
|---|---|---|
| 太快了 | MD5 每秒可以計算數十億次,SHA-256 也差不多 | 攻擊者用 GPU 暴力破解,8 碼密碼幾小時就破完 |
| 沒有鹽(Salt) | 同樣的密碼永遠產生同樣的雜湊 | 攻擊者用彩虹表(Rainbow Table)秒查 |
| 不能調整難度 | 運算量固定,無法隨硬體進步而增加 | 十年前安全的設定,現在可能已經不夠 |
什麼是彩虹表?簡單說,就是有人事先把常見密碼(如 <code>123456</code>、<code>password</code>、<code>qwerty123</code>)的 MD5/SHA-256 雜湊值全部算好,建成一個超大的對照表。攻擊者拿到你資料庫裡的雜湊值,直接查表就知道原始密碼。中國甚至有商業網站(如 CMD5)專門提供這種反查服務。
密碼專用雜湊函數(如 bcrypt、Argon2)專門解決這三個問題:加鹽、故意設計得很慢、可以調整難度。
2.2 bcrypt:老牌可靠的密碼守衛
bcrypt 誕生於 1999 年,是目前使用最廣泛的密碼雜湊演算法。它有三個關鍵特性:
- 自動加鹽:每次雜湊都會產生隨機鹽值,同樣的密碼會得到不同的雜湊值
- 可調整工作因子(Cost Factor):數字每加 1,計算時間翻倍
- 經過 25 年以上的實戰考驗
// Node.js — 使用 bcrypt 雜湊密碼
const bcrypt = require('bcrypt');
// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
const saltRounds = 12; // 工作因子:2^12 = 4096 次迭代
// saltRounds 每加 1,計算時間約翻倍
// 10 ≈ 100ms, 12 ≈ 300ms, 14 ≈ 1s(在現代硬體上)
return await bcrypt.hash(plainPassword, saltRounds);
}
// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
// bcrypt.compare 會自動從雜湊值中提取鹽值來比對
return await bcrypt.compare(plainPassword, hashedPassword);
}
// 使用範例
const hashed = await hashPassword('MyP@ssw0rd!2024');
// 結果類似:$2b$12$LJ3m5x2Y8v4Z... (每次都不同,因為鹽值不同)
const isValid = await verifyPassword('MyP@ssw0rd!2024', hashed);
// true
2.3 Argon2:新一代密碼雜湊冠軍
Argon2 是 2015 年密碼雜湊競賽(Password Hashing Competition, PHC)的冠軍,被認為是目前最安全的密碼雜湊演算法。它比 bcrypt 多了一個超能力:抵抗 GPU 暴力破解。
bcrypt 主要消耗 CPU 運算時間,但現代 GPU(如 NVIDIA RTX 4090)有數千個核心可以平行計算,能大幅加速暴力破解。Argon2 不只消耗 CPU,還會消耗大量記憶體,而 GPU 的記憶體相對有限且昂貴,這讓 GPU 暴力破解變得極不划算。
Argon2 有三種變體:
| 變體 | 特性 | 建議場景 |
|---|---|---|
| Argon2d | 抗 GPU 攻擊,但可能受 side-channel 攻擊影響 | 加密貨幣挖礦 |
| Argon2i | 抗 side-channel 攻擊 | 密碼雜湊(較保守) |
| Argon2id | 結合 d 和 i 的優點 | 密碼雜湊的首選(OWASP 推薦) |
// Node.js — 使用 Argon2id 雜湊密碼(推薦)
const argon2 = require('argon2');
// ✅ 註冊時:雜湊密碼
async function hashPassword(plainPassword) {
return await argon2.hash(plainPassword, {
type: argon2.argon2id, // 使用 Argon2id 變體
memoryCost: 65536, // 64 MB 記憶體(讓 GPU 暴力破解變得昂貴)
timeCost: 3, // 迭代次數
parallelism: 4 // 平行度(使用 4 個執行緒)
});
}
// ✅ 登入時:驗證密碼
async function verifyPassword(plainPassword, hashedPassword) {
return await argon2.verify(hashedPassword, plainPassword);
}
// ✅ 進階:根據伺服器效能動態調整參數
async function hashPasswordWithTuning(plainPassword) {
const startTime = Date.now();
const hashed = await argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
const elapsed = Date.now() - startTime;
// 目標:雜湊一次花費 200ms ~ 500ms
// 太快 = 攻擊者也能快速暴力破解
// 太慢 = 使用者登入體驗差
console.log(<code>雜湊耗時:${elapsed}ms</code>);
return hashed;
}
2.4 bcrypt vs Argon2:到底該選哪個?
| 比較項目 | bcrypt | Argon2id |
|---|---|---|
| 問世年份 | 1999 年 | 2015 年 |
| 抗 GPU 攻擊 | 中等(主要消耗 CPU) | 強(消耗 CPU + 大量記憶體) |
| 成熟度 | 25 年以上實戰,非常成熟 | 較新,但已獲廣泛認可 |
| OWASP 推薦 | ✅ 推薦(備選) | ✅ 首選推薦 |
| 生態系支援 | 幾乎所有語言都有穩定套件 | 主流語言都有,但部分套件需要原生編譯 |
| 密碼長度限制 | 最多 72 bytes(超過會被截斷) | 無實質限制 |
| 參數調整彈性 | 只能調工作因子 | 可調記憶體、時間、平行度 |
| 建議使用場景 | 既有系統、部署環境受限 | 新專案首選 |
飛飛觀點:
如果你在做新專案,選 Argon2id。如果你的系統已經在用 bcrypt 而且 cost factor 夠高(至少 12),不用急著遷移——bcrypt 依然是安全的。最重要的是:不管你選哪個,都比 MD5、SHA-256、甚至 SHA-512 好一萬倍。
2.5 密碼雜湊的 OWASP 建議參數
// OWASP 2024 推薦的最低參數設定
// Argon2id(首選)
const argon2Config = {
type: argon2.argon2id,
memoryCost: 19456, // OWASP 最低建議:19 MB
timeCost: 2, // OWASP 最低建議:2 次迭代
parallelism: 1 // OWASP 最低建議:1 執行緒
};
// 如果伺服器資源允許,建議提高到:memoryCost: 65536, timeCost: 3, parallelism: 4
// bcrypt(備選)
const bcryptConfig = {
saltRounds: 12 // OWASP 最低建議:10,建議用 12 以上
};
三、對稱加密 vs 非對稱加密:什麼時候該用哪一把鎖?
密碼用雜湊解決了,但有些資料你需要加密後還能解密還原——比如使用者的信用卡號、身分證字號、醫療記錄。這時候就要用「加密」了。
加密分成兩大類:對稱加密和非對稱加密。用一個生活比喻來區分:
3.1 對稱加密:同一把鑰匙開同一把鎖
比喻:你跟室友共用一把門鑰匙。你鎖門,他也能開門,因為你們用的是同一把鑰匙。
對稱加密的「加密」和「解密」用的是同一把金鑰。速度快、效率高,適合加密大量資料。
| 演算法 | 安全等級 | 金鑰長度 | 狀態 |
|---|---|---|---|
| DES | ❌ 已不安全 | 56 bit | 已淘汰,別用 |
| 3DES | ⚠️ 逐步淘汰 | 168 bit | NIST 2023 年後禁用 |
| AES-128 | ✅ 安全 | 128 bit | 目前標準 |
| AES-256 | ✅ 非常安全 | 256 bit | 推薦使用 |
// Node.js — 使用 AES-256-GCM 加密(推薦)
const crypto = require('crypto');
// ✅ 加密函數
function encrypt(plainText, secretKey) {
// GCM 模式提供加密 + 完整性驗證(比 CBC 更安全)
const iv = crypto.randomBytes(12); // GCM 建議 12 bytes IV
const cipher = crypto.createCipheriv('aes-256-gcm', secretKey, iv);
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag(); // 認證標籤,防止密文被竄改
// 將 IV + 認證標籤 + 密文一起存儲
return {
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
encryptedData: encrypted
};
}
// ✅ 解密函數
function decrypt(encryptedObj, secretKey) {
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
secretKey,
Buffer.from(encryptedObj.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));
let decrypted = decipher.update(encryptedObj.encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// 使用範例
const key = crypto.randomBytes(32); // 256 bit 金鑰
const encrypted = encrypt('4311-9522-0000-1234', key);
const decrypted = decrypt(encrypted, key);
// decrypted === '4311-9522-0000-1234'
為什麼用 GCM 而不是 CBC?
| 模式 | 加密 | 完整性驗證 | 說明 |
|---|---|---|---|
| AES-CBC | ✅ | ❌ | 只加密,不驗證。攻擊者可以偷偷修改密文(Padding Oracle Attack) |
| AES-GCM | ✅ | ✅ | 加密 + 驗證。密文被竄改會直接解密失敗 |
3.2 非對稱加密:一把鑰匙鎖門,另一把鑰匙開門
比喻:你家門口有一個投信口(公鑰),任何人都可以把信塞進去。但只有你拿著門鑰匙(私鑰)才能打開信箱拿信。
非對稱加密使用一對金鑰:公鑰(Public Key)和私鑰(Private Key)。公鑰加密的東西只有私鑰能解密,反之亦然。
| 演算法 | 金鑰長度 | 速度 | 適用場景 |
|---|---|---|---|
| RSA | 2048+ bit | 較慢 | 數位簽章、金鑰交換(傳統方案) |
| ECDSA | 256 bit | 快 | 數位簽章(現代首選) |
| ECDH | 256 bit | 快 | 金鑰交換 |
| Ed25519 | 256 bit | 很快 | 數位簽章(最新推薦) |
// Node.js — 使用 RSA 非對稱加密
const crypto = require('crypto');
// ✅ Step 1:產生金鑰對
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', {
modulusLength: 2048,
publicKeyEncoding: { type: 'spki', format: 'pem' },
privateKeyEncoding: { type: 'pkcs8', format: 'pem' }
});
// ✅ Step 2:用公鑰加密(任何人都可以加密)
function encryptWithPublicKey(plainText, pubKey) {
const encrypted = crypto.publicEncrypt(
{
key: pubKey,
padding: crypto.constants.RSA_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(plainText)
);
return encrypted.toString('base64');
}
// ✅ Step 3:用私鑰解密(只有持有私鑰的人能解密)
function decryptWithPrivateKey(encryptedBase64, privKey) {
const decrypted = crypto.privateDecrypt(
{
key: privKey,
padding: crypto.constants.RSA_OAEP_PADDING,
oaepHash: 'sha256'
},
Buffer.from(encryptedBase64, 'base64')
);
return decrypted.toString('utf8');
}
3.3 什麼時候用對稱、什麼時候用非對稱?
| 場景 | 用什麼 | 原因 |
|---|---|---|
| 加密資料庫中的信用卡號 | 對稱加密(AES-256-GCM) | 加解密都在自己的伺服器,速度快 |
| 加密 API 傳輸中的資料 | 非對稱 + 對稱(HTTPS/TLS) | 先用非對稱交換對稱金鑰,再用對稱加密資料 |
| JWT Token 簽章 | 非對稱(RS256 / ES256) | 多個服務需要驗證 Token,但只有一個服務能簽發 |
| 加密設定檔中的密碼 | 對稱加密(AES-256) | 單機使用,速度快 |
| 驗證軟體更新的真實性 | 非對稱(數位簽章) | 發布者用私鑰簽章,使用者用公鑰驗證 |
飛飛觀點:
在實務上,很少「只用對稱」或「只用非對稱」。HTTPS(TLS)就是一個完美的混合案例:先用非對稱加密安全地交換一組「對話金鑰」(Session Key),之後的通訊就用這把對稱金鑰來加密。這叫做混合加密(Hybrid Encryption)——用非對稱解決「怎麼安全傳遞金鑰」的問題,用對稱解決「怎麼高效加密大量資料」的問題。
四、金鑰管理:最容易被忽略的致命問題
你可以用最強的 AES-256 加密、最安全的 Argon2id 雜湊,但如果金鑰管理沒做好,一切都是白搭。
這就像你買了一把世界上最堅固的鎖,但把鑰匙藏在門口的花盆底下——小偷第一個就翻那裡。
4.1 金鑰管理最常犯的五大錯誤
| 錯誤 | 有多常見 | 後果 |
|---|---|---|
| 金鑰硬寫在程式碼裡 | 非常常見 | 推上 GitHub = 全世界都看得到 |
| 金鑰跟資料存在同一台伺服器 | 常見 | 伺服器被入侵 = 金鑰 + 資料一起外洩 |
| 所有環境用同一組金鑰 | 常見 | 開發環境洩漏 = 正式環境也完蛋 |
| 金鑰永遠不換(No Rotation) | 非常常見 | 金鑰洩漏卻不知道,損害持續擴大 |
| 沒有金鑰備份與恢復計畫 | 常見 | 金鑰遺失 = 資料永遠無法解密 |
// ❌ 最常見的致命錯誤:金鑰硬寫在程式碼裡
const SECRET_KEY = 'my-super-secret-key-12345'; // 推上 GitHub 就完蛋
const DB_PASSWORD = 'admin123'; // 更不用說這種
const API_KEY = 'sk-abc123def456'; // 第三方 API 金鑰也是
// ❌ 稍微好一點但還是不夠:寫在設定檔裡
// config.json(也被推上了 GitHub)
// { "secretKey": "my-super-secret-key" }
// ✅ 正確做法:從環境變數讀取
const SECRET_KEY = process.env.JWT_SECRET;
const DB_PASSWORD = process.env.DB_PASSWORD;
const API_KEY = process.env.STRIPE_API_KEY;
// ✅ 加上驗證:確保金鑰存在且符合最低要求
function validateEnvSecrets() {
const required = ['JWT_SECRET', 'DB_PASSWORD', 'ENCRYPTION_KEY'];
for (const key of required) {
if (!process.env[key]) {
throw new Error(<code>環境變數 ${key} 未設定,拒絕啟動</code>);
}
if (process.env[key].length < 32) {
throw new Error(<code>環境變數 ${key} 長度不足 32 字元</code>);
}
}
}
// 在應用程式啟動時立即檢查
validateEnvSecrets();
4.2 金鑰管理的四個層級
從「堪用」到「企業級」,金鑰管理可以分成四個層級。你可以根據專案的規模和敏感程度,選擇適合的層級:
| 層級 | 做法 | 適合場景 | 安全程度 |
|---|---|---|---|
| Level 1 | 環境變數 + <code>.env</code> 檔(不進版控) | Side project、個人專案 | ⭐⭐ |
| Level 2 | CI/CD 的 Secrets 管理(GitHub Secrets、GitLab CI Variables) | 小型團隊、新創 | ⭐⭐⭐ |
| Level 3 | 專用的 Secrets Manager(AWS Secrets Manager、HashiCorp Vault) | 中型以上企業 | ⭐⭐⭐⭐ |
| Level 4 | HSM 硬體安全模組(Hardware Security Module) | 金融業、政府機關、醫療體系 | ⭐⭐⭐⭐⭐ |
Level 1:環境變數(最低要求)
# .env 檔案(加入 .gitignore,絕對不能進版控)
JWT_SECRET=a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6
ENCRYPTION_KEY=q7r8s9t0u1v2w3x4y5z6a7b8c9d0e1f2
DB_PASSWORD=your-strong-database-password-here
# .gitignore — 這幾行是必須的
.env
.env.local
.env.production
*.key
*.pem
Level 3:AWS Secrets Manager 範例
// Node.js — 使用 AWS Secrets Manager
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const client = new SecretsManagerClient({ region: 'ap-northeast-1' }); // 東京區域
async function getSecret(secretName) {
const command = new GetSecretValueCommand({ SecretId: secretName });
const response = await client.send(command);
return JSON.parse(response.SecretString);
}
// 應用程式啟動時載入所有 Secrets
async function loadSecrets() {
const secrets = await getSecret('my-app/production');
// secrets = { JWT_SECRET: '...', DB_PASSWORD: '...', ENCRYPTION_KEY: '...' }
return secrets;
}
4.3 金鑰輪換(Key Rotation):定期換鎖
金鑰跟密碼一樣,不是設了就永遠不動。定期更換金鑰(Key Rotation)是金鑰管理的核心實踐。
為什麼要輪換?因為你不一定知道金鑰什麼時候已經洩漏了。定期更換,可以限制洩漏的影響範圍。
// Node.js — 金鑰輪換策略:同時支援新舊金鑰
const ENCRYPTION_KEYS = {
// 金鑰版本管理:新資料用最新版加密,解密時根據版本號選擇對應金鑰
'v3': process.env.ENCRYPTION_KEY_V3, // 目前使用(加密用)
'v2': process.env.ENCRYPTION_KEY_V2, // 前一版(解密用,過渡期保留)
// 'v1' 已廢棄,不再保留
};
const CURRENT_KEY_VERSION = 'v3';
// ✅ 加密時:永遠使用最新版金鑰,並記錄版本號
function encryptWithVersion(plainText) {
const key = Buffer.from(ENCRYPTION_KEYS[CURRENT_KEY_VERSION], 'hex');
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
return {
version: CURRENT_KEY_VERSION, // 記錄用了哪個版本的金鑰
iv: iv.toString('hex'),
authTag: cipher.getAuthTag().toString('hex'),
data: encrypted
};
}
// ✅ 解密時:根據版本號找到對應的金鑰
function decryptWithVersion(encryptedObj) {
const key = Buffer.from(ENCRYPTION_KEYS[encryptedObj.version], 'hex');
if (!key) {
throw new Error(<code>金鑰版本 ${encryptedObj.version} 已廢棄,無法解密</code>);
}
const decipher = crypto.createDecipheriv(
'aes-256-gcm', key, Buffer.from(encryptedObj.iv, 'hex')
);
decipher.setAuthTag(Buffer.from(encryptedObj.authTag, 'hex'));
let decrypted = decipher.update(encryptedObj.data, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// ✅ 背景任務:逐步將舊版金鑰加密的資料重新加密
async function reEncryptOldRecords() {
const oldRecords = await db.query(
"SELECT id, encrypted_data FROM sensitive_data WHERE encrypted_data->>'version' != $1",
[CURRENT_KEY_VERSION]
);
for (const record of oldRecords.rows) {
const decrypted = decryptWithVersion(record.encrypted_data);
const reEncrypted = encryptWithVersion(decrypted);
await db.query(
'UPDATE sensitive_data SET encrypted_data = $1 WHERE id = $2',
[JSON.stringify(reEncrypted), record.id]
);
}
}
飛飛觀點:
金鑰輪換最怕的就是「換了新鑰匙,舊鎖打不開了」。所以一定要做版本管理——加密時記錄用了哪個版本的金鑰,解密時根據版本號去找對應的金鑰。過渡期內新舊金鑰並存,等所有舊資料都重新加密完了,再廢棄舊金鑰。
五、實戰案例:台灣電商會員系統的機敏資料處理
讓我們用一個完整的案例,走過從頭到尾的機敏資料處理流程。
場景描述
你的團隊要為一間台灣電商平台處理以下機敏資料:
| 資料類型 | 範例 | 處理方式 | 原因 |
|---|---|---|---|
| 密碼 | <code>MyP@ss123</code> | Argon2id 雜湊 | 不需要還原,只要比對 |
| 信用卡號 | <code>4311-9522-0000-1234</code> | AES-256-GCM 加密 | 需要解密用於扣款 |
| 身分證字號 | <code>A123456789</code> | AES-256-GCM 加密 | 法規要求可查詢 |
| <code>user@example.com</code> | 明碼存儲 + 存取控制 | 需要經常讀取,用權限控制保護 | |
| 手機號碼 | <code>0912-345-678</code> | AES-256-GCM 加密 或遮蔽 | 依業務需求決定 |
| JWT Secret | <code>a1b2c3…</code> | 環境變數 / Secrets Manager | 金鑰,不進資料庫 |
完整程式碼範例
// /src/services/crypto.service.js
// 台灣電商平台:機敏資料處理服務
const argon2 = require('argon2');
const crypto = require('crypto');
class CryptoService {
constructor() {
// 從環境變數載入金鑰(啟動時驗證)
this.encryptionKey = Buffer.from(process.env.ENCRYPTION_KEY, 'hex');
if (this.encryptionKey.length !== 32) {
throw new Error('ENCRYPTION_KEY 必須是 64 個 hex 字元(32 bytes)');
}
}
// ===== 密碼處理(雜湊,不可逆)=====
async hashPassword(plainPassword) {
return await argon2.hash(plainPassword, {
type: argon2.argon2id,
memoryCost: 65536,
timeCost: 3,
parallelism: 4
});
}
async verifyPassword(plainPassword, hashedPassword) {
try {
return await argon2.verify(hashedPassword, plainPassword);
} catch {
return false; // 格式錯誤也回傳 false,不洩漏錯誤資訊
}
}
// ===== 機敏資料加密(可逆)=====
encrypt(plainText) {
const iv = crypto.randomBytes(12);
const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
let encrypted = cipher.update(plainText, 'utf8', 'hex');
encrypted += cipher.final('hex');
// 將 IV + AuthTag + 密文合併為一個字串,方便存儲
return [
iv.toString('hex'),
cipher.getAuthTag().toString('hex'),
encrypted
].join(':');
}
decrypt(encryptedString) {
const [ivHex, authTagHex, encryptedData] = encryptedString.split(':');
const decipher = crypto.createDecipheriv(
'aes-256-gcm',
this.encryptionKey,
Buffer.from(ivHex, 'hex')
);
decipher.setAuthTag(Buffer.from(authTagHex, 'hex'));
let decrypted = decipher.update(encryptedData, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
}
// ===== 資料遮蔽(顯示用)=====
maskCreditCard(cardNumber) {
// 4311-9522-0000-1234 → **** **** **** 1234
const clean = cardNumber.replace(/[-\s]/g, '');
return <code class="kb-btn">**** **** **** ${clean.slice(-4)}</code>;
}
maskPhone(phone) {
// 0912-345-678 → 09XX-XXX-678
return phone.replace(/(\d{2})\d{2}-\d{3}-(\d{3})/, '$1XX-XXX-$2');
}
maskIdNumber(idNumber) {
// A123456789 → A12*****89
return idNumber.slice(0, 3) + '*****' + idNumber.slice(-2);
}
}
module.exports = new CryptoService();
// /src/routes/user.routes.js
// 註冊與登入流程
const cryptoService = require('../services/crypto.service');
// ✅ 註冊:密碼雜湊 + 機敏資料加密
app.post('/api/register', async (req, res) => {
const { email, password, phone, idNumber } = req.body;
// 密碼 → Argon2id 雜湊(不可逆)
const passwordHash = await cryptoService.hashPassword(password);
// 手機 → AES-256-GCM 加密(可逆,需要發簡訊驗證)
const encryptedPhone = cryptoService.encrypt(phone);
// 身分證 → AES-256-GCM 加密(可逆,法規要求可查詢)
const encryptedId = cryptoService.encrypt(idNumber);
await db.query(
`INSERT INTO users (email, password_hash, phone_encrypted, id_number_encrypted)
VALUES ($1, $2, $3, $4)`,
[email, passwordHash, encryptedPhone, encryptedId]
);
res.status(201).json({ message: '註冊成功' });
});
// ✅ 登入:密碼驗證 + 防止 Timing Attack
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.query(
'SELECT id, password_hash, login_attempts, locked_until FROM users WHERE email = $1',
[email]
);
// 不管帳號存不存在,都花相同時間做雜湊比對(防 Timing Attack)
if (!user.rows[0]) {
await cryptoService.hashPassword('dummy-password');
return res.status(401).json({ error: '帳號或密碼錯誤' });
}
const userData = user.rows[0];
// 檢查是否被鎖定
if (userData.locked_until && new Date(userData.locked_until) > new Date()) {
return res.status(423).json({ error: '帳號暫時鎖定,請稍後再試' });
}
const isValid = await cryptoService.verifyPassword(password, userData.password_hash);
if (!isValid) {
// 登入失敗:增加嘗試次數
const newAttempts = (userData.login_attempts || 0) + 1;
const lockUntil = newAttempts >= 5
? new Date(Date.now() + 15 * 60 * 1000) // 5 次失敗鎖 15 分鐘
: null;
await db.query(
'UPDATE users SET login_attempts = $1, locked_until = $2 WHERE id = $3',
[newAttempts, lockUntil, userData.id]
);
return res.status(401).json({ error: '帳號或密碼錯誤' });
}
// 登入成功:重設嘗試次數
await db.query(
'UPDATE users SET login_attempts = 0, locked_until = NULL WHERE id = $1',
[userData.id]
);
// ... 簽發 JWT Token
});
// ✅ 查看個人資料:解密 + 遮蔽
app.get('/api/profile', authenticate, async (req, res) => {
const user = await db.query(
'SELECT email, phone_encrypted, id_number_encrypted FROM users WHERE id = $1',
[req.user.id]
);
const userData = user.rows[0];
// 解密後遮蔽顯示(前端只看到遮蔽版本)
const phone = cryptoService.decrypt(userData.phone_encrypted);
const idNumber = cryptoService.decrypt(userData.id_number_encrypted);
res.json({
email: userData.email,
phone: cryptoService.maskPhone(phone), // 09XX-XXX-678
idNumber: cryptoService.maskIdNumber(idNumber) // A12*****89
});
});
六、密碼與機敏資料處理 Checklist
以下是一份可以直接用於專案的檢核清單:
## 密碼安全
- [ ] 密碼使用 Argon2id(首選)或 bcrypt(備選)雜湊
- [ ] 絕對沒有使用 MD5、SHA-1、SHA-256 雜湊密碼
- [ ] bcrypt 的 cost factor ≥ 12
- [ ] Argon2id 的 memoryCost ≥ 19456(OWASP 最低要求)
- [ ] 雜湊一次的耗時在 200ms ~ 500ms 之間
- [ ] 登入錯誤訊息統一為「帳號或密碼錯誤」
- [ ] 有防止 Timing Attack 的措施
- [ ] 登入失敗 5 次鎖定帳號 15 分鐘
## 加密機制
- [ ] 對稱加密使用 AES-256-GCM(不是 CBC)
- [ ] 每次加密使用隨機的 IV(不可重複使用)
- [ ] 加密演算法沒有使用 DES、3DES、RC4
- [ ] 傳輸中的資料全程使用 TLS 1.2 以上
- [ ] 信用卡號、身分證等機敏資料已加密存儲
## 金鑰管理
- [ ] 金鑰沒有硬寫在程式碼裡
- [ ] 金鑰沒有存在版本控制(Git)中
- [ ] .env 檔案已加入 .gitignore
- [ ] 開發 / 測試 / 正式環境使用不同的金鑰
- [ ] 有金鑰輪換計畫(至少每年更換一次)
- [ ] 金鑰輪換時有版本管理(新舊金鑰並存)
- [ ] 有金鑰備份與恢復計畫
## 資料遮蔽
- [ ] 信用卡號顯示為 **** **** **** 末四碼
- [ ] 手機號碼部分遮蔽
- [ ] 身分證字號部分遮蔽
- [ ] 日誌中不記錄任何機敏資料的明碼
- [ ] API Response 中不回傳不必要的機敏欄位
七、團隊落地建議:讓加密不再是一個人的事
建議一:建立團隊的機敏資料分類表
在專案一開始,就列出所有會經手的機敏資料,並決定每種資料的處理方式。這份表格應該放在專案文件中,所有開發者都能看到。
| 資料類型 | 分類等級 | 存儲方式 | 傳輸方式 | 顯示方式 | 負責人 |
|---------|---------|---------|---------|---------|-------|
| 密碼 | 極高 | Argon2id 雜湊 | TLS | 不顯示 | 後端 |
| 信用卡號 | 極高 | AES-256 加密 | TLS | 遮蔽末四碼 | 後端 |
| 身分證 | 高 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| 手機號碼 | 中 | AES-256 加密 | TLS | 部分遮蔽 | 後端 |
| Email | 中 | 明碼 + 存取控制 | TLS | 完整顯示 | 後端 |
| 姓名 | 低 | 明碼 + 存取控制 | TLS | 完整顯示 | 前端 |
建議二:封裝共用的加密服務
不要讓每個開發者自己寫加密邏輯。把加密、解密、雜湊都封裝成共用的服務(就像本文的 <code>CryptoService</code>),確保全團隊用同一套標準。
建議三:在 Code Review 加入加密檢查
在 Code Review 時,加入以下安全問題:
- 這段程式碼有處理機敏資料嗎?用了什麼加密方式?
- 金鑰從哪裡來?有沒有硬寫在程式碼裡?
- 日誌中有沒有不小心記錄了機敏資料?
- API Response 有沒有回傳不必要的機敏欄位?
建議四:用 Git Hook 防止金鑰外洩
# 安裝 git-secrets(由 AWS 開發的工具)
# brew install git-secrets (macOS)
# 或手動安裝:https://github.com/awslabs/git-secrets
# 初始化
git secrets --install
git secrets --register-aws # 偵測 AWS 金鑰
# 自訂規則:偵測常見的金鑰格式
git secrets --add 'password\s*=\s*.+'
git secrets --add 'secret\s*=\s*.+'
git secrets --add 'PRIVATE.KEY'
# 之後每次 git commit 都會自動檢查,發現金鑰就阻止提交
八、常見問題 FAQ
Q1:我的專案很小,真的需要這麼複雜的加密機制嗎?
規模小不代表可以偷懶。 只要你的系統有處理密碼和個人資料,就需要做好基本的保護。好消息是,基礎的保護並不複雜:密碼用 bcrypt/Argon2 雜湊(一個函式呼叫的事)、金鑰不要硬寫在程式碼裡(用 <code>.env</code> 就好)、傳輸用 HTTPS。這三件事花不了你一天的時間,但能幫你省下未來幾個月的災難處理。
Q2:我的系統已經用 MD5 雜湊密碼了,怎麼遷移到 Argon2?
這是很多既有系統會遇到的問題。你不需要強制所有使用者重設密碼。一個常見的漸進式遷移策略:在使用者下次登入成功時(表示他輸入了正確的密碼),趁這個機會用 Argon2 重新雜湊並更新資料庫。同時在資料庫加一個欄位記錄雜湊演算法版本,登入驗證時根據版本選擇不同的比對方式。
// 漸進式遷移範例
async function loginWithMigration(email, password) {
const user = await db.query('SELECT * FROM users WHERE email = $1', [email]);
if (user.hash_version === 'md5') {
// 舊格式:用 MD5 驗證
const md5Hash = crypto.createHash('md5').update(password).digest('hex');
if (md5Hash !== user.password_hash) return false;
// 驗證成功!趁機升級到 Argon2
const newHash = await argon2.hash(password, { type: argon2.argon2id });
await db.query(
'UPDATE users SET password_hash = $1, hash_version = $2 WHERE id = $3',
[newHash, 'argon2id', user.id]
);
} else {
// 新格式:用 Argon2 驗證
return await argon2.verify(user.password_hash, password);
}
return true;
}
Q3:AES 加密的金鑰應該是多少 bytes?怎麼產生?
AES-256 需要 32 bytes(256 bits)的金鑰。用密碼學安全的隨機數產生器來產生,絕對不要自己亂打一串字。
// ✅ 正確:用 crypto 產生隨機金鑰
const key = crypto.randomBytes(32).toString('hex');
console.log(key); // 64 個 hex 字元,例如:a1b2c3d4...
// ❌ 錯誤:用自己想的字串
const key = 'my-secret-key'; // 太短、可預測、不夠隨機
Q4:JWT Token 裡可以放密碼或信用卡號嗎?
絕對不行。 JWT 預設只做簽章(signature),不做加密。也就是說,任何人拿到你的 JWT,都可以用 Base64 解碼看到裡面的內容。JWT 裡應該只放使用者 ID 和角色等「就算被看到也不會造成直接危害」的資訊。密碼、信用卡號、身分證等機敏資料,永遠不應該出現在 JWT 裡。
九、結語:加密是手藝,不是魔法
回到蓋房子的比喻。
保險箱再堅固,鑰匙管理不好也白搭。加密演算法再強大,金鑰硬寫在程式碼裡也沒用。密碼雜湊再安全,不加鹽就形同虛設。
密碼與機敏資料處理的核心不是「用了什麼演算法」,而是「有沒有在每一個環節都做對」。從資料的產生、傳輸、存儲、到顯示,每一步都需要正確的保護措施。就像蓋房子的保險箱,不只是買一個好保險箱就夠了——你還要把它固定在牆裡、保管好鑰匙、定期更換密碼、只讓該看的人看。
安全不是一次性的動作,而是持續的紀律。
飛飛觀點:
記住,完美的加密機制不存在,但「夠好的加密」加上「正確的管理」,可以讓攻擊者的成本高到不值得攻擊你。安全不是恐懼,而是創造的基礎——當你知道使用者的資料被好好保護,你才能安心地創造更好的產品。
十、延伸閱讀
官方資源
- OWASP Password Storage Cheat Sheet:密碼儲存的完整指南
- OWASP Cryptographic Storage Cheat Sheet:加密儲存最佳實踐
- OWASP Key Management Cheat Sheet:金鑰管理指南
- NIST SP 800-63B:數位身分認證指南
- NIST SP 800-132:密碼基礎金鑰衍生建議
工具推薦
| 工具 | 用途 | 說明 |
|---|---|---|
| argon2 | Node.js Argon2 雜湊 | OWASP 推薦首選 |
| bcrypt | Node.js bcrypt 雜湊 | 老牌穩定 |
| git-secrets | 防止金鑰提交到 Git | AWS 開發的工具 |
| HashiCorp Vault | 金鑰管理 | 開源 Secrets Manager |
| AWS Secrets Manager | 雲端金鑰管理 | AWS 原生服務 |