Compare commits

...
Sign in to create a new pull request.

10 commits

Author SHA1 Message Date
190e38dfc1 Merge pull request 'bug: adding ccn instead of creating doesn't encrypt key' (#4) from bux-fix/adding-ccn-does-not-encrypt-key into master
Reviewed-on: #4
2025-04-24 17:40:09 +00:00
107504050e bug: adding ccn instead of creating doesn't encrypt key 2025-04-24 19:37:50 +02:00
36c7401fa8
CCN invitation and write permissions to CCN 2025-04-23 18:13:29 +02:00
a8ffce918e
Feat: Implement support for multiple CCNs 2025-04-11 22:12:37 +02:00
097f02938d Merge pull request 'chunkable-events' (#3) from chunkable-events into master
Reviewed-on: #3
2025-04-08 13:48:58 +00:00
8906d8f7f7
make sure migrations always run from 0 to n 2025-03-26 19:27:33 +01:00
89d9dc3cbe
✂️ Implement message chunking mechanism for NIP-44 size limit compliance
This ensures all messages can be properly encrypted and transmitted regardless of size.

Fixes issue #2
2025-03-24 20:14:52 +01:00
a4134fa416
🔄 Synchronize Biome linting rules between relay and frontend
🛠️ Apply identical Biome configuration from frontend to relay service
🧹 Ensure consistent code formatting and quality standards across components
📝 Maintain unified development experience throughout the project
2025-03-24 19:23:22 +01:00
4bd0839669
fix bug where profile can be replaced by anyone in the CCN 2025-03-19 19:09:48 +01:00
7dbb4a522f replaceable-events (#1) 2025-03-18 15:06:13 +00:00
19 changed files with 1650 additions and 319 deletions

14
Makefile Normal file
View 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
View 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"
}
}
}

View file

@ -18,3 +18,27 @@ export const MIN_POW = 8;
* - Difficulty 21: ~5-6 seconds * - Difficulty 21: ~5-6 seconds
*/ */
export const POW_TO_MINE = 10; 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;

View file

@ -1,8 +1,11 @@
{ {
"tasks": { "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": { "imports": {
"@biomejs/biome": "npm:@biomejs/biome@^1.9.4",
"@db/sqlite": "jsr:@db/sqlite@^0.12.0", "@db/sqlite": "jsr:@db/sqlite@^0.12.0",
"@noble/ciphers": "jsr:@noble/ciphers@^1.2.1", "@noble/ciphers": "jsr:@noble/ciphers@^1.2.1",
"@nostr/tools": "jsr:@nostr/tools@^2.10.4", "@nostr/tools": "jsr:@nostr/tools@^2.10.4",
@ -12,13 +15,5 @@
"@std/fmt": "jsr:@std/fmt@^1.0.4", "@std/fmt": "jsr:@std/fmt@^1.0.4",
"@std/log": "jsr:@std/log@^0.224.13", "@std/log": "jsr:@std/log@^0.224.13",
"@types/deno": "npm:@types/deno@^2.0.0" "@types/deno": "npm:@types/deno@^2.0.0"
},
"fmt": {
"indentWidth": 2,
"useTabs": false,
"lineWidth": 80,
"proseWrap": "always",
"semiColons": true,
"singleQuote": false
} }
} }

40
deno.lock generated
View file

@ -30,6 +30,8 @@
"jsr:@std/path@0.217": "0.217.0", "jsr:@std/path@0.217": "0.217.0",
"jsr:@std/path@0.221": "0.221.0", "jsr:@std/path@0.221": "0.221.0",
"jsr:@std/path@^1.0.8": "1.0.8", "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/ciphers@~0.5.1": "0.5.3",
"npm:@noble/curves@1.2.0": "1.2.0", "npm:@noble/curves@1.2.0": "1.2.0",
"npm:@noble/hashes@1.3.1": "1.3.1", "npm:@noble/hashes@1.3.1": "1.3.1",
@ -168,6 +170,43 @@
} }
}, },
"npm": { "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": { "@noble/ciphers@0.5.3": {
"integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w==" "integrity": "sha512-B0+6IIHiqEs3BPMT0hcRmHvEj2QHOLu+uwt+tqDDeVd0oyVzh7BPrDcPjRnV1PV/5LaknXJJQvOuRGR0zQJz+w=="
}, },
@ -272,6 +311,7 @@
"jsr:@std/encoding@^1.0.6", "jsr:@std/encoding@^1.0.6",
"jsr:@std/fmt@^1.0.4", "jsr:@std/fmt@^1.0.4",
"jsr:@std/log@~0.224.13", "jsr:@std/log@~0.224.13",
"npm:@biomejs/biome@^1.9.4",
"npm:@types/deno@2" "npm:@types/deno@2"
] ]
} }

View 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');
}

983
index.ts

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,2 @@
ALTER TABLE events
ADD COLUMN replaced INTEGER NOT NULL DEFAULT 0;

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

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

