From 65d9eca142c95a69375436f206bf6fa8cc82770f Mon Sep 17 00:00:00 2001 From: Danny Morabito Date: Tue, 5 Aug 2025 23:47:38 +0200 Subject: [PATCH] initial commit --- .gitignore | 36 +++++++++++++++++++++++ Dockerfile | 20 +++++++++++++ README.md | 78 +++++++++++++++++++++++++++++++++++++++++++++++++ bun.lock | 29 ++++++++++++++++++ index.ts | 7 +++++ package.json | 12 ++++++++ src/main.ts | 81 +++++++++++++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 29 ++++++++++++++++++ 8 files changed, 292 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 bun.lock create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/main.ts create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53ffd00 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# dependencies (bun install) +node_modules + +# output +out +dist +*.tgz + +# code coverage +coverage +*.lcov + +# logs +logs +_.log +report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# caches +.eslintcache +.cache +*.tsbuildinfo + +# IntelliJ based IDEs +.idea + +# Finder (MacOS) folder config +.DS_Store + +allowed-pubkeys.json diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..894158f --- /dev/null +++ b/Dockerfile @@ -0,0 +1,20 @@ +FROM oven/bun:canary-debian AS builder + +WORKDIR /app + +ADD package.json . +RUN bun install + +ADD . . + +RUN bun build index.ts --compile + +FROM debian:bookworm-slim + +WORKDIR /app + +COPY --from=builder /app/index /app/index + +EXPOSE 3000 + +CMD ["/app/index"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..c097546 --- /dev/null +++ b/README.md @@ -0,0 +1,78 @@ +# NIP-42 Proxy + +This project is a NIP-42 proxy for Nostr relays. It provides an authentication layer in front of a public relay, allowing only authenticated users to connect and interact with it. + +## Features + +- **NIP-42 Authentication**: Enforces NIP-42 authentication, ensuring that only authorized users can access the relay. +- **Proxy Layer**: Acts as a proxy, forwarding messages between authenticated clients and the main relay. +- **Whitelist**: Filters access based on a whitelist of public keys defined in `allowed-pubkeys.json`. + +## Prerequisites + +- [Docker](https://www.docker.com/) installed on your system. + +## Installation + +1. **Clone the repository**: + ```bash + git clone https://github.com/your-username/nip42-proxy.git + cd nip42-proxy + ``` + +## Configuration + +1. **Whitelist (Optional)**: + - Create a file named `allowed-pubkeys.json` in the root directory. + - Add an array of whitelisted public keys in the following format: + ```json + [ + "pubkey1", + "pubkey2" + ] + ``` + - If this file does not exist, the proxy will allow any user to authenticate. + +2. **Relay URL**: + - The proxy can be configured to connect to a specific relay using one of the following methods (in order of priority): + 1. **Environment Variable**: Set the `RELAY_URL` environment variable when running the Docker container: + ```bash + docker run -e RELAY_URL="wss://your-relay-url.com" ... + ``` + 2. **Default**: If no URL is provided, the proxy will connect to the default relay: `wss://relay.arx-ccn.com`. + +## Usage + +To run the proxy using Docker, follow these steps: + +1. **Build the Docker image**: + ```bash + docker build -t nip42-proxy . + ``` + +2. **Run the Docker container**: + ```bash + docker run -p 3000:3000 -v $(pwd)/allowed-pubkeys.json:/app/allowed-pubkeys.json --name nip42-proxy nip42-proxy + ``` + + - This command maps port `3000` on your local machine to port `3000` in the container. + - It also mounts the `allowed-pubkeys.json` file from your local directory into the container. + + To run with a custom relay URL, use the `-e` flag: + ```bash + docker run -p 3000:3000 -e RELAY_URL="wss://your-relay-url.com" -v $(pwd)/allowed-pubkeys.json:/app/allowed-pubkeys.json --name nip42-proxy nip42-proxy + ``` + +The server will start, and you can connect to it using a Nostr client that supports NIP-42 authentication. + +## How It Works + +1. **Client Connection**: When a client connects to the proxy, it is initially in an unauthenticated state. +2. **Authentication Request**: The proxy sends an `AUTH` challenge to the client. +3. **Client Authentication**: The client must respond with a valid `AUTH` event, signed with a whitelisted public key. +4. **Authenticated State**: Once authenticated, the client can send and receive messages from the main relay through the proxy. +5. **Message Forwarding**: All messages from the authenticated client are forwarded to the main relay, and all messages from the main relay are forwarded to the client. + +## Contributing + +Contributions are welcome! Please open an issue or submit a pull request with your improvements. \ No newline at end of file diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..c6f8faf --- /dev/null +++ b/bun.lock @@ -0,0 +1,29 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "nip42-proxy", + "devDependencies": { + "@types/bun": "latest", + }, + "peerDependencies": { + "typescript": "^5", + }, + }, + }, + "packages": { + "@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="], + + "@types/node": ["@types/node@24.2.0", "", { "dependencies": { "undici-types": "~7.10.0" } }, "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw=="], + + "@types/react": ["@types/react@19.1.9", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-WmdoynAX8Stew/36uTSVMcLJJ1KRh6L3IZRx1PZ7qJtBqT3dYTgyDTx8H1qoRghErydW7xw9mSJ3wS//tCRpFA=="], + + "bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "typescript": ["typescript@5.9.2", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A=="], + + "undici-types": ["undici-types@7.10.0", "", {}, "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="], + } +} diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..c00edb7 --- /dev/null +++ b/index.ts @@ -0,0 +1,7 @@ +import { main } from "./src/main.ts"; + +let relay = process.env.RELAY_URL ?? Bun.argv[Bun.argv.length - 1]; +if (!relay?.startsWith("wss://")) + relay = "wss://relay.arx-ccn.com"; + +main(relay) diff --git a/package.json b/package.json new file mode 100644 index 0000000..90b4c81 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "nip42-proxy", + "module": "index.ts", + "type": "module", + "private": true, + "devDependencies": { + "@types/bun": "latest" + }, + "peerDependencies": { + "typescript": "^5" + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..017cd92 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,81 @@ +import type { ServerWebSocket } from "bun"; + +interface Event { + kind: number; + tags: string[][]; + content: string; + created_at: number; + pubkey: string; + id: string; + sig: string; +} + +type Nip42ProxySocketData = { + authenticated: boolean; + authToken: string; + remoteWs: WebSocket; +}; + +async function validateAuthEvent(event: Event, challenge: string): boolean { + if (event.kind !== 22242) return false; + const last30Seconds = Math.floor(Date.now() / 1000) - 30; + if (event.created_at < last30Seconds) return false; + const challengeTag = event.tags.find(tag => tag[0] === 'challange')?.[1]; + if (challengeTag !== challenge) return false; + const file = Bun.file("./allowed-pubkeys.json"); + if (!file.exists()) return true; + const allowedPubkeys = JSON.parse(await file.text()); + if (!allowedPubkeys.includes(event.pubkey)) return false; + return true; +} + +const sendMessage = (ws: ServerWebSocket, message: any[]) => ws.send(JSON.stringify(message), true); +const sendAuth = (ws: ServerWebSocket) => sendMessage(ws, ["AUTH", ws.data.authToken, "This is an authenticated relay."]); + +export function main(mainRelayUrl: string) { + const server = Bun.serve({ + fetch(req, server) { + const upgrade = server.upgrade(req, { + data: { + authenticated: false, + authToken: Bun.randomUUIDv7() + } + }); + if (!upgrade) console.log(`http request`) + return new Response("this is a proxy. Use http to access it"); + }, + websocket: { + async message(ws, msg) { + const [command, ...data] = JSON.parse(msg); + if (!ws.data.authenticated) { + if (command === "REQ") { + const [name, ...filters] = data; + sendMessage(ws, ["CLOSED", name, 'auth-required: you must authenticate first']); + } + if (command === "EVENT") { + const [event] = data; + sendMessage(ws, ["OK", event.id, false, 'auth-required: you must authenticate first']); + } + if (command === "AUTH") { + const [event] = data; + const valid = await validateAuthEvent(event, ws.data.authToken); + if (!valid) return sendMessage(ws, ['NOTICE', "Invalid auth event"]); + ws.data.authenticated = true; + return; + } + return sendAuth(ws); + } + ws.data.remoteWs.send(msg); + }, + open(ws) { + sendAuth(ws); + ws.data.remoteWs = new WebSocket(mainRelayUrl); + ws.data.remoteWs.onmessage = (data) => ws.send(data.data, true); + }, + perMessageDeflate: true, + perMessageDeflateCompressionLevel: 9 + } + }) + console.log('Server listening @', server.url.host) +} + diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bfa0fea --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "Preserve", + "moduleDetection": "force", + "jsx": "react-jsx", + "allowJs": true, + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + } +}