143 lines
4.8 KiB
TypeScript
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)
|
|
}
|