From a8ffce918ef2043a4b45b30df379ab8c4b2f2752 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 9 Apr 2025 13:40:02 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Feat:=20Implement=20support=20for?= =?UTF-8?q?=20multiple=20CCNs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 14 ++ eventEncryptionDecryption.ts | 187 ++++++++++++------ index.ts | 367 +++++++++++++++++++++++++++-------- migrations/5-multiCCN.sql | 21 ++ public/landing.html | 212 ++++++++++++++++++++ utils.ts | 106 +++++++--- utils/option.ts | 40 ++++ 7 files changed, 778 insertions(+), 169 deletions(-) create mode 100644 Makefile create mode 100644 migrations/5-multiCCN.sql create mode 100644 public/landing.html create mode 100644 utils/option.ts diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..41f978e --- /dev/null +++ b/Makefile @@ -0,0 +1,14 @@ +TARGETS = x86_64-unknown-linux-gnu aarch64-unknown-linux-gnu x86_64-apple-darwin aarch64-apple-darwin + +.PHONY: all clean + +all: $(TARGETS:%=dist/relay-%) + +dist/relay-%: | dist/ + deno compile -A -r --target $* --include migrations --include public --no-lock --output $@ index.ts + +dist/: + mkdir -p dist + +clean: + rm -f dist/* \ No newline at end of file diff --git a/eventEncryptionDecryption.ts b/eventEncryptionDecryption.ts index d77e2f5..3ca4292 100644 --- a/eventEncryptionDecryption.ts +++ b/eventEncryptionDecryption.ts @@ -3,10 +3,12 @@ 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, + 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 {} @@ -14,12 +16,16 @@ export class ChunkedEventReceived extends Error {} 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 ccnPubKey = await getCCNPubkey(); - const ccnPrivateKey = await getCCNPrivateKey(); + 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) { @@ -95,12 +101,101 @@ export async function createEncryptedEvent( 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 { + 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(); + }); +} + +/** + * 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 { - const ccnPrivkey = await getCCNPrivateKey(); - +): Promise { if (event.kind !== 1059) { throw new Error('Cannot decrypt event -- not a gift wrap'); } @@ -111,64 +206,30 @@ export async function decryptEvent( 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 allCCNs = getAllCCNs(db); + if (allCCNs.length === 0) { + throw new Error('No CCNs found'); } - 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; + for (const ccn of allCCNs) { + const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey); + const decryptedEvent = attemptDecryptWithKey(event, ccnPrivkey, ccn.pubkey); + if (decryptedEvent.isSome) { + return { ...decryptedEvent.value, ccnPubkey: ccn.pubkey }; } - - 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; } + + for (const ccn of allCCNs) { + const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey); + try { + const chuncked = handleChunkedMessage(db, event, ccnPrivkey, ccn.pubkey); + return { ...chuncked, ccnPubkey: ccn.pubkey }; + } catch (e) { + if (e instanceof ChunkedEventReceived) { + throw e; + } + } + } + + throw new Error('Failed to decrypt event with any CCN key'); } diff --git a/index.ts b/index.ts index f9cbd6b..8f3a4f2 100644 --- a/index.ts +++ b/index.ts @@ -16,8 +16,9 @@ import { decryptEvent, } from './eventEncryptionDecryption.ts'; import { - getCCNPrivateKey, - getCCNPubkey, + createNewCCN, + getActiveCCN, + getAllCCNs, isAddressableEvent, isArray, isCCNReplaceableEvent, @@ -115,6 +116,7 @@ export function runMigrations(db: Database, latestVersion: number) { function addEventToDb( decryptedEvent: nostrTools.VerifiedEvent, encryptedEvent: nostrTools.VerifiedEvent, + ccnPubkey: string, ) { const existingEvent = sql` SELECT * FROM events WHERE id = ${decryptedEvent.id} @@ -131,6 +133,7 @@ function addEventToDb( WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} + AND ccn_pubkey = ${ccnPubkey} `(db); } @@ -143,6 +146,7 @@ function addEventToDb( WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} AND created_at < ${decryptedEvent.created_at} + AND ccn_pubkey = ${ccnPubkey} AND id IN ( SELECT event_id FROM event_tags WHERE tag_name = 'd' @@ -163,6 +167,7 @@ function addEventToDb( SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND created_at < ${decryptedEvent.created_at} + AND ccn_pubkey = ${ccnPubkey} AND id IN ( SELECT event_id FROM event_tags WHERE tag_name = 'd' @@ -176,7 +181,7 @@ function addEventToDb( } sql` - INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen) VALUES ( + INSERT INTO events (id, original_id, pubkey, created_at, kind, content, sig, first_seen, ccn_pubkey) VALUES ( ${decryptedEvent.id}, ${encryptedEvent.id}, ${decryptedEvent.pubkey}, @@ -184,7 +189,8 @@ function addEventToDb( ${decryptedEvent.kind}, ${decryptedEvent.content}, ${decryptedEvent.sig}, - unixepoch() + unixepoch(), + ${ccnPubkey} ) `(db); if (decryptedEvent.tags) { @@ -225,9 +231,58 @@ function cleanupOldChunks() { sql`DELETE FROM event_chunks WHERE created_at < ${cutoffTime}`(db); } -async function setupAndSubscribeToExternalEvents() { - const ccnPubkey = await getCCNPubkey(); +let knownOriginalEventsCache: string[] = []; +function updateKnownEventsCache() { + knownOriginalEventsCache = sql`SELECT original_id FROM events`(db).flatMap( + (row) => row.original_id, + ); +} + +/** + * Creates a subscription event handler for processing encrypted events. + * This handler decrypts and adds valid events to the database. + * @param database The database instance to use + * @returns An event handler function + */ +function createSubscriptionEventHandler(database: Database) { + return async (event: nostrTools.Event) => { + if (knownOriginalEventsCache.indexOf(event.id) >= 0) return; + if (!nostrTools.verifyEvent(event)) { + log.warn('Invalid event received'); + return; + } + if (encryptedEventIsInDb(event)) return; + try { + const decryptedEvent = await decryptEvent(database, event); + addEventToDb(decryptedEvent, event, decryptedEvent.ccnPubkey); + updateKnownEventsCache(); + } catch (e) { + if (e instanceof EventAlreadyExistsException) return; + if (e instanceof ChunkedEventReceived) { + return; + } + } + }; +} + +/** + * Publishes an event to relays, handling both single events and chunked events + * @param encryptedEvent The encrypted event or array of chunked events + */ +async function publishToRelays( + encryptedEvent: nostrTools.Event | nostrTools.Event[], +): Promise { + if (Array.isArray(encryptedEvent)) { + for (const chunk of encryptedEvent) { + await Promise.any(pool.publish(relays, chunk)); + } + } else { + await Promise.any(pool.publish(relays, encryptedEvent)); + } +} + +function setupAndSubscribeToExternalEvents() { const isInitialized = sql` SELECT name FROM sqlite_master WHERE type='table' AND name='migration_history' `(db)[0]; @@ -241,72 +296,23 @@ async function setupAndSubscribeToExternalEvents() { runMigrations(db, latestVersion); + const allCCNs = sql`SELECT pubkey FROM ccns`(db); + const ccnPubkeys = allCCNs.map((ccn) => ccn.pubkey); + pool.subscribeMany( relays, [ { - '#p': [ccnPubkey], + '#p': ccnPubkeys, kinds: [1059], }, ], { - async onevent(event: nostrTools.Event) { - if (timer) { - timerCleaned = true; - clearTimeout(timer); - } - if (knownOriginalEvents.indexOf(event.id) >= 0) return; - if (!nostrTools.verifyEvent(event)) { - log.warn('Invalid event received'); - return; - } - if (encryptedEventIsInDb(event)) return; - try { - const decryptedEvent = await decryptEvent(db, event); - addEventToDb(decryptedEvent, event); - } catch (e) { - if (e instanceof EventAlreadyExistsException) return; - if (e instanceof ChunkedEventReceived) { - return; - } - } - }, + onevent: createSubscriptionEventHandler(db), }, ); - let timerCleaned = false; - - const knownOriginalEvents = sql`SELECT original_id FROM events`(db).flatMap( - (row) => row.original_id, - ); - - const timer = setTimeout(async () => { - // if nothing is found in 10 seconds, create a new CCN, TODO: change logic - const ccnCreationEventTemplate = { - kind: 0, - content: JSON.stringify({ - display_name: 'New CCN', - name: 'New CCN', - bot: true, - }), - created_at: Math.floor(Date.now() / 1000), - tags: [['p', ccnPubkey]], - }; - const ccnCreationEvent = nostrTools.finalizeEvent( - ccnCreationEventTemplate, - await getCCNPrivateKey(), - ); - const encryptedCCNCreationEvent = - await createEncryptedEvent(ccnCreationEvent); - if (timerCleaned) return; // in case we get an event before the timer is cleaned - if (Array.isArray(encryptedCCNCreationEvent)) { - for (const event of encryptedCCNCreationEvent) - await Promise.any(pool.publish(relays, event)); - } else { - await Promise.any(pool.publish(relays, encryptedCCNCreationEvent)); - } - }, 10000); - + updateKnownEventsCache(); setInterval(cleanupOldChunks, CHUNK_CLEANUP_INTERVAL); } @@ -326,6 +332,49 @@ class UserConnection { this.subscriptions = subscriptions; this.db = db; } + + /** + * Sends a response to the client + * @param responseArray The response array to send + */ + sendResponse(responseArray: unknown[]): void { + this.socket.send(JSON.stringify(responseArray)); + } + + /** + * Sends a notice to the client + * @param message The message to send + */ + sendNotice(message: string): void { + this.sendResponse(['NOTICE', message]); + } + + /** + * Sends an event to the client + * @param subscriptionId The subscription ID + * @param event The event to send + */ + sendEvent(subscriptionId: string, event: NostrEvent): void { + this.sendResponse(['EVENT', subscriptionId, event]); + } + + /** + * Sends an end of stored events message + * @param subscriptionId The subscription ID + */ + sendEOSE(subscriptionId: string): void { + this.sendResponse(['EOSE', subscriptionId]); + } + + /** + * Sends an OK response + * @param eventId The event ID + * @param success Whether the operation was successful + * @param message The message to send + */ + sendOK(eventId: string, success: boolean, message: string): void { + this.sendResponse(['OK', eventId, success, message]); + } } function filtersMatchingEvent( @@ -370,7 +419,13 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { )}`, ); - let query = sqlPartial`SELECT * FROM events WHERE replaced = 0`; + const activeCCN = getActiveCCN(connection.db); + if (!activeCCN) { + connection.sendNotice('No active CCN found'); + return log.warn('No active CCN found'); + } + + let query = sqlPartial`SELECT * FROM events WHERE replaced = 0 AND ccn_pubkey = ${activeCCN.pubkey}`; const filtersAreNotEmpty = filters.some((filter) => { return Object.values(filter).some((value) => { @@ -593,9 +648,9 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { sig: events[i].sig, }; - connection.socket.send(JSON.stringify(['EVENT', subscriptionId, event])); + connection.sendEvent(subscriptionId, event); } - connection.socket.send(JSON.stringify(['EOSE', subscriptionId])); + connection.sendEOSE(subscriptionId); connection.subscriptions.set(subscriptionId, filters); } @@ -606,20 +661,24 @@ async function handleEvent( ) { const valid = nostrTools.verifyEvent(event); if (!valid) { - connection.socket.send(JSON.stringify(['NOTICE', 'Invalid event'])); + connection.sendNotice('Invalid event'); return log.warn('Invalid event'); } - const encryptedEvent = await createEncryptedEvent(event); + const activeCCN = getActiveCCN(connection.db); + if (!activeCCN) { + connection.sendNotice('No active CCN found'); + return log.warn('No active CCN found'); + } + + const encryptedEvent = await createEncryptedEvent(event, connection.db); try { if (Array.isArray(encryptedEvent)) { - await Promise.all( - encryptedEvent.map((chunk) => Promise.any(pool.publish(relays, chunk))), - ); - addEventToDb(event, encryptedEvent[0]); + await publishToRelays(encryptedEvent); + addEventToDb(event, encryptedEvent[0], activeCCN.pubkey); } else { - addEventToDb(event, encryptedEvent); - await Promise.any(pool.publish(relays, encryptedEvent)); + addEventToDb(event, encryptedEvent, activeCCN.pubkey); + await publishToRelays(encryptedEvent); } } catch (e) { if (e instanceof EventAlreadyExistsException) { @@ -628,13 +687,13 @@ async function handleEvent( } } - connection.socket.send(JSON.stringify(['OK', event.id, true, 'Event added'])); + connection.sendOK(event.id, true, 'Event added'); const filtersThatMatchEvent = filtersMatchingEvent(event, connection); for (let i = 0; i < filtersThatMatchEvent.length; i++) { const filter = filtersThatMatchEvent[i]; - connection.socket.send(JSON.stringify(['EVENT', filter, event])); + connection.sendEvent(filter, event); } } @@ -648,6 +707,153 @@ function handleClose(connection: UserConnection, subscriptionId: string) { connection.subscriptions.delete(subscriptionId); } +/** + * Activates a CCN by setting it as the active one in the database + * @param database The database instance to use + * @param pubkey The public key of the CCN to activate + */ +function activateCCN(database: Database, pubkey: string): void { + sql`UPDATE ccns SET is_active = 0`(database); + sql`UPDATE ccns SET is_active = 1 WHERE pubkey = ${pubkey}`(database); +} + +/** + * Handles errors in socket operations, logs them and sends a notification to the client + * @param connection The WebSocket connection + * @param operation The operation that failed + * @param error The error that occurred + */ +function handleSocketError( + connection: UserConnection, + operation: string, + error: unknown, +): void { + const errorMessage = error instanceof Error ? error.message : 'Unknown error'; + log.error(`Error ${operation}: ${errorMessage}`); + connection.sendNotice(`Failed to ${operation}`); +} + +async function handleCreateCCN( + connection: UserConnection, + data: { name: string; seed?: string }, +): Promise { + try { + if (!data.name || typeof data.name !== 'string') { + connection.sendNotice('Name is required'); + return; + } + + const newCcn = await createNewCCN(connection.db, data.name, data.seed); + + activateCCN(connection.db, newCcn.pubkey); + + pool.subscribeMany( + relays, + [ + { + '#p': [newCcn.pubkey], + kinds: [1059], + }, + ], + { + onevent: createSubscriptionEventHandler(connection.db), + }, + ); + + connection.sendResponse([ + 'OK', + 'CCN CREATED', + true, + JSON.stringify({ + pubkey: newCcn.pubkey, + name: data.name, + }), + ]); + + log.info(`CCN created: ${data.name}`); + } catch (error: unknown) { + handleSocketError(connection, 'create CCN', error); + } +} + +function handleGetCCNs(connection: UserConnection): void { + try { + const ccns = getAllCCNs(connection.db); + connection.sendResponse(['OK', 'CCN LIST', true, JSON.stringify(ccns)]); + } catch (error: unknown) { + handleSocketError(connection, 'get CCNs', error); + } +} + +function handleActivateCCN( + connection: UserConnection, + data: { pubkey: string }, +): void { + try { + if (!data.pubkey || typeof data.pubkey !== 'string') { + connection.sendNotice('CCN pubkey is required'); + return; + } + + const ccnExists = sql` + SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${data.pubkey} + `(connection.db)[0].count; + + if (ccnExists === 0) { + connection.sendNotice('CCN not found'); + return; + } + + for (const subscriptionId of connection.subscriptions.keys()) { + connection.sendResponse([ + 'CLOSED', + subscriptionId, + 'Subscription closed due to CCN activation', + ]); + } + + connection.subscriptions.clear(); + log.info('All subscriptions cleared due to CCN activation'); + + activateCCN(connection.db, data.pubkey); + + const activatedCCN = sql` + SELECT pubkey, name FROM ccns WHERE pubkey = ${data.pubkey} + `(connection.db)[0]; + + connection.sendResponse([ + 'OK', + 'CCN ACTIVATED', + true, + JSON.stringify(activatedCCN), + ]); + + log.info(`CCN activated: ${activatedCCN.name}`); + } catch (error: unknown) { + handleSocketError(connection, 'activate CCN', error); + } +} + +function handleCCNCommands( + connection: UserConnection, + command: string, + data: unknown, +) { + switch (command) { + case 'CREATE': + return handleCreateCCN( + connection, + data as { name: string; seed?: string }, + ); + case 'LIST': + return handleGetCCNs(connection); + case 'ACTIVATE': + return handleActivateCCN(connection, data as { pubkey: string }); + default: + return log.warn('Invalid CCN command'); + } +} + Deno.serve({ port: 6942, handler: (request) => { @@ -672,14 +878,16 @@ Deno.serve({ const data = JSON.parse(event.data); if (!isArray(data)) return log.warn('Invalid request'); - const msg = n.clientMsg().parse(data); - switch (msg[0]) { + const msgType = data[0]; + switch (msgType) { case 'REQ': return handleRequest(connection, n.clientREQ().parse(data)); case 'EVENT': return handleEvent(connection, n.clientEVENT().parse(data)[1]); case 'CLOSE': return handleClose(connection, n.clientCLOSE().parse(data)[1]); + case 'CCN': + return handleCCNCommands(connection, data[1] as string, data[2]); default: return log.warn('Invalid request'); } @@ -688,6 +896,11 @@ Deno.serve({ return response; } - return new Response('Eve Relay'); + return new Response( + Deno.readTextFileSync(`${import.meta.dirname}/public/landing.html`), + { + headers: { 'Content-Type': 'text/html' }, + }, + ); }, }); diff --git a/migrations/5-multiCCN.sql b/migrations/5-multiCCN.sql new file mode 100644 index 0000000..ef01788 --- /dev/null +++ b/migrations/5-multiCCN.sql @@ -0,0 +1,21 @@ +CREATE TABLE ccns ( + ccn_id TEXT PRIMARY KEY DEFAULT (lower(hex(randomblob(16)))), + pubkey TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + is_active INTEGER NOT NULL DEFAULT 1 +); + +ALTER TABLE events +ADD COLUMN ccn_pubkey TEXT; + +CREATE INDEX idx_events_ccn_pubkey ON events(ccn_pubkey); + +ALTER TABLE event_chunks RENAME COLUMN chunk_data TO content; +ALTER TABLE event_chunks ADD COLUMN ccn_pubkey TEXT; +ALTER TABLE event_chunks DROP COLUMN conversation_key; +CREATE INDEX idx_event_chunks_ccn_pubkey ON event_chunks(ccn_pubkey); + +UPDATE ccns SET is_active = 0; +UPDATE ccns SET is_active = 1 +WHERE pubkey = (SELECT pubkey FROM ccns LIMIT 1); \ No newline at end of file diff --git a/public/landing.html b/public/landing.html new file mode 100644 index 0000000..d0642ae --- /dev/null +++ b/public/landing.html @@ -0,0 +1,212 @@ + + + + + + Eve Relay - Secure Nostr Relay with CCN + + + + +
+ +

Eve Relay

+

A secure and efficient Nostr relay with Closed Community Network (CCN) functionality

+
+ +
+
+ Important: This relay is designed for WebSocket connections only. HTTP requests are not supported for data operations. +
+ +

Connection Details

+

Connect to the relay using WebSocket:

+
ws://localhost:6942
+ +
+
+

Nostr Commands

+
    +
  • REQ - Subscribe to events
  • +
  • EVENT - Publish an event
  • +
  • CLOSE - Close a subscription
  • +
+
+ +
+

CCN Commands

+
    +
  • CCN CREATE - Create a new CCN
  • +
  • CCN LIST - List all active CCNs
  • +
  • CCN ACTIVATE - Activate a specific CCN
  • +
+
+
+ +

Documentation

+

For detailed information about Arx-CCN functionality and best practices, please refer to the official documentation.

+ View Documentation +
+ + \ No newline at end of file diff --git a/utils.ts b/utils.ts index 6b6b63f..fd83c36 100644 --- a/utils.ts +++ b/utils.ts @@ -1,13 +1,15 @@ -import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; -import { exists } from 'jsr:@std/fs'; import * as nostrTools from '@nostr/tools'; import * as nip06 from '@nostr/tools/nip06'; +import type { Database } from 'jsr:@db/sqlite'; +import { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; +import { exists } from 'jsr:@std/fs'; import { decryptUint8Array, encryptUint8Array, encryptionKey, } from './utils/encryption.ts'; import { getEveFilePath } from './utils/files.ts'; +import { sql } from './utils/queries.ts'; export function isLocalhost(req: Request): boolean { const url = new URL(req.url); @@ -38,35 +40,66 @@ export function randomTimeUpTo2DaysInThePast() { ); } -export async function getCCNPubkey(): Promise { - const ccnPubPath = await getEveFilePath('ccn.pub'); - const seedPath = await getEveFilePath('ccn.seed'); - const doWeHaveKey = await exists(ccnPubPath); - if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath); - const ccnSeed = - Deno.env.get('CCN_SEED') || - ((await exists(seedPath)) - ? Deno.readTextFileSync(seedPath) - : nip06.generateSeedWords()); - const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed); - const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey); - const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey); - - Deno.writeTextFileSync(ccnPubPath, ccnPublicKey); - Deno.writeTextFileSync( - await getEveFilePath('ccn.priv'), - encodeBase64(encryptedPrivateKey), - ); - Deno.writeTextFileSync(seedPath, ccnSeed); - - return ccnPublicKey; +/** + * Get all CCNs from the database + */ +export function getAllCCNs(db: Database): { pubkey: string; name: string }[] { + return sql`SELECT pubkey, name FROM ccns`(db) as { + pubkey: string; + name: string; + }[]; } -export async function getCCNPrivateKey(): Promise { - const encryptedPrivateKey = Deno.readTextFileSync( - await getEveFilePath('ccn.priv'), - ); - return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); +/** + * Create a new CCN and store it in the database + * + * @param db - The database instance + * @param name - The name of the CCN + * @param seed - The seed words for the CCN + * @returns The public key and private key of the CCN + */ +export async function createNewCCN( + db: Database, + name: string, + seed?: string, +): Promise<{ pubkey: string; privkey: Uint8Array }> { + const ccnSeed = seed || nip06.generateSeedWords(); + const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed); + const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey); + + const ccnSeedPath = await getEveFilePath(`ccn_seeds/${ccnPublicKey}`); + const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`); + + await Deno.mkdir(await getEveFilePath('ccn_seeds'), { recursive: true }); + await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true }); + + const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey); + + Deno.writeTextFileSync(ccnSeedPath, ccnSeed); + Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey)); + + sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${name})`(db); + + return { + pubkey: ccnPublicKey, + privkey: ccnPrivateKey, + }; +} + +/** + * Get the private key for a specific CCN + */ +export async function getCCNPrivateKeyByPubkey( + pubkey: string, +): Promise { + const ccnPrivPath = await getEveFilePath(`ccn_keys/${pubkey}`); + + if (await exists(ccnPrivPath)) { + const encryptedPrivateKey = Deno.readTextFileSync(ccnPrivPath); + return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); + } + + throw new Error(`CCN private key for ${pubkey} not found`); } export function isReplaceableEvent(kind: number): boolean { @@ -104,3 +137,18 @@ export function parseATagQuery(aTagValue: string): { dTag: parts.length > 2 ? parts[2] : undefined, }; } + +/** + * Get the single active CCN from the database + * @returns The active CCN or null if none is active + */ +export function getActiveCCN( + db: Database, +): { pubkey: string; name: string } | null { + const result = sql`SELECT pubkey, name FROM ccns WHERE is_active = 1 LIMIT 1`( + db, + ); + return result.length > 0 + ? (result[0] as { pubkey: string; name: string }) + : null; +} diff --git a/utils/option.ts b/utils/option.ts new file mode 100644 index 0000000..0add74e --- /dev/null +++ b/utils/option.ts @@ -0,0 +1,40 @@ +export type Option = + | { + value: T; + isSome: true; + } + | { + value: undefined; + isSome: false; + }; + +export function Some(value: T): Option { + return { value, isSome: true }; +} + +export function None(): Option { + return { value: undefined, isSome: false }; +} + +export function map(option: Option, fn: (value: T) => U): Option { + return option.isSome ? Some(fn(option.value)) : None(); +} + +export function flatMap( + option: Option, + fn: (value: T) => Option, +): Option { + return option.isSome ? fn(option.value) : None(); +} + +export function getOrElse(option: Option, defaultValue: T): T { + return option.isSome ? option.value : defaultValue; +} + +export function fold( + option: Option, + onNone: () => U, + onSome: (value: T) => U, +): U { + return option.isSome ? onSome(option.value) : onNone(); +}