feat: add password history feature with dialog and encryption handling

This commit is contained in:
shuaiplus
2026-04-18 02:05:01 +08:00
parent 7ebd12fa07
commit 38b33df719
6 changed files with 238 additions and 3 deletions
+61
View File
@@ -1,6 +1,7 @@
import { base64ToBytes, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData } from '../crypto';
import type {
Cipher,
CipherPasswordHistoryEntry,
Folder,
SessionState,
VaultDraft,
@@ -346,6 +347,61 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac);
}
async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
if (!Array.isArray(entries) || entries.length === 0) return null;
const out: CipherPasswordHistoryEntry[] = [];
for (const entry of entries) {
const rawPassword = String(entry?.password || '');
const plainPassword = entry?.decPassword ?? rawPassword;
const encryptedPassword = looksLikeCipherString(rawPassword)
? rawPassword
: await encryptTextValue(plainPassword, enc, mac);
if (!encryptedPassword) continue;
out.push({
password: encryptedPassword,
lastUsedDate: toIsoDateOrNow(entry?.lastUsedDate),
});
}
return out.length ? out : null;
}
async function buildUpdatedPasswordHistory(
cipher: Cipher | null,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<CipherPasswordHistoryEntry[] | null> {
const existingHistory = Array.isArray(cipher?.passwordHistory) ? cipher.passwordHistory : [];
const currentPassword = String(cipher?.login?.decPassword || '');
const nextPassword = String(draft.loginPassword || '');
const passwordChanged = currentPassword !== nextPassword;
const history = await encryptPasswordHistory(existingHistory, enc, mac);
if (!passwordChanged || !currentPassword.trim()) {
return history;
}
const encryptedCurrentPassword = await encryptTextValue(currentPassword, enc, mac);
if (!encryptedCurrentPassword) {
return history;
}
const nextEntries: CipherPasswordHistoryEntry[] = [
{
password: encryptedCurrentPassword,
lastUsedDate: new Date().toISOString(),
},
...(history || []),
];
return nextEntries.slice(0, 5);
}
async function encryptCustomFields(
fields: VaultDraftField[],
enc: Uint8Array,
@@ -473,6 +529,7 @@ async function buildCipherPayload(
const userMac = base64ToBytes(session.symMacKey);
const keys = await getCipherKeys(cipher, userEnc, userMac);
const type = Number(draft.type || cipher?.type || 1);
const now = new Date().toISOString();
const payload: Record<string, unknown> = {
type,
@@ -487,6 +544,7 @@ async function buildCipherPayload(
secureNote: null,
sshKey: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
};
if (cipher?.id) {
@@ -495,6 +553,7 @@ async function buildCipherPayload(
}
if (type === 1) {
const passwordChanged = String(cipher?.login?.decPassword || '') !== String(draft.loginPassword || '');
const existingFido2 =
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
? (cipher.login as any).fido2Credentials
@@ -508,9 +567,11 @@ async function buildCipherPayload(
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
passwordRevisionDate: passwordChanged ? now : existingLogin.passwordRevisionDate ?? null,
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
};
payload.passwordHistory = await buildUpdatedPasswordHistory(cipher, draft, keys.enc, keys.mac);
} else if (type === 3) {
payload.card = {
cardholderName: await encryptTextValue(draft.cardholderName, keys.enc, keys.mac),
+4
View File
@@ -462,6 +462,8 @@ const messages: Record<Locale, Record<string, string>> = {
txt_item_created: "Item created",
txt_item_deleted: "Item deleted",
txt_item_history: "Item History",
txt_password_history: "Password History",
txt_password_updated_value: "Password updated: {value}",
txt_item_name_is_required: "Item name is required.",
txt_item_updated: "Item updated",
txt_last_edited_value: "Last edited: {value}",
@@ -1075,6 +1077,8 @@ const zhCNOverrides: Record<string, string> = {
txt_notes: '备注',
txt_replace_device_name_with_note: '为这台设备设置自定义名称,不会改变系统识别到的设备类型。',
txt_item_history: '项目历史',
txt_password_history: '密码历史记录',
txt_password_updated_value: '密码新于: {value}',
txt_last_edited_value: '最后编辑:{value}',
txt_created_value: '创建于:{value}',
txt_username: '用户名',
+7 -1
View File
@@ -148,6 +148,12 @@ export interface CipherField {
decValue?: string;
}
export interface CipherPasswordHistoryEntry {
password?: string | null;
lastUsedDate?: string | null;
decPassword?: string;
}
export interface Cipher {
id: string;
type: number;
@@ -167,7 +173,7 @@ export interface Cipher {
identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null;
secureNote?: { type?: number | null } | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
passwordHistory?: CipherPasswordHistoryEntry[] | null;
fields?: CipherField[] | null;
decName?: string;
decNotes?: string;