feat: implement hashcash proof-of-work for session creation
Add SHA-256-based hashcash proof-of-work requirement to POST /session to prevent abuse via rapid session creation. The server advertises the required difficulty via GET /server (hashcash_bits field), and clients must include a valid stamp in the X-Hashcash request header. Server-side: - New internal/hashcash package with stamp validation (format, bits, date, resource, replay prevention via in-memory spent set) - Config: NEOIRC_HASHCASH_BITS env var (default 20, set 0 to disable) - GET /server includes hashcash_bits when > 0 - POST /session validates X-Hashcash header when enabled - Returns HTTP 402 for missing/invalid stamps Client-side: - SPA: fetches hashcash_bits from /server, computes stamp using Web Crypto API with batched SHA-256, shows 'Computing proof-of-work...' feedback during computation - CLI: api package gains MintHashcash() function, CreateSession() auto-fetches server info and computes stamp when required Stamp format: 1:bits:YYMMDD:resource::counter (standard hashcash) closes #11
This commit is contained in:
187
README.md
187
README.md
@@ -987,7 +987,18 @@ the format:
|
||||
|
||||
Create a new user session. This is the entry point for all clients.
|
||||
|
||||
**Request:**
|
||||
If the server requires hashcash proof-of-work (see
|
||||
[Hashcash Proof-of-Work](#hashcash-proof-of-work)), the client must include a
|
||||
valid stamp in the `X-Hashcash` request header. The required difficulty is
|
||||
advertised via `GET /api/v1/server` in the `hashcash_bits` field.
|
||||
|
||||
**Request Headers:**
|
||||
|
||||
| Header | Required | Description |
|
||||
|--------------|----------|-------------|
|
||||
| `X-Hashcash` | Conditional | Hashcash stamp (required when server has `hashcash_bits` > 0) |
|
||||
|
||||
**Request Body:**
|
||||
```json
|
||||
{"nick": "alice"}
|
||||
```
|
||||
@@ -1016,12 +1027,15 @@ Create a new user session. This is the entry point for all clients.
|
||||
| Status | Error | When |
|
||||
|--------|-------|------|
|
||||
| 400 | `nick must be 1-32 characters` | Empty or too-long nick |
|
||||
| 402 | `hashcash proof-of-work required` | Missing `X-Hashcash` header when hashcash is enabled |
|
||||
| 402 | `invalid hashcash stamp: ...` | Stamp fails validation (wrong bits, expired, reused, etc.) |
|
||||
| 409 | `nick already taken` | Another active session holds this nick |
|
||||
|
||||
**curl example:**
|
||||
```bash
|
||||
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
|
||||
-d '{"nick":"alice"}' | jq -r .token)
|
||||
echo $TOKEN
|
||||
```
|
||||
@@ -1376,16 +1390,18 @@ Return server metadata. No authentication required.
|
||||
"name": "My NeoIRC Server",
|
||||
"version": "0.1.0",
|
||||
"motd": "Welcome! Be nice.",
|
||||
"users": 42
|
||||
"users": 42,
|
||||
"hashcash_bits": 20
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|-----------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| Field | Type | Description |
|
||||
|-----------------|---------|-------------|
|
||||
| `name` | string | Server display name |
|
||||
| `version` | string | Server version |
|
||||
| `motd` | string | Message of the day |
|
||||
| `users` | integer | Number of currently active user sessions |
|
||||
| `hashcash_bits` | integer | Required proof-of-work difficulty (leading zero bits). Only present when > 0. See [Hashcash Proof-of-Work](#hashcash-proof-of-work). |
|
||||
|
||||
### GET /.well-known/healthcheck.json — Health Check
|
||||
|
||||
@@ -1819,6 +1835,7 @@ directory is also loaded automatically via
|
||||
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
|
||||
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
|
||||
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint |
|
||||
| `NEOIRC_HASHCASH_BITS` | int | `20` | Required hashcash proof-of-work difficulty (leading zero bits in SHA-256) for session creation. Set to `0` to disable. |
|
||||
| `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
|
||||
|
||||
### Example `.env` file
|
||||
@@ -1830,6 +1847,7 @@ MOTD=Welcome! Be excellent to each other.
|
||||
DEBUG=false
|
||||
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
|
||||
SESSION_IDLE_TIMEOUT=24h
|
||||
NEOIRC_HASHCASH_BITS=20
|
||||
```
|
||||
|
||||
---
|
||||
@@ -2102,62 +2120,102 @@ Clients should handle these message commands from the queue:
|
||||
|
||||
## Rate Limiting & Abuse Prevention
|
||||
|
||||
Session creation (`POST /api/v1/session`) will require a
|
||||
### Hashcash Proof-of-Work
|
||||
|
||||
Session creation (`POST /api/v1/session`) requires a
|
||||
[hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
|
||||
This is the primary defense against resource exhaustion — no CAPTCHAs, no
|
||||
account registration, no IP-based rate limits that punish shared networks.
|
||||
|
||||
### How It Works
|
||||
|
||||
1. Client requests a challenge: `GET /api/v1/challenge`
|
||||
```json
|
||||
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"}
|
||||
```
|
||||
2. Server returns a nonce and a required difficulty (number of leading zero
|
||||
bits in the SHA-256 hash)
|
||||
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)`
|
||||
has the required number of leading zero bits:
|
||||
```
|
||||
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good)
|
||||
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good)
|
||||
...
|
||||
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!)
|
||||
```
|
||||
4. Client submits the proof with the session request:
|
||||
```json
|
||||
POST /api/v1/session
|
||||
{"nick": "alice", "proof": {"nonce": "a1b2c3", "counter": 94217}}
|
||||
```
|
||||
5. Server verifies:
|
||||
- Nonce was issued by this server and hasn't expired
|
||||
- Nonce hasn't been used before (prevent replay)
|
||||
- `SHA-256(nonce || ":" || counter)` has the required leading zeros
|
||||
- If valid, create the session normally
|
||||
1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
|
||||
field (e.g., `20`) indicating the required difficulty.
|
||||
2. Client computes a hashcash stamp: find a counter value such that the
|
||||
SHA-256 hash of the stamp string has the required number of leading zero
|
||||
bits.
|
||||
3. Client includes the stamp in the `X-Hashcash` request header when creating
|
||||
a session: `POST /api/v1/session`.
|
||||
4. Server validates the stamp:
|
||||
- Version is `1`
|
||||
- Claimed bits ≥ required bits
|
||||
- Resource matches the server name
|
||||
- Date is within 48 hours (not expired, not too far in the future)
|
||||
- SHA-256 hash has the required leading zero bits
|
||||
- Stamp has not been used before (replay prevention)
|
||||
|
||||
### Adaptive Difficulty
|
||||
### Stamp Format
|
||||
|
||||
The required difficulty scales with server load. Under normal conditions, the
|
||||
cost is negligible (a few milliseconds of CPU). As concurrent sessions or
|
||||
session creation rate increases, difficulty rises — making bulk session creation
|
||||
exponentially more expensive for attackers while remaining cheap for legitimate
|
||||
single-user connections.
|
||||
Standard hashcash format:
|
||||
|
||||
| Server Load | Difficulty (bits) | Approx. Client CPU |
|
||||
|--------------------|-------------------|--------------------|
|
||||
| Normal (< 100/min) | 16 | ~1ms |
|
||||
| Elevated | 20 | ~15ms |
|
||||
| High | 24 | ~250ms |
|
||||
| Under attack | 28+ | ~4s+ |
|
||||
```
|
||||
1:bits:date:resource::counter
|
||||
```
|
||||
|
||||
Each additional bit of difficulty doubles the expected work. An attacker
|
||||
creating 1000 sessions at difficulty 28 needs ~4000 CPU-seconds; a legitimate
|
||||
user creating one session needs ~4 seconds once and never again for the
|
||||
duration of their session.
|
||||
| Field | Description |
|
||||
|------------|-------------|
|
||||
| `1` | Version (always `1`) |
|
||||
| `bits` | Claimed difficulty (must be ≥ server's `hashcash_bits`) |
|
||||
| `date` | Date stamp in `YYMMDD` or `YYMMDDHHMMSS` format (UTC) |
|
||||
| `resource` | The server name (from `GET /api/v1/server`; defaults to `neoirc`) |
|
||||
| (empty) | Extension field (unused) |
|
||||
| `counter` | Hex counter value found by the client to satisfy the PoW |
|
||||
|
||||
**Example stamp:** `1:20:260310:neoirc::3a2f1b`
|
||||
|
||||
The SHA-256 hash of this entire string must have at least 20 leading zero bits.
|
||||
|
||||
### Computing a Stamp
|
||||
|
||||
```bash
|
||||
# Pseudocode
|
||||
bits = 20
|
||||
resource = "neoirc"
|
||||
date = "260310" # YYMMDD in UTC
|
||||
counter = 0
|
||||
|
||||
loop:
|
||||
stamp = "1:{bits}:{date}:{resource}::{hex(counter)}"
|
||||
hash = SHA-256(stamp)
|
||||
if leading_zero_bits(hash) >= bits:
|
||||
return stamp
|
||||
counter++
|
||||
```
|
||||
|
||||
At difficulty 20, this requires approximately 2^20 (~1M) hash attempts on
|
||||
average, taking roughly 0.5–2 seconds on modern hardware.
|
||||
|
||||
### Client Integration
|
||||
|
||||
Both the embedded web SPA and the CLI client automatically handle hashcash:
|
||||
|
||||
1. Fetch `GET /api/v1/server` to read `hashcash_bits`
|
||||
2. If `hashcash_bits > 0`, compute a valid stamp
|
||||
3. Include the stamp in the `X-Hashcash` header on `POST /api/v1/session`
|
||||
|
||||
The web SPA uses the Web Crypto API (`crypto.subtle.digest`) for SHA-256
|
||||
computation with batched parallelism. The CLI client uses Go's `crypto/sha256`.
|
||||
|
||||
### Configuration
|
||||
|
||||
Set `NEOIRC_HASHCASH_BITS` to control difficulty:
|
||||
|
||||
| Value | Effect | Approx. Client CPU |
|
||||
|-------|--------|---------------------|
|
||||
| `0` | Disabled (no proof-of-work required) | — |
|
||||
| `16` | Light protection | ~1ms |
|
||||
| `20` | Default — good balance | ~0.5–2s |
|
||||
| `24` | Strong protection | ~10–30s |
|
||||
| `28` | Very strong (may frustrate users) | ~2–10min |
|
||||
|
||||
Each additional bit doubles the expected work. An attacker creating 1000
|
||||
sessions at difficulty 20 needs ~1000–2000 CPU-seconds; a legitimate user
|
||||
creating one session pays once and keeps their session.
|
||||
|
||||
### Why Hashcash and Not Rate Limits?
|
||||
|
||||
- **No state to track**: No IP tables, no token buckets, no sliding windows.
|
||||
The server only needs to verify a hash.
|
||||
The server only needs to verify a single hash.
|
||||
- **Works through NATs and proxies**: Doesn't punish shared IPs (university
|
||||
campuses, corporate networks, Tor exits). Every client computes their own
|
||||
proof independently.
|
||||
@@ -2165,36 +2223,9 @@ duration of their session.
|
||||
(one SHA-256 hash) regardless of difficulty. Only the client does more work.
|
||||
- **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
|
||||
No registration, no email, no phone number, no CAPTCHA. Just compute.
|
||||
- **Trivial for legitimate clients**: A single-user client pays ~1ms of CPU
|
||||
once. A botnet trying to create thousands of sessions pays exponentially more.
|
||||
- **Language-agnostic**: SHA-256 is available in every programming language.
|
||||
The proof computation is trivially implementable in any client.
|
||||
|
||||
### Challenge Endpoint (Planned)
|
||||
|
||||
```
|
||||
GET /api/v1/challenge
|
||||
```
|
||||
|
||||
**Response:** `200 OK`
|
||||
```json
|
||||
{
|
||||
"nonce": "a1b2c3d4e5f6...",
|
||||
"difficulty": 20,
|
||||
"algorithm": "sha256",
|
||||
"expires": "2026-02-10T20:01:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
| Field | Type | Description |
|
||||
|--------------|---------|-------------|
|
||||
| `nonce` | string | Server-generated random hex string (32+ chars) |
|
||||
| `difficulty` | integer | Required number of leading zero bits in the hash |
|
||||
| `algorithm` | string | Hash algorithm (always `sha256` for now) |
|
||||
| `expires` | string | ISO 8601 expiry time for this challenge |
|
||||
|
||||
**Status:** Not yet implemented. Tracked for post-MVP.
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
@@ -2223,7 +2254,7 @@ GET /api/v1/challenge
|
||||
|
||||
### Post-MVP (Planned)
|
||||
|
||||
- [ ] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [x] **Hashcash proof-of-work** for session creation (abuse prevention)
|
||||
- [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
|
||||
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
|
||||
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
|
||||
|
||||
Reference in New Issue
Block a user