feat: add master password hint functionality

- Updated user model to include masterPasswordHint.
- Modified sync handler to return masterPasswordHint.
- Implemented password hint retrieval in public API.
- Enhanced user profile management to allow updating of password hint.
- Added UI components for displaying and editing password hint.
- Updated localization files for new password hint strings.
- Improved rate limiting for sensitive public requests.
- Adjusted database schema to accommodate master password hint.
This commit is contained in:
shuaiplus
2026-03-19 00:38:56 +08:00
parent 8bc43b8f0c
commit facd0ea5f7
26 changed files with 460 additions and 26 deletions
+37 -1
View File
@@ -201,11 +201,12 @@ export async function registerAccount(args: {
email: string;
name: string;
password: string;
masterPasswordHint?: string;
inviteCode?: string;
fallbackIterations: number;
}): Promise<{ ok: true } | { ok: false; message: string }> {
try {
const { email, name, password, inviteCode, fallbackIterations } = args;
const { email, name, password, masterPasswordHint, inviteCode, fallbackIterations } = args;
const masterKey = await pbkdf2(password, email, fallbackIterations, 32);
const masterHash = await pbkdf2(masterKey, password, 1, 32);
const encKey = await hkdfExpand(masterKey, 'enc', 32);
@@ -233,6 +234,7 @@ export async function registerAccount(args: {
body: JSON.stringify({
email: email.toLowerCase(),
name,
masterPasswordHint: String(masterPasswordHint || '').trim() || undefined,
masterPasswordHash: bytesToBase64(masterHash),
key: encryptedVaultKey,
kdf: 0,
@@ -255,6 +257,20 @@ export async function registerAccount(args: {
}
}
export async function getPasswordHint(email: string): Promise<{ masterPasswordHint: string | null }> {
const resp = await fetch('/api/accounts/password-hint', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: email.trim().toLowerCase() }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to load password hint');
}
const body = (await parseJson<{ masterPasswordHint?: string | null }>(resp)) || {};
return { masterPasswordHint: body.masterPasswordHint ?? null };
}
export function createAuthedFetch(getSession: () => SessionState | null, setSession: SessionSetter) {
return async function authedFetch(input: string, init: RequestInit = {}): Promise<Response> {
const session = getSession();
@@ -294,6 +310,26 @@ export async function getProfile(authedFetch: AuthedFetch): Promise<Profile> {
return body;
}
export async function updateProfile(
authedFetch: AuthedFetch,
payload: { masterPasswordHint: string }
): Promise<Profile> {
const resp = await authedFetch('/api/accounts/profile', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
masterPasswordHint: String(payload.masterPasswordHint || '').trim() || null,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Save profile failed');
}
const body = await parseJson<Profile>(resp);
if (!body) throw new Error('Invalid profile');
return body;
}
export async function unlockVaultKey(profileKey: string, masterKey: Uint8Array): Promise<{ symEncKey: string; symMacKey: string }> {
const encKey = await hkdfExpand(masterKey, 'enc', 32);
const macKey = await hkdfExpand(masterKey, 'mac', 32);
+3 -2
View File
@@ -149,8 +149,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
};
}
export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
const initial = readInitialAppBootstrapState();
export async function bootstrapAppSession(initial: InitialAppBootstrapState = readInitialAppBootstrapState()): Promise<BootstrapAppResult> {
const defaultKdfIterations = initial.defaultKdfIterations;
const jwtWarning = initial.jwtWarning;
@@ -309,6 +308,7 @@ export async function performRegistration(args: {
email: string;
name: string;
password: string;
masterPasswordHint: string;
inviteCode: string;
fallbackIterations: number;
}) {
@@ -316,6 +316,7 @@ export async function performRegistration(args: {
email: args.email.trim().toLowerCase(),
name: args.name.trim(),
password: args.password,
masterPasswordHint: args.masterPasswordHint.trim(),
inviteCode: args.inviteCode.trim(),
fallbackIterations: args.fallbackIterations,
});
+26
View File
@@ -458,6 +458,19 @@ const messages: Record<Locale, Record<string, string>> = {
txt_password: "Password",
txt_password_is_already_verified: "Password is already verified.",
txt_passwords_do_not_match: "Passwords do not match",
txt_password_hint: "Password Hint",
txt_password_hint_optional: "Password Hint (optional)",
txt_password_hint_placeholder: "A clue only you would understand",
txt_password_hint_register_placeholder: "This hint can be shown directly on the web login page.",
txt_password_hint_register_help: "This hint can be shown directly on the web login page. Do not include your master password, recovery code, or anything that can reveal it outright.",
txt_password_hint_login_help: "Forgot the master password? Reveal the hint you saved during registration.",
txt_password_hint_login_note: "Only a hint is shown here. It should help you remember the password, not expose it.",
txt_show_password_hint: "Show Password Hint",
txt_hide_password_hint: "Hide Password Hint",
txt_loading_password_hint: "Loading hint...",
txt_password_hint_not_set: "No password hint is available for this email.",
txt_password_hint_load_failed: "Failed to load password hint",
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
txt_phone: "Phone",
txt_please_input_email_and_password: "Please input email and password",
txt_please_input_master_password: "Please input master password",
@@ -1095,6 +1108,19 @@ const zhCNOverrides: Record<string, string> = {
txt_opera_extension: 'Opera 扩展',
txt_password_is_already_verified: '密码已验证',
txt_passwords_do_not_match: '两次输入的密码不一致',
txt_password_hint: '密码提示',
txt_password_hint_optional: '密码提示(可选)',
txt_password_hint_placeholder: '写一句只有你自己看得懂的线索',
txt_password_hint_register_placeholder: '这个提示可以在网页登录页直接显示。',
txt_password_hint_register_help: '这个提示可以在网页登录页直接显示。不要填写主密码、恢复代码,或任何能直接暴露密码的信息。',
txt_password_hint_login_help: '忘记主密码时,可以查看注册时保存的提示。',
txt_password_hint_login_note: '这里只会显示提示语,不会显示你的主密码本身。',
txt_show_password_hint: '查看密码提示',
txt_hide_password_hint: '隐藏密码提示',
txt_loading_password_hint: '正在加载提示...',
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
txt_password_hint_load_failed: '加载密码提示失败',
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
txt_phone: '电话',
txt_please_input_email_and_password: '请输入邮箱和密码',
txt_please_input_master_password: '请输入主密码',
+1
View File
@@ -13,6 +13,7 @@ export interface Profile {
email: string;
name: string;
key: string;
masterPasswordHint?: string | null;
privateKey?: string | null;
publicKey?: string | null;
role: 'admin' | 'user';