mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
Merge branch 'main' of https://github.com/shuaiplus/nodewarden
This commit is contained in:
@@ -192,6 +192,7 @@ async function executeConfiguredBackup(
|
|||||||
});
|
});
|
||||||
const archive = await buildBackupArchive(env, now, {
|
const archive = await buildBackupArchive(env, now, {
|
||||||
includeAttachments: destination.includeAttachments,
|
includeAttachments: destination.includeAttachments,
|
||||||
|
timeZone: destination.schedule.timezone,
|
||||||
progress: progress
|
progress: progress
|
||||||
? async (event) => {
|
? async (event) => {
|
||||||
if (event.step === 'archive_ready') {
|
if (event.step === 'archive_ready') {
|
||||||
|
|||||||
@@ -63,11 +63,10 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher {
|
|||||||
|
|
||||||
export function normalizeCipherLoginForStorage(login: any): any {
|
export function normalizeCipherLoginForStorage(login: any): any {
|
||||||
if (!login || typeof login !== 'object') return login ?? null;
|
if (!login || typeof login !== 'object') return login ?? null;
|
||||||
|
return {
|
||||||
const rest = { ...login };
|
...login,
|
||||||
const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join('');
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||||
delete (rest as Record<string, unknown>)[passkeyField];
|
};
|
||||||
return rest;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||||
|
const WEB_REFRESH_COOKIE = 'nodewarden_web_refresh';
|
||||||
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
// Android client (2026.2.x) deserializes TwoFactorProviders2 keys with -1 for recovery code.
|
||||||
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
// Keep request parsing backward-compatible with historical provider values (8 / 100).
|
||||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1';
|
||||||
@@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shouldUseWebSession(request: Request): boolean {
|
||||||
|
return String(request.headers.get('X-NodeWarden-Web-Session') || '').trim() === '1';
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookieValue(request: Request, name: string): string | null {
|
||||||
|
const rawCookie = String(request.headers.get('Cookie') || '').trim();
|
||||||
|
if (!rawCookie) return null;
|
||||||
|
for (const part of rawCookie.split(';')) {
|
||||||
|
const [key, ...rest] = part.trim().split('=');
|
||||||
|
if (key !== name) continue;
|
||||||
|
const value = rest.join('=').trim();
|
||||||
|
return value ? decodeURIComponent(value) : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRefreshCookie(request: Request, refreshToken: string, maxAgeSeconds: number): string {
|
||||||
|
const isHttps = new URL(request.url).protocol === 'https:';
|
||||||
|
const parts = [
|
||||||
|
`${WEB_REFRESH_COOKIE}=${encodeURIComponent(refreshToken)}`,
|
||||||
|
'Path=/identity/connect',
|
||||||
|
'HttpOnly',
|
||||||
|
'SameSite=Strict',
|
||||||
|
`Max-Age=${Math.max(0, Math.floor(maxAgeSeconds))}`,
|
||||||
|
];
|
||||||
|
if (isHttps) parts.push('Secure');
|
||||||
|
return parts.join('; ');
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildClearedRefreshCookie(request: Request): string {
|
||||||
|
return buildRefreshCookie(request, '', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function withWebRefreshCookie(request: Request, response: Response, refreshToken: string | null): Response {
|
||||||
|
const headers = new Headers(response.headers);
|
||||||
|
headers.append(
|
||||||
|
'Set-Cookie',
|
||||||
|
refreshToken
|
||||||
|
? buildRefreshCookie(request, refreshToken, Math.floor(LIMITS.auth.refreshTokenTtlMs / 1000))
|
||||||
|
: buildClearedRefreshCookie(request)
|
||||||
|
);
|
||||||
|
return new Response(response.body, {
|
||||||
|
status: response.status,
|
||||||
|
statusText: response.statusText,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function buildPreloginResponse(
|
function buildPreloginResponse(
|
||||||
email: string,
|
email: string,
|
||||||
kdfType: number,
|
kdfType: number,
|
||||||
@@ -283,7 +332,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: refreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
|
||||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
@@ -305,7 +354,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, refreshToken)
|
||||||
|
: baseResponse;
|
||||||
|
|
||||||
} else if (grantType === 'send_access') {
|
} else if (grantType === 'send_access') {
|
||||||
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute);
|
||||||
@@ -371,14 +423,21 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Refresh token
|
// Refresh token
|
||||||
const refreshToken = body.refresh_token;
|
const refreshToken = String(body.refresh_token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? parseCookieValue(request, WEB_REFRESH_COOKIE)
|
||||||
|
: null
|
||||||
|
);
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
return identityErrorResponse('Refresh token is required', 'invalid_request', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await auth.refreshAccessToken(refreshToken);
|
const result = await auth.refreshAccessToken(refreshToken);
|
||||||
if (!result) {
|
if (!result) {
|
||||||
return identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
const invalidResponse = identityErrorResponse('Invalid refresh token', 'invalid_grant', 400);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, invalidResponse, null)
|
||||||
|
: invalidResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep a short overlap window for old refresh token to absorb
|
// Keep a short overlap window for old refresh token to absorb
|
||||||
@@ -395,7 +454,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
access_token: accessToken,
|
access_token: accessToken,
|
||||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||||
token_type: 'Bearer',
|
token_type: 'Bearer',
|
||||||
refresh_token: newRefreshToken,
|
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }),
|
||||||
Key: user.key,
|
Key: user.key,
|
||||||
PrivateKey: user.privateKey,
|
PrivateKey: user.privateKey,
|
||||||
AccountKeys: buildAccountKeys(user),
|
AccountKeys: buildAccountKeys(user),
|
||||||
@@ -416,7 +475,10 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
|||||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||||
};
|
};
|
||||||
|
|
||||||
return jsonResponse(response);
|
const baseResponse = jsonResponse(response);
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, newRefreshToken)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|
||||||
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
return identityErrorResponse('Unsupported grant type', 'unsupported_grant_type', 400);
|
||||||
@@ -470,10 +532,17 @@ export async function handleRevocation(request: Request, env: Env): Promise<Resp
|
|||||||
return new Response(null, { status: 200 });
|
return new Response(null, { status: 200 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = String(body.token || '').trim();
|
const token = String(body.token || '').trim() || (
|
||||||
|
shouldUseWebSession(request)
|
||||||
|
? (parseCookieValue(request, WEB_REFRESH_COOKIE) || '')
|
||||||
|
: ''
|
||||||
|
);
|
||||||
if (token) {
|
if (token) {
|
||||||
await storage.deleteRefreshToken(token);
|
await storage.deleteRefreshToken(token);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Response(null, { status: 200 });
|
const baseResponse = new Response(null, { status: 200 });
|
||||||
|
return shouldUseWebSession(request)
|
||||||
|
? withWebRefreshCookie(request, baseResponse, null)
|
||||||
|
: baseResponse;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
|||||||
})) || null,
|
})) || null,
|
||||||
totp: c.login.totp ?? null,
|
totp: c.login.totp ?? null,
|
||||||
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
autofillOnPageLoad: c.login.autofillOnPageLoad ?? null,
|
||||||
|
fido2Credentials: Array.isArray(c.login.fido2Credentials) ? c.login.fido2Credentials : null,
|
||||||
uri: c.login.uri ?? null,
|
uri: c.login.uri ?? null,
|
||||||
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
passwordRevisionDate: c.login.passwordRevisionDate ?? null,
|
||||||
} : null,
|
} : null,
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export interface BackupFileIntegrityCheckResult {
|
|||||||
export interface BuildBackupArchiveOptions {
|
export interface BuildBackupArchiveOptions {
|
||||||
includeAttachments?: boolean;
|
includeAttachments?: boolean;
|
||||||
progress?: BackupArchiveBuildProgressReporter;
|
progress?: BackupArchiveBuildProgressReporter;
|
||||||
|
timeZone?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BackupArchiveBuildProgressEvent {
|
export interface BackupArchiveBuildProgressEvent {
|
||||||
@@ -93,17 +94,30 @@ async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
|||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date = new Date(), checksumPrefix: string | null = null): string {
|
function getDateParts(date: Date, timeZone: string): string {
|
||||||
const parts = [
|
const formatter = new Intl.DateTimeFormat('en-CA', {
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
timeZone,
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
year: 'numeric',
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
month: '2-digit',
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
day: '2-digit',
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
hour: '2-digit',
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
minute: '2-digit',
|
||||||
];
|
second: '2-digit',
|
||||||
|
hourCycle: 'h23',
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const pick = (type: string): string => parts.find((part) => part.type === type)?.value || '';
|
||||||
|
return `${pick('year')}${pick('month')}${pick('day')}_${pick('hour')}${pick('minute')}${pick('second')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBackupFileNameInTimeZone(
|
||||||
|
date: Date = new Date(),
|
||||||
|
checksumPrefix: string | null = null,
|
||||||
|
timeZone: string = 'UTC'
|
||||||
|
): string {
|
||||||
|
const parts = getDateParts(date, timeZone);
|
||||||
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
const suffix = checksumPrefix ? `_${checksumPrefix}` : '';
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}${suffix}.zip`;
|
return `nodewarden_backup_${parts}${suffix}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
export function extractBackupFileChecksumPrefix(fileName: string): string | null {
|
||||||
@@ -398,7 +412,8 @@ export async function buildBackupArchive(
|
|||||||
});
|
});
|
||||||
const bytes = zipSync(createZipEntries(files));
|
const bytes = zipSync(createZipEntries(files));
|
||||||
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
const fileHashPrefix = (await sha256Hex(bytes)).slice(0, BACKUP_FILE_HASH_PREFIX_LENGTH);
|
||||||
const fileName = buildBackupFileName(date, fileHashPrefix);
|
const backupTimeZone = options.timeZone || 'UTC';
|
||||||
|
const fileName = buildBackupFileNameInTimeZone(date, fileHashPrefix, backupTimeZone);
|
||||||
await options.progress?.({
|
await options.progress?.({
|
||||||
step: 'archive_ready',
|
step: 'archive_ready',
|
||||||
fileName,
|
fileName,
|
||||||
|
|||||||
+3
-1
@@ -94,6 +94,7 @@ export interface CipherLogin {
|
|||||||
uris: CipherLoginUri[] | null;
|
uris: CipherLoginUri[] | null;
|
||||||
totp: string | null;
|
totp: string | null;
|
||||||
autofillOnPageLoad: boolean | null;
|
autofillOnPageLoad: boolean | null;
|
||||||
|
fido2Credentials: any[] | null;
|
||||||
uri: string | null;
|
uri: string | null;
|
||||||
passwordRevisionDate: string | null;
|
passwordRevisionDate: string | null;
|
||||||
}
|
}
|
||||||
@@ -346,7 +347,8 @@ export interface TokenResponse {
|
|||||||
access_token: string;
|
access_token: string;
|
||||||
expires_in: number;
|
expires_in: number;
|
||||||
token_type: string;
|
token_type: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
|
web_session?: boolean;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
Key: string;
|
Key: string;
|
||||||
PrivateKey: string | null;
|
PrivateKey: string | null;
|
||||||
|
|||||||
+38
-7
@@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-Request-Email',
|
'X-Request-Email',
|
||||||
'X-Device-Identifier',
|
'X-Device-Identifier',
|
||||||
'X-Device-Name',
|
'X-Device-Name',
|
||||||
|
'X-NodeWarden-Web-Session',
|
||||||
];
|
];
|
||||||
|
|
||||||
function getAllowedOrigin(request: Request): string | null {
|
function isExtensionOrigin(origin: string): boolean {
|
||||||
|
return (
|
||||||
|
origin.startsWith('chrome-extension://')
|
||||||
|
|| origin.startsWith('moz-extension://')
|
||||||
|
|| origin.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWildcardCorsPath(path: string): boolean {
|
||||||
|
return (
|
||||||
|
path.startsWith('/icons/')
|
||||||
|
|| path === '/config'
|
||||||
|
|| path === '/api/config'
|
||||||
|
|| path === '/api/version'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const origin = request.headers.get('Origin');
|
||||||
if (!origin) return '*';
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
return origin;
|
return { allowOrigin: '*', allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (!origin) {
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
|
}
|
||||||
|
if (origin === url.origin) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(origin)) {
|
||||||
|
return { allowOrigin: origin, allowCredentials: false };
|
||||||
|
}
|
||||||
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request): Record<string, string> {
|
||||||
@@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
'Access-Control-Allow-Headers': allowHeaders.join(', '),
|
||||||
'Access-Control-Expose-Headers': '*',
|
'Access-Control-Expose-Headers': '*',
|
||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
'Access-Control-Allow-Private-Network': 'true',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const allowedOrigin = getAllowedOrigin(request);
|
const corsPolicy = getCorsPolicy(request);
|
||||||
if (allowedOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = allowedOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
|
if (corsPolicy.allowCredentials) {
|
||||||
headers['Access-Control-Allow-Credentials'] = 'true';
|
headers['Access-Control-Allow-Credentials'] = 'true';
|
||||||
|
}
|
||||||
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
headers['Vary'] = 'Origin, Access-Control-Request-Headers';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+66
-14
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
|
|||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
getAuthorizedDevices,
|
getAuthorizedDevices,
|
||||||
|
clearProfileSnapshot,
|
||||||
getCurrentDeviceIdentifier,
|
getCurrentDeviceIdentifier,
|
||||||
getPasswordHint,
|
getPasswordHint,
|
||||||
|
loadProfileSnapshot,
|
||||||
|
saveProfileSnapshot,
|
||||||
|
revokeCurrentSession,
|
||||||
getTotpStatus,
|
getTotpStatus,
|
||||||
saveSession,
|
saveSession,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
@@ -39,6 +43,7 @@ import {
|
|||||||
performRecoverTwoFactorLogin,
|
performRecoverTwoFactorLogin,
|
||||||
performRegistration,
|
performRegistration,
|
||||||
performTotpLogin,
|
performTotpLogin,
|
||||||
|
hydrateLockedSession,
|
||||||
performUnlock,
|
performUnlock,
|
||||||
type JwtUnsafeReason,
|
type JwtUnsafeReason,
|
||||||
type PendingTotp,
|
type PendingTotp,
|
||||||
@@ -53,6 +58,17 @@ import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
|||||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||||
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
import type { AppPhase, Cipher, Folder as VaultFolder, Profile, Send, SessionState } from '@/lib/types';
|
||||||
|
|
||||||
|
function isBackupProgressDetail(value: unknown): value is BackupProgressDetail {
|
||||||
|
if (!value || typeof value !== 'object') return false;
|
||||||
|
const detail = value as Record<string, unknown>;
|
||||||
|
const operation = detail.operation;
|
||||||
|
return (
|
||||||
|
(operation === 'backup-restore' || operation === 'backup-export' || operation === 'backup-remote-run')
|
||||||
|
&& typeof detail.step === 'string'
|
||||||
|
&& typeof detail.fileName === 'string'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const IMPORT_ROUTE = '/backup/import-export';
|
const IMPORT_ROUTE = '/backup/import-export';
|
||||||
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
const IMPORT_ROUTE_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
|
||||||
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
const IMPORT_ROUTE_ALIASES: ReadonlySet<string> = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE));
|
||||||
@@ -124,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
|
|||||||
export default function App() {
|
export default function App() {
|
||||||
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
|
||||||
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
|
||||||
|
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
|
||||||
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
|
||||||
const [location, navigate] = useLocation();
|
const [location, navigate] = useLocation();
|
||||||
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
|
||||||
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
|
||||||
const [profile, setProfile] = useState<Profile | null>(null);
|
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
|
||||||
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
|
||||||
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
|
||||||
|
|
||||||
@@ -161,6 +178,7 @@ export default function App() {
|
|||||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||||
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
|
||||||
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
|
||||||
|
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
|
||||||
|
|
||||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||||
const [mobileLayout, setMobileLayout] = useState(false);
|
const [mobileLayout, setMobileLayout] = useState(false);
|
||||||
@@ -262,6 +280,16 @@ export default function App() {
|
|||||||
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
||||||
}, [themePreference]);
|
}, [themePreference]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
saveProfileSnapshot(profile);
|
||||||
|
}, [profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase === 'locked' && profile?.key && session) {
|
||||||
|
setUnlockPreparing(false);
|
||||||
|
}
|
||||||
|
}, [phase, profile, session]);
|
||||||
|
|
||||||
useEffect(() => installMagneticUiFeedback(), []);
|
useEffect(() => installMagneticUiFeedback(), []);
|
||||||
|
|
||||||
function handleToggleTheme() {
|
function handleToggleTheme() {
|
||||||
@@ -323,6 +351,7 @@ export default function App() {
|
|||||||
setSession(boot.session);
|
setSession(boot.session);
|
||||||
setProfile(boot.profile);
|
setProfile(boot.profile);
|
||||||
setPhase(boot.phase);
|
setPhase(boot.phase);
|
||||||
|
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
|
||||||
})();
|
})();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
@@ -330,9 +359,34 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, [initialBootstrap]);
|
}, [initialBootstrap]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'locked' || !session) return;
|
||||||
|
let cancelled = false;
|
||||||
|
void (async () => {
|
||||||
|
const result = await hydrateLockedSession(session, profile);
|
||||||
|
if (cancelled) return;
|
||||||
|
if (!result.session) {
|
||||||
|
setSession(null);
|
||||||
|
setProfile(null);
|
||||||
|
setUnlockPreparing(false);
|
||||||
|
setPhase('login');
|
||||||
|
if (location !== '/login') navigate('/login');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSession(result.session);
|
||||||
|
if (result.profile) {
|
||||||
|
setProfile(result.profile);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [phase, session?.email, location, navigate]);
|
||||||
|
|
||||||
async function finalizeLogin(login: CompletedLogin) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
setSession(login.session);
|
setSession(login.session);
|
||||||
setProfile(login.profile);
|
setProfile(login.profile);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
@@ -517,6 +571,7 @@ export default function App() {
|
|||||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/lock') navigate('/vault');
|
if (location === '/' || location === '/lock') navigate('/vault');
|
||||||
pushToast('success', t('txt_unlocked'));
|
pushToast('success', t('txt_unlocked'));
|
||||||
@@ -533,14 +588,18 @@ export default function App() {
|
|||||||
delete nextSession.symEncKey;
|
delete nextSession.symEncKey;
|
||||||
delete nextSession.symMacKey;
|
delete nextSession.symMacKey;
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPhase('locked');
|
setPhase('locked');
|
||||||
navigate('/lock');
|
navigate('/lock');
|
||||||
}
|
}
|
||||||
|
|
||||||
function logoutNow() {
|
function logoutNow() {
|
||||||
|
void revokeCurrentSession(sessionRef.current);
|
||||||
setConfirm(null);
|
setConfirm(null);
|
||||||
setSession(null);
|
setSession(null);
|
||||||
|
clearProfileSnapshot();
|
||||||
setProfile(null);
|
setProfile(null);
|
||||||
|
setUnlockPreparing(false);
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setPhase('login');
|
setPhase('login');
|
||||||
navigate('/login');
|
navigate('/login');
|
||||||
@@ -871,9 +930,11 @@ export default function App() {
|
|||||||
|
|
||||||
const connect = () => {
|
const connect = () => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
|
const accessToken = session.accessToken;
|
||||||
|
if (!accessToken) return;
|
||||||
try {
|
try {
|
||||||
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
const hubUrl = new URL('/notifications/hub', window.location.origin);
|
||||||
hubUrl.searchParams.set('access_token', session.accessToken);
|
hubUrl.searchParams.set('access_token', accessToken);
|
||||||
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
socket = new WebSocket(hubUrl.toString());
|
socket = new WebSocket(hubUrl.toString());
|
||||||
} catch {
|
} catch {
|
||||||
@@ -927,17 +988,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) {
|
||||||
const payload = frame.arguments?.[0]?.Payload;
|
const payload = frame.arguments?.[0]?.Payload;
|
||||||
if (
|
if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload);
|
||||||
payload
|
|
||||||
&& typeof payload === 'object'
|
|
||||||
&& (
|
|
||||||
payload.operation === 'backup-restore'
|
|
||||||
|| payload.operation === 'backup-export'
|
|
||||||
|| payload.operation === 'backup-remote-run'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
dispatchBackupProgress(payload as BackupProgressDetail);
|
|
||||||
}
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue;
|
||||||
@@ -1197,7 +1248,8 @@ export default function App() {
|
|||||||
<AuthViews
|
<AuthViews
|
||||||
mode={phase}
|
mode={phase}
|
||||||
pendingAction={pendingAuthAction}
|
pendingAction={pendingAuthAction}
|
||||||
unlockReady={!!profile}
|
unlockReady={!!profile?.key && !!session}
|
||||||
|
unlockPreparing={unlockPreparing}
|
||||||
loginValues={loginValues}
|
loginValues={loginValues}
|
||||||
registerValues={registerValues}
|
registerValues={registerValues}
|
||||||
unlockPassword={unlockPassword}
|
unlockPassword={unlockPassword}
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
return status || '-';
|
return status || '-';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeToggleableStatus = (status: string): 'active' | 'banned' | null => {
|
||||||
|
const normalized = String(status || '').toLowerCase();
|
||||||
|
if (normalized === 'active' || normalized === 'banned') return normalized;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="stack">
|
<div className="stack">
|
||||||
<section className="card">
|
<section className="card">
|
||||||
@@ -55,7 +61,9 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{props.users.map((user) => (
|
{props.users.map((user) => {
|
||||||
|
const toggleableStatus = normalizeToggleableStatus(user.status);
|
||||||
|
return (
|
||||||
<tr key={user.id}>
|
<tr key={user.id}>
|
||||||
<td data-label={t('txt_email')}>{user.email}</td>
|
<td data-label={t('txt_email')}>{user.email}</td>
|
||||||
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
<td data-label={t('txt_name')}>{user.name || t('txt_dash')}</td>
|
||||||
@@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
disabled={user.id === props.currentUserId}
|
disabled={user.id === props.currentUserId || !toggleableStatus}
|
||||||
onClick={() => void props.onToggleUserStatus(user.id, user.status)}
|
onClick={() => {
|
||||||
|
if (!toggleableStatus) return;
|
||||||
|
void props.onToggleUserStatus(user.id, toggleableStatus);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
{user.status === 'active' ? <UserX size={14} className="btn-icon" /> : <UserCheck size={14} className="btn-icon" />}
|
||||||
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
{user.status === 'active' ? t('txt_ban') : t('txt_unban')}
|
||||||
@@ -81,7 +92,8 @@ export default function AdminPage(props: AdminPageProps) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ interface AuthViewsProps {
|
|||||||
mode: 'login' | 'register' | 'locked';
|
mode: 'login' | 'register' | 'locked';
|
||||||
pendingAction: 'login' | 'register' | 'unlock' | null;
|
pendingAction: 'login' | 'register' | 'unlock' | null;
|
||||||
unlockReady: boolean;
|
unlockReady: boolean;
|
||||||
|
unlockPreparing: boolean;
|
||||||
loginValues: LoginValues;
|
loginValues: LoginValues;
|
||||||
registerValues: RegisterValues;
|
registerValues: RegisterValues;
|
||||||
unlockPassword: string;
|
unlockPassword: string;
|
||||||
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
|
|||||||
type="button"
|
type="button"
|
||||||
className="auth-link-btn"
|
className="auth-link-btn"
|
||||||
onClick={props.onShowLockedPasswordHint}
|
onClick={props.onShowLockedPasswordHint}
|
||||||
disabled={unlockBusy}
|
disabled={unlockBusy || props.unlockPreparing}
|
||||||
>
|
>
|
||||||
{t('txt_show_password_hint')}
|
{t('txt_show_password_hint')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
|
{props.unlockPreparing ? (
|
||||||
|
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||||
|
) : null}
|
||||||
|
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
|
||||||
<Unlock size={16} className="btn-icon" />
|
<Unlock size={16} className="btn-icon" />
|
||||||
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
|
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||||
</button>
|
</button>
|
||||||
<div className="or">{t('txt_or')}</div>
|
<div className="or">{t('txt_or')}</div>
|
||||||
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
|
||||||
|
|||||||
@@ -625,7 +625,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
setSettings(result.settings);
|
setSettings(result.settings);
|
||||||
setSelectedDestinationId(selectedDestination.id);
|
setSelectedDestinationId(selectedDestination.id);
|
||||||
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
|
||||||
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.fileName }));
|
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed');
|
||||||
setLocalError(message);
|
setLocalError(message);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'preact/hooks';
|
import { useEffect, useState } from 'preact/hooks';
|
||||||
import { Download, Eye, Lock } from 'lucide-preact';
|
import { Download, Eye, Lock } from 'lucide-preact';
|
||||||
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
import { accessPublicSend, accessPublicSendFile, decryptPublicSend, decryptPublicSendFileBytes } from '@/lib/api/send';
|
||||||
|
import { toBufferSource } from '@/lib/crypto';
|
||||||
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
import { downloadBytesAsFile, readResponseBytesWithProgress } from '@/lib/download';
|
||||||
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
import StandalonePageFrame from '@/components/StandalonePageFrame';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
@@ -61,13 +62,13 @@ export default function PublicSendPage(props: PublicSendPageProps) {
|
|||||||
if (props.keyPart) {
|
if (props.keyPart) {
|
||||||
try {
|
try {
|
||||||
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
const decryptedBytes = await decryptPublicSendFileBytes(encryptedBytes, props.keyPart);
|
||||||
blob = new Blob([decryptedBytes as unknown as BlobPart], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(decryptedBytes)], { type: 'application/octet-stream' });
|
||||||
} catch {
|
} catch {
|
||||||
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
// Legacy compatibility: early web-created file sends uploaded plaintext bytes.
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
blob = new Blob([encryptedBytes], { type: 'application/octet-stream' });
|
blob = new Blob([toBufferSource(encryptedBytes)], { type: 'application/octet-stream' });
|
||||||
}
|
}
|
||||||
downloadBytesAsFile(
|
downloadBytesAsFile(
|
||||||
new Uint8Array(await blob.arrayBuffer()),
|
new Uint8Array(await blob.arrayBuffer()),
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { JSX } from 'preact';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
import { Clipboard, Globe, GripVertical } from 'lucide-preact';
|
||||||
import {
|
import {
|
||||||
@@ -96,6 +97,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.cipher.id,
|
id: props.cipher.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -113,7 +115,7 @@ function SortableTotpRow(props: SortableTotpRowProps) {
|
|||||||
className="btn btn-secondary small totp-drag-btn"
|
className="btn btn-secondary small totp-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
draftFromCipher,
|
draftFromCipher,
|
||||||
buildCipherDuplicateSignature,
|
buildCipherDuplicateSignature,
|
||||||
firstCipherUri,
|
firstCipherUri,
|
||||||
|
firstPasskeyCreationTime,
|
||||||
isCipherVisibleInArchive,
|
isCipherVisibleInArchive,
|
||||||
isCipherVisibleInNormalVault,
|
isCipherVisibleInNormalVault,
|
||||||
isCipherVisibleInTrash,
|
isCipherVisibleInTrash,
|
||||||
@@ -103,6 +104,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [repromptOpen, setRepromptOpen] = useState(false);
|
const [repromptOpen, setRepromptOpen] = useState(false);
|
||||||
const [repromptPassword, setRepromptPassword] = useState('');
|
const [repromptPassword, setRepromptPassword] = useState('');
|
||||||
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
const [repromptApprovedCipherId, setRepromptApprovedCipherId] = useState<string | null>(null);
|
||||||
|
const [pendingDeletePasskeyIndex, setPendingDeletePasskeyIndex] = useState<number | null>(null);
|
||||||
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
const [isMobileLayout, setIsMobileLayout] = useState(getInitialIsMobileLayout);
|
||||||
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
const [mobilePanel, setMobilePanel] = useState<'list' | 'detail' | 'edit'>('list');
|
||||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||||
@@ -444,6 +446,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setLocalError('');
|
setLocalError('');
|
||||||
setAttachmentQueue([]);
|
setAttachmentQueue([]);
|
||||||
setRemovedAttachmentIds({});
|
setRemovedAttachmentIds({});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
if (isMobileLayout) setMobilePanel(returnToDetail ? 'detail' : 'list');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -451,6 +454,18 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
setDraft((prev) => (prev ? { ...prev, ...patch } : prev));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function confirmDeleteLoginPasskey(): void {
|
||||||
|
if (pendingDeletePasskeyIndex == null) return;
|
||||||
|
setDraft((prev) => {
|
||||||
|
if (!prev) return prev;
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
loginFido2Credentials: prev.loginFido2Credentials.filter((_, index) => index !== pendingDeletePasskeyIndex),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
setPendingDeletePasskeyIndex(null);
|
||||||
|
}
|
||||||
|
|
||||||
async function seedSshDefaults(force = false): Promise<void> {
|
async function seedSshDefaults(force = false): Promise<void> {
|
||||||
const ticket = ++sshSeedTicketRef.current;
|
const ticket = ++sshSeedTicketRef.current;
|
||||||
try {
|
try {
|
||||||
@@ -946,6 +961,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
onUpdateDraftLoginUri={updateDraftLoginUri}
|
onUpdateDraftLoginUri={updateDraftLoginUri}
|
||||||
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
onUpdateDraftLoginUriMatch={updateDraftLoginUriMatch}
|
||||||
onReorderDraftLoginUri={reorderDraftLoginUri}
|
onReorderDraftLoginUri={reorderDraftLoginUri}
|
||||||
|
onRequestDeleteLoginPasskey={setPendingDeletePasskeyIndex}
|
||||||
onQueueAttachmentFiles={queueAttachmentFiles}
|
onQueueAttachmentFiles={queueAttachmentFiles}
|
||||||
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
onToggleExistingAttachmentRemoval={toggleExistingAttachmentRemoval}
|
||||||
onRemoveQueuedAttachment={removeQueuedAttachment}
|
onRemoveQueuedAttachment={removeQueuedAttachment}
|
||||||
@@ -971,6 +987,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
repromptApprovedCipherId={repromptApprovedCipherId}
|
repromptApprovedCipherId={repromptApprovedCipherId}
|
||||||
showPassword={showPassword}
|
showPassword={showPassword}
|
||||||
totpLive={totpLive}
|
totpLive={totpLive}
|
||||||
|
passkeyCreatedAt={firstPasskeyCreationTime(selectedCipher)}
|
||||||
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
hiddenFieldVisibleMap={hiddenFieldVisibleMap}
|
||||||
folderName={folderName}
|
folderName={folderName}
|
||||||
onOpenReprompt={() => setRepromptOpen(true)}
|
onOpenReprompt={() => setRepromptOpen(true)}
|
||||||
@@ -1013,6 +1030,7 @@ function folderName(id: string | null | undefined): string {
|
|||||||
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
deleteAllFoldersOpen={deleteAllFoldersOpen}
|
||||||
repromptOpen={repromptOpen}
|
repromptOpen={repromptOpen}
|
||||||
repromptPassword={repromptPassword}
|
repromptPassword={repromptPassword}
|
||||||
|
deletePasskeyOpen={pendingDeletePasskeyIndex != null}
|
||||||
onConfirmAddField={() => {
|
onConfirmAddField={() => {
|
||||||
if (!draft) return;
|
if (!draft) return;
|
||||||
if (!fieldLabel.trim()) {
|
if (!fieldLabel.trim()) {
|
||||||
@@ -1075,6 +1093,8 @@ function folderName(id: string | null | undefined): string {
|
|||||||
setRepromptPassword('');
|
setRepromptPassword('');
|
||||||
}}
|
}}
|
||||||
onRepromptPasswordChange={setRepromptPassword}
|
onRepromptPasswordChange={setRepromptPassword}
|
||||||
|
onConfirmDeletePasskey={confirmDeleteLoginPasskey}
|
||||||
|
onCancelDeletePasskey={() => setPendingDeletePasskeyIndex(null)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -134,6 +134,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
...COMMON_TIME_ZONES,
|
...COMMON_TIME_ZONES,
|
||||||
...props.availableTimeZones,
|
...props.availableTimeZones,
|
||||||
]));
|
]));
|
||||||
|
const selectedIntervalHours = props.selectedDestination?.schedule.intervalHours ?? 24;
|
||||||
|
|
||||||
if (props.selectedRecommendedProvider) {
|
if (props.selectedRecommendedProvider) {
|
||||||
return (
|
return (
|
||||||
@@ -216,7 +217,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
type="text"
|
type="text"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
pattern="[0-9]*"
|
pattern="[0-9]*"
|
||||||
value={String(props.selectedDestination.schedule.intervalHours || 24)}
|
value={String(selectedIntervalHours)}
|
||||||
disabled={props.loadingSettings || props.disableWhileBusy}
|
disabled={props.loadingSettings || props.disableWhileBusy}
|
||||||
onInput={(event) => {
|
onInput={(event) => {
|
||||||
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
const raw = (event.currentTarget as HTMLInputElement).value.replace(/[^\d]/g, '');
|
||||||
@@ -234,7 +235,7 @@ export function BackupDestinationDetail(props: BackupDestinationDetailProps) {
|
|||||||
</div>
|
</div>
|
||||||
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
<div className="backup-interval-presets" aria-label={t('txt_backup_interval_hours_presets')}>
|
||||||
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
{INTERVAL_HOUR_PRESETS.map((preset) => {
|
||||||
const active = preset === props.selectedDestination.schedule.intervalHours;
|
const active = preset === selectedIntervalHours;
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={preset}
|
key={preset}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ interface VaultDetailViewProps {
|
|||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
totpLive: { code: string; remain: number } | null;
|
totpLive: { code: string; remain: number } | null;
|
||||||
|
passkeyCreatedAt: string | null;
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
downloadingAttachmentKey: string;
|
downloadingAttachmentKey: string;
|
||||||
@@ -135,6 +136,15 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{!!props.passkeyCreatedAt && (
|
||||||
|
<div className="kv-row">
|
||||||
|
<span className="kv-label">{t('txt_passkey')}</span>
|
||||||
|
<div className="kv-main">
|
||||||
|
<strong>{t('txt_passkey_created_at_value', { value: formatHistoryTime(props.passkeyCreatedAt) })}</strong>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ interface VaultDialogsProps {
|
|||||||
deleteAllFoldersOpen: boolean;
|
deleteAllFoldersOpen: boolean;
|
||||||
repromptOpen: boolean;
|
repromptOpen: boolean;
|
||||||
repromptPassword: string;
|
repromptPassword: string;
|
||||||
|
deletePasskeyOpen: boolean;
|
||||||
onConfirmAddField: () => void;
|
onConfirmAddField: () => void;
|
||||||
onCancelFieldModal: () => void;
|
onCancelFieldModal: () => void;
|
||||||
onFieldTypeChange: (value: CustomFieldType) => void;
|
onFieldTypeChange: (value: CustomFieldType) => void;
|
||||||
@@ -54,6 +55,8 @@ interface VaultDialogsProps {
|
|||||||
onConfirmReprompt: () => void;
|
onConfirmReprompt: () => void;
|
||||||
onCancelReprompt: () => void;
|
onCancelReprompt: () => void;
|
||||||
onRepromptPasswordChange: (value: string) => void;
|
onRepromptPasswordChange: (value: string) => void;
|
||||||
|
onConfirmDeletePasskey: () => void;
|
||||||
|
onCancelDeletePasskey: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VaultDialogs(props: VaultDialogsProps) {
|
export default function VaultDialogs(props: VaultDialogsProps) {
|
||||||
@@ -181,6 +184,17 @@ export default function VaultDialogs(props: VaultDialogsProps) {
|
|||||||
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
<input className="input" type="password" value={props.repromptPassword} onInput={(e) => props.onRepromptPasswordChange((e.currentTarget as HTMLInputElement).value)} />
|
||||||
</label>
|
</label>
|
||||||
</ConfirmDialog>
|
</ConfirmDialog>
|
||||||
|
|
||||||
|
<ConfirmDialog
|
||||||
|
open={props.deletePasskeyOpen}
|
||||||
|
title={t('txt_delete_passkey')}
|
||||||
|
message={t('txt_are_you_sure_you_want_to_delete_this_passkey')}
|
||||||
|
confirmText={t('txt_delete')}
|
||||||
|
cancelText={t('txt_cancel')}
|
||||||
|
danger
|
||||||
|
onConfirm={props.onConfirmDeletePasskey}
|
||||||
|
onCancel={props.onCancelDeletePasskey}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { RefObject } from 'preact';
|
import type { JSX, RefObject } from 'preact';
|
||||||
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
import { CheckCheck, Download, GripVertical, Paperclip, Plus, RefreshCw, Star, StarOff, Trash2, Upload, X } from 'lucide-preact';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import {
|
import {
|
||||||
@@ -20,7 +20,15 @@ import {
|
|||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { CREATE_TYPE_OPTIONS, cipherTypeLabel, createEmptyLoginUri, formatAttachmentSize, toBooleanFieldValue, WEBSITE_MATCH_OPTIONS } from '@/components/vault/vault-page-helpers';
|
import {
|
||||||
|
CREATE_TYPE_OPTIONS,
|
||||||
|
cipherTypeLabel,
|
||||||
|
createEmptyLoginUri,
|
||||||
|
formatAttachmentSize,
|
||||||
|
formatHistoryTime,
|
||||||
|
toBooleanFieldValue,
|
||||||
|
WEBSITE_MATCH_OPTIONS,
|
||||||
|
} from '@/components/vault/vault-page-helpers';
|
||||||
|
|
||||||
interface VaultEditorProps {
|
interface VaultEditorProps {
|
||||||
draft: VaultDraft;
|
draft: VaultDraft;
|
||||||
@@ -44,6 +52,7 @@ interface VaultEditorProps {
|
|||||||
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
onUpdateDraftLoginUri: (index: number, value: string) => void;
|
||||||
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
onUpdateDraftLoginUriMatch: (index: number, value: number | null) => void;
|
||||||
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
onReorderDraftLoginUri: (fromIndex: number, toIndex: number) => void;
|
||||||
|
onRequestDeleteLoginPasskey: (index: number) => void;
|
||||||
onQueueAttachmentFiles: (list: FileList | null) => void;
|
onQueueAttachmentFiles: (list: FileList | null) => void;
|
||||||
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
onToggleExistingAttachmentRemoval: (attachmentId: string) => void;
|
||||||
onRemoveQueuedAttachment: (index: number) => void;
|
onRemoveQueuedAttachment: (index: number) => void;
|
||||||
@@ -71,6 +80,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
const { attributes, listeners, setActivatorNodeRef, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||||
id: props.id,
|
id: props.id,
|
||||||
});
|
});
|
||||||
|
const dragButtonAttributes = attributes as JSX.HTMLAttributes<HTMLButtonElement>;
|
||||||
|
|
||||||
const style = {
|
const style = {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -89,7 +99,7 @@ function SortableWebsiteRow(props: SortableWebsiteRowProps) {
|
|||||||
className="btn btn-secondary small website-drag-btn"
|
className="btn btn-secondary small website-drag-btn"
|
||||||
title={t('txt_drag_to_reorder')}
|
title={t('txt_drag_to_reorder')}
|
||||||
aria-label={t('txt_drag_to_reorder')}
|
aria-label={t('txt_drag_to_reorder')}
|
||||||
{...attributes}
|
{...dragButtonAttributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
>
|
>
|
||||||
<GripVertical size={14} className="btn-icon" />
|
<GripVertical size={14} className="btn-icon" />
|
||||||
@@ -287,6 +297,42 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
))}
|
))}
|
||||||
</SortableContext>
|
</SortableContext>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
|
{props.draft.loginFido2Credentials.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="section-head" style={{ marginTop: '18px' }}>
|
||||||
|
<h4>{t('txt_passkeys')}</h4>
|
||||||
|
</div>
|
||||||
|
<div className="attachment-list">
|
||||||
|
{props.draft.loginFido2Credentials.map((credential, index) => {
|
||||||
|
const createdAt = String(credential?.creationDate || '').trim();
|
||||||
|
const label = createdAt
|
||||||
|
? t('txt_passkey_created_at_value', { value: formatHistoryTime(createdAt) })
|
||||||
|
: t('txt_passkey');
|
||||||
|
return (
|
||||||
|
<div key={`login-passkey-${index}`} className="attachment-row">
|
||||||
|
<div className="attachment-main">
|
||||||
|
<div className="attachment-text">
|
||||||
|
<strong>{t('txt_passkey')}</strong>
|
||||||
|
<span>{label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="kv-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary small"
|
||||||
|
disabled={props.busy}
|
||||||
|
onClick={() => props.onRequestDeleteLoginPasskey(index)}
|
||||||
|
>
|
||||||
|
<X size={14} className="btn-icon" />
|
||||||
|
{t('txt_remove')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -194,6 +194,9 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
|
|||||||
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
uri: valueOrFallback(uri.decUri ?? uri.uri),
|
||||||
match: uri.match ?? null,
|
match: uri.match ?? null,
|
||||||
})),
|
})),
|
||||||
|
fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({
|
||||||
|
creationDate: valueOrFallback(credential.creationDate),
|
||||||
|
})),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
card: cipher.card
|
card: cipher.card
|
||||||
@@ -262,6 +265,7 @@ export function createEmptyDraft(type: number): VaultDraft {
|
|||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [createEmptyLoginUri()],
|
loginUris: [createEmptyLoginUri()],
|
||||||
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
cardBrand: '',
|
cardBrand: '',
|
||||||
@@ -310,6 +314,9 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
|
|||||||
uri: x.decUri || x.uri || '',
|
uri: x.decUri || x.uri || '',
|
||||||
match: x.match ?? null,
|
match: x.match ?? null,
|
||||||
}));
|
}));
|
||||||
|
draft.loginFido2Credentials = Array.isArray(cipher.login.fido2Credentials)
|
||||||
|
? cipher.login.fido2Credentials.map((credential) => ({ ...credential }))
|
||||||
|
: [];
|
||||||
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
if (!draft.loginUris.length) draft.loginUris = [createEmptyLoginUri()];
|
||||||
}
|
}
|
||||||
if (cipher.card) {
|
if (cipher.card) {
|
||||||
@@ -406,6 +413,16 @@ export function creationTimeValue(cipher: Cipher): number {
|
|||||||
return Number.isFinite(time) ? time : 0;
|
return Number.isFinite(time) ? time : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function firstPasskeyCreationTime(cipher: Cipher | null): string | null {
|
||||||
|
const credentials = cipher?.login?.fido2Credentials;
|
||||||
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
|
for (const credential of credentials) {
|
||||||
|
const raw = String(credential?.creationDate || '').trim();
|
||||||
|
if (raw) return raw;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const failedIconHosts = new Set<string>();
|
const failedIconHosts = new Set<string>();
|
||||||
|
|
||||||
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
export function VaultListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { base64ToBytes, decryptBw } from './crypto';
|
import { base64ToBytes, decryptBw, toBufferSource } from './crypto';
|
||||||
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup';
|
||||||
import type { Profile, SessionState } from './types';
|
import type { Profile, SessionState } from './types';
|
||||||
|
|
||||||
@@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM';
|
|||||||
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey(
|
return crypto.subtle.importKey(
|
||||||
'pkcs8',
|
'pkcs8',
|
||||||
pkcs8,
|
toBufferSource(pkcs8),
|
||||||
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
{ name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH },
|
||||||
false,
|
false,
|
||||||
['decrypt']
|
['decrypt']
|
||||||
@@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise<CryptoKey> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
async function importPortableAesKey(keyBytes: Uint8Array): Promise<CryptoKey> {
|
||||||
return crypto.subtle.importKey('raw', keyBytes, { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
return crypto.subtle.importKey('raw', toBufferSource(keyBytes), { name: AES_GCM_ALGORITHM }, false, ['decrypt']);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function decryptPortableBackupSettings(
|
export async function decryptPortableBackupSettings(
|
||||||
@@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings(
|
|||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: PORTABLE_ALGORITHM },
|
{ name: PORTABLE_ALGORITHM },
|
||||||
privateKey,
|
privateKey,
|
||||||
base64ToBytes(wrap.wrappedKey)
|
toBufferSource(base64ToBytes(wrap.wrappedKey))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
const aesKey = await importPortableAesKey(portableDek);
|
const aesKey = await importPortableAesKey(portableDek);
|
||||||
const plaintext = new Uint8Array(
|
const plaintext = new Uint8Array(
|
||||||
await crypto.subtle.decrypt(
|
await crypto.subtle.decrypt(
|
||||||
{ name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) },
|
{ name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) },
|
||||||
aesKey,
|
aesKey,
|
||||||
base64ToBytes(portable.ciphertext)
|
toBufferSource(base64ToBytes(portable.ciphertext))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings;
|
||||||
|
|||||||
+113
-16
@@ -10,8 +10,10 @@ import type {
|
|||||||
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
|
||||||
|
|
||||||
const SESSION_KEY = 'nodewarden.web.session.v4';
|
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 DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
|
||||||
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
|
||||||
|
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
|
||||||
|
|
||||||
export interface PreloginResult {
|
export interface PreloginResult {
|
||||||
hash: string;
|
hash: string;
|
||||||
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
|
|||||||
kdfParallelism: 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;
|
||||||
|
|
||||||
function randomHex(length: number): string {
|
function randomHex(length: number): string {
|
||||||
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
|
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);
|
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
|
||||||
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
|
|||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(SESSION_KEY);
|
const raw = localStorage.getItem(SESSION_KEY);
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
const parsed = JSON.parse(raw) as SessionState;
|
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
|
||||||
if (!parsed.accessToken || !parsed.refreshToken) return null;
|
if (parsed.authMode === 'web-cookie' && parsed.email) {
|
||||||
|
return {
|
||||||
|
email: parsed.email,
|
||||||
|
authMode: 'web-cookie',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||||
return {
|
return {
|
||||||
accessToken: parsed.accessToken,
|
accessToken: parsed.accessToken,
|
||||||
refreshToken: parsed.refreshToken,
|
refreshToken: parsed.refreshToken,
|
||||||
email: parsed.email,
|
email: parsed.email,
|
||||||
|
authMode: 'token',
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
|
|||||||
localStorage.removeItem(SESSION_KEY);
|
localStorage.removeItem(SESSION_KEY);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const persisted: SessionState = {
|
const persisted: PersistedSessionState = {
|
||||||
accessToken: session.accessToken,
|
|
||||||
refreshToken: session.refreshToken,
|
|
||||||
email: session.email,
|
email: session.email,
|
||||||
|
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
|
||||||
};
|
};
|
||||||
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
|
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 || !parsed?.key) return null;
|
||||||
|
if (email && parsed.email !== email) return null;
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveProfileSnapshot(profile: Profile | null): void {
|
||||||
|
if (!profile) return;
|
||||||
|
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearProfileSnapshot(): void {
|
||||||
|
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
export function getCurrentDeviceIdentifier(): string {
|
export function getCurrentDeviceIdentifier(): string {
|
||||||
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
|
||||||
}
|
}
|
||||||
@@ -170,7 +218,10 @@ export async function loginWithPassword(
|
|||||||
}
|
}
|
||||||
const resp = await fetch('/identity/connect/token', {
|
const resp = await fetch('/identity/connect/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
[WEB_SESSION_HEADER]: '1',
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||||
@@ -183,18 +234,60 @@ export async function loginWithPassword(
|
|||||||
return json;
|
return json;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
|
function isTransientRefreshStatus(status: number): boolean {
|
||||||
|
return status === 0 || status === 429 || status >= 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||||
const body = new URLSearchParams();
|
const body = new URLSearchParams();
|
||||||
body.set('grant_type', 'refresh_token');
|
body.set('grant_type', 'refresh_token');
|
||||||
body.set('refresh_token', refreshToken);
|
if (session.authMode !== 'web-cookie' && session.refreshToken) {
|
||||||
|
body.set('refresh_token', session.refreshToken);
|
||||||
|
}
|
||||||
|
try {
|
||||||
const resp = await fetch('/identity/connect/token', {
|
const resp = await fetch('/identity/connect/token', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: {
|
||||||
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
|
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||||
|
},
|
||||||
body: body.toString(),
|
body: body.toString(),
|
||||||
});
|
});
|
||||||
if (!resp.ok) return null;
|
if (!resp.ok) {
|
||||||
|
const json = await parseJson<TokenError>(resp);
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: isTransientRefreshStatus(resp.status),
|
||||||
|
error: json?.error_description || json?.error || 'Session refresh failed',
|
||||||
|
};
|
||||||
|
}
|
||||||
const json = await parseJson<TokenSuccess>(resp);
|
const json = await parseJson<TokenSuccess>(resp);
|
||||||
return json || null;
|
if (!json?.access_token) {
|
||||||
|
return { ok: false, transient: false, error: 'Session refresh failed' };
|
||||||
|
}
|
||||||
|
return { ok: true, token: json };
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
transient: true,
|
||||||
|
error: error instanceof Error ? error.message : 'Network error',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
export async function registerAccount(args: {
|
||||||
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
|
|||||||
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
headers.set('Authorization', `Bearer ${session.accessToken}`);
|
||||||
|
|
||||||
let resp = await fetch(input, { ...init, headers });
|
let resp = await fetch(input, { ...init, headers });
|
||||||
if (resp.status !== 401 || !session.refreshToken) return resp;
|
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed?.access_token) {
|
if (!refreshed.ok) {
|
||||||
|
if (refreshed.transient) {
|
||||||
|
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
|
||||||
|
}
|
||||||
setSession(null);
|
setSession(null);
|
||||||
throw new Error('Session expired');
|
throw new Error('Session expired');
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextSession: SessionState = {
|
const nextSession: SessionState = {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||||
|
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||||
};
|
};
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
saveSession(nextSession);
|
saveSession(nextSession);
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
type AuthedFetch,
|
type AuthedFetch,
|
||||||
} from './shared';
|
} from './shared';
|
||||||
import { readResponseBytesWithProgress } from '../download';
|
import { readResponseBytesWithProgress } from '../download';
|
||||||
|
import { toBufferSource } from '../crypto';
|
||||||
import { unzipSync, zipSync } from 'fflate';
|
import { unzipSync, zipSync } from 'fflate';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
@@ -148,32 +149,21 @@ interface BackupExportManifest {
|
|||||||
|
|
||||||
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
|
||||||
|
|
||||||
function parseBackupTimestampFromFileName(fileName: string): Date | null {
|
function extractBackupTimestampFromFileName(fileName: string): string | null {
|
||||||
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
const match = String(fileName || '').match(/nodewarden_backup_(\d{8})_(\d{6})(?:_[0-9a-f]{5})?\.zip$/i);
|
||||||
if (!match) return null;
|
if (!match) return null;
|
||||||
const datePart = match[1];
|
return `${match[1]}_${match[2]}`;
|
||||||
const timePart = match[2];
|
|
||||||
const iso = `${datePart.slice(0, 4)}-${datePart.slice(4, 6)}-${datePart.slice(6, 8)}T${timePart.slice(0, 2)}:${timePart.slice(2, 4)}:${timePart.slice(4, 6)}.000Z`;
|
|
||||||
const parsed = new Date(iso);
|
|
||||||
return Number.isFinite(parsed.getTime()) ? parsed : null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildBackupFileName(date: Date, checksumPrefix: string): string {
|
function buildBackupFileName(timestamp: string, checksumPrefix: string): string {
|
||||||
const parts = [
|
return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`;
|
||||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
|
||||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
|
||||||
date.getUTCDate().toString().padStart(2, '0'),
|
|
||||||
date.getUTCHours().toString().padStart(2, '0'),
|
|
||||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
|
||||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
|
||||||
];
|
|
||||||
return `nodewarden_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}_${checksumPrefix}.zip`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise<string> {
|
||||||
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
const integrity = await verifyBackupFileIntegrity(bytes, fileName);
|
||||||
const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date();
|
const timestamp = extractBackupTimestampFromFileName(fileName);
|
||||||
return buildBackupFileName(effectiveDate, integrity.actualPrefix);
|
if (!timestamp) return fileName;
|
||||||
|
return buildBackupFileName(timestamp, integrity.actualPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function exportAdminBackup(
|
export async function exportAdminBackup(
|
||||||
@@ -378,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
async function sha256Hex(bytes: Uint8Array): Promise<string> {
|
||||||
const digest = await crypto.subtle.digest('SHA-256', bytes);
|
const digest = await crypto.subtle.digest('SHA-256', toBufferSource(bytes));
|
||||||
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
return Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -152,10 +152,13 @@ export async function createSend(
|
|||||||
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
|
||||||
const uploadUrl = uploadInfo?.url;
|
const uploadUrl = uploadInfo?.url;
|
||||||
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
|
||||||
|
if (!session.accessToken) throw new Error('Unauthorized');
|
||||||
|
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
|
||||||
|
new Uint8Array(payload).set(encryptedFileBytes);
|
||||||
const uploadResp = await uploadDirectEncryptedPayload({
|
const uploadResp = await uploadDirectEncryptedPayload({
|
||||||
accessToken: session.accessToken,
|
accessToken: session.accessToken,
|
||||||
uploadUrl,
|
uploadUrl,
|
||||||
payload: encryptedFileBytes,
|
payload,
|
||||||
fileUploadType: uploadInfo?.fileUploadType,
|
fileUploadType: uploadInfo?.fileUploadType,
|
||||||
unsupportedMessage: 'Unsupported send upload type',
|
unsupportedMessage: 'Unsupported send upload type',
|
||||||
onProgress,
|
onProgress,
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ interface UploadWithProgressOptions {
|
|||||||
accessToken?: string;
|
accessToken?: string;
|
||||||
method?: string;
|
method?: string;
|
||||||
headers?: HeadersInit;
|
headers?: HeadersInit;
|
||||||
body?: Document | XMLHttpRequestBodyInit | null;
|
body?: XMLHttpRequestBodyInit | null;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DirectEncryptedUploadOptions {
|
interface DirectEncryptedUploadOptions {
|
||||||
accessToken: string;
|
accessToken: string;
|
||||||
uploadUrl: string;
|
uploadUrl: string;
|
||||||
payload: ArrayBuffer | Uint8Array;
|
payload: XMLHttpRequestBodyInit;
|
||||||
fileUploadType: number | null | undefined;
|
fileUploadType: number | null | undefined;
|
||||||
unsupportedMessage: string;
|
unsupportedMessage: string;
|
||||||
onProgress?: (percent: number | null) => void;
|
onProgress?: (percent: number | null) => void;
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export async function uploadCipherAttachment(
|
|||||||
const attachmentId = String(meta.attachmentId || '').trim();
|
const attachmentId = String(meta.attachmentId || '').trim();
|
||||||
const uploadUrl = String(meta.url || '').trim();
|
const uploadUrl = String(meta.url || '').trim();
|
||||||
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
|
||||||
|
if (!session.accessToken) throw new Error('Unauthorized');
|
||||||
|
|
||||||
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
const payload = new ArrayBuffer(encryptedBytes.byteLength);
|
||||||
new Uint8Array(payload).set(encryptedBytes);
|
new Uint8Array(payload).set(encryptedBytes);
|
||||||
@@ -392,6 +393,56 @@ function toIsoDateOrNow(value: unknown): string {
|
|||||||
return parsed.toISOString();
|
return parsed.toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function encryptMaybeFidoValue(
|
||||||
|
value: unknown,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array,
|
||||||
|
fallback = ''
|
||||||
|
): Promise<string> {
|
||||||
|
const normalized = String(value ?? '').trim() || fallback;
|
||||||
|
if (looksLikeCipherString(normalized)) return normalized;
|
||||||
|
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function encryptMaybeNullableFidoValue(
|
||||||
|
value: unknown,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<string | null> {
|
||||||
|
const normalized = String(value ?? '').trim();
|
||||||
|
if (!normalized) return null;
|
||||||
|
if (looksLikeCipherString(normalized)) return normalized;
|
||||||
|
return encryptBw(new TextEncoder().encode(normalized), enc, mac);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function normalizeFido2Credentials(
|
||||||
|
credentials: Array<Record<string, unknown>> | null | undefined,
|
||||||
|
enc: Uint8Array,
|
||||||
|
mac: Uint8Array
|
||||||
|
): Promise<Array<Record<string, unknown>> | null> {
|
||||||
|
if (!Array.isArray(credentials) || credentials.length === 0) return null;
|
||||||
|
const out: Array<Record<string, unknown>> = [];
|
||||||
|
for (const credential of credentials) {
|
||||||
|
if (!credential || typeof credential !== 'object') continue;
|
||||||
|
out.push({
|
||||||
|
credentialId: await encryptMaybeFidoValue(credential.credentialId, enc, mac),
|
||||||
|
keyType: await encryptMaybeFidoValue(credential.keyType, enc, mac, 'public-key'),
|
||||||
|
keyAlgorithm: await encryptMaybeFidoValue(credential.keyAlgorithm, enc, mac, 'ECDSA'),
|
||||||
|
keyCurve: await encryptMaybeFidoValue(credential.keyCurve, enc, mac, 'P-256'),
|
||||||
|
keyValue: await encryptMaybeFidoValue(credential.keyValue, enc, mac),
|
||||||
|
rpId: await encryptMaybeFidoValue(credential.rpId, enc, mac),
|
||||||
|
rpName: await encryptMaybeNullableFidoValue(credential.rpName, enc, mac),
|
||||||
|
userHandle: await encryptMaybeNullableFidoValue(credential.userHandle, enc, mac),
|
||||||
|
userName: await encryptMaybeNullableFidoValue(credential.userName, enc, mac),
|
||||||
|
userDisplayName: await encryptMaybeNullableFidoValue(credential.userDisplayName, enc, mac),
|
||||||
|
counter: await encryptMaybeFidoValue(credential.counter, enc, mac, '0'),
|
||||||
|
discoverable: await encryptMaybeFidoValue(credential.discoverable, enc, mac, 'false'),
|
||||||
|
creationDate: toIsoDateOrNow(credential.creationDate),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.length ? out : null;
|
||||||
|
}
|
||||||
|
|
||||||
async function getCipherKeys(
|
async function getCipherKeys(
|
||||||
cipher: Cipher | null,
|
cipher: Cipher | null,
|
||||||
userEnc: Uint8Array,
|
userEnc: Uint8Array,
|
||||||
@@ -440,10 +491,15 @@ async function buildCipherPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (type === 1) {
|
if (type === 1) {
|
||||||
|
const existingFido2 =
|
||||||
|
cipher?.login && Array.isArray((cipher.login as any).fido2Credentials)
|
||||||
|
? (cipher.login as any).fido2Credentials
|
||||||
|
: draft.loginFido2Credentials;
|
||||||
payload.login = {
|
payload.login = {
|
||||||
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac),
|
||||||
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac),
|
||||||
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac),
|
||||||
|
fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac),
|
||||||
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac),
|
||||||
};
|
};
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
|
|||||||
+47
-22
@@ -2,6 +2,7 @@ import {
|
|||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
deriveLoginHashLocally,
|
deriveLoginHashLocally,
|
||||||
getProfile,
|
getProfile,
|
||||||
|
loadProfileSnapshot,
|
||||||
loadSession,
|
loadSession,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
|
|||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
|
needsBackgroundHydration?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
|
|||||||
newRecoveryCode: string | null;
|
newRecoveryCode: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeJwtExp(accessToken: string): number | null {
|
function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||||
try {
|
try {
|
||||||
|
if (!accessToken) return null;
|
||||||
const parts = accessToken.split('.');
|
const parts = accessToken.split('.');
|
||||||
if (parts.length < 2) return null;
|
if (parts.length < 2) return null;
|
||||||
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||||
if (!session.refreshToken) return session;
|
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||||
const exp = decodeJwtExp(session.accessToken);
|
const exp = decodeJwtExp(session.accessToken);
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||||
|
|
||||||
if (exp !== null && exp - nowSeconds > 60) {
|
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||||
return session;
|
return session;
|
||||||
}
|
}
|
||||||
|
|
||||||
const refreshed = await refreshAccessToken(session.refreshToken);
|
const refreshed = await refreshAccessToken(session);
|
||||||
if (!refreshed?.access_token) {
|
if (!refreshed.ok) {
|
||||||
return exp !== null && exp > nowSeconds ? session : null;
|
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...session,
|
...session,
|
||||||
accessToken: refreshed.access_token,
|
accessToken: refreshed.token.access_token,
|
||||||
refreshToken: refreshed.refresh_token || session.refreshToken,
|
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||||
|
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
const cachedProfile = loadProfileSnapshot(loaded.email);
|
||||||
const session = await maybeRefreshSession(loaded);
|
if (cachedProfile) {
|
||||||
if (!session) {
|
return {
|
||||||
throw new Error('Session expired');
|
defaultKdfIterations,
|
||||||
|
jwtWarning: null,
|
||||||
|
session: loaded,
|
||||||
|
profile: cachedProfile,
|
||||||
|
phase: 'locked',
|
||||||
|
needsBackgroundHydration: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
defaultKdfIterations,
|
||||||
|
jwtWarning: null,
|
||||||
|
session: loaded,
|
||||||
|
profile: null,
|
||||||
|
phase: 'locked',
|
||||||
|
needsBackgroundHydration: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hydrateLockedSession(
|
||||||
|
session: SessionState,
|
||||||
|
fallbackProfile: Profile | null = null
|
||||||
|
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||||
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
|
if (!refreshedSession?.accessToken) {
|
||||||
|
return { session: null, profile: null };
|
||||||
|
}
|
||||||
|
try {
|
||||||
const profile = await getProfile(
|
const profile = await getProfile(
|
||||||
createAuthedFetch(
|
createAuthedFetch(
|
||||||
() => session,
|
() => refreshedSession,
|
||||||
() => {}
|
() => {}
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
session: refreshedSession,
|
||||||
jwtWarning: null,
|
|
||||||
session,
|
|
||||||
profile,
|
profile,
|
||||||
phase: 'locked',
|
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
session: refreshedSession,
|
||||||
jwtWarning: null,
|
profile: fallbackProfile,
|
||||||
session: null,
|
|
||||||
profile: null,
|
|
||||||
phase: initial.phase === 'register' ? 'register' : 'login',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,6 +260,7 @@ export async function completeLogin(
|
|||||||
accessToken: token.access_token,
|
accessToken: token.access_token,
|
||||||
refreshToken: token.refresh_token,
|
refreshToken: token.refresh_token,
|
||||||
email: normalizedEmail,
|
email: normalizedEmail,
|
||||||
|
authMode: token.web_session ? 'web-cookie' : 'token',
|
||||||
};
|
};
|
||||||
const tempFetch = createAuthedFetch(
|
const tempFetch = createAuthedFetch(
|
||||||
() => baseSession,
|
() => baseSession,
|
||||||
|
|||||||
@@ -99,6 +99,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft {
|
|||||||
loginPassword: '',
|
loginPassword: '',
|
||||||
loginTotp: '',
|
loginTotp: '',
|
||||||
loginUris: [{ uri: '', match: null }],
|
loginUris: [{ uri: '', match: null }],
|
||||||
|
loginFido2Credentials: [],
|
||||||
cardholderName: '',
|
cardholderName: '',
|
||||||
cardNumber: '',
|
cardNumber: '',
|
||||||
cardBrand: '',
|
cardBrand: '',
|
||||||
@@ -173,6 +174,9 @@ export function importCipherToDraft(cipher: Record<string, unknown>, folderId: s
|
|||||||
})
|
})
|
||||||
.filter((u) => !!u.uri);
|
.filter((u) => !!u.uri);
|
||||||
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
|
draft.loginUris = uris.length ? uris : [{ uri: '', match: null }];
|
||||||
|
draft.loginFido2Credentials = Array.isArray(login.fido2Credentials)
|
||||||
|
? login.fido2Credentials.filter((item): item is Record<string, unknown> => !!item && typeof item === 'object')
|
||||||
|
: [];
|
||||||
} else if (type === 3) {
|
} else if (type === 3) {
|
||||||
const card = (cipher.card || {}) as Record<string, unknown>;
|
const card = (cipher.card || {}) as Record<string, unknown>;
|
||||||
draft.cardholderName = asText(card.cardholderName);
|
draft.cardholderName = asText(card.cardholderName);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
export function toBufferSource(bytes: Uint8Array): ArrayBuffer {
|
||||||
return new Uint8Array(bytes).buffer;
|
return new Uint8Array(bytes).buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -198,6 +198,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
|
|||||||
match: (uri as { match?: unknown })?.match ?? null,
|
match: (uri as { match?: unknown })?.match ?? null,
|
||||||
}))
|
}))
|
||||||
: [],
|
: [],
|
||||||
|
fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [],
|
||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
@@ -291,6 +292,11 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
|
|||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
|
fido2Credentials: Array.isArray(cipher.login.fido2Credentials)
|
||||||
|
? await Promise.all(
|
||||||
|
cipher.login.fido2Credentials.map((credential) => deepDecryptUnknown(credential, keyParts.enc, keyParts.mac))
|
||||||
|
)
|
||||||
|
: [],
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
out.login = null;
|
out.login = null;
|
||||||
|
|||||||
@@ -293,6 +293,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
|
txt_are_you_sure_you_want_to_delete_count_selected_items: "Are you sure you want to delete {count} selected items?",
|
||||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: "Are you sure you want to permanently delete {count} selected items?",
|
||||||
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
txt_are_you_sure_you_want_to_delete_this_item: "Are you sure you want to delete this item?",
|
||||||
|
txt_are_you_sure_you_want_to_delete_this_passkey: "Are you sure you want to delete this passkey?",
|
||||||
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
txt_are_you_sure_you_want_to_log_out: "Are you sure you want to log out?",
|
||||||
txt_authenticator_key: "Authenticator Key",
|
txt_authenticator_key: "Authenticator Key",
|
||||||
txt_authorized_devices: "Authorized Devices",
|
txt_authorized_devices: "Authorized Devices",
|
||||||
@@ -352,6 +353,7 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
|
txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?",
|
||||||
txt_delete_all_invites: "Delete all invites",
|
txt_delete_all_invites: "Delete all invites",
|
||||||
txt_delete_item: "Delete Item",
|
txt_delete_item: "Delete Item",
|
||||||
|
txt_delete_passkey: "Delete Passkey",
|
||||||
txt_delete_item_failed: "Delete item failed",
|
txt_delete_item_failed: "Delete item failed",
|
||||||
txt_delete_permanently: "Delete Permanently",
|
txt_delete_permanently: "Delete Permanently",
|
||||||
txt_archive: "Archive",
|
txt_archive: "Archive",
|
||||||
@@ -571,6 +573,9 @@ const messages: Record<Locale, Record<string, string>> = {
|
|||||||
txt_password_hint_not_set: "No password hint is available for this email.",
|
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_load_failed: "Failed to load password hint",
|
||||||
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
txt_password_hint_too_long: "Password hint must be 120 characters or fewer",
|
||||||
|
txt_passkey: "Passkey",
|
||||||
|
txt_passkeys: "Passkeys",
|
||||||
|
txt_passkey_created_at_value: "Created on {value}",
|
||||||
txt_phone: "Phone",
|
txt_phone: "Phone",
|
||||||
txt_please_input_email_and_password: "Please input email and password",
|
txt_please_input_email_and_password: "Please input email and password",
|
||||||
txt_please_input_master_password: "Please input master password",
|
txt_please_input_master_password: "Please input master password",
|
||||||
@@ -1161,6 +1166,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_no_name: '(无名称)',
|
txt_no_name: '(无名称)',
|
||||||
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?',
|
||||||
txt_delete_item: '删除项目',
|
txt_delete_item: '删除项目',
|
||||||
|
txt_delete_passkey: '删除通行密钥',
|
||||||
txt_delete_selected_items: '删除所选项目',
|
txt_delete_selected_items: '删除所选项目',
|
||||||
txt_move_selected_items: '移动所选项目',
|
txt_move_selected_items: '移动所选项目',
|
||||||
txt_create_folder: '创建文件夹',
|
txt_create_folder: '创建文件夹',
|
||||||
@@ -1224,6 +1230,7 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
txt_are_you_sure_you_want_to_delete_count_selected_items: '确认删除所选的 {count} 个项目?',
|
||||||
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
txt_are_you_sure_you_want_to_delete_count_selected_items_permanently: '确认永久删除所选的 {count} 个项目?',
|
||||||
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
txt_are_you_sure_you_want_to_delete_this_item: '确认删除此项目?',
|
||||||
|
txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?',
|
||||||
txt_authenticator_key: '验证器密钥',
|
txt_authenticator_key: '验证器密钥',
|
||||||
txt_brand: '品牌',
|
txt_brand: '品牌',
|
||||||
txt_bulk_delete_failed: '批量删除失败',
|
txt_bulk_delete_failed: '批量删除失败',
|
||||||
@@ -1324,6 +1331,9 @@ const zhCNOverrides: Record<string, string> = {
|
|||||||
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。',
|
||||||
txt_password_hint_load_failed: '加载密码提示失败',
|
txt_password_hint_load_failed: '加载密码提示失败',
|
||||||
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
txt_password_hint_too_long: '密码提示最多只能输入 120 个字符',
|
||||||
|
txt_passkey: '通行密钥',
|
||||||
|
txt_passkeys: '通行密钥',
|
||||||
|
txt_passkey_created_at_value: '创建于 {value}',
|
||||||
txt_phone: '电话',
|
txt_phone: '电话',
|
||||||
txt_please_input_email_and_password: '请输入邮箱和密码',
|
txt_please_input_email_and_password: '请输入邮箱和密码',
|
||||||
txt_please_input_master_password: '请输入主密码',
|
txt_please_input_master_password: '请输入主密码',
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export interface BitwardenCipherInput {
|
|||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
|
fido2Credentials?: Array<Record<string, unknown>> | null;
|
||||||
} | null;
|
} | null;
|
||||||
card?: Record<string, unknown> | null;
|
card?: Record<string, unknown> | null;
|
||||||
identity?: Record<string, unknown> | null;
|
identity?: Record<string, unknown> | null;
|
||||||
@@ -89,6 +90,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
|
|||||||
username: item.login.username ?? null,
|
username: item.login.username ?? null,
|
||||||
password: item.login.password ?? null,
|
password: item.login.password ?? null,
|
||||||
totp: item.login.totp ?? null,
|
totp: item.login.totp ?? null,
|
||||||
|
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
|
||||||
uris: Array.isArray(item.login.uris)
|
uris: Array.isArray(item.login.uris)
|
||||||
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null }))
|
||||||
: null,
|
: null,
|
||||||
|
|||||||
+12
-3
@@ -1,9 +1,10 @@
|
|||||||
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
|
||||||
|
|
||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
accessToken: string;
|
accessToken?: string;
|
||||||
refreshToken: string;
|
refreshToken?: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
authMode?: 'token' | 'web-cookie';
|
||||||
symEncKey?: string;
|
symEncKey?: string;
|
||||||
symMacKey?: string;
|
symMacKey?: string;
|
||||||
}
|
}
|
||||||
@@ -48,11 +49,17 @@ export interface CipherAttachment {
|
|||||||
object?: string;
|
object?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface CipherLoginPasskey {
|
||||||
|
creationDate?: string | null;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CipherLogin {
|
export interface CipherLogin {
|
||||||
username?: string | null;
|
username?: string | null;
|
||||||
password?: string | null;
|
password?: string | null;
|
||||||
totp?: string | null;
|
totp?: string | null;
|
||||||
uris?: CipherLoginUri[] | null;
|
uris?: CipherLoginUri[] | null;
|
||||||
|
fido2Credentials?: CipherLoginPasskey[] | null;
|
||||||
decUsername?: string;
|
decUsername?: string;
|
||||||
decPassword?: string;
|
decPassword?: string;
|
||||||
decTotp?: string;
|
decTotp?: string;
|
||||||
@@ -222,6 +229,7 @@ export interface VaultDraft {
|
|||||||
loginPassword: string;
|
loginPassword: string;
|
||||||
loginTotp: string;
|
loginTotp: string;
|
||||||
loginUris: VaultDraftLoginUri[];
|
loginUris: VaultDraftLoginUri[];
|
||||||
|
loginFido2Credentials: Array<Record<string, unknown>>;
|
||||||
cardholderName: string;
|
cardholderName: string;
|
||||||
cardNumber: string;
|
cardNumber: string;
|
||||||
cardBrand: string;
|
cardBrand: string;
|
||||||
@@ -265,7 +273,8 @@ export interface WebBootstrapResponse {
|
|||||||
|
|
||||||
export interface TokenSuccess {
|
export interface TokenSuccess {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token?: string;
|
||||||
|
web_session?: boolean;
|
||||||
expires_in?: number;
|
expires_in?: number;
|
||||||
token_type?: string;
|
token_type?: string;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
|
|||||||
@@ -6,9 +6,8 @@
|
|||||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
"jsx": "react-jsx",
|
"jsx": "react-jsx",
|
||||||
"jsxImportSource": "preact",
|
"jsxImportSource": "preact",
|
||||||
"baseUrl": ".",
|
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["src/*"],
|
"@/*": ["./src/*"],
|
||||||
"@shared/*": ["../shared/*"]
|
"@shared/*": ["../shared/*"]
|
||||||
},
|
},
|
||||||
"strict": true,
|
"strict": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user