CCN invitation and write permissions to CCN

This commit is contained in:
Danny Morabito 2025-04-23 18:13:29 +02:00
parent a8ffce918e
commit 36c7401fa8
Signed by: dannym
GPG key ID: 7CC8056A5A04557E
5 changed files with 395 additions and 107 deletions

View file

@ -14,6 +14,71 @@ import { sql } from './utils/queries.ts';
export class EventAlreadyExistsException extends Error {} export class EventAlreadyExistsException extends Error {}
export class ChunkedEventReceived 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( export async function createEncryptedEvent(
event: nostrTools.VerifiedEvent, event: nostrTools.VerifiedEvent,
db: Database, db: Database,
@ -29,32 +94,7 @@ export async function createEncryptedEvent(
const eventJson = JSON.stringify(event); const eventJson = JSON.stringify(event);
if (eventJson.length <= MAX_CHUNK_SIZE) { if (eventJson.length <= MAX_CHUNK_SIZE) {
const randomPrivateKey = nostrTools.generateSecretKey(); return createEncryptedEventForPubkey(ccnPubKey, event);
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[] = []; const chunks: string[] = [];
@ -67,35 +107,15 @@ export async function createEncryptedEvent(
const encryptedChunks = []; const encryptedChunks = [];
for (let i = 0; i < chunks.length; i++) { for (let i = 0; i < chunks.length; i++) {
const chunk = chunks[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( encryptedChunks.push(
nostrTools.finalizeEvent(minedGiftWrap, randomPrivateKey), createEncryptedChunkForPubkey(
ccnPubKey,
chunk,
i,
totalChunks,
messageId,
ccnPrivateKey,
),
); );
} }
@ -111,26 +131,30 @@ function attemptDecryptWithKey(
ccnPrivkey: Uint8Array, ccnPrivkey: Uint8Array,
ccnPubkey: string, ccnPubkey: string,
): Option<nostrTools.VerifiedEvent> { ): Option<nostrTools.VerifiedEvent> {
const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey); try {
const sealResult = map( const conversationKey = nip44.getConversationKey(ccnPrivkey, event.pubkey);
Some(nip44.decrypt(event.content, conversationKey)), const sealResult = map(
JSON.parse, Some(nip44.decrypt(event.content, conversationKey)),
); JSON.parse,
);
return flatMap(sealResult, (seal) => { return flatMap(sealResult, (seal) => {
if (!seal || seal.kind !== 13) return None(); if (!seal || seal.kind !== 13) return None();
const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk'); const chunkTag = seal.tags.find((tag: string[]) => tag[0] === 'chunk');
if (!chunkTag) { if (!chunkTag) {
const contentResult = map( const contentResult = map(
Some(nip44.decrypt(seal.content, conversationKey)), Some(nip44.decrypt(seal.content, conversationKey)),
JSON.parse, JSON.parse,
); );
return map(contentResult, (content) => ({ ...content, ccnPubkey })); return map(contentResult, (content) => ({ ...content, ccnPubkey }));
} }
return None();
});
} catch {
return None(); return None();
}); }
} }
/** /**
@ -200,34 +224,67 @@ export async function decryptEvent(
throw new Error('Cannot decrypt event -- not a gift wrap'); 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); const allCCNs = getAllCCNs(db);
if (allCCNs.length === 0) { if (allCCNs.length === 0) {
throw new Error('No CCNs found'); throw new Error('No CCNs found');
} }
for (const ccn of allCCNs) { const pow = nostrTools.nip13.getPow(event.id);
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey); const isInvite =
const decryptedEvent = attemptDecryptWithKey(event, ccnPrivkey, ccn.pubkey); event.tags.findIndex(
if (decryptedEvent.isSome) { (tag: string[]) => tag[0] === 'type' && tag[1] === 'invite',
return { ...decryptedEvent.value, ccnPubkey: ccn.pubkey }; ) !== -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) { if (isInvite) {
const ccnPrivkey = await getCCNPrivateKeyByPubkey(ccn.pubkey); const ccnPrivkey = await getCCNPrivateKeyByPubkey(eventDestination);
try { const decryptedEvent = attemptDecryptWithKey(
const chuncked = handleChunkedMessage(db, event, ccnPrivkey, ccn.pubkey); event,
return { ...chuncked, ccnPubkey: ccn.pubkey }; ccnPrivkey,
} catch (e) { eventDestination,
if (e instanceof ChunkedEventReceived) { );
throw e; 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;
} }
} }

195
index.ts
View file

@ -1,5 +1,8 @@
import { bytesToHex, hexToBytes } from '@noble/ciphers/utils';
import { randomBytes } from '@noble/ciphers/webcrypto'; import { randomBytes } from '@noble/ciphers/webcrypto';
import { sha512 } from '@noble/hashes/sha2';
import * as nostrTools from '@nostr/tools'; import * as nostrTools from '@nostr/tools';
import { base64 } from '@scure/base';
import { Database } from 'jsr:@db/sqlite'; import { Database } from 'jsr:@db/sqlite';
import { NSchema as n } from 'jsr:@nostrify/nostrify'; import { NSchema as n } from 'jsr:@nostrify/nostrify';
import type { import type {
@ -8,17 +11,23 @@ import type {
NostrFilter, NostrFilter,
} from 'jsr:@nostrify/types'; } from 'jsr:@nostrify/types';
import { encodeBase64 } from 'jsr:@std/encoding@0.224/base64'; 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 { import {
ChunkedEventReceived, ChunkedEventReceived,
EventAlreadyExistsException, EventAlreadyExistsException,
createEncryptedEvent, createEncryptedEvent,
createEncryptedEventForPubkey,
decryptEvent, decryptEvent,
} from './eventEncryptionDecryption.ts'; } from './eventEncryptionDecryption.ts';
import { import {
createNewCCN, createNewCCN,
getActiveCCN, getActiveCCN,
getAllCCNs, getAllCCNs,
getCCNPrivateKeyByPubkey,
isAddressableEvent, isAddressableEvent,
isArray, isArray,
isCCNReplaceableEvent, isCCNReplaceableEvent,
@ -42,6 +51,8 @@ if (!Deno.env.has('ENCRYPTION_KEY')) {
Deno.exit(1); Deno.exit(1);
} }
await Deno.mkdir(await getEveFilePath('ccn_keys'), { recursive: true });
const db = new Database(await getEveFilePath('db')); const db = new Database(await getEveFilePath('db'));
const pool = new nostrTools.SimplePool(); const pool = new nostrTools.SimplePool();
const relays = [ const relays = [
@ -123,6 +134,100 @@ function addEventToDb(
`(db)[0]; `(db)[0];
if (existingEvent) throw new EventAlreadyExistsException(); 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 { try {
db.run('BEGIN TRANSACTION'); db.run('BEGIN TRANSACTION');
@ -735,7 +840,7 @@ function handleSocketError(
async function handleCreateCCN( async function handleCreateCCN(
connection: UserConnection, connection: UserConnection,
data: { name: string; seed?: string }, data: { name: string; seed?: string; creator: string },
): Promise<void> { ): Promise<void> {
try { try {
if (!data.name || typeof data.name !== 'string') { if (!data.name || typeof data.name !== 'string') {
@ -743,7 +848,17 @@ async function handleCreateCCN(
return; 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); activateCCN(connection.db, newCcn.pubkey);
@ -834,6 +949,73 @@ function handleActivateCCN(
} }
} }
async function handleAddCCN(
connection: UserConnection,
data: { name: string; allowedPubkeys: string[]; privateKey: string },
): Promise<void> {
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( function handleCCNCommands(
connection: UserConnection, connection: UserConnection,
command: string, command: string,
@ -843,7 +1025,12 @@ function handleCCNCommands(
case 'CREATE': case 'CREATE':
return handleCreateCCN( return handleCreateCCN(
connection, 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': case 'LIST':
return handleGetCCNs(connection); return handleGetCCNs(connection);

View file

@ -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);

View file

@ -61,6 +61,7 @@ export function getAllCCNs(db: Database): { pubkey: string; name: string }[] {
export async function createNewCCN( export async function createNewCCN(
db: Database, db: Database,
name: string, name: string,
creator: string,
seed?: string, seed?: string,
): Promise<{ pubkey: string; privkey: Uint8Array }> { ): Promise<{ pubkey: string; privkey: Uint8Array }> {
const ccnSeed = seed || nip06.generateSeedWords(); const ccnSeed = seed || nip06.generateSeedWords();
@ -78,7 +79,14 @@ export async function createNewCCN(
Deno.writeTextFileSync(ccnSeedPath, ccnSeed); Deno.writeTextFileSync(ccnSeedPath, ccnSeed);
Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey)); Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey));
db.run('BEGIN TRANSACTION');
sql`INSERT INTO ccns (pubkey, name) VALUES (${ccnPublicKey}, ${name})`(db); 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 { return {
pubkey: ccnPublicKey, pubkey: ccnPublicKey,

12
utils/invites.ts Normal file
View file

@ -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 };
}