Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
82 changes: 82 additions & 0 deletions docs/privacy.md
Original file line number Diff line number Diff line change
@@ -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 <target> --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.
80 changes: 80 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
},
"dependencies": {
"nostr-tools": "2.23.9",
"socks-proxy-agent": "^8.0.5",
"ws": "8.21.1"
},
"devDependencies": {
Expand Down
17 changes: 16 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.`
Expand All @@ -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')
}
Expand Down Expand Up @@ -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)
Expand Down
68 changes: 65 additions & 3 deletions src/netguard.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -14,6 +16,39 @@ export class BlockedHostError extends Error {}

const trustedRelayOrigins = new Set<string>()

// 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
Expand Down Expand Up @@ -204,7 +239,15 @@ export async function publicRelayUrls(urls: readonly string[]): Promise<string[]
let url: URL
try {
url = new URL(raw)
await resolvePublicHost(url.hostname)
if (url.hostname.toLowerCase().endsWith('.onion')) {
// Onion services are only reachable through a proxy that resolves
// remotely; without one the URL is useless and dropped.
if (proxyActive()) out.push(raw)
continue
}
// With a proxy the remote side resolves and dials, so a local
// resolution check adds nothing.
if (!proxyActive()) await resolvePublicHost(url.hostname)
} catch {
continue
}
Expand Down Expand Up @@ -258,8 +301,23 @@ export const publicLookup: LookupFunction = (hostname, options, callback) => {

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
}
Expand All @@ -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'])
Loading