import { createStrengthMeter } from './password-strength.js';
import { COI_STATE, getCOIState, initCOIDetection, onServiceWorkerActivated } from './coi-detector.js';
import { StorageMode, getArchiveScopeId, getStorageMode, getStoredMode, isOpfsEnabled } from './storage.js';
import { SESSION_CONFIG } from './session.js';
import { registerServiceWorker } from './sw-register.js';
let config = null;
let worker = null;
let qrScanner = null;
let strengthMeter = null;
let isUnencryptedArchive = false;
let tofuStatus = { valid: true, isFirstVisit: true };
let unlockInFlight = false;
let decryptInFlight = false;
let activeQrScannerSession = 0;
let activeUnlockRequestId = null;
let activeDecryptRequestId = null;
let nextWorkerRequestId = 1;
let activeAppInitToken = 0;
let qrLibraryLoadPromise = null;
let qrScannerTeardownPromise = null;
let activeSessionExpiryTs = 0;
let activeSessionExpiryTimerId = null;
const LEGACY_SESSION_KEYS = {
DEK: 'cass_session_dek',
EXPIRY: 'cass_session_expiry',
UNLOCKED: 'cass_unlocked',
};
const elements = {
authScreen: null,
appScreen: null,
passwordInput: null,
unlockBtn: null,
togglePassword: null,
qrBtn: null,
qrScanner: null,
qrReader: null,
qrCancelBtn: null,
fingerprintValue: null,
fingerprintHelp: null,
fingerprintTooltip: null,
authError: null,
authProgress: null,
progressFill: null,
progressText: null,
lockBtn: null,
};
async function init() {
cacheElements();
setupEventListeners();
try {
config = await loadConfig();
tofuStatus = await displayFingerprint();
} catch (error) {
showError('Failed to load archive configuration. The archive may be corrupted.');
console.error('Config load error:', error);
return;
}
if (config?.encrypted === false) {
clearStoredSession();
window.cassSession = null;
setupUnencryptedMode();
enableForm();
return;
}
try {
worker = new Worker('./crypto_worker.js');
worker.onmessage = handleWorkerMessage;
worker.onerror = handleWorkerError;
} catch (error) {
showError('Failed to initialize decryption worker. Your browser may not support Web Workers.');
console.error('Worker init error:', error);
disableForm();
return;
}
checkExistingSession();
if (elements.passwordInput && elements.strengthMeter) {
strengthMeter = createStrengthMeter(elements.passwordInput, {
meterContainer: elements.strengthMeter,
labelElement: elements.strengthLabel,
suggestionsList: elements.strengthSuggestions,
});
}
elements.unlockBtn.disabled = false;
elements.passwordInput.disabled = false;
}
function cacheElements() {
elements.authScreen = document.getElementById('auth-screen');
elements.appScreen = document.getElementById('app-screen');
elements.passwordInput = document.getElementById('password');
elements.unlockBtn = document.getElementById('unlock-btn');
elements.togglePassword = document.getElementById('toggle-password');
elements.qrBtn = document.getElementById('qr-btn');
elements.qrScanner = document.getElementById('qr-scanner');
elements.qrReader = document.getElementById('qr-reader');
elements.qrCancelBtn = document.getElementById('qr-cancel-btn');
elements.fingerprintValue = document.getElementById('fingerprint-value');
elements.fingerprintHelp = document.getElementById('fingerprint-help');
elements.fingerprintTooltip = document.getElementById('fingerprint-tooltip');
elements.authError = document.getElementById('auth-error');
elements.authProgress = document.getElementById('auth-progress');
elements.progressFill = elements.authProgress?.querySelector('.progress-fill');
elements.progressText = elements.authProgress?.querySelector('.progress-text');
elements.lockBtn = document.getElementById('lock-btn');
elements.strengthMeter = document.getElementById('strength-meter');
elements.strengthLabel = document.getElementById('strength-label');
elements.strengthSuggestions = document.getElementById('strength-suggestions');
}
function setupEventListeners() {
document.getElementById('auth-form')?.addEventListener('submit', handleUnlockClick);
elements.togglePassword?.addEventListener('click', togglePasswordVisibility);
elements.qrBtn?.addEventListener('click', openQrScanner);
elements.qrCancelBtn?.addEventListener('click', closeQrScanner);
elements.fingerprintHelp?.addEventListener('click', toggleFingerprintTooltip);
elements.lockBtn?.addEventListener('click', handleLockButtonClick);
window.addEventListener('cass:lock', handleExternalLockEvent);
window.addEventListener('cass:session-mode-change', (event) => {
const mode = event?.detail?.mode;
if (mode === StorageMode.MEMORY) {
clearStoredSession();
return;
}
if (window.cassSession?.dek) {
persistSession(window.cassSession.dek, activeSessionExpiryTs);
}
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && !elements.qrScanner?.classList.contains('hidden')) {
void closeQrScanner();
}
});
document.addEventListener('visibilitychange', handleSessionVisibilityChange);
}
function allocateWorkerRequestId() {
const requestId = nextWorkerRequestId;
nextWorkerRequestId += 1;
return requestId;
}
function beginAppInitAttempt() {
activeAppInitToken += 1;
return activeAppInitToken;
}
function isCurrentAppInitToken(token) {
return token === activeAppInitToken;
}
function invalidateAppInitAttempt() {
activeAppInitToken += 1;
}
function beginQrScannerSession() {
activeQrScannerSession += 1;
return activeQrScannerSession;
}
function invalidateQrScannerSession() {
activeQrScannerSession += 1;
}
function isCurrentQrScannerSession(sessionToken) {
return sessionToken === activeQrScannerSession;
}
function clearWorkerKeys() {
try {
worker?.postMessage({ type: 'CLEAR_KEYS' });
} catch (error) {
console.warn('Failed to clear worker keys:', error);
}
}
function clearActiveSessionExpiryTimer() {
if (activeSessionExpiryTimerId !== null) {
clearTimeout(activeSessionExpiryTimerId);
activeSessionExpiryTimerId = null;
}
}
function clearActiveSessionExpiry() {
clearActiveSessionExpiryTimer();
activeSessionExpiryTs = 0;
}
async function expireActiveSession() {
if (!window.cassSession?.dek) {
clearActiveSessionExpiry();
return;
}
await lockArchive({ broadcast: true, action: 'expired' });
showError('Your session expired. Please unlock the archive again.');
}
function scheduleActiveSessionExpiry(expiryTs) {
clearActiveSessionExpiryTimer();
const numericExpiry = Number(expiryTs);
if (!Number.isFinite(numericExpiry) || numericExpiry <= 0) {
activeSessionExpiryTs = 0;
return;
}
activeSessionExpiryTs = Math.trunc(numericExpiry);
const remainingMs = activeSessionExpiryTs - Date.now();
if (remainingMs <= 0) {
void expireActiveSession();
return;
}
activeSessionExpiryTimerId = window.setTimeout(() => {
activeSessionExpiryTimerId = null;
void expireActiveSession();
}, remainingMs);
}
function handleSessionVisibilityChange() {
if (document.hidden || activeSessionExpiryTs <= 0) {
return;
}
if (Date.now() >= activeSessionExpiryTs) {
void expireActiveSession();
return;
}
scheduleActiveSessionExpiry(activeSessionExpiryTs);
}
function broadcastAuthLock(action = 'lock') {
window.dispatchEvent(new CustomEvent('cass:lock', {
detail: {
action,
source: 'auth',
},
}));
}
function isCurrentWorkerMessage(type, requestId) {
if (requestId === null || requestId === undefined) {
return true;
}
switch (type) {
case 'UNLOCK_SUCCESS':
case 'UNLOCK_FAILED':
return requestId === activeUnlockRequestId;
case 'DECRYPT_SUCCESS':
case 'DECRYPT_FAILED':
case 'DB_READY':
return requestId === activeDecryptRequestId;
case 'PROGRESS':
return requestId === activeUnlockRequestId || requestId === activeDecryptRequestId;
default:
return true;
}
}
async function loadConfig() {
const response = await fetch('./config.json');
if (!response.ok) {
throw new Error(`Failed to load config: ${response.status}`);
}
return response.json();
}
function getSessionKeys() {
const scopeId = getArchiveScopeId();
return {
DEK: `cass_session_dek_${scopeId}`,
EXPIRY: `cass_session_expiry_${scopeId}`,
UNLOCKED: `cass_unlocked_${scopeId}`,
};
}
function getTofuKey() {
return `cass_fingerprint_v2_${getArchiveScopeId()}`;
}
async function displayFingerprint() {
try {
const response = await fetch('./integrity.json');
if (response.ok) {
const integrity = await response.json();
const fingerprint = await computeFingerprint(JSON.stringify(integrity));
elements.fingerprintValue.textContent = fingerprint;
const result = await verifyTofu(fingerprint, getTofuKey());
displayTofuStatus(result);
return result;
} else {
const fingerprint = await computeFingerprint(JSON.stringify(config));
elements.fingerprintValue.textContent = fingerprint;
const result = await verifyTofu(fingerprint, getTofuKey());
displayTofuStatus(result);
return result;
}
} catch (error) {
if (config?.export_id) {
const bytes = base64ToBytes(config.export_id);
const fingerprint = formatFingerprint(bytes.slice(0, 8));
elements.fingerprintValue.textContent = fingerprint;
} else {
elements.fingerprintValue.textContent = 'unavailable';
}
return { valid: true, isFirstVisit: true };
}
}
function setupUnencryptedMode() {
isUnencryptedArchive = true;
const subtitle = document.querySelector('.auth-header .subtitle');
if (subtitle) {
subtitle.textContent = 'This archive is NOT encrypted. Anyone with access can read it.';
}
if (elements.passwordInput) {
elements.passwordInput.required = false;
}
const passwordGroup = elements.passwordInput?.closest('.form-group');
passwordGroup?.classList.add('hidden');
const divider = document.querySelector('.auth-form .divider');
divider?.classList.add('hidden');
elements.qrBtn?.classList.add('hidden');
elements.togglePassword?.classList.add('hidden');
if (elements.unlockBtn) {
const label = elements.unlockBtn.querySelector('.btn-text');
if (label) {
label.textContent = 'Open Archive';
}
}
const warning = document.createElement('div');
warning.className = 'tofu-warning-banner';
const warningContent = document.createElement('div');
warningContent.className = 'tofu-warning-content';
const warningTitle = document.createElement('strong');
warningTitle.textContent = 'Unencrypted archive';
warningContent.appendChild(warningTitle);
const warningBody = document.createElement('p');
warningBody.textContent =
'This export was generated WITHOUT encryption. Treat it as public data.';
warningContent.appendChild(warningBody);
warning.appendChild(warningContent);
const authForm = document.querySelector('.auth-form');
if (authForm) {
authForm.parentNode.insertBefore(warning, authForm);
} else {
elements.authScreen?.appendChild(warning);
}
}
async function verifyTofu(currentFingerprint, storageKey) {
try {
const storedFingerprint = localStorage.getItem(storageKey);
if (!storedFingerprint) {
localStorage.setItem(storageKey, currentFingerprint);
return { valid: true, isFirstVisit: true };
}
if (storedFingerprint === currentFingerprint) {
return { valid: true, isFirstVisit: false };
}
return {
valid: false,
reason: 'TOFU_VIOLATION',
previousFingerprint: storedFingerprint,
currentFingerprint: currentFingerprint
};
} catch (e) {
console.warn('TOFU check unavailable:', e);
return { valid: true, isFirstVisit: true };
}
}
function displayTofuStatus(result) {
const helpElement = elements.fingerprintHelp;
if (!helpElement) return;
if (!result.valid && result.reason === 'TOFU_VIOLATION') {
helpElement.classList.add('tofu-warning');
helpElement.textContent = '⚠️';
helpElement.title = 'SECURITY WARNING: Archive fingerprint has changed since your last visit!\n' +
`Previous: ${result.previousFingerprint}\n` +
`Current: ${result.currentFingerprint}\n\n` +
'If you did not expect this change, DO NOT enter your password.';
showTofuWarning(result);
} else if (result.isFirstVisit) {
helpElement.title = 'First visit - fingerprint stored for future verification';
} else {
helpElement.classList.add('tofu-verified');
helpElement.title = 'Fingerprint verified - matches previous visit';
}
}
function showTofuWarning(result) {
let warning = document.getElementById('tofu-warning');
if (!warning) {
warning = document.createElement('div');
warning.id = 'tofu-warning';
warning.className = 'tofu-warning-banner';
warning.innerHTML = `
<div class="tofu-warning-content">
<strong>⚠️ Security Warning</strong>
<p>The archive fingerprint has changed since your last visit.</p>
<p class="tofu-fingerprints">
<span>Previous: <code id="tofu-prev-fp"></code></span>
<span>Current: <code id="tofu-curr-fp"></code></span>
</p>
<p>If you did not expect this change, <strong>DO NOT enter your password</strong>.</p>
<div class="tofu-actions">
<button type="button" id="tofu-accept-btn" class="tofu-accept">I trust this change</button>
<button type="button" id="tofu-dismiss-btn" class="tofu-dismiss">Dismiss warning</button>
</div>
</div>
`;
warning.querySelector('#tofu-prev-fp').textContent = result.previousFingerprint;
warning.querySelector('#tofu-curr-fp').textContent = result.currentFingerprint;
const authForm = document.querySelector('.auth-form');
if (authForm) {
authForm.parentNode.insertBefore(warning, authForm);
} else {
elements.authScreen?.appendChild(warning);
}
document.getElementById('tofu-accept-btn')?.addEventListener('click', () => {
acceptNewFingerprint(result.currentFingerprint);
warning.remove();
});
document.getElementById('tofu-dismiss-btn')?.addEventListener('click', () => {
warning.remove();
});
}
}
function acceptNewFingerprint(newFingerprint) {
const tofuKey = getTofuKey();
try {
localStorage.setItem(tofuKey, newFingerprint);
const helpElement = elements.fingerprintHelp;
if (helpElement) {
helpElement.classList.remove('tofu-warning');
helpElement.classList.add('tofu-verified');
helpElement.title = 'Fingerprint updated - new fingerprint stored';
}
} catch (e) {
console.warn('Failed to store new fingerprint:', e);
}
}
async function computeFingerprint(data) {
const encoder = new TextEncoder();
const dataBytes = encoder.encode(data);
const hashBuffer = await crypto.subtle.digest('SHA-256', dataBytes);
const hashArray = new Uint8Array(hashBuffer);
return formatFingerprint(hashArray.slice(0, 8));
}
function formatFingerprint(bytes) {
return Array.from(bytes)
.map(b => b.toString(16).padStart(2, '0'))
.join(':');
}
async function handleUnlockClick(event) {
if (event) {
event.preventDefault();
}
if (unlockInFlight || decryptInFlight) {
return;
}
if (isUnencryptedArchive) {
await transitionToAppUnencrypted();
return;
}
const password = elements.passwordInput.value;
if (password.length === 0) {
showError('Please enter a password');
elements.passwordInput.focus();
return;
}
if (!worker) {
showError('Decryption worker not initialized');
return;
}
hideError();
showProgress('Deriving key...');
disableForm();
unlockInFlight = true;
activeUnlockRequestId = allocateWorkerRequestId();
worker.postMessage({
type: 'UNLOCK_PASSWORD',
password: password,
config: config,
requestId: activeUnlockRequestId,
});
}
function togglePasswordVisibility() {
const input = elements.passwordInput;
const icon = elements.togglePassword.querySelector('.eye-icon');
if (input.type === 'password') {
input.type = 'text';
icon.textContent = '🙈';
} else {
input.type = 'password';
icon.textContent = '👁';
}
}
function toggleFingerprintTooltip() {
elements.fingerprintTooltip?.classList.toggle('hidden');
}
async function openQrScanner() {
await waitForQrScannerTeardown();
if (qrScanner && !elements.qrScanner?.classList.contains('hidden')) {
return;
}
const sessionToken = beginQrScannerSession();
elements.qrScanner.classList.remove('hidden');
try {
await loadQrScannerLibrary();
} catch (error) {
showError('Failed to load QR scanner library');
await closeQrScanner();
return;
}
if (
!isCurrentQrScannerSession(sessionToken)
|| elements.qrScanner?.classList.contains('hidden')
) {
return;
}
try {
const scanner = new window.Html5Qrcode('qr-reader');
qrScanner = scanner;
await scanner.start(
{ facingMode: 'environment' },
{ fps: 10, qrbox: { width: 250, height: 250 } },
handleQrSuccess,
handleQrError
);
if (
!isCurrentQrScannerSession(sessionToken)
|| elements.qrScanner?.classList.contains('hidden')
) {
await finalizeQrScannerClose(scanner);
return;
}
} catch (error) {
console.error('QR scanner error:', error);
if (error.name === 'NotAllowedError') {
showError('Camera permission denied. Please allow camera access to scan QR codes.');
} else {
showError('Failed to start camera. Please enter password manually.');
}
await closeQrScanner();
}
}
async function closeQrScanner() {
invalidateQrScannerSession();
const scanner = qrScanner;
qrScanner = null;
elements.qrScanner.classList.add('hidden');
let teardown = finalizeQrScannerClose(scanner);
teardown = teardown.finally(() => {
if (qrScannerTeardownPromise === teardown) {
qrScannerTeardownPromise = null;
}
});
qrScannerTeardownPromise = teardown;
await teardown;
}
function handleQrSuccess(decodedText) {
if (unlockInFlight || decryptInFlight) {
return;
}
void closeQrScanner();
hideError();
showProgress('Deriving key from QR...');
disableForm();
unlockInFlight = true;
activeUnlockRequestId = allocateWorkerRequestId();
let recoverySecret;
try {
const data = JSON.parse(decodedText);
recoverySecret = data.recovery_secret || data.secret || decodedText;
} catch {
recoverySecret = decodedText;
}
worker.postMessage({
type: 'UNLOCK_RECOVERY',
recoverySecret: recoverySecret,
config: config,
requestId: activeUnlockRequestId,
});
}
function handleQrError(error) {
if (!error?.includes?.('QR code parse')) {
console.debug('QR scan:', error);
}
}
function handleWorkerMessage(event) {
const payload = event?.data && typeof event.data === 'object' ? event.data : null;
if (!payload || typeof payload.type !== 'string' || payload.type.length === 0) {
console.warn('Ignoring malformed worker message payload');
void handleWorkerError(new Error('Malformed worker response'));
return;
}
const { type, ...data } = payload;
if (!isCurrentWorkerMessage(type, data.requestId)) {
console.debug('Ignoring stale worker message:', type, data.requestId);
return;
}
switch (type) {
case 'UNLOCK_SUCCESS':
handleUnlockSuccess(data);
break;
case 'UNLOCK_FAILED':
handleUnlockFailed(data);
break;
case 'PROGRESS':
updateProgress(data.phase, data.percent);
break;
case 'DECRYPT_SUCCESS':
handleDecryptSuccess(data);
break;
case 'DECRYPT_FAILED':
handleDecryptFailed(data);
break;
case 'DB_READY':
handleDatabaseReady(data);
break;
case 'WORKER_ERROR':
void handleWorkerError(new Error(data.error || 'Worker error'));
break;
default:
console.warn('Unknown worker message type:', type);
void handleWorkerError(new Error(`Unknown worker message type: ${type}`));
}
}
async function handleWorkerError(error) {
console.error('Worker error:', error);
const hadActiveSession =
decryptInFlight
|| unlockInFlight
|| !!window.cassSession?.dek;
invalidateAppInitAttempt();
unlockInFlight = false;
decryptInFlight = false;
await closeQrScanner();
activeUnlockRequestId = null;
activeDecryptRequestId = null;
clearActiveSessionExpiry();
clearWorkerKeys();
clearStoredSession();
window.cassSession = null;
await closeLiveDatabase();
hideProgress();
enableForm();
if (hadActiveSession) {
broadcastAuthLock('lock');
elements.appScreen.classList.add('hidden');
elements.authScreen.classList.remove('hidden');
elements.passwordInput.value = '';
}
showError('An error occurred during decryption. Please try again.');
}
function handleUnlockSuccess(data) {
unlockInFlight = false;
activeUnlockRequestId = null;
hideProgress();
window.cassSession = {
dek: data.dek,
config: config,
};
persistSession(data.dek);
transitionToApp();
}
function handleUnlockFailed(data) {
unlockInFlight = false;
activeUnlockRequestId = null;
hideProgress();
enableForm();
const message = data.error || 'Incorrect password or invalid recovery code';
showError(message);
elements.passwordInput.value = '';
elements.passwordInput.focus();
}
async function handleDecryptSuccess(data) {
const initToken = activeAppInitToken;
updateProgress('Database decrypted', 100);
if (!data?.dbBytes) {
await recoverFromAppInitFailure(
'Decryption did not return a database payload',
new Error('Missing database payload'),
initToken
);
return;
}
try {
const dbModule = await import('./database.js');
let dbBytes;
if (data.dbBytes instanceof ArrayBuffer) {
dbBytes = new Uint8Array(data.dbBytes);
} else if (ArrayBuffer.isView(data.dbBytes)) {
dbBytes = new Uint8Array(
data.dbBytes.buffer,
data.dbBytes.byteOffset,
data.dbBytes.byteLength
);
} else {
throw new Error('Invalid database payload');
}
await dbModule.initDatabase(dbBytes);
if (!isCurrentAppInitToken(initToken)) {
await closeLiveDatabase();
return;
}
const stats = dbModule.getStatistics();
if (!isCurrentAppInitToken(initToken)) {
await closeLiveDatabase();
return;
}
window.dispatchEvent(new CustomEvent('cass:db-ready', {
detail: {
conversationCount: stats.conversations || 0,
messageCount: stats.messages || 0,
},
}));
if (!isCurrentAppInitToken(initToken)) {
await closeLiveDatabase();
return;
}
decryptInFlight = false;
activeDecryptRequestId = null;
} catch (error) {
if (!isCurrentAppInitToken(initToken)) {
await closeLiveDatabase();
return;
}
await recoverFromAppInitFailure('Failed to initialize database', error, initToken);
}
}
function handleDecryptFailed(data) {
invalidateAppInitAttempt();
decryptInFlight = false;
activeDecryptRequestId = null;
void closeQrScanner();
hideProgress();
showError(`Decryption failed: ${data.error}`);
enableForm();
elements.appScreen.classList.add('hidden');
elements.authScreen.classList.remove('hidden');
clearActiveSessionExpiry();
clearWorkerKeys();
clearStoredSession();
window.cassSession = null;
void closeLiveDatabase();
broadcastAuthLock('lock');
elements.passwordInput.value = '';
}
function handleDatabaseReady(data) {
decryptInFlight = false;
activeDecryptRequestId = null;
hideProgress();
window.dispatchEvent(new CustomEvent('cass:db-ready', { detail: data }));
}
async function recoverFromAppInitFailure(message, error, initToken = activeAppInitToken) {
if (!isCurrentAppInitToken(initToken)) {
return;
}
invalidateAppInitAttempt();
console.error(message, error);
unlockInFlight = false;
decryptInFlight = false;
await closeQrScanner();
activeUnlockRequestId = null;
activeDecryptRequestId = null;
clearActiveSessionExpiry();
clearWorkerKeys();
clearStoredSession();
window.cassSession = null;
await closeLiveDatabase();
broadcastAuthLock('lock');
hideProgress();
enableForm();
elements.appScreen.classList.add('hidden');
elements.authScreen.classList.remove('hidden');
if (elements.passwordInput) {
elements.passwordInput.value = '';
}
showError(message);
}
function transitionToApp() {
if (decryptInFlight) {
return;
}
const appInitToken = beginAppInitAttempt();
decryptInFlight = true;
activeDecryptRequestId = allocateWorkerRequestId();
elements.authScreen.classList.add('hidden');
elements.appScreen.classList.remove('hidden');
try {
worker.postMessage({
type: 'DECRYPT_DATABASE',
dek: window.cassSession.dek,
config: config,
opfsEnabled: isOpfsEnabled(),
requestId: activeDecryptRequestId,
});
} catch (error) {
void recoverFromAppInitFailure('Failed to start archive decryption', error, appInitToken);
return;
}
void loadViewerModule(appInitToken).catch((error) => {
void recoverFromAppInitFailure('Failed to load archive viewer', error, appInitToken);
});
}
async function transitionToAppUnencrypted() {
if (decryptInFlight) {
return;
}
const appInitToken = beginAppInitAttempt();
decryptInFlight = true;
hideError();
disableForm();
elements.authScreen.classList.add('hidden');
elements.appScreen.classList.remove('hidden');
try {
await loadViewerModule(appInitToken);
} catch (error) {
await recoverFromAppInitFailure('Failed to load archive viewer', error, appInitToken);
return;
}
try {
const didLoad = await loadUnencryptedDatabase(appInitToken);
if (!didLoad || !isCurrentAppInitToken(appInitToken)) {
return;
}
decryptInFlight = false;
} catch (error) {
await recoverFromAppInitFailure('Failed to load unencrypted database', error, appInitToken);
}
}
async function loadUnencryptedDatabase(initToken = activeAppInitToken) {
const payloadPath = getUnencryptedPayloadPath();
const response = await fetch(payloadPath);
if (!response.ok) {
throw new Error(`Failed to load database: ${response.status}`);
}
const dbBytes = new Uint8Array(await response.arrayBuffer());
if (!isCurrentAppInitToken(initToken)) {
return false;
}
const dbModule = await import('./database.js');
await dbModule.initDatabase(dbBytes);
if (!isCurrentAppInitToken(initToken)) {
await closeLiveDatabase();
return false;
}
const stats = dbModule.getStatistics();
window.dispatchEvent(new CustomEvent('cass:db-ready', {
detail: {
conversationCount: stats.conversations || 0,
messageCount: stats.messages || 0,
},
}));
return true;
}
function getUnencryptedPayloadPath() {
const rawPath = config?.payload?.path;
if (typeof rawPath === 'string' && rawPath.trim().length > 0) {
return normalizeUnencryptedPayloadPath(rawPath);
}
return './payload/data.db';
}
function normalizeUnencryptedPayloadPath(rawPath) {
const trimmed = rawPath.trim();
if (!trimmed) {
throw new Error('Unencrypted payload path cannot be empty');
}
if (trimmed.startsWith('/') || trimmed.startsWith('\\') || /^[A-Za-z]:[\\/]/.test(trimmed)) {
throw new Error('Unencrypted payload path must be relative');
}
if (trimmed.includes('?') || trimmed.includes('#') || trimmed.includes('\\')) {
throw new Error('Unencrypted payload path contains invalid characters');
}
let normalized = trimmed;
while (normalized.startsWith('./')) {
normalized = normalized.slice(2);
}
const segments = normalized.split('/');
if (segments.length < 2) {
throw new Error('Unencrypted payload path must reference a file under payload/');
}
const safeSegments = [];
for (const segment of segments) {
if (!segment || segment === '.' || segment === '..') {
throw new Error('Unencrypted payload path contains traversal segments');
}
let decodedSegment;
try {
decodedSegment = decodeURIComponent(segment);
} catch (error) {
throw new Error('Unencrypted payload path contains invalid escapes');
}
if (
decodedSegment === '.'
|| decodedSegment === '..'
|| decodedSegment.includes('/')
|| decodedSegment.includes('\\')
|| decodedSegment.includes('\0')
) {
throw new Error('Unencrypted payload path contains invalid encoded segments');
}
safeSegments.push(segment);
}
if (safeSegments[0] !== 'payload') {
throw new Error('Unencrypted payload path must reside under payload/');
}
return `./${safeSegments.join('/')}`;
}
function handleLockButtonClick(event) {
if (event) {
event.preventDefault();
}
void lockArchive({ broadcast: true, action: 'lock' });
}
function handleExternalLockEvent(event) {
if (event?.detail?.source === 'auth') {
return;
}
void lockArchive({
broadcast: false,
action: event?.detail?.action || 'lock',
});
}
async function closeLiveDatabase() {
try {
const dbModule = await import('./database.js');
dbModule.closeDatabase();
} catch (error) {
console.warn('Failed to close live database during lock:', error);
}
}
async function lockArchive(options = {}) {
const { broadcast = false, action = 'lock' } = options;
invalidateAppInitAttempt();
unlockInFlight = false;
decryptInFlight = false;
await closeQrScanner();
activeUnlockRequestId = null;
activeDecryptRequestId = null;
clearActiveSessionExpiry();
window.cassSession = null;
clearStoredSession();
clearWorkerKeys();
if (broadcast) {
broadcastAuthLock(action);
}
await closeLiveDatabase();
elements.appScreen.classList.add('hidden');
elements.authScreen.classList.remove('hidden');
elements.passwordInput.value = '';
enableForm();
hideError();
hideProgress();
}
async function loadQrScannerLibrary() {
if (window.Html5Qrcode) {
return;
}
if (qrLibraryLoadPromise) {
await qrLibraryLoadPromise;
return;
}
qrLibraryLoadPromise = new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = './vendor/html5-qrcode.min.js';
script.onload = () => {
qrLibraryLoadPromise = null;
resolve();
};
script.onerror = (error) => {
qrLibraryLoadPromise = null;
script.remove();
reject(error);
};
document.head.appendChild(script);
});
await qrLibraryLoadPromise;
}
async function waitForQrScannerTeardown() {
if (qrScannerTeardownPromise) {
await qrScannerTeardownPromise;
}
}
async function finalizeQrScannerClose(scanner) {
if (scanner) {
try {
await scanner.stop();
} catch (error) {
}
try {
await scanner.clear();
} catch (error) {
}
}
if (qrScanner === scanner) {
qrScanner = null;
}
elements.qrReader?.replaceChildren();
}
function checkExistingSession() {
if (tofuStatus?.valid === false) {
clearStoredSession();
return;
}
const restored = restoreSession();
if (restored) {
transitionToApp();
}
}
function getPreferredSessionMode() {
const currentMode = getStorageMode();
if (
currentMode === StorageMode.MEMORY
|| currentMode === StorageMode.SESSION
|| currentMode === StorageMode.LOCAL
) {
return currentMode;
}
const savedMode = getStoredMode();
if (
savedMode === StorageMode.MEMORY
|| savedMode === StorageMode.SESSION
|| savedMode === StorageMode.LOCAL
) {
return savedMode;
}
return StorageMode.MEMORY;
}
function getSessionStorage(mode) {
try {
if (mode === StorageMode.SESSION) {
return sessionStorage;
}
if (mode === StorageMode.LOCAL) {
return localStorage;
}
} catch (e) {
}
return null;
}
function persistSession(dekBase64, expiryTs = activeSessionExpiryTs) {
const expiry = Number.isFinite(Number(expiryTs)) && Number(expiryTs) > Date.now()
? Math.trunc(Number(expiryTs))
: Date.now() + SESSION_CONFIG.DEFAULT_DURATION_MS;
scheduleActiveSessionExpiry(expiry);
const mode = getPreferredSessionMode();
clearStoredSession();
const storage = getSessionStorage(mode);
if (!storage) {
return;
}
const sessionKeys = getSessionKeys();
try {
storage.setItem(sessionKeys.DEK, dekBase64);
storage.setItem(sessionKeys.EXPIRY, expiry.toString());
storage.setItem(sessionKeys.UNLOCKED, 'true');
} catch (e) {
}
}
function restoreSession() {
const mode = getPreferredSessionMode();
const storage = getSessionStorage(mode);
if (!storage || !config) {
clearStoredSession();
return false;
}
try {
const sessionKeys = getSessionKeys();
const unlocked = storage.getItem(sessionKeys.UNLOCKED);
const dekStored = storage.getItem(sessionKeys.DEK);
const expiry = parseInt(storage.getItem(sessionKeys.EXPIRY) || '0', 10);
if (unlocked !== 'true' || !dekStored) {
clearStoredSession();
return false;
}
if (Date.now() > expiry) {
clearStoredSession();
return false;
}
window.cassSession = {
dek: dekStored,
config: config,
};
scheduleActiveSessionExpiry(expiry);
return true;
} catch (e) {
clearStoredSession();
return false;
}
}
function clearStoredSession() {
const sessionKeys = getSessionKeys();
for (const storage of [getSessionStorage(StorageMode.SESSION), getSessionStorage(StorageMode.LOCAL)]) {
if (!storage) {
continue;
}
try {
for (const key of [
sessionKeys.DEK,
sessionKeys.EXPIRY,
sessionKeys.UNLOCKED,
LEGACY_SESSION_KEYS.DEK,
LEGACY_SESSION_KEYS.EXPIRY,
LEGACY_SESSION_KEYS.UNLOCKED,
]) {
storage.removeItem(key);
}
} catch (e) {
}
}
}
async function loadViewerModule(initToken = activeAppInitToken) {
const module = await import('./viewer.js');
if (!isCurrentAppInitToken(initToken)) {
return;
}
module.init?.();
}
function showError(message) {
const errorMsg = elements.authError.querySelector('.error-message');
if (errorMsg) {
errorMsg.textContent = message;
}
elements.authError.classList.remove('hidden');
}
function hideError() {
elements.authError.classList.add('hidden');
}
function showProgress(text) {
elements.progressText.textContent = text;
elements.progressFill.style.width = '0%';
elements.authProgress.classList.remove('hidden');
}
function updateProgress(phase, percent) {
elements.progressText.textContent = phase;
elements.progressFill.style.width = `${percent}%`;
}
function hideProgress() {
elements.authProgress.classList.add('hidden');
}
function disableForm() {
elements.passwordInput.disabled = true;
elements.unlockBtn.disabled = true;
elements.qrBtn.disabled = true;
}
function enableForm() {
elements.passwordInput.disabled = false;
elements.unlockBtn.disabled = false;
elements.qrBtn.disabled = false;
}
function base64ToBytes(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
function bootstrapCrossOriginIsolation() {
const coiStatus = document.getElementById('coi-status');
const authScreen = document.getElementById('auth-screen');
const appScreen = document.getElementById('app-screen');
const revealAuthScreenIfLocked = () => {
if (!authScreen) {
return;
}
if (appScreen && !appScreen.classList.contains('hidden')) {
return;
}
authScreen.classList.remove('hidden');
};
authScreen?.classList.add('hidden');
registerServiceWorker().catch((error) => {
console.warn('Service worker registration failed:', error);
});
initCOIDetection({
statusContainer: coiStatus,
authContainer: authScreen,
onReady: revealAuthScreenIfLocked,
maxWaitMs: 3000,
}).then((state) => {
console.log('[App] COI initialization complete, state:', state);
}).catch((error) => {
console.error('[App] COI initialization failed:', error);
coiStatus?.classList.add('hidden');
revealAuthScreenIfLocked();
});
onServiceWorkerActivated(async () => {
const state = await getCOIState();
if (state === COI_STATE.READY && authScreen?.classList.contains('hidden')) {
coiStatus?.classList.add('hidden');
revealAuthScreenIfLocked();
}
});
}
function startApp() {
bootstrapCrossOriginIsolation();
void init();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', startApp);
} else {
startApp();
}