feat: add PWA offline unlock support

This commit is contained in:
shuaiplus
2026-06-09 12:09:44 +08:00
parent 5ed7c949c1
commit d4749d3f82
10 changed files with 527 additions and 10 deletions
+6
View File
@@ -18,6 +18,12 @@
<link rel="icon" type="image/svg+xml" href="/nodewarden-logo-bg.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="manifest" href="/manifest.webmanifest" />
<meta name="theme-color" content="#0f172a" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="NodeWarden" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<title>NodeWarden</title>
<style>
+48
View File
@@ -0,0 +1,48 @@
{
"name": "NodeWarden",
"short_name": "NodeWarden",
"description": "A lightweight Bitwarden-compatible vault for Cloudflare Workers.",
"id": "/",
"start_url": "/vault",
"scope": "/",
"display": "standalone",
"display_override": ["window-controls-overlay", "standalone", "minimal-ui"],
"orientation": "any",
"background_color": "#eef4ff",
"theme_color": "#0f172a",
"categories": ["security", "productivity", "utilities"],
"icons": [
{
"src": "/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"shortcuts": [
{
"name": "Vault",
"short_name": "Vault",
"url": "/vault",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
},
{
"name": "TOTP Codes",
"short_name": "TOTP",
"url": "/vault/totp",
"icons": [{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }]
}
]
}
+2
View File
@@ -54,6 +54,7 @@ import { useToastManager } from '@/hooks/useToastManager';
import { t } from '@/lib/i18n';
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
import {
@@ -746,6 +747,7 @@ export default function App() {
setConfirm(null);
setSession(null);
clearProfileSnapshot();
clearOfflineUnlockRecord();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
+8
View File
@@ -95,6 +95,12 @@ export function loadSession(): SessionState | null {
authMode: 'web-cookie',
};
}
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
return {
email: parsed.email,
authMode: 'token',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return {
accessToken: parsed.accessToken,
@@ -233,6 +239,7 @@ export async function loginWithPassword(
totpCode?: string;
rememberDevice?: boolean;
useRememberToken?: boolean;
signal?: AbortSignal;
}
): Promise<TokenSuccess | TokenError> {
const body = new URLSearchParams();
@@ -262,6 +269,7 @@ export async function loginWithPassword(
[WEB_SESSION_HEADER]: '1',
},
body: body.toString(),
signal: options?.signal,
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (resp.ok) {
+85 -7
View File
@@ -12,6 +12,14 @@ import {
} from '@/lib/api/auth';
import { readInviteCodeFromUrl } from '@/lib/app-support';
import { t, translateServerError } from '@/lib/i18n';
import {
getOfflineUnlockKdfIterations,
hasOfflineUnlockRecord,
kdfIterationsFromLogin,
loadOfflineProfileSnapshot,
saveOfflineUnlockRecord,
unlockOfflineVaultWithMasterKey,
} from '@/lib/offline-auth';
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
export interface PendingTotp {
@@ -93,6 +101,20 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
};
}
function browserReportsOffline(): boolean {
return typeof navigator !== 'undefined' && navigator.onLine === false;
}
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
if (typeof AbortController === 'undefined') return null;
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
return {
controller,
cancel: () => clearTimeout(timer),
};
}
function readWindowBootstrap(): WebBootstrapResponse {
if (typeof window === 'undefined') return {};
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
@@ -248,6 +270,12 @@ export async function hydrateLockedSession(
): Promise<{ session: SessionState | null; profile: Profile | null }> {
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
if (hasOfflineUnlockRecord(session.email)) {
return {
session,
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
};
}
return { session: null, profile: null };
}
try {
@@ -272,7 +300,8 @@ export async function hydrateLockedSession(
export async function completeLogin(
token: TokenSuccess,
email: string,
masterKey: Uint8Array
masterKey: Uint8Array,
fallbackKdfIterations: number
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -291,6 +320,12 @@ export async function completeLogin(
throw new Error('Missing profile key');
}
const keys = await unlockVaultKey(profile.key, masterKey);
saveOfflineUnlockRecord({
email: normalizedEmail,
profile,
profileKey: profile.key,
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
});
return {
session: { ...baseSession, ...keys },
profile,
@@ -310,7 +345,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) {
return {
kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
};
}
@@ -342,7 +377,7 @@ export async function performTotpLogin(
rememberDevice,
});
if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000));
}
const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -361,7 +396,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) {
return {
login: await completeLogin(token, normalizedEmail, derived.masterKey),
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
newRecoveryCode: recovered.newRecoveryCode || null,
};
}
@@ -397,13 +432,56 @@ export async function performUnlock(
fallbackIterations: number
): Promise<PasswordLoginResult> {
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
const hasOfflineUnlock = !!offlineIterations;
const kdfIterations = offlineIterations || fallbackIterations;
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
const unlockOffline = async (): Promise<PasswordLoginResult> => {
try {
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
return {
kind: 'success',
login: {
session: offline.session,
profile: offline.profile,
profilePromise: Promise.resolve(offline.profile),
},
};
} catch {
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
}
};
if (hasOfflineUnlock && browserReportsOffline()) {
return unlockOffline();
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
signal: abortable?.controller.signal,
});
} catch {
if (hasOfflineUnlock) {
return unlockOffline();
}
return {
kind: 'error',
message: t('txt_unlock_failed_master_password_is_incorrect'),
};
} finally {
abortable?.cancel();
}
if ('access_token' in token && token.access_token) {
return {
kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey),
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
};
}
+160
View File
@@ -0,0 +1,160 @@
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
interface OfflineUnlockRecord {
version: 1;
email: string;
profile: Profile;
profileKey: string;
kdfIterations: number;
savedAt: number;
}
function normalizeEmail(email: string | null | undefined): string {
return String(email || '').trim().toLowerCase();
}
function stripOfflineProfile(profile: Profile): Profile {
return {
...profile,
email: normalizeEmail(profile.email),
key: '',
privateKey: null,
};
}
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
const email = normalizeEmail(parsed.email);
const profileKey = String(parsed.profileKey || '').trim();
const iterations = Number(parsed.kdfIterations || 0);
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
return null;
}
const profile = parsed.profile && typeof parsed.profile === 'object'
? stripOfflineProfile(parsed.profile as Profile)
: {
id: '',
email,
name: email,
key: '',
privateKey: null,
role: 'user',
};
return {
version: 1,
email,
profile,
profileKey,
kdfIterations: iterations,
savedAt: Number(parsed.savedAt || 0) || 0,
};
} catch {
return null;
}
}
function readRecord(): OfflineUnlockRecord | null {
if (typeof localStorage === 'undefined') return null;
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
}
export function hasOfflineUnlockRecord(email?: string | null): boolean {
const record = readRecord();
if (!record) return false;
const normalized = normalizeEmail(email);
return !normalized || record.email === normalized;
}
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
const record = readRecord();
if (!record) return null;
const normalized = normalizeEmail(email);
if (normalized && record.email !== normalized) return null;
return record.kdfIterations;
}
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
const record = readRecord();
if (!record) return null;
const normalized = normalizeEmail(email);
if (normalized && record.email !== normalized) return null;
return stripOfflineProfile(record.profile);
}
export function saveOfflineUnlockRecord(args: {
email: string;
profile: Profile;
profileKey: string;
kdfIterations: number;
}): void {
if (typeof localStorage === 'undefined') return;
const email = normalizeEmail(args.email || args.profile.email);
const profileKey = String(args.profileKey || '').trim();
const kdfIterations = Number(args.kdfIterations || 0);
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
const record: OfflineUnlockRecord = {
version: 1,
email,
profile: stripOfflineProfile({ ...args.profile, email }),
profileKey,
kdfIterations,
savedAt: Date.now(),
};
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
}
export function clearOfflineUnlockRecord(): void {
try {
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
} catch {
// Ignore storage failures during logout cleanup.
}
}
export async function unlockOfflineVault(
session: SessionState,
profile: Profile | null,
password: string
): Promise<{ session: SessionState; profile: Profile }> {
const record = readRecord();
const email = normalizeEmail(profile?.email || session.email);
if (!record || record.email !== email) {
throw new Error('Offline unlock is not available on this device.');
}
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
}
export async function unlockOfflineVaultWithMasterKey(
session: SessionState,
profile: Profile | null,
masterKey: Uint8Array
): Promise<{ session: SessionState; profile: Profile }> {
const record = readRecord();
const email = normalizeEmail(profile?.email || session.email);
if (!record || record.email !== email) {
throw new Error('Offline unlock is not available on this device.');
}
const keys = await unlockVaultKey(record.profileKey, masterKey);
return {
session: {
...session,
email: record.email,
...keys,
},
profile: {
...stripOfflineProfile(record.profile),
key: record.profileKey,
},
};
}
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
const value = Number(token.KdfIterations || fallbackIterations || 600000);
return Number.isFinite(value) && value > 0 ? value : 600000;
}
+11
View File
@@ -0,0 +1,11 @@
export function registerNodeWardenServiceWorker(): void {
if (typeof window === 'undefined') return;
if (!('serviceWorker' in navigator)) return;
if (import.meta.env.DEV) return;
window.addEventListener('load', () => {
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
// PWA support is progressive enhancement; the vault still works without it.
});
});
}
+2
View File
@@ -2,6 +2,7 @@ import { render } from 'preact';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { initI18n } from './lib/i18n';
import { registerNodeWardenServiceWorker } from './lib/pwa';
import './tailwind.css';
import './styles.css';
@@ -29,4 +30,5 @@ function renderApp(): void {
void initI18n().finally(() => {
renderApp();
registerNodeWardenServiceWorker();
});
+203 -1
View File
@@ -1,10 +1,212 @@
import { fileURLToPath } from 'node:url';
import path from 'node:path';
import { createHash } from 'node:crypto';
import preact from '@preact/preset-vite';
import { defineConfig, type Plugin } from 'vite';
const rootDir = fileURLToPath(new URL('.', import.meta.url));
function buildServiceWorkerSource(precacheUrls: string[], version: string): string {
return `const CACHE_VERSION = ${JSON.stringify(`nodewarden-pwa-${version}`)};
const APP_SHELL_CACHE = \`\${CACHE_VERSION}-shell\`;
const RUNTIME_CACHE = 'nodewarden-pwa-runtime-v1';
const PRECACHE_URLS = ${JSON.stringify(precacheUrls, null, 2)};
const CRITICAL_SHELL_URLS = ['/', '/index.html', '/vault'];
const STATIC_PATH_RE = /^\\/(?:assets\\/|payment-logos\\/|icon-|logo-|favicon|apple-touch-icon|nodewarden-|manifest\\.webmanifest$)/;
const NEVER_CACHE_PATH_RE = /^\\/(?:api|identity|setup|config|notifications|icons|\\.well-known|cdn-cgi)(?:\\/|$)/;
const OFFLINE_FALLBACK_HTML = '<!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>NodeWarden</title><style>html,body{height:100%;margin:0;background:#eef4ff;color:#0f172a;font-family:ui-sans-serif,system-ui,sans-serif}.boot-screen{min-height:100%;display:grid;place-items:center;padding:24px;box-sizing:border-box}.boot-card{width:min(420px,100%);display:grid;gap:12px;justify-items:center;padding:28px;border:1px solid rgba(148,163,184,.35);border-radius:22px;background:rgba(255,255,255,.86);box-shadow:0 20px 45px rgba(15,23,42,.1)}.boot-logo{width:74px;height:58px;object-fit:contain}.boot-title{font-weight:700}.boot-sub{color:#475569;text-align:center;font-size:14px;line-height:1.5}</style></head><body><div class="boot-screen"><div class="boot-card"><img class="boot-logo" src="/nodewarden-logo.svg" alt=""><div class="boot-title">NodeWarden</div><div class="boot-sub">Offline cache is not ready on this device. Open NodeWarden once while online, then try offline again.</div></div></div></body></html>';
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(APP_SHELL_CACHE)
.then(async (cache) => {
await cache.addAll(CRITICAL_SHELL_URLS);
const nonCriticalUrls = PRECACHE_URLS.filter((url) => !CRITICAL_SHELL_URLS.includes(url));
await Promise.allSettled(nonCriticalUrls.map((url) => cache.add(url)));
})
.then(() => self.skipWaiting())
);
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(
keys
.filter((key) => key.startsWith('nodewarden-pwa-') && key.endsWith('-shell') && key !== APP_SHELL_CACHE)
.map((key) => caches.delete(key))
))
.then(() => self.clients.claim())
);
});
function isSameOriginHttpGet(request) {
if (request.method !== 'GET') return false;
const url = new URL(request.url);
return url.origin === self.location.origin;
}
function isCacheableResponse(response) {
return response && response.ok && (response.type === 'basic' || response.type === 'default');
}
async function refreshNavigationCache(request) {
const cache = await caches.open(APP_SHELL_CACHE);
try {
const response = await fetch(request);
if (isCacheableResponse(response)) {
await cache.put('/', response.clone());
await cache.put('/index.html', response.clone());
await warmStaticDependencies(response.clone());
}
return response;
} catch {
return null;
}
}
async function warmStaticDependencies(response) {
try {
const html = await response.text();
const runtimeCache = await caches.open(RUNTIME_CACHE);
const urls = Array.from(html.matchAll(/\\b(?:src|href)=["']([^"']+)["']/g))
.map((match) => {
try {
return new URL(match[1], self.location.origin);
} catch {
return null;
}
})
.filter((url) => url && url.origin === self.location.origin && STATIC_PATH_RE.test(url.pathname))
.map((url) => url.pathname + url.search);
await Promise.allSettled(Array.from(new Set(urls)).map((url) => runtimeCache.add(url)));
await trimRuntimeCache(runtimeCache, 120);
} catch {
// Dependency warming is best-effort; never slow or break navigation for it.
}
}
async function appShellNavigation(request) {
const cache = await caches.open(APP_SHELL_CACHE);
const url = new URL(request.url);
return (
(await cache.match(request, { ignoreSearch: true }))
|| (await cache.match(url.pathname, { ignoreSearch: true }))
|| (await cache.match('/'))
|| (await cache.match('/index.html'))
|| new Response(OFFLINE_FALLBACK_HTML, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=UTF-8' },
})
);
}
async function trimRuntimeCache(cache, maxEntries) {
const keys = await cache.keys();
if (keys.length <= maxEntries) return;
await Promise.all(keys.slice(0, keys.length - maxEntries).map((key) => cache.delete(key)));
}
async function cacheFirst(request) {
const shellCache = await caches.open(APP_SHELL_CACHE);
const cachedShell = await shellCache.match(request);
if (cachedShell) return cachedShell;
const runtimeCache = await caches.open(RUNTIME_CACHE);
const cachedRuntime = await runtimeCache.match(request);
if (cachedRuntime) return cachedRuntime;
const legacyRuntime = await matchLegacyRuntimeCache(request);
if (legacyRuntime) return legacyRuntime;
const response = await fetch(request);
if (isCacheableResponse(response)) {
void runtimeCache.put(request, response.clone()).then(() => trimRuntimeCache(runtimeCache, 120));
}
return response;
}
async function matchLegacyRuntimeCache(request) {
const keys = await caches.keys();
for (const key of keys) {
if (key === RUNTIME_CACHE || !key.startsWith('nodewarden-pwa-') || !key.endsWith('-runtime')) continue;
const cache = await caches.open(key);
const cached = await cache.match(request);
if (cached) return cached;
}
return null;
}
self.addEventListener('fetch', (event) => {
const request = event.request;
if (!isSameOriginHttpGet(request)) return;
const url = new URL(request.url);
if (NEVER_CACHE_PATH_RE.test(url.pathname)) return;
if (request.mode === 'navigate') {
event.respondWith(appShellNavigation(request));
if (navigator.onLine !== false) {
event.waitUntil(refreshNavigationCache(request));
}
return;
}
if (STATIC_PATH_RE.test(url.pathname) || request.destination === 'script' || request.destination === 'style' || request.destination === 'font' || request.destination === 'image' || request.destination === 'worker') {
event.respondWith(cacheFirst(request));
}
});
`;
}
function buildCacheVersion(isDemo: boolean, urls: string[]): string {
const digest = createHash('sha256')
.update(`${isDemo ? 'demo' : 'app'}\n${urls.join('\n')}`)
.digest('hex')
.slice(0, 16);
return `${isDemo ? 'demo' : 'app'}-${digest}`;
}
function pwaServiceWorkerPlugin(isDemo: boolean): Plugin {
return {
name: 'nodewarden-pwa-service-worker',
generateBundle(_, bundle) {
const urls = new Set<string>([
'/',
'/index.html',
'/vault',
'/manifest.webmanifest',
'/nodewarden-logo.svg',
'/nodewarden-logo-bg.svg',
'/nodewarden-wordmark.svg',
'/favicon.ico',
'/favicon-32.png',
'/apple-touch-icon.png',
'/icon-192.png',
'/icon-512.png',
'/logo-64.png',
]);
const buildUrls = new Set<string>(urls);
for (const [fileName, output] of Object.entries(bundle)) {
if (output.type !== 'chunk' && output.type !== 'asset') continue;
if (fileName === 'sw.js' || fileName === 'robots.txt') continue;
if (fileName.endsWith('.map')) continue;
buildUrls.add(`/${fileName}`);
}
const sortedUrls = Array.from(urls).sort();
const version = buildCacheVersion(isDemo, Array.from(buildUrls).sort());
this.emitFile({
type: 'asset',
fileName: 'sw.js',
source: buildServiceWorkerSource(sortedUrls, version),
});
},
};
}
function searchIndexPolicyPlugin(isDemo: boolean): Plugin {
return {
name: 'nodewarden-search-index-policy',
@@ -59,7 +261,7 @@ export default defineConfig(({ mode }) => {
return {
root: rootDir,
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo)],
plugins: [preact(), searchIndexPolicyPlugin(isDemo), resourcePriorityPlugin(isDemo), pwaServiceWorkerPlugin(isDemo)],
define: {
__NODEWARDEN_DEMO__: JSON.stringify(isDemo),
},