From 036bf516e2d060e6e3241256c7c91d7e15172bff Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 7 Aug 2026 18:20:06 +0100 Subject: [PATCH 1/2] Add SOCKS5/Tor proxy support for relay connections, plus privacy doc Readers and bridge operators can route all relay traffic through a SOCKS5 proxy via --proxy or GOPHERKIND_PROXY (socks5h, so DNS and .onion names resolve at the proxy). Onion relay URLs are accepted only when proxied. Trusted local development relays are still dialled directly. When proxied, the socket-time DNS guard cannot run, so untrusted relay URLs keep only the hostname-level internal-address check; this is documented. docs/privacy.md states the threat model plainly: gopherkind is a durability and authorship tool, not a privacy tool. It covers what is public by design, what relays and bridges can observe, Tor and onion-service mitigations, and what is out of scope. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- SECURITY.md | 9 +++++ docs/index.md | 1 + docs/privacy.md | 82 +++++++++++++++++++++++++++++++++++++++++++ package-lock.json | 80 +++++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/cli.ts | 17 ++++++++- src/netguard.ts | 68 +++++++++++++++++++++++++++++++++-- test/netguard.test.ts | 29 +++++++++++++++ 8 files changed, 283 insertions(+), 4 deletions(-) create mode 100644 docs/privacy.md diff --git a/SECURITY.md b/SECURITY.md index 11ec896..ed0ccd2 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -38,6 +38,15 @@ is given in the changelog unless you prefer otherwise. ## Deployment notes that are security-relevant +- Relay connections can be routed through a SOCKS5 proxy (`--proxy + socks5h://host:port` or `GOPHERKIND_PROXY`) so relays do not learn the + reader's or bridge's network location, and so `.onion` relay URLs are + reachable. When a proxy is active the connection-time DNS guard cannot run + (the proxy resolves and dials), so untrusted relay URLs get only the + hostname-level internal-address check. A trusted relay on a local address + is still dialled directly. See [docs/privacy.md](docs/privacy.md) for the + full threat model. + - The HTTP frontend trusts loopback as the operator. Behind a reverse proxy every request originates on loopback, so operator trust is disabled unless the bridge is bound to a loopback address (or you pass diff --git a/docs/index.md b/docs/index.md index 8d7ab3c..4f5b57c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -15,6 +15,7 @@ prints it in the terminal. |---|---| | [Why gopher on Nostr](why-gopher-on-nostr.md) | The case, and the honest limits of it | | [Getting started](getting-started.md) | Install, read, pair a signer, publish a hole, verify it | +| [Privacy](privacy.md) | The threat model: what is public, what relays see, Tor and onion options | | [FAQ](faq.md) | Retention, deletion, keys, tokens, what this is not | | [Troubleshooting](troubleshooting.md) | Error messages and what to do about them | | [Support](support.md) | What funding buys, and why it is worth yours | diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..8b15491 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,82 @@ +# Privacy + +gopherkind is a durability and authorship tool, not a privacy tool. This page +states exactly what is visible to whom, and what you can do about it. Read it +before you publish or bridge anything sensitive. + +## What is public by design + +- **Everything served is public.** A gopherhole is signed Nostr events copied + to relays. Treat publishing here exactly like publishing on a website. +- **Authorship is permanent and linkable.** Documents are signed by a npub. + That is the point: a hole belongs to a key, not a hostname, and survives any + server being seized. The cost is that everything one key publishes is + trivially attributable to that key, forever. If you do not want a hole + linked to your main identity, generate a dedicated key for it. +- **Gopher is plaintext and unauthenticated.** RFC 1436 has no TLS and no + credentials. The protocol is read-only, so there is nothing to steal in + transit, but anyone on the network path can see what a gopher client + fetches. The Gemini frontend is TLS; the HTTP frontend is whatever you put + in front of it. + +## What relays and bridges can observe + +- **Relays see read patterns.** Every document read is a subscription filter + naming the author and path. A relay you query learns what you read and when, + plus your IP address unless you proxy. Reading never requires a key, so + there is at least no reader pubkey to correlate. +- **Relays see your network location** unless you use Tor (below). +- **A bridge is a web server.** It logs what visitors fetch like any other. + Reading through someone else's bridge means trusting that bridge; reading + through the terminal client (`gopherkind read`) means trusting only the + relays you query. + +## Mitigations + +### Readers: route relay traffic through Tor + +```sh +gopherkind read --proxy socks5h://127.0.0.1:9050 +# or for everything: +export GOPHERKIND_PROXY=socks5h://127.0.0.1:9050 +``` + +All relay connections — reads, publishes, NIP-46 signing, the bridge's own +fetches — go through the SOCKS5 proxy. Use `socks5h` so DNS resolves at the +proxy; this also makes `wss://....onion` relay URLs usable, which are +otherwise unreachable and rejected. A trusted relay configured as a local +address (a development relay) is still dialled directly, because Tor cannot +reach your loopback. + +Choose your read relays deliberately with `--relay`. Querying one relay you +trust (your own, or a paid relay with a no-logging stance) shrinks the set of +parties that see your filters; querying four big public relays broadcasts them. + +### Operators: serve the hole as an onion service + +Visitors then leave no IP address with the network path, and the hole gets a +second, unseizable address: + +``` +# torrc +HiddenServiceDir /var/lib/tor/gopherkind/ +HiddenServicePort 70 127.0.0.1:7070 +HiddenServicePort 80 127.0.0.1:8070 +``` + +Combine with `--host 127.0.0.1` so the frontends answer only on loopback and +the onion address is the only way in. + +## What this does not give you + +- **Not anonymity.** Tor hides your network location from relays and bridges. + It does not stop a relay seeing your read filters, and it does not unlink a + publisher's key from their content. +- **Not metadata resistance against a global observer.** Timing and traffic + correlation across relays and exits is out of scope. +- **Not secrecy.** There is no access control. NIP-40 expiry asks relays to + forget; relays are not obliged to comply. + +If your threat model needs those properties, you need different tools. This +one is for making sure a document still resolves after every host that ever +served it is gone. diff --git a/package-lock.json b/package-lock.json index 160eaca..3a2a476 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "MIT", "dependencies": { "nostr-tools": "2.23.9", + "socks-proxy-agent": "^8.0.5", "ws": "8.21.1" }, "bin": { @@ -295,6 +296,47 @@ "@types/node": "*" } }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ip-address": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", + "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/nostr-tools": { "version": "2.23.9", "resolved": "https://registry.npmjs.org/nostr-tools/-/nostr-tools-2.23.9.tgz", @@ -324,6 +366,44 @@ "integrity": "sha512-78BTryCLcLYv96ONU8Ws3Q1JzjlAt+43pWQhIl86xZmWeegYCNLPml7yQ+gG3vR6V5h4XGj+TxO+SS5dsThQIA==", "license": "MIT" }, + "node_modules/smart-buffer": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", + "engines": { + "node": ">= 6.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks": { + "version": "2.8.9", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.9.tgz", + "integrity": "sha512-LJhUYUvItdQ0LkJTmPeaEObWXAqFyfmP85x0tch/ez9cahmhlBBLbIqDFnvBnUJGagb0JbIQrkBs1wJ+yRYpEw==", + "license": "MIT", + "dependencies": { + "ip-address": "^10.1.1", + "smart-buffer": "^4.2.0" + }, + "engines": { + "node": ">= 10.0.0", + "npm": ">= 3.0.0" + } + }, + "node_modules/socks-proxy-agent": { + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "^4.3.4", + "socks": "^2.8.3" + }, + "engines": { + "node": ">= 14" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", diff --git a/package.json b/package.json index c992d50..dcc97eb 100644 --- a/package.json +++ b/package.json @@ -64,6 +64,7 @@ }, "dependencies": { "nostr-tools": "2.23.9", + "socks-proxy-agent": "^8.0.5", "ws": "8.21.1" }, "devDependencies": { diff --git a/src/cli.ts b/src/cli.ts index 681a888..bbb2424 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { } from './commands.ts' import { createHttpServer } from './http.ts' import { resolveSigner, requireSignerIdentity } from './signing.ts' +import { configureProxy } from './netguard.ts' import { runBrowse } from './browse.ts' import { BookmarkStore } from './bookmarks.ts' import { aboutContent } from './about.ts' @@ -88,7 +89,10 @@ const USAGE = `usage: gopherkind version | help - every command takes [--relay wss://...]... and [--state-dir d]. + every command takes [--relay wss://...]..., [--proxy socks5h://host:port] + and [--state-dir d]. --proxy routes relay connections through a SOCKS5 + proxy (use socks5h so DNS, and any .onion names, resolve at the proxy; + Tor's default is socks5h://127.0.0.1:9050). GOPHERKIND_PROXY does the same. signer resolution: GOPHERKIND_BUNKER (one-off bunker URI), else whatever \`gopherkind pair\` stored. User secret keys are never accepted.` @@ -97,9 +101,19 @@ const [command, ...rest] = process.argv.slice(2) const COMMON = { relay: { type: 'string', multiple: true }, + proxy: { type: 'string' }, 'state-dir': { type: 'string' }, } as const +// --proxy is pre-scanned rather than threaded through every parseArgs call +// below. Each command still declares the option so strict parsing accepts it. +{ + const inline = rest.find((a) => a.startsWith('--proxy=')) + const separate = rest.indexOf('--proxy') + if (inline !== undefined) configureProxy(inline.slice('--proxy='.length)) + else if (separate !== -1 && rest[separate + 1] !== undefined) configureProxy(rest[separate + 1]) +} + function stateDirOf(v: { 'state-dir'?: string }): string { return v['state-dir'] ?? path.join(os.homedir(), '.gopherkind') } @@ -143,6 +157,7 @@ if (command === 'serve') { 'http-behind-proxy': { type: 'boolean', default: false }, 'no-local-trust': { type: 'boolean', default: false }, 'trust-loopback-anyway': { type: 'boolean', default: false }, + proxy: { type: 'string' }, }, }) const port = Number(values.port) diff --git a/src/netguard.ts b/src/netguard.ts index 52f495c..0bad607 100644 --- a/src/netguard.ts +++ b/src/netguard.ts @@ -1,7 +1,9 @@ import { isIP, type LookupFunction } from 'node:net' import { lookup as dnsLookup } from 'node:dns' import { lookup } from 'node:dns/promises' +import process from 'node:process' import WebSocket from 'ws' +import { SocksProxyAgent } from 'socks-proxy-agent' import { useWebSocketImplementation } from 'nostr-tools/pool' // SSRF guard for the internet-exposed gopher proxy. A remote visitor names @@ -14,6 +16,39 @@ export class BlockedHostError extends Error {} const trustedRelayOrigins = new Set() +// Optional SOCKS5 proxy (typically Tor's 127.0.0.1:9050) for all relay +// connections. Readers who do not want a relay to learn their network +// location set GOPHERKIND_PROXY=socks5h://127.0.0.1:9050 or pass --proxy. +// socks5h resolves DNS at the proxy, which is also what makes .onion relay +// URLs reachable. When a proxy is active the socket-time lookup guard below +// cannot run (the proxy, not us, resolves and dials), so untrusted URLs get +// only the hostname-level internal-address check. +let proxyAgent: SocksProxyAgent | null = null + +export function configureProxy(raw: string | undefined | null): void { + if (raw === undefined || raw === null || raw === '') { + proxyAgent = null + return + } + let url: URL + try { + url = new URL(raw) + } catch { + throw new Error(`invalid proxy URL: ${raw}`) + } + if (url.protocol !== 'socks5:' && url.protocol !== 'socks5h:') { + throw new Error('only socks5:// or socks5h:// proxies are supported (use socks5h for Tor)') + } + // Normalise to socks5h so DNS always resolves at the proxy. With socks5 the + // client resolves first, which leaks the query locally and cannot resolve + // .onion at all. + proxyAgent = new SocksProxyAgent(`socks5h://${url.host}`) +} + +export function proxyActive(): boolean { + return proxyAgent !== null +} + function originOf(raw: string): string | null { try { return new URL(raw).origin @@ -204,7 +239,15 @@ export async function publicRelayUrls(urls: readonly string[]): Promise { class GuardedWebSocket extends WebSocket { constructor(address: string | URL, protocols?: string | string[]) { - const origin = originOf(String(address)) - if (origin !== null && trustedRelayOrigins.has(origin)) { + const raw = String(address) + const origin = originOf(raw) + const trusted = origin !== null && trustedRelayOrigins.has(origin) + if (proxyAgent !== null) { + // A trusted relay that is also a local address is a development relay; + // dial it directly, Tor cannot reach it. Everything else goes through + // the proxy. Untrusted URLs still get the hostname-level internal + // check, since the socket-time lookup guard cannot run through a proxy. + if (trusted && urlHostBlocked(raw)) { + super(address, protocols ?? []) + return + } + if (urlHostBlocked(raw)) throw new BlockedHostError(`refusing ${raw}`) + super(address, protocols ?? [], { agent: proxyAgent }) + return + } + if (trusted) { super(address, protocols ?? []) return } @@ -271,3 +329,7 @@ class GuardedWebSocket extends WebSocket { // guarded implementation once so HoleStore, publishers and NIP-46 all use the // same connection-time network boundary. useWebSocketImplementation(GuardedWebSocket) + +// Env-var configuration applies to every flow (readers, publishers, NIP-46, +// the bridge) without each call site threading the option through. +configureProxy(process.env['GOPHERKIND_PROXY']) diff --git a/test/netguard.test.ts b/test/netguard.test.ts index 6940aff..d412d00 100644 --- a/test/netguard.test.ts +++ b/test/netguard.test.ts @@ -7,6 +7,8 @@ import { urlHostBlocked, publicLookup, publicRelayUrls, + configureProxy, + proxyActive, } from '../src/netguard.ts' import { fetchGopher } from '../src/gopherclient.ts' @@ -87,3 +89,30 @@ test('fetchGopher refuses a selector carrying CR or LF', async () => { /bad selector/, ) }) + +test('configureProxy accepts socks5 and socks5h, rejects everything else', () => { + try { + configureProxy('socks5h://127.0.0.1:9050') + assert.equal(proxyActive(), true) + configureProxy('socks5://127.0.0.1:9050') + assert.equal(proxyActive(), true) + assert.throws(() => configureProxy('http://127.0.0.1:8080'), /socks5/) + assert.throws(() => configureProxy('not a url'), /invalid proxy URL/) + } finally { + configureProxy(undefined) + } + assert.equal(proxyActive(), false) +}) + +test('onion relay URLs pass only when a proxy is active', async () => { + const onion = 'wss://relayexamplebbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb.onion' + assert.deepEqual(await publicRelayUrls([onion]), []) + try { + configureProxy('socks5h://127.0.0.1:9050') + assert.deepEqual(await publicRelayUrls([onion]), [onion]) + // hostname-level internal-address checks still apply through a proxy + assert.deepEqual(await publicRelayUrls(['ws://127.0.0.1:4869']), []) + } finally { + configureProxy(undefined) + } +}) From 3edde5845cac90186cfe60dc07b6f6fc4a3c8076 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 7 Aug 2026 18:31:02 +0100 Subject: [PATCH 2/2] Changelog entry for SOCKS5/Tor proxy support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b65773d..b9139c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +- relay connections can be routed through a SOCKS5 proxy with `--proxy + socks5h://host:port` or `GOPHERKIND_PROXY`, so a reader or bridge no longer + hands its network location to every relay it queries. socks5h is enforced + so DNS, and any `.onion` relay name, resolves at the proxy. Trusted local + development relays are still dialled directly. When a proxy is active the + connection-time DNS guard cannot run, so untrusted relay URLs keep only + the hostname-level internal-address check. docs/privacy.md states the whole + threat model: what is public by design, what relays and bridges can + observe, and what this tool does not claim to be + - the project page reads on a phone. Two things broke below about 372px: the card grid asked for a 22rem minimum track, which is wider than a phone, so the floor could not shrink and the whole page scrolled sideways with the