Compare commits
10 commits
feature/ml
...
master
Author | SHA1 | Date | |
---|---|---|---|
190e38dfc1 | |||
107504050e | |||
36c7401fa8 | |||
a8ffce918e | |||
097f02938d | |||
8906d8f7f7 | |||
89d9dc3cbe | |||
a4134fa416 | |||
4bd0839669 | |||
7dbb4a522f |
20 changed files with 1652 additions and 1058 deletions
14
Makefile
Normal file
14
Makefile
Normal file
|
@ -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/*
|
44
biome.json
Normal file
44
biome.json
Normal file
|
@ -0,0 +1,44 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"files": {
|
||||
"include": ["index.ts", "**/*.ts"]
|
||||
},
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"style": {
|
||||
"noNonNullAssertion": "off",
|
||||
"useNodejsImportProtocol": "warn"
|
||||
},
|
||||
"complexity": {
|
||||
"useLiteralKeys": "off"
|
||||
}
|
||||
}
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"formatWithErrors": true,
|
||||
"ignore": [],
|
||||
"attributePosition": "auto",
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"lineEnding": "lf"
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"arrowParentheses": "always",
|
||||
"bracketSameLine": true,
|
||||
"bracketSpacing": true,
|
||||
"quoteStyle": "single",
|
||||
"quoteProperties": "asNeeded",
|
||||
"semicolons": "always",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
}
|
||||
}
|
24
consts.ts
24
consts.ts
|
@ -18,3 +18,27 @@ export const MIN_POW = 8;
|
|||
* - Difficulty 21: ~5-6 seconds
|
||||
*/
|
||||
export const POW_TO_MINE = 10;
|
||||
|
||||
/**
|
||||
* Maximum size of a note chunk in bytes.
|
||||
*
|
||||
* This value determines the maximum size of a note that can be encrypted and
|
||||
* sent in a single chunk.
|
||||
*/
|
||||
export const MAX_CHUNK_SIZE = 32768;
|
||||
|
||||
/**
|
||||
* Interval for cleaning up expired note chunks in milliseconds.
|
||||
*
|
||||
* This value determines how often the relay will check for and remove expired
|
||||
* note chunks from the database.
|
||||
*/
|
||||
export const CHUNK_CLEANUP_INTERVAL = 1000 * 60 * 60;
|
||||
|
||||
/**
|
||||
* Maximum age of a note chunk in milliseconds.
|
||||
*
|
||||
* This value determines the maximum duration a note chunk can remain in the
|
||||
* database before it is considered expired and eligible for cleanup.
|
||||
*/
|
||||
export const CHUNK_MAX_AGE = 1000 * 60 * 60 * 24;
|
||||
|
|
14
deno.json
14
deno.json
|
@ -1,11 +1,13 @@
|
|||
{
|
||||
"tasks": {
|
||||
"dev": "deno run --allow-read --allow-write --allow-net --allow-ffi --allow-env --env-file --watch index.ts"
|
||||
"dev": "deno run --allow-read --allow-write --allow-net --allow-ffi --allow-env --env-file --watch index.ts",
|
||||
"lint": "biome check",
|
||||
"lint:fix": "biome check --write --unsafe"
|
||||
},
|
||||
"imports": {
|
||||
"@biomejs/biome": "npm:@biomejs/biome@^1.9.4",
|
||||
"@db/sqlite": "jsr:@db/sqlite@^0.12.0",
|
||||
"@noble/ciphers": "jsr:@noble/ciphers@^1.2.1",
|
||||
"@noble/secp256k1": "jsr:@noble/secp256k1@^2.2.3",
|
||||
"@nostr/tools": "jsr:@nostr/tools@^2.10.4",
|
||||
"@nostrify/nostrify": "jsr:@nostrify/nostrify@^0.37.0",
|
||||
"@nostrify/types": "jsr:@nostrify/types@^0.36.0",
|
||||
|
@ -13,13 +15,5 @@
|
|||
"@std/fmt": "jsr:@std/fmt@^1.0.4",
|
||||
"@std/log": "jsr:@std/log@^0.224.13",
|
||||
"@types/deno": "npm:@types/deno@^2.0.0"
|
||||
},
|
||||
"fmt": {
|
||||
"indentWidth": 2,
|
||||
"useTabs": false,
|
||||
"lineWidth": 80,
|
||||
"proseWrap": "always",
|
||||
"semiColons": true,
|
||||
"singleQuote": false
|
||||
}
|
||||
}
|
||||
|
|
45
deno.lock
generated
45
deno.lock
generated
|
@ -5,7 +5,6 @@
|
|||
"jsr:@db/sqlite@0.12": "0.12.0",
|
||||
"jsr:@denosaurs/plug@1": "1.0.6",
|
||||
"jsr:@noble/ciphers@^1.2.1": "1.2.1",
|
||||
"jsr:@noble/secp256k1@^2.2.3": "2.2.3",
|
||||
"jsr:@nostr/tools@^2.10.4": "2.10.4",
|
||||
"jsr:@nostrify/nostrify@*": "0.37.0",
|
||||
"jsr:@nostrify/nostrify@0.37": "0.37.0",
|
||||
|
@ -31,6 +30,8 @@
|
|||
"jsr:@std/path@0.217": "0.217.0",
|
||||
"jsr:@std/path@0.221": "0.221.0",
|
||||
"jsr:@std/path@^1.0.8": "1.0.8",
|
||||
"npm:@biomejs/biome@1.9.4": "1.9.4",
|
||||
"npm:@biomejs/biome@^1.9.4": "1.9.4",
|
||||
"npm:@noble/ciphers@~0.5.1": "0.5.3",
|
||||
"npm:@noble/curves@1.2.0": "1.2.0",
|
||||
"npm:@noble/hashes@1.3.1": "1.3.1",
|
||||
|
@ -67,9 +68,6 @@
|
|||
"@noble/ciphers@1.2.1": {
|
||||
"integrity": "e8eba45a1a6fefa6e522872d2f6b2bcc40d6ff928bdacfb3add5e245c1656819"
|
||||
},
|
||||
"@noble/secp256k1@2.2.3": {
|
||||
"integrity": "830435da513d7d65fa6868061a0048b0f3ade456646c0f79a0675e7f4e965600"
|
||||
},
|
||||
"@nostr/tools@2.10.4": {
|
||||
"integrity": "7fda015c96b4f674727843aecb990e2af1989e4724588415ccf6f69066abfd4f",
|
||||
"dependencies": [
|
||||
|
@ -172,6 +170,43 @@
|
|||
}
|
||||
},
|
||||
"npm": {
|
||||
"@biomejs/biome@1.9.4": {
|
||||
"integrity": "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog==",
|
||||
"dependencies": [
|
||||
"@biomejs/cli-darwin-arm64",
|
||||
"@biomejs/cli-darwin-x64",
|
||||
"@biomejs/cli-linux-arm64",
|
||||
"@biomejs/cli-linux-arm64-musl",
|
||||
"@biomejs/cli-linux-x64",
|
||||
"@biomejs/cli-linux-x64-musl",
|
||||
"@biomejs/cli-win32-arm64",
|
||||
"@biomejs/cli-win32-x64"
|
||||
]
|
||||
},
|
||||
"@biomejs/cli-darwin-arm64@1.9.4": {
|
||||
"integrity": "sha512-bFBsPWrNvkdKrNCYeAp+xo2HecOGPAy9WyNyB/jKnnedgzl4W4Hb9ZMzYNbf8dMCGmUdSavlYHiR01QaYR58cw=="
|
||||
},
|
||||
"@biomejs/cli-darwin-x64@1.9.4": {
|
||||
"integrity": "sha512-ngYBh/+bEedqkSevPVhLP4QfVPCpb+4BBe2p7Xs32dBgs7rh9nY2AIYUL6BgLw1JVXV8GlpKmb/hNiuIxfPfZg=="
|
||||
},
|
||||
"@biomejs/cli-linux-arm64-musl@1.9.4": {
|
||||
"integrity": "sha512-v665Ct9WCRjGa8+kTr0CzApU0+XXtRgwmzIf1SeKSGAv+2scAlW6JR5PMFo6FzqqZ64Po79cKODKf3/AAmECqA=="
|
||||
},
|
||||
"@biomejs/cli-linux-arm64@1.9.4": {
|
||||
"integrity": "sha512-fJIW0+LYujdjUgJJuwesP4EjIBl/N/TcOX3IvIHJQNsAqvV2CHIogsmA94BPG6jZATS4Hi+xv4SkBBQSt1N4/g=="
|
||||
},
|
||||
"@biomejs/cli-linux-x64-musl@1.9.4": {
|
||||
"integrity": "sha512-gEhi/jSBhZ2m6wjV530Yy8+fNqG8PAinM3oV7CyO+6c3CEh16Eizm21uHVsyVBEB6RIM8JHIl6AGYCv6Q6Q9Tg=="
|
||||
},
|
||||
"@biomejs/cli-linux-x64@1.9.4": {
|
||||
"integrity": "sha512-lRCJv/Vi3Vlwmbd6K+oQ0KhLHMAysN8lXoCI7XeHlxaajk06u7G+UsFSO01NAs5iYuWKmVZjmiOzJ0OJmGsMwg=="
|
||||
},
|
||||
"@biomejs/cli-win32-arm64@1.9.4": {
|
||||
"integrity": "sha512-tlbhLk+WXZmgwoIKwHIHEBZUwxml7bRJgk0X2sPyNR3S93cdRq6XulAZRQJ17FYGGzWne0fgrXBKpl7l4M87Hg=="
|
||||
},
|
||||
"@biomejs/cli-win32-x64@1.9.4": {
|
||||
"integrity": "sha512-8Y5wMhVIPaWe6jw2H+KlEm4wP/f7EW3810ZLmDlrEEy5KvBsb9ECEfu/kMWD484ijfQ8+nIi0giMgu9g1UAuuA=="
|
||||
},
|
||||
"@noble/ciphers@0.5.3": {
|
||||
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
|
||||
},
|
||||
|
@ -270,13 +305,13 @@
|
|||
"dependencies": [
|
||||
"jsr:@db/sqlite@0.12",
|
||||
"jsr:@noble/ciphers@^1.2.1",
|
||||
"jsr:@noble/secp256k1@^2.2.3",
|
||||
"jsr:@nostr/tools@^2.10.4",
|
||||
"jsr:@nostrify/nostrify@0.37",
|
||||
"jsr:@nostrify/types@0.36",
|
||||
"jsr:@std/encoding@^1.0.6",
|
||||
"jsr:@std/fmt@^1.0.4",
|
||||
"jsr:@std/log@~0.224.13",
|
||||
"npm:@biomejs/biome@^1.9.4",
|
||||
"npm:@types/deno@2"
|
||||
]
|
||||
}
|
||||
|
|
292
eventEncryptionDecryption.ts
Normal file
292
eventEncryptionDecryption.ts
Normal file
|
@ -0,0 +1,292 @@
|
|||
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<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 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<nostrTools.VerifiedEvent> {
|
||||
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<nostrTools.VerifiedEvent & { ccnPubkey: string }> {
|
||||
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');
|
||||
}
|
2
migrations/3-replaceableEvents.sql
Normal file
2
migrations/3-replaceableEvents.sql
Normal file
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE events
|
||||
ADD COLUMN replaced INTEGER NOT NULL DEFAULT 0;
|
13
migrations/4-createChunksStore.sql
Normal file
13
migrations/4-createChunksStore.sql
Normal file
|
@ -0,0 +1,13 @@
|
|||
CREATE TABLE event_chunks (
|
||||
chunk_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
message_id TEXT NOT NULL,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
total_chunks INTEGER NOT NULL,
|
||||
chunk_data TEXT NOT NULL,
|
||||
conversation_key TEXT NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(message_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_event_chunks_message_id ON event_chunks(message_id);
|
||||
CREATE INDEX idx_event_chunks_created_at ON event_chunks(created_at);
|
21
migrations/5-multiCCN.sql
Normal file
21
migrations/5-multiCCN.sql
Normal file
|
@ -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);
|
24
migrations/6-invitations.sql
Normal file
24
migrations/6-invitations.sql
Normal 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);
|
674
mls.ts
674
mls.ts
|
@ -1,674 +0,0 @@
|
|||
import type { NPub, NSec } from "@nostr/tools/nip19";
|
||||
import * as nip19 from "@nostr/tools/nip19";
|
||||
import * as nostrTools from "@nostr/tools";
|
||||
import { hkdf } from "npm:@noble/hashes@1.3.1/hkdf";
|
||||
import { sha256 } from "npm:@noble/hashes@1.3.1/sha256";
|
||||
import * as secp256k1 from "@noble/secp256k1";
|
||||
import { randomBytes } from "@noble/ciphers/webcrypto";
|
||||
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { bytesEqual, getCCNPrivateKey } from "./utils.ts";
|
||||
import { decodeBase64, encodeBase64 } from "jsr:@std/encoding/base64";
|
||||
import {
|
||||
decryptUint8Array,
|
||||
encryptionKey,
|
||||
encryptUint8Array,
|
||||
} from "./utils/encryption.ts";
|
||||
|
||||
type CCNId = string;
|
||||
type Epoch = number;
|
||||
|
||||
interface CCNState {
|
||||
id: CCNId;
|
||||
epoch: Epoch;
|
||||
members: Map<NPub, NPub>;
|
||||
ccnKey: Uint8Array;
|
||||
privateKey: NSec;
|
||||
ratchetTree: RatchetNode[];
|
||||
}
|
||||
|
||||
interface RatchetNode {
|
||||
publicKey: NPub | null;
|
||||
path: number[];
|
||||
parent: number | null;
|
||||
children: number[];
|
||||
}
|
||||
|
||||
interface EncryptedMessage {
|
||||
sender: NPub;
|
||||
epoch: Epoch;
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
interface Welcome {
|
||||
ccnId: CCNId;
|
||||
epoch: Epoch;
|
||||
encryptedCCNState: Uint8Array;
|
||||
pathSecret: Uint8Array;
|
||||
inviterPublicKey: NPub;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
interface SerializedCCN {
|
||||
version: number;
|
||||
id: CCNId;
|
||||
epoch: Epoch;
|
||||
members: [NPub, NPub][];
|
||||
ccnKey: string;
|
||||
privateKey: NSec;
|
||||
ratchetTree: SerializedRatchetNode[];
|
||||
}
|
||||
|
||||
interface SerializedRatchetNode {
|
||||
publicKey: NPub | null;
|
||||
path: number[];
|
||||
parent: number | null;
|
||||
children: number[];
|
||||
}
|
||||
|
||||
const textEncoder = new TextEncoder();
|
||||
const textDecoder = new TextDecoder();
|
||||
|
||||
function generateNonce(): Uint8Array {
|
||||
return randomBytes(24);
|
||||
}
|
||||
|
||||
function deriveCCNKey(
|
||||
secret: Uint8Array,
|
||||
ccnId: CCNId,
|
||||
epoch: Epoch,
|
||||
): Uint8Array {
|
||||
const info = textEncoder.encode(`MLS CCN Key ${ccnId} ${epoch}`);
|
||||
return hkdf(sha256, secret, new Uint8Array(0), info, 32);
|
||||
}
|
||||
|
||||
function derivePathSecret(secret: Uint8Array, path: number[]): Uint8Array {
|
||||
const pathStr = path.join("/");
|
||||
const info = textEncoder.encode(`MLS Path Secret ${pathStr}`);
|
||||
return hkdf(sha256, secret, new Uint8Array(0), info, 32);
|
||||
}
|
||||
|
||||
interface UpdateMessage {
|
||||
sender: NPub;
|
||||
ccnId: CCNId;
|
||||
epoch: Epoch;
|
||||
pathSecrets: Map<NPub, EncryptedPathSecret>;
|
||||
updatedPublicKeys: Map<number, NPub>;
|
||||
signature: Uint8Array;
|
||||
}
|
||||
|
||||
interface EncryptedPathSecret {
|
||||
ciphertext: Uint8Array;
|
||||
nonce: Uint8Array;
|
||||
}
|
||||
|
||||
export class MLS {
|
||||
private ccns: Map<CCNId, CCNState> = new Map();
|
||||
private identity: { publicKey: NPub; privateKey: NSec };
|
||||
|
||||
constructor(privateKey?: NSec) {
|
||||
const privateKeyBytes = privateKey
|
||||
? nip19.decode(privateKey).data
|
||||
: nostrTools.generateSecretKey();
|
||||
if (!privateKey) {
|
||||
privateKey = nip19.nsecEncode(privateKeyBytes);
|
||||
}
|
||||
this.identity = {
|
||||
privateKey: privateKey,
|
||||
publicKey: nip19.npubEncode(
|
||||
nostrTools.getPublicKey(privateKeyBytes),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
get memberId(): NPub {
|
||||
return nip19.npubEncode(nostrTools.getPublicKey(
|
||||
nip19.decode(this.identity.privateKey).data,
|
||||
));
|
||||
}
|
||||
|
||||
createCCN(ccnId: CCNId): CCNId {
|
||||
const secret = randomBytes(32);
|
||||
const epoch = 0;
|
||||
const ccnKey = deriveCCNKey(secret, ccnId, epoch);
|
||||
|
||||
const ratchetTree: RatchetNode[] = [
|
||||
{
|
||||
publicKey: this.identity.publicKey,
|
||||
path: [0],
|
||||
parent: null,
|
||||
children: [],
|
||||
},
|
||||
];
|
||||
|
||||
const ccnState: CCNState = {
|
||||
id: ccnId,
|
||||
epoch,
|
||||
members: new Map([[this.memberId, this.identity.publicKey]]),
|
||||
ccnKey,
|
||||
privateKey: this.identity.privateKey,
|
||||
ratchetTree,
|
||||
};
|
||||
|
||||
this.ccns.set(ccnId, ccnState);
|
||||
return ccnId;
|
||||
}
|
||||
|
||||
serializeCCN(ccnId: CCNId) {
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) {
|
||||
throw new Error(`CCN ${ccnId} not found`);
|
||||
}
|
||||
const serializedCCN: SerializedCCN = {
|
||||
version: 1, // For future compatibility
|
||||
id: CCN.id,
|
||||
epoch: CCN.epoch,
|
||||
members: Array.from(CCN.members.entries()),
|
||||
ccnKey: encodeBase64(CCN.ccnKey),
|
||||
privateKey: CCN.privateKey,
|
||||
ratchetTree: CCN.ratchetTree,
|
||||
};
|
||||
const json = JSON.stringify(serializedCCN);
|
||||
const encrypted = encryptUint8Array(
|
||||
textEncoder.encode(json),
|
||||
encryptionKey,
|
||||
);
|
||||
return encodeBase64(encrypted);
|
||||
}
|
||||
|
||||
deserializeCCN(serialized: string) {
|
||||
const encrypted = decodeBase64(serialized);
|
||||
const decrypted = decryptUint8Array(encrypted, encryptionKey);
|
||||
const json = textDecoder.decode(decrypted);
|
||||
const serializedCCN = JSON.parse(json) as SerializedCCN;
|
||||
|
||||
if (serializedCCN.version !== 1) {
|
||||
throw new Error(
|
||||
`Unknown CCN serialization version: ${serializedCCN.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
this.ccns.set(
|
||||
serializedCCN.id,
|
||||
{
|
||||
id: serializedCCN.id,
|
||||
epoch: serializedCCN.epoch,
|
||||
members: new Map(serializedCCN.members),
|
||||
ccnKey: decodeBase64(serializedCCN.ccnKey),
|
||||
privateKey: serializedCCN.privateKey,
|
||||
ratchetTree: serializedCCN.ratchetTree,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
addMember(
|
||||
ccnId: CCNId,
|
||||
memberId: NPub,
|
||||
): Welcome | null {
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) return null;
|
||||
|
||||
CCN.epoch += 1;
|
||||
CCN.members.set(memberId, memberId);
|
||||
|
||||
const newNodeIndex = CCN.ratchetTree.length;
|
||||
CCN.ratchetTree.push({
|
||||
publicKey: memberId,
|
||||
path: [newNodeIndex],
|
||||
parent: null,
|
||||
children: [],
|
||||
});
|
||||
|
||||
const newSecret = randomBytes(32);
|
||||
CCN.ccnKey = deriveCCNKey(newSecret, ccnId, CCN.epoch);
|
||||
|
||||
const pathSecret = derivePathSecret(newSecret, [newNodeIndex]);
|
||||
const serializedState = JSON.stringify({
|
||||
id: CCN.id,
|
||||
epoch: CCN.epoch,
|
||||
members: Array.from(CCN.members.entries()),
|
||||
ratchetTree: CCN.ratchetTree,
|
||||
});
|
||||
const nonce = generateNonce();
|
||||
const sharedSecret = secp256k1.getSharedSecret(
|
||||
this.identity.privateKey,
|
||||
memberId,
|
||||
true,
|
||||
).slice(1); // Remove the prefix byte
|
||||
|
||||
const cipher = xchacha20poly1305(sharedSecret, nonce);
|
||||
const encryptedCCNState = cipher.encrypt(
|
||||
textEncoder.encode(serializedState),
|
||||
);
|
||||
|
||||
const welcome: Welcome = {
|
||||
ccnId,
|
||||
epoch: CCN.epoch,
|
||||
encryptedCCNState,
|
||||
pathSecret,
|
||||
inviterPublicKey: this.identity.publicKey,
|
||||
nonce,
|
||||
};
|
||||
|
||||
return welcome;
|
||||
}
|
||||
|
||||
joinCCN(welcome: Welcome): CCNId | null {
|
||||
try {
|
||||
const {
|
||||
ccnId,
|
||||
epoch,
|
||||
encryptedCCNState,
|
||||
pathSecret,
|
||||
inviterPublicKey,
|
||||
nonce,
|
||||
} = welcome;
|
||||
|
||||
const ccnKey = deriveCCNKey(pathSecret, ccnId, epoch);
|
||||
|
||||
const sharedSecret = secp256k1.getSharedSecret(
|
||||
this.identity.privateKey,
|
||||
inviterPublicKey,
|
||||
true,
|
||||
).slice(1); // Remove the prefix byte
|
||||
|
||||
const decipher = xchacha20poly1305(sharedSecret, nonce);
|
||||
const decryptedStateBytes = decipher.decrypt(encryptedCCNState);
|
||||
const decryptedState = JSON.parse(
|
||||
textDecoder.decode(decryptedStateBytes),
|
||||
);
|
||||
|
||||
const members = new Map<NPub, NPub>(decryptedState.members);
|
||||
const ratchetTree = decryptedState.ratchetTree;
|
||||
|
||||
const ccnState: CCNState = {
|
||||
id: ccnId,
|
||||
epoch,
|
||||
members,
|
||||
ccnKey,
|
||||
privateKey: this.identity.privateKey,
|
||||
ratchetTree,
|
||||
};
|
||||
|
||||
this.ccns.set(ccnId, ccnState);
|
||||
return ccnId;
|
||||
} catch (error) {
|
||||
console.error("Failed to join ccn:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
encryptMessage(ccnId: CCNId, plaintext: string): EncryptedMessage | null {
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) return null;
|
||||
|
||||
const nonce = generateNonce();
|
||||
const cipher = xchacha20poly1305(CCN.ccnKey, nonce);
|
||||
const ciphertext = cipher.encrypt(textEncoder.encode(plaintext));
|
||||
|
||||
const messageToSign = new Uint8Array([
|
||||
...textEncoder.encode(ccnId),
|
||||
...new Uint8Array(new BigUint64Array([BigInt(CCN.epoch)]).buffer),
|
||||
...ciphertext,
|
||||
...nonce,
|
||||
]);
|
||||
|
||||
const messageHash = sha256(messageToSign);
|
||||
const signature = secp256k1.sign(messageHash, this.identity.privateKey);
|
||||
|
||||
return {
|
||||
sender: this.memberId,
|
||||
epoch: CCN.epoch,
|
||||
ciphertext,
|
||||
nonce,
|
||||
signature: signature.toCompactRawBytes(),
|
||||
};
|
||||
}
|
||||
|
||||
decryptMessage(ccnId: CCNId, message: EncryptedMessage): string | null {
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) return null;
|
||||
|
||||
if (message.epoch !== CCN.epoch) {
|
||||
console.error("Message from wrong epoch");
|
||||
return null;
|
||||
}
|
||||
|
||||
const senderPublicKey = CCN.members.get(message.sender);
|
||||
if (!senderPublicKey) {
|
||||
console.error("Message from unknown sender");
|
||||
return null;
|
||||
}
|
||||
|
||||
// Verify signature
|
||||
const messageToVerify = new Uint8Array([
|
||||
...textEncoder.encode(ccnId),
|
||||
...new Uint8Array(new BigUint64Array([BigInt(message.epoch)]).buffer),
|
||||
...message.ciphertext,
|
||||
...message.nonce,
|
||||
]);
|
||||
|
||||
const messageHash = sha256(messageToVerify);
|
||||
const isValid = secp256k1.verify(
|
||||
message.signature,
|
||||
messageHash,
|
||||
senderPublicKey,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.error("Invalid message signature");
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const decipher = xchacha20poly1305(CCN.ccnKey, message.nonce);
|
||||
const plaintext = decipher.decrypt(message.ciphertext);
|
||||
return textDecoder.decode(plaintext);
|
||||
} catch (error) {
|
||||
console.error("Failed to decrypt message:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
updateCCNKey(ccnId: CCNId): UpdateMessage | null {
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) return null;
|
||||
|
||||
const newEpoch = CCN.epoch + 1;
|
||||
const newRootSecret = randomBytes(32);
|
||||
const updatedTree = [...CCN.ratchetTree];
|
||||
const myLeafIndex = updatedTree.findIndex((node) =>
|
||||
node.publicKey &&
|
||||
bytesEqual(node.publicKey, this.identity.publicKey)
|
||||
);
|
||||
|
||||
if (myLeafIndex === -1) {
|
||||
console.error("Cannot find self in ratchet tree");
|
||||
return null;
|
||||
}
|
||||
|
||||
const myPath: number[] = [myLeafIndex];
|
||||
let currentNode = myLeafIndex;
|
||||
|
||||
while (updatedTree[currentNode].parent !== null) {
|
||||
currentNode = updatedTree[currentNode].parent!;
|
||||
myPath.unshift(currentNode);
|
||||
}
|
||||
|
||||
const pathSecrets: Uint8Array[] = [newRootSecret];
|
||||
const updatedPublicKeys = new Map<number, NPub>();
|
||||
|
||||
for (let i = 0; i < myPath.length; i++) {
|
||||
const nodeIndex = myPath[i];
|
||||
|
||||
let newPublicKey: NPub;
|
||||
|
||||
if (i === 0) {
|
||||
newPublicKey = nip19.npubEncode(nostrTools.getPublicKey(newRootSecret));
|
||||
} else {
|
||||
const parentIndex = myPath[i - 1];
|
||||
const childSecret = derivePathSecret(pathSecrets[i - 1], [
|
||||
parentIndex,
|
||||
nodeIndex,
|
||||
]);
|
||||
pathSecrets.push(childSecret);
|
||||
|
||||
newPublicKey = nip19.npubEncode(nostrTools.getPublicKey(childSecret));
|
||||
}
|
||||
|
||||
updatedTree[nodeIndex].publicKey = newPublicKey;
|
||||
updatedPublicKeys.set(nodeIndex, newPublicKey);
|
||||
}
|
||||
|
||||
const encryptedPathSecrets = new Map<NPub, EncryptedPathSecret>();
|
||||
|
||||
for (const [memberId, memberPublicKey] of CCN.members.entries()) {
|
||||
if (memberId === this.memberId) continue;
|
||||
|
||||
const memberLeafIndex = updatedTree.findIndex((node) =>
|
||||
node.publicKey &&
|
||||
bytesEqual(node.publicKey, memberPublicKey)
|
||||
);
|
||||
|
||||
if (memberLeafIndex === -1) continue;
|
||||
|
||||
let memberNode = memberLeafIndex;
|
||||
const memberPath: number[] = [memberLeafIndex];
|
||||
|
||||
while (updatedTree[memberNode].parent !== null) {
|
||||
memberNode = updatedTree[memberNode].parent!;
|
||||
memberPath.unshift(memberNode);
|
||||
}
|
||||
|
||||
let commonAncestorIndex = 0;
|
||||
while (
|
||||
commonAncestorIndex < myPath.length &&
|
||||
commonAncestorIndex < memberPath.length &&
|
||||
myPath[commonAncestorIndex] === memberPath[commonAncestorIndex]
|
||||
) {
|
||||
commonAncestorIndex++;
|
||||
}
|
||||
|
||||
const secretToShare = pathSecrets[commonAncestorIndex - 1];
|
||||
|
||||
const nonce = generateNonce();
|
||||
const sharedSecret = secp256k1.getSharedSecret(
|
||||
this.identity.privateKey,
|
||||
memberPublicKey,
|
||||
true,
|
||||
).slice(1);
|
||||
|
||||
const cipher = xchacha20poly1305(sharedSecret, nonce);
|
||||
const ciphertext = cipher.encrypt(secretToShare);
|
||||
|
||||
encryptedPathSecrets.set(memberId, { ciphertext, nonce });
|
||||
}
|
||||
|
||||
const updateData = new Uint8Array([
|
||||
...textEncoder.encode(ccnId),
|
||||
...new Uint8Array(new BigUint64Array([BigInt(newEpoch)]).buffer),
|
||||
...textEncoder.encode(
|
||||
JSON.stringify(Array.from(encryptedPathSecrets.entries())),
|
||||
),
|
||||
...textEncoder.encode(
|
||||
JSON.stringify(Array.from(updatedPublicKeys.entries())),
|
||||
),
|
||||
]);
|
||||
|
||||
const updateHash = sha256(updateData);
|
||||
const signature = secp256k1.sign(updateHash, this.identity.privateKey);
|
||||
|
||||
CCN.epoch = newEpoch;
|
||||
CCN.ratchetTree = updatedTree;
|
||||
CCN.ccnKey = deriveCCNKey(newRootSecret, ccnId, newEpoch);
|
||||
|
||||
const updateMessage: UpdateMessage = {
|
||||
sender: this.memberId,
|
||||
ccnId,
|
||||
epoch: newEpoch,
|
||||
pathSecrets: encryptedPathSecrets,
|
||||
updatedPublicKeys,
|
||||
signature: signature.toCompactRawBytes(),
|
||||
};
|
||||
|
||||
return updateMessage;
|
||||
}
|
||||
|
||||
processUpdate(updateMessage: UpdateMessage): boolean {
|
||||
const {
|
||||
sender,
|
||||
ccnId,
|
||||
epoch,
|
||||
pathSecrets,
|
||||
updatedPublicKeys,
|
||||
signature,
|
||||
} = updateMessage;
|
||||
const CCN = this.ccns.get(ccnId);
|
||||
if (!CCN) {
|
||||
console.error("Unknown CCN");
|
||||
return false;
|
||||
}
|
||||
if (epoch !== CCN.epoch + 1) {
|
||||
console.error("Invalid epoch in update");
|
||||
return false;
|
||||
}
|
||||
const senderPublicKey = CCN.members.get(sender);
|
||||
if (!senderPublicKey) {
|
||||
console.error("Update from unknown sender");
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateData = new Uint8Array([
|
||||
...textEncoder.encode(ccnId),
|
||||
...new Uint8Array(new BigUint64Array([BigInt(epoch)]).buffer),
|
||||
...textEncoder.encode(JSON.stringify(Array.from(pathSecrets.entries()))),
|
||||
...textEncoder.encode(
|
||||
JSON.stringify(Array.from(updatedPublicKeys.entries())),
|
||||
),
|
||||
]);
|
||||
|
||||
const updateHash = sha256(updateData);
|
||||
const isValid = secp256k1.verify(
|
||||
signature,
|
||||
updateHash,
|
||||
senderPublicKey,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
console.error("Invalid update signature");
|
||||
return false;
|
||||
}
|
||||
|
||||
const myPathSecret = pathSecrets.get(this.memberId);
|
||||
if (!myPathSecret) {
|
||||
console.error("No path secret for me in update");
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const sharedSecret = secp256k1.getSharedSecret(
|
||||
this.identity.privateKey,
|
||||
senderPublicKey,
|
||||
true,
|
||||
).slice(1);
|
||||
|
||||
const decipher = xchacha20poly1305(sharedSecret, myPathSecret.nonce);
|
||||
const decryptedSecret = decipher.decrypt(myPathSecret.ciphertext);
|
||||
|
||||
const myLeafIndex = CCN.ratchetTree.findIndex((node) =>
|
||||
node.publicKey &&
|
||||
bytesEqual(node.publicKey, this.identity.publicKey)
|
||||
);
|
||||
|
||||
if (myLeafIndex === -1) {
|
||||
console.error("Cannot find self in ratchet tree");
|
||||
return false;
|
||||
}
|
||||
|
||||
const myPath: number[] = [myLeafIndex];
|
||||
let currentNode = myLeafIndex;
|
||||
|
||||
while (CCN.ratchetTree[currentNode].parent !== null) {
|
||||
currentNode = CCN.ratchetTree[currentNode].parent!;
|
||||
myPath.unshift(currentNode);
|
||||
}
|
||||
|
||||
const updatedTree = [...CCN.ratchetTree];
|
||||
for (const [nodeIndex, newPublicKey] of updatedPublicKeys.entries()) {
|
||||
const index = Number(nodeIndex);
|
||||
if (index >= 0 && index < updatedTree.length) {
|
||||
updatedTree[index].publicKey = newPublicKey;
|
||||
}
|
||||
}
|
||||
|
||||
CCN.epoch = epoch;
|
||||
CCN.ratchetTree = updatedTree;
|
||||
CCN.ccnKey = deriveCCNKey(decryptedSecret, ccnId, epoch);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error("Failed to process update:", error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
generateInvite(ccnId: CCNId, recipientPubkey: NPub): string {
|
||||
const welcome = this.addMember(
|
||||
ccnId,
|
||||
recipientPubkey,
|
||||
);
|
||||
|
||||
if (!welcome) {
|
||||
throw new Error("Failed to create welcome message");
|
||||
}
|
||||
|
||||
const ccn = this.ccns.get(ccnId);
|
||||
if (!ccn) {
|
||||
throw new Error("CCN not found");
|
||||
}
|
||||
|
||||
const ccnPrivateKey = ccn.ccnKey;
|
||||
const conversationKey = nostrTools.nip44.getConversationKey(
|
||||
ccnPrivateKey,
|
||||
recipientPubkey,
|
||||
);
|
||||
|
||||
const invite = {
|
||||
type: "ccn-invite",
|
||||
version: 1,
|
||||
ccnId,
|
||||
welcome,
|
||||
metadata: {
|
||||
createdAt: Math.floor(Date.now() / 1000),
|
||||
memberCount: ccn.members.size,
|
||||
},
|
||||
};
|
||||
|
||||
const encryptedInvite = nostrTools.nip44.encrypt(
|
||||
JSON.stringify(invite),
|
||||
conversationKey,
|
||||
);
|
||||
|
||||
return `ccn:${ccnId}:${btoa(encryptedInvite)}`;
|
||||
}
|
||||
|
||||
acceptInvite(inviteString: string): CCNId | null {
|
||||
try {
|
||||
// Parse the invite string format: ccn:ccnId:encryptedData
|
||||
const parts = inviteString.split(":");
|
||||
if (parts.length !== 3 || parts[0] !== "ccn") {
|
||||
throw new Error("Invalid invite format");
|
||||
}
|
||||
|
||||
const ccnId = parts[1];
|
||||
const encryptedInvite = atob(parts[2]);
|
||||
|
||||
const parsedData = JSON.parse(nostrTools.nip44.decrypt(
|
||||
encryptedInvite,
|
||||
nip19.decode<"nsec">(this.identity.privateKey).data,
|
||||
));
|
||||
|
||||
if (parsedData.type !== "ccn-invite" || parsedData.version !== 1) {
|
||||
throw new Error("Invalid invite type or version");
|
||||
}
|
||||
|
||||
const welcome = parsedData.welcome;
|
||||
|
||||
const joinResult = this.joinCCN(welcome);
|
||||
|
||||
if (!joinResult) {
|
||||
throw new Error("Failed to join CCN");
|
||||
}
|
||||
|
||||
if (!this.ccns.has(ccnId)) {
|
||||
throw new Error("CCN was not properly added to the ccns map");
|
||||
}
|
||||
|
||||
return ccnId;
|
||||
} catch (error) {
|
||||
console.error("Failed to accept invite:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
212
public/landing.html
Normal file
212
public/landing.html
Normal file
|
@ -0,0 +1,212 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Eve Relay - Secure Nostr Relay with CCN</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3498db;
|
||||
--primary-dark: #2980b9;
|
||||
--text: #2c3e50;
|
||||
--text-light: #7f8c8d;
|
||||
--background: #ffffff;
|
||||
--background-alt: #f8f9fa;
|
||||
--border: #e9ecef;
|
||||
--success: #2ecc71;
|
||||
--warning: #f1c40f;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
background: var(--background);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
header {
|
||||
text-align: center;
|
||||
padding: 4rem 0;
|
||||
background: var(--background-alt);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
margin-bottom: 1.5rem;
|
||||
background: var(--primary);
|
||||
border-radius: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto 1.5rem;
|
||||
}
|
||||
|
||||
.logo-text {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: white;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-light);
|
||||
max-width: 600px;
|
||||
margin: 0 auto 2rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 2.5rem 0 1rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem;
|
||||
}
|
||||
|
||||
code {
|
||||
background: var(--background-alt);
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--background-alt);
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.note {
|
||||
background: #fffde7;
|
||||
border-left: 4px solid var(--warning);
|
||||
padding: 1.25rem;
|
||||
margin: 1.5rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--background);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.command-list {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.command-list li {
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--background-alt);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.command-list code {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.cta-button {
|
||||
display: inline-block;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="logo">
|
||||
<span class="logo-text">E</span>
|
||||
</div>
|
||||
<h1>Eve Relay</h1>
|
||||
<p class="subtitle">A secure and efficient Nostr relay with Closed Community Network (CCN) functionality</p>
|
||||
</header>
|
||||
|
||||
<div class="container">
|
||||
<div class="note">
|
||||
<strong>Important:</strong> This relay is designed for WebSocket connections only. HTTP requests are not supported for data operations.
|
||||
</div>
|
||||
|
||||
<h2>Connection Details</h2>
|
||||
<p>Connect to the relay using WebSocket:</p>
|
||||
<pre>ws://localhost:6942</pre>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h3>Nostr Commands</h3>
|
||||
<ul class="command-list">
|
||||
<li><code>REQ</code> - Subscribe to events</li>
|
||||
<li><code>EVENT</code> - Publish an event</li>
|
||||
<li><code>CLOSE</code> - Close a subscription</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h3>CCN Commands</h3>
|
||||
<ul class="command-list">
|
||||
<li><code>CCN CREATE</code> - Create a new CCN</li>
|
||||
<li><code>CCN LIST</code> - List all active CCNs</li>
|
||||
<li><code>CCN ACTIVATE</code> - Activate a specific CCN</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h2>Documentation</h2>
|
||||
<p>For detailed information about Arx-CCN functionality and best practices, please refer to the official documentation.</p>
|
||||
<a href="#" class="cta-button">View Documentation</a>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
199
utils.ts
199
utils.ts
|
@ -1,20 +1,21 @@
|
|||
import { exists } from "jsr:@std/fs";
|
||||
import * as nostrTools from "@nostr/tools";
|
||||
import * as nip06 from "@nostr/tools/nip06";
|
||||
import { decodeBase64, encodeBase64 } from "jsr:@std/encoding@0.224/base64";
|
||||
import { getEveFilePath } from "./utils/files.ts";
|
||||
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,
|
||||
encryptionKey,
|
||||
encryptUint8Array,
|
||||
} from "./utils/encryption.ts";
|
||||
import { NSec } from "@nostr/tools/nip19";
|
||||
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);
|
||||
const hostname = url.hostname;
|
||||
return (
|
||||
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost"
|
||||
hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost'
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -39,75 +40,123 @@ export function randomTimeUpTo2DaysInThePast() {
|
|||
);
|
||||
}
|
||||
|
||||
export async function getCCNPubkey(): Promise<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
export async function getMLSPrivateKey(): Promise<NSec> {
|
||||
const mlsPrivPath = await getEveFilePath("mls.priv");
|
||||
const doWeHaveKey = await exists(mlsPrivPath);
|
||||
if (doWeHaveKey) {
|
||||
const encryptedPrivateKey = Deno.readTextFileSync(mlsPrivPath);
|
||||
const decryptedPrivateKey = decryptUint8Array(
|
||||
decodeBase64(encryptedPrivateKey),
|
||||
encryptionKey,
|
||||
);
|
||||
return nostrTools.nip19.nsecEncode(decryptedPrivateKey);
|
||||
}
|
||||
const mlsPrivateKey = nostrTools.generateSecretKey();
|
||||
const encryptedPrivateKey = encryptUint8Array(mlsPrivateKey, encryptionKey);
|
||||
Deno.writeTextFileSync(mlsPrivPath, encodeBase64(encryptedPrivateKey));
|
||||
return nostrTools.nip19.nsecEncode(mlsPrivateKey);
|
||||
}
|
||||
|
||||
export async function getCCNPrivateKey(): Promise<Uint8Array> {
|
||||
const encryptedPrivateKey = Deno.readTextFileSync(
|
||||
await getEveFilePath("ccn.priv"),
|
||||
);
|
||||
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey);
|
||||
/**
|
||||
* 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;
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two byte-like objects in a constant-time manner to prevent timing attacks.
|
||||
* Create a new CCN and store it in the database
|
||||
*
|
||||
* @param a - First byte-like object to compare
|
||||
* @param b - Second byte-like object to compare
|
||||
* @returns boolean indicating whether the inputs contain identical bytes
|
||||
* @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 function bytesEqual<
|
||||
T extends Uint8Array | number[] | string,
|
||||
>(a: T, b: T): boolean {
|
||||
const aLength = a.length;
|
||||
const bLength = b.length;
|
||||
let result = aLength !== bLength ? 1 : 0;
|
||||
const maxLength = Math.max(aLength, bLength);
|
||||
for (let i = 0; i < maxLength; i++) {
|
||||
const aVal = i < aLength
|
||||
? (typeof a === "string" ? a.charCodeAt(i) : a[i])
|
||||
: 0;
|
||||
const bVal = i < bLength
|
||||
? (typeof b === "string" ? b.charCodeAt(i) : b[i])
|
||||
: 0;
|
||||
result |= aVal ^ bVal;
|
||||
}
|
||||
return result === 0;
|
||||
export async function createNewCCN(
|
||||
db: Database,
|
||||
name: string,
|
||||
creator: 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));
|
||||
|
||||
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,
|
||||
privkey: ccnPrivateKey,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the private key for a specific CCN
|
||||
*/
|
||||
export async function getCCNPrivateKeyByPubkey(
|
||||
pubkey: string,
|
||||
): Promise<Uint8Array> {
|
||||
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 {
|
||||
return (kind >= 10000 && kind < 20000) || kind === 0 || kind === 3;
|
||||
}
|
||||
|
||||
export function isAddressableEvent(kind: number): boolean {
|
||||
return kind >= 30000 && kind < 40000;
|
||||
}
|
||||
|
||||
export function isRegularEvent(kind: number): boolean {
|
||||
return (
|
||||
(kind >= 1000 && kind < 10000) ||
|
||||
(kind >= 4 && kind < 45) ||
|
||||
kind === 1 ||
|
||||
kind === 2
|
||||
);
|
||||
}
|
||||
|
||||
export function isCCNReplaceableEvent(kind: number): boolean {
|
||||
return kind >= 60000 && kind < 65536;
|
||||
}
|
||||
|
||||
export function parseATagQuery(aTagValue: string): {
|
||||
kind: number;
|
||||
pubkey: string;
|
||||
dTag?: string;
|
||||
} {
|
||||
const parts = aTagValue.split(':');
|
||||
if (parts.length < 2) return { kind: 0, pubkey: '' };
|
||||
|
||||
return {
|
||||
kind: Number.parseInt(parts[0], 10),
|
||||
pubkey: parts[1],
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { xchacha20poly1305 } from "@noble/ciphers/chacha";
|
||||
import { managedNonce } from "@noble/ciphers/webcrypto";
|
||||
import { decodeBase64 } from "jsr:@std/encoding/base64";
|
||||
export const encryptionKey = decodeBase64(Deno.env.get("ENCRYPTION_KEY") || "");
|
||||
import { decodeBase64 } from 'jsr:@std/encoding/base64';
|
||||
import { xchacha20poly1305 } from '@noble/ciphers/chacha';
|
||||
import { managedNonce } from '@noble/ciphers/webcrypto';
|
||||
export const encryptionKey = decodeBase64(Deno.env.get('ENCRYPTION_KEY') || '');
|
||||
|
||||
/**
|
||||
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { exists } from "jsr:@std/fs";
|
||||
import { exists } from 'jsr:@std/fs';
|
||||
|
||||
/**
|
||||
* Return the path to Eve's configuration directory and ensures its existence.
|
||||
|
@ -14,13 +14,11 @@ import { exists } from "jsr:@std/fs";
|
|||
|
||||
export async function getEveConfigHome(): Promise<string> {
|
||||
let storagePath: string;
|
||||
if (Deno.build.os === "darwin") {
|
||||
storagePath = `${
|
||||
Deno.env.get("HOME")
|
||||
}/Library/Application Support/eve/arx/Eve`;
|
||||
if (Deno.build.os === 'darwin') {
|
||||
storagePath = `${Deno.env.get('HOME')}/Library/Application Support/eve/arx/Eve`;
|
||||
} else {
|
||||
const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME") ??
|
||||
`${Deno.env.get("HOME")}/.config`;
|
||||
const xdgConfigHome =
|
||||
Deno.env.get('XDG_CONFIG_HOME') ?? `${Deno.env.get('HOME')}/.config`;
|
||||
storagePath = `${xdgConfigHome}/arx/Eve`;
|
||||
}
|
||||
if (!(await exists(storagePath))) {
|
||||
|
|
12
utils/invites.ts
Normal file
12
utils/invites.ts
Normal 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 };
|
||||
}
|
|
@ -1,59 +1,59 @@
|
|||
import * as colors from "jsr:@std/fmt@^1.0.4/colors";
|
||||
import * as log from "jsr:@std/log";
|
||||
import { getEveFilePath } from "./files.ts";
|
||||
export * as log from "jsr:@std/log";
|
||||
import * as colors from 'jsr:@std/fmt@^1.0.4/colors';
|
||||
import * as log from 'jsr:@std/log';
|
||||
import { getEveFilePath } from './files.ts';
|
||||
export * as log from 'jsr:@std/log';
|
||||
|
||||
export async function setupLogger() {
|
||||
const formatLevel = (level: number): string => {
|
||||
return (
|
||||
{
|
||||
10: colors.gray("[DEBUG]"),
|
||||
20: colors.green("[INFO] "),
|
||||
30: colors.yellow("[WARN] "),
|
||||
40: colors.red("[ERROR]"),
|
||||
50: colors.bgRed("[FATAL]"),
|
||||
10: colors.gray('[DEBUG]'),
|
||||
20: colors.green('[INFO] '),
|
||||
30: colors.yellow('[WARN] '),
|
||||
40: colors.red('[ERROR]'),
|
||||
50: colors.bgRed('[FATAL]'),
|
||||
}[level] || `[LVL${level}]`
|
||||
);
|
||||
};
|
||||
|
||||
const levelName = (level: number): string => {
|
||||
return {
|
||||
10: "DEBUG",
|
||||
20: "INFO",
|
||||
30: "WARN",
|
||||
40: "ERROR",
|
||||
50: "FATAL",
|
||||
}[level] || `LVL${level}`;
|
||||
return (
|
||||
{
|
||||
10: 'DEBUG',
|
||||
20: 'INFO',
|
||||
30: 'WARN',
|
||||
40: 'ERROR',
|
||||
50: 'FATAL',
|
||||
}[level] || `LVL${level}`
|
||||
);
|
||||
};
|
||||
|
||||
const formatArg = (arg: unknown): string => {
|
||||
if (typeof arg === "object") return JSON.stringify(arg);
|
||||
if (typeof arg === 'object') return JSON.stringify(arg);
|
||||
return String(arg);
|
||||
};
|
||||
|
||||
await log.setup({
|
||||
handlers: {
|
||||
console: new log.ConsoleHandler("DEBUG", {
|
||||
console: new log.ConsoleHandler('DEBUG', {
|
||||
useColors: true,
|
||||
formatter: (record) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
let msg = `${colors.dim(`[${timestamp}]`)} ${
|
||||
formatLevel(record.level)
|
||||
} ${record.msg}`;
|
||||
let msg = `${colors.dim(`[${timestamp}]`)} ${formatLevel(record.level)} ${record.msg}`;
|
||||
|
||||
if (record.args.length > 0) {
|
||||
const args = record.args
|
||||
.map((arg, i) => `${colors.dim(`arg${i}:`)} ${formatArg(arg)}`)
|
||||
.join(" ");
|
||||
msg += ` ${colors.dim("|")} ${args}`;
|
||||
.join(' ');
|
||||
msg += ` ${colors.dim('|')} ${args}`;
|
||||
}
|
||||
|
||||
return msg;
|
||||
},
|
||||
}),
|
||||
file: new log.FileHandler("DEBUG", {
|
||||
filename: Deno.env.get("LOG_FILE") ||
|
||||
await getEveFilePath("eve-logs.jsonl"),
|
||||
file: new log.FileHandler('DEBUG', {
|
||||
filename:
|
||||
Deno.env.get('LOG_FILE') || (await getEveFilePath('eve-logs.jsonl')),
|
||||
formatter: (record) => {
|
||||
const timestamp = new Date().toISOString();
|
||||
return JSON.stringify({
|
||||
|
@ -67,8 +67,8 @@ export async function setupLogger() {
|
|||
},
|
||||
loggers: {
|
||||
default: {
|
||||
level: "DEBUG",
|
||||
handlers: ["console", "file"],
|
||||
level: 'DEBUG',
|
||||
handlers: ['console', 'file'],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
40
utils/option.ts
Normal file
40
utils/option.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
export type Option<T> =
|
||||
| {
|
||||
value: T;
|
||||
isSome: true;
|
||||
}
|
||||
| {
|
||||
value: undefined;
|
||||
isSome: false;
|
||||
};
|
||||
|
||||
export function Some<T>(value: T): Option<T> {
|
||||
return { value, isSome: true };
|
||||
}
|
||||
|
||||
export function None<T>(): Option<T> {
|
||||
return { value: undefined, isSome: false };
|
||||
}
|
||||
|
||||
export function map<T, U>(option: Option<T>, fn: (value: T) => U): Option<U> {
|
||||
return option.isSome ? Some(fn(option.value)) : None();
|
||||
}
|
||||
|
||||
export function flatMap<T, U>(
|
||||
option: Option<T>,
|
||||
fn: (value: T) => Option<U>,
|
||||
): Option<U> {
|
||||
return option.isSome ? fn(option.value) : None();
|
||||
}
|
||||
|
||||
export function getOrElse<T>(option: Option<T>, defaultValue: T): T {
|
||||
return option.isSome ? option.value : defaultValue;
|
||||
}
|
||||
|
||||
export function fold<T, U>(
|
||||
option: Option<T>,
|
||||
onNone: () => U,
|
||||
onSome: (value: T) => U,
|
||||
): U {
|
||||
return option.isSome ? onSome(option.value) : onNone();
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import type { BindValue, Database } from "@db/sqlite";
|
||||
import type { BindValue, Database } from '@db/sqlite';
|
||||
|
||||
/**
|
||||
* Construct a SQL query with placeholders for values.
|
||||
|
@ -23,8 +23,8 @@ export function sqlPartial(
|
|||
) {
|
||||
return {
|
||||
query: segments.reduce(
|
||||
(acc, str, i) => acc + str + (i < values.length ? "?" : ""),
|
||||
"",
|
||||
(acc, str, i) => acc + str + (i < values.length ? '?' : ''),
|
||||
'',
|
||||
),
|
||||
values: values,
|
||||
};
|
||||
|
@ -72,7 +72,7 @@ export function mixQuery(...queries: { query: string; values: BindValue[] }[]) {
|
|||
query: `${acc.query} ${query}`,
|
||||
values: [...acc.values, ...values],
|
||||
}),
|
||||
{ query: "", values: [] },
|
||||
{ query: '', values: [] },
|
||||
);
|
||||
return { query, values };
|
||||
}
|
||||
|
|
Loading…
Add table
Reference in a new issue