240 lines
10 KiB
JavaScript
240 lines
10 KiB
JavaScript
const express = require('express');
|
||
const crypto = require('crypto');
|
||
const bcrypt = require('bcryptjs');
|
||
const db = require('../db');
|
||
const { authMiddleware } = require('../middleware/auth');
|
||
|
||
const router = express.Router();
|
||
|
||
// PBKDF2 迭代数:新 PIN 使用 OWASP 2023 建议的 600000;老用户兼容旧值 100000
|
||
const PBKDF2_ITERATIONS = 600000;
|
||
const LEGACY_ITERATIONS = 100000;
|
||
|
||
// In-memory session store for unlocked encryption keys(滑动过期)
|
||
const unlockedSessions = new Map();
|
||
const SESSION_TTL = 3600000; // 1 小时滑动过期
|
||
|
||
// 解锁失败限流:userId -> { count, firstTs, lockedUntil }
|
||
const unlockFails = new Map();
|
||
const FAIL_LIMIT = 3; // 5 分钟内最多失败次数
|
||
const FAIL_WINDOW = 5 * 60 * 1000; // 统计窗口
|
||
const LOCK_DURATION = 5 * 60 * 1000; // 锁定时长
|
||
|
||
function getEncryptionKey(userId, pin) {
|
||
const setting = db.get('SELECT kdf_salt, pin_iter FROM user_settings WHERE user_id = ?', [userId]);
|
||
if (!setting || !setting.kdf_salt) return null;
|
||
const salt = Buffer.from(setting.kdf_salt, 'hex');
|
||
// 按用户存储的迭代数派生:老用户 100000 继续可用,新 set-pin 后为 600000
|
||
const iterations = setting.pin_iter || LEGACY_ITERATIONS;
|
||
return crypto.pbkdf2Sync(pin, salt, iterations, 32, 'sha256');
|
||
}
|
||
|
||
// 会话辅助:惰性过期检查 + 滑动续期
|
||
function getSession(userId) {
|
||
const session = unlockedSessions.get(userId);
|
||
if (!session) return null;
|
||
if (Date.now() > session.expires) { // 已过期,惰性清除
|
||
unlockedSessions.delete(userId);
|
||
return null;
|
||
}
|
||
return session;
|
||
}
|
||
|
||
function refreshSession(userId) {
|
||
const session = getSession(userId);
|
||
if (!session) return false;
|
||
session.expires = Date.now() + SESSION_TTL; // 每次操作刷新过期时间
|
||
return true;
|
||
}
|
||
|
||
// 限流辅助
|
||
function isLocked(userId) {
|
||
const rec = unlockFails.get(userId);
|
||
if (!rec || !rec.lockedUntil) return false;
|
||
if (Date.now() >= rec.lockedUntil) { // 锁定期结束,清除
|
||
unlockFails.delete(userId);
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
function recordFail(userId) {
|
||
const now = Date.now();
|
||
let rec = unlockFails.get(userId);
|
||
if (!rec || (now - rec.firstTs) > FAIL_WINDOW) rec = { count: 0, firstTs: now, lockedUntil: 0 };
|
||
rec.count += 1;
|
||
if (rec.count >= FAIL_LIMIT) {
|
||
rec.lockedUntil = now + LOCK_DURATION;
|
||
rec.count = 0;
|
||
}
|
||
unlockFails.set(userId, rec);
|
||
}
|
||
|
||
function encrypt(text, key) {
|
||
const iv = crypto.randomBytes(12);
|
||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||
let encrypted = cipher.update(text, 'utf8', 'hex');
|
||
encrypted += cipher.final('hex');
|
||
const tag = cipher.getAuthTag().toString('hex');
|
||
return iv.toString('hex') + ':' + tag + ':' + encrypted;
|
||
}
|
||
|
||
function decrypt(encoded, key) {
|
||
try {
|
||
const parts = encoded.split(':');
|
||
const iv = Buffer.from(parts[0], 'hex');
|
||
const tag = Buffer.from(parts[1], 'hex');
|
||
const encrypted = parts[2];
|
||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||
decipher.setAuthTag(tag);
|
||
let decrypted = decipher.update(encrypted, 'hex', 'utf8');
|
||
decrypted += decipher.final('utf8');
|
||
return decrypted;
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Set or change PIN
|
||
router.post('/set-pin', authMiddleware, (req, res) => {
|
||
const { pin } = req.body;
|
||
// PIN 强度:仅长度 < 6 拒绝(纯数字不强制拒绝,但提示建议包含字母)
|
||
if (!pin || typeof pin !== 'string' || pin.length < 6) {
|
||
return res.status(400).json({ error: 'PIN 至少 6 位,建议包含字母' });
|
||
}
|
||
const existing = db.get('SELECT id, kdf_salt FROM user_settings WHERE user_id = ?', [req.user.id]);
|
||
const entries = db.all('SELECT id, encrypted_password FROM password_entries WHERE user_id = ?', [req.user.id]);
|
||
|
||
// L8:改 PIN 旧密文重新加密——旧条目用「解锁会话中的旧密钥」解密,
|
||
// 换新 salt 新密钥后重新加密写回。若已有条目但无解锁会话,则无法解密旧数据,
|
||
// 拒绝修改并要求先解锁(否则改完 PIN 旧条目将永久不可解)。
|
||
let oldKey = null;
|
||
if (existing && existing.kdf_salt && entries.length > 0) {
|
||
const session = getSession(req.user.id);
|
||
if (!session) {
|
||
return res.status(400).json({ error: '请先解锁密码管理器后再修改 PIN(需用旧 PIN 重新加密已有条目)' });
|
||
}
|
||
oldKey = session.key;
|
||
}
|
||
|
||
const pinHash = bcrypt.hashSync(pin, 10);
|
||
const kdfSalt = crypto.randomBytes(16).toString('hex');
|
||
// 新密钥直接用新 salt 派生(避免依赖已更新到 DB 的 kdf_salt)
|
||
const newKey = crypto.pbkdf2Sync(pin, Buffer.from(kdfSalt, 'hex'), PBKDF2_ITERATIONS, 32, 'sha256');
|
||
|
||
try {
|
||
// 事务:换 salt 与重加密原子完成(失败自动 ROLLBACK,绝不出现"盐已换但密文没重加密"的中间态)
|
||
db.transaction(() => {
|
||
if (existing) {
|
||
db.run('UPDATE user_settings SET pin_hash=?, kdf_salt=?, pin_iter=? WHERE user_id=?',
|
||
[pinHash, kdfSalt, PBKDF2_ITERATIONS, req.user.id]);
|
||
} else {
|
||
db.run('INSERT INTO user_settings (user_id, pin_hash, kdf_salt, pin_iter) VALUES (?, ?, ?, ?)',
|
||
[req.user.id, pinHash, kdfSalt, PBKDF2_ITERATIONS]);
|
||
}
|
||
if (oldKey) {
|
||
for (const e of entries) {
|
||
const plain = decrypt(e.encrypted_password, oldKey);
|
||
if (plain === null) continue; // 单条损坏则跳过保留原样,避免整批失败
|
||
db.run('UPDATE password_entries SET encrypted_password = ? WHERE id = ?',
|
||
[encrypt(plain, newKey), e.id]);
|
||
}
|
||
}
|
||
});
|
||
} catch (e) {
|
||
console.error('Set-PIN error:', e.message);
|
||
return res.status(500).json({ error: '服务器内部错误' });
|
||
}
|
||
// Auto-unlock after setting PIN(滑动过期)——直接用新密钥,避免再查库
|
||
unlockedSessions.set(req.user.id, { key: newKey, expires: Date.now() + SESSION_TTL });
|
||
res.json({ message: 'PIN 设置成功' });
|
||
});
|
||
|
||
// Check if PIN is set
|
||
router.get('/pin-status', authMiddleware, (req, res) => {
|
||
const setting = db.get('SELECT pin_hash FROM user_settings WHERE user_id = ?', [req.user.id]);
|
||
res.json({ hasPin: !!setting && !!setting.pin_hash, unlocked: !!getSession(req.user.id) });
|
||
});
|
||
|
||
// Unlock with PIN
|
||
router.post('/unlock', authMiddleware, (req, res) => {
|
||
const { pin } = req.body;
|
||
if (!pin) return res.status(400).json({ error: '请输入 PIN' });
|
||
// 失败限流:锁定期内直接拒绝
|
||
if (isLocked(req.user.id)) {
|
||
return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
|
||
}
|
||
const setting = db.get('SELECT pin_hash FROM user_settings WHERE user_id = ?', [req.user.id]);
|
||
if (!setting || !setting.pin_hash) return res.status(400).json({ error: '请先设置 PIN' });
|
||
if (!bcrypt.compareSync(pin, setting.pin_hash)) {
|
||
recordFail(req.user.id);
|
||
// 达到失败上限后本次直接返回 429
|
||
if (isLocked(req.user.id)) return res.status(429).json({ error: '尝试次数过多,请稍后再试' });
|
||
return res.status(401).json({ error: 'PIN 错误' });
|
||
}
|
||
const key = getEncryptionKey(req.user.id, pin);
|
||
if (!key) return res.status(500).json({ error: '解密失败' });
|
||
unlockFails.delete(req.user.id); // 成功解锁清零失败计数
|
||
unlockedSessions.set(req.user.id, { key, expires: Date.now() + SESSION_TTL }); // 滑动过期,不再用一次性 setTimeout
|
||
res.json({ message: '已解锁' });
|
||
});
|
||
|
||
// Lock
|
||
router.post('/lock', authMiddleware, (req, res) => {
|
||
unlockedSessions.delete(req.user.id);
|
||
res.json({ message: '已锁定' });
|
||
});
|
||
|
||
// Ensure unlocked middleware(滑动过期:每次操作刷新过期时间)
|
||
function requireUnlock(req, res, next) {
|
||
if (!refreshSession(req.user.id)) {
|
||
return res.status(401).json({ error: '请先解锁密码管理器' });
|
||
}
|
||
next();
|
||
}
|
||
|
||
// List all password entries (decrypted)
|
||
router.get('/', authMiddleware, requireUnlock, (req, res) => {
|
||
const entries = db.all('SELECT * FROM password_entries WHERE user_id = ? ORDER BY created_at DESC', [req.user.id]);
|
||
const session = getSession(req.user.id);
|
||
if (!session) return res.status(401).json({ error: '请先解锁密码管理器' });
|
||
const decrypted = entries.map(e => {
|
||
const pwd = decrypt(e.encrypted_password, session.key);
|
||
return { id: e.id, title: e.title, username: e.username, password: pwd || '', url: e.url, notes: e.notes, created_at: e.created_at };
|
||
});
|
||
res.json(decrypted);
|
||
});
|
||
|
||
// Create
|
||
router.post('/', authMiddleware, requireUnlock, (req, res) => {
|
||
const { title, username, password, url, notes } = req.body;
|
||
if (!title || !password) return res.status(400).json({ error: '标题和密码不能为空' });
|
||
const session = getSession(req.user.id);
|
||
const encrypted = encrypt(password, session.key);
|
||
const id = db.run(
|
||
'INSERT INTO password_entries (user_id, title, username, encrypted_password, url, notes) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[req.user.id, title, username || '', encrypted, url || '', notes || '']);
|
||
res.json({ id, title, username, password, url, notes, message: '保存成功' });
|
||
});
|
||
|
||
// Update
|
||
router.put('/:id', authMiddleware, requireUnlock, (req, res) => {
|
||
const { title, username, password, url, notes } = req.body;
|
||
const existing = db.get('SELECT id FROM password_entries WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||
if (!existing) return res.status(404).json({ error: '记录不存在' });
|
||
const session = getSession(req.user.id);
|
||
const encrypted = encrypt(password || '', session.key);
|
||
db.run(
|
||
"UPDATE password_entries SET title=?, username=?, encrypted_password=?, url=?, notes=?, updated_at=datetime('now') WHERE id=?",
|
||
[title || '', username || '', encrypted, url || '', notes || '', req.params.id]);
|
||
res.json({ message: '更新成功' });
|
||
});
|
||
|
||
// Delete
|
||
router.delete('/:id', authMiddleware, requireUnlock, (req, res) => {
|
||
const existing = db.get('SELECT id FROM password_entries WHERE id = ? AND user_id = ?', [req.params.id, req.user.id]);
|
||
if (!existing) return res.status(404).json({ error: '记录不存在' });
|
||
db.run('DELETE FROM password_entries WHERE id = ?', [req.params.id]);
|
||
res.json({ message: '删除成功' });
|
||
});
|
||
|
||
module.exports = router;
|