3 Commits

Author SHA1 Message Date
clawbot
3513943d47 fix: add web-builder Docker stage for JSX compilation
All checks were successful
check / check (push) Successful in 2m16s
The previous rework removed web/dist/ from git tracking (correct) but
did not add a web-builder stage to the Dockerfile. This caused
//go:embed dist/* in web/embed.go to fail with 'no matching files found'.

Add the 4-stage Dockerfile layout:
1. web-builder: compile Preact JSX SPA via esbuild
2. lint: format checking and linting with placeholder dist files
3. builder: compile Go binaries with real web assets from web-builder
4. runtime: minimal Alpine image with neoircd binary

Also update README to document the 4-stage build.
2026-03-10 03:09:12 -07:00
user
5b07730bd2 fix: move hashcash PoW from build artifact to JSX source
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 03:09:12 -07:00
clawbot
5d0b362c0f 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
2026-03-10 03:09:12 -07:00
21 changed files with 680 additions and 318 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/

237
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
@@ -1376,16 +1378,18 @@ Return server metadata. No authentication required.
"name": "My NeoIRC Server", "name": "My NeoIRC Server",
"version": "0.1.0", "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 | | `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
@@ -1624,10 +1628,6 @@ authenticity.
termination. termination.
- **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`). - **CORS**: The server allows all origins by default (`Access-Control-Allow-Origin: *`).
Restrict this in production via reverse proxy configuration if needed. Restrict this in production via reverse proxy configuration if needed.
- **Content-Security-Policy**: The server sets a strict CSP header on all
responses, restricting resource loading to same-origin and disabling
dangerous features (object embeds, framing, base tag injection). The
embedded SPA works without `'unsafe-inline'` for scripts or styles.
--- ---
@@ -1823,6 +1823,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
@@ -1834,6 +1835,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
``` ```
--- ---
@@ -1856,16 +1858,16 @@ docker run -p 8080:8080 \
neoirc neoirc
``` ```
The Dockerfile is a four-stage build: The Dockerfile is a 4-stage build:
1. **web-builder**: Installs Node dependencies and compiles the SPA (JSX → 1. **Web builder stage** (`web-builder`): Compiles the Preact JSX SPA into
bundled JS via esbuild) into `web/dist/` static assets (`web/dist/`) using esbuild
2. **lint**: Runs formatting checks and golangci-lint against the Go source 2. **Lint stage** (`lint`): Runs formatting checks and linting via golangci-lint
(uses empty placeholder files for `web/dist/` so it runs independently of 3. **Build stage** (`builder`): Compiles `neoircd` and `neoirc-cli`, runs tests
web-builder for fast feedback) (CLI built to verify compilation, not included in final image)
3. **builder**: Runs tests and compiles static `neoircd` and `neoirc-cli` 4. **Runtime stage**: Alpine Linux + `neoircd` binary only
binaries with the real SPA assets from web-builder (CLI built to verify
compilation, not included in final image) `web/dist/` is not committed to git — it is built from `web/src/` by the
4. **final**: Minimal Alpine image with only the `neoircd` binary web-builder stage during `docker build`.
### Binary ### Binary
@@ -2106,62 +2108,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.
@@ -2169,36 +2211,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
@@ -2227,7 +2242,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`
@@ -2314,14 +2329,16 @@ 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) │ ├── build.sh # esbuild script: JSX → dist/
│ ├── package.json # Node dependencies (preact, esbuild) │ ├── package.json # Node dependencies (esbuild, preact)
│ ├── package-lock.json │ ├── src/ # SPA source (Preact JSX)
│ ├── src/ # SPA source files (JSX + HTML + CSS)
│ │ ├── app.jsx │ │ ├── app.jsx
│ │ ├── index.html │ │ ├── index.html
│ │ └── style.css │ │ └── style.css
│ └── dist/ # Generated at Docker build time (not committed) │ └── dist/ # Built SPA (generated by web-builder Docker stage)
│ ├── index.html
│ ├── style.css
│ └── app.js
├── schema/ # JSON Schema definitions (planned) ├── schema/ # JSON Schema definitions (planned)
├── go.mod ├── go.mod
├── go.sum ├── go.sum
@@ -2336,7 +2353,7 @@ neoirc/
| Purpose | Library | | Purpose | Library |
|------------|---------| |------------|---------|
| DI | `go.uber.org/fx` | | DI | `go.uber.org/fx` |
| Router | `github.com/go-chi/chi/v5` | | Router | `github.com/go-chi/chi` |
| Logging | `log/slog` (stdlib) | | Logging | `log/slog` (stdlib) |
| Config | `github.com/spf13/viper` | | Config | `github.com/spf13/viper` |
| Env | `github.com/joho/godotenv/autoload` | | Env | `github.com/joho/godotenv/autoload` |

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

