From 36c7401fa8702d9fc3be69c6d674f03b228ed068 Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Wed, 23 Apr 2025 18:13:29 +0200 Subject: [PATCH] CCN invitation and write permissions to CCN --- eventEncryptionDecryption.ts | 247 +++++++++++++++++++++-------------- index.ts | 211 ++++++++++++++++++++++++++++-- migrations/6-invitations.sql | 24 ++++ utils.ts | 8 ++ utils/invites.ts | 12 ++ 5 files changed, 395 insertions(+), 107 deletions(-) create mode 100644 migrations/6-invitations.sql create mode 100644 utils/invites.ts diff --git a/eventEncryptionDecryption.ts b/eventEncryptionDecryption.ts index 3ca4292..2553ee5 100644 --- a/eventEncryptionDecryption.ts +++ b/eventEncryptionDecryption.ts @@ -14,6 +14,71 @@ 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, @@ -29,32 +94,7 @@ export async function createEncryptedEvent( 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; + return createEncryptedEventForPubkey(ccnPubKey, event); } const chunks: string[] = []; @@ -67,35 +107,15 @@ export async function createEncryptedEvent( 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), + createEncryptedChunkForPubkey( + ccnPubKey, + chunk, + i, + totalChunks, + messageId, + ccnPrivateKey, + ), ); } @@ -111,26 +131,30 @@ function attemptDecryptWithKey( ccnPrivkey: Uint8Array, ccnPubkey: string, ): Option { - const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey); - const sealResult = map( - Some(nip44.decrypt(event.content, conversationKey)), - JSON.parse, - ); + 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(); + 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 })); - } + 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(); - }); + } } /** @@ -163,8 +187,8 @@ function handleChunkedMessage( const chunk = nip44.decrypt(seal.content, conversationKey); const storedChunks = sql` - SELECT COUNT(*) as count - FROM event_chunks + SELECT COUNT(*) as count + FROM event_chunks WHERE message_id = ${messageId} `(db)[0].count; @@ -175,8 +199,8 @@ function handleChunkedMessage( if (storedChunks + 1 === Number(totalChunks)) { const allChunks = sql` - SELECT * FROM event_chunks - WHERE message_id = ${messageId} + SELECT * FROM event_chunks + WHERE message_id = ${messageId} ORDER BY chunk_index `(db); @@ -200,34 +224,67 @@ export async function decryptEvent( 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 allCCNs = getAllCCNs(db); if (allCCNs.length === 0) { throw new Error('No CCNs found'); } - 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 }; - } + 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'); } - 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; - } + 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; } } diff --git a/index.ts b/index.ts index 8f3a4f2..104eaba 100644 --- a/index.ts +++ b/index.ts @@ -1,5 +1,8 @@ +import { bytesToHex, hexToBytes } from '@noble/ciphers/utils'; import { randomBytes } from '@noble/ciphers/webcrypto'; +import { sha512 } from '@noble/hashes/sha2'; import * as nostrTools from '@nostr/tools'; +import { base64 } from '@scure/base'; import { Database } from 'jsr:@db/sqlite'; import { NSchema as n } from 'jsr:@nostrify/nostrify'; import type { @@ -8,17 +11,23 @@ import type { NostrFilter, } from 'jsr:@nostrify/types'; import { encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; -import { CHUNK_CLEANUP_INTERVAL, CHUNK_MAX_AGE } from './consts.ts'; +import { + CHUNK_CLEANUP_INTERVAL, + CHUNK_MAX_AGE, + POW_TO_MINE, +} from './consts.ts'; import { ChunkedEventReceived, EventAlreadyExistsException, createEncryptedEvent, + createEncryptedEventForPubkey, decryptEvent, } from './eventEncryptionDecryption.ts'; import { createNewCCN, getActiveCCN, getAllCCNs, + getCCNPrivateKeyByPubkey, isAddressableEvent, isArray, isCCNReplaceableEvent, @@ -42,6 +51,8 @@ if (!Deno.env.has('ENCRYPTION_KEY')) { Deno.exit(1); } +await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true }); + const db = new Database(await getEveFilePath('db')); const pool = new nostrTools.SimplePool(); const relays = [ @@ -123,12 +134,106 @@ function addEventToDb( `(db)[0]; if (existingEvent) throw new EventAlreadyExistsException(); + + const isInvite = + decryptedEvent.tags.findIndex( + (tag: string[]) => tag[0] === 'type' && tag[1] === 'invite', + ) !== -1; + + if (isInvite) { + const shadContent = bytesToHex( + sha512.create().update(decryptedEvent.content).digest(), + ); + + const inviteUsed = sql` + SELECT COUNT(*) as count FROM inviter_invitee WHERE invite_hash = ${shadContent} + `(db)[0].count; + + if (inviteUsed > 0) { + throw new Error('Invite already used'); + } + + const inviteEvent = sql` + SELECT * FROM events WHERE kind = 9999 AND id IN ( + SELECT event_id FROM event_tags WHERE tag_name = 'i' AND tag_id IN ( + SELECT tag_id FROM event_tags_values WHERE value_position = 1 AND value = ${shadContent} + ) + ) + `(db)[0]; + + if (!inviteEvent) { + throw new Error('Invite event not found'); + } + + const inviterPubkey = inviteEvent.pubkey; + const inviteePubkey = decryptedEvent.pubkey; + + db.run('BEGIN TRANSACTION'); + + sql` + INSERT INTO inviter_invitee (ccn_pubkey, inviter_pubkey, invitee_pubkey, invite_hash) VALUES (${ccnPubkey}, ${inviterPubkey}, ${inviteePubkey}, ${shadContent}) + `(db); + + sql` + INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPubkey}, ${inviteePubkey}) + `(db); + + db.run('COMMIT TRANSACTION'); + + const allowedPubkeys = sql` + SELECT pubkey FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} + `(db).flatMap((row) => row.pubkey); + const ccnName = sql` + SELECT name FROM ccns WHERE pubkey = ${ccnPubkey} + `(db)[0].name; + + getCCNPrivateKeyByPubkey(ccnPubkey).then((ccnPrivateKey) => { + if (!ccnPrivateKey) { + throw new Error('CCN private key not found'); + } + + const tags = allowedPubkeys.map((pubkey) => ['p', pubkey]); + tags.push(['t', 'invite']); + tags.push(['name', ccnName]); + + const privateKeyEvent = nostrTools.finalizeEvent( + nostrTools.nip13.minePow( + { + kind: 9998, + created_at: Date.now(), + content: base64.encode(ccnPrivateKey), + tags, + pubkey: ccnPubkey, + }, + POW_TO_MINE, + ), + ccnPrivateKey, + ); + + const encryptedKeyEvent = createEncryptedEventForPubkey( + inviteePubkey, + privateKeyEvent, + ); + publishToRelays(encryptedKeyEvent); + }); + + return; + } + + const isAllowedWrite = sql` + SELECT COUNT(*) as count FROM allowed_writes WHERE ccn_pubkey = ${ccnPubkey} AND pubkey = ${decryptedEvent.pubkey} + `(db)[0].count; + + if (isAllowedWrite === 0) { + throw new Error('Not allowed to write to this CCN'); + } + try { db.run('BEGIN TRANSACTION'); if (isReplaceableEvent(decryptedEvent.kind)) { sql` - UPDATE events + UPDATE events SET replaced = 1 WHERE kind = ${decryptedEvent.kind} AND pubkey = ${decryptedEvent.pubkey} @@ -141,15 +246,15 @@ function addEventToDb( const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; if (dTag) { sql` - UPDATE events + UPDATE events SET replaced = 1 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' + SELECT event_id FROM event_tags + WHERE tag_name = 'd' AND tag_id IN ( SELECT tag_id FROM event_tags_values WHERE value_position = 1 @@ -163,14 +268,14 @@ function addEventToDb( if (isCCNReplaceableEvent(decryptedEvent.kind)) { const dTag = decryptedEvent.tags.find((tag) => tag[0] === 'd')?.[1]; sql` - UPDATE events + UPDATE events 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' + SELECT event_id FROM event_tags + WHERE tag_name = 'd' AND tag_id IN ( SELECT tag_id FROM event_tags_values WHERE value_position = 1 @@ -561,7 +666,7 @@ function handleRequest(connection: UserConnection, request: NostrClientREQ) { FROM event_tags t WHERE t.tag_name = ${tag} AND t.tag_id IN ( - SELECT v.tag_id + SELECT v.tag_id FROM event_tags_values v WHERE v.value_position = 1 AND v.value = ${tagValue} @@ -735,7 +840,7 @@ function handleSocketError( async function handleCreateCCN( connection: UserConnection, - data: { name: string; seed?: string }, + data: { name: string; seed?: string; creator: string }, ): Promise { try { if (!data.name || typeof data.name !== 'string') { @@ -743,7 +848,17 @@ async function handleCreateCCN( return; } - const newCcn = await createNewCCN(connection.db, data.name, data.seed); + if (!data.creator || typeof data.creator !== 'string') { + connection.sendNotice('Creator is required'); + return; + } + + const newCcn = await createNewCCN( + connection.db, + data.name, + data.creator, + data.seed, + ); activateCCN(connection.db, newCcn.pubkey); @@ -834,6 +949,73 @@ function handleActivateCCN( } } +async function handleAddCCN( + connection: UserConnection, + data: { name: string; allowedPubkeys: string[]; privateKey: string }, +): Promise { + try { + if (!data.privateKey || typeof data.privateKey !== 'string') { + connection.sendNotice('CCN private key is required'); + return; + } + + const privateKeyBytes = hexToBytes(data.privateKey); + const pubkey = nostrTools.getPublicKey(privateKeyBytes); + + const ccnExists = sql` + SELECT COUNT(*) as count FROM ccns WHERE pubkey = ${pubkey} + `(connection.db)[0].count; + + if (ccnExists > 0) { + connection.sendNotice('CCN already exists'); + return; + } + + const ccnPublicKey = nostrTools.getPublicKey(privateKeyBytes); + const ccnPrivPath = await getEveFilePath(`ccn_keys/${ccnPublicKey}`); + Deno.writeTextFileSync(ccnPrivPath, encodeBase64(privateKeyBytes)); + + db.run('BEGIN TRANSACTION'); + + sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${data.name})`( + db, + ); + for (const allowedPubkey of data.allowedPubkeys) + sql`INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPublicKey}, ${allowedPubkey})`( + db, + ); + + db.run('COMMIT TRANSACTION'); + + activateCCN(connection.db, ccnPublicKey); + + pool.subscribeMany( + relays, + [ + { + '#p': [ccnPublicKey], + kinds: [1059], + }, + ], + { + onevent: createSubscriptionEventHandler(connection.db), + }, + ); + + connection.sendResponse([ + 'OK', + 'CCN ADDED', + true, + JSON.stringify({ + pubkey: ccnPublicKey, + name: 'New CCN', + }), + ]); + } catch (error: unknown) { + handleSocketError(connection, 'ADD CCN', error); + } +} + function handleCCNCommands( connection: UserConnection, command: string, @@ -843,7 +1025,12 @@ function handleCCNCommands( case 'CREATE': return handleCreateCCN( connection, - data as { name: string; seed?: string }, + data as { name: string; seed?: string; creator: string }, + ); + case 'ADD': + return handleAddCCN( + connection, + data as { name: string; allowedPubkeys: string[]; privateKey: string }, ); case 'LIST': return handleGetCCNs(connection); diff --git a/migrations/6-invitations.sql b/migrations/6-invitations.sql new file mode 100644 index 0000000..ab6aeba --- /dev/null +++ b/migrations/6-invitations.sql @@ -0,0 +1,24 @@ +CREATE TABLE inviter_invitee( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(16)))), + ccn_pubkey TEXT NOT NULL, + inviter_pubkey TEXT NOT NULL, + invitee_pubkey TEXT NOT NULL, + invite_hash TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + FOREIGN KEY (ccn_pubkey) REFERENCES ccns(pubkey) ON DELETE CASCADE +); + +CREATE INDEX idx_inviter_invitee_ccn_pubkey ON inviter_invitee(ccn_pubkey); +CREATE INDEX idx_inviter_invitee_inviter_pubkey ON inviter_invitee(inviter_pubkey); +CREATE INDEX idx_inviter_invitee_invitee_pubkey ON inviter_invitee(invitee_pubkey); + +CREATE TABLE allowed_writes ( + id TEXT PRIMARY KEY NOT NULL DEFAULT (lower(hex(randomblob(16)))), + ccn_pubkey TEXT NOT NULL, + pubkey TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + FOREIGN KEY (ccn_pubkey) REFERENCES ccns(pubkey) ON DELETE CASCADE +); + +CREATE INDEX idx_allowed_writes_ccn_pubkey ON allowed_writes(ccn_pubkey); +CREATE INDEX idx_allowed_writes_pubkey ON allowed_writes(pubkey); diff --git a/utils.ts b/utils.ts index fd83c36..7c67aa5 100644 --- a/utils.ts +++ b/utils.ts @@ -61,6 +61,7 @@ export function getAllCCNs(db: Database): { pubkey: string; name: string }[] { export async function createNewCCN( db: Database, name: string, + creator: string, seed?: string, ): Promise<{ pubkey: string; privkey: Uint8Array }> { const ccnSeed = seed || nip06.generateSeedWords(); @@ -78,7 +79,14 @@ export async function createNewCCN( Deno.writeTextFileSync(ccnSeedPath, ccnSeed); Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey)); + db.run('BEGIN TRANSACTION'); + sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${name})`(db); + sql`INSERT INTO allowed_writes (ccn_pubkey, pubkey) VALUES (${ccnPublicKey}, ${creator})`( + db, + ); + + db.run('COMMIT TRANSACTION'); return { pubkey: ccnPublicKey, diff --git a/utils/invites.ts b/utils/invites.ts new file mode 100644 index 0000000..45a61ec --- /dev/null +++ b/utils/invites.ts @@ -0,0 +1,12 @@ +import { bytesToHex } from '@noble/ciphers/utils'; +import { nip19 } from '@nostr/tools'; +import { bech32m } from '@scure/base'; + +export function readInvite(invite: `${string}1${string}`) { + const decoded = bech32m.decode(invite, false); + if (decoded.prefix !== 'eveinvite') return false; + const hexBytes = bech32m.fromWords(decoded.words); + const npub = nip19.npubEncode(bytesToHex(hexBytes.slice(0, 32))); + const inviteCode = bytesToHex(hexBytes.slice(32)); + return { npub, invite: inviteCode }; +}