2 Commits

Author SHA1 Message Date
user
1e9fb36f5b fix: move hashcash PoW from build artifact to JSX source
Some checks failed
check / check (push) Failing after 48s
The hashcash proof-of-work implementation was incorrectly added to the
build artifact web/dist/app.js instead of the source file web/src/app.jsx.
Running web/build.sh would overwrite all hashcash changes.

Changes:
- Add checkLeadingZeros() and mintHashcash() functions to app.jsx
- Integrate hashcash into LoginScreen: fetch hashcash_bits from /server,
  compute stamp via Web Crypto API before session creation, show
  'Computing proof-of-work...' feedback
- Remove web/dist/ from git tracking (build artifacts)
- Add web/dist/ to .gitignore
2026-03-10 02:58:48 -07:00
clawbot
8c2071f93a feat: implement hashcash proof-of-work for session creation
All checks were successful
check / check (push) Successful in 4s
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
2026-03-10 02:51:15 -07:00
15 changed files with 658 additions and 255 deletions

2
.gitignore vendored
View File

@@ -21,7 +21,6 @@ node_modules/
*.key *.key
# Build artifacts # Build artifacts
web/dist/
/neoircd /neoircd
/bin/ /bin/
*.exe *.exe
@@ -37,3 +36,4 @@ data.db
debug.log debug.log
/neoirc-cli /neoirc-cli
web/node_modules/ web/node_modules/
web/dist/

View File

@@ -1,13 +1,3 @@
# Web build stage — compile SPA from source
# node:22-alpine, 2026-03-09
FROM node@sha256:8094c002d08262dba12645a3b4a15cd6cd627d30bc782f53229a2ec13ee22a00 AS web-builder
WORKDIR /web
COPY web/package.json web/package-lock.json ./
RUN npm ci
COPY web/src/ src/
COPY web/build.sh build.sh
RUN sh build.sh
# Lint stage — fast feedback on formatting and lint issues # Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.1.6, 2026-03-02 # golangci/golangci-lint:v2.1.6, 2026-03-02
FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint FROM golangci/golangci-lint@sha256:568ee1c1c53493575fa9494e280e579ac9ca865787bafe4df3023ae59ecf299b AS lint
@@ -15,9 +5,6 @@ WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
# Create placeholder files so //go:embed dist/* in web/embed.go resolves
# without depending on the web-builder stage (lint should fail fast)
RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css web/dist/app.js
RUN make fmt-check RUN make fmt-check
RUN make lint RUN make lint
@@ -34,7 +21,6 @@ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . COPY . .
COPY --from=web-builder /web/dist/ web/dist/
RUN make test RUN make test

231
README.md
View File

