/** * E2E Encryption Service using TweetNaCl * https://tweetnacl.js.org/ * * Secret keys are encrypted at rest using AES-GCM with a key * derived from the user's UUID via PBKDF2. * * Storage keys are namespaced per account (UUID hash prefix) * to support multiple accounts in the same browser. */ const STORAGE_PREFIX = 'kashilo_' class CryptoService { constructor() { this.nacl = null this.naclUtil = null this.keyPair = null this.wrappingKey = null this._storageKeys = null this.ready = this.init() } async init() { if (!window.nacl) { await this.loadScript('/js/vendor/nacl-fast.min.js') await this.loadScript('/js/vendor/nacl-util.min.js') } this.nacl = window.nacl this.naclUtil = window.nacl.util } loadScript(src) { return new Promise((resolve, reject) => { if (document.querySelector(`script[src="${src}"]`)) { resolve() return } const script = document.createElement('script') script.src = src script.onload = resolve script.onerror = reject document.head.appendChild(script) }) } async _uuidPrefix(uuid) { const data = new TextEncoder().encode(uuid) const hash = await crypto.subtle.digest('SHA-256', data) return Array.from(new Uint8Array(hash)).slice(0, 4) .map(b => b.toString(16).padStart(2, '0')).join('') } async _getStorageKeys(uuid) { if (this._storageKeys) return this._storageKeys const prefix = await this._uuidPrefix(uuid) this._storageKeys = { keypair: `${STORAGE_PREFIX}kp_${prefix}`, salt: `${STORAGE_PREFIX}salt_${prefix}`, listingKeys: `${STORAGE_PREFIX}lk_${prefix}` } return this._storageKeys } async deriveKey(uuid) { const enc = new TextEncoder() const keys = await this._getStorageKeys(uuid) let salt = localStorage.getItem(keys.salt) if (!salt) { const saltBytes = crypto.getRandomValues(new Uint8Array(16)) salt = Array.from(saltBytes).map(b => b.toString(16).padStart(2, '0')).join('') localStorage.setItem(keys.salt, salt) } const saltBytes = new Uint8Array(salt.match(/.{2}/g).map(h => parseInt(h, 16))) const baseKey = await crypto.subtle.importKey( 'raw', enc.encode(uuid), 'PBKDF2', false, ['deriveKey'] ) return crypto.subtle.deriveKey( { name: 'PBKDF2', salt: saltBytes, iterations: 100000, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ) } async encryptForStorage(data, wrappingKey) { const iv = crypto.getRandomValues(new Uint8Array(12)) const enc = new TextEncoder() const ciphertext = await crypto.subtle.encrypt( { name: 'AES-GCM', iv }, wrappingKey, enc.encode(JSON.stringify(data)) ) return { iv: Array.from(iv).map(b => b.toString(16).padStart(2, '0')).join(''), ct: Array.from(new Uint8Array(ciphertext)).map(b => b.toString(16).padStart(2, '0')).join('') } } async decryptFromStorage(stored, wrappingKey) { const iv = new Uint8Array(stored.iv.match(/.{2}/g).map(h => parseInt(h, 16))) const ct = new Uint8Array(stored.ct.match(/.{2}/g).map(h => parseInt(h, 16))) const plainBuf = await crypto.subtle.decrypt( { name: 'AES-GCM', iv }, wrappingKey, ct ) return JSON.parse(new TextDecoder().decode(plainBuf)) } async unlock(uuid) { await this.ready const keys = await this._getStorageKeys(uuid) const wrappingKey = await this.deriveKey(uuid) this.wrappingKey = wrappingKey const stored = localStorage.getItem(keys.keypair) if (stored) { try { const parsed = JSON.parse(stored) if (parsed.ct && parsed.iv) { const data = await this.decryptFromStorage(parsed, wrappingKey) this.keyPair = { publicKey: this.naclUtil.decodeBase64(data.publicKey), secretKey: this.naclUtil.decodeBase64(data.secretKey) } return } if (parsed.publicKey && parsed.secretKey) { this.keyPair = { publicKey: this.naclUtil.decodeBase64(parsed.publicKey), secretKey: this.naclUtil.decodeBase64(parsed.secretKey) } await this.saveKeyPair(wrappingKey) return } } catch (e) { console.warn('Failed to load keypair, generating new one') localStorage.removeItem(keys.listingKeys) } } const migrated = await this._migrateOldStorage(uuid, wrappingKey, keys) if (!migrated) await this.generateKeyPair(wrappingKey) } async _migrateOldStorage(uuid, wrappingKey, keys) { const oldKeypair = localStorage.getItem('kashilo_keypair') const oldSalt = localStorage.getItem('kashilo_keypair_salt') if (!oldKeypair || !oldSalt) return false try { const oldSaltBytes = new Uint8Array(oldSalt.match(/.{2}/g).map(h => parseInt(h, 16))) const enc = new TextEncoder() const baseKey = await crypto.subtle.importKey( 'raw', enc.encode(uuid), 'PBKDF2', false, ['deriveKey'] ) const oldWrappingKey = await crypto.subtle.deriveKey( { name: 'PBKDF2', salt: oldSaltBytes, iterations: 100000, hash: 'SHA-256' }, baseKey, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt'] ) const parsed = JSON.parse(oldKeypair) if (parsed.ct && parsed.iv) { const data = await this.decryptFromStorage(parsed, oldWrappingKey) this.keyPair = { publicKey: this.naclUtil.decodeBase64(data.publicKey), secretKey: this.naclUtil.decodeBase64(data.secretKey) } await this.saveKeyPair(wrappingKey) const oldListing = localStorage.getItem('kashilo_listing_keys') if (oldListing) { try { const listingData = await this.decryptFromStorage(JSON.parse(oldListing), oldWrappingKey) const encrypted = await this.encryptForStorage(listingData, wrappingKey) localStorage.setItem(keys.listingKeys, JSON.stringify(encrypted)) localStorage.removeItem('kashilo_listing_keys') } catch {} } localStorage.removeItem('kashilo_keypair') localStorage.removeItem('kashilo_keypair_salt') console.debug('[crypto] migrated old storage to namespaced keys') return true } } catch (e) { console.debug('[crypto] old storage migration failed (different account)') } return false } async generateKeyPair(wrappingKey) { this.keyPair = this.nacl.box.keyPair() await this.saveKeyPair(wrappingKey) } async saveKeyPair(wrappingKey) { const data = { publicKey: this.naclUtil.encodeBase64(this.keyPair.publicKey), secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey) } const encrypted = await this.encryptForStorage(data, wrappingKey) localStorage.setItem(this._storageKeys.keypair, JSON.stringify(encrypted)) } lock() { this.keyPair = null this.wrappingKey = null this._storageKeys = null } destroyKeyPair() { this.keyPair = null this.wrappingKey = null if (this._storageKeys) { localStorage.removeItem(this._storageKeys.keypair) localStorage.removeItem(this._storageKeys.salt) localStorage.removeItem(this._storageKeys.listingKeys) } localStorage.removeItem('kashilo_keypair') localStorage.removeItem('kashilo_keypair_salt') localStorage.removeItem('kashilo_listing_keys') this._storageKeys = null } getPublicKey() { if (!this.keyPair) return null return this.naclUtil.encodeBase64(this.keyPair.publicKey) } async getListingKeysStore() { if (!this.wrappingKey || !this._storageKeys) return {} const stored = localStorage.getItem(this._storageKeys.listingKeys) if (!stored) return {} try { const parsed = JSON.parse(stored) return await this.decryptFromStorage(parsed, this.wrappingKey) } catch (e) { console.warn('Failed to load listing keys store', e) return {} } } async saveListingKeysStore(store) { if (!this.wrappingKey) throw new Error('Not unlocked') const encrypted = await this.encryptForStorage(store, this.wrappingKey) localStorage.setItem(this._storageKeys.listingKeys, JSON.stringify(encrypted)) } async generateListingKeyPair(listingId) { if (!this.wrappingKey) throw new Error('Not unlocked') await this.ready const kp = this.nacl.box.keyPair() const store = await this.getListingKeysStore() store[listingId] = this.naclUtil.encodeBase64(kp.secretKey) await this.saveListingKeysStore(store) return this.naclUtil.encodeBase64(kp.publicKey) } async getListingSecretKey(listingId) { const store = await this.getListingKeysStore() return store[listingId] || null } /** * Encrypt a message for a recipient * @param {string} message - Plain text message * @param {string} recipientPublicKey - Base64 encoded public key * @param {string} [senderSecretKey] - Optional base64 secret key (e.g. listing-specific) * @returns {object} - { nonce, ciphertext } both base64 encoded */ encrypt(message, recipientPublicKey, senderSecretKey) { const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength) const messageUint8 = this.naclUtil.decodeUTF8(message) const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey) const sk = senderSecretKey ? this.naclUtil.decodeBase64(senderSecretKey) : this.keyPair.secretKey const sharedKey = this.nacl.box.before(recipientKey, sk) const encrypted = this.nacl.secretbox(messageUint8, nonce, sharedKey) return { nonce: this.naclUtil.encodeBase64(nonce), ciphertext: this.naclUtil.encodeBase64(encrypted) } } /** * Decrypt a message from a sender * @param {string} ciphertext - Base64 encoded ciphertext * @param {string} nonce - Base64 encoded nonce * @param {string} otherPublicKey - Base64 encoded public key of the other party * @param {string} [mySecretKey] - Optional base64 secret key (e.g. listing-specific) * @returns {string|null} - Decrypted message or null if failed */ decrypt(ciphertext, nonce, otherPublicKey, mySecretKey) { try { const otherKey = this.naclUtil.decodeBase64(otherPublicKey) const sk = mySecretKey ? this.naclUtil.decodeBase64(mySecretKey) : this.keyPair.secretKey const sharedKey = this.nacl.box.before(otherKey, sk) const decrypted = this.nacl.secretbox.open( this.naclUtil.decodeBase64(ciphertext), this.naclUtil.decodeBase64(nonce), sharedKey ) if (!decrypted) return null return this.naclUtil.encodeUTF8(decrypted) } catch (e) { console.error('Decryption failed:', e) return null } } } export const cryptoService = new CryptoService()