212
public/landing.html Normal file
View 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>

151
utils.ts
View file

@ -1,19 +1,21 @@
import { exists } from "jsr:@std/fs"; import * as nostrTools from '@nostr/tools';
import * as nostrTools from "@nostr/tools"; import * as nip06 from '@nostr/tools/nip06';
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 { decodeBase64, encodeBase64 } from 'jsr:@std/encoding@0.224/base64';
import { getEveFilePath } from "./utils/files.ts"; import { exists } from 'jsr:@std/fs';
import { import {
decryptUint8Array, decryptUint8Array,
encryptionKey,
encryptUint8Array, encryptUint8Array,
} from "./utils/encryption.ts"; encryptionKey,
} from './utils/encryption.ts';
import { getEveFilePath } from './utils/files.ts';
import { sql } from './utils/queries.ts';
export function isLocalhost(req: Request): boolean { export function isLocalhost(req: Request): boolean {
const url = new URL(req.url); const url = new URL(req.url);
const hostname = url.hostname; const hostname = url.hostname;
return ( return (
hostname === "127.0.0.1" || hostname === "::1" || hostname === "localhost" hostname === '127.0.0.1' || hostname === '::1' || hostname === 'localhost'
); );
} }
@ -38,32 +40,123 @@ export function randomTimeUpTo2DaysInThePast() {
); );
} }
export async function getCCNPubkey(): Promise<string> { /**
const ccnPubPath = await getEveFilePath("ccn.pub"); * Get all CCNs from the database
const seedPath = await getEveFilePath("ccn.seed"); */
const doWeHaveKey = await exists(ccnPubPath); export function getAllCCNs(db: Database): { pubkey: string; name: string }[] {
if (doWeHaveKey) return Deno.readTextFileSync(ccnPubPath); return sql`SELECT pubkey, name FROM ccns`(db) as {
const ccnSeed = Deno.env.get("CCN_SEED") || pubkey: string;
((await exists(seedPath)) name: string;
? Deno.readTextFileSync(seedPath) }[];
: nip06.generateSeedWords()); }
/**
* Create a new CCN and store it in the database
*
* @param db - The database instance
* @param name - The name of the CCN
* @param seed - The seed words for the CCN
* @returns The public key and private key of the CCN
*/
export async function createNewCCN(
db: Database,
name: string,
creator: string,
seed?: string,
): Promise<{ pubkey: string; privkey: Uint8Array }> {
const ccnSeed = seed || nip06.generateSeedWords();
const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed); const ccnPrivateKey = nip06.privateKeyFromSeedWords(ccnSeed);
const ccnPublicKey = nostrTools.getPublicKey(ccnPrivateKey); 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); const encryptedPrivateKey = encryptUint8Array(ccnPrivateKey, encryptionKey);
Deno.writeTextFileSync(ccnPubPath, ccnPublicKey); Deno.writeTextFileSync(ccnSeedPath, ccnSeed);
Deno.writeTextFileSync( Deno.writeTextFileSync(ccnPrivPath, encodeBase64(encryptedPrivateKey));
await getEveFilePath("ccn.priv"),
encodeBase64(encryptedPrivateKey),
);
Deno.writeTextFileSync(seedPath, ccnSeed);
return ccnPublicKey; 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,
};
} }
export async function getCCNPrivateKey(): Promise<Uint8Array> { /**
const encryptedPrivateKey = Deno.readTextFileSync( * Get the private key for a specific CCN
await getEveFilePath("ccn.priv"), */
); export async function getCCNPrivateKeyByPubkey(
return decryptUint8Array(decodeBase64(encryptedPrivateKey), encryptionKey); 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;
} }

View file