@@ -987,7 +987,18 @@ the format:
Create a new user session. This is the entry point for all clients. 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 ```json
{"nick": "alice"} {"nick": "alice"}
``` ```
@@ -1016,12 +1027,15 @@ Create a new user session. This is the entry point for all clients.
| Status | Error | When | | Status | Error | When |
|--------|-------|------| |--------|-------|------|
| 400 | `nick must be 1-32 characters` | Empty or too-long nick | | 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 | | 409 | `nick already taken` | Another active session holds this nick |
**curl example:** **curl example:**
```bash ```bash
TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \ TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/session \
-H 'Content-Type: application/json' \ -H 'Content-Type: application/json' \
-H 'X-Hashcash: 1:20:260310:neoirc::3a2f1' \
-d '{"nick":"alice"}' | jq -r .token) -d '{"nick":"alice"}' | jq -r .token)
echo $TOKEN echo $TOKEN
``` ```
@@ -1032,12 +1046,6 @@ Return the current user's session state.
**Request:** No body. Requires auth. **Request:** No body. Requires auth.
**Query Parameters:**
| Parameter | Type | Default | Description |
|-----------|--------|---------|-------------|
| `initChannelState` | string | (none) | When set to `1`, enqueues synthetic JOIN + TOPIC + NAMES messages for every channel the session belongs to into the calling client's queue. Used by the SPA on reconnect to restore channel tabs without re-sending JOIN commands. |
**Response:** `200 OK` **Response:** `200 OK`
```json ```json
{ {
@@ -1070,12 +1078,6 @@ curl -s http://localhost:8080/api/v1/state \
-H "Authorization: Bearer $TOKEN" | jq . -H "Authorization: Bearer $TOKEN" | jq .
``` ```
**Reconnect with channel state initialization:**
```bash
curl -s "http://localhost:8080/api/v1/state?initChannelState=1" \
-H "Authorization: Bearer $TOKEN" | jq .
```
### GET /api/v1/messages — Poll Messages (Long-Poll) ### GET /api/v1/messages — Poll Messages (Long-Poll)
Retrieve messages from the client's delivery queue. This is the primary Retrieve messages from the client's delivery queue. This is the primary
@@ -1374,18 +1376,18 @@ Return server metadata. No authentication required.
```json ```json
{ {
"name": "My NeoIRC Server", "name": "My NeoIRC Server",
"version": "0.1.0",
"motd": "Welcome! Be nice.", "motd": "Welcome! Be nice.",
"users": 42 "users": 42,
"hashcash_bits": 20
} }
``` ```
| Field | Type | Description | | Field | Type | Description |
|-----------|---------|-------------| |-----------------|---------|-------------|
| `name` | string | Server display name | | `name` | string | Server display name |
| `version` | string | Server version |
| `motd` | string | Message of the day | | `motd` | string | Message of the day |
| `users` | integer | Number of currently active user sessions | | `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 ### GET /.well-known/healthcheck.json — Health Check
@@ -1819,6 +1821,7 @@ directory is also loaded automatically via
| `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) | | `SENTRY_DSN` | string | `""` | Sentry error tracking DSN (optional) |
| `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. | | `METRICS_USERNAME` | string | `""` | Basic auth username for `/metrics` endpoint. If empty, metrics endpoint is disabled. |
| `METRICS_PASSWORD` | string | `""` | Basic auth password for `/metrics` endpoint | | `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) | | `MAINTENANCE_MODE` | bool | `false` | Maintenance mode flag (reserved) |
### Example `.env` file ### Example `.env` file
@@ -1830,6 +1833,7 @@ MOTD=Welcome! Be excellent to each other.
DEBUG=false DEBUG=false
DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL DBURL=file:///var/lib/neoirc/state.db?_journal_mode=WAL
SESSION_IDLE_TIMEOUT=24h SESSION_IDLE_TIMEOUT=24h
NEOIRC_HASHCASH_BITS=20
``` ```
--- ---
@@ -1852,16 +1856,26 @@ docker run -p 8080:8080 \
neoirc neoirc
``` ```
The Dockerfile is a four-stage build: The Dockerfile is a multi-stage build:
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → 1. **Build stage**: Compiles `neoircd` and `neoirc-cli` (CLI built to verify
bundled JS via esbuild) into `web/dist/`
2. **lint**: Runs formatting checks and golangci-lint against the Go source
(uses empty placeholder files for `web/dist/` so it runs independently of
web-builder for fast feedback)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli`
binaries with the real SPA assets from web-builder (CLI built to verify
compilation, not included in final image) compilation, not included in final image)
4. **final**: Minimal Alpine image with only the `neoircd` binary 2. **Final stage**: Alpine Linux + `neoircd` binary only
```dockerfile
FROM golang:1.24-alpine AS builder
WORKDIR /src
RUN apk add --no-cache make
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o /neoircd ./cmd/neoircd/
RUN go build -o /neoirc-cli ./cmd/neoirc-cli/
FROM alpine:latest
COPY --from=builder /neoircd /usr/local/bin/neoircd
EXPOSE 8080
CMD ["neoircd"]
```
### Binary ### Binary
@@ -2102,62 +2116,102 @@ Clients should handle these message commands from the queue:
## Rate Limiting & Abuse Prevention ## 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. [hashcash](https://en.wikipedia.org/wiki/Hashcash)-style proof-of-work token.
This is the primary defense against resource exhaustion — no CAPTCHAs, no This is the primary defense against resource exhaustion — no CAPTCHAs, no
account registration, no IP-based rate limits that punish shared networks. account registration, no IP-based rate limits that punish shared networks.
### How It Works ### How It Works
1. Client requests a challenge: `GET /api/v1/challenge` 1. Client fetches server info: `GET /api/v1/server` returns a `hashcash_bits`
```json field (e.g., `20`) indicating the required difficulty.
→ {"nonce": "random-hex-string", "difficulty": 20, "expires": "2026-02-10T20:01:00Z"} 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
2. Server returns a nonce and a required difficulty (number of leading zero bits.
bits in the SHA-256 hash) 3. Client includes the stamp in the `X-Hashcash` request header when creating
3. Client finds a counter value such that `SHA-256(nonce || ":" || counter)` a session: `POST /api/v1/session`.
has the required number of leading zero bits: 4. Server validates the stamp:
``` - Version is `1`
SHA-256("a1b2c3:0") = 0xf3a1... (0 leading zeros — no good) - Claimed bits ≥ required bits
SHA-256("a1b2c3:1") = 0x8c72... (0 leading zeros — no good) - Resource matches the server name
... - Date is within 48 hours (not expired, not too far in the future)
SHA-256("a1b2c3:94217") = 0x00003a... (20 leading zero bits — success!) - SHA-256 hash has the required leading zero bits
``` - Stamp has not been used before (replay prevention)
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
### Adaptive Difficulty ### Stamp Format
The required difficulty scales with server load. Under normal conditions, the Standard hashcash format:
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.
| Server Load | Difficulty (bits) | Approx. Client CPU | ```
|--------------------|-------------------|--------------------| 1:bits:date:resource::counter
| Normal (< 100/min) | 16 | ~1ms | ```
| Elevated | 20 | ~15ms |
| High | 24 | ~250ms |
| Under attack | 28+ | ~4s+ |
Each additional bit of difficulty doubles the expected work. An attacker | Field | Description |
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 | `1` | Version (always `1`) |
duration of their session. | `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.52 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.52s |
| `24` | Strong protection | ~1030s |
| `28` | Very strong (may frustrate users) | ~210min |
Each additional bit doubles the expected work. An attacker creating 1000
sessions at difficulty 20 needs ~10002000 CPU-seconds; a legitimate user
creating one session pays once and keeps their session.
### Why Hashcash and Not Rate Limits? ### Why Hashcash and Not Rate Limits?
- **No state to track**: No IP tables, no token buckets, no sliding windows. - **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 - **Works through NATs and proxies**: Doesn't punish shared IPs (university
campuses, corporate networks, Tor exits). Every client computes their own campuses, corporate networks, Tor exits). Every client computes their own
proof independently. proof independently.
@@ -2165,36 +2219,9 @@ duration of their session.
(one SHA-256 hash) regardless of difficulty. Only the client does more work. (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. - **Fits the "no accounts" philosophy**: Proof-of-work is the cost of entry.
No registration, no email, no phone number, no CAPTCHA. Just compute. 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. - **Language-agnostic**: SHA-256 is available in every programming language.
The proof computation is trivially implementable in any client. 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 ## Roadmap
@@ -2223,7 +2250,7 @@ GET /api/v1/challenge
### Post-MVP (Planned) ### 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` - [ ] **Queue pruning** — delete old queue entries per `QUEUE_MAX_AGE`
- [ ] **Message rotation** — enforce `MAX_HISTORY` per channel - [ ] **Message rotation** — enforce `MAX_HISTORY` per channel
- [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n` - [ ] **Channel modes** — enforce `+i`, `+m`, `+s`, `+t`, `+n`
@@ -2310,14 +2337,10 @@ neoirc/
│ └── http.go # HTTP timeouts │ └── http.go # HTTP timeouts
├── web/ ├── web/
│ ├── embed.go # go:embed directive for SPA │ ├── embed.go # go:embed directive for SPA
── build.sh # SPA build script (esbuild, runs in Docker) ── dist/ # Built SPA (vanilla JS, no build step)
├── package.json # Node dependencies (preact, esbuild) ├── index.html
├── package-lock.json ├── style.css
├── src/ # SPA source files (JSX + HTML + CSS) └── app.js
│ │ ├── app.jsx
│ │ ├── index.html
│ │ └── style.css
│ └── dist/ # Generated at Docker build time (not committed)
├── schema/ # JSON Schema definitions (planned) ├── schema/ # JSON Schema definitions (planned)
├── go.mod ├── go.mod
├── go.sum ├── go.sum

View File

@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-03-09 last_modified: 2026-02-22
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -98,13 +98,6 @@ style conventions are in separate documents:
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo. a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
repository if it can be avoided. The build process (e.g. Dockerfile, Makefile)
should generate these at build time. Notable exception: Go protobuf generated
files (`.pb.go`) ARE committed because repos need to work with `go get`, which
downloads code but does not execute code generation.
- Never use `git add -A` or `git add .`. Always stage files explicitly by name. - Never use `git add -A` or `git add .`. Always stage files explicitly by name.
- Never force-push to `main`. - Never force-push to `main`.
@@ -151,14 +144,8 @@ style conventions are in separate documents:
- Use SemVer. - Use SemVer.
- Database migrations live in `internal/db/migrations/` and must be embedded in - Database migrations live in `internal/db/migrations/` and must be embedded in
the binary. the binary. Pre-1.0.0: modify existing migrations (no installed base assumed).
- `000_migration.sql` — contains ONLY the creation of the migrations Post-1.0.0: add new migration files.
tracking table itself. Nothing else.
- `001_schema.sql` — the full application schema.
- **Pre-1.0.0:** never add additional migration files (002, 003, etc.).
There is no installed base to migrate. Edit `001_schema.sql` directly.
- **Post-1.0.0:** add new numbered migration files for each schema change.
Never edit existing migrations after release.
- All repos should have an `.editorconfig` enforcing the project's indentation - All repos should have an `.editorconfig` enforcing the project's indentation
settings. settings.

View File

@@ -43,13 +43,34 @@ func NewClient(baseURL string) *Client {
} }
// CreateSession creates a new session on the server. // CreateSession creates a new session on the server.
// If the server requires hashcash proof-of-work, it
// automatically fetches the difficulty and computes a
// valid stamp.
func (client *Client) CreateSession( func (client *Client) CreateSession(
nick string, nick string,
) (*SessionResponse, error) { ) (*SessionResponse, error) {
data, err := client.do( // Fetch server info to check for hashcash requirement.
info, err := client.GetServerInfo()
var headers map[string]string
if err == nil && info.HashcashBits > 0 {
resource := info.Name
if resource == "" {
resource = "neoirc"
}
stamp := MintHashcash(info.HashcashBits, resource)
headers = map[string]string{
"X-Hashcash": stamp,
}
}
data, err := client.doWithHeaders(
http.MethodPost, http.MethodPost,
"/api/v1/session", "/api/v1/session",
&SessionRequest{Nick: nick}, &SessionRequest{Nick: nick},
headers,
) )
if err != nil { if err != nil {
return nil, err return nil, err
@@ -261,6 +282,16 @@ func (client *Client) GetServerInfo() (
func (client *Client) do( func (client *Client) do(
method, path string, method, path string,
body any, body any,
) ([]byte, error) {
return client.doWithHeaders(
method, path, body, nil,
)
}
func (client *Client) doWithHeaders(
method, path string,
body any,
extraHeaders map[string]string,
) ([]byte, error) { ) ([]byte, error) {
var bodyReader io.Reader var bodyReader io.Reader
@@ -293,6 +324,10 @@ func (client *Client) do(
) )
} }
for key, val := range extraHeaders {
request.Header.Set(key, val)
}
resp, err := client.HTTPClient.Do(request) resp, err := client.HTTPClient.Do(request)
if err != nil { if err != nil {
return nil, fmt.Errorf("http: %w", err) return nil, fmt.Errorf("http: %w", err)

View File

@@ -0,0 +1,79 @@
package neoircapi
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"fmt"
"math/big"
"time"
)
const (
// bitsPerByte is the number of bits in a byte.
bitsPerByte = 8
// fullByteMask is 0xFF, a mask for all bits in a byte.
fullByteMask = 0xFF
// counterSpace is the range for random counter seeds.
counterSpace = 1 << 48
)
// MintHashcash computes a hashcash stamp with the given
// difficulty (leading zero bits) and resource string.
func MintHashcash(bits int, resource string) string {
date := time.Now().UTC().Format("060102")
prefix := fmt.Sprintf(
"1:%d:%s:%s::", bits, date, resource,
)
for {
counter := randomCounter()
stamp := prefix + counter
hash := sha256.Sum256([]byte(stamp))
if hasLeadingZeroBits(hash[:], bits) {
return stamp
}
}
}
// hasLeadingZeroBits checks if hash has at least numBits
// leading zero bits.
func hasLeadingZeroBits(
hash []byte,
numBits int,
) bool {
fullBytes := numBits / bitsPerByte
remainBits := numBits % bitsPerByte
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(
fullByteMask << (bitsPerByte - remainBits),
)
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
// randomCounter generates a random hex counter string.
func randomCounter() string {
counterVal, err := rand.Int(
rand.Reader, big.NewInt(counterSpace),
)
if err != nil {
// Fallback to timestamp-based counter on error.
return fmt.Sprintf("%x", time.Now().UnixNano())
}
return hex.EncodeToString(counterVal.Bytes())
}

View File

@@ -66,6 +66,7 @@ type ServerInfo struct {
Name string `json:"name"` Name string `json:"name"`
MOTD string `json:"motd"` MOTD string `json:"motd"`
Version string `json:"version"` Version string `json:"version"`
HashcashBits int `json:"hashcash_bits"` //nolint:tagliatelle
} }
// MessagesResponse wraps polling results. // MessagesResponse wraps polling results.

View File

@@ -44,6 +44,7 @@ type Config struct {
ServerName string ServerName string
FederationKey string FederationKey string
SessionIdleTimeout string SessionIdleTimeout string
HashcashBits int
params *Params params *Params
log *slog.Logger log *slog.Logger
} }
@@ -74,6 +75,7 @@ func New(
viper.SetDefault("SERVER_NAME", "") viper.SetDefault("SERVER_NAME", "")
viper.SetDefault("FEDERATION_KEY", "") viper.SetDefault("FEDERATION_KEY", "")
viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h") viper.SetDefault("SESSION_IDLE_TIMEOUT", "24h")
viper.SetDefault("NEOIRC_HASHCASH_BITS", "20")
err := viper.ReadInConfig() err := viper.ReadInConfig()
if err != nil { if err != nil {
@@ -98,6 +100,7 @@ func New(
ServerName: viper.GetString("SERVER_NAME"), ServerName: viper.GetString("SERVER_NAME"),
FederationKey: viper.GetString("FEDERATION_KEY"), FederationKey: viper.GetString("FEDERATION_KEY"),
SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"), SessionIdleTimeout: viper.GetString("SESSION_IDLE_TIMEOUT"),
HashcashBits: viper.GetInt("NEOIRC_HASHCASH_BITS"),
log: log, log: log,
params: &params, params: &params,
} }

View File

@@ -144,6 +144,33 @@ func (hdlr *Handlers) handleCreateSession(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
// Validate hashcash proof-of-work if configured.
if hdlr.params.Config.HashcashBits > 0 {
stamp := request.Header.Get("X-Hashcash")
if stamp == "" {
hdlr.respondError(
writer, request,
"hashcash proof-of-work required",
http.StatusPaymentRequired,
)
return
}
err := hdlr.hashcashVal.Validate(
stamp, hdlr.params.Config.HashcashBits,
)
if err != nil {
hdlr.respondError(
writer, request,
"invalid hashcash stamp: "+err.Error(),
http.StatusPaymentRequired,
)
return
}
}
type createRequest struct { type createRequest struct {
Nick string `json:"nick"` Nick string `json:"nick"`
} }
@@ -444,17 +471,13 @@ func (hdlr *Handlers) enqueueNumeric(
} }
// HandleState returns the current session's info and // HandleState returns the current session's info and
// channels. When called with ?initChannelState=1, it also // channels.
// enqueues synthetic JOIN + TOPIC + NAMES messages for
// every channel the session belongs to so that a
// reconnecting client can rebuild its channel tabs from
// the message stream.
func (hdlr *Handlers) HandleState() http.HandlerFunc { func (hdlr *Handlers) HandleState() http.HandlerFunc {
return func( return func(
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
sessionID, clientID, nick, ok := sessionID, _, nick, ok :=
hdlr.requireAuth(writer, request) hdlr.requireAuth(writer, request)
if !ok { if !ok {
return return
@@ -476,12 +499,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
return return
} }
if request.URL.Query().Get("initChannelState") == "1" {
hdlr.initChannelState(
request, clientID, sessionID, nick,
)
}
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": nick, "nick": nick,
@@ -490,52 +507,6 @@ func (hdlr *Handlers) HandleState() http.HandlerFunc {
} }
} }
// initChannelState enqueues synthetic JOIN messages and
// join-numerics (TOPIC, NAMES) for every channel the
// session belongs to. Messages are enqueued only to the
// specified client so other clients/sessions are not
// affected.
func (hdlr *Handlers) initChannelState(
request *http.Request,
clientID, sessionID int64,
nick string,
) {
ctx := request.Context()
channels, err := hdlr.params.Database.
GetSessionChannels(ctx, sessionID)
if err != nil || len(channels) == 0 {
return
}
for _, chanInfo := range channels {
// Enqueue a synthetic JOIN (only to this client).
dbID, _, insErr := hdlr.params.Database.
InsertMessage(
ctx, "JOIN", nick, chanInfo.Name,
nil, nil, nil,
)
if insErr != nil {
hdlr.log.Error(
"initChannelState: insert JOIN",
"error", insErr,
)
continue
}
_ = hdlr.params.Database.EnqueueToClient(
ctx, clientID, dbID,
)
// Enqueue TOPIC + NAMES numerics.
hdlr.deliverJoinNumerics(
request, clientID, sessionID,
nick, chanInfo.Name, chanInfo.ID,
)
}
}
// HandleListAllChannels returns all channels on the server. // HandleListAllChannels returns all channels on the server.
func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc { func (hdlr *Handlers) HandleListAllChannels() http.HandlerFunc {
return func( return func(
@@ -2391,11 +2362,18 @@ func (hdlr *Handlers) HandleServerInfo() http.HandlerFunc {
return return
} }
hdlr.respondJSON(writer, request, map[string]any{ resp := map[string]any{
"name": hdlr.params.Config.ServerName, "name": hdlr.params.Config.ServerName,
"version": hdlr.params.Globals.Version,
"motd": hdlr.params.Config.MOTD, "motd": hdlr.params.Config.MOTD,
"users": users, "users": users,
}, http.StatusOK) }
if hdlr.params.Config.HashcashBits > 0 {
resp["hashcash_bits"] = hdlr.params.Config.HashcashBits
}
hdlr.respondJSON(
writer, request, resp, http.StatusOK,
)
} }
} }

View File

@@ -85,6 +85,7 @@ func newTestServer(
cfg.DBURL = dbURL cfg.DBURL = dbURL
cfg.Port = 0 cfg.Port = 0
cfg.HashcashBits = 0
return cfg, nil return cfg, nil
}, },

View File

@@ -182,12 +182,6 @@ func (hdlr *Handlers) handleLogin(
request, clientID, sessionID, payload.Nick, request, clientID, sessionID, payload.Nick,
) )
// Initialize channel state so the new client knows
// which channels the session already belongs to.
hdlr.initChannelState(
request, clientID, sessionID, payload.Nick,
)
hdlr.respondJSON(writer, request, map[string]any{ hdlr.respondJSON(writer, request, map[string]any{
"id": sessionID, "id": sessionID,
"nick": payload.Nick, "nick": payload.Nick,

View File

@@ -13,6 +13,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/config" "git.eeqj.de/sneak/neoirc/internal/config"
"git.eeqj.de/sneak/neoirc/internal/db" "git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/hashcash"
"git.eeqj.de/sneak/neoirc/internal/healthcheck" "git.eeqj.de/sneak/neoirc/internal/healthcheck"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
"go.uber.org/fx" "go.uber.org/fx"
@@ -39,6 +40,7 @@ type Handlers struct {
log *slog.Logger log *slog.Logger
hc *healthcheck.Healthcheck hc *healthcheck.Healthcheck
broker *broker.Broker broker *broker.Broker
hashcashVal *hashcash.Validator
cancelCleanup context.CancelFunc cancelCleanup context.CancelFunc
} }
@@ -47,11 +49,17 @@ func New(
lifecycle fx.Lifecycle, lifecycle fx.Lifecycle,
params Params, params Params,
) (*Handlers, error) { ) (*Handlers, error) {
resource := params.Config.ServerName
if resource == "" {
resource = "neoirc"
}
hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup hdlr := &Handlers{ //nolint:exhaustruct // cancelCleanup set in startCleanup
params: &params, params: &params,
log: params.Logger.Get(), log: params.Logger.Get(),
hc: params.Healthcheck, hc: params.Healthcheck,
broker: broker.New(), broker: broker.New(),
hashcashVal: hashcash.NewValidator(resource),
} }
lifecycle.Append(fx.Hook{ lifecycle.Append(fx.Hook{

View File

@@ -0,0 +1,277 @@
// Package hashcash implements SHA-256-based hashcash
// proof-of-work validation for abuse prevention.
//
// Stamp format: 1:bits:YYMMDD:resource::counter.
//
// The SHA-256 hash of the entire stamp string must have
// at least `bits` leading zero bits.
package hashcash
import (
"crypto/sha256"
"errors"
"fmt"
"strconv"
"strings"
"sync"
"time"
)
const (
// stampVersion is the only supported hashcash version.
stampVersion = "1"
// stampFields is the number of fields in a stamp.
stampFields = 6
// maxStampAge is how old a stamp can be before
// rejection.
maxStampAge = 48 * time.Hour
// maxFutureSkew allows stamps slightly in the future.
maxFutureSkew = 1 * time.Hour
// pruneInterval controls how often expired stamps are
// removed from the spent set.
pruneInterval = 10 * time.Minute
// dateFormatShort is the YYMMDD date layout.
dateFormatShort = "060102"
// dateFormatLong is the YYMMDDHHMMSS date layout.
dateFormatLong = "060102150405"
// dateShortLen is the length of YYMMDD.
dateShortLen = 6
// dateLongLen is the length of YYMMDDHHMMSS.
dateLongLen = 12
// bitsPerByte is the number of bits in a byte.
bitsPerByte = 8
// fullByteMask is 0xFF, a mask for all bits in a byte.
fullByteMask = 0xFF
)
var (
errInvalidFields = errors.New("invalid stamp field count")
errBadVersion = errors.New("unsupported stamp version")
errInsufficientBits = errors.New("insufficient difficulty")
errWrongResource = errors.New("wrong resource")
errStampExpired = errors.New("stamp expired")
errStampFuture = errors.New("stamp date in future")
errProofFailed = errors.New("proof-of-work failed")
errStampReused = errors.New("stamp already used")
errBadDateFormat = errors.New("unrecognized date format")
)
// Validator checks hashcash stamps for validity and
// prevents replay attacks via an in-memory spent set.
type Validator struct {
resource string
mu sync.Mutex
spent map[string]time.Time
}
// NewValidator creates a Validator for the given resource.
func NewValidator(resource string) *Validator {
validator := &Validator{
resource: resource,
mu: sync.Mutex{},
spent: make(map[string]time.Time),
}
go validator.pruneLoop()
return validator
}
// Validate checks a hashcash stamp. It returns nil if the
// stamp is valid and has not been seen before.
func (v *Validator) Validate(
stamp string,
requiredBits int,
) error {
if requiredBits <= 0 {
return nil
}
parts := strings.Split(stamp, ":")
if len(parts) != stampFields {
return fmt.Errorf(
"%w: expected %d, got %d",
errInvalidFields, stampFields, len(parts),
)
}
version := parts[0]
bitsStr := parts[1]
dateStr := parts[2]
resource := parts[3]
if err := v.validateHeader(
version, bitsStr, resource, requiredBits,
); err != nil {
return err
}
stampTime, err := parseStampDate(dateStr)
if err != nil {
return err
}
if err := validateTime(stampTime); err != nil {
return err
}
if err := validateProof(
stamp, requiredBits,
); err != nil {
return err
}
return v.checkAndRecordStamp(stamp, stampTime)
}
func (v *Validator) validateHeader(
version, bitsStr, resource string,
requiredBits int,
) error {
if version != stampVersion {
return fmt.Errorf(
"%w: %s", errBadVersion, version,
)
}
claimedBits, err := strconv.Atoi(bitsStr)
if err != nil || claimedBits < requiredBits {
return fmt.Errorf(
"%w: need %d bits",
errInsufficientBits, requiredBits,
)
}
if resource != v.resource {
return fmt.Errorf(
"%w: got %q, want %q",
errWrongResource, resource, v.resource,
)
}
return nil
}
func validateTime(stampTime time.Time) error {
now := time.Now()
if now.Sub(stampTime) > maxStampAge {
return errStampExpired
}
if stampTime.Sub(now) > maxFutureSkew {
return errStampFuture
}
return nil
}
func validateProof(stamp string, requiredBits int) error {
hash := sha256.Sum256([]byte(stamp))
if !hasLeadingZeroBits(hash[:], requiredBits) {
return fmt.Errorf(
"%w: need %d leading zero bits",
errProofFailed, requiredBits,
)
}
return nil
}
func (v *Validator) checkAndRecordStamp(
stamp string,
stampTime time.Time,
) error {
v.mu.Lock()
defer v.mu.Unlock()
if _, ok := v.spent[stamp]; ok {
return errStampReused
}
v.spent[stamp] = stampTime
return nil
}
// hasLeadingZeroBits checks if the hash has at least n
// leading zero bits.
func hasLeadingZeroBits(hash []byte, numBits int) bool {
fullBytes := numBits / bitsPerByte
remainBits := numBits % bitsPerByte
for idx := range fullBytes {
if hash[idx] != 0 {
return false
}
}
if remainBits > 0 && fullBytes < len(hash) {
mask := byte(
fullByteMask << (bitsPerByte - remainBits),
)
if hash[fullBytes]&mask != 0 {
return false
}
}
return true
}
// parseStampDate parses a hashcash date stamp.
// Supports YYMMDD and YYMMDDHHMMSS formats.
func parseStampDate(dateStr string) (time.Time, error) {
switch len(dateStr) {
case dateShortLen:
parsed, err := time.Parse(
dateFormatShort, dateStr,
)
if err != nil {
return time.Time{}, fmt.Errorf(
"parse date: %w", err,
)
}
return parsed, nil
case dateLongLen:
parsed, err := time.Parse(
dateFormatLong, dateStr,
)
if err != nil {
return time.Time{}, fmt.Errorf(
"parse date: %w", err,
)
}
return parsed, nil
default:
return time.Time{}, fmt.Errorf(
"%w: %q", errBadDateFormat, dateStr,
)
}
}
// pruneLoop periodically removes expired stamps from the
// spent set.
func (v *Validator) pruneLoop() {
ticker := time.NewTicker(pruneInterval)
defer ticker.Stop()
for range ticker.C {
v.prune()
}
}
func (v *Validator) prune() {
cutoff := time.Now().Add(-maxStampAge)
v.mu.Lock()
defer v.mu.Unlock()
for stamp, stampTime := range v.spent {
if stampTime.Before(cutoff) {
delete(v.spent, stamp)
}
}
}

View File

@@ -16,11 +16,6 @@ import (
const routeTimeout = 60 * time.Second const routeTimeout = 60 * time.Second
// cspHeader is the Content-Security-Policy applied to the embedded web SPA.
// The SPA loads external scripts and stylesheets from the same origin only;
// all API communication uses same-origin fetch (no WebSockets).
const cspHeader = "default-src 'self'; script-src 'self'; style-src 'self'"
// SetupRoutes configures the HTTP routes and middleware. // SetupRoutes configures the HTTP routes and middleware.
func (srv *Server) SetupRoutes() { func (srv *Server) SetupRoutes() {
srv.router = chi.NewRouter() srv.router = chi.NewRouter()
@@ -138,11 +133,6 @@ func (srv *Server) setupSPA() {
writer http.ResponseWriter, writer http.ResponseWriter,
request *http.Request, request *http.Request,
) { ) {
writer.Header().Set(
"Content-Security-Policy",
cspHeader,
)
readFS, ok := distFS.(fs.ReadFileFS) readFS, ok := distFS.(fs.ReadFileFS)
if !ok { if !ok {
fileServer.ServeHTTP(writer, request) fileServer.ServeHTTP(writer, request)

View File

@@ -8,6 +8,56 @@ const MEMBER_REFRESH_INTERVAL = 10000;
const ACTION_PREFIX = "\x01ACTION "; const ACTION_PREFIX = "\x01ACTION ";
const ACTION_SUFFIX = "\x01"; const ACTION_SUFFIX = "\x01";
// Hashcash proof-of-work helpers using Web Crypto API.
function checkLeadingZeros(hashBytes, bits) {
let count = 0;
for (let i = 0; i < hashBytes.length; i++) {
if (hashBytes[i] === 0) {
count += 8;
continue;
}
let b = hashBytes[i];
while ((b & 0x80) === 0) {
count++;
b <<= 1;
}
break;
}
return count >= bits;
}
async function mintHashcash(bits, resource) {
const encoder = new TextEncoder();
const now = new Date();
const date =
String(now.getUTCFullYear()).slice(2) +
String(now.getUTCMonth() + 1).padStart(2, "0") +
String(now.getUTCDate()).padStart(2, "0");
const prefix = `1:${bits}:${date}:${resource}::`;
let nonce = Math.floor(Math.random() * 0x100000);
const batchSize = 1024;
for (;;) {
const stamps = [];
const hashPromises = [];
for (let i = 0; i < batchSize; i++) {
const stamp = prefix + (nonce + i).toString(16);
stamps.push(stamp);
hashPromises.push(
crypto.subtle.digest("SHA-256", encoder.encode(stamp)),
);
}
const hashes = await Promise.all(hashPromises);
for (let i = 0; i < hashes.length; i++) {
if (checkLeadingZeros(new Uint8Array(hashes[i]), bits)) {
return stamps[i];
}
}
nonce += batchSize;
}
}
function api(path, opts = {}) { function api(path, opts = {}) {
const token = localStorage.getItem("neoirc_token"); const token = localStorage.getItem("neoirc_token");
const headers = { const headers = {
@@ -60,17 +110,21 @@ function LoginScreen({ onLogin }) {
const [motd, setMotd] = useState(""); const [motd, setMotd] = useState("");
const [serverName, setServerName] = useState("NeoIRC"); const [serverName, setServerName] = useState("NeoIRC");
const inputRef = useRef(); const inputRef = useRef();
const hashcashBitsRef = useRef(0);
const hashcashResourceRef = useRef("neoirc");
useEffect(() => { useEffect(() => {
api("/server") api("/server")
.then((s) => { .then((s) => {
if (s.name) setServerName(s.name); if (s.name) setServerName(s.name);
if (s.motd) setMotd(s.motd); if (s.motd) setMotd(s.motd);
hashcashBitsRef.current = s.hashcash_bits || 0;
if (s.name) hashcashResourceRef.current = s.name;
}) })
.catch(() => {}); .catch(() => {});
const saved = localStorage.getItem("neoirc_token"); const saved = localStorage.getItem("neoirc_token");
if (saved) { if (saved) {
api("/state?initChannelState=1") api("/state")
.then((u) => onLogin(u.nick, true)) .then((u) => onLogin(u.nick, true))
.catch(() => localStorage.removeItem("neoirc_token")); .catch(() => localStorage.removeItem("neoirc_token"));
} }
@@ -81,9 +135,20 @@ function LoginScreen({ onLogin }) {
e.preventDefault(); e.preventDefault();
setError(""); setError("");
try { try {
const extraHeaders = {};
if (hashcashBitsRef.current > 0) {
setError("Computing proof-of-work...");
const stamp = await mintHashcash(
hashcashBitsRef.current,
hashcashResourceRef.current,
);
extraHeaders["X-Hashcash"] = stamp;
setError("");
}
const res = await api("/session", { const res = await api("/session", {
method: "POST", method: "POST",
body: JSON.stringify({ nick: nick.trim() }), body: JSON.stringify({ nick: nick.trim() }),
headers: extraHeaders,
}); });
localStorage.setItem("neoirc_token", res.token); localStorage.setItem("neoirc_token", res.token);
onLogin(res.nick); onLogin(res.nick);
@@ -333,24 +398,7 @@ function App() {
case "JOIN": { case "JOIN": {
const text = `${msg.from} has joined ${msg.to}`; const text = `${msg.from} has joined ${msg.to}`;
if (msg.to) addMessage(msg.to, { ...base, text, system: true }); if (msg.to) addMessage(msg.to, { ...base, text, system: true });
if (msg.to && msg.to.startsWith("#")) { if (msg.to && msg.to.startsWith("#")) refreshMembers(msg.to);
// Create a tab when the current user joins a channel
// (including JOINs from initChannelState on reconnect).
if (msg.from === nickRef.current) {
setTabs((prev) => {
if (
prev.find(
(t) => t.type === "channel" && t.name === msg.to,
)
)
return prev;
return [...prev, { type: "channel", name: msg.to }];
});
}
refreshMembers(msg.to);
}
break; break;
} }
@@ -653,13 +701,9 @@ function App() {
setLoggedIn(true); setLoggedIn(true);
addSystemMessage("Server", `Connected as ${userNick}`); addSystemMessage("Server", `Connected as ${userNick}`);
// Request MOTD on resumed sessions (new sessions get
// it automatically from the server during creation).
if (isResumed) { if (isResumed) {
// Request MOTD on resumed sessions (new sessions
// get it automatically from the server during
// creation). Channel state is initialized by the
// server via the message queue
// (?initChannelState=1), so we do not need to
// re-JOIN channels here.
try { try {
await api("/messages", { await api("/messages", {
method: "POST", method: "POST",
@@ -668,11 +712,8 @@ function App() {
} catch (e) { } catch (e) {
// MOTD is non-critical. // MOTD is non-critical.
} }
return;
} }
// Fresh session — join any previously saved channels.
const saved = JSON.parse( const saved = JSON.parse(
localStorage.getItem("neoirc_channels") || "[]", localStorage.getItem("neoirc_channels") || "[]",
); );