Files
daywork/intern-log/src/db/vault.ts
T

143 lines
4.8 KiB
TypeScript

import { db, type Credential } from './schema'
import { getSetting, setSetting } from './repo'
import { aesGcmDecrypt, aesGcmEncrypt, bufToB64, deriveKeyFromPassword, randomSaltB64 } from '../lib/crypto'
import { nowLocalISO } from '../lib/date'
const VERIFY_TEXT = 'intern-log-vault-verify'
const ENCRYPT_PREFIX = 'enc:v1:'
// 主密钥仅存于内存,页面刷新或手动锁定后需重新解锁
let masterKey: CryptoKey | null = null
export function isVaultUnlocked(): boolean {
return masterKey !== null
}
export function lockVault(): void {
masterKey = null
}
export async function isVaultInitialized(): Promise<boolean> {
return (await getSetting('pwd_salt', '')) !== ''
}
export async function setupVault(password: string): Promise<void> {
if (password.length < 6) throw new Error('主密码至少 6 位')
if (await isVaultInitialized()) throw new Error('主密码已设置')
const salt = randomSaltB64()
const key = await deriveKeyFromPassword(password, salt)
const verifier = await aesGcmEncrypt(key, VERIFY_TEXT)
await setSetting('pwd_salt', salt)
await setSetting('pwd_verifier', verifier)
masterKey = key
}
export async function unlockVault(password: string): Promise<void> {
const salt = await getSetting('pwd_salt', '')
if (!salt) throw new Error('主密码尚未设置')
const key = await deriveKeyFromPassword(password, salt)
const verifier = await getSetting('pwd_verifier', '')
try {
await aesGcmDecrypt(key, verifier)
} catch {
throw new Error('主密码不正确')
}
masterKey = key
}
async function requireKey(): Promise<CryptoKey> {
if (!masterKey) throw new Error('密码库未解锁')
return masterKey
}
export async function encryptSecret(plain: string): Promise<string> {
const key = await requireKey()
return ENCRYPT_PREFIX + await aesGcmEncrypt(key, plain)
}
export async function decryptSecret(packed: string | null | undefined): Promise<string> {
if (!packed) return ''
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
const key = await requireKey()
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
}
// ---------- 账号密码 CRUD ----------
export async function listCredentials(): Promise<Credential[]> {
return db.credential.orderBy('system').toArray()
}
export interface CredentialInput {
system: string
account: string
password: string
remark: string
}
export async function saveCredential(input: CredentialInput, id?: number): Promise<void> {
const enc = await encryptSecret(input.password)
const base = {
system: input.system.trim(),
account: input.account.trim(),
password: enc,
remark: input.remark.trim()
}
if (id) {
await db.credential.update(id, { ...base, updated_at: nowLocalISO() })
} else {
await db.credential.add({ ...base, created_at: nowLocalISO(), updated_at: nowLocalISO() })
}
}
export async function deleteCredential(id: number): Promise<void> {
await db.credential.delete(id)
}
export async function revealCredentialPassword(id: number): Promise<string> {
const row = await db.credential.get(id)
if (!row) throw new Error('记录不存在')
return decryptSecret(row.password)
}
// ---------- 修改主密码:旧密钥解密全部记录后用新密钥重新加密 ----------
export async function changeMasterPassword(oldPwd: string, newPwd: string): Promise<number> {
if (!oldPwd) throw new Error('请输入旧主密码')
if (newPwd.length < 6) throw new Error('新主密码至少 6 位')
if (newPwd === oldPwd) throw new Error('新主密码不能与旧主密码相同')
const salt = await getSetting('pwd_salt', '')
if (!salt) throw new Error('密码库尚未初始化')
const oldKey = await deriveKeyFromPassword(oldPwd, salt)
const verifier = await getSetting('pwd_verifier', '')
try {
await aesGcmDecrypt(oldKey, verifier)
} catch {
throw new Error('旧主密码不正确')
}
const newKey = await deriveKeyFromPassword(newPwd, salt)
const rows = await db.credential.toArray()
let count = 0
for (const row of rows) {
if (row.id == null) continue
const plain = await decryptSecretWithKey(row.password, oldKey)
const enc = ENCRYPT_PREFIX + await aesGcmEncrypt(newKey, plain)
await db.credential.update(row.id, { password: enc })
count++
}
await setSetting('pwd_verifier', await aesGcmEncrypt(newKey, VERIFY_TEXT))
masterKey = newKey
return count
}
async function decryptSecretWithKey(packed: string | null | undefined, key: CryptoKey): Promise<string> {
if (!packed) return ''
if (!packed.startsWith(ENCRYPT_PREFIX)) return packed
return aesGcmDecrypt(key, packed.slice(ENCRYPT_PREFIX.length))
}
// 供备份模块使用:生成新的随机盐(占位,保持导出接口一致)
export function newSaltB64(): string {
return bufToB64(crypto.getRandomValues(new Uint8Array(16)).buffer)
}