Files
rainblogweb/routes/passwords.js
T

240 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;