Files
securebit-chat/sw.js
T
lockbitchat 27279ae7c6
CodeQL Analysis / Analyze CodeQL (push) Waiting to run
Deploy Application / deploy (push) Waiting to run
Mirror to Codeberg / mirror (push) Waiting to run
Mirror to PrivacyGuides / mirror (push) Waiting to run
feat(crypto): Double Ratchet forward secrecy; hardening pass; release v5.7.1
Adds the Double Ratchet (Signal's design) on top of the existing ECDH session
keys, so message protection no longer rests on one set of keys lasting the whole
conversation. Every message gets its own key, derived through a one-way function
and discarded after use, and each change of direction introduces a fresh ECDH
key pair that re-keys the session root.

The ratchet needed no handshake change: both peers already hold each other's
authenticated ECDH public key, and the safety code compared during verification
covers exactly those keys. Its root is derived from the existing shared secret
through its own branch of the key schedule.

Support is negotiated in the invitation and response and used only when both
sides have it; a peer on an earlier release falls back to per-session keys. The
security panel reports which of the two is actually in force.

Out-of-order delivery is supported within fixed bounds (512 skipped keys per
chain, 1024 retained, five-minute expiry), and inbound frames are authenticated
before any ratchet state is committed, so a malformed frame cannot desynchronise
a live session.

Also in this release:

- Verification is enforced as a gate, not a label: control frames (reconnection
  signalling, call setup, message deletion, delivery receipts) are acted on only
  after both peers have compared the safety code, and verified state is set in a
  single guarded place.
- Chat content reaches the interface through one authenticated path; an older,
  weaker inbound path was retired.
- The security panel measures what it displays — several checks previously
  returned a fixed result and now exercise the subsystem they describe.
- Invitation data is no longer kept in local storage, and entries left by earlier
  versions are cleared on first launch.
- View-once and disappearing messages no longer place their text in system
  notifications.
- Shared-secret buffers are overwritten once derivation completes; scanned QR
  codes are decompressed with a size limit; voice notes are validated against
  audio type and size budgets before skipping the consent prompt; the master
  password is collected by the app rather than a browser dialog.
- Connection setup no longer fails on networks where STUN/TURN are unreachable:
  it proceeds as soon as usable candidates exist and only waits while there are
  none.

Test suite grows from 27 to 41 files, covering forward secrecy, post-compromise
re-keying, out-of-order delivery across ratchet steps, the skipped-key bounds,
tamper resistance, negotiation fallback, and byte-level key-derivation
compatibility with 5.6.0.
2026-08-05 23:02:20 -04:00

545 lines
18 KiB
JavaScript

// SecureBit.chat Service Worker
// Conservative PWA Edition v4.7.56 - Minimal Caching Strategy
// Enhanced with version-aware cache management
// Dynamic version detection from meta.json
let APP_VERSION = 'v4.7.56';
let CACHE_NAME = 'securebit-pwa-v4.7.56';
let STATIC_CACHE = 'securebit-pwa-static-v4.7.56';
let DYNAMIC_CACHE = 'securebit-pwa-dynamic-v4.7.56';
// Build stamp — rewritten by scripts/post-build.js on every release so this file's
// bytes change each deploy. That is what makes the browser detect a new Service Worker,
// reinstall it, drop stale caches and (via controllerchange) prompt the page to update.
const SW_BUILD_VERSION = '1785985047695';
// Load version from meta.json on install
async function getAppVersion() {
try {
const response = await fetch('/meta.json?t=' + Date.now(), {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate'
}
});
if (response.ok) {
const meta = await response.json();
const version = meta.version || meta.buildVersion || 'v4.7.56';
APP_VERSION = version;
CACHE_NAME = `securebit-pwa-${version}`;
STATIC_CACHE = `securebit-pwa-static-${version}`;
DYNAMIC_CACHE = `securebit-pwa-dynamic-${version}`;
return version;
}
} catch (error) {
console.warn('⚠️ Failed to load version from meta.json, using default');
}
return APP_VERSION;
}
// Essential files for PWA offline functionality
// DO NOT include JS files from dist/ - they should load from network for updates
const STATIC_ASSETS = [
'/',
'/index.html',
'/manifest.json',
// DO NOT cache /dist/app.js and /dist/app-boot.js - they should be updated
// This allows the update system to work correctly
// Essential styles for PWA
'/src/styles/pwa.css',
// PWA icons (required for install)
'/logo/icon-192x192.png',
'/logo/icon-512x512.png',
'/logo/favicon.ico',
// PWA components only
'/src/pwa/pwa-manager.js',
'/src/pwa/install-prompt.js',
'/src/scripts/pwa-register.js',
'/src/scripts/pwa-offline-test.js'
];
// Sensitive files that should never be cached
const SENSITIVE_PATTERNS = [
/\/api\//,
/preimage/,
/payment/,
/session/,
/auth/,
/verification/
];
// Network first patterns (always try network first)
const NETWORK_FIRST_PATTERNS = [
/\/api\//,
/\/session\//,
/\/payment\//,
/\/verification\//,
/preimage/,
/auth/
];
// Cache first patterns (only essential PWA assets)
const CACHE_FIRST_PATTERNS = [
/manifest\.json$/,
/logo\/icon-.*\.png$/,
/logo\/favicon\.ico$/,
/src\/styles\/pwa\.css$/,
/src\/pwa\/.*\.js$/,
/src\/scripts\/pwa-.*\.js$/
];
// Explicit allowlist for any response that may be written to Cache Storage.
// Unknown GET responses are deliberately excluded by default.
const CACHEABLE_PATHS = new Set([
'/',
'/index.html',
'/manifest.json',
'/src/styles/pwa.css',
'/logo/icon-192x192.png',
'/logo/icon-512x512.png',
'/logo/favicon.ico',
'/src/pwa/pwa-manager.js',
'/src/pwa/install-prompt.js',
'/src/scripts/pwa-register.js',
'/src/scripts/pwa-offline-test.js'
]);
function isSensitivePath(pathname) {
return SENSITIVE_PATTERNS.some(pattern => pattern.test(pathname));
}
function isCacheableStaticPath(pathname) {
return CACHEABLE_PATHS.has(pathname);
}
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'PWA_INSTALLED') {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({ type: 'PWA_INSTALL_DETECTED' });
});
});
}
});
// Install event - cache static assets with better error handling
self.addEventListener('install', (event) => {
event.waitUntil(
getAppVersion().then(async (version) => {
console.log('📦 Service Worker installing with version:', version);
return caches.open(STATIC_CACHE)
.then(async (cache) => {
// Cache assets one by one to handle failures gracefully
const cachePromises = STATIC_ASSETS.map(async (url) => {
try {
// Skip sensitive patterns
if (SENSITIVE_PATTERNS.some(pattern => pattern.test(url))) {
return;
}
// Add cache-busting for meta.json
if (url.includes('meta.json')) {
url = url + '?t=' + Date.now();
}
await cache.add(url);
} catch (error) {
console.warn(`⚠️ Failed to cache ${url}:`, error.message);
// Continue with other assets even if one fails
}
});
await Promise.allSettled(cachePromises);
// Force activation of new service worker
return self.skipWaiting();
})
.catch((error) => {
console.error('❌ Failed to open cache:', error);
// Still skip waiting to activate the service worker
return self.skipWaiting();
});
})
);
});
// Activate event - clean up old caches and notify about updates
self.addEventListener('activate', (event) => {
event.waitUntil(
getAppVersion().then(async (version) => {
console.log('✅ Service Worker activating with version:', version);
const cacheNames = await caches.keys();
// Remove all old caches that don't match current version
const deletePromises = cacheNames.map(cacheName => {
// Remove caches that don't match current version
if (cacheName !== STATIC_CACHE &&
cacheName !== DYNAMIC_CACHE &&
cacheName !== CACHE_NAME &&
cacheName.startsWith('securebit-pwa-')) {
console.log(`🗑️ Removing old cache: ${cacheName}`);
return caches.delete(cacheName);
}
});
await Promise.all(deletePromises);
// Notify all clients about the update
return self.clients.claim().then(() => {
self.clients.matchAll().then(clients => {
clients.forEach(client => {
client.postMessage({
type: 'SW_ACTIVATED',
version: version,
timestamp: Date.now()
});
});
});
});
})
);
});
// Removed duplicate activate event code
// Fetch event - handle requests with security-aware caching
self.addEventListener('fetch', (event) => {
const url = new URL(event.request.url);
// Skip non-GET requests
if (event.request.method !== 'GET') {
return;
}
// Skip sensitive endpoints
if (isSensitivePath(url.pathname)) {
console.log('🔒 Skipping cache for sensitive endpoint:', url.pathname);
return;
}
// Skip chrome-extension and non-http requests
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return;
}
// Network-first for meta.json (never cache)
if (url.pathname === '/meta.json' || url.pathname.endsWith('/meta.json')) {
event.respondWith(
fetch(event.request, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
}).catch(() => {
// Fallback if network is unavailable
return new Response(JSON.stringify({
version: APP_VERSION,
error: 'Network unavailable'
}), {
headers: { 'Content-Type': 'application/json' }
});
})
);
return;
}
// Network-first for JS files from dist/ (don't cache for updates)
if (url.pathname.startsWith('/dist/') && (url.pathname.endsWith('.js') || url.pathname.endsWith('.mjs'))) {
event.respondWith(
fetch(event.request, {
cache: 'no-store',
headers: {
'Cache-Control': 'no-cache, no-store, must-revalidate',
'Pragma': 'no-cache'
}
}).catch((error) => {
// Log error for debugging
console.warn('⚠️ Failed to fetch JS file:', url.pathname, error.message);
// Try to get from cache as fallback
return caches.match(event.request).then(cachedResponse => {
if (cachedResponse) {
console.log('📦 Using cached version of:', url.pathname);
return cachedResponse;
}
// Only return 503 if no cache available
return new Response('Network unavailable', {
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'text/plain' }
});
});
})
);
return;
}
event.respondWith(handleRequest(event.request));
});
// Conservative request handling - only cache PWA essentials
async function handleRequest(request) {
const url = new URL(request.url);
try {
// Strategy 1: Cache First (only for essential PWA assets)
if (
url.origin === self.location.origin &&
isCacheableStaticPath(url.pathname) &&
!isSensitivePath(url.pathname) &&
CACHE_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))
) {
return await cacheFirst(request);
}
// Strategy 2: Network First (for all other requests)
if (NETWORK_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))) {
return await networkFirst(request);
}
// Strategy 3: Network First for everything else (no aggressive caching)
return await networkFirst(request);
} catch (error) {
console.error('❌ Request handling failed:', error);
return await handleOffline(request);
}
}
// Cache First strategy with Response cloning fix
async function cacheFirst(request) {
const url = new URL(request.url);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
try {
const networkResponse = await fetch(request);
if (
networkResponse &&
networkResponse.ok &&
url.origin === self.location.origin &&
isCacheableStaticPath(url.pathname) &&
!isSensitivePath(url.pathname)
) {
// Clone the response before using it
const responseToCache = networkResponse.clone();
const cache = await caches.open(STATIC_CACHE);
cache.put(request, responseToCache);
}
return networkResponse;
} catch (error) {
console.warn('⚠️ Cache-first strategy failed:', error.message);
return await handleOffline(request);
}
}
// Network First strategy with Response cloning fix
async function networkFirst(request) {
const url = new URL(request.url);
try {
const networkResponse = await fetch(request);
if (networkResponse && networkResponse.ok) {
// Only cache explicitly known-safe static responses.
if (
url.origin === self.location.origin &&
isCacheableStaticPath(url.pathname) &&
!isSensitivePath(url.pathname)
) {
// Clone the response before caching
const responseToCache = networkResponse.clone();
const cache = await caches.open(DYNAMIC_CACHE);
cache.put(request, responseToCache).catch(err => {
console.warn('⚠️ Cache put failed (non-critical):', err.message);
});
}
return networkResponse;
}
// If response is not ok, try cache
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return networkResponse; // Return the non-ok response anyway
} catch (error) {
console.warn('⚠️ Network-first strategy failed:', error.message);
const cachedResponse = await caches.match(request);
if (cachedResponse) {
return cachedResponse;
}
return await handleOffline(request);
}
}
// Stale While Revalidate strategy with Response cloning fix
async function staleWhileRevalidate(request) {
const url = new URL(request.url);
const cachedResponse = await caches.match(request);
const networkResponsePromise = fetch(request)
.then((networkResponse) => {
if (
networkResponse &&
networkResponse.ok &&
url.origin === self.location.origin &&
isCacheableStaticPath(url.pathname) &&
!isSensitivePath(url.pathname)
) {
// Clone the response before caching
const responseToCache = networkResponse.clone();
caches.open(DYNAMIC_CACHE)
.then(cache => cache.put(request, responseToCache))
.catch(error => console.warn('⚠️ Cache update failed:', error.message));
}
return networkResponse;
})
.catch(error => {
console.warn('⚠️ Network request failed:', error.message);
return null;
});
return cachedResponse || networkResponsePromise || handleOffline(request);
}
// Offline fallback - minimal caching for PWA only
async function handleOffline(request) {
const url = new URL(request.url);
// For navigation requests, return cached index.html
if (request.destination === 'document' || request.mode === 'navigate') {
const cachedIndex = await caches.match('/index.html');
if (cachedIndex) {
return cachedIndex;
}
// Fallback to root if index.html not found
const cachedRoot = await caches.match('/');
if (cachedRoot) {
return cachedRoot;
}
}
// For PWA assets, try to return cached version
if (CACHE_FIRST_PATTERNS.some(pattern => pattern.test(url.pathname))) {
const cachedAsset = await caches.match(request);
if (cachedAsset) {
return cachedAsset;
}
}
// Return a generic offline response for everything else
return new Response(
JSON.stringify({
error: 'Offline',
message: 'Network unavailable - PWA offline mode',
url: url.pathname
}),
{
status: 503,
statusText: 'Service Unavailable',
headers: { 'Content-Type': 'application/json' }
}
);
}
// Background sync for failed requests
self.addEventListener('sync', (event) => {
if (event.tag === 'retry-failed-requests') {
event.waitUntil(retryFailedRequests());
}
});
async function retryFailedRequests() {
try {
// Get all cached requests that failed
const cache = await caches.open(DYNAMIC_CACHE);
const requests = await cache.keys();
for (const request of requests) {
try {
// Try to fetch the request again
const response = await fetch(request);
if (response.ok) {
// Update cache with successful response
await cache.put(request, response);
}
} catch (error) {
console.warn('⚠️ Retry failed for:', request.url, error.message);
}
}
} catch (error) {
console.error('❌ Failed to retry requests:', error);
}
}
// Notification click handler
self.addEventListener('notificationclick', (event) => {
event.notification.close();
event.waitUntil(
clients.openWindow('/')
);
});
// Message handler for communication with main thread
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') {
self.skipWaiting();
}
if (event.data && event.data.type === 'CACHE_CLEAR') {
event.waitUntil(clearCaches());
}
if (event.data && event.data.type === 'CACHE_STATUS') {
event.waitUntil(getCacheStatus().then(status => {
event.ports[0].postMessage(status);
}));
}
});
// Clear all caches
async function clearCaches() {
const cacheNames = await caches.keys();
await Promise.all(
cacheNames.map(cacheName => caches.delete(cacheName))
);
}
// Get cache status
async function getCacheStatus() {
const cacheNames = await caches.keys();
const status = {};
for (const cacheName of cacheNames) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
status[cacheName] = keys.length;
}
return status;
}
// Error handler
self.addEventListener('error', (event) => {
console.error('❌ Service Worker error:', event.error);
});
// Unhandled rejection handler
self.addEventListener('unhandledrejection', (event) => {
console.error('❌ Service Worker unhandled rejection:', event.reason);
});