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 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) + } +})