@ -1,7 +1,7 @@
import { xchacha20poly1305 } from "@noble/ciphers/chacha"; import { decodeBase64 } from 'jsr:@std/encoding/base64';
import { managedNonce } from "@noble/ciphers/webcrypto"; import { xchacha20poly1305 } from '@noble/ciphers/chacha';
import { decodeBase64 } from "jsr:@std/encoding/base64"; import { managedNonce } from '@noble/ciphers/webcrypto';
export const encryptionKey = decodeBase64(Deno.env.get("ENCRYPTION_KEY") || ""); export const encryptionKey = decodeBase64(Deno.env.get('ENCRYPTION_KEY') || '');
/** /**
* Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm. * Encrypts a given Uint8Array using the XChaCha20-Poly1305 algorithm.

View file

@ -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. * 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> { export async function getEveConfigHome(): Promise<string> {
let storagePath: string; let storagePath: string;
if (Deno.build.os === "darwin") { if (Deno.build.os === 'darwin') {
storagePath = `${ storagePath = `${Deno.env.get('HOME')}/Library/Application Support/eve/arx/Eve`;
Deno.env.get("HOME")
}/Library/Application Support/eve/arx/Eve`;
} else { } else {
const xdgConfigHome = Deno.env.get("XDG_CONFIG_HOME") ?? const xdgConfigHome =
`${Deno.env.get("HOME")}/.config`; Deno.env.get('XDG_CONFIG_HOME') ?? `${Deno.env.get('HOME')}/.config`;
storagePath = `${xdgConfigHome}/arx/Eve`; storagePath = `${xdgConfigHome}/arx/Eve`;
} }
if (!(await exists(storagePath))) { if (!(await exists(storagePath))) {

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

View file

@ -1,59 +1,59 @@
import * as colors from "jsr:@std/fmt@^1.0.4/colors"; import * as colors from 'jsr:@std/fmt@^1.0.4/colors';
import * as log from "jsr:@std/log"; import * as log from 'jsr:@std/log';
import { getEveFilePath } from "./files.ts"; import { getEveFilePath } from './files.ts';
export * as log from "jsr:@std/log"; export * as log from 'jsr:@std/log';
export async function setupLogger() { export async function setupLogger() {
const formatLevel = (level: number): string => { const formatLevel = (level: number): string => {
return ( return (
{ {
10: colors.gray("[DEBUG]"), 10: colors.gray('[DEBUG]'),
20: colors.green("[INFO] "), 20: colors.green('[INFO] '),
30: colors.yellow("[WARN] "), 30: colors.yellow('[WARN] '),
40: colors.red("[ERROR]"), 40: colors.red('[ERROR]'),
50: colors.bgRed("[FATAL]"), 50: colors.bgRed('[FATAL]'),
}[level] || `[LVL${level}]` }[level] || `[LVL${level}]`
); );
}; };
const levelName = (level: number): string => { const levelName = (level: number): string => {
return { return (
10: "DEBUG", {
20: "INFO", 10: 'DEBUG',
30: "WARN", 20: 'INFO',
40: "ERROR", 30: 'WARN',
50: "FATAL", 40: 'ERROR',
}[level] || `LVL${level}`; 50: 'FATAL',
}[level] || `LVL${level}`
);
}; };
const formatArg = (arg: unknown): string => { const formatArg = (arg: unknown): string => {
if (typeof arg === "object") return JSON.stringify(arg); if (typeof arg === 'object') return JSON.stringify(arg);
return String(arg); return String(arg);
}; };
await log.setup({ await log.setup({
handlers: { handlers: {
console: new log.ConsoleHandler("DEBUG", { console: new log.ConsoleHandler('DEBUG', {
useColors: true, useColors: true,
formatter: (record) => { formatter: (record) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
let msg = `${colors.dim(`[${timestamp}]`)} ${ let msg = `${colors.dim(`[${timestamp}]`)} ${formatLevel(record.level)} ${record.msg}`;
formatLevel(record.level)
} ${record.msg}`;
if (record.args.length > 0) { if (record.args.length > 0) {
const args = record.args const args = record.args
.map((arg, i) => `${colors.dim(`arg${i}:`)} ${formatArg(arg)}`) .map((arg, i) => `${colors.dim(`arg${i}:`)} ${formatArg(arg)}`)
.join(" "); .join(' ');
msg += ` ${colors.dim("|")} ${args}`; msg += ` ${colors.dim('|')} ${args}`;
} }
return msg; return msg;
}, },
}), }),
file: new log.FileHandler("DEBUG", { file: new log.FileHandler('DEBUG', {
filename: Deno.env.get("LOG_FILE") || filename:
await getEveFilePath("eve-logs.jsonl"), Deno.env.get('LOG_FILE') || (await getEveFilePath('eve-logs.jsonl')),
formatter: (record) => { formatter: (record) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
return JSON.stringify({ return JSON.stringify({
@ -67,8 +67,8 @@ export async function setupLogger() {
}, },
loggers: { loggers: {
default: { default: {
level: "DEBUG", level: 'DEBUG',
handlers: ["console", "file"], handlers: ['console', 'file'],
}, },
}, },
}); });

40
utils/option.ts Normal file
View 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();
}

View file

@ -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. * Construct a SQL query with placeholders for values.
@ -23,8 +23,8 @@ export function sqlPartial(
) { ) {
return { return {
query: segments.reduce( query: segments.reduce(
(acc, str, i) => acc + str + (i < values.length ? "?" : ""), (acc, str, i) => acc + str + (i < values.length ? '?' : ''),
"", '',
), ),
values: values, values: values,
}; };
@ -72,7 +72,7 @@ export function mixQuery(...queries: { query: string; values: BindValue[] }[]) {
query: `${acc.query} ${query}`, query: `${acc.query} ${query}`,
values: [...acc.values, ...values], values: [...acc.values, ...values],
}), }),
{ query: "", values: [] }, { query: '', values: [] },
); );
return { query, values }; return { query, values };
} }