[安全程式設計] 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 加密 法規要求可查詢
Email <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 裡。


九、結語:加密是手藝,不是魔法

回到蓋房子的比喻。

保險箱再堅固,鑰匙管理不好也白搭。加密演算法再強大,金鑰硬寫在程式碼裡也沒用。密碼雜湊再安全,不加鹽就形同虛設。

密碼與機敏資料處理的核心不是「用了什麼演算法」,而是「有沒有在每一個環節都做對」。從資料的產生、傳輸、存儲、到顯示,每一步都需要正確的保護措施。就像蓋房子的保險箱,不只是買一個好保險箱就夠了——你還要把它固定在牆裡、保管好鑰匙、定期更換密碼、只讓該看的人看。

安全不是一次性的動作,而是持續的紀律。

飛飛觀點:
記住,完美的加密機制不存在,但「夠好的加密」加上「正確的管理」,可以讓攻擊者的成本高到不值得攻擊你。安全不是恐懼,而是創造的基礎——當你知道使用者的資料被好好保護,你才能安心地創造更好的產品。


十、延伸閱讀

官方資源

工具推薦

工具 用途 說明
argon2 Node.js Argon2 雜湊 OWASP 推薦首選
bcrypt Node.js bcrypt 雜湊 老牌穩定
git-secrets 防止金鑰提交到 Git AWS 開發的工具
HashiCorp Vault 金鑰管理 開源 Secrets Manager
AWS Secrets Manager 雲端金鑰管理 AWS 原生服務