import type { Database } from '@db/sqlite'; import * as nostrTools from '@nostr/tools'; import { nip44 } from '@nostr/tools'; import { MAX_CHUNK_SIZE, MIN_POW, POW_TO_MINE } from './consts.ts'; import { getActiveCCN, getAllCCNs, getCCNPrivateKeyByPubkey, randomTimeUpTo2DaysInThePast, } from './utils.ts'; import { None, type Option, Some, flatMap, map } from './utils/option.ts'; import { sql } from './utils/queries.ts'; export class EventAlreadyExistsException extends Error {} export class ChunkedEventReceived extends Error {} export function createEncryptedEventForPubkey( pubkey: string, event: nostrTools.VerifiedEvent, ) { const randomPrivateKey = nostrTools.generateSecretKey(); const randomPrivateKeyPubKey = nostrTools.getPublicKey(randomPrivateKey); const conversationKey = nip44.getConversationKey(randomPrivateKey, pubkey); const eventJson = JSON.stringify(event); const encryptedEvent = nip44.encrypt(eventJson, conversationKey); const sealTemplate = { kind: 13, created_at: randomTimeUpTo2DaysInThePast(), content: encryptedEvent, tags: [], }; const seal = nostrTools.finalizeEvent(sealTemplate, randomPrivateKey); const giftWrapTemplate = { kind: 1059, created_at: randomTimeUpTo2DaysInThePast(), content: nip44.encrypt(JSON.stringify(seal), conversationKey), tags: [['p', pubkey]], pubkey: randomPrivateKeyPubKey, }; const minedGiftWrap = nostrTools.nip13.minePow(giftWrapTemplate, POW_TO_MINE); const giftWrap = nostrTools.finalizeEvent(minedGiftWrap, randomPrivateKey); return giftWrap; } export function createEncryptedChunkForPubkey( pubkey: string, chunk: string, chunkIndex: number, totalChunks: number, messageId: string, privateKey: Uint8Array, ) { const randomPrivateKey = nostrTools.generateSecretKey(); const randomPrivateKeyPubKey = nostrTools.getPublicKey(randomPrivateKey); const conversationKey = nip44.getConversationKey(randomPrivateKey, pubkey); const sealTemplate = { kind: 13, created_at: randomTimeUpTo2DaysInThePast(), content: nip44.encrypt(chunk, conversationKey), tags: [['chunk', String(chunkIndex), String(totalChunks), messageId]], }; const seal = nostrTools.finalizeEvent(sealTemplate, privateKey); const giftWrapTemplate = { kind: 1059, created_at: randomTimeUpTo2DaysInThePast(), content: nip44.encrypt(JSON.stringify(seal), conversationKey), tags: [['p', pubkey]], pubkey: randomPrivateKeyPubKey, }; const minedGiftWrap = nostrTools.nip13.minePow(giftWrapTemplate, POW_TO_MINE); const giftWrap = nostrTools.finalizeEvent(minedGiftWrap, randomPrivateKey); return giftWrap; } export async function createEncryptedEvent( event: nostrTools.VerifiedEvent, db: Database, ): Promise { if (!event.id) throw new Error('Event must have an ID'); if (!event.sig) throw new Error('Event must be signed'); const activeCCN = getActiveCCN(db); if (!activeCCN) throw new Error('No active CCN found'); const ccnPubKey = activeCCN.pubkey; const ccnPrivateKey = await getCCNPrivateKeyByPubkey(ccnPubKey); const eventJson = JSON.stringify(event); if (eventJson.length <= MAX_CHUNK_SIZE) { return createEncryptedEventForPubkey(ccnPubKey, event); } const chunks: string[] = []; for (let i = 0; i < eventJson.length; i += MAX_CHUNK_SIZE) chunks.push(eventJson.slice(i, i + MAX_CHUNK_SIZE)); const messageId = crypto.randomUUID(); const totalChunks = chunks.length; const encryptedChunks = []; for (let i = 0; i < chunks.length; i++) { const chunk = chunks[i]; encryptedChunks.push( createEncryptedChunkForPubkey( ccnPubKey, chunk, i, totalChunks, messageId, ccnPrivateKey, ), ); } return encryptedChunks; } /** * Attempts to decrypt an event using a specific CCN private key * @returns The decrypted event with CCN pubkey if successful, None otherwise */ function attemptDecryptWithKey( event: nostrTools.Event, ccnPrivkey: Uint8Array, ccnPubkey: string, ): Option { try { const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey); const sealResult = map( Some(nip44.decrypt(event.content, conversationKey)), JSON.parse, ); return flatMap(sealResult, (seal) => { if (!seal || seal.kind !== 13) return None(); const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk'); if (!chunkTag) { const contentResult = map( Some(nip44.decrypt(seal.content, conversationKey)), JSON.parse, ); return map(contentResult, (content) => ({ ...content, ccnPubkey })); } return None(); }); } catch { return None(); } } /** * Handles a chunked message by storing it in the database and checking if all chunks are received * @returns The complete decrypted event if all chunks are received, throws ChunkedEventReceived otherwise */ function handleChunkedMessage( db: Database, event: nostrTools.Event, ccnPrivkey: Uint8Array, ccnPubkey: string, ): nostrTools.VerifiedEvent { const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey); const sealResult = map( Some(nip44.decrypt(event.content, conversationKey)), JSON.parse, ); const seal = sealResult.isSome ? sealResult.value : null; if (!seal) { throw new Error('Invalid chunked message format'); } const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk'); if (!chunkTag) { throw new Error('Invalid chunked message format'); } const [_, chunkIndex, totalChunks, messageId] = chunkTag; const chunk = nip44.decrypt(seal.content, conversationKey); const storedChunks = sql` SELECT COUNT(*) as count FROM event_chunks WHERE message_id = ${messageId} `(db)[0].count; sql` INSERT INTO event_chunks (message_id, chunk_index, total_chunks, content, created_at, ccn_pubkey) VALUES (${messageId}, ${chunkIndex}, ${totalChunks}, ${chunk}, ${Math.floor(Date.now() / 1000)}, ${ccnPubkey}) `(db); if (storedChunks + 1 === Number(totalChunks)) { const allChunks = sql` SELECT * FROM event_chunks WHERE message_id = ${messageId} ORDER BY chunk_index `(db); let fullContent = ''; for (const chunk of allChunks) { fullContent += chunk.content; } const content = JSON.parse(fullContent); return { ...content, ccnPubkey }; } throw new ChunkedEventReceived(); } export async function decryptEvent( db: Database, event: nostrTools.Event, ): Promise { if (event.kind !== 1059) { throw new Error('Cannot decrypt event -- not a gift wrap'); } const allCCNs = getAllCCNs(db); if (allCCNs.length === 0) { throw new Error('No CCNs found'); } const pow = nostrTools.nip13.getPow(event.id); const isInvite = event.tags.findIndex( (tag: string[]) => tag[0] === 'type' && tag[1] === 'invite', ) !== -1; const eventDestination = event.tags.find( (tag: string[]) => tag[0] === 'p', )?.[1]; if (!eventDestination) { throw new Error('Cannot decrypt event -- no destination'); } if (isInvite) { const ccnPrivkey = await getCCNPrivateKeyByPubkey(eventDestination); const decryptedEvent = attemptDecryptWithKey( event, ccnPrivkey, eventDestination, ); if (decryptedEvent.isSome) { const recipient = decryptedEvent.value.tags.find( (tag: string[]) => tag[0] === 'p', )?.[1]; if (recipient !== eventDestination) throw new Error('Cannot decrypt invite'); return { ...decryptedEvent.value, ccnPubkey: eventDestination }; } throw new Error('Cannot decrypt invite'); } if (pow < MIN_POW) { throw new Error('Cannot decrypt event -- PoW too low'); } const ccnPrivkey = await getCCNPrivateKeyByPubkey(eventDestination); const decryptedEvent = attemptDecryptWithKey( event, ccnPrivkey, eventDestination, ); if (decryptedEvent.isSome) { return { ...decryptedEvent.value, ccnPubkey: eventDestination }; } try { const chuncked = handleChunkedMessage( db, event, ccnPrivkey, eventDestination, ); return { ...chuncked, ccnPubkey: eventDestination }; } catch (e) { if (e instanceof ChunkedEventReceived) { throw e; } } throw new Error('Failed to decrypt event with any CCN key'); }