let dek = null;
let config = null;
const MAX_ARCHIVE_CHUNK_SIZE = 32 * 1024 * 1024;
const MAX_ARCHIVE_CHUNKS = 0xFFFFFFFF;
function hashScopeId(input) {
let hash = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
hash ^= input.charCodeAt(i);
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
function getArchiveScopeId() {
try {
return hashScopeId(new URL('./', self.location.href).href);
} catch (error) {
const href = typeof self?.location?.href === 'string'
? self.location.href
: 'unknown';
return hashScopeId(href.split('#')[0].split('?')[0]);
}
}
function getArchiveOpfsDbName() {
return `cass-archive-${getArchiveScopeId()}.db`;
}
self.onmessage = async (event) => {
const payload = event?.data && typeof event.data === 'object' ? event.data : null;
const requestId = payload && 'requestId' in payload ? payload.requestId : null;
if (!payload || typeof payload.type !== 'string' || payload.type.length === 0) {
console.warn('Ignoring malformed worker request payload');
if (requestId !== null && requestId !== undefined) {
self.postMessage({
type: 'WORKER_ERROR',
error: 'Malformed worker request payload',
requestId,
});
}
return;
}
const { type, ...data } = payload;
try {
switch (type) {
case 'UNLOCK_PASSWORD':
await handleUnlockPassword(data.password, data.config, requestId);
break;
case 'UNLOCK_RECOVERY':
await handleUnlockRecovery(data.recoverySecret, data.config, requestId);
break;
case 'DECRYPT_DATABASE':
await handleDecryptDatabase(data.dek, data.config, data.opfsEnabled, requestId);
break;
case 'CLEAR_KEYS':
clearKeys();
break;
default:
throw new Error(`Unknown worker message type: ${type}`);
}
} catch (error) {
console.error('Worker error:', error);
self.postMessage({
type: getWorkerFailureMessageType(type),
error: error?.message || String(error),
requestId,
});
}
};
function getWorkerFailureMessageType(type) {
switch (type) {
case 'UNLOCK_PASSWORD':
case 'UNLOCK_RECOVERY':
return 'UNLOCK_FAILED';
case 'DECRYPT_DATABASE':
return 'DECRYPT_FAILED';
default:
return 'WORKER_ERROR';
}
}
async function handleUnlockPassword(password, cfg, requestId) {
config = cfg;
validateSupportedPayloadFormat(config);
const passwordSlots = config.key_slots.filter(s => s.slot_type === 'password');
if (passwordSlots.length === 0) {
throw new Error('No password slot found in archive');
}
self.postMessage({ type: 'PROGRESS', phase: 'Deriving key...', percent: 10, requestId });
for (const slot of passwordSlots) {
try {
const kek = await deriveKekFromPassword(password, slot);
self.postMessage({ type: 'PROGRESS', phase: 'Unwrapping key...', percent: 80, requestId });
const unwrappedDek = await unwrapDek(kek, slot, config.export_id);
dek = unwrappedDek;
self.postMessage({
type: 'UNLOCK_SUCCESS',
dek: arrayToBase64(dek),
requestId,
});
return;
} catch (error) {
console.debug('Slot unlock failed:', error);
}
}
throw new Error('Incorrect password');
}
async function handleUnlockRecovery(recoverySecret, cfg, requestId) {
config = cfg;
validateSupportedPayloadFormat(config);
const recoverySlots = config.key_slots.filter(s => s.slot_type === 'recovery');
if (recoverySlots.length === 0) {
throw new Error('No recovery slot found in archive');
}
self.postMessage({ type: 'PROGRESS', phase: 'Deriving key...', percent: 10, requestId });
let secretBytes;
if (typeof recoverySecret === 'string') {
try {
secretBytes = base64ToArray(recoverySecret);
} catch {
secretBytes = new TextEncoder().encode(recoverySecret);
}
} else {
secretBytes = recoverySecret;
}
for (const slot of recoverySlots) {
try {
const kek = await deriveKekFromRecovery(secretBytes, slot);
self.postMessage({ type: 'PROGRESS', phase: 'Unwrapping key...', percent: 80, requestId });
const unwrappedDek = await unwrapDek(kek, slot, config.export_id);
dek = unwrappedDek;
self.postMessage({
type: 'UNLOCK_SUCCESS',
dek: arrayToBase64(dek),
requestId,
});
return;
} catch (error) {
console.debug('Recovery slot unlock failed:', error);
}
}
throw new Error('Invalid recovery code');
}
async function deriveKekFromPassword(password, slot) {
const params = slot.argon2_params || config.kdf_defaults;
const salt = base64ToArray(slot.salt);
if (!self.argon2) {
await loadArgon2();
}
const result = await self.argon2.hash({
pass: password,
salt: salt,
time: params.iterations,
mem: params.memory_kb,
parallelism: params.parallelism,
hashLen: 32,
type: self.argon2.ArgonType.Argon2id,
});
return new Uint8Array(result.hash);
}
async function deriveKekFromRecovery(secretBytes, slot) {
const salt = base64ToArray(slot.salt);
const info = new TextEncoder().encode('cass-pages-kek-v2');
const baseKey = await crypto.subtle.importKey(
'raw',
secretBytes,
'HKDF',
false,
['deriveBits']
);
const kekBits = await crypto.subtle.deriveBits(
{
name: 'HKDF',
hash: 'SHA-256',
salt: salt,
info: info,
},
baseKey,
256
);
return new Uint8Array(kekBits);
}
async function unwrapDek(kek, slot, exportId) {
const wrappedDek = base64ToArray(slot.wrapped_dek);
const nonce = base64ToArray(slot.nonce);
const exportIdBytes = base64ToArray(exportId);
const aad = new Uint8Array(exportIdBytes.length + 1);
aad.set(exportIdBytes);
aad[exportIdBytes.length] = slot.id;
const kekKey = await crypto.subtle.importKey(
'raw',
kek,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const dekBytes = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: nonce,
additionalData: aad,
},
kekKey,
wrappedDek
);
return new Uint8Array(dekBytes);
}
async function handleDecryptDatabase(dekBase64, cfg, opfsEnabled, requestId) {
config = cfg;
validateSupportedPayloadFormat(config);
dek = base64ToArray(dekBase64);
const { payload } = config;
const totalChunks = payload.chunk_count;
const baseNonce = base64ToArray(config.base_nonce);
const exportId = base64ToArray(config.export_id);
self.postMessage({ type: 'PROGRESS', phase: 'Decrypting...', percent: 0, requestId });
const dekKey = await crypto.subtle.importKey(
'raw',
dek,
{ name: 'AES-GCM' },
false,
['decrypt']
);
const plaintextChunks = [];
let totalDecrypted = 0;
for (let i = 0; i < totalChunks; i++) {
const chunkName = `chunk-${String(i).padStart(5, '0')}.bin`;
const expectedChunkPath = `payload/${chunkName}`;
if (payload.files[i] !== expectedChunkPath) {
throw new Error(`Invalid payload file entry ${i}: expected ${expectedChunkPath}`);
}
const chunkUrl = `./payload/${chunkName}`;
try {
const response = await fetch(chunkUrl);
if (!response.ok) {
throw new Error(`Failed to fetch chunk ${i}: ${response.status}`);
}
const encryptedChunk = await response.arrayBuffer();
const chunkNonce = deriveChunkNonce(baseNonce, i);
const aad = buildChunkAad(exportId, i);
const decrypted = await crypto.subtle.decrypt(
{
name: 'AES-GCM',
iv: chunkNonce,
additionalData: aad,
},
dekKey,
encryptedChunk
);
const plaintext = await decompressDeflate(new Uint8Array(decrypted));
plaintextChunks.push(plaintext);
totalDecrypted += plaintext.byteLength;
const percent = Math.round(((i + 1) / totalChunks) * 90);
self.postMessage({
type: 'PROGRESS',
phase: `Decrypting chunk ${i + 1}/${totalChunks}...`,
percent: percent,
requestId,
});
} catch (error) {
throw new Error(`Failed to decrypt chunk ${i}: ${error.message}`);
}
}
self.postMessage({ type: 'PROGRESS', phase: 'Loading database...', percent: 95, requestId });
const dbBytes = concatenateChunks(plaintextChunks);
const transfer = dbBytes.buffer.slice(
dbBytes.byteOffset,
dbBytes.byteOffset + dbBytes.byteLength
);
self.postMessage(
{
type: 'DECRYPT_SUCCESS',
dbSize: dbBytes.byteLength,
dbBytes: transfer,
requestId,
},
[transfer]
);
}
function validateSupportedPayloadFormat(cfg) {
if (!cfg || typeof cfg !== 'object') {
throw new Error('Invalid archive config');
}
if (cfg.version !== 2) {
throw new Error(`Unsupported archive schema version: ${cfg.version ?? 'missing'}`);
}
if (cfg.compression !== 'deflate') {
throw new Error(`Unsupported archive compression: ${cfg.compression ?? 'missing'}`);
}
const payload = cfg.payload;
if (!payload || typeof payload !== 'object') {
throw new Error('Invalid archive payload metadata');
}
if (!Number.isSafeInteger(payload.chunk_size) || payload.chunk_size <= 0) {
throw new Error(`Invalid archive chunk_size: ${payload.chunk_size ?? 'missing'}`);
}
if (payload.chunk_size > MAX_ARCHIVE_CHUNK_SIZE) {
throw new Error(`Invalid archive chunk_size: ${payload.chunk_size} exceeds maximum ${MAX_ARCHIVE_CHUNK_SIZE}`);
}
if (!Number.isSafeInteger(payload.chunk_count) || payload.chunk_count < 0) {
throw new Error(`Invalid archive chunk_count: ${payload.chunk_count ?? 'missing'}`);
}
if (payload.chunk_count > MAX_ARCHIVE_CHUNKS) {
throw new Error(`Invalid archive chunk_count: ${payload.chunk_count} exceeds maximum`);
}
if (!Array.isArray(payload.files) || payload.files.length !== payload.chunk_count) {
throw new Error('Invalid archive payload files list');
}
}
function deriveChunkNonce(baseNonce, counter) {
const nonce = new Uint8Array(12);
nonce.set(baseNonce.subarray(0, 8));
const counterView = new DataView(new ArrayBuffer(4));
counterView.setUint32(0, counter, false); const counterBytes = new Uint8Array(counterView.buffer);
nonce.set(counterBytes, 8);
return nonce;
}
function buildChunkAad(exportId, chunkIndex) {
const SCHEMA_VERSION = 2;
const aad = new Uint8Array(exportId.length + 4 + 1); aad.set(exportId);
const view = new DataView(aad.buffer, exportId.length, 4);
view.setUint32(0, chunkIndex, false);
aad[exportId.length + 4] = SCHEMA_VERSION;
return aad;
}
function concatenateChunks(chunks) {
const totalLength = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const chunk of chunks) {
result.set(chunk, offset);
offset += chunk.byteLength;
}
return result;
}
async function decompressDeflate(compressed) {
if (self.fflate?.inflateSync) {
return self.fflate.inflateSync(compressed);
}
if (self.DecompressionStream) {
const ds = new DecompressionStream('deflate-raw');
const writer = ds.writable.getWriter();
const reader = ds.readable.getReader();
writer.write(compressed);
writer.close();
const chunks = [];
while (true) {
const { done, value } = await reader.read();
if (done) break;
chunks.push(value);
}
return concatenateChunks(chunks);
}
await loadFflate();
return self.fflate.inflateSync(compressed);
}
async function initDatabase(dbBytes, opfsEnabled, requestId) {
if (!self.sqlite3) {
await loadSqlite();
}
try {
const sqlite3 = await self.sqlite3InitModule();
let db;
if (opfsEnabled && sqlite3.oo1.OpfsDb) {
try {
const opfsDbName = getArchiveOpfsDbName();
const opfs = await navigator.storage.getDirectory();
const fileHandle = await opfs.getFileHandle(opfsDbName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(dbBytes);
await writable.close();
db = new sqlite3.oo1.OpfsDb(opfsDbName);
} catch (opfsError) {
console.warn('OPFS not available, using in-memory:', opfsError);
db = new sqlite3.oo1.DB();
db.deserialize(dbBytes);
}
} else {
db = new sqlite3.oo1.DB();
db.deserialize(dbBytes);
}
self.cassDb = db;
self.postMessage({
type: 'DB_READY',
conversationCount: getConversationCount(db),
messageCount: getMessageCount(db),
requestId,
});
} catch (error) {
throw new Error(`Failed to initialize database: ${error.message}`);
}
}
function getConversationCount(db) {
try {
const result = db.exec('SELECT COUNT(*) FROM conversations');
return result[0]?.values[0][0] || 0;
} catch {
return 0;
}
}
function getMessageCount(db) {
try {
const result = db.exec('SELECT COUNT(*) FROM messages');
return result[0]?.values[0][0] || 0;
} catch {
return 0;
}
}
function clearKeys() {
if (dek) {
dek.fill(0);
dek = null;
}
config = null;
if (self.cassDb) {
try {
self.cassDb.close();
} catch {
}
self.cassDb = null;
}
}
async function loadArgon2() {
try {
importScripts('./vendor/argon2-wasm.js');
} catch (error) {
throw new Error('Failed to load Argon2 library. Ensure argon2-wasm.js is in the vendor folder.');
}
}
async function loadFflate() {
try {
importScripts('./vendor/fflate.min.js');
} catch (error) {
throw new Error('Failed to load decompression library.');
}
}
async function loadSqlite() {
try {
importScripts('./vendor/sqlite3.js');
} catch (error) {
throw new Error('Failed to load SQLite library.');
}
}
function base64ToArray(base64) {
const normalized = normalizeBase64(base64);
const binary = atob(normalized);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function normalizeBase64(base64) {
const trimmed = base64.trim().replace(/-/g, '+').replace(/_/g, '/');
const padding = trimmed.length % 4;
if (padding === 0) {
return trimmed;
}
return trimmed + '='.repeat(4 - padding);
}
function arrayToBase64(bytes) {
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}