/** * E2E Encryption Service using TweetNaCl * https://tweetnacl.js.org/ */ const STORAGE_KEY = 'dgray_keypair' class CryptoService { constructor() { this.nacl = null this.naclUtil = null this.keyPair = 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 this.loadOrCreateKeyPair() } 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) }) } loadOrCreateKeyPair() { const stored = localStorage.getItem(STORAGE_KEY) if (stored) { try { const parsed = JSON.parse(stored) this.keyPair = { publicKey: this.naclUtil.decodeBase64(parsed.publicKey), secretKey: this.naclUtil.decodeBase64(parsed.secretKey) } return } catch (e) { console.warn('Failed to load keypair, generating new one') } } this.generateKeyPair() } generateKeyPair() { this.keyPair = this.nacl.box.keyPair() const toStore = { publicKey: this.naclUtil.encodeBase64(this.keyPair.publicKey), secretKey: this.naclUtil.encodeBase64(this.keyPair.secretKey) } localStorage.setItem(STORAGE_KEY, JSON.stringify(toStore)) } getPublicKey() { if (!this.keyPair) return null return this.naclUtil.encodeBase64(this.keyPair.publicKey) } /** * Encrypt a message for a recipient * @param {string} message - Plain text message * @param {string} recipientPublicKey - Base64 encoded public key * @returns {object} - { nonce, ciphertext } both base64 encoded */ encrypt(message, recipientPublicKey) { const nonce = this.nacl.randomBytes(this.nacl.box.nonceLength) const messageUint8 = this.naclUtil.decodeUTF8(message) const recipientKey = this.naclUtil.decodeBase64(recipientPublicKey) const sharedKey = this.nacl.box.before(recipientKey, this.keyPair.secretKey) 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 * @returns {string|null} - Decrypted message or null if failed */ decrypt(ciphertext, nonce, otherPublicKey) { try { const otherKey = this.naclUtil.decodeBase64(otherPublicKey) const sharedKey = this.nacl.box.before(otherKey, this.keyPair.secretKey) 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()