diff --git a/.gitignore b/.gitignore index f84d584..c76a65f 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ npm-debug.log* database_backup_*.json .env backend/.env +frontend/dist/ # Playwright e2e/playwright-report/ diff --git a/CLAUDE.md b/CLAUDE.md index 7fdebc0..edfb954 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,6 +68,7 @@ MongoDB 集合清單(`scheduleApp` 資料庫): - `holidays`:台灣國定假日(`_id` 為日期字串如 `2025-01-01`) - `schoolEvents`:學校行事曆快取(`_id` 為學期代碼如 `1142`,7 天 TTL) - `scheduleShares`:班表分享連結(`_id` 為隨機 token,記錄對應的 profile/scheduleName/personFilter,無過期機制) +- `pdfPayloadKeyState`:PDF 隱藏資料加密目前的金鑰版本(單一文件,`_id` 固定為 `pdf_payload_key_state`),記錄 `currentVersion`、`rotatedAt`,每 30 天自動輪替 --- @@ -92,17 +93,19 @@ backend/ app.js # Express 設定、middleware、路由掛載 config.js # 環境變數 validators.js # 輸入驗證(profile/schedule 名稱、settings) - db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares 四個 collection) + db/connect.js # MongoDB 連線管理(profiles、holidays、schoolEvents、scheduleShares、pdfPayloadKeyState 五個 collection) routes/ # status, holidays, profiles, schedules, generate, schoolCalendar, pdfPayload, shares services/ scheduleAlgorithm.js # 排班核心演算法(純函數) scheduleRenderer.js # HTML 渲染 holidayService.js # 假日快取、CDN 更新 schoolCalendar.js # 學校行事曆(記憶體 6h + MongoDB 7 天持久快取) - pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(金鑰來自 PDF_PAYLOAD_SECRET) + pdfPayloadCrypto.js # PDF 匯入/匯出隱藏資料的 AES-256-GCM 加解密(Root Key + HKDF 版本衍生,見下方環境變數說明) + pdfPayloadKeyRotation.js # PDF 加密金鑰版本的記憶體快取 + 每 30 天自動輪替(DB 為真相來源,跟 holidayService 同一種快取模式) repositories/ profileRepository.js # MongoDB CRUD(profiles、schedules) shareRepository.js # 班表分享連結的 token 產生/查詢(scheduleShares collection) + pdfPayloadKeyStateRepository.js # PDF 加密目前金鑰版本狀態的讀寫(pdfPayloadKeyState collection) tests/ frontend/ @@ -134,7 +137,7 @@ frontend/ scheduleCompare.js # 比較已儲存班表 Modal(人員異動/填補率/勤務設定差異) exportWeekFilter.js # 匯出週次篩選(getExportData,供複製/Excel/PDF/人員Excel 共用) personTaskStats.js # 值勤統計 Modal(人員 × 勤務次數,加總 + 每週明細,Excel/PDF 匯出共用同一份計算) - pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_SECRET 會額外把班表資料加密嵌入 PDF metadata) + pdfExport.js # PDF 匯出(html2canvas + jsPDF,支援每頁 N 週自動分頁,末頁附值勤統計;若後端有設定 PDF_PAYLOAD_ROOT_SECRET 會額外把班表資料加密嵌入 PDF metadata) pdfImport.js # 從匯出的 PDF 檔案讀出隱藏資料還原班表(需搭配 pdf-lib 解析 metadata + 後端解密) scheduleShare.js # 分享班表 Modal:產生免登入連結(可選只分享單一人員、可設有效期限),並列出/撤銷已產生的連結 sharedView.js # 免登入分享連結的頁面(接管整個 document.body,不含後台功能) @@ -198,11 +201,21 @@ MONGODB_URI=mongodb+srv://:@.mongodb.net/ DB_NAME=scheduleApp # 預設值 PORT=3000 # 預設值 CORS_ORIGIN=* # 預設值;未設定時 config.js 也會 fallback 成 *,僅適合開發環境 -PDF_PAYLOAD_SECRET= # 選填,64 字元 hex(32 bytes)。未設定時 PDF 匯入/匯出隱藏資料功能自動停用,其他功能不受影響 +PDF_PAYLOAD_SECRET= # 選填,改版前的舊制單一金鑰,現在只用來解密沒有版號的舊格式 PDF +PDF_PAYLOAD_ROOT_SECRET= # 選填,64 字元 hex(32 bytes),唯一需要手動備份的值,見下方說明 +PDF_PAYLOAD_KEK_CURRENT=v1 # 選填,只在 MongoDB 完全沒有版本狀態時當「初始種子值」用一次,之後由 DB 自動輪替接管 ``` 若未提供 `MONGODB_URI`,伺服器仍會啟動,但所有資料庫功能停用(會顯示警告)。 -若未提供 `PDF_PAYLOAD_SECRET`,`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 會回傳 503,前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過(不影響一般匯出/檢視)。 + +**PDF 隱藏資料加密機制(Root Key + HKDF 版本衍生,含每 30 天自動輪替)**:三個環境變數都選填,完全不設定就整個功能停用(`/api/pdf-payload/encrypt`、`/api/pdf-payload/decrypt` 回 503),前端的 PDF 匯入按鈕與匯出時的隱藏資料嵌入都會靜默跳過,不影響一般匯出/檢視。 + +- 加密**新資料**只用 `PDF_PAYLOAD_ROOT_SECRET` + 「目前版本號」:實際 KEK=`HKDF(Root, info=版本號)` 現場算出來,不另外存成環境變數。 +- **目前版本號存在 MongoDB(`pdfPayloadKeyState` collection),不是環境變數**:`pdfPayloadKeyRotation.js` 在伺服器啟動時初始化記憶體快取(DB 已有狀態就直接載入;DB 是空的才會讀一次 `PDF_PAYLOAD_KEK_CURRENT` 當初始種子值,之後這個環境變數就不會再被讀取),並且每 30 天自動把版本號往上遞增(例如 v1→v2)、寫回 DB——不需要手動改任何環境變數或重啟伺服器。啟動時與每 24 小時都會檢查一次是否已到 30 天(若伺服器曾經關機超過 30 天,啟動時會立刻補做一次輪替)。目前版本、上次輪替時間、下次預計輪替時間可從 `GET /api/status` 的 `pdfPayloadKeyRotation` 欄位查看。只要 `PDF_PAYLOAD_ROOT_SECRET` 沒變,舊版本號加密過的 PDF 換了幾輪版本都一樣算得出對應 KEK、正常解密。 +- MongoDB 未連線時,「目前版本號」無法初始化/輪替,等同 `isConfigured()` 為 false(無法加密新資料),但解密已存在的 PDF 不受影響(解密走的是 payload 自帶的版號 + Root 現算 KEK,不依賴 DB;v0 舊格式更是完全不需要 DB 或 Root,只需要 `PDF_PAYLOAD_SECRET`)。 +- `PDF_PAYLOAD_SECRET`(改版前的舊制單一金鑰)現在**只**用來解密「沒有版號欄位」的舊格式 PDF(視為隱含 v0,金鑰直接當 KEK 用、不經 HKDF),不會再用來加密任何新資料。這是唯一的重點:只要這批舊 PDF 還有人在用,這個環境變數就不能拿掉。 +- **唯一需要手動備份的值是 `PDF_PAYLOAD_ROOT_SECRET`**(存進密碼管理器/雲端筆記,跟主機分開放)。它一旦產生就永遠不能變、不能重新產生——重新產生等於所有版本的 KEK 一起失效,所有 Root 機制下匯出的 PDF 全部解不開。 +- 詳細機制見 `backend/src/services/pdfPayloadCrypto.js`、`backend/src/services/pdfPayloadKeyRotation.js` 開頭的註解與 `backend/.env.example`。 --- diff --git a/backend/.env.example b/backend/.env.example index 6c28123..d0e3efe 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -3,7 +3,23 @@ DB_NAME=scheduleApp PORT=3000 CORS_ORIGIN=* -# 選填:PDF 匯入/匯出的隱藏資料加密金鑰。未設定時該功能停用(其他功能不受影響)。 -# 必須是 64 字元 hex 字串(32 bytes),可用下列指令產生: +# PDF 匯入/匯出的隱藏資料加密——Root Key + HKDF 版本衍生機制。三者都選填, +# 完全不設定的話該功能停用,其他功能不受影響。 + +# 舊制單一金鑰(改版前的機制)。現在只用來解密「改版前就已經匯出、沒有版號欄位」 +# 的舊格式 PDF,不會再用來加密任何新資料。若沒有這種舊 PDF 需要相容,可以不設定。 +# 64 字元 hex 字串(32 bytes): # node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" PDF_PAYLOAD_SECRET= + +# 唯一需要手動備份的值(存進密碼管理器/雲端筆記,跟主機分開放)。產生後永遠不變、 +# 不重新產生——重新產生等於所有版本的金鑰全部跟著變,舊 PDF 全部解不開。 +# 64 字元 hex 字串(32 bytes),產生方式同上。 +PDF_PAYLOAD_ROOT_SECRET= + +# 目前加密要用哪一版的「初始值」,例如 v1、v2……只有資料庫裡完全沒有版本狀態 +# (系統第一次啟用這個機制)時才會讀這個值當起點,之後 DB 就是唯一的真相來源, +# 這個環境變數不會再被讀取——版本會每 30 天自動輪替,不需要手動改這裡或重啟。 +# 實際加解密用的金鑰=HKDF(PDF_PAYLOAD_ROOT_SECRET, info=版本號) 現場算出來, +# 不另外存成環境變數。 +PDF_PAYLOAD_KEK_CURRENT=v1 diff --git a/backend/server.js b/backend/server.js index c3d0611..d3559dc 100644 --- a/backend/server.js +++ b/backend/server.js @@ -1,6 +1,7 @@ const debug = require('debug'); const { connect, disconnect, ensureConfigDocument } = require('./src/db/connect'); const { seedHolidays, refreshHolidaysFromCDN } = require('./src/services/holidayService'); +const { initKeyState, rotateIfDue } = require('./src/services/pdfPayloadKeyRotation'); const app = require('./src/app'); const { PORT, MONGODB_URI } = require('./src/config'); @@ -22,6 +23,13 @@ const startServer = async () => { await refreshHolidaysFromCDN(); setInterval(refreshHolidaysFromCDN, 24 * 60 * 60 * 1000); + // PDF 隱藏資料加密金鑰版本:啟動時載入/初始化狀態,之後每天檢查一次是否 + // 已達 30 天輪替間隔(真正的輪替判斷在 rotateIfDue() 內部,這裡只是固定 + // 頻率去問一次,不代表每天都會真的輪替)。 + await initKeyState(); + await rotateIfDue(); + setInterval(rotateIfDue, 24 * 60 * 60 * 1000); + app.listen(PORT, () => { debugServer(`伺服器正在 http://localhost:${PORT} 上運行`); }); diff --git a/backend/src/config.js b/backend/src/config.js index 366af7d..c207df8 100644 --- a/backend/src/config.js +++ b/backend/src/config.js @@ -10,8 +10,15 @@ const PORT = process.env.PORT || 3000; const MONGODB_URI = process.env.MONGODB_URI; const DB_NAME = process.env.DB_NAME || 'scheduleApp'; const CORS_ORIGIN = process.env.CORS_ORIGIN || '*'; -// PDF 匯入/匯出的隱藏資料加密金鑰:64 字元 hex 字串(32 bytes)。未設定時該功能停用。 +// PDF 匯入/匯出隱藏資料加密——Root Key + HKDF 版本衍生機制: +// - PDF_PAYLOAD_SECRET:改版前的舊制單一金鑰,現在只作為 v0(無版號的舊格式)解密相容用, +// 不再用來加密新資料。 +// - PDF_PAYLOAD_ROOT_SECRET:唯一需要手動備份的值,永遠不變、不重新產生。 +// - PDF_PAYLOAD_KEK_CURRENT:目前加密要用哪一版(例如 "v1"),換版本只改這個值。 +// 實際加解密用的金鑰=HKDF(PDF_PAYLOAD_ROOT_SECRET, info=該版本號) 現場算出來,不另外存。 const PDF_PAYLOAD_SECRET = process.env.PDF_PAYLOAD_SECRET; +const PDF_PAYLOAD_ROOT_SECRET = process.env.PDF_PAYLOAD_ROOT_SECRET; +const PDF_PAYLOAD_KEK_CURRENT = process.env.PDF_PAYLOAD_KEK_CURRENT; module.exports = { SAFE_PROFILE_NAME_REGEX, @@ -22,4 +29,6 @@ module.exports = { DB_NAME, CORS_ORIGIN, PDF_PAYLOAD_SECRET, + PDF_PAYLOAD_ROOT_SECRET, + PDF_PAYLOAD_KEK_CURRENT, }; diff --git a/backend/src/db/connect.js b/backend/src/db/connect.js index e953ffd..5948802 100644 --- a/backend/src/db/connect.js +++ b/backend/src/db/connect.js @@ -11,6 +11,7 @@ let configCollection; let holidaysCollection; let schoolEventsCollection; let scheduleSharesCollection; +let pdfPayloadKeyStateCollection; let isDbConnected = false; if (MONGODB_URI) { @@ -41,6 +42,8 @@ const getSchoolEventsCollection = () => schoolEventsCollection; const getScheduleSharesCollection = () => scheduleSharesCollection; +const getPdfPayloadKeyStateCollection = () => pdfPayloadKeyStateCollection; + const connect = async () => { if (!client) return; debugServer('正在連線至 MongoDB...'); @@ -52,6 +55,7 @@ const connect = async () => { holidaysCollection = db.collection('holidays'); schoolEventsCollection = db.collection('schoolEvents'); scheduleSharesCollection = db.collection('scheduleShares'); + pdfPayloadKeyStateCollection = db.collection('pdfPayloadKeyState'); // TTL index:只會刪除「有 expiresAt 欄位且已過期」的文件,沒有這個欄位的永久 // 分享連結不受影響。MongoDB 背景清除任務約每 60 秒跑一次,不是精準即時刪除, // 所以 GET /api/schedule-shares/:token 仍須自行檢查 expiresAt,不能只靠這個 index。 @@ -96,6 +100,7 @@ module.exports = { getHolidaysCollection, getSchoolEventsCollection, getScheduleSharesCollection, + getPdfPayloadKeyStateCollection, connect, disconnect, ensureConfigDocument, diff --git a/backend/src/repositories/pdfPayloadKeyStateRepository.js b/backend/src/repositories/pdfPayloadKeyStateRepository.js new file mode 100644 index 0000000..f7f0c1b --- /dev/null +++ b/backend/src/repositories/pdfPayloadKeyStateRepository.js @@ -0,0 +1,20 @@ +const { getPdfPayloadKeyStateCollection } = require('../db/connect'); + +const STATE_ID = 'pdf_payload_key_state'; + +const getState = async () => { + const collection = getPdfPayloadKeyStateCollection(); + return collection.findOne({ _id: STATE_ID }); +}; + +// upsert:第一次呼叫(尚無文件)會建立,之後每次輪替都是更新同一筆文件。 +const setState = async (currentVersion, rotatedAt) => { + const collection = getPdfPayloadKeyStateCollection(); + await collection.updateOne( + { _id: STATE_ID }, + { $set: { currentVersion, rotatedAt } }, + { upsert: true } + ); +}; + +module.exports = { getState, setState }; diff --git a/backend/src/routes/pdfPayload.js b/backend/src/routes/pdfPayload.js index d599849..010581f 100644 --- a/backend/src/routes/pdfPayload.js +++ b/backend/src/routes/pdfPayload.js @@ -2,7 +2,7 @@ const express = require('express'); const debug = require('debug')('app:pdfPayload'); -const { encryptPayload, decryptPayload, isConfigured } = require('../services/pdfPayloadCrypto'); +const { encryptPayload, decryptPayload, isConfigured, isDecryptionPossible } = require('../services/pdfPayloadCrypto'); const router = express.Router(); @@ -20,9 +20,11 @@ router.post('/api/pdf-payload/encrypt', (req, res) => { } }); -// 把從 PDF metadata 讀出的 base64 字串解密還原成班表資料。 +// 把從 PDF metadata 讀出的字串解密還原成班表資料。 +// 這裡刻意用 isDecryptionPossible() 而非 isConfigured():只設定了舊制 +// PDF_PAYLOAD_SECRET、還沒設定 Root 的部署,仍然要能解開改版前匯出的舊格式 PDF。 router.post('/api/pdf-payload/decrypt', (req, res) => { - if (!isConfigured()) return res.status(503).json({ message: 'PDF 加密功能未設定' }); + if (!isDecryptionPossible()) return res.status(503).json({ message: 'PDF 加密功能未設定' }); try { const { payload } = req.body; if (typeof payload !== 'string' || !payload) { diff --git a/backend/src/routes/schedules.js b/backend/src/routes/schedules.js index bbd6349..11af9c3 100644 --- a/backend/src/routes/schedules.js +++ b/backend/src/routes/schedules.js @@ -1,7 +1,7 @@ const express = require('express'); const debug = require('debug'); const { getIsDbConnected } = require('../db/connect'); -const { validateScheduleName, validateProfileName } = require('../validators'); +const { validateScheduleName, validateProfileName, validateScheduleData } = require('../validators'); const repo = require('../repositories/profileRepository'); const debugDb = debug('app:db'); @@ -18,8 +18,9 @@ router.post('/api/schedules', async (req, res) => { return res.status(400).json({ message: validation.error }); } - if (!Array.isArray(data) || data.length === 0) { - return res.status(400).json({ message: '班表數據必須是非空數組' }); + const dataValidation = validateScheduleData(data); + if (!dataValidation.valid) { + return res.status(400).json({ message: dataValidation.error }); } // 優先使用請求帶來的 profile,向下兼容未帶 profile 的舊呼叫 diff --git a/backend/src/routes/status.js b/backend/src/routes/status.js index 0fa44a6..96e8fe5 100644 --- a/backend/src/routes/status.js +++ b/backend/src/routes/status.js @@ -2,6 +2,7 @@ const express = require('express'); const { getIsDbConnected, getHolidaysCollection, getConfigCollection } = require('../db/connect'); const { holidaysCache, lastRefreshStatus } = require('../services/holidayService'); const { getLastFetchStatus } = require('../services/schoolCalendar'); +const { getRotationStatus } = require('../services/pdfPayloadKeyRotation'); const router = express.Router(); @@ -15,6 +16,9 @@ router.get('/api/status', async (req, res) => { // 讓人可以直接從 /api/status 看出這兩個機制是不是還正常。 holidaysLastRefresh: lastRefreshStatus, schoolCalendarLastFetch: getLastFetchStatus(), + // PDF 隱藏資料加密金鑰版本目前輪替到哪一版、上次/下次輪替時間;DB 未連線 + // 或狀態尚未初始化時全部欄位為 null,不代表輪替失敗,只是還沒開始追蹤。 + pdfPayloadKeyRotation: getRotationStatus(), }; if (isDbConnected) { diff --git a/backend/src/services/pdfPayloadCrypto.js b/backend/src/services/pdfPayloadCrypto.js index d186618..1c4f373 100644 --- a/backend/src/services/pdfPayloadCrypto.js +++ b/backend/src/services/pdfPayloadCrypto.js @@ -1,44 +1,103 @@ 'use strict'; const crypto = require('crypto'); -const { PDF_PAYLOAD_SECRET } = require('../config'); +const { PDF_PAYLOAD_SECRET, PDF_PAYLOAD_ROOT_SECRET } = require('../config'); +const { getCurrentVersion } = require('./pdfPayloadKeyRotation'); const ALGORITHM = 'aes-256-gcm'; const IV_LENGTH = 12; const AUTH_TAG_LENGTH = 16; +const KEY_LENGTH = 32; +// HKDF 的 salt 不是秘密,固定值即可——IKM(Root Secret)本身已經是高熵隨機值, +// 不是需要防彩虹表的低熵密碼,salt 在這裡的作用只是網域區隔,不需要保密或隨機化。 +const HKDF_SALT = Buffer.from('schedule-pdf-payload-hkdf-salt', 'utf8'); +const HEX_KEY_REGEX = /^[0-9a-fA-F]{64}$/; +const VERSION_REGEX = /^v[1-9]\d*$/; -const getKey = () => { - if (!PDF_PAYLOAD_SECRET || !/^[0-9a-fA-F]{64}$/.test(PDF_PAYLOAD_SECRET)) return null; - return Buffer.from(PDF_PAYLOAD_SECRET, 'hex'); +const parseHexKey = (value) => (value && HEX_KEY_REGEX.test(value) ? Buffer.from(value, 'hex') : null); + +// v0(改版前)的相容金鑰:PDF_PAYLOAD_SECRET 的值直接當 KEK 用,不經過 HKDF、 +// 也不再用來加密任何新資料——只為了讓改版前就已經匯出、流通在外的舊 PDF 還能解密。 +const getV0Key = () => parseHexKey(PDF_PAYLOAD_SECRET); + +const getRootSecret = () => parseHexKey(PDF_PAYLOAD_ROOT_SECRET); + +const isVersionFormatValid = (version) => typeof version === 'string' && VERSION_REGEX.test(version); + +// 每一版實際用來加解密的金鑰用 HKDF 從 Root 現場算出來,不另外存成環境變數。 +// HKDF 是單向函式:就算某一版算出來的 KEK 外洩(例如不小心寫進 log),也推不回 +// Root、推不出其他版本的 KEK,換一個新版本號就能直接隔離,不影響 Root 或其他版本。 +const deriveKekForVersion = (version) => { + if (!isVersionFormatValid(version)) return null; + const root = getRootSecret(); + if (!root) return null; + const info = Buffer.from(`schedule-pdf-payload:${version}`, 'utf8'); + return Buffer.from(crypto.hkdfSync('sha256', root, HKDF_SALT, info, KEY_LENGTH)); +}; + +// 目前版本號現在改由 pdfPayloadKeyRotation.js 的記憶體快取提供(DB 為真相來源、 +// 每 30 天自動輪替),這裡不再直接讀 PDF_PAYLOAD_KEK_CURRENT 環境變數——那個值 +// 只在系統第一次啟動、DB 裡還沒有狀態文件時,被 pdfPayloadKeyRotation.js 拿去當 +// 種子值用一次。 + +// 加密(產生新資料)是否可用:取決於 Root + 目前版本號是否都正確設定。 +// 舊制的 PDF_PAYLOAD_SECRET 只影響「能不能解開 v0 舊格式」,不影響這裡。 +const isConfigured = () => { + const version = getCurrentVersion(); + return version !== null && deriveKekForVersion(version) !== null; }; -const isConfigured = () => getKey() !== null; +// 解密是否有任何可能——只要 Root(可以算出任何版本的 KEK)或舊制金鑰(v0)任一有設定即可, +// 實際某個 payload 到底解不解得開,還是要看它宣告的版本對不對得上。 +const isDecryptionPossible = () => getRootSecret() !== null || getV0Key() !== null; -// 回傳 base64 字串:iv(12 bytes) + authTag(16 bytes) + 密文,供直接嵌入 PDF metadata 欄位。 +// payload 格式:{版本號}:{base64(iv+authTag+密文)}。加密永遠用「目前版本」, +// 不會用舊版本(含 v0)加密新資料。 const encryptPayload = (plainObj) => { - const key = getKey(); - if (!key) throw new Error('PDF_PAYLOAD_SECRET 未設定或格式錯誤'); + const version = getCurrentVersion(); + const key = deriveKekForVersion(version); + if (!version || !key) { + throw new Error('PDF_PAYLOAD_ROOT_SECRET 未設定或格式錯誤,或金鑰版本狀態尚未初始化'); + } const iv = crypto.randomBytes(IV_LENGTH); const cipher = crypto.createCipheriv(ALGORITHM, key, iv); const plaintext = Buffer.from(JSON.stringify(plainObj), 'utf8'); const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); const authTag = cipher.getAuthTag(); - return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + const body = Buffer.concat([iv, authTag, encrypted]).toString('base64'); + return `${version}:${body}`; }; -// GCM 的 authTag 驗證失敗(資料被竄改、或根本不是本系統加密的內容)會在 final() 拋出例外。 -const decryptPayload = (payloadBase64) => { - const key = getKey(); - if (!key) throw new Error('PDF_PAYLOAD_SECRET 未設定或格式錯誤'); - const buf = Buffer.from(payloadBase64, 'base64'); - if (buf.length <= IV_LENGTH + AUTH_TAG_LENGTH) throw new Error('payload 長度不足'); - const iv = buf.subarray(0, IV_LENGTH); - const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); - const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH); +const decryptWithKey = (key, iv, authTag, encrypted) => { const decipher = crypto.createDecipheriv(ALGORITHM, key, iv); decipher.setAuthTag(authTag); const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]); return JSON.parse(decrypted.toString('utf8')); }; -module.exports = { encryptPayload, decryptPayload, isConfigured }; +// GCM 的 authTag 驗證失敗(金鑰不對、資料被竄改、或根本不是本系統加密的內容) +// 會在 final() 拋出例外,統一往外拋讓呼叫端當成「無法解密」處理。 +// +// 沒有版號欄位(不含冒號)的舊格式,視同隱含 v0,直接用 PDF_PAYLOAD_SECRET 當 KEK +// 解密(不經 HKDF)——確保改版前就已經匯出、流通在外的 PDF 不用重新匯出照樣能匯入。 +// 有版號欄位的,用 HKDF(Root, info=該版本號) 現場算出對應 KEK 解密,不管系統 +// 目前實際輪替到哪一版,只要 Root 沒換過,任何版本都算得出來。 +const decryptPayload = (payload) => { + if (typeof payload !== 'string' || !payload) throw new Error('payload 格式錯誤'); + + const separatorIndex = payload.indexOf(':'); + const version = separatorIndex === -1 ? 'v0' : payload.slice(0, separatorIndex); + const body = separatorIndex === -1 ? payload : payload.slice(separatorIndex + 1); + + const key = version === 'v0' ? getV0Key() : deriveKekForVersion(version); + if (!key) throw new Error(`找不到版本 ${version} 對應的金鑰`); + + const buf = Buffer.from(body, 'base64'); + if (buf.length <= IV_LENGTH + AUTH_TAG_LENGTH) throw new Error('payload 長度不足'); + const iv = buf.subarray(0, IV_LENGTH); + const authTag = buf.subarray(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH); + const encrypted = buf.subarray(IV_LENGTH + AUTH_TAG_LENGTH); + return decryptWithKey(key, iv, authTag, encrypted); +}; + +module.exports = { encryptPayload, decryptPayload, isConfigured, isDecryptionPossible }; diff --git a/backend/src/services/pdfPayloadKeyRotation.js b/backend/src/services/pdfPayloadKeyRotation.js new file mode 100644 index 0000000..2ba4f09 --- /dev/null +++ b/backend/src/services/pdfPayloadKeyRotation.js @@ -0,0 +1,78 @@ +const debug = require('debug')('app:pdfPayloadKeyRotation'); +const { getIsDbConnected } = require('../db/connect'); +const { PDF_PAYLOAD_KEK_CURRENT } = require('../config'); +const repo = require('../repositories/pdfPayloadKeyStateRepository'); + +const ROTATION_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; // 30 天自動輪替一次 +const DEFAULT_VERSION = 'v1'; +const VERSION_REGEX = /^v([1-9]\d*)$/; + +// 目前版本號的記憶體快取,供 pdfPayloadCrypto.js 同步讀取(加密流程本身不碰 DB, +// 只有這個服務自己的初始化/定期輪替邏輯需要非同步存取 Mongo)。 +// 跟 holidayService.js 的 holidaysCache 是同一種「記憶體快取 + 定期跟 DB 同步」模式。 +let cachedState = null; // { currentVersion, rotatedAt } | null(尚未初始化或無 DB) + +const parseVersionNumber = (version) => { + const match = VERSION_REGEX.exec(version); + return match ? parseInt(match[1], 10) : null; +}; + +const nextVersion = (version) => { + const n = parseVersionNumber(version); + return n === null ? DEFAULT_VERSION : `v${n + 1}`; +}; + +const getCurrentVersion = () => cachedState?.currentVersion || null; + +// 供 /api/status 回報用:目前版本、上次輪替時間、下次預計輪替時間。 +const getRotationStatus = () => { + if (!cachedState) return { currentVersion: null, rotatedAt: null, nextRotationDue: null }; + return { + currentVersion: cachedState.currentVersion, + rotatedAt: cachedState.rotatedAt, + nextRotationDue: new Date(new Date(cachedState.rotatedAt).getTime() + ROTATION_INTERVAL_MS).toISOString(), + }; +}; + +// 伺服器啟動時呼叫:DB 裡沒有狀態文件(第一次啟動這個機制)就用環境變數 +// PDF_PAYLOAD_KEK_CURRENT 當種子值(沒設定就預設 v1)建立一筆,之後 DB 就是 +// 唯一的真相來源,這個環境變數不會再被讀取。 +const initKeyState = async () => { + if (!getIsDbConnected()) return; + try { + const existing = await repo.getState(); + if (existing) { + cachedState = { currentVersion: existing.currentVersion, rotatedAt: existing.rotatedAt }; + debug('已從資料庫載入目前金鑰版本: %s', cachedState.currentVersion); + return; + } + const seedVersion = VERSION_REGEX.test(PDF_PAYLOAD_KEK_CURRENT) ? PDF_PAYLOAD_KEK_CURRENT : DEFAULT_VERSION; + const rotatedAt = new Date().toISOString(); + await repo.setState(seedVersion, rotatedAt); + cachedState = { currentVersion: seedVersion, rotatedAt }; + debug('資料庫尚無金鑰版本狀態,已建立初始版本: %s', seedVersion); + } catch (error) { + console.error('[pdfPayloadKeyRotation] 初始化金鑰版本狀態失敗:', error); + } +}; + +// 每次呼叫都檢查是否已達輪替間隔(預設每 30 天),到了就自動升版並寫回資料庫。 +// Root 本身完全不受影響——只是換一個 info 參數,HKDF 現場重算,不需要人工介入。 +const rotateIfDue = async () => { + if (!getIsDbConnected() || !cachedState) return; + const elapsed = Date.now() - new Date(cachedState.rotatedAt).getTime(); + if (elapsed < ROTATION_INTERVAL_MS) return; + + const newVersion = nextVersion(cachedState.currentVersion); + const rotatedAt = new Date().toISOString(); + try { + await repo.setState(newVersion, rotatedAt); + const oldVersion = cachedState.currentVersion; + cachedState = { currentVersion: newVersion, rotatedAt }; + debug('金鑰版本已自動輪替: %s → %s', oldVersion, newVersion); + } catch (error) { + console.error('[pdfPayloadKeyRotation] 自動輪替金鑰版本失敗(沿用目前版本繼續運作):', error); + } +}; + +module.exports = { initKeyState, rotateIfDue, getCurrentVersion, getRotationStatus }; diff --git a/backend/src/services/scheduleRenderer.js b/backend/src/services/scheduleRenderer.js index 775e263..f6d3cc5 100644 --- a/backend/src/services/scheduleRenderer.js +++ b/backend/src/services/scheduleRenderer.js @@ -1,7 +1,10 @@ // 本地 escapeHtml(內部使用) +// 一律先用 String() 轉成字串再轉義,不能只在 typeof === 'string' 時才處理—— +// 否則陣列/物件會原樣通過這個函式,後面樣板字串插值時呼叫它們自己的 +// toString()(例如陣列會 join 內容),繞過所有跳脫直接把原始 HTML 注入。 const escapeHtml = (unsafe) => { - if (typeof unsafe !== 'string') return unsafe; - return unsafe + if (unsafe === null || unsafe === undefined) return unsafe; + return String(unsafe) .replace(/&/g, '&') .replace(//g, '>') @@ -46,14 +49,15 @@ const generateScheduleHtml = (fullScheduleData) => { ), 1 ); - const priorityCls = `p${task.priority || 9}`; + const priorityDisplay = escapeHtml(task.priority) || 9; + const priorityCls = `p${priorityDisplay}`; let rows = ''; for (let pi = 0; pi < maxPersonnel; pi++) { rows += ''; if (pi === 0) { rows += ` ${escapeHtml(task.name)} - 需 ${task.count} · P${task.priority || 9} + 需 ${escapeHtml(task.count)} · P${priorityDisplay} `; } weekDayDates.forEach((_, dayIndex) => { diff --git a/backend/src/validators.js b/backend/src/validators.js index cf7c606..ee429e6 100644 --- a/backend/src/validators.js +++ b/backend/src/validators.js @@ -1,8 +1,11 @@ const { SAFE_PROFILE_NAME_REGEX, SAFE_SCHEDULE_NAME_REGEX } = require('./config'); +// 一律先用 String() 轉成字串再轉義,不能只在 typeof === 'string' 時才處理—— +// 否則陣列/物件會原樣通過這個函式,後面樣板字串插值時呼叫它們自己的 +// toString()(例如陣列會 join 內容),繞過所有跳脫直接把原始 HTML 注入。 const escapeHtml = (unsafe) => { - if (typeof unsafe !== 'string') return unsafe; - return unsafe + if (unsafe === null || unsafe === undefined) return unsafe; + return String(unsafe) .replace(/&/g, '&') .replace(//g, '>') @@ -99,9 +102,58 @@ const validateSettings = (settings) => { return { valid: true }; }; +const validateScheduleData = (data) => { + if (!Array.isArray(data) || data.length === 0) { + return { valid: false, error: '班表數據必須是非空數組' }; + } + + for (let w = 0; w < data.length; w++) { + const week = data[w]; + if (!week || typeof week !== 'object') { + return { valid: false, error: `第 ${w} 週資料格式錯誤` }; + } + if (typeof week.dateRange !== 'string') { + return { valid: false, error: `第 ${w} 週 dateRange 必須是字串` }; + } + if (!Array.isArray(week.weekDayDates)) { + return { valid: false, error: `第 ${w} 週 weekDayDates 必須是數組` }; + } + if (!Array.isArray(week.scheduleDays)) { + return { valid: false, error: `第 ${w} 週 scheduleDays 必須是數組` }; + } + if (!Array.isArray(week.schedule)) { + return { valid: false, error: `第 ${w} 週 schedule 必須是數組` }; + } + if (!Array.isArray(week.tasks)) { + return { valid: false, error: `第 ${w} 週 tasks 必須是數組` }; + } + for (let i = 0; i < week.tasks.length; i++) { + const task = week.tasks[i]; + if (!task || typeof task.name !== 'string' || task.name.length > 100) { + return { valid: false, error: `第 ${w} 週 Task ${i} 名稱無效` }; + } + if (typeof task.count !== 'number' || !Number.isFinite(task.count)) { + return { valid: false, error: `第 ${w} 週 Task ${i} 人數必須是數字` }; + } + if ( + task.priority !== undefined && + (typeof task.priority !== 'number' || + !Number.isInteger(task.priority) || + task.priority < 1 || + task.priority > 9) + ) { + return { valid: false, error: `第 ${w} 週 Task ${i} 優先級必須是 1-9 的整數` }; + } + } + } + + return { valid: true }; +}; + module.exports = { escapeHtml, validateProfileName, validateScheduleName, validateSettings, + validateScheduleData, }; diff --git a/backend/tests/repositories/pdfPayloadKeyStateRepository.test.js b/backend/tests/repositories/pdfPayloadKeyStateRepository.test.js new file mode 100644 index 0000000..599f808 --- /dev/null +++ b/backend/tests/repositories/pdfPayloadKeyStateRepository.test.js @@ -0,0 +1,49 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +jest.mock('../../src/db/connect', () => ({ + getPdfPayloadKeyStateCollection: jest.fn(), +})); + +const { getPdfPayloadKeyStateCollection } = require('../../src/db/connect'); +const { getState, setState } = require('../../src/repositories/pdfPayloadKeyStateRepository'); + +const makeCol = () => ({ + findOne: jest.fn().mockResolvedValue(null), + updateOne: jest.fn().mockResolvedValue({ acknowledged: true }), +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('getState', () => { + it('依固定 _id 查詢狀態文件', async () => { + const col = makeCol(); + col.findOne.mockResolvedValue({ _id: 'pdf_payload_key_state', currentVersion: 'v2', rotatedAt: '2026-01-01' }); + getPdfPayloadKeyStateCollection.mockReturnValue(col); + const result = await getState(); + expect(col.findOne).toHaveBeenCalledWith({ _id: 'pdf_payload_key_state' }); + expect(result.currentVersion).toBe('v2'); + }); + + it('查無資料時回傳 null', async () => { + const col = makeCol(); + getPdfPayloadKeyStateCollection.mockReturnValue(col); + expect(await getState()).toBeNull(); + }); +}); + +describe('setState', () => { + it('用 upsert 寫入固定 _id 的文件', async () => { + const col = makeCol(); + getPdfPayloadKeyStateCollection.mockReturnValue(col); + await setState('v3', '2026-06-01T00:00:00.000Z'); + expect(col.updateOne).toHaveBeenCalledWith( + { _id: 'pdf_payload_key_state' }, + { $set: { currentVersion: 'v3', rotatedAt: '2026-06-01T00:00:00.000Z' } }, + { upsert: true } + ); + }); +}); diff --git a/backend/tests/routes/pdfPayload.test.js b/backend/tests/routes/pdfPayload.test.js index 4335a24..9e071b5 100644 --- a/backend/tests/routes/pdfPayload.test.js +++ b/backend/tests/routes/pdfPayload.test.js @@ -4,13 +4,19 @@ process.env.NODE_ENV = 'test'; jest.mock('../../src/services/pdfPayloadCrypto', () => ({ isConfigured: jest.fn(), + isDecryptionPossible: jest.fn(), encryptPayload: jest.fn(), decryptPayload: jest.fn(), })); const request = require('supertest'); const app = require('../../server'); -const { isConfigured, encryptPayload, decryptPayload } = require('../../src/services/pdfPayloadCrypto'); +const { + isConfigured, + isDecryptionPossible, + encryptPayload, + decryptPayload, +} = require('../../src/services/pdfPayloadCrypto'); beforeEach(() => { jest.clearAllMocks(); @@ -48,35 +54,43 @@ describe('POST /api/pdf-payload/encrypt', () => { }); describe('POST /api/pdf-payload/decrypt', () => { - it('未設定金鑰時回傳 503', async () => { - isConfigured.mockReturnValue(false); + it('完全沒有可能解密(isDecryptionPossible 為 false)時回傳 503', async () => { + isDecryptionPossible.mockReturnValue(false); const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); expect(res.status).toBe(503); expect(decryptPayload).not.toHaveBeenCalled(); }); + it('只設定舊制金鑰、isConfigured 為 false 時,decrypt 仍可用(走 isDecryptionPossible)', async () => { + isConfigured.mockReturnValue(false); + isDecryptionPossible.mockReturnValue(true); + decryptPayload.mockReturnValue({ a: 1 }); + const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); + expect(res.status).toBe(200); + }); + it('缺少 payload 欄位時回傳 400', async () => { - isConfigured.mockReturnValue(true); + isDecryptionPossible.mockReturnValue(true); const res = await request(app).post('/api/pdf-payload/decrypt').send({}); expect(res.status).toBe(400); }); it('payload 不是字串時回傳 400', async () => { - isConfigured.mockReturnValue(true); + isDecryptionPossible.mockReturnValue(true); const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 123 }); expect(res.status).toBe(400); }); it('成功解密時回傳 data', async () => { - isConfigured.mockReturnValue(true); + isDecryptionPossible.mockReturnValue(true); decryptPayload.mockReturnValue({ a: 1 }); const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); expect(res.status).toBe(200); expect(res.body.data).toEqual({ a: 1 }); }); - it('解密失敗(竄改或非本系統匯出的 PDF)時回傳 400', async () => { - isConfigured.mockReturnValue(true); + it('解密失敗(竄改、金鑰對不上或非本系統匯出的 PDF)時回傳 400', async () => { + isDecryptionPossible.mockReturnValue(true); decryptPayload.mockImplementation(() => { throw new Error('bad auth tag'); }); const res = await request(app).post('/api/pdf-payload/decrypt').send({ payload: 'xxx' }); expect(res.status).toBe(400); diff --git a/backend/tests/routes/schedules.test.js b/backend/tests/routes/schedules.test.js index eca42d7..fd292fd 100644 --- a/backend/tests/routes/schedules.test.js +++ b/backend/tests/routes/schedules.test.js @@ -32,7 +32,17 @@ const app = require('../../server'); const { getIsDbConnected } = require('../../src/db/connect'); const repo = require('../../src/repositories/profileRepository'); -const sampleData = [{ week: 1, schedule: [], tasks: [], fillStats: [], dateRange: '2025-01-06~10' }]; +const sampleData = [ + { + week: 1, + schedule: [], + tasks: [], + fillStats: [], + dateRange: '2025-01-06~10', + weekDayDates: ['01/06', '01/07', '01/08', '01/09', '01/10'], + scheduleDays: [], + }, +]; beforeEach(() => { jest.clearAllMocks(); @@ -72,6 +82,46 @@ describe('POST /api/schedules', () => { expect(res.status).toBe(400); }); + it('週資料缺少 scheduleDays 時回傳 400', async () => { + const badData = [{ dateRange: 'x', weekDayDates: [], schedule: [], tasks: [] }]; + const res = await request(app) + .post('/api/schedules') + .send({ name: '2025-W01', data: badData, profile: 'default' }); + expect(res.status).toBe(400); + }); + + it('task.priority 為非法字串(XSS payload)時回傳 400', async () => { + const badData = [ + { + dateRange: 'x', + weekDayDates: [], + scheduleDays: [], + schedule: [], + tasks: [{ name: '素描教室', count: 1, priority: '">' }], + }, + ]; + const res = await request(app) + .post('/api/schedules') + .send({ name: '2025-W01', data: badData, profile: 'default' }); + expect(res.status).toBe(400); + }); + + it('task.count 為非數字時回傳 400', async () => { + const badData = [ + { + dateRange: 'x', + weekDayDates: [], + scheduleDays: [], + schedule: [], + tasks: [{ name: '素描教室', count: '' }], + }, + ]; + const res = await request(app) + .post('/api/schedules') + .send({ name: '2025-W01', data: badData, profile: 'default' }); + expect(res.status).toBe(400); + }); + it('成功儲存班表,回傳 201', async () => { repo.saveSchedule.mockResolvedValue(); const res = await request(app) diff --git a/backend/tests/routes/status.test.js b/backend/tests/routes/status.test.js index c0ca9c8..9f74b0e 100644 --- a/backend/tests/routes/status.test.js +++ b/backend/tests/routes/status.test.js @@ -42,6 +42,13 @@ jest.mock('../../src/services/schoolCalendar', () => ({ getLastFetchStatus: jest.fn().mockReturnValue({ at: null, success: null, eventCount: null, warning: null }), })); +jest.mock('../../src/services/pdfPayloadKeyRotation', () => ({ + initKeyState: jest.fn(), + rotateIfDue: jest.fn(), + getCurrentVersion: jest.fn(), + getRotationStatus: jest.fn().mockReturnValue({ currentVersion: null, rotatedAt: null, nextRotationDue: null }), +})); + // ── 測試主體 ────────────────────────────────────────────────────────────────── const request = require('supertest'); @@ -49,6 +56,7 @@ const app = require('../../server'); const { getIsDbConnected, getHolidaysCollection, getConfigCollection } = require('../../src/db/connect'); const { holidaysCache, lastRefreshStatus } = require('../../src/services/holidayService'); const { getLastFetchStatus } = require('../../src/services/schoolCalendar'); +const { getRotationStatus } = require('../../src/services/pdfPayloadKeyRotation'); beforeEach(() => { jest.clearAllMocks(); @@ -126,4 +134,21 @@ describe('GET /api/status', () => { expect(res.body.schoolCalendarLastFetch.success).toBe(false); expect(res.body.schoolCalendarLastFetch.warning).toContain('格式已變更'); }); + + it('回傳 PDF 加密金鑰目前的輪替狀態', async () => { + getIsDbConnected.mockReturnValue(false); + getRotationStatus.mockReturnValue({ + currentVersion: 'v2', + rotatedAt: '2026-01-01T00:00:00.000Z', + nextRotationDue: '2026-01-31T00:00:00.000Z', + }); + + const res = await request(app).get('/api/status'); + + expect(res.body.pdfPayloadKeyRotation).toEqual({ + currentVersion: 'v2', + rotatedAt: '2026-01-01T00:00:00.000Z', + nextRotationDue: '2026-01-31T00:00:00.000Z', + }); + }); }); diff --git a/backend/tests/services/pdfPayloadCrypto.test.js b/backend/tests/services/pdfPayloadCrypto.test.js index 1ff0e36..acdae10 100644 --- a/backend/tests/services/pdfPayloadCrypto.test.js +++ b/backend/tests/services/pdfPayloadCrypto.test.js @@ -2,70 +2,171 @@ process.env.NODE_ENV = 'test'; +const crypto = require('crypto'); + describe('pdfPayloadCrypto', () => { - const VALID_SECRET = 'a'.repeat(64); + const ROOT = 'a'.repeat(64); + const LEGACY_SECRET = 'b'.repeat(64); + const ENV_KEYS = ['PDF_PAYLOAD_SECRET', 'PDF_PAYLOAD_ROOT_SECRET']; - const loadModule = (secret) => { + // 目前版本號現在由 pdfPayloadKeyRotation.js(DB 為真相來源)提供,這裡的加解密邏輯 + // 測試不需要真的碰 DB,直接 mock 掉那個依賴、用 currentVersion 參數控制它回傳什麼版本。 + // env 為 undefined 的欄位會被 delete,其餘會被設定;每次都 resetModules 重新 + // require,確保 config.js 讀到的是這次測試指定的值,不是上一個測試殘留的。 + const loadModule = (env = {}, currentVersion = null) => { jest.resetModules(); - if (secret === undefined) { - delete process.env.PDF_PAYLOAD_SECRET; - } else { - process.env.PDF_PAYLOAD_SECRET = secret; + for (const key of ENV_KEYS) { + if (env[key] === undefined) delete process.env[key]; + else process.env[key] = env[key]; } + jest.doMock('../../src/services/pdfPayloadKeyRotation', () => ({ + getCurrentVersion: jest.fn().mockReturnValue(currentVersion), + })); return require('../../src/services/pdfPayloadCrypto'); }; afterEach(() => { - delete process.env.PDF_PAYLOAD_SECRET; + for (const key of ENV_KEYS) delete process.env[key]; }); - it('isConfigured() 在密鑰未設定時回傳 false', () => { - const { isConfigured } = loadModule(undefined); - expect(isConfigured()).toBe(false); - }); + // 模擬改版前的舊格式 payload:沒有版號欄位,直接用 PDF_PAYLOAD_SECRET 當 KEK + // 做 AES-256-GCM 加密,複製當初 v0 機制的加密邏輯,不透過現在的 encryptPayload() + // (它現在只會產生新格式)。 + const buildLegacyV0Payload = (plainObj, hexKey) => { + const key = Buffer.from(hexKey, 'hex'); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv); + const plaintext = Buffer.from(JSON.stringify(plainObj), 'utf8'); + const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final()]); + const authTag = cipher.getAuthTag(); + return Buffer.concat([iv, authTag, encrypted]).toString('base64'); + }; - it('isConfigured() 在密鑰格式錯誤時回傳 false', () => { - const { isConfigured } = loadModule('not-a-valid-hex-key'); - expect(isConfigured()).toBe(false); - }); + describe('isConfigured(加密新資料是否可用)', () => { + it('什麼都沒設定時回傳 false', () => { + expect(loadModule({}, null).isConfigured()).toBe(false); + }); - it('isConfigured() 在密鑰格式正確時回傳 true', () => { - const { isConfigured } = loadModule(VALID_SECRET); - expect(isConfigured()).toBe(true); - }); + it('只設定舊制 PDF_PAYLOAD_SECRET(沒有 Root)時回傳 false', () => { + expect(loadModule({ PDF_PAYLOAD_SECRET: LEGACY_SECRET }, null).isConfigured()).toBe(false); + }); - it('encryptPayload/decryptPayload 可以正確往返還原資料', () => { - const { encryptPayload, decryptPayload } = loadModule(VALID_SECRET); - const original = { weeks: [{ tasks: ['素描教室'], schedule: [['張三', '李四']] }] }; - const encrypted = encryptPayload(original); - expect(typeof encrypted).toBe('string'); - expect(decryptPayload(encrypted)).toEqual(original); - }); + it('有 Root 但目前版本號為 null(尚未初始化)時回傳 false', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, null); + expect(mod.isConfigured()).toBe(false); + }); - it('相同資料每次加密結果不同(iv 隨機產生)', () => { - const { encryptPayload } = loadModule(VALID_SECRET); - expect(encryptPayload({ x: 1 })).not.toBe(encryptPayload({ x: 1 })); + it('Root 格式錯誤時回傳 false', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: 'nope' }, 'v1'); + expect(mod.isConfigured()).toBe(false); + }); + + it('Root 與版本號都正確時回傳 true', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + expect(mod.isConfigured()).toBe(true); + }); }); - it('decryptPayload 遇到被竄改的內容會拋出例外', () => { - const { encryptPayload, decryptPayload } = loadModule(VALID_SECRET); - const encrypted = encryptPayload({ x: 1 }); - const tampered = encrypted.slice(0, -4) + (encrypted.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); - expect(() => decryptPayload(tampered)).toThrow(); + describe('isDecryptionPossible(解密是否有任何可能)', () => { + it('什麼都沒設定時回傳 false', () => { + expect(loadModule({}, null).isDecryptionPossible()).toBe(false); + }); + + it('只設定舊制 PDF_PAYLOAD_SECRET 時回傳 true(仍可解 v0 舊格式)', () => { + expect(loadModule({ PDF_PAYLOAD_SECRET: LEGACY_SECRET }, null).isDecryptionPossible()).toBe(true); + }); + + it('只設定 Root(目前版本號 null)時回傳 true(Root 本身就能算出任何版本的 KEK)', () => { + expect(loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, null).isDecryptionPossible()).toBe(true); + }); }); - it('decryptPayload 遇到格式不符的字串(非本系統加密)會拋出例外', () => { - const { decryptPayload } = loadModule(VALID_SECRET); - expect(() => decryptPayload(Buffer.from('random unrelated text').toString('base64'))).toThrow(); + describe('新格式加解密(Root + HKDF 版本衍生)', () => { + it('encryptPayload/decryptPayload 可以正確往返還原資料', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const original = { weeks: [{ tasks: ['素描教室'], schedule: [['張三', '李四']] }] }; + const encrypted = mod.encryptPayload(original); + expect(mod.decryptPayload(encrypted)).toEqual(original); + }); + + it('加密結果開頭帶有目前版本號前綴', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v3'); + expect(mod.encryptPayload({ x: 1 })).toMatch(/^v3:/); + }); + + it('相同資料每次加密結果不同(iv 隨機產生)', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + expect(mod.encryptPayload({ x: 1 })).not.toBe(mod.encryptPayload({ x: 1 })); + }); + + it('換了目前版本號之後,用舊版本號加密過的 payload 仍然解得開(Root 沒變)', () => { + const modV1 = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const encryptedUnderV1 = modV1.encryptPayload({ x: 1 }); + + const modV2 = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v2'); + expect(modV2.decryptPayload(encryptedUnderV1)).toEqual({ x: 1 }); + }); + + it('payload 宣告的版本號跟實際加密用的版本號對不上時解密失敗', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const encrypted = mod.encryptPayload({ x: 1 }); + const wrongVersionPayload = encrypted.replace(/^v1:/, 'v2:'); + expect(() => mod.decryptPayload(wrongVersionPayload)).toThrow(); + }); + + it('Root 不同的話,同版本號也算不出一樣的 KEK(換 Root 等於所有版本一起失效)', () => { + const modOldRoot = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const encrypted = modOldRoot.encryptPayload({ x: 1 }); + + const differentRoot = 'c'.repeat(64); + const modNewRoot = loadModule({ PDF_PAYLOAD_ROOT_SECRET: differentRoot }, 'v1'); + expect(() => modNewRoot.decryptPayload(encrypted)).toThrow(); + }); + + it('decryptPayload 遇到被竄改的內容會拋出例外', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const encrypted = mod.encryptPayload({ x: 1 }); + const tampered = encrypted.slice(0, -4) + (encrypted.slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); + expect(() => mod.decryptPayload(tampered)).toThrow(); + }); + + it('encryptPayload 在 Root/版本號未設定時拋出例外', () => { + const mod = loadModule({}, null); + expect(() => mod.encryptPayload({ x: 1 })).toThrow(); + }); }); - it('encryptPayload 在密鑰未設定時拋出例外', () => { - const { encryptPayload } = loadModule(undefined); - expect(() => encryptPayload({ x: 1 })).toThrow(); + describe('v0 舊格式相容(改版前用 PDF_PAYLOAD_SECRET 直接加密的 PDF)', () => { + it('沒有版號前綴的舊格式,只設定 PDF_PAYLOAD_SECRET(沒有 Root)也能正確解密', () => { + const mod = loadModule({ PDF_PAYLOAD_SECRET: LEGACY_SECRET }, null); + const original = { legacy: true }; + const legacyPayload = buildLegacyV0Payload(original, LEGACY_SECRET); + expect(mod.decryptPayload(legacyPayload)).toEqual(original); + }); + + it('改版後即使已設定 Root + 新版本號,舊格式 payload 仍然走 v0 邏輯正確解密', () => { + const mod = loadModule({ PDF_PAYLOAD_SECRET: LEGACY_SECRET, PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const original = { legacy: true }; + const legacyPayload = buildLegacyV0Payload(original, LEGACY_SECRET); + expect(mod.decryptPayload(legacyPayload)).toEqual(original); + }); + + it('沒有設定 PDF_PAYLOAD_SECRET 時,舊格式 payload 解密失敗', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + const legacyPayload = buildLegacyV0Payload({ legacy: true }, LEGACY_SECRET); + expect(() => mod.decryptPayload(legacyPayload)).toThrow(); + }); }); - it('decryptPayload 在密鑰未設定時拋出例外', () => { - const { decryptPayload } = loadModule(undefined); - expect(() => decryptPayload('anything')).toThrow(); + describe('decryptPayload 的一般錯誤情況', () => { + it('什麼金鑰都沒設定時拋出例外', () => { + const mod = loadModule({}, null); + expect(() => mod.decryptPayload('v1:anything')).toThrow(); + }); + + it('格式不符的字串(非本系統加密)會拋出例外', () => { + const mod = loadModule({ PDF_PAYLOAD_ROOT_SECRET: ROOT }, 'v1'); + expect(() => mod.decryptPayload('v1:' + Buffer.from('random unrelated text').toString('base64'))).toThrow(); + }); }); }); diff --git a/backend/tests/services/pdfPayloadKeyRotation.test.js b/backend/tests/services/pdfPayloadKeyRotation.test.js new file mode 100644 index 0000000..da4b86f --- /dev/null +++ b/backend/tests/services/pdfPayloadKeyRotation.test.js @@ -0,0 +1,148 @@ +'use strict'; + +process.env.NODE_ENV = 'test'; + +const DAY_MS = 24 * 60 * 60 * 1000; + +describe('pdfPayloadKeyRotation', () => { + // isDbConnected/getState/setState 都用 mock 控制;每個測試重新 resetModules + + // doMock,確保 config.js 的 PDF_PAYLOAD_KEK_CURRENT 跟這幾個依賴都是全新的、 + // 不會被上一個測試的呼叫紀錄或殘留狀態干擾。 + const loadModule = ({ dbConnected = true, seedEnv, getStateResult = null, setStateImpl } = {}) => { + jest.resetModules(); + if (seedEnv === undefined) delete process.env.PDF_PAYLOAD_KEK_CURRENT; + else process.env.PDF_PAYLOAD_KEK_CURRENT = seedEnv; + + const getState = jest.fn().mockResolvedValue(getStateResult); + const setState = setStateImpl || jest.fn().mockResolvedValue(undefined); + + jest.doMock('../../src/db/connect', () => ({ + getIsDbConnected: jest.fn().mockReturnValue(dbConnected), + })); + jest.doMock('../../src/repositories/pdfPayloadKeyStateRepository', () => ({ getState, setState })); + + const mod = require('../../src/services/pdfPayloadKeyRotation'); + return { mod, getState, setState }; + }; + + afterEach(() => { + delete process.env.PDF_PAYLOAD_KEK_CURRENT; + }); + + describe('initKeyState', () => { + it('DB 未連線時不做任何事,getCurrentVersion 維持 null', async () => { + const { mod, getState, setState } = loadModule({ dbConnected: false }); + await mod.initKeyState(); + expect(getState).not.toHaveBeenCalled(); + expect(setState).not.toHaveBeenCalled(); + expect(mod.getCurrentVersion()).toBeNull(); + }); + + it('資料庫已有狀態文件時直接載入,不會呼叫 setState', async () => { + const { mod, setState } = loadModule({ + getStateResult: { currentVersion: 'v5', rotatedAt: '2026-01-01T00:00:00.000Z' }, + }); + await mod.initKeyState(); + expect(mod.getCurrentVersion()).toBe('v5'); + expect(setState).not.toHaveBeenCalled(); + }); + + it('資料庫沒有狀態文件、有設定 PDF_PAYLOAD_KEK_CURRENT 時,用它當種子值建立', async () => { + const { mod, setState } = loadModule({ getStateResult: null, seedEnv: 'v3' }); + await mod.initKeyState(); + expect(mod.getCurrentVersion()).toBe('v3'); + expect(setState).toHaveBeenCalledWith('v3', expect.any(String)); + }); + + it('資料庫沒有狀態文件、也沒設定種子環境變數時,預設用 v1', async () => { + const { mod, setState } = loadModule({ getStateResult: null }); + await mod.initKeyState(); + expect(mod.getCurrentVersion()).toBe('v1'); + expect(setState).toHaveBeenCalledWith('v1', expect.any(String)); + }); + + it('種子環境變數格式不合法時,忽略它、改用預設 v1', async () => { + const { mod } = loadModule({ getStateResult: null, seedEnv: 'not-a-version' }); + await mod.initKeyState(); + expect(mod.getCurrentVersion()).toBe('v1'); + }); + + it('寫入資料庫失敗時不拋出例外,getCurrentVersion 維持 null', async () => { + const { mod } = loadModule({ + getStateResult: null, + setStateImpl: jest.fn().mockRejectedValue(new Error('db down')), + }); + await expect(mod.initKeyState()).resolves.not.toThrow(); + expect(mod.getCurrentVersion()).toBeNull(); + }); + }); + + describe('getRotationStatus', () => { + it('尚未初始化時全部欄位為 null', () => { + const { mod } = loadModule({ dbConnected: false }); + expect(mod.getRotationStatus()).toEqual({ currentVersion: null, rotatedAt: null, nextRotationDue: null }); + }); + + it('初始化後回傳目前版本、上次輪替時間、下次預計輪替時間(+30 天)', async () => { + const rotatedAt = '2026-01-01T00:00:00.000Z'; + const { mod } = loadModule({ getStateResult: { currentVersion: 'v2', rotatedAt } }); + await mod.initKeyState(); + const status = mod.getRotationStatus(); + expect(status.currentVersion).toBe('v2'); + expect(status.rotatedAt).toBe(rotatedAt); + expect(new Date(status.nextRotationDue).getTime() - new Date(rotatedAt).getTime()).toBe(30 * DAY_MS); + }); + }); + + describe('rotateIfDue', () => { + it('DB 未連線時不做任何事', async () => { + const { mod, setState } = loadModule({ dbConnected: false }); + await mod.rotateIfDue(); + expect(setState).not.toHaveBeenCalled(); + }); + + it('尚未初始化(cachedState 為 null)時不做任何事', async () => { + const { mod, setState } = loadModule({ getStateResult: null }); + // 故意不呼叫 initKeyState(),模擬還沒初始化就先被呼叫的情況 + await mod.rotateIfDue(); + expect(setState).not.toHaveBeenCalled(); + }); + + it('距離上次輪替不到 30 天時不輪替', async () => { + const rotatedAt = new Date(Date.now() - 5 * DAY_MS).toISOString(); + const { mod, setState } = loadModule({ getStateResult: { currentVersion: 'v1', rotatedAt } }); + await mod.initKeyState(); + await mod.rotateIfDue(); + expect(setState).not.toHaveBeenCalled(); + expect(mod.getCurrentVersion()).toBe('v1'); + }); + + it('距離上次輪替滿 30 天時自動升版並寫回資料庫', async () => { + const rotatedAt = new Date(Date.now() - 31 * DAY_MS).toISOString(); + const { mod, setState } = loadModule({ getStateResult: { currentVersion: 'v1', rotatedAt } }); + await mod.initKeyState(); + await mod.rotateIfDue(); + expect(mod.getCurrentVersion()).toBe('v2'); + expect(setState).toHaveBeenCalledWith('v2', expect.any(String)); + }); + + it('版本號正確遞增(v9 → v10)', async () => { + const rotatedAt = new Date(Date.now() - 31 * DAY_MS).toISOString(); + const { mod } = loadModule({ getStateResult: { currentVersion: 'v9', rotatedAt } }); + await mod.initKeyState(); + await mod.rotateIfDue(); + expect(mod.getCurrentVersion()).toBe('v10'); + }); + + it('輪替寫入資料庫失敗時,沿用舊版本繼續運作,不拋出例外', async () => { + const rotatedAt = new Date(Date.now() - 31 * DAY_MS).toISOString(); + const { mod } = loadModule({ + getStateResult: { currentVersion: 'v1', rotatedAt }, + setStateImpl: jest.fn().mockRejectedValue(new Error('db down')), + }); + await mod.initKeyState(); + await expect(mod.rotateIfDue()).resolves.not.toThrow(); + expect(mod.getCurrentVersion()).toBe('v1'); + }); + }); +}); diff --git a/backend/tests/services/scheduleRenderer.test.js b/backend/tests/services/scheduleRenderer.test.js index 642570a..a074c1d 100644 --- a/backend/tests/services/scheduleRenderer.test.js +++ b/backend/tests/services/scheduleRenderer.test.js @@ -56,4 +56,50 @@ describe('generateScheduleHtml', () => { const html = generateScheduleHtml([week]); expect(html).toContain('100%'); }); + + it('task.priority 帶惡意字串時會被轉義,不會產生可執行的標籤', () => { + const payload = '">'; + const week = makeWeek({ tasks: [{ name: '早班', count: 1, priority: payload }] }); + const html = generateScheduleHtml([week]); + expect(html).not.toContain(payload); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); + + it('task.count 帶惡意字串時會被轉義,不會產生可執行的標籤', () => { + const payload = ''; + const week = makeWeek({ tasks: [{ name: '早班', count: payload, priority: 1 }] }); + const html = generateScheduleHtml([week]); + expect(html).not.toContain(payload); + expect(html).toContain('<script>'); + }); + + it('task.priority 為合法數字時不受轉義影響,正常顯示', () => { + const week = makeWeek({ tasks: [{ name: '早班', count: 1, priority: 3 }] }); + const html = generateScheduleHtml([week]); + expect(html).toContain('p3'); + expect(html).toContain('P3'); + }); + + it('假日 description 為陣列時不能繞過轉義(曾經是真實可利用的漏洞:陣列 toString 會跳過 escapeHtml)', () => { + const week = makeWeek({ + schedule: [[[]]], + scheduleDays: [{ shouldSchedule: false, description: [''] }], + }); + const html = generateScheduleHtml([week]); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); + + it('人員姓名(schedule 內容)為陣列時不能繞過轉義', () => { + // schedule[day][task][pi] 本身是陣列(不是字串)——正是實際攻擊測試中 + // 繞過驗證+轉義的手法:personName = [''],樣板字串插值時 + // 陣列的 toString() 會把內容原樣接進 HTML。 + const week = makeWeek({ + schedule: [[[['']]]], + }); + const html = generateScheduleHtml([week]); + expect(html).not.toContain(''); + expect(html).toContain('<img'); + }); }); diff --git a/backend/tests/validators.test.js b/backend/tests/validators.test.js index 0fcff36..5c9db0e 100644 --- a/backend/tests/validators.test.js +++ b/backend/tests/validators.test.js @@ -5,6 +5,7 @@ const { validateProfileName, validateScheduleName, validateSettings, + validateScheduleData, } = require('../src/validators'); // ─── escapeHtml ──────────────────────────────────────────────────────────── @@ -16,11 +17,21 @@ describe('escapeHtml', () => { it('轉義 "', () => expect(escapeHtml('"hello"')).toBe('"hello"')); it("轉義 '", () => expect(escapeHtml("it's")).toBe('it's')); it('安全字串不變', () => expect(escapeHtml('hello world 123')).toBe('hello world 123')); - it('非字串型別原樣回傳', () => { - expect(escapeHtml(42)).toBe(42); + it('數字轉成字串後回傳(樣板字串插值結果不變)', () => expect(escapeHtml(42)).toBe('42')); + it('null/undefined 原樣回傳', () => { expect(escapeHtml(null)).toBe(null); expect(escapeHtml(undefined)).toBe(undefined); }); + it('陣列型別會被字串化後轉義,不能繞過跳脫(曾經是真實可利用的漏洞)', () => { + const result = escapeHtml(['']); + expect(result).not.toContain(' { + const result = escapeHtml({ toString: () => '' }); + expect(result).not.toContain('' }] }; + expect(validateScheduleData([week]).valid).toBe(false); + }); + + it('task.priority 為非法字串(XSS payload)回傳 invalid', () => { + const week = { + ...minWeek(), + tasks: [{ name: '早班', count: 1, priority: '">' }], + }; + expect(validateScheduleData([week]).valid).toBe(false); + }); + + it('task.priority=10 超出上限回傳 invalid', () => { + const week = { ...minWeek(), tasks: [{ name: '早班', count: 1, priority: 10 }] }; + expect(validateScheduleData([week]).valid).toBe(false); + }); + + it('task.priority 未設定時合法(選填)', () => { + const week = { ...minWeek(), tasks: [{ name: '早班', count: 1 }] }; + expect(validateScheduleData([week]).valid).toBe(true); + }); +});