331 lines
12 KiB
JavaScript
331 lines
12 KiB
JavaScript
/**
|
|
* 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()
|