// SpotNotes End-to-End Encryption (E2EE) Module // Provides client-side zero-knowledge encryption using standard Web Crypto API. // Server operator never has access to plaintext private notes or user private keys. window.SpotCrypto = window.SpotCrypto || {}; (function() { const PBKDF2_ITERATIONS = 100000; const SESSION_KEY_NAME = 'spotnotes_user_privkey'; // In-memory cache for decrypted symmetric notepad keys: padId -> CryptoKey const padKeyCache = new Map(); let currentUserPrivateKey = null; // --- Utility: Encoding / Decoding --- function bufToB64(buffer) { let binary = ''; const bytes = new Uint8Array(buffer); for (let i = 0; i < bytes.byteLength; i++) { binary += String.fromCharCode(bytes[i]); } return window.btoa(binary); } function b64ToBuf(base64) { const binary = window.atob(base64); const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) { bytes[i] = binary.charCodeAt(i); } return bytes.buffer; } function bufToB64Url(buffer) { return bufToB64(buffer).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); } function b64UrlToBuf(b64url) { let b64 = b64url.replace(/-/g, '+').replace(/_/g, '/'); while (b64.length % 4) b64 += '='; return b64ToBuf(b64); } // --- Key Derivation (PBKDF2) for User Password --- async function deriveKEK(password, saltUint8Array) { const enc = new TextEncoder(); const baseKey = await crypto.subtle.importKey( 'raw', enc.encode(password), 'PBKDF2', false, ['deriveKey'] ); return await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltUint8Array, iterations: PBKDF2_ITERATIONS, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ); } // --- User Keypair (RSA-OAEP) Generation & Storage --- window.SpotCrypto.generateUserRegistrationPayload = async function(email, password, firstName, lastName) { // 1. Generate RSA-OAEP Keypair const keyPair = await crypto.subtle.generateKey( { name: 'RSA-OAEP', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['encrypt', 'decrypt'] ); // 2. Export Public Key as JWK string const pubJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey); const publicKeyStr = JSON.stringify(pubJwk); // 3. Generate random 16-byte salt for user password KEK const salt = crypto.getRandomValues(new Uint8Array(16)); const saltStr = bufToB64(salt.buffer); // 4. Derive KEK and encrypt the private key const kek = await deriveKEK(password, salt); const privJwk = await crypto.subtle.exportKey('jwk', keyPair.privateKey); const privJsonBytes = new TextEncoder().encode(JSON.stringify(privJwk)); const iv = crypto.getRandomValues(new Uint8Array(12)); const encryptedPrivBuffer = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, kek, privJsonBytes ); const encPrivatePayload = JSON.stringify({ iv: bufToB64(iv.buffer), ct: bufToB64(encryptedPrivBuffer) }); // Cache private key in session currentUserPrivateKey = keyPair.privateKey; try { sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk)); } catch (e) { console.warn('sessionStorage unavailable', e); } return { email, password, first_name: firstName, last_name: lastName, public_key: publicKeyStr, encrypted_private_key: encPrivatePayload, encryption_salt: saltStr }; }; // Unlock User Private Key on Login window.SpotCrypto.unlockUserPrivateKey = async function(user, password) { if (!user.encrypted_private_key || !user.encryption_salt) { console.warn('User has no encryption key stored.'); return null; } try { const salt = new Uint8Array(b64ToBuf(user.encryption_salt)); const kek = await deriveKEK(password, salt); const encBundle = JSON.parse(user.encrypted_private_key); const iv = new Uint8Array(b64ToBuf(encBundle.iv)); const ct = b64ToBuf(encBundle.ct); const decryptedBuf = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, kek, ct ); const privJwk = JSON.parse(new TextDecoder().decode(decryptedBuf)); const privateKey = await crypto.subtle.importKey( 'jwk', privJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['decrypt'] ); currentUserPrivateKey = privateKey; try { sessionStorage.setItem(SESSION_KEY_NAME, JSON.stringify(privJwk)); } catch (e) { console.warn('sessionStorage unavailable', e); } return privateKey; } catch (err) { console.error('Failed to unlock private key:', err); throw new Error('Failed to decrypt user private encryption key.'); } }; // Restore Private Key from Session Storage (e.g., page refresh) window.SpotCrypto.restoreUserPrivateKeyFromSession = async function() { if (currentUserPrivateKey) return currentUserPrivateKey; try { const stored = sessionStorage.getItem(SESSION_KEY_NAME); if (!stored) return null; const privJwk = JSON.parse(stored); currentUserPrivateKey = await crypto.subtle.importKey( 'jwk', privJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, true, ['decrypt'] ); return currentUserPrivateKey; } catch (e) { console.warn('Could not restore private key from session', e); return null; } }; window.SpotCrypto.clearSession = function() { currentUserPrivateKey = null; padKeyCache.clear(); try { sessionStorage.removeItem(SESSION_KEY_NAME); } catch (e) {} }; // --- Notepad Encryption Keys (AES-GCM 256) --- // Generate a fresh random AES-GCM 256 key for a new private notepad window.SpotCrypto.createNotepadKey = async function(ownerPublicKeyJwkStr) { // 1. Generate AES-GCM key const padKey = await crypto.subtle.generateKey( { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); // 2. Export raw key bytes const rawKey = await crypto.subtle.exportKey('raw', padKey); // 3. Encrypt raw key with Owner's RSA Public Key const pubJwk = JSON.parse(ownerPublicKeyJwkStr); const ownerPubKey = await crypto.subtle.importKey( 'jwk', pubJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const wrappedKeyBuf = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, ownerPubKey, rawKey ); const encryptedKeyB64 = bufToB64(wrappedKeyBuf); return { padKey, rawKey, encryptedKeyB64 }; }; // Encrypt an existing Notepad Key for a collaborator using their RSA public key window.SpotCrypto.wrapPadKeyForCollaborator = async function(padKey, collaboratorPublicKeyJwkStr) { const rawKey = await crypto.subtle.exportKey('raw', padKey); const pubJwk = typeof collaboratorPublicKeyJwkStr === 'string' ? JSON.parse(collaboratorPublicKeyJwkStr) : collaboratorPublicKeyJwkStr; const collabPubKey = await crypto.subtle.importKey( 'jwk', pubJwk, { name: 'RSA-OAEP', hash: 'SHA-256' }, false, ['encrypt'] ); const wrappedKeyBuf = await crypto.subtle.encrypt( { name: 'RSA-OAEP' }, collabPubKey, rawKey ); return bufToB64(wrappedKeyBuf); }; // Decrypt a wrapped Notepad Key using user's private key window.SpotCrypto.unwrapNotepadKey = async function(padId, encryptedKeyB64) { if (!encryptedKeyB64) return null; if (padKeyCache.has(padId)) return padKeyCache.get(padId); const privKey = await window.SpotCrypto.restoreUserPrivateKeyFromSession(); if (!privKey) { console.warn(`No private key available to decrypt notepad ${padId}`); return null; } try { const wrappedBuf = b64ToBuf(encryptedKeyB64); const rawKeyBuf = await crypto.subtle.decrypt( { name: 'RSA-OAEP' }, privKey, wrappedBuf ); const padKey = await crypto.subtle.importKey( 'raw', rawKeyBuf, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); padKeyCache.set(padId, padKey); return padKey; } catch (err) { console.error(`Failed to unwrap key for notepad ${padId}:`, err); return null; } }; // Export a Notepad Key to base64url for zero-knowledge Link Sharing (#key=...) window.SpotCrypto.exportPadKeyForLink = async function(padKey) { const rawKey = await crypto.subtle.exportKey('raw', padKey); return bufToB64Url(rawKey); }; // Import a Notepad Key from a link URL fragment (#key=...) window.SpotCrypto.importPadKeyFromLink = async function(padId, keyB64Url) { try { const rawKeyBuf = b64UrlToBuf(keyB64Url); const padKey = await crypto.subtle.importKey( 'raw', rawKeyBuf, { name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt'] ); padKeyCache.set(padId, padKey); return padKey; } catch (err) { console.error('Failed to import pad key from link hash:', err); return null; } }; window.SpotCrypto.getCachedPadKey = function(padId) { return padKeyCache.get(padId) || null; }; window.SpotCrypto.setCachedPadKey = function(padId, key) { padKeyCache.set(padId, key); }; // --- Note Content Encryption & Decryption --- // Encrypts { title, content, lat, lng } client-side window.SpotCrypto.encryptNote = async function(noteData, padKey) { const payload = { title: noteData.title || '', content: noteData.content || '', lat: parseFloat(noteData.lat) || 0.0, lng: parseFloat(noteData.lng) || 0.0, ts: Date.now() }; const jsonBytes = new TextEncoder().encode(JSON.stringify(payload)); const iv = crypto.getRandomValues(new Uint8Array(12)); const ciphertextBuf = await crypto.subtle.encrypt( { name: 'AES-GCM', iv: iv }, padKey, jsonBytes ); return `ENC1:${bufToB64(iv.buffer)}:${bufToB64(ciphertextBuf)}`; }; // Decrypts a note client-side. If not encrypted, returns note as-is. window.SpotCrypto.decryptNote = async function(note, padKey) { if (!note || !note.is_encrypted) { return { ...note, title: note.title || '', content: note.content || '', lat: note.lat, lng: note.lng }; } if (!padKey) { return { ...note, title: '🔒 [Encrypted Private Note]', content: 'Key required to decrypt this note.', is_locked: true }; } try { const rawContent = note.content || ''; if (!rawContent.startsWith('ENC1:')) { return { ...note, title: '🔒 [Encrypted Private Note]', content: 'Unrecognized encryption format.' }; } const parts = rawContent.split(':'); const iv = new Uint8Array(b64ToBuf(parts[1])); const ct = b64ToBuf(parts[2]); const decryptedBuf = await crypto.subtle.decrypt( { name: 'AES-GCM', iv: iv }, padKey, ct ); const payload = JSON.parse(new TextDecoder().decode(decryptedBuf)); return { ...note, title: payload.title, content: payload.content, lat: payload.lat, lng: payload.lng, is_locked: false }; } catch (err) { console.warn(`Failed to decrypt note ${note.id}:`, err); return { ...note, title: '🔒 [Decryption Error]', content: 'Failed to decrypt note content with current key.', is_locked: true }; } }; // Decrypt an array of notes window.SpotCrypto.decryptNotes = async function(notes, padKey) { if (!notes || !Array.isArray(notes)) return []; return Promise.all(notes.map(n => window.SpotCrypto.decryptNote(n, padKey))); }; })();