Eve-Relay/eventEncryptionDecryption.ts
Danny Morabito 89d9dc3cbe
✂️ Implement message chunking mechanism for NIP-44 size limit compliance
This ensures all messages can be properly encrypted and transmitted regardless of size.

Fixes issue #2
2025-03-24 20:14:52 +01:00

174 lines
5.3 KiB
TypeScript

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 {
getCCNPrivateKey,
getCCNPubkey,
randomTimeUpTo2DaysInThePast,
} from './utils.ts';
import { sql } from './utils/queries.ts';
export class EventAlreadyExistsException extends Error {}
export class ChunkedEventReceived extends Error {}
export async function createEncryptedEvent(
event: nostrTools.VerifiedEvent,
): Promise<nostrTools.VerifiedEvent | nostrTools.VerifiedEvent[]> {
if (!event.id) throw new Error('Event must have an ID');
if (!event.sig) throw new Error('Event must be signed');
const ccnPubKey = await getCCNPubkey();
const ccnPrivateKey = await getCCNPrivateKey();
const eventJson = JSON.stringify(event);
if (eventJson.length <= MAX_CHUNK_SIZE) {
const randomPrivateKey = nostrTools.generateSecretKey();
const randomPrivateKeyPubKey = nostrTools.getPublicKey(randomPrivateKey);
const conversationKey = nip44.getConversationKey(
randomPrivateKey,
ccnPubKey,
);
const sealTemplate = {
kind: 13,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(eventJson, conversationKey),
tags: [],
};
const seal = nostrTools.finalizeEvent(sealTemplate, ccnPrivateKey);
const giftWrapTemplate = {
kind: 1059,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(JSON.stringify(seal), conversationKey),
tags: [['p', ccnPubKey]],
pubkey: randomPrivateKeyPubKey,
};
const minedGiftWrap = nostrTools.nip13.minePow(
giftWrapTemplate,
POW_TO_MINE,
);
const giftWrap = nostrTools.finalizeEvent(minedGiftWrap, randomPrivateKey);
return giftWrap;
}
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];
const randomPrivateKey = nostrTools.generateSecretKey();
const randomPrivateKeyPubKey = nostrTools.getPublicKey(randomPrivateKey);
const conversationKey = nip44.getConversationKey(
randomPrivateKey,
ccnPubKey,
);
const sealTemplate = {
kind: 13,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(chunk, conversationKey),
tags: [['chunk', String(i), String(totalChunks), messageId]],
};
const seal = nostrTools.finalizeEvent(sealTemplate, ccnPrivateKey);
const giftWrapTemplate = {
kind: 1059,
created_at: randomTimeUpTo2DaysInThePast(),
content: nip44.encrypt(JSON.stringify(seal), conversationKey),
tags: [['p', ccnPubKey]],
pubkey: randomPrivateKeyPubKey,
};
const minedGiftWrap = nostrTools.nip13.minePow(
giftWrapTemplate,
POW_TO_MINE,
);
encryptedChunks.push(
nostrTools.finalizeEvent(minedGiftWrap, randomPrivateKey),
);
}
return encryptedChunks;
}
export async function decryptEvent(
db: Database,
event: nostrTools.Event,
): Promise<nostrTools.VerifiedEvent> {
const ccnPrivkey = await getCCNPrivateKey();
if (event.kind !== 1059) {
throw new Error('Cannot decrypt event -- not a gift wrap');
}
const pow = nostrTools.nip13.getPow(event.id);
if (pow < MIN_POW) {
throw new Error('Cannot decrypt event -- PoW too low');
}
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
const seal = JSON.parse(nip44.decrypt(event.content, conversationKey));
if (!seal) throw new Error('Cannot decrypt event -- no seal');
if (seal.kind !== 13) {
throw new Error('Cannot decrypt event subevent -- not a seal');
}
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
if (!chunkTag) {
const content = JSON.parse(nip44.decrypt(seal.content, conversationKey));
return content as nostrTools.VerifiedEvent;
}
const [_, chunkIndex, totalChunks, messageId] = chunkTag;
const chunk = nip44.decrypt(seal.content, conversationKey);
try {
sql`
INSERT INTO event_chunks (
message_id,
chunk_index,
total_chunks,
chunk_data,
conversation_key,
created_at
) VALUES (
${messageId},
${Number(chunkIndex)},
${Number(totalChunks)},
${chunk},
${conversationKey},
${Math.floor(Date.now() / 1000)}
)
`(db);
const chunks = sql`
SELECT chunk_data
FROM event_chunks
WHERE message_id = ${messageId}
ORDER BY chunk_index ASC
`(db);
if (chunks.length === Number(totalChunks)) {
const completeEventJson = chunks.map((c) => c.chunk_data).join('');
sql`DELETE FROM event_chunks WHERE message_id = ${messageId}`(db);
return JSON.parse(completeEventJson) as nostrTools.VerifiedEvent;
}
throw new ChunkedEventReceived(
`Chunked event received (${chunks.length}/${totalChunks}) - messageId: ${messageId}`,
);
} catch (e) {
if (e instanceof Error && e.message.includes('UNIQUE constraint failed'))
throw new Error(
`Duplicate chunk received (${Number(chunkIndex) + 1}/${totalChunks}) - messageId: ${messageId}`,
);
throw e;
}
}