@@ -63,9 +63,10 @@ type Channel struct {
// ServerInfo is the response from GET /api/v1/server. // ServerInfo is the response from GET /api/v1/server.
type ServerInfo struct { 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.

2
go.mod
View File

@@ -6,7 +6,7 @@ require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/gdamore/tcell/v2 v2.13.8 github.com/gdamore/tcell/v2 v2.13.8
github.com/getsentry/sentry-go v0.42.0 github.com/getsentry/sentry-go v0.42.0
github.com/go-chi/chi/v5 v5.2.1 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.2 github.com/go-chi/cors v1.2.2
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1

4
go.sum
View File

@@ -18,8 +18,8 @@ github.com/gdamore/tcell/v2 v2.13.8 h1:Mys/Kl5wfC/GcC5Cx4C2BIQH9dbnhnkPgS9/wF3Rl
github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/gdamore/tcell/v2 v2.13.8/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo=
github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8= github.com/getsentry/sentry-go v0.42.0 h1:eeFMACuZTbUQf90RE8dE4tXeSe4CZyfvR1MBL7RLEt8=
github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s= github.com/getsentry/sentry-go v0.42.0/go.mod h1:eRXCoh3uvmjQLY6qu63BjUZnaBu5L5WhMV1RwYO8W5s=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8= github.com/go-chi/chi v1.5.5 h1:vOB/HbEMt9QqBqErz07QehcOKHaWFtuj87tTDVz2qXE=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= github.com/go-chi/chi v1.5.5/go.mod h1:C9JqLr3tIYjDOZpzn+BCuxY8z8vmca43EeMgyZt7irw=
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=

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

@@ -64,14 +64,12 @@ func (database *Database) RegisterUser(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -139,14 +137,12 @@ func (database *Database) LoginUser(
now := time.Now() now := time.Now()
tokenHash := hashToken(token)
res, err := database.conn.ExecContext(ctx, res, err := database.conn.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(
"create login client: %w", err, "create login client: %w", err,

View File

@@ -1,20 +0,0 @@
// Package db provides database access and migration management.
package db
import (
"errors"
"modernc.org/sqlite"
sqlite3 "modernc.org/sqlite/lib"
)
// IsUniqueConstraintError reports whether err is a SQLite
// unique-constraint violation.
func IsUniqueConstraintError(err error) bool {
var sqliteErr *sqlite.Error
if !errors.As(err, &sqliteErr) {
return false
}
return sqliteErr.Code() == sqlite3.SQLITE_CONSTRAINT_UNIQUE
}

View File

@@ -3,7 +3,6 @@ package db
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"crypto/sha256"
"database/sql" "database/sql"
"encoding/hex" "encoding/hex"
"encoding/json" "encoding/json"
@@ -32,14 +31,6 @@ func generateToken() (string, error) {
return hex.EncodeToString(buf), nil return hex.EncodeToString(buf), nil
} }
// hashToken returns the lowercase hex-encoded SHA-256
// digest of a plaintext token string.
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
// IRCMessage is the IRC envelope for all messages. // IRCMessage is the IRC envelope for all messages.
type IRCMessage struct { type IRCMessage struct {
ID string `json:"id"` ID string `json:"id"`
@@ -114,14 +105,12 @@ func (database *Database) CreateSession(
sessionID, _ := res.LastInsertId() sessionID, _ := res.LastInsertId()
tokenHash := hashToken(token)
clientRes, err := transaction.ExecContext(ctx, clientRes, err := transaction.ExecContext(ctx,
`INSERT INTO clients `INSERT INTO clients
(uuid, session_id, token, (uuid, session_id, token,
created_at, last_seen) created_at, last_seen)
VALUES (?, ?, ?, ?, ?)`, VALUES (?, ?, ?, ?, ?)`,
clientUUID, sessionID, tokenHash, now, now) clientUUID, sessionID, token, now, now)
if err != nil { if err != nil {
_ = transaction.Rollback() _ = transaction.Rollback()
@@ -154,8 +143,6 @@ func (database *Database) GetSessionByToken(
nick string nick string
) )
tokenHash := hashToken(token)
err := database.conn.QueryRowContext( err := database.conn.QueryRowContext(
ctx, ctx,
`SELECT s.id, c.id, s.nick `SELECT s.id, c.id, s.nick
@@ -163,7 +150,7 @@ func (database *Database) GetSessionByToken(
INNER JOIN sessions s INNER JOIN sessions s
ON s.id = c.session_id ON s.id = c.session_id
WHERE c.token = ?`, WHERE c.token = ?`,
tokenHash, token,
).Scan(&sessionID, &clientID, &nick) ).Scan(&sessionID, &clientID, &nick)
if err != nil { if err != nil {
return 0, 0, "", fmt.Errorf( return 0, 0, "", fmt.Errorf(

View File

@@ -10,9 +10,8 @@ import (
"strings" "strings"
"time" "time"
"git.eeqj.de/sneak/neoirc/internal/db"
"git.eeqj.de/sneak/neoirc/internal/irc" "git.eeqj.de/sneak/neoirc/internal/irc"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
) )
var validNickRe = regexp.MustCompile( var validNickRe = regexp.MustCompile(
@@ -145,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"`
} }
@@ -200,7 +226,7 @@ func (hdlr *Handlers) handleCreateSessionError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if db.IsUniqueConstraintError(err) { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -445,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
@@ -477,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,
@@ -491,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(
@@ -1428,7 +1398,7 @@ func (hdlr *Handlers) executeNickChange(
request.Context(), sessionID, newNick, request.Context(), sessionID, newNick,
) )
if err != nil { if err != nil {
if db.IsUniqueConstraintError(err) { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondIRCError( hdlr.respondIRCError(
writer, request, clientID, sessionID, writer, request, clientID, sessionID,
irc.ErrNicknameInUse, nick, []string{newNick}, irc.ErrNicknameInUse, nick, []string{newNick},
@@ -2392,11 +2362,19 @@ 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, "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

@@ -4,8 +4,6 @@ import (
"encoding/json" "encoding/json"
"net/http" "net/http"
"strings" "strings"
"git.eeqj.de/sneak/neoirc/internal/db"
) )
const minPasswordLength = 8 const minPasswordLength = 8
@@ -96,7 +94,7 @@ func (hdlr *Handlers) handleRegisterError(
request *http.Request, request *http.Request,
err error, err error,
) { ) {
if db.IsUniqueConstraintError(err) { if strings.Contains(err.Error(), "UNIQUE") {
hdlr.respondError( hdlr.respondError(
writer, request, writer, request,
"nick already taken", "nick already taken",
@@ -184,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

@@ -11,7 +11,7 @@ import (
"git.eeqj.de/sneak/neoirc/internal/globals" "git.eeqj.de/sneak/neoirc/internal/globals"
"git.eeqj.de/sneak/neoirc/internal/logger" "git.eeqj.de/sneak/neoirc/internal/logger"
basicauth "github.com/99designs/basicauth-go" basicauth "github.com/99designs/basicauth-go"
chimw "github.com/go-chi/chi/v5/middleware" chimw "github.com/go-chi/chi/middleware"
"github.com/go-chi/cors" "github.com/go-chi/cors"
metrics "github.com/slok/go-http-metrics/metrics/prometheus" metrics "github.com/slok/go-http-metrics/metrics/prometheus"
ghmm "github.com/slok/go-http-metrics/middleware" ghmm "github.com/slok/go-http-metrics/middleware"
@@ -142,6 +142,20 @@ func (mware *Middleware) CORS() func(http.Handler) http.Handler {
}) })
} }
// Auth returns middleware that performs authentication.
func (mware *Middleware) Auth() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
mware.log.Info("AUTH: before request")
next.ServeHTTP(writer, request)
})
}
}
// Metrics returns middleware that records HTTP metrics. // Metrics returns middleware that records HTTP metrics.
func (mware *Middleware) Metrics() func(http.Handler) http.Handler { func (mware *Middleware) Metrics() func(http.Handler) http.Handler {
metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields metricsMiddleware := ghmm.New(ghmm.Config{ //nolint:exhaustruct // optional fields
@@ -166,36 +180,3 @@ func (mware *Middleware) MetricsAuth() func(http.Handler) http.Handler {
}, },
) )
} }
// cspPolicy is the Content-Security-Policy header value applied to all
// responses. The embedded SPA loads scripts and styles from same-origin
// files only (no inline scripts or inline style attributes), so a strict
// policy works without 'unsafe-inline'.
const cspPolicy = "default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self'; " +
"connect-src 'self'; " +
"img-src 'self'; " +
"font-src 'self'; " +
"object-src 'none'; " +
"frame-ancestors 'none'; " +
"base-uri 'self'; " +
"form-action 'self'"
// CSP returns middleware that sets the Content-Security-Policy header on
// every response for defense-in-depth against XSS.
func (mware *Middleware) CSP() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(
func(
writer http.ResponseWriter,
request *http.Request,
) {
writer.Header().Set(
"Content-Security-Policy",
cspPolicy,
)
next.ServeHTTP(writer, request)
})
}
}

View File

@@ -8,8 +8,8 @@ import (
"git.eeqj.de/sneak/neoirc/web" "git.eeqj.de/sneak/neoirc/web"
sentryhttp "github.com/getsentry/sentry-go/http" sentryhttp "github.com/getsentry/sentry-go/http"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
"github.com/go-chi/chi/v5/middleware" "github.com/go-chi/chi/middleware"
"github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/client_golang/prometheus/promhttp"
"github.com/spf13/viper" "github.com/spf13/viper"
) )
@@ -29,7 +29,6 @@ func (srv *Server) SetupRoutes() {
} }
srv.router.Use(srv.mw.CORS()) srv.router.Use(srv.mw.CORS())
srv.router.Use(srv.mw.CSP())
srv.router.Use(middleware.Timeout(routeTimeout)) srv.router.Use(middleware.Timeout(routeTimeout))
if srv.sentryEnabled { if srv.sentryEnabled {

View File

@@ -20,7 +20,7 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"github.com/getsentry/sentry-go" "github.com/getsentry/sentry-go"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi"
_ "github.com/joho/godotenv/autoload" // loads .env file _ "github.com/joho/godotenv/autoload" // loads .env file
) )

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") || "[]",
); );