This commit is contained in:
shuaiplus
2026-04-09 16:50:49 +08:00
33 changed files with 639 additions and 141 deletions
+1
View File
@@ -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') {
+4 -5
View File
@@ -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 {
+77 -8
View File
@@ -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;
} }
+1
View File
@@ -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,
+26 -11
View File
@@ -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
View File
@@ -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;
+39 -8
View File
@@ -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;
headers['Access-Control-Allow-Credentials'] = 'true'; if (corsPolicy.allowCredentials) {
headers['Access-Control-Allow-Credentials'] = 'true';
}
headers['Vary'] = 'Origin, Access-Control-Request-Headers'; headers['Vary'] = 'Origin, Access-Control-Request-Headers';
} }
+66 -14
View File
@@ -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}
+18 -6
View File
@@ -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,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{props.users.map((user) => ( {props.users.map((user) => {
<tr key={user.id}> const toggleableStatus = normalizeToggleableStatus(user.status);
return (
<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>
<td data-label={t('txt_role')}>{roleText(user.role)}</td> <td data-label={t('txt_role')}>{roleText(user.role)}</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')}
@@ -80,8 +91,9 @@ export default function AdminPage(props: AdminPageProps) {
)} )}
</div> </div>
</td> </td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</section> </section>
+7 -3
View File
@@ -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}>
+1 -1
View File
@@ -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);
+4 -3
View File
@@ -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()),
+3 -1
View File
@@ -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" />
+20
View File
@@ -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}
/>
</> </>
); );
} }
+49 -3
View File
@@ -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 }) {
+6 -6
View File
@@ -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;
+116 -19
View File
@@ -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) {
const resp = await fetch('/identity/connect/token', { body.set('refresh_token', session.refreshToken);
}
try {
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(),
});
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
return {
ok: false,
transient: isTransientRefreshStatus(resp.status),
error: json?.error_description || json?.error || 'Session refresh failed',
};
}
const json = await parseJson<TokenSuccess>(resp);
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', 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(),
}); }).catch(() => undefined);
if (!resp.ok) return null;
const json = await parseJson<TokenSuccess>(resp);
return json || null;
} }
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);
+9 -19
View File
@@ -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('');
} }
+4 -1
View File
@@ -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,
+2 -2
View File
@@ -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;
+56
View File
@@ -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
View File
@@ -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
}; };
} }
const cachedProfile = loadProfileSnapshot(loaded.email);
if (cachedProfile) {
return {
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 { try {
const session = await maybeRefreshSession(loaded);
if (!session) {
throw new Error('Session expired');
}
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,
+4
View File
@@ -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);
+1 -1
View File
@@ -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;
} }
+6
View File
@@ -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;
+10
View File
@@ -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
View File
@@ -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;
+1 -2
View File
@@ -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,