mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
738 lines
27 KiB
TypeScript
738 lines
27 KiB
TypeScript
import { bytesToBase64, decryptBw, encryptBw, hkdfExpand, pbkdf2 } from '../crypto';
|
|
import { t, translateServerError } from '../i18n';
|
|
import type { AuthorizedDevice } from '../types';
|
|
import type {
|
|
Profile,
|
|
SessionState,
|
|
TokenError,
|
|
TokenSuccess,
|
|
} from '../types';
|
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
|
|
|
const SESSION_KEY = 'nodewarden.web.session.v4';
|
|
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
|
|
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
|
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
|
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
|
|
|
export interface PreloginResult {
|
|
hash: string;
|
|
masterKey: Uint8Array;
|
|
kdfIterations: number;
|
|
}
|
|
|
|
export interface PreloginKdfConfig {
|
|
kdfType: number;
|
|
kdfIterations: number;
|
|
kdfMemory: number | null;
|
|
kdfParallelism: number | null;
|
|
}
|
|
|
|
interface PersistedSessionState {
|
|
email: string;
|
|
authMode: 'token' | 'web-cookie';
|
|
}
|
|
|
|
interface RefreshFailure {
|
|
ok: false;
|
|
transient: boolean;
|
|
error: string;
|
|
}
|
|
|
|
interface RefreshSuccess {
|
|
ok: true;
|
|
token: TokenSuccess;
|
|
}
|
|
|
|
type RefreshResult = RefreshFailure | RefreshSuccess;
|
|
|
|
const pendingRefreshes = new Map<string, Promise<RefreshResult>>();
|
|
|
|
function randomHex(length: number): string {
|
|
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
|
}
|
|
|
|
function getOrCreateDeviceIdentifier(): string {
|
|
const current = (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
|
if (current) return current;
|
|
const next = `${randomHex(8)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(12)}`;
|
|
localStorage.setItem(DEVICE_IDENTIFIER_KEY, next);
|
|
return next;
|
|
}
|
|
|
|
function guessDeviceName(): string {
|
|
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
|
|
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
|
|
const browser = ua.includes('edg/') ? 'Edge' : ua.includes('chrome/') ? 'Chrome' : ua.includes('firefox/') ? 'Firefox' : ua.includes('safari/') ? 'Safari' : 'Browser';
|
|
const os = ua.includes('windows') ? 'Windows' : ua.includes('mac os') ? 'macOS' : ua.includes('linux') ? 'Linux' : ua.includes('android') ? 'Android' : ua.includes('iphone') || ua.includes('ipad') ? 'iOS' : platform || 'Unknown OS';
|
|
return `${browser} on ${os}`.slice(0, 128);
|
|
}
|
|
|
|
function getRememberTwoFactorToken(): string | null {
|
|
const token = (localStorage.getItem(TOTP_REMEMBER_TOKEN_KEY) || '').trim();
|
|
return token || null;
|
|
}
|
|
|
|
function saveRememberTwoFactorToken(token: string | undefined): void {
|
|
const normalized = String(token || '').trim();
|
|
if (!normalized) return;
|
|
localStorage.setItem(TOTP_REMEMBER_TOKEN_KEY, normalized);
|
|
}
|
|
|
|
function clearRememberTwoFactorToken(): void {
|
|
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
|
|
}
|
|
|
|
export function loadSession(): SessionState | null {
|
|
try {
|
|
const raw = localStorage.getItem(SESSION_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
|
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
|
return {
|
|
email: parsed.email,
|
|
authMode: 'web-cookie',
|
|
};
|
|
}
|
|
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
|
return {
|
|
accessToken: parsed.accessToken,
|
|
refreshToken: parsed.refreshToken,
|
|
email: parsed.email,
|
|
authMode: 'token',
|
|
};
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function saveSession(session: SessionState | null): void {
|
|
if (!session) {
|
|
localStorage.removeItem(SESSION_KEY);
|
|
return;
|
|
}
|
|
const persisted: PersistedSessionState = {
|
|
email: session.email,
|
|
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
|
};
|
|
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
|
}
|
|
|
|
export function loadProfileSnapshot(email?: string | null): Profile | null {
|
|
try {
|
|
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
|
if (!raw) return null;
|
|
const parsed = JSON.parse(raw) as Profile;
|
|
if (!parsed?.email) return null;
|
|
if (email && parsed.email !== email) return null;
|
|
const snapshot = stripProfileSecrets(parsed);
|
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
|
return snapshot;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function saveProfileSnapshot(profile: Profile | null): void {
|
|
if (!profile) return;
|
|
const nextSnapshot = stripProfileSecrets(profile);
|
|
try {
|
|
const rawExisting = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
|
|
if (rawExisting) {
|
|
const existing = stripProfileSecrets(JSON.parse(rawExisting) as Profile);
|
|
if (
|
|
existing
|
|
&& existing.email === nextSnapshot?.email
|
|
&& existing.role === 'admin'
|
|
&& nextSnapshot?.role !== 'admin'
|
|
) {
|
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify({
|
|
...nextSnapshot,
|
|
role: 'admin',
|
|
}));
|
|
return;
|
|
}
|
|
}
|
|
} catch {
|
|
// Fall back to writing the normalized snapshot below.
|
|
}
|
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(nextSnapshot));
|
|
}
|
|
|
|
export function clearProfileSnapshot(): void {
|
|
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
|
}
|
|
|
|
export function stripProfileSecrets(profile: Profile | null): Profile | null {
|
|
if (!profile) return null;
|
|
return {
|
|
id: String(profile.id || ''),
|
|
email: String(profile.email || ''),
|
|
name: String(profile.name || ''),
|
|
role: profile.role === 'admin' ? 'admin' : 'user',
|
|
masterPasswordHint: profile.masterPasswordHint ?? null,
|
|
publicKey: profile.publicKey ?? null,
|
|
key: '',
|
|
privateKey: null,
|
|
};
|
|
}
|
|
|
|
export function getCurrentDeviceIdentifier(): string {
|
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
|
}
|
|
|
|
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
|
|
const pre = await fetch('/identity/accounts/prelogin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: email.toLowerCase() }),
|
|
});
|
|
if (!pre.ok) throw new Error('prelogin failed');
|
|
const data = (await parseJson<{ kdfIterations?: number }>(pre)) || {};
|
|
const iterations = Number(data.kdfIterations || fallbackIterations);
|
|
const masterKey = await pbkdf2(password, email.toLowerCase(), iterations, 32);
|
|
const hash = await pbkdf2(masterKey, password, 1, 32);
|
|
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
|
}
|
|
|
|
export async function deriveLoginHashLocally(
|
|
email: string,
|
|
password: string,
|
|
fallbackIterations: number
|
|
): Promise<PreloginResult> {
|
|
const normalizedEmail = String(email || '').trim().toLowerCase();
|
|
const iterations = Number(fallbackIterations || 600000);
|
|
const masterKey = await pbkdf2(password, normalizedEmail, iterations, 32);
|
|
const hash = await pbkdf2(masterKey, password, 1, 32);
|
|
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
|
}
|
|
|
|
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
|
|
const normalized = String(email || '').trim().toLowerCase();
|
|
if (!normalized) throw new Error('Email is required');
|
|
const pre = await fetch('/identity/accounts/prelogin', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email: normalized }),
|
|
});
|
|
if (!pre.ok) throw new Error('prelogin failed');
|
|
const data = (await parseJson<{ kdf?: number; kdfIterations?: number; kdfMemory?: number | null; kdfParallelism?: number | null }>(pre)) || {};
|
|
return {
|
|
kdfType: Number(data.kdf ?? 0) || 0,
|
|
kdfIterations: Number(data.kdfIterations || fallbackIterations),
|
|
kdfMemory: data.kdfMemory == null ? null : Number(data.kdfMemory),
|
|
kdfParallelism: data.kdfParallelism == null ? null : Number(data.kdfParallelism),
|
|
};
|
|
}
|
|
|
|
export async function loginWithPassword(
|
|
email: string,
|
|
passwordHash: string,
|
|
options?: {
|
|
totpCode?: string;
|
|
rememberDevice?: boolean;
|
|
useRememberToken?: boolean;
|
|
}
|
|
): Promise<TokenSuccess | TokenError> {
|
|
const body = new URLSearchParams();
|
|
body.set('grant_type', 'password');
|
|
body.set('username', email.toLowerCase());
|
|
body.set('password', passwordHash);
|
|
body.set('scope', 'api offline_access');
|
|
body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
|
|
body.set('deviceName', guessDeviceName());
|
|
body.set('deviceType', '14');
|
|
|
|
const rememberedToken = options?.useRememberToken ? getRememberTwoFactorToken() : null;
|
|
if (rememberedToken) {
|
|
body.set('twoFactorProvider', '5');
|
|
body.set('twoFactorToken', rememberedToken);
|
|
} else if (options?.totpCode) {
|
|
body.set('twoFactorProvider', '0');
|
|
body.set('twoFactorToken', options.totpCode);
|
|
if (options.rememberDevice) {
|
|
body.set('twoFactorRemember', '1');
|
|
}
|
|
}
|
|
const resp = await fetch('/identity/connect/token', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
[WEB_SESSION_HEADER]: '1',
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
|
if (resp.ok) {
|
|
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
|
|
} else if (rememberedToken) {
|
|
clearRememberTwoFactorToken();
|
|
}
|
|
if (!resp.ok) return json;
|
|
return json;
|
|
}
|
|
|
|
function isTransientRefreshStatus(status: number): boolean {
|
|
return status === 0 || status === 429 || status >= 500;
|
|
}
|
|
|
|
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
|
const body = new URLSearchParams();
|
|
body.set('grant_type', 'refresh_token');
|
|
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
|
body.set('refresh_token', session.refreshToken);
|
|
}
|
|
try {
|
|
const resp = await fetch('/identity/connect/token', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
|
},
|
|
body: body.toString(),
|
|
});
|
|
if (!resp.ok) {
|
|
const json = await parseJson<TokenError>(resp);
|
|
return {
|
|
ok: false,
|
|
transient: isTransientRefreshStatus(resp.status),
|
|
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')),
|
|
};
|
|
}
|
|
const json = await parseJson<TokenSuccess>(resp);
|
|
if (!json?.access_token) {
|
|
return { ok: false, transient: false, error: t('txt_session_refresh_failed') };
|
|
}
|
|
return { ok: true, token: json };
|
|
} catch (error) {
|
|
return {
|
|
ok: false,
|
|
transient: true,
|
|
error: error instanceof Error ? error.message : 'Network error',
|
|
};
|
|
}
|
|
}
|
|
|
|
function refreshKey(session: SessionState): string {
|
|
if (session.authMode === 'web-cookie') return `web-cookie:${session.email || ''}`;
|
|
return `token:${session.refreshToken || ''}`;
|
|
}
|
|
|
|
function refreshAccessTokenOnce(session: SessionState): Promise<RefreshResult> {
|
|
const key = refreshKey(session);
|
|
const existing = pendingRefreshes.get(key);
|
|
if (existing) return existing;
|
|
|
|
const request = refreshAccessToken(session).finally(() => {
|
|
if (pendingRefreshes.get(key) === request) {
|
|
pendingRefreshes.delete(key);
|
|
}
|
|
});
|
|
pendingRefreshes.set(key, request);
|
|
return request;
|
|
}
|
|
|
|
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
|
|
const body = new URLSearchParams();
|
|
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
|
|
body.set('token', session.refreshToken);
|
|
}
|
|
await fetch('/identity/connect/revocation', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
|
},
|
|
body: body.toString(),
|
|
}).catch(() => undefined);
|
|
}
|
|
|
|
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, 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);
|
|
const macKey = await hkdfExpand(masterKey, 'mac', 32);
|
|
const sym = crypto.getRandomValues(new Uint8Array(64));
|
|
const encryptedVaultKey = await encryptBw(sym, encKey, macKey);
|
|
|
|
const keyPair = await crypto.subtle.generateKey(
|
|
{
|
|
name: 'RSA-OAEP',
|
|
modulusLength: 2048,
|
|
publicExponent: new Uint8Array([1, 0, 1]),
|
|
hash: 'SHA-1',
|
|
},
|
|
true,
|
|
['encrypt', 'decrypt']
|
|
);
|
|
const publicKey = new Uint8Array(await crypto.subtle.exportKey('spki', keyPair.publicKey));
|
|
const privateKey = new Uint8Array(await crypto.subtle.exportKey('pkcs8', keyPair.privateKey));
|
|
const encryptedPrivateKey = await encryptBw(privateKey, sym.slice(0, 32), sym.slice(32, 64));
|
|
|
|
const resp = await fetch('/api/accounts/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: email.toLowerCase(),
|
|
name,
|
|
masterPasswordHint: String(masterPasswordHint || '').trim() || undefined,
|
|
masterPasswordHash: bytesToBase64(masterHash),
|
|
key: encryptedVaultKey,
|
|
kdf: 0,
|
|
kdfIterations: fallbackIterations,
|
|
inviteCode: inviteCode || undefined,
|
|
keys: {
|
|
publicKey: bytesToBase64(publicKey),
|
|
encryptedPrivateKey,
|
|
},
|
|
}),
|
|
});
|
|
|
|
if (!resp.ok) {
|
|
const json = await parseJson<TokenError>(resp);
|
|
return { ok: false, message: translateServerError(json?.error_description || json?.error, t('txt_register_failed')) };
|
|
}
|
|
return { ok: true };
|
|
} catch (error) {
|
|
return { ok: false, message: error instanceof Error ? translateServerError(error.message, error.message) : t('txt_register_failed') };
|
|
}
|
|
}
|
|
|
|
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(translateServerError(body?.error_description || body?.error, t('txt_password_hint_load_failed')));
|
|
}
|
|
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 retryableRequest = async (headers: Headers): Promise<Response> => {
|
|
const maxAttempts = 3;
|
|
let lastError: unknown;
|
|
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
try {
|
|
const response = await fetch(input, { ...init, headers });
|
|
if (response.status !== 429 && (response.status < 500 || response.status >= 600)) {
|
|
return response;
|
|
}
|
|
lastError = new Error(`HTTP ${response.status}`);
|
|
if (attempt === maxAttempts - 1) {
|
|
return response;
|
|
}
|
|
} catch (error) {
|
|
lastError = error;
|
|
if (attempt === maxAttempts - 1) {
|
|
throw error;
|
|
}
|
|
}
|
|
const delayMs = 250 * (2 ** attempt) + Math.floor(Math.random() * 120);
|
|
await new Promise((resolve) => window.setTimeout(resolve, delayMs));
|
|
}
|
|
throw lastError instanceof Error ? lastError : new Error('Request failed');
|
|
};
|
|
|
|
const session = getSession();
|
|
if (!session?.accessToken) throw new Error('Unauthorized');
|
|
const headers = new Headers(init.headers || {});
|
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
|
|
|
let resp = await retryableRequest(headers);
|
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
|
|
|
const latest = getSession();
|
|
if (latest?.accessToken && latest.accessToken !== session.accessToken) {
|
|
const latestHeaders = new Headers(init.headers || {});
|
|
latestHeaders.set('Authorization', `Bearer ${latest.accessToken}`);
|
|
resp = await retryableRequest(latestHeaders);
|
|
if (resp.status !== 401) return resp;
|
|
}
|
|
|
|
const refreshSource = latest || session;
|
|
const refreshed = await refreshAccessTokenOnce(refreshSource);
|
|
if (!refreshed.ok) {
|
|
if (refreshed.transient) {
|
|
throw new Error(refreshed.error || t('txt_session_refresh_failed'));
|
|
}
|
|
setSession(null);
|
|
throw new Error(t('txt_session_refresh_failed'));
|
|
}
|
|
|
|
const nextSession: SessionState = {
|
|
...refreshSource,
|
|
accessToken: refreshed.token.access_token,
|
|
refreshToken: refreshed.token.refresh_token || refreshSource.refreshToken,
|
|
authMode: refreshed.token.web_session ? 'web-cookie' : (refreshSource.authMode || 'token'),
|
|
};
|
|
setSession(nextSession);
|
|
saveSession(nextSession);
|
|
|
|
const retryHeaders = new Headers(init.headers || {});
|
|
retryHeaders.set('Authorization', `Bearer ${nextSession.accessToken}`);
|
|
resp = await retryableRequest(retryHeaders);
|
|
return resp;
|
|
};
|
|
}
|
|
|
|
export async function getProfile(authedFetch: AuthedFetch): Promise<Profile> {
|
|
const resp = await authedFetch('/api/accounts/profile');
|
|
if (!resp.ok) throw new Error('Failed to load profile');
|
|
const body = await parseJson<Profile>(resp);
|
|
if (!body) throw new Error('Invalid 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(translateServerError(body?.error_description || body?.error, t('txt_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);
|
|
const keyBytes = await decryptBw(profileKey, encKey, macKey);
|
|
if (!keyBytes || keyBytes.length < 64) throw new Error('Invalid profile key');
|
|
return {
|
|
symEncKey: bytesToBase64(keyBytes.slice(0, 32)),
|
|
symMacKey: bytesToBase64(keyBytes.slice(32, 64)),
|
|
};
|
|
}
|
|
|
|
export async function changeMasterPassword(
|
|
authedFetch: AuthedFetch,
|
|
args: {
|
|
email: string;
|
|
currentPassword: string;
|
|
newPassword: string;
|
|
currentIterations: number;
|
|
profileKey: string;
|
|
}
|
|
): Promise<void> {
|
|
const current = await deriveLoginHash(args.email, args.currentPassword, args.currentIterations);
|
|
const oldEnc = await hkdfExpand(current.masterKey, 'enc', 32);
|
|
const oldMac = await hkdfExpand(current.masterKey, 'mac', 32);
|
|
const userSym = await decryptBw(args.profileKey, oldEnc, oldMac);
|
|
const nextMasterKey = await pbkdf2(args.newPassword, args.email, current.kdfIterations, 32);
|
|
const nextHash = await pbkdf2(nextMasterKey, args.newPassword, 1, 32);
|
|
const nextEnc = await hkdfExpand(nextMasterKey, 'enc', 32);
|
|
const nextMac = await hkdfExpand(nextMasterKey, 'mac', 32);
|
|
const newKey = await encryptBw(userSym.slice(0, 64), nextEnc, nextMac);
|
|
|
|
const resp = await authedFetch('/api/accounts/password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
currentPasswordHash: current.hash,
|
|
newMasterPasswordHash: bytesToBase64(nextHash),
|
|
newKey,
|
|
kdf: 0,
|
|
kdfIterations: current.kdfIterations,
|
|
}),
|
|
});
|
|
if (!resp.ok) throw new Error('Change master password failed');
|
|
}
|
|
|
|
export async function setTotp(
|
|
authedFetch: AuthedFetch,
|
|
payload: { enabled: boolean; token?: string; secret?: string; masterPasswordHash?: string }
|
|
): Promise<void> {
|
|
const resp = await authedFetch('/api/accounts/totp', {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_totp_update_failed')));
|
|
}
|
|
}
|
|
|
|
export async function verifyMasterPassword(
|
|
authedFetch: AuthedFetch,
|
|
masterPasswordHash: string
|
|
): Promise<void> {
|
|
const resp = await authedFetch('/api/accounts/verify-password', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ masterPasswordHash }),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
|
|
}
|
|
}
|
|
|
|
export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<number> {
|
|
const resp = await authedFetch('/api/accounts/revision-date');
|
|
if (!resp.ok) {
|
|
throw new Error('Failed to load revision date');
|
|
}
|
|
const body = await parseJson<number>(resp);
|
|
const stamp = Number(body);
|
|
if (!Number.isFinite(stamp) || stamp <= 0) {
|
|
throw new Error('Invalid revision date');
|
|
}
|
|
return stamp;
|
|
}
|
|
|
|
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
|
const resp = await authedFetch('/api/accounts/totp');
|
|
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
|
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
|
return { enabled: !!body.enabled };
|
|
}
|
|
|
|
export async function getTotpRecoveryCode(
|
|
authedFetch: AuthedFetch,
|
|
masterPasswordHash: string
|
|
): Promise<string> {
|
|
const resp = await authedFetch('/api/accounts/totp/recovery-code', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ masterPasswordHash }),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_recovery_code_failed')));
|
|
}
|
|
const body = (await parseJson<{ code?: string }>(resp)) || {};
|
|
return String(body.code || '');
|
|
}
|
|
|
|
export async function recoverTwoFactor(
|
|
email: string,
|
|
masterPasswordHash: string,
|
|
recoveryCode: string
|
|
): Promise<{ newRecoveryCode?: string }> {
|
|
const resp = await fetch('/identity/accounts/recover-2fa', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: email.toLowerCase().trim(),
|
|
masterPasswordHash,
|
|
recoveryCode,
|
|
}),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_recover_2fa_failed')));
|
|
}
|
|
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
|
|
}
|
|
|
|
export async function getAuthorizedDevices(authedFetch: AuthedFetch): Promise<AuthorizedDevice[]> {
|
|
const resp = await authedFetch('/api/devices/authorized');
|
|
if (!resp.ok) throw new Error(t('txt_load_devices_failed'));
|
|
const body = await parseJson<{ object: 'list'; data: AuthorizedDevice[] }>(resp);
|
|
return body?.data || [];
|
|
}
|
|
|
|
export async function revokeAuthorizedDeviceTrust(
|
|
authedFetch: AuthedFetch,
|
|
deviceIdentifier: string
|
|
): Promise<void> {
|
|
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
|
if (!resp.ok) throw new Error(t('txt_revoke_device_trust_failed'));
|
|
}
|
|
|
|
export async function trustAuthorizedDevicePermanently(
|
|
authedFetch: AuthedFetch,
|
|
deviceIdentifier: string
|
|
): Promise<void> {
|
|
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}/permanent`, { method: 'POST' });
|
|
if (!resp.ok) throw new Error(t('txt_trust_device_permanently_failed'));
|
|
}
|
|
|
|
export async function revokeAllAuthorizedDeviceTrust(authedFetch: AuthedFetch): Promise<void> {
|
|
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
|
|
if (!resp.ok) throw new Error(t('txt_revoke_all_device_trust_failed'));
|
|
}
|
|
|
|
export async function deleteAuthorizedDevice(
|
|
authedFetch: AuthedFetch,
|
|
deviceIdentifier: string
|
|
): Promise<void> {
|
|
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
|
|
if (!resp.ok) throw new Error(t('txt_remove_device_failed'));
|
|
}
|
|
|
|
export async function updateAuthorizedDeviceName(
|
|
authedFetch: AuthedFetch,
|
|
deviceIdentifier: string,
|
|
name: string
|
|
): Promise<void> {
|
|
const normalized = String(name || '').trim();
|
|
if (!normalized) throw new Error(t('txt_device_note_required'));
|
|
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}/name`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: normalized }),
|
|
});
|
|
if (!resp.ok) throw new Error(t('txt_update_device_note_failed'));
|
|
}
|
|
|
|
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise<void> {
|
|
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
|
|
if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed'));
|
|
}
|
|
|
|
export async function getApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
|
const resp = await authedFetch('/api/accounts/api-key', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ masterPasswordHash }),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_get_api_key_failed')));
|
|
}
|
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
|
return String(body.apiKey || '');
|
|
}
|
|
|
|
export async function rotateApiKey(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<string> {
|
|
const resp = await authedFetch('/api/accounts/rotate-api-key', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ masterPasswordHash }),
|
|
});
|
|
if (!resp.ok) {
|
|
const body = await parseJson<TokenError>(resp);
|
|
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_rotate_api_key_failed')));
|
|
}
|
|
const body = (await parseJson<{ apiKey?: string }>(resp)) || {};
|
|
return String(body.apiKey || '');
|
|
}
|