diff --git a/src/handlers/backup.ts b/src/handlers/backup.ts index 69dfd0f..1b5584b 100644 --- a/src/handlers/backup.ts +++ b/src/handlers/backup.ts @@ -192,6 +192,7 @@ async function executeConfiguredBackup( }); const archive = await buildBackupArchive(env, now, { includeAttachments: destination.includeAttachments, + timeZone: destination.schedule.timezone, progress: progress ? async (event) => { if (event.step === 'archive_ready') { diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 4819a84..fec3e46 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -63,11 +63,10 @@ function normalizeCipherForStorage(cipher: Cipher): Cipher { export function normalizeCipherLoginForStorage(login: any): any { if (!login || typeof login !== 'object') return login ?? null; - - const rest = { ...login }; - const passkeyField = ['f', 'i', 'd', 'o', '2', 'C', 'r', 'e', 'd', 'e', 'n', 't', 'i', 'a', 'l', 's'].join(''); - delete (rest as Record)[passkeyField]; - return rest; + return { + ...login, + fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null, + }; } export function normalizeCipherLoginForCompatibility(login: any): any { diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 34371b9..41ecd45 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -18,6 +18,7 @@ import { const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; 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. // Keep request parsing backward-compatible with historical provider values (8 / 100). const TWO_FACTOR_PROVIDER_RECOVERY_CODE_RESPONSE = '-1'; @@ -31,6 +32,54 @@ function resolveTotpSecret(userSecret: string | null): string | 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( email: string, kdfType: number, @@ -283,7 +332,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - refresh_token: refreshToken, + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, @@ -305,7 +354,10 @@ export async function handleToken(request: Request, env: Env): Promise userDecryptionOptions: buildUserDecryptionOptions(user), }; - return jsonResponse(response); + const baseResponse = jsonResponse(response); + return shouldUseWebSession(request) + ? withWebRefreshCookie(request, baseResponse, refreshToken) + : baseResponse; } else if (grantType === 'send_access') { const sendAccessLimit = await rateLimit.consumeBudget(`${clientIdentifier}:public`, LIMITS.rateLimit.publicRequestsPerMinute); @@ -371,14 +423,21 @@ export async function handleToken(request: Request, env: Env): Promise } // Refresh token - const refreshToken = body.refresh_token; + const refreshToken = String(body.refresh_token || '').trim() || ( + shouldUseWebSession(request) + ? parseCookieValue(request, WEB_REFRESH_COOKIE) + : null + ); if (!refreshToken) { return identityErrorResponse('Refresh token is required', 'invalid_request', 400); } const result = await auth.refreshAccessToken(refreshToken); 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 @@ -395,7 +454,7 @@ export async function handleToken(request: Request, env: Env): Promise access_token: accessToken, expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', - refresh_token: newRefreshToken, + ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: newRefreshToken }), Key: user.key, PrivateKey: user.privateKey, AccountKeys: buildAccountKeys(user), @@ -416,7 +475,10 @@ export async function handleToken(request: Request, env: Env): Promise 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); @@ -470,10 +532,17 @@ export async function handleRevocation(request: Request, env: Env): Promise { 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 { - const parts = [ - 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'), - ]; +function getDateParts(date: Date, timeZone: string): string { + const formatter = new Intl.DateTimeFormat('en-CA', { + timeZone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + 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}` : ''; - 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 { @@ -398,7 +412,8 @@ export async function buildBackupArchive( }); const bytes = zipSync(createZipEntries(files)); 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?.({ step: 'archive_ready', fileName, diff --git a/src/types/index.ts b/src/types/index.ts index 7491297..b017d3b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -94,6 +94,7 @@ export interface CipherLogin { uris: CipherLoginUri[] | null; totp: string | null; autofillOnPageLoad: boolean | null; + fido2Credentials: any[] | null; uri: string | null; passwordRevisionDate: string | null; } @@ -346,7 +347,8 @@ export interface TokenResponse { access_token: string; expires_in: number; token_type: string; - refresh_token: string; + refresh_token?: string; + web_session?: boolean; TwoFactorToken?: string; Key: string; PrivateKey: string | null; diff --git a/src/utils/response.ts b/src/utils/response.ts index b104d16..a8f5625 100644 --- a/src/utils/response.ts +++ b/src/utils/response.ts @@ -15,12 +15,42 @@ const DEFAULT_CORS_HEADERS = [ 'X-Request-Email', 'X-Device-Identifier', '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'); - if (!origin) return '*'; - return origin; + if (isWildcardCorsPath(url.pathname)) { + 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 { @@ -35,13 +65,14 @@ function buildCorsHeaders(request: Request): Record { 'Access-Control-Allow-Headers': allowHeaders.join(', '), 'Access-Control-Expose-Headers': '*', 'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds), - 'Access-Control-Allow-Private-Network': 'true', }; - const allowedOrigin = getAllowedOrigin(request); - if (allowedOrigin) { - headers['Access-Control-Allow-Origin'] = allowedOrigin; - headers['Access-Control-Allow-Credentials'] = 'true'; + const corsPolicy = getCorsPolicy(request); + if (corsPolicy.allowOrigin) { + headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin; + if (corsPolicy.allowCredentials) { + headers['Access-Control-Allow-Credentials'] = 'true'; + } headers['Vary'] = 'Origin, Access-Control-Request-Headers'; } diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 4627e9b..5e89afe 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage'; import { createAuthedFetch, getAuthorizedDevices, + clearProfileSnapshot, getCurrentDeviceIdentifier, getPasswordHint, + loadProfileSnapshot, + saveProfileSnapshot, + revokeCurrentSession, getTotpStatus, saveSession, } from '@/lib/api/auth'; @@ -39,6 +43,7 @@ import { performRecoverTwoFactorLogin, performRegistration, performTotpLogin, + hydrateLockedSession, performUnlock, type JwtUnsafeReason, 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 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; + 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_PATHS = [IMPORT_ROUTE, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const; const IMPORT_ROUTE_ALIASES: ReadonlySet = new Set(IMPORT_ROUTE_PATHS.filter((path) => path !== IMPORT_ROUTE)); @@ -124,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' { export default function App() { const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []); const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []); + const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]); const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null); const [location, navigate] = useLocation(); const [phase, setPhase] = useState(initialBootstrap.phase); const [session, setSessionState] = useState(initialBootstrap.session); - const [profile, setProfile] = useState(null); + const [profile, setProfile] = useState(initialProfileSnapshot); const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations); 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 [themePreference, setThemePreference] = useState(() => readThemePreference()); const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme()); + const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key); const [confirm, setConfirm] = useState(null); const [mobileLayout, setMobileLayout] = useState(false); @@ -262,6 +280,16 @@ export default function App() { window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); }, [themePreference]); + useEffect(() => { + saveProfileSnapshot(profile); + }, [profile]); + + useEffect(() => { + if (phase === 'locked' && profile?.key && session) { + setUnlockPreparing(false); + } + }, [phase, profile, session]); + useEffect(() => installMagneticUiFeedback(), []); function handleToggleTheme() { @@ -323,6 +351,7 @@ export default function App() { setSession(boot.session); setProfile(boot.profile); setPhase(boot.phase); + setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key); })(); return () => { @@ -330,9 +359,34 @@ export default function App() { }; }, [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) { setSession(login.session); setProfile(login.profile); + setUnlockPreparing(false); setPendingTotp(null); setTotpCode(''); setPhase('app'); @@ -517,6 +571,7 @@ export default function App() { const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations); setSession(nextSession); setUnlockPassword(''); + setUnlockPreparing(false); setPhase('app'); if (location === '/' || location === '/lock') navigate('/vault'); pushToast('success', t('txt_unlocked')); @@ -533,14 +588,18 @@ export default function App() { delete nextSession.symEncKey; delete nextSession.symMacKey; setSession(nextSession); + setUnlockPreparing(false); setPhase('locked'); navigate('/lock'); } function logoutNow() { + void revokeCurrentSession(sessionRef.current); setConfirm(null); setSession(null); + clearProfileSnapshot(); setProfile(null); + setUnlockPreparing(false); setPendingTotp(null); setPhase('login'); navigate('/login'); @@ -871,9 +930,11 @@ export default function App() { const connect = () => { if (disposed) return; + const accessToken = session.accessToken; + if (!accessToken) return; try { 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:'; socket = new WebSocket(hubUrl.toString()); } catch { @@ -927,17 +988,7 @@ export default function App() { } if (updateType === SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS) { const payload = frame.arguments?.[0]?.Payload; - if ( - payload - && typeof payload === 'object' - && ( - payload.operation === 'backup-restore' - || payload.operation === 'backup-export' - || payload.operation === 'backup-remote-run' - ) - ) { - dispatchBackupProgress(payload as BackupProgressDetail); - } + if (isBackupProgressDetail(payload)) dispatchBackupProgress(payload); continue; } if (updateType !== SIGNALR_UPDATE_TYPE_SYNC_VAULT) continue; @@ -1197,7 +1248,8 @@ export default function App() { { + const normalized = String(status || '').toLowerCase(); + if (normalized === 'active' || normalized === 'banned') return normalized; + return null; + }; + return (
@@ -55,8 +61,10 @@ export default function AdminPage(props: AdminPageProps) { - {props.users.map((user) => ( - + {props.users.map((user) => { + const toggleableStatus = normalizeToggleableStatus(user.status); + return ( + {user.email} {user.name || t('txt_dash')} {roleText(user.role)} @@ -66,8 +74,11 @@ export default function AdminPage(props: AdminPageProps) {
- - ))} + + ); + })} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index 932f979..ffd5d21 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -21,6 +21,7 @@ interface AuthViewsProps { mode: 'login' | 'register' | 'locked'; pendingAction: 'login' | 'register' | 'unlock' | null; unlockReady: boolean; + unlockPreparing: boolean; loginValues: LoginValues; registerValues: RegisterValues; unlockPassword: string; @@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) { type="button" className="auth-link-btn" onClick={props.onShowLockedPasswordHint} - disabled={unlockBusy} + disabled={unlockBusy || props.unlockPreparing} > {t('txt_show_password_hint')} -
{t('txt_or')}
+ + + ); + })} + + + )} )} diff --git a/webapp/src/components/vault/vault-page-helpers.tsx b/webapp/src/components/vault/vault-page-helpers.tsx index 82e914e..cdc67a3 100644 --- a/webapp/src/components/vault/vault-page-helpers.tsx +++ b/webapp/src/components/vault/vault-page-helpers.tsx @@ -194,6 +194,9 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string { uri: valueOrFallback(uri.decUri ?? uri.uri), match: uri.match ?? null, })), + fido2Credentials: (cipher.login.fido2Credentials || []).map((credential) => ({ + creationDate: valueOrFallback(credential.creationDate), + })), } : null, card: cipher.card @@ -262,6 +265,7 @@ export function createEmptyDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [createEmptyLoginUri()], + loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -310,6 +314,9 @@ export function draftFromCipher(cipher: Cipher): VaultDraft { uri: x.decUri || x.uri || '', 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 (cipher.card) { @@ -406,6 +413,16 @@ export function creationTimeValue(cipher: Cipher): number { 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(); export function VaultListIcon({ cipher }: { cipher: Cipher }) { diff --git a/webapp/src/lib/admin-backup-portable.ts b/webapp/src/lib/admin-backup-portable.ts index 75915e1..6ab1a83 100644 --- a/webapp/src/lib/admin-backup-portable.ts +++ b/webapp/src/lib/admin-backup-portable.ts @@ -1,4 +1,4 @@ -import { base64ToBytes, decryptBw } from './crypto'; +import { base64ToBytes, decryptBw, toBufferSource } from './crypto'; import type { AdminBackupSettings, BackupSettingsPortablePayload } from './api/backup'; import type { Profile, SessionState } from './types'; @@ -9,7 +9,7 @@ const AES_GCM_ALGORITHM = 'AES-GCM'; async function importPortablePrivateKey(pkcs8: Uint8Array): Promise { return crypto.subtle.importKey( 'pkcs8', - pkcs8, + toBufferSource(pkcs8), { name: PORTABLE_ALGORITHM, hash: PORTABLE_HASH }, false, ['decrypt'] @@ -17,7 +17,7 @@ async function importPortablePrivateKey(pkcs8: Uint8Array): Promise { } async function importPortableAesKey(keyBytes: Uint8Array): Promise { - 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( @@ -50,15 +50,15 @@ export async function decryptPortableBackupSettings( await crypto.subtle.decrypt( { name: PORTABLE_ALGORITHM }, privateKey, - base64ToBytes(wrap.wrappedKey) + toBufferSource(base64ToBytes(wrap.wrappedKey)) ) ); const aesKey = await importPortableAesKey(portableDek); const plaintext = new Uint8Array( await crypto.subtle.decrypt( - { name: AES_GCM_ALGORITHM, iv: base64ToBytes(portable.iv) }, + { name: AES_GCM_ALGORITHM, iv: toBufferSource(base64ToBytes(portable.iv)) }, aesKey, - base64ToBytes(portable.ciphertext) + toBufferSource(base64ToBytes(portable.ciphertext)) ) ); return JSON.parse(new TextDecoder().decode(plaintext)) as AdminBackupSettings; diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index d13dd4e..a122c4a 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -10,8 +10,10 @@ import type { import { parseJson, type AuthedFetch, type SessionSetter } from './shared'; const SESSION_KEY = 'nodewarden.web.session.v4'; +const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1'; const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1'; const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1'; +const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session'; export interface PreloginResult { hash: string; @@ -26,6 +28,24 @@ export interface PreloginKdfConfig { 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 { 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); @@ -66,12 +86,19 @@ export function loadSession(): SessionState | null { try { const raw = localStorage.getItem(SESSION_KEY); if (!raw) return null; - const parsed = JSON.parse(raw) as SessionState; - if (!parsed.accessToken || !parsed.refreshToken) return null; + const parsed = JSON.parse(raw) as Partial & Partial; + if (parsed.authMode === 'web-cookie' && parsed.email) { + return { + email: parsed.email, + authMode: 'web-cookie', + }; + } + if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; return { accessToken: parsed.accessToken, refreshToken: parsed.refreshToken, email: parsed.email, + authMode: 'token', }; } catch { return null; @@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void { localStorage.removeItem(SESSION_KEY); return; } - const persisted: SessionState = { - accessToken: session.accessToken, - refreshToken: session.refreshToken, + const persisted: PersistedSessionState = { email: session.email, + authMode: session.authMode === 'token' ? 'token' : 'web-cookie', }; localStorage.setItem(SESSION_KEY, JSON.stringify(persisted)); } +export function loadProfileSnapshot(email?: string | null): Profile | null { + try { + const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as Profile; + if (!parsed?.email || !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 { return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim(); } @@ -170,7 +218,10 @@ export async function loginWithPassword( } const resp = await fetch('/identity/connect/token', { 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(), }); const json = (await parseJson(resp)) || {}; @@ -183,18 +234,60 @@ export async function loginWithPassword( return json; } -export async function refreshAccessToken(refreshToken: string): Promise { +function isTransientRefreshStatus(status: number): boolean { + return status === 0 || status === 429 || status >= 500; +} + +export async function refreshAccessToken(session: SessionState): Promise { const body = new URLSearchParams(); body.set('grant_type', 'refresh_token'); - body.set('refresh_token', refreshToken); - const resp = await fetch('/identity/connect/token', { + if (session.authMode !== 'web-cookie' && session.refreshToken) { + body.set('refresh_token', session.refreshToken); + } + try { + const resp = await fetch('/identity/connect/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), + }, + body: body.toString(), + }); + if (!resp.ok) { + const json = await parseJson(resp); + return { + ok: false, + transient: isTransientRefreshStatus(resp.status), + error: json?.error_description || json?.error || 'Session refresh failed', + }; + } + const json = await parseJson(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 { + 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' }, + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + ...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), + }, body: body.toString(), - }); - if (!resp.ok) return null; - const json = await parseJson(resp); - return json || null; + }).catch(() => undefined); } export async function registerAccount(args: { @@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess headers.set('Authorization', `Bearer ${session.accessToken}`); 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); - if (!refreshed?.access_token) { + const refreshed = await refreshAccessToken(session); + if (!refreshed.ok) { + if (refreshed.transient) { + throw new Error(refreshed.error || 'Session refresh temporarily unavailable'); + } setSession(null); throw new Error('Session expired'); } const nextSession: SessionState = { ...session, - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token || session.refreshToken, + accessToken: refreshed.token.access_token, + refreshToken: refreshed.token.refresh_token || session.refreshToken, + authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'), }; setSession(nextSession); saveSession(nextSession); diff --git a/webapp/src/lib/api/backup.ts b/webapp/src/lib/api/backup.ts index 5965c15..80dbc91 100644 --- a/webapp/src/lib/api/backup.ts +++ b/webapp/src/lib/api/backup.ts @@ -16,6 +16,7 @@ import { type AuthedFetch, } from './shared'; import { readResponseBytesWithProgress } from '../download'; +import { toBufferSource } from '../crypto'; import { unzipSync, zipSync } from 'fflate'; export type { @@ -148,32 +149,21 @@ interface BackupExportManifest { 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); if (!match) return null; - const datePart = match[1]; - 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; + return `${match[1]}_${match[2]}`; } -function buildBackupFileName(date: Date, checksumPrefix: string): string { - const parts = [ - 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`; +function buildBackupFileName(timestamp: string, checksumPrefix: string): string { + return `nodewarden_backup_${timestamp}_${checksumPrefix}.zip`; } async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array): Promise { const integrity = await verifyBackupFileIntegrity(bytes, fileName); - const effectiveDate = parseBackupTimestampFromFileName(fileName) || new Date(); - return buildBackupFileName(effectiveDate, integrity.actualPrefix); + const timestamp = extractBackupTimestampFromFileName(fileName); + if (!timestamp) return fileName; + return buildBackupFileName(timestamp, integrity.actualPrefix); } export async function exportAdminBackup( @@ -378,7 +368,7 @@ export function extractBackupFileChecksumPrefix(fileName: string): string | null } async function sha256Hex(bytes: Uint8Array): Promise { - 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(''); } diff --git a/webapp/src/lib/api/send.ts b/webapp/src/lib/api/send.ts index fbee87e..65196e0 100644 --- a/webapp/src/lib/api/send.ts +++ b/webapp/src/lib/api/send.ts @@ -152,10 +152,13 @@ export async function createSend( const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp); const uploadUrl = uploadInfo?.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({ accessToken: session.accessToken, uploadUrl, - payload: encryptedFileBytes, + payload, fileUploadType: uploadInfo?.fileUploadType, unsupportedMessage: 'Unsupported send upload type', onProgress, diff --git a/webapp/src/lib/api/shared.ts b/webapp/src/lib/api/shared.ts index 445a3e4..db1335a 100644 --- a/webapp/src/lib/api/shared.ts +++ b/webapp/src/lib/api/shared.ts @@ -63,14 +63,14 @@ interface UploadWithProgressOptions { accessToken?: string; method?: string; headers?: HeadersInit; - body?: Document | XMLHttpRequestBodyInit | null; + body?: XMLHttpRequestBodyInit | null; onProgress?: (percent: number | null) => void; } interface DirectEncryptedUploadOptions { accessToken: string; uploadUrl: string; - payload: ArrayBuffer | Uint8Array; + payload: XMLHttpRequestBodyInit; fileUploadType: number | null | undefined; unsupportedMessage: string; onProgress?: (percent: number | null) => void; diff --git a/webapp/src/lib/api/vault.ts b/webapp/src/lib/api/vault.ts index 5fc6e14..56b80b3 100644 --- a/webapp/src/lib/api/vault.ts +++ b/webapp/src/lib/api/vault.ts @@ -240,6 +240,7 @@ export async function uploadCipherAttachment( const attachmentId = String(meta.attachmentId || '').trim(); const uploadUrl = String(meta.url || '').trim(); if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed'); + if (!session.accessToken) throw new Error('Unauthorized'); const payload = new ArrayBuffer(encryptedBytes.byteLength); new Uint8Array(payload).set(encryptedBytes); @@ -392,6 +393,56 @@ function toIsoDateOrNow(value: unknown): string { return parsed.toISOString(); } +async function encryptMaybeFidoValue( + value: unknown, + enc: Uint8Array, + mac: Uint8Array, + fallback = '' +): Promise { + 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 { + 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> | null | undefined, + enc: Uint8Array, + mac: Uint8Array +): Promise> | null> { + if (!Array.isArray(credentials) || credentials.length === 0) return null; + const out: Array> = []; + 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( cipher: Cipher | null, userEnc: Uint8Array, @@ -440,10 +491,15 @@ async function buildCipherPayload( } if (type === 1) { + const existingFido2 = + cipher?.login && Array.isArray((cipher.login as any).fido2Credentials) + ? (cipher.login as any).fido2Credentials + : draft.loginFido2Credentials; payload.login = { username: await encryptTextValue(draft.loginUsername, keys.enc, keys.mac), password: await encryptTextValue(draft.loginPassword, keys.enc, keys.mac), totp: await encryptTextValue(draft.loginTotp, keys.enc, keys.mac), + fido2Credentials: await normalizeFido2Credentials(existingFido2, keys.enc, keys.mac), uris: await encryptUris(draft.loginUris || [], keys.enc, keys.mac), }; } else if (type === 3) { diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index f291054..645bdd5 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -2,6 +2,7 @@ import { createAuthedFetch, deriveLoginHashLocally, getProfile, + loadProfileSnapshot, loadSession, loginWithPassword, refreshAccessToken, @@ -26,6 +27,7 @@ export interface BootstrapAppResult { session: SessionState | null; profile: Profile | null; phase: AppPhase; + needsBackgroundHydration?: boolean; } export interface InitialAppBootstrapState { @@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult { newRecoveryCode: string | null; } -function decodeJwtExp(accessToken: string): number | null { +function decodeJwtExp(accessToken: string | undefined): number | null { try { + if (!accessToken) return null; const parts = accessToken.split('.'); if (parts.length < 2) return null; const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); @@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null { } async function maybeRefreshSession(session: SessionState): Promise { - if (!session.refreshToken) return session; + if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null; const exp = decodeJwtExp(session.accessToken); const nowSeconds = Math.floor(Date.now() / 1000); - if (exp !== null && exp - nowSeconds > 60) { + if (session.accessToken && exp !== null && exp - nowSeconds > 60) { return session; } - const refreshed = await refreshAccessToken(session.refreshToken); - if (!refreshed?.access_token) { - return exp !== null && exp > nowSeconds ? session : null; + const refreshed = await refreshAccessToken(session); + if (!refreshed.ok) { + return session.accessToken && exp !== null && exp > nowSeconds ? session : null; } return { ...session, - accessToken: refreshed.access_token, - refreshToken: refreshed.refresh_token || session.refreshToken, + accessToken: refreshed.token.access_token, + 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 { - const session = await maybeRefreshSession(loaded); - if (!session) { - throw new Error('Session expired'); - } const profile = await getProfile( createAuthedFetch( - () => session, + () => refreshedSession, () => {} ) ); return { - defaultKdfIterations, - jwtWarning: null, - session, + session: refreshedSession, profile, - phase: 'locked', }; } catch { return { - defaultKdfIterations, - jwtWarning: null, - session: null, - profile: null, - phase: initial.phase === 'register' ? 'register' : 'login', + session: refreshedSession, + profile: fallbackProfile, }; } } @@ -236,6 +260,7 @@ export async function completeLogin( accessToken: token.access_token, refreshToken: token.refresh_token, email: normalizedEmail, + authMode: token.web_session ? 'web-cookie' : 'token', }; const tempFetch = createAuthedFetch( () => baseSession, diff --git a/webapp/src/lib/app-support.ts b/webapp/src/lib/app-support.ts index 70f403f..4404edc 100644 --- a/webapp/src/lib/app-support.ts +++ b/webapp/src/lib/app-support.ts @@ -99,6 +99,7 @@ export function buildEmptyImportDraft(type: number): VaultDraft { loginPassword: '', loginTotp: '', loginUris: [{ uri: '', match: null }], + loginFido2Credentials: [], cardholderName: '', cardNumber: '', cardBrand: '', @@ -173,6 +174,9 @@ export function importCipherToDraft(cipher: Record, folderId: s }) .filter((u) => !!u.uri); draft.loginUris = uris.length ? uris : [{ uri: '', match: null }]; + draft.loginFido2Credentials = Array.isArray(login.fido2Credentials) + ? login.fido2Credentials.filter((item): item is Record => !!item && typeof item === 'object') + : []; } else if (type === 3) { const card = (cipher.card || {}) as Record; draft.cardholderName = asText(card.cardholderName); diff --git a/webapp/src/lib/crypto.ts b/webapp/src/lib/crypto.ts index 5724005..691e1e4 100644 --- a/webapp/src/lib/crypto.ts +++ b/webapp/src/lib/crypto.ts @@ -18,7 +18,7 @@ export function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array { return out; } -function toBufferSource(bytes: Uint8Array): ArrayBuffer { +export function toBufferSource(bytes: Uint8Array): ArrayBuffer { return new Uint8Array(bytes).buffer; } diff --git a/webapp/src/lib/export-formats.ts b/webapp/src/lib/export-formats.ts index cf6f673..685d1ea 100644 --- a/webapp/src/lib/export-formats.ts +++ b/webapp/src/lib/export-formats.ts @@ -198,6 +198,7 @@ function mapCipherEncrypted(cipher: Cipher): Record { match: (uri as { match?: unknown })?.match ?? null, })) : [], + fido2Credentials: Array.isArray(login.fido2Credentials) ? cloneValue(login.fido2Credentials) : [], } : 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 { out.login = null; diff --git a/webapp/src/lib/i18n.ts b/webapp/src/lib/i18n.ts index f561dab..34f427a 100644 --- a/webapp/src/lib/i18n.ts +++ b/webapp/src/lib/i18n.ts @@ -293,6 +293,7 @@ const messages: Record> = { 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_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_authenticator_key: "Authenticator Key", txt_authorized_devices: "Authorized Devices", @@ -352,6 +353,7 @@ const messages: Record> = { txt_delete_all_invite_codes_active_inactive: "Delete all invite codes (active/inactive)?", txt_delete_all_invites: "Delete all invites", txt_delete_item: "Delete Item", + txt_delete_passkey: "Delete Passkey", txt_delete_item_failed: "Delete item failed", txt_delete_permanently: "Delete Permanently", txt_archive: "Archive", @@ -571,6 +573,9 @@ const messages: Record> = { txt_password_hint_not_set: "No password hint is available for this email.", txt_password_hint_load_failed: "Failed to load password hint", txt_password_hint_too_long: "Password hint must be 120 characters or fewer", + txt_passkey: "Passkey", + txt_passkeys: "Passkeys", + txt_passkey_created_at_value: "Created on {value}", txt_phone: "Phone", txt_please_input_email_and_password: "Please input email and password", txt_please_input_master_password: "Please input master password", @@ -1161,6 +1166,7 @@ const zhCNOverrides: Record = { txt_no_name: '(无名称)', txt_are_you_sure_you_want_to_log_out: '确认要退出登录吗?', txt_delete_item: '删除项目', + txt_delete_passkey: '删除通行密钥', txt_delete_selected_items: '删除所选项目', txt_move_selected_items: '移动所选项目', txt_create_folder: '创建文件夹', @@ -1224,6 +1230,7 @@ const zhCNOverrides: Record = { 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_this_item: '确认删除此项目?', + txt_are_you_sure_you_want_to_delete_this_passkey: '确认删除这个通行密钥?', txt_authenticator_key: '验证器密钥', txt_brand: '品牌', txt_bulk_delete_failed: '批量删除失败', @@ -1324,6 +1331,9 @@ const zhCNOverrides: Record = { txt_password_hint_not_set: '这个邮箱没有可显示的密码提示。', txt_password_hint_load_failed: '加载密码提示失败', txt_password_hint_too_long: '密码提示最多只能输入 120 个字符', + txt_passkey: '通行密钥', + txt_passkeys: '通行密钥', + txt_passkey_created_at_value: '创建于 {value}', txt_phone: '电话', txt_please_input_email_and_password: '请输入邮箱和密码', txt_please_input_master_password: '请输入主密码', diff --git a/webapp/src/lib/import-formats-bitwarden.ts b/webapp/src/lib/import-formats-bitwarden.ts index d276526..ad14a35 100644 --- a/webapp/src/lib/import-formats-bitwarden.ts +++ b/webapp/src/lib/import-formats-bitwarden.ts @@ -31,6 +31,7 @@ export interface BitwardenCipherInput { username?: string | null; password?: string | null; totp?: string | null; + fido2Credentials?: Array> | null; } | null; card?: Record | null; identity?: Record | null; @@ -89,6 +90,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload { username: item.login.username ?? null, password: item.login.password ?? null, totp: item.login.totp ?? null, + fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null, uris: Array.isArray(item.login.uris) ? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null })) : null, diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 170e458..f602fca 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -1,9 +1,10 @@ export type AppPhase = 'register' | 'login' | 'locked' | 'app'; export interface SessionState { - accessToken: string; - refreshToken: string; + accessToken?: string; + refreshToken?: string; email: string; + authMode?: 'token' | 'web-cookie'; symEncKey?: string; symMacKey?: string; } @@ -48,11 +49,17 @@ export interface CipherAttachment { object?: string; } +export interface CipherLoginPasskey { + creationDate?: string | null; + [key: string]: unknown; +} + export interface CipherLogin { username?: string | null; password?: string | null; totp?: string | null; uris?: CipherLoginUri[] | null; + fido2Credentials?: CipherLoginPasskey[] | null; decUsername?: string; decPassword?: string; decTotp?: string; @@ -222,6 +229,7 @@ export interface VaultDraft { loginPassword: string; loginTotp: string; loginUris: VaultDraftLoginUri[]; + loginFido2Credentials: Array>; cardholderName: string; cardNumber: string; cardBrand: string; @@ -265,7 +273,8 @@ export interface WebBootstrapResponse { export interface TokenSuccess { access_token: string; - refresh_token: string; + refresh_token?: string; + web_session?: boolean; expires_in?: number; token_type?: string; TwoFactorToken?: string; diff --git a/webapp/tsconfig.json b/webapp/tsconfig.json index d9d7377..1e83dcc 100644 --- a/webapp/tsconfig.json +++ b/webapp/tsconfig.json @@ -6,9 +6,8 @@ "lib": ["ES2022", "DOM", "DOM.Iterable"], "jsx": "react-jsx", "jsxImportSource": "preact", - "baseUrl": ".", "paths": { - "@/*": ["src/*"], + "@/*": ["./src/*"], "@shared/*": ["../shared/*"] }, "strict